Files
pad/internal/cli/client_import.go
T
xarmian ad6cfc2c5e fix(cli): a bundle import shows progress, abandons a stalled upload, and resolves a lost answer (BUG-3476) (#1902)
* fix(cli): a bundle import shows progress, abandons a stalled upload, and resolves a lost answer (BUG-3476)

`pad workspace import <bundle>` printed nothing until the server answered,
sat silent on a dead TCP path until the kernel gave up or the 1h client
cap fired, and when the answer was lost could not say whether the
workspace existed, so a re-run could duplicate it.

- Each bundle import names its attempt with an import key (UUID), which
  the bundle door records and GET /workspaces/import-status answers
  (BUG-3475).
- Progress to stderr every 5s: bytes sent / total and percent, updated
  in place on a TTY.
- A stall (the transport takes no bytes for 90s; the server's per-Read
  deadline is 60s) abandons the upload.
- After the last byte: "Upload complete; the server is importing", then
  import-status every 10s. A settled outcome seen twice while the answer
  has not arrived ends the wait: the answer was lost.
- After any failure that is not the server's own JSON answer, it asks
  the key (waiting up to 90s for a running attempt to settle) and
  reports: complete -> success with the slug; kept -> the partial
  workspace named; removed / not_created -> safe to re-run; no record or
  unknown -> "outcome unknown: check 'pad workspace list' before
  re-running, a re-run may create a duplicate". An older server ignores
  the key and lands in that honest unknown branch.

The legacy JSON import is unchanged (its route takes no key).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(cli): the stall message says what the counter knows (BUG-3476 codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-08 15:03:38 -04:00

109 lines
3.6 KiB
Go

package cli
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync/atomic"
"time"
)
// BUG-3476: `pad workspace import` of a bundle printed nothing until the
// response, could sit silent for minutes on a dead TCP path (a blocked body
// write fails only at the kernel's retransmit limit), and when the answer was
// lost it could not say whether the workspace exists. These are the pieces the
// command watches the upload with: a reader that counts what the transport has
// taken, a context-aware upload, and the outcome lookup BUG-3475 added.
// CountingReader counts the bytes read through it and when the last read
// happened. The HTTP transport reads the body only as the connection accepts
// it, so a read that stops arriving is a stalled upload.
type CountingReader struct {
r io.Reader
n atomic.Int64
lastRead atomic.Int64 // UnixNano of the latest read that returned bytes
}
// NewCountingReader wraps r. The stall clock starts now, so a connection that
// never starts reading counts as stalled too.
func NewCountingReader(r io.Reader) *CountingReader {
c := &CountingReader{r: r}
c.lastRead.Store(time.Now().UnixNano())
return c
}
func (c *CountingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if n > 0 {
c.n.Add(int64(n))
c.lastRead.Store(time.Now().UnixNano())
}
return n, err
}
// Sent is how many bytes the transport has taken.
func (c *CountingReader) Sent() int64 { return c.n.Load() }
// SinceLastRead is how long ago the transport last took bytes.
func (c *CountingReader) SinceLastRead() time.Duration {
return time.Since(time.Unix(0, c.lastRead.Load()))
}
// PostStreamContext is PostStreamWithContentTypeHeaders under a context, so a
// caller watching the upload can abandon it.
func (c *Client) PostStreamContext(ctx context.Context, path string, body io.Reader, contentType string, result interface{}) (http.Header, error) {
req, err := c.newRequest("POST", path, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
req.Header.Set("Content-Type", contentType)
resp, err := c.streamClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
header := resp.Header
return header, c.handleResponse(resp, result)
}
// Import outcome states, as GET /workspaces/import-status reports them
// (BUG-3475, internal/server/import_outcomes.go).
const (
ImportStateRunning = "running"
ImportStateComplete = "complete"
ImportStateKept = "kept"
ImportStateRemoved = "removed"
ImportStateNotCreated = "not_created"
ImportStateUnknown = "unknown"
)
// ImportStatus is what the server knows about one import attempt.
type ImportStatus struct {
State string `json:"state"`
WorkspaceSlug string `json:"workspace_slug,omitempty"`
WorkspaceName string `json:"workspace_name,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
}
// ErrImportStatusUnknown is the server holding no record of the key: never
// started there, expired, lost to a restart, or a server older than BUG-3475,
// which ignores the key. It is an UNKNOWN outcome, never "nothing created".
var ErrImportStatusUnknown = errors.New("the server holds no record of this import")
// GetImportStatus asks what became of the caller's import attempt with key.
func (c *Client) GetImportStatus(key string) (*ImportStatus, error) {
var st ImportStatus
if err := c.get("/workspaces/import-status?key="+url.QueryEscape(key), &st); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.Code == "not_found" {
return nil, ErrImportStatusUnknown
}
return nil, err
}
return &st, nil
}