mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
ed89604ad8
* fix(mcp): a refused call names its cause on both transports (BUG-3864) A 403 from one of the shared gates now carries details.reason (role, token_scope, no_access, workspace_not_authorized), with code and message unchanged. MCP builds one permission_denied envelope from it on both transports, with a hint that says what to do next. It used to lead with "Insufficient role" for every 403, so a bot with a read-only API token was told to ask for a role. An unverified Cloud email is reported as the server's own email_not_verified code. Stdio gets the same envelope from a structured marker the CLI root writes for a 403 coded forbidden, permission_denied or email_not_verified. Before it, the token-scope and unverified-email refusals said server_error there. ToolSurfaceVersion 0.99 -> 0.100 (behavior). Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): the inline workspace-role gates name their reason too (BUG-3864) The bulk endpoint, the owner-only member doors and the owner-only app install and upgrade doors say role, or no_access for a guest. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(mcp): the permission hint is the same on both transports (BUG-3864) The remote hint appended the route, which stdio has no way to know, so one refusal reached the two transports with different hints. The tests compared prefixes only and missed it; both now assert the transports agree exactly. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi * fix(server): no role hint for a caller with no role to change (BUG-3864) requireMinRole and the grants list said role for a guest, who holds grants rather than a role, and for a caller with no workspace role at all. Both now go through roleGateReason, which answers no_access for either. Claude-Session: https://claude.ai/code/session_01MPeShv3DBfM9C94PTvMnqi
3239 lines
126 KiB
Go
3239 lines
126 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/PerpetualSoftware/pad/internal/bodypatch"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Client is a thin HTTP client for the Pad API.
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
// streamClient has a much longer timeout than httpClient and is
|
|
// used by RawStream / PostStreamWithContentType for endpoints
|
|
// that can transfer multi-GiB payloads (workspace export
|
|
// bundles). Sharing the default 10s timeout would kill those
|
|
// transfers mid-flight on anything but a local network.
|
|
streamClient *http.Client
|
|
authToken string // session or API token, sent as Authorization: Bearer
|
|
agentName string // optional agent name, sent as X-Pad-Agent header
|
|
// workerOnce/workerID: the lease worker id sent as X-Pad-Worker
|
|
// (TASK-3613), resolved on the first request rather than at
|
|
// construction, since resolving it may mint one into the registry.
|
|
workerOnce sync.Once
|
|
workerID string
|
|
|
|
// capMu guards the lazy, cached probe of GET /server/capabilities behind
|
|
// CollectionNotFoundIsAuthoritative and ServerSupportsItemFieldAppend.
|
|
// capProbed is set only once a DEFINITIVE answer is cached (a 200 that
|
|
// decodes, or a clean 404); a transient probe failure leaves capProbed
|
|
// false so a later call re-probes rather than poisoning the cache. caps is
|
|
// the cached definitive answer. Each reader decides for itself what an
|
|
// INDETERMINATE probe means, because the safe side differs per flag.
|
|
capMu sync.Mutex
|
|
capProbed bool
|
|
caps serverCapabilityFlags
|
|
}
|
|
|
|
// serverCapabilityFlags is the subset of GET /server/capabilities the CLI
|
|
// acts on. A clean 404 (a build with no such endpoint) is the zero value.
|
|
type serverCapabilityFlags struct {
|
|
CollectionResolution bool `json:"collection_resolution"`
|
|
ItemFieldAppend bool `json:"item_field_append"`
|
|
SearchCollectionResolution bool `json:"search_collection_resolution"`
|
|
ItemScopedCommentWrites bool `json:"item_scoped_comment_writes"`
|
|
AttachmentAttach bool `json:"attachment_attach"`
|
|
LibraryActivate bool `json:"library_activate"`
|
|
BuiltinUpdate bool `json:"builtin_update"`
|
|
ItemPatch bool `json:"item_patch"`
|
|
IdempotencyKeys bool `json:"idempotency_keys"`
|
|
}
|
|
|
|
func NewClient(host string, port int) *Client {
|
|
return NewClientFromURL(fmt.Sprintf("http://%s:%d", host, port))
|
|
}
|
|
|
|
// NewClientFromURL creates a client from a full base URL (e.g., "https://app.getpad.dev").
|
|
func NewClientFromURL(baseURL string) *Client {
|
|
baseURL = strings.TrimRight(baseURL, "/")
|
|
c := &Client{
|
|
baseURL: baseURL + "/api/v1",
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
// Long-running transfer client for streaming endpoints
|
|
// (workspace export bundles in/out, future S3 downloads).
|
|
// 10s on the default client is the right SLA for normal API
|
|
// calls but kills a multi-GiB bundle upload mid-stream over
|
|
// anything but a fast local link. 1 hour is generous enough
|
|
// for ~100 MB/s uplinks shipping a 350 GiB bundle and still
|
|
// caps a hung connection eventually. (Codex review on PR
|
|
// #306 round 2.)
|
|
streamClient: &http.Client{
|
|
Timeout: 1 * time.Hour,
|
|
},
|
|
}
|
|
|
|
// Auth resolution order: PAD_TOKEN env override first (explicit,
|
|
// per-process — lets concurrent agents on one machine act as
|
|
// different users, issue #879), then the saved credential for THIS
|
|
// server URL. We use the original baseURL (without the /api/v1
|
|
// suffix added below) so the lookup matches what login/save
|
|
// commands key on. Credentials for other servers are left untouched
|
|
// in the store — see TASK-1228 / IDEA-1226 for the per-server design.
|
|
if tok := EnvTokenFor(baseURL); tok != "" {
|
|
c.authToken = tok
|
|
} else if store, err := LoadStore(); err == nil {
|
|
if creds := store.Get(baseURL); creds != nil {
|
|
c.authToken = creds.Token
|
|
}
|
|
}
|
|
|
|
// Resolve the agent identity that becomes X-Pad-Agent. Used to read
|
|
// .pad.toml's agent_name and nothing else, which meant any workspace that
|
|
// had not opted in recorded agent writes as human ones (BUG-2542). See
|
|
// ResolveAgentName for the precedence and for what this signal cannot do.
|
|
c.agentName = ResolveAgentName()
|
|
|
|
return c
|
|
}
|
|
|
|
// SetAuthToken sets the authorization token for API requests.
|
|
func (c *Client) SetAuthToken(token string) {
|
|
c.authToken = token
|
|
}
|
|
|
|
// Health checks if the server is running.
|
|
func (c *Client) Health() error {
|
|
req, err := c.newRequest("GET", "/health", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unhealthy: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Workspaces ---
|
|
|
|
func (c *Client) ListWorkspaces() ([]models.Workspace, error) {
|
|
var result []models.Workspace
|
|
return result, c.get("/workspaces", &result)
|
|
}
|
|
|
|
func (c *Client) CreateWorkspace(input models.WorkspaceCreate) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.post("/workspaces", input, &result)
|
|
}
|
|
|
|
func (c *Client) GetWorkspace(slug string) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.get("/workspaces/"+url.PathEscape(slug), &result)
|
|
}
|
|
|
|
func (c *Client) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.patch("/workspaces/"+url.PathEscape(slug), input, &result)
|
|
}
|
|
|
|
// DeletedWorkspace is one entry from GET /api/v1/workspaces/deleted — a
|
|
// soft-deleted workspace still inside the restore window, plus the
|
|
// purge-window fields (both derived server-side from the shared purge
|
|
// retention constant) so callers can render "N days left".
|
|
type DeletedWorkspace struct {
|
|
models.Workspace
|
|
PurgeAt time.Time `json:"purge_at"`
|
|
DaysLeft int `json:"days_left"`
|
|
}
|
|
|
|
// ListDeletedWorkspaces returns the soft-deleted workspaces the current
|
|
// user owns that are still restorable (not yet past the purge window).
|
|
func (c *Client) ListDeletedWorkspaces() ([]DeletedWorkspace, error) {
|
|
var result []DeletedWorkspace
|
|
return result, c.get("/workspaces/deleted", &result)
|
|
}
|
|
|
|
// RestoreWorkspace un-soft-deletes a workspace by slug via the restore
|
|
// endpoint (owner-only server-side). Returns the now-live workspace.
|
|
func (c *Client) RestoreWorkspace(slug string) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.post("/workspaces/"+url.PathEscape(slug)+"/restore", nil, &result)
|
|
}
|
|
|
|
// ClaimWorkspaceResponse is the shape of POST /api/v1/oauth/claim.
|
|
// `AlreadyAdded` reports whether the workspace was already in the
|
|
// calling connection's allow-list (idempotent re-claim returns true).
|
|
// `Note` is populated only for PAT / CLI-session callers that have no
|
|
// OAuth grant to add the workspace to — see handleOAuthClaim for the
|
|
// design rationale.
|
|
type ClaimWorkspaceResponse struct {
|
|
Workspace string `json:"workspace"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
AlreadyAdded bool `json:"already_added"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// ClaimWorkspace redeems a 6-digit claim code against the
|
|
// /api/v1/oauth/claim endpoint, granting the calling OAuth connection
|
|
// access to the named workspace. Used by `pad workspace claim` and
|
|
// by the MCP `pad_workspace.action: claim` route.
|
|
func (c *Client) ClaimWorkspace(workspaceSlug, code string) (*ClaimWorkspaceResponse, error) {
|
|
var result ClaimWorkspaceResponse
|
|
body := map[string]string{"workspace": workspaceSlug, "code": code}
|
|
return &result, c.post("/oauth/claim", body, &result)
|
|
}
|
|
|
|
// --- Collections ---
|
|
|
|
func (c *Client) ListCollections(wsSlug string) ([]models.Collection, error) {
|
|
var result []models.Collection
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/collections", &result)
|
|
}
|
|
|
|
func (c *Client) CreateCollection(wsSlug string, input models.CollectionCreate) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/collections", input, &result)
|
|
}
|
|
|
|
func (c *Client) GetCollection(wsSlug, collSlug string) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/collections/"+url.PathEscape(collSlug), &result)
|
|
}
|
|
|
|
func (c *Client) UpdateCollection(wsSlug, collSlug string, input models.CollectionUpdate) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.patch("/workspaces/"+url.PathEscape(wsSlug)+"/collections/"+url.PathEscape(collSlug), input, &result)
|
|
}
|
|
|
|
// DeleteCollection soft-deletes a collection by setting
|
|
// collections.deleted_at. Items in the collection are NOT cascaded;
|
|
// they remain in the database with the soft-deleted collection_id.
|
|
// Server-side rejects collections where is_default=true (template
|
|
// seeds) and requires the workspace `owner` role
|
|
// (handlers_collections.go::handleDeleteCollection,
|
|
// store/collections.go:316).
|
|
func (c *Client) DeleteCollection(wsSlug, collSlug string) error {
|
|
return c.delete("/workspaces/" + url.PathEscape(wsSlug) + "/collections/" + url.PathEscape(collSlug))
|
|
}
|
|
|
|
// ListArchivedCollections returns the workspace's archived collections
|
|
// (TASK-2189).
|
|
func (c *Client) ListArchivedCollections(wsSlug string) ([]models.ArchivedCollection, error) {
|
|
var out []models.ArchivedCollection
|
|
err := c.get("/workspaces/"+url.PathEscape(wsSlug)+"/archived-collections", &out)
|
|
return out, err
|
|
}
|
|
|
|
// RestoreCollection un-archives a collection by slug or id (TASK-2189).
|
|
func (c *Client) RestoreCollection(wsSlug, collRef string) (*models.Collection, error) {
|
|
var out models.Collection
|
|
if err := c.post("/workspaces/"+url.PathEscape(wsSlug)+"/archived-collections/"+url.PathEscape(collRef)+"/restore", nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// --- Items ---
|
|
|
|
// ListItems returns items across all collections in a workspace.
|
|
// Use params for filtering, sorting, grouping, pagination, etc.
|
|
func (c *Client) ListItems(wsSlug string, params url.Values) ([]models.Item, error) {
|
|
items, _, err := c.ListItemsReport(wsSlug, params)
|
|
return items, err
|
|
}
|
|
|
|
// ItemListPage is what an item list response says besides its rows: the
|
|
// filter keys the server honoured although no schema declares them (BUG-3480's
|
|
// X-Pad-Undeclared-Filter-Keys) and the paging state (BUG-3704).
|
|
type ItemListPage struct {
|
|
UndeclaredKeys []string
|
|
// Known is false when the server sent no X-Pad-Has-More (one older than
|
|
// BUG-3704); HasMore and NextOffset are then unset.
|
|
Known bool
|
|
HasMore bool
|
|
NextOffset int
|
|
}
|
|
|
|
func itemListPage(h http.Header) ItemListPage {
|
|
p := ItemListPage{UndeclaredKeys: undeclaredFilterKeys(h)}
|
|
switch h.Get("X-Pad-Has-More") {
|
|
case "true":
|
|
p.Known, p.HasMore = true, true
|
|
p.NextOffset, _ = strconv.Atoi(h.Get("X-Pad-Next-Offset"))
|
|
case "false":
|
|
p.Known = true
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ListAllItems is ListItems for a caller that needs every matching row (a
|
|
// report): it follows the server's next-offset until has-more is false
|
|
// (BUG-3704). params' limit and offset are its own. A server older than
|
|
// BUG-3704 sends no paging state and lists everything in one response.
|
|
func (c *Client) ListAllItems(wsSlug string, params url.Values) ([]models.Item, error) {
|
|
q := url.Values{}
|
|
for k, v := range params {
|
|
q[k] = v
|
|
}
|
|
q.Del("limit")
|
|
q.Del("offset")
|
|
var all []models.Item
|
|
for offset := 0; ; {
|
|
if offset > 0 {
|
|
q.Set("offset", strconv.Itoa(offset))
|
|
}
|
|
items, page, err := c.ListItemsReport(wsSlug, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, items...)
|
|
if !page.HasMore || page.NextOffset <= offset {
|
|
return all, nil
|
|
}
|
|
offset = page.NextOffset
|
|
}
|
|
}
|
|
|
|
// ListAllCollectionItems is ListAllItems for one collection: it follows the
|
|
// server's next-offset until has-more is false (BUG-3793).
|
|
func (c *Client) ListAllCollectionItems(wsSlug, collSlug string, params url.Values) ([]models.Item, error) {
|
|
q := url.Values{}
|
|
for k, v := range params {
|
|
q[k] = v
|
|
}
|
|
q.Del("limit")
|
|
q.Del("offset")
|
|
var all []models.Item
|
|
for offset := 0; ; {
|
|
if offset > 0 {
|
|
q.Set("offset", strconv.Itoa(offset))
|
|
}
|
|
items, page, err := c.ListCollectionItemsReport(wsSlug, collSlug, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, items...)
|
|
if !page.HasMore || page.NextOffset <= offset {
|
|
return all, nil
|
|
}
|
|
offset = page.NextOffset
|
|
}
|
|
}
|
|
|
|
// ListItemsReport is ListItems plus what the response says besides its rows
|
|
// (ItemListPage), for a caller that warns about undeclared keys or truncation.
|
|
func (c *Client) ListItemsReport(wsSlug string, params url.Values) ([]models.Item, ItemListPage, error) {
|
|
var result []models.Item
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
h, err := c.getWithHeader(path, &result)
|
|
return result, itemListPage(h), err
|
|
}
|
|
|
|
func undeclaredFilterKeys(h http.Header) []string {
|
|
v := h.Get("X-Pad-Undeclared-Filter-Keys")
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(v, ",")
|
|
}
|
|
|
|
// ListTags returns the distinct tags used across a workspace's items with
|
|
// per-tag item counts (ordered by count desc then tag asc).
|
|
func (c *Client) ListTags(wsSlug string) ([]models.TagCount, error) {
|
|
var result []models.TagCount
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/tags", &result)
|
|
}
|
|
|
|
// ListCollectionItems returns items within a specific collection.
|
|
func (c *Client) ListCollectionItems(wsSlug, collSlug string, params url.Values) ([]models.Item, error) {
|
|
items, _, err := c.ListCollectionItemsReport(wsSlug, collSlug, params)
|
|
return items, err
|
|
}
|
|
|
|
// ListCollectionItemsReport is ListCollectionItems plus the ItemListPage
|
|
// (see ListItemsReport).
|
|
func (c *Client) ListCollectionItemsReport(wsSlug, collSlug string, params url.Values) ([]models.Item, ItemListPage, error) {
|
|
var result []models.Item
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/collections/" + url.PathEscape(collSlug) + "/items"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
h, err := c.getWithHeader(path, &result)
|
|
return result, itemListPage(h), err
|
|
}
|
|
|
|
func (c *Client) CreateItem(wsSlug, collSlug string, input models.ItemCreate) (*models.Item, error) {
|
|
var result models.Item
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/collections/" + url.PathEscape(collSlug) + "/items"
|
|
err := c.post(path, input, &result)
|
|
// TASK-3585: with a key, a create whose outcome the transport lost is
|
|
// retried ONCE; a server that honors keys answers the item the first
|
|
// attempt created instead of creating another.
|
|
if err != nil && input.OpKey != "" && retryableWithKey(err) && c.serverHonorsKeys() {
|
|
result = models.Item{}
|
|
err = c.post(path, input, &result)
|
|
}
|
|
return &result, err
|
|
}
|
|
|
|
// CollectionNotFoundIsAuthoritative reports whether a collection-not-found from
|
|
// this server should be TRUSTED — i.e. the alias retry in
|
|
// WithCollectionAliasFallback should be SKIPPED. It answers the question the
|
|
// helper actually needs, which is not quite "does the server resolve": it also
|
|
// has to say the safe thing when the answer is unknown.
|
|
//
|
|
// - Server advertises collection_resolution=true → true. It already tried the
|
|
// singular/alias fallback and enforced exact-match + the archived/hidden
|
|
// refusal (resolveItemCollectionSlug, BUG-2578/2630), so its not-found is
|
|
// final: do not retry.
|
|
// - Server DEFINITIVELY lacks the resolver — a clean 404 (no such endpoint) or
|
|
// an explicit collection_resolution=false → false. Retry the legacy alias;
|
|
// that old build never had the archived-claims protection a retry could
|
|
// defeat, so the retry is non-regressive there.
|
|
// - Probe is INDETERMINATE (a transport error, timeout, or 5xx) → true, and
|
|
// the result is NOT cached. Failing CLOSED here is a DELIBERATE asymmetry:
|
|
// the cost of failing closed on a blip is at worst one alias-shorthand
|
|
// failure the user can simply re-run, whereas failing OPEN (retrying) risks
|
|
// a wrong-write that bypasses the archived/hidden protection and cannot be
|
|
// un-done. A recoverable UX miss is always the safer side of that trade.
|
|
// Not caching matters for the same reason: a single transient blip must not
|
|
// permanently re-enable the retry for the rest of the session. The only
|
|
// case this could "cost" is an old server whose capabilities probe
|
|
// transiently errors instead of returning a clean 404 — but a missing route
|
|
// returns 404, not a transient error, so a genuine old build still retries.
|
|
//
|
|
// The definitive verdict is cached (static for the server's lifetime); an
|
|
// indeterminate probe is re-tried on the next call.
|
|
func (c *Client) CollectionNotFoundIsAuthoritative() bool {
|
|
caps, definitive := c.serverCapabilities()
|
|
if !definitive {
|
|
// Fail closed without caching: trust the not-found for THIS call, but
|
|
// re-probe next time in case the blip clears.
|
|
return true
|
|
}
|
|
return caps.CollectionResolution
|
|
}
|
|
|
|
// ServerSupportsItemFieldAppend reports whether the item PATCH on this server
|
|
// honours append_implementation_note / append_decision (BUG-3056), which lets
|
|
// `pad item note` / `decide` append under the server's write lock instead of
|
|
// sending the whole fields blob back.
|
|
//
|
|
// It must be asked BEFORE the write, never inferred after it: an older build
|
|
// ignores the unknown keys and answers 200 having written nothing, and the
|
|
// only recovery from discovering that afterwards is a second write, which
|
|
// duplicates the entry whenever the discovery was wrong.
|
|
//
|
|
// An INDETERMINATE probe answers false, uncached — the opposite side from
|
|
// CollectionNotFoundIsAuthoritative, for the same reason that one gives: pick
|
|
// the side whose failure is recoverable. The legacy full-fields write is
|
|
// CORRECT against every server (it carries only the old concurrent-write race);
|
|
// the append against a server without it silently writes nothing.
|
|
func (c *Client) ServerSupportsItemFieldAppend() bool {
|
|
caps, definitive := c.serverCapabilities()
|
|
return definitive && caps.ItemFieldAppend
|
|
}
|
|
|
|
// ServerResolvesSearchCollection reports whether GET /search resolves its
|
|
// `collection` filter server-side (BUG-2659), so the CLI can send the
|
|
// collection exactly as typed and let an exact match beat the alias. An
|
|
// indeterminate probe answers false: the fallback is today's client-side
|
|
// normalisation, which on a read-only filter can only misscope a search,
|
|
// never write anywhere.
|
|
func (c *Client) ServerResolvesSearchCollection() bool {
|
|
caps, definitive := c.serverCapabilities()
|
|
return definitive && caps.SearchCollectionResolution
|
|
}
|
|
|
|
// serverCapabilities returns the cached definitive capability answer, probing
|
|
// once if there is none. definitive is false when the probe was
|
|
// indeterminate; that is not cached, so the next call probes again.
|
|
func (c *Client) serverCapabilities() (serverCapabilityFlags, bool) {
|
|
c.capMu.Lock()
|
|
defer c.capMu.Unlock()
|
|
if c.capProbed {
|
|
return c.caps, true
|
|
}
|
|
caps, definitive := c.probeServerCapabilities()
|
|
if !definitive {
|
|
return serverCapabilityFlags{}, false
|
|
}
|
|
c.caps = caps
|
|
c.capProbed = true
|
|
return caps, true
|
|
}
|
|
|
|
// probeServerCapabilities issues the one GET /server/capabilities probe and
|
|
// classifies the outcome. definitive is true only when the server gave a clear
|
|
// answer — HTTP 200 (the advertised flags) or HTTP 404 (every flag false: a
|
|
// build with no capabilities endpoint has none of these features). A transport
|
|
// error, a 200 whose body will not decode, or any other status (e.g. a 5xx) is
|
|
// NOT definitive.
|
|
func (c *Client) probeServerCapabilities() (serverCapabilityFlags, bool) {
|
|
req, err := c.newRequest("GET", "/server/capabilities", nil)
|
|
if err != nil {
|
|
return serverCapabilityFlags{}, false
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return serverCapabilityFlags{}, false
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
var caps serverCapabilityFlags
|
|
if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil {
|
|
return serverCapabilityFlags{}, false
|
|
}
|
|
return caps, true
|
|
case http.StatusNotFound:
|
|
return serverCapabilityFlags{}, true
|
|
default:
|
|
return serverCapabilityFlags{}, false
|
|
}
|
|
}
|
|
|
|
func (c *Client) GetItem(wsSlug, itemSlug string) (*models.Item, error) {
|
|
var result models.Item
|
|
if err := c.get("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug), &result); err != nil {
|
|
return nil, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Client) UpdateItem(wsSlug, itemSlug string, input models.ItemUpdate) (*models.Item, error) {
|
|
var result models.Item
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug)
|
|
err := c.patch(path, input, &result)
|
|
// TASK-3585: a keyed update (an append) is retried once with the same
|
|
// key, behind the same capability gate as create and comment.
|
|
if err != nil && input.OpKey != "" && retryableWithKey(err) && c.serverHonorsKeys() {
|
|
result = models.Item{}
|
|
err = c.patch(path, input, &result)
|
|
}
|
|
if err != nil {
|
|
return nil, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// CollabSetAside is the item's set-aside edits (BUG-3244): op-log rows a collab
|
|
// schema-version rebuild moved out because they can no longer replay.
|
|
type CollabSetAside struct {
|
|
Ref string `json:"ref"`
|
|
SetAside []models.YjsSetAside `json:"set_aside"`
|
|
}
|
|
|
|
// ListCollabSetAside reads the item's set-aside edits as raw updates.
|
|
func (c *Client) ListCollabSetAside(wsSlug, itemSlug string) (*CollabSetAside, error) {
|
|
var result CollabSetAside
|
|
if err := c.get("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/collab-set-aside", &result); err != nil {
|
|
return nil, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// DiscardCollabSetAside deletes the item's set-aside edits and returns how many
|
|
// rows it removed.
|
|
func (c *Client) DiscardCollabSetAside(wsSlug, itemSlug string) (int64, error) {
|
|
var result struct {
|
|
Discarded int64 `json:"discarded"`
|
|
}
|
|
if err := c.deleteWithResult("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/collab-set-aside", &result); err != nil {
|
|
return 0, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
return result.Discarded, nil
|
|
}
|
|
|
|
func (c *Client) DeleteItem(wsSlug, itemSlug string) error {
|
|
return wrapItemNotFound(c.delete("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)), itemSlug, wsSlug)
|
|
}
|
|
|
|
// wrapItemNotFound rewrites a bare "not_found" APIError from the item-by-ref
|
|
// endpoints into a message that echoes the failing ref and workspace, so
|
|
// `pad item show TASK-999999` reads "item TASK-999999 not found in workspace
|
|
// docapp" instead of a context-free "Item not found". It returns a fresh
|
|
// *APIError (same Code/Details, enriched Message) rather than a wrapper type,
|
|
// so the concrete type stays *APIError — both errors.As AND direct
|
|
// err.(*APIError) assertions (e.g. bulk-update's per-row code capture) keep
|
|
// matching. Any other error (or nil) passes through unchanged.
|
|
func wrapItemNotFound(err error, itemSlug, wsSlug string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Code == "not_found" {
|
|
return &APIError{
|
|
Code: apiErr.Code,
|
|
Message: fmt.Sprintf("item %s not found in workspace %s", itemSlug, wsSlug),
|
|
Details: apiErr.Details,
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ListItemVersions returns the item's version history (newest-first), with
|
|
// reverse-patch diffs already resolved to full content server-side. Backs
|
|
// `pad item history` (TASK-2022). Reuses the existing read-only
|
|
// GET /items/{slug}/versions endpoint — no new store surface.
|
|
func (c *Client) ListItemVersions(wsSlug, itemSlug string) ([]models.Version, error) {
|
|
return c.ListItemVersionsPage(wsSlug, itemSlug, 0, false)
|
|
}
|
|
|
|
// ListItemVersionsPage is ListItemVersions with the BUG-2608 bounds: `limit`
|
|
// caps the newest-first window (0 = server default, i.e. unbounded), and
|
|
// `summary` asks the server to skip reverse-patch resolution and return
|
|
// metadata only.
|
|
//
|
|
// Pass summary=true whenever the caller is going to discard content. It is not
|
|
// merely a smaller response: resolving means walking the item's entire patch
|
|
// chain, so a history listing that projects to metadata was paying for bodies
|
|
// it never showed.
|
|
func (c *Client) ListItemVersionsPage(wsSlug, itemSlug string, limit int, summary bool) ([]models.Version, error) {
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/versions"
|
|
q := url.Values{}
|
|
if limit > 0 {
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
}
|
|
if summary {
|
|
q.Set("summary", "true")
|
|
}
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
var result []models.Version
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// RestoreItem un-archives a soft-deleted item via the restore endpoint, which
|
|
// resolves the ref/slug with include-deleted semantics server-side (the normal
|
|
// resolver 404s on archived items). Returns the restored item.
|
|
func (c *Client) RestoreItem(wsSlug, itemSlug string) (*models.Item, error) {
|
|
var result models.Item
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/restore", nil, &result)
|
|
}
|
|
|
|
// StarItem stars an item for the current user.
|
|
func (c *Client) StarItem(wsSlug, itemSlug string) error {
|
|
return c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/star", nil, nil)
|
|
}
|
|
|
|
// UnstarItem removes a star from an item for the current user.
|
|
func (c *Client) UnstarItem(wsSlug, itemSlug string) error {
|
|
return c.delete("/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/star")
|
|
}
|
|
|
|
// ListStarredItemsPage returns at most limit of the current user's starred
|
|
// items, from the start of the list, and whether more exist (BUG-3790: MCP
|
|
// asks for one row past its cap and states the truncation; it never pages).
|
|
func (c *Client) ListStarredItemsPage(wsSlug string, includeTerminal bool, limit int) ([]models.Item, ItemListPage, error) {
|
|
q := url.Values{}
|
|
if includeTerminal {
|
|
q.Set("include_terminal", "true")
|
|
}
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
var page []models.Item
|
|
h, err := c.getWithHeader("/workspaces/"+url.PathEscape(wsSlug)+"/starred?"+q.Encode(), &page)
|
|
return page, itemListPage(h), err
|
|
}
|
|
|
|
// ListStarredItems returns the current user's starred items in a workspace,
|
|
// following the server's next-offset until has-more is false (BUG-3790: the
|
|
// list is bounded per page).
|
|
func (c *Client) ListStarredItems(wsSlug string, includeTerminal bool) ([]models.Item, error) {
|
|
q := url.Values{}
|
|
if includeTerminal {
|
|
q.Set("include_terminal", "true")
|
|
}
|
|
var all []models.Item
|
|
for offset := 0; ; {
|
|
if offset > 0 {
|
|
q.Set("offset", strconv.Itoa(offset))
|
|
}
|
|
var page []models.Item
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/starred"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
h, err := c.getWithHeader(path, &page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
all = append(all, page...)
|
|
p := itemListPage(h)
|
|
if !p.HasMore || p.NextOffset <= offset {
|
|
return all, nil
|
|
}
|
|
offset = p.NextOffset
|
|
}
|
|
}
|
|
|
|
// CreateWatch creates (or replaces the predicate on) a durable watch for
|
|
// the current user on an item (TASK-2533). predicate is the raw
|
|
// `--until field=value` string, or "" for an unconditional watch.
|
|
func (c *Client) CreateWatch(wsSlug, itemSlug, predicate string) (*models.Watch, error) {
|
|
var body interface{}
|
|
if predicate != "" {
|
|
body = map[string]string{"predicate": predicate}
|
|
}
|
|
var result models.Watch
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/watch", body, &result)
|
|
}
|
|
|
|
// DeleteWatch removes the current user's watch on an item.
|
|
func (c *Client) DeleteWatch(wsSlug, itemSlug string) error {
|
|
return c.delete("/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/watch")
|
|
}
|
|
|
|
// PushResult is the response body of a successful pad push, mirroring
|
|
// server.pushResponse's wire shape. Workspace is the CANONICAL slug the
|
|
// server resolved the push against (dispatcher review round 2, codex
|
|
// P1/P2) — a JSON consumer needs it because the notification stream is
|
|
// user-scoped across every workspace the caller belongs to, not just
|
|
// the one this call happened to target.
|
|
type PushResult struct {
|
|
Ref string `json:"ref"`
|
|
Workspace string `json:"workspace"`
|
|
Pushed bool `json:"pushed"`
|
|
Message string `json:"message"`
|
|
// DeliveredSessions mirrors the server's field of the same name — how
|
|
// many of the caller's own live sessions the push's delivery predicate
|
|
// matched. It was missing here while this struct's doc comment claimed
|
|
// to mirror the response shape, so `pad push --format json` silently
|
|
// dropped it (codex round 3 on BUG-2698/2699).
|
|
//
|
|
// A POINTER, because the field is genuinely tri-state on the wire:
|
|
// a number is a real count; NULL means the notification was published
|
|
// but the presence registry could not be read to count it (BUG-2698);
|
|
// and an ABSENT key means a server predating session targeting. The
|
|
// second and third are both `nil` here — a CLI consumer that needs to
|
|
// tell them apart has to read the raw body, which no caller does. What
|
|
// matters is that neither is reported as 0, because 0 and "unknown" are
|
|
// different answers.
|
|
//
|
|
// AND 0 IS NOT "reached nobody" ON THIS PATH (codex round 24). That
|
|
// guarantee is the TARGETED one: the server skips the publish when a
|
|
// named target is absent, so nothing was sent. `pad push` only ever
|
|
// broadcasts — internal/cli never sends target_session_id — and a
|
|
// broadcast is ALWAYS published, so a 0 here means no session was
|
|
// registered at the moment the count was taken, not that nobody got it.
|
|
// A session registering in the interval receives it.
|
|
//
|
|
// NO omitempty (codex round 10): it would drop the nil case on the way
|
|
// back OUT, so `pad push --format json` would print no field at all for
|
|
// "published, count unknown" — silently re-collapsing the distinction
|
|
// this pointer exists to carry. The field is always present in the
|
|
// CLI's own JSON, as a number or as null.
|
|
DeliveredSessions *int `json:"delivered_sessions"`
|
|
}
|
|
|
|
// PushItem publishes a self-addressed push notification (IDEA-2544
|
|
// Phase 1) on an item, over the same watch-events bus/stream `pad watch
|
|
// --stream --for-session` consumes. Transient, fire-and-forget — see
|
|
// server.handlePushToItem's doc comment for the no-durability rationale.
|
|
func (c *Client) PushItem(wsSlug, itemSlug, message string) (*PushResult, error) {
|
|
return c.PushItemKeyed(wsSlug, itemSlug, message, "")
|
|
}
|
|
|
|
// PushItemKeyed is PushItem with an idempotency key (TASK-3585). With a key
|
|
// it retries once with that key after a transport error or a 5xx, and only
|
|
// against a server that advertises idempotency_keys: there the retry
|
|
// answers the first push (or 409 push_outcome_unknown when that push could
|
|
// not be confirmed) instead of delivering it again.
|
|
func (c *Client) PushItemKeyed(wsSlug, itemSlug, message, opKey string) (*PushResult, error) {
|
|
body := map[string]string{"message": message}
|
|
if opKey != "" {
|
|
body["op_key"] = opKey
|
|
}
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/push"
|
|
var result PushResult
|
|
err := c.post(path, body, &result)
|
|
if err != nil && opKey != "" && retryableWithKey(err) && c.serverHonorsKeys() {
|
|
result = PushResult{}
|
|
err = c.post(path, body, &result)
|
|
}
|
|
return &result, err
|
|
}
|
|
|
|
// ListWatches returns every watch the current user holds, across all
|
|
// workspaces they belong to (TASK-2533 — a watch is personal, not
|
|
// workspace-scoped; see Store.ListWatchesForUser's doc comment).
|
|
func (c *Client) ListWatches() ([]models.Watch, error) {
|
|
var result []models.Watch
|
|
return result, c.get("/watches", &result)
|
|
}
|
|
|
|
// StreamSessionIdentity is what a monitor tells the server about itself
|
|
// when it opens the event stream (PLAN-2558 S2, TASK-2560), so the
|
|
// server's presence registry can name the session instead of showing an
|
|
// opaque uuid.
|
|
//
|
|
// The two fields are the wire-safe subset of what `pad session register`
|
|
// records locally (internal/cli/session_registry.go). What is missing
|
|
// is the point: the messaging socket path and its identity never leave
|
|
// this machine, the cwd travels as a BASENAME, because "docapp" is what
|
|
// a session picker needs while "/home/dave/Dev/docapp" additionally
|
|
// hands over a home directory and an account name — and the agent name
|
|
// the registry carries since TASK-2767 stays local until IDEA-2750 part
|
|
// 2b gives it a reviewed place on the wire.
|
|
//
|
|
// Both fields are optional. A zero value produces the pre-S2 behaviour:
|
|
// an unlabelled session that still receives every event.
|
|
type StreamSessionIdentity struct {
|
|
// Label names the session for a human — the working directory's
|
|
// basename ("docapp"). The server sanitizes and truncates it.
|
|
Label string
|
|
// PID is this process's own pid, for telling two sessions with the
|
|
// same label apart.
|
|
PID int
|
|
// Armed is PLAN-2613 S2's consent declaration: when true, the stream
|
|
// request carries ?armed=true and the server admits this connection to
|
|
// KindPush delivery (the gate S1 built). False — the zero value — is
|
|
// the legacy/unarmed shape and receives ordinary watch-matched events
|
|
// only. Unlike Label/PID this rides a query param, not a header: see
|
|
// server.sessionArmedQueryParam for why (audit-visible by design, and
|
|
// reachable by a future non-CLI client that can't set headers).
|
|
Armed bool
|
|
}
|
|
|
|
// NewWatchEventsStreamRequest builds an authenticated GET request for
|
|
// GET /api/v1/events/stream (TASK-2533), used by
|
|
// `pad watch --stream --for-session`. Deliberately returns the request
|
|
// rather than doing the round trip itself: an SSE connection is meant to
|
|
// stay open for a whole session, and both c.httpClient (10s timeout) and
|
|
// c.streamClient (1h timeout, sized for bundle transfers) would
|
|
// eventually kill it — the caller must supply its own zero-timeout
|
|
// http.Client. lastEventID, when non-empty, is sent as Last-Event-ID for
|
|
// resume; ident, when non-zero, announces the session (S2).
|
|
func (c *Client) NewWatchEventsStreamRequest(ctx context.Context, lastEventID string, ident StreamSessionIdentity) (*http.Request, error) {
|
|
req, err := c.newRequest("GET", "/events/stream", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
req.Header.Set("Cache-Control", "no-cache")
|
|
if lastEventID != "" {
|
|
req.Header.Set("Last-Event-ID", lastEventID)
|
|
}
|
|
if label := headerSafeLabel(ident.Label); label != "" {
|
|
req.Header.Set("X-Pad-Session-Label", label)
|
|
}
|
|
if ident.PID > 0 {
|
|
req.Header.Set("X-Pad-Session-Pid", strconv.Itoa(ident.PID))
|
|
}
|
|
if ident.Armed {
|
|
// The consent declaration is a QUERY PARAM, not a header —
|
|
// server.sessionArmedQueryParam documents why (a plain audit-safe
|
|
// boolean, and reachable by a browser EventSource that can't set
|
|
// headers). The literal "armed" and the exact value "true" are the
|
|
// wire contract that side matches; only "true" counts as armed
|
|
// there, so an unarmed session sends nothing rather than
|
|
// armed=false.
|
|
q := req.URL.Query()
|
|
q.Set("armed", "true")
|
|
req.URL.RawQuery = q.Encode()
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// maxHeaderLabelLen bounds the label the client is willing to put on
|
|
// the wire, in runes. It is deliberately looser than the server's own
|
|
// cap (server.maxSessionLabelLen, 64): the server decides what a label
|
|
// should LOOK like, while this only has to keep the request from being
|
|
// absurd. Leaving the two independent means neither has to be kept in
|
|
// sync with the other to stay correct.
|
|
const maxHeaderLabelLen = 256
|
|
|
|
// headerSafeLabel makes a label safe to put in an HTTP header value, or
|
|
// returns "" if nothing usable survives.
|
|
//
|
|
// This is not belt-and-braces for the server's sanitizer — it fixes a
|
|
// failure the server can never see. Unix directory names may contain
|
|
// newlines, tabs and other control bytes ("doc\napp" is a legal
|
|
// directory), and Go's http.Client REFUSES to send a request whose
|
|
// header value contains one: Do returns "invalid header field value"
|
|
// and the request never leaves. In the monitor that surfaces as a
|
|
// connection error, which its retry loop treats like an unreachable
|
|
// padd — so a user who happened to name a directory with a newline
|
|
// would get no notifications at all, forever, silently, because the
|
|
// monitor prints nothing by contract. Losing the label is a cosmetic
|
|
// problem; losing the stream is not, and the cause would be invisible.
|
|
//
|
|
// Reproduced before fixing (a real directory, a real client) rather
|
|
// than reasoned about: the client-side refusal is what makes this
|
|
// unfixable server-side.
|
|
func headerSafeLabel(label string) string {
|
|
if label == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(label))
|
|
for _, r := range label {
|
|
// The server collapses whitespace and trims; the client's only
|
|
// job is to not build an unsendable request, so anything
|
|
// non-printable is simply dropped. Space itself is printable
|
|
// and legal in a header value, so it survives.
|
|
if unicode.IsPrint(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
out := strings.TrimSpace(b.String())
|
|
if runes := []rune(out); len(runes) > maxHeaderLabelLen {
|
|
out = strings.TrimSpace(string(runes[:maxHeaderLabelLen]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (c *Client) MoveItem(wsSlug, itemSlug string, input map[string]any) (*models.Item, error) {
|
|
return c.MoveItemWithForce(wsSlug, itemSlug, input, false)
|
|
}
|
|
|
|
// MoveItemWithForce is the open-children-guard-aware variant of
|
|
// MoveItem (IDEA-1494 R3 P1). When `force` is true, the URL gets a
|
|
// `?force=true` query so the server-side move handler skips the guard
|
|
// and still records the collection + fields change. Same escape-hatch
|
|
// semantics as `pad item update --force`.
|
|
func (c *Client) MoveItemWithForce(wsSlug, itemSlug string, input map[string]any, force bool) (*models.Item, error) {
|
|
var result models.Item
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/move"
|
|
if force {
|
|
path += "?force=true"
|
|
}
|
|
return &result, c.post(path, input, &result)
|
|
}
|
|
|
|
// --- Item context (PLAN-3598) ---
|
|
|
|
// GetItemContext fetches the item's brief (GET /items/{ref}/context) as the
|
|
// server's JSON, verbatim. intent, role and budget are optional ("" / 0).
|
|
// Any other budget is sent as given, so a negative one gets the server's
|
|
// 400, as it does over MCP, rather than silently becoming the default.
|
|
func (c *Client) GetItemContext(wsSlug, itemSlug, intent, role string, budget int) (json.RawMessage, error) {
|
|
q := url.Values{}
|
|
if intent != "" {
|
|
q.Set("intent", intent)
|
|
}
|
|
if role != "" {
|
|
q.Set("role", role)
|
|
}
|
|
if budget != 0 {
|
|
q.Set("budget", strconv.Itoa(budget))
|
|
}
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/context"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
var result json.RawMessage
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Links ---
|
|
|
|
func (c *Client) GetItemLinks(wsSlug, itemSlug string) ([]models.ItemLink, error) {
|
|
var result []models.ItemLink
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/links", &result)
|
|
}
|
|
|
|
func (c *Client) CreateItemLink(wsSlug, itemSlug string, input models.ItemLinkCreate) (*models.ItemLink, error) {
|
|
var result models.ItemLink
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/links", input, &result)
|
|
}
|
|
|
|
func (c *Client) DeleteItemLink(wsSlug, linkID string) error {
|
|
return c.delete("/workspaces/" + url.PathEscape(wsSlug) + "/links/" + url.PathEscape(linkID))
|
|
}
|
|
|
|
// GetBacklinks fetches the items that contain a `[[<itemRef>]]`
|
|
// reference to the queried item. Phase 1 returns ref-form backlinks
|
|
// only — title and cross-workspace forms wait for Phase 2 of
|
|
// PLAN-1593. The server applies visibility filtering before
|
|
// returning, so callers see only sources they're allowed to see.
|
|
//
|
|
// `limit` and `offset` paginate (server clamps limit to [1,300]).
|
|
// Zero values fall through to the server default (50).
|
|
func (c *Client) GetBacklinks(wsSlug, itemSlug string, limit, offset int) ([]models.Backlink, error) {
|
|
q := ""
|
|
if limit > 0 {
|
|
q = "?limit=" + strconv.Itoa(limit)
|
|
}
|
|
if offset > 0 {
|
|
if q == "" {
|
|
q = "?"
|
|
} else {
|
|
q += "&"
|
|
}
|
|
q += "offset=" + strconv.Itoa(offset)
|
|
}
|
|
var result []models.Backlink
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/backlinks"+q, &result)
|
|
}
|
|
|
|
// --- Comments ---
|
|
|
|
func (c *Client) ListComments(wsSlug, itemSlug string) ([]models.Comment, error) {
|
|
var result []models.Comment
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/comments", &result)
|
|
}
|
|
|
|
func (c *Client) CreateComment(wsSlug, itemSlug string, input models.CommentCreate) (*models.Comment, error) {
|
|
var result models.Comment
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/comments"
|
|
err := c.post(path, input, &result)
|
|
// TASK-3585: the same single keyed retry as CreateItem, behind the same
|
|
// capability gate.
|
|
if err != nil && input.OpKey != "" && retryableWithKey(err) && c.serverHonorsKeys() {
|
|
result = models.Comment{}
|
|
err = c.post(path, input, &result)
|
|
}
|
|
return &result, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
|
|
// UpdateComment replaces a comment's body through the item-scoped route
|
|
// (TASK-2695), which refuses a comment that is not on itemRef. The server's
|
|
// not_found is passed through as-is: it already says whether the ITEM or the
|
|
// COMMENT was missing, and wrapItemNotFound would relabel both as the item.
|
|
func (c *Client) UpdateComment(wsSlug, itemRef, commentID, body string) (*models.Comment, error) {
|
|
var result models.Comment
|
|
err := c.patch(itemCommentPath(wsSlug, itemRef, commentID), map[string]string{"body": body}, &result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// DeleteComment removes a comment through the item-scoped route (TASK-2695).
|
|
func (c *Client) DeleteComment(wsSlug, itemRef, commentID string) error {
|
|
return c.delete(itemCommentPath(wsSlug, itemRef, commentID))
|
|
}
|
|
|
|
func itemCommentPath(wsSlug, itemRef, commentID string) string {
|
|
return "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemRef) + "/comments/" + url.PathEscape(commentID)
|
|
}
|
|
|
|
// ServerSupportsItemScopedCommentWrites reports whether this server routes
|
|
// comment PATCH/DELETE under the item (TASK-2695). An indeterminate probe
|
|
// answers false: sending anyway would turn an older server's unrouted 404
|
|
// into a false "comment not found".
|
|
func (c *Client) ServerSupportsItemScopedCommentWrites() bool {
|
|
caps, definitive := c.serverCapabilities()
|
|
return definitive && caps.ItemScopedCommentWrites
|
|
}
|
|
|
|
// ServerSupportsAttachmentAttach reports whether this server routes
|
|
// POST /attachments/{id}/attach (TASK-2247). An indeterminate probe answers
|
|
// false, for the same reason as ServerSupportsItemScopedCommentWrites.
|
|
func (c *Client) ServerSupportsAttachmentAttach() bool {
|
|
caps, definitive := c.serverCapabilities()
|
|
return definitive && caps.AttachmentAttach
|
|
}
|
|
|
|
// ServerSupportsLibraryActivate reports whether this server activates a
|
|
// library entry itself, recording its built-in origin (TASK-3462). An
|
|
// indeterminate probe answers false: the legacy client-built create still
|
|
// activates the entry, it only records no origin.
|
|
func (c *Client) ServerSupportsLibraryActivate() bool {
|
|
caps, definitive := c.serverCapabilities()
|
|
return definitive && caps.LibraryActivate
|
|
}
|
|
|
|
// ServerSupportsBuiltinUpdate reports whether this server serves the
|
|
// built-in state and update routes (TASK-3462 U3c). An older build answers
|
|
// them with a bare 404 that reads exactly like "not made from a built-in", so
|
|
// an indeterminate probe answers false and the caller refuses.
|
|
func (c *Client) ServerSupportsBuiltinUpdate() bool {
|
|
supported, definitive := c.BuiltinUpdateSupport()
|
|
return definitive && supported
|
|
}
|
|
|
|
// BuiltinUpdateSupport is ServerSupportsBuiltinUpdate with the probe's
|
|
// certainty kept apart (codex r1): definitive false means the capabilities
|
|
// probe failed, which is not the same as an older server and must not be
|
|
// reported as one.
|
|
func (c *Client) BuiltinUpdateSupport() (supported, definitive bool) {
|
|
caps, definitive := c.serverCapabilities()
|
|
return caps.BuiltinUpdate, definitive
|
|
}
|
|
|
|
// ServerSupportsItemPatch reports whether this server serves POST
|
|
// /workspaces/{ws}/items/{ref}/patch (PLAN-3578). An older build answers that
|
|
// route with a bare 404 that reads exactly like a missing item, so the CLI
|
|
// refuses instead of sending when this is absent.
|
|
func (c *Client) ServerSupportsItemPatch() bool {
|
|
caps, _ := c.serverCapabilities()
|
|
return caps.ItemPatch
|
|
}
|
|
|
|
// PatchResult is the answer to a patch write (PLAN-3578).
|
|
type PatchResult struct {
|
|
Ref string `json:"ref"`
|
|
Seq int64 `json:"seq"`
|
|
PatchID string `json:"patch_id"`
|
|
UndoOf string `json:"undo_of,omitempty"`
|
|
VersionID string `json:"version_id,omitempty"`
|
|
Rebased bool `json:"rebased"`
|
|
Intervening *models.PatchIntervening `json:"intervening,omitempty"`
|
|
Diff string `json:"diff"`
|
|
}
|
|
|
|
// PatchItem applies content-anchored edits to the item's body.
|
|
func (c *Client) PatchItem(wsSlug, itemSlug string, edits []bodypatch.Edit, expectedSeq *int64, opKey string) (*PatchResult, error) {
|
|
body := map[string]any{"edits": edits}
|
|
if expectedSeq != nil {
|
|
body["expected_seq"] = *expectedSeq
|
|
}
|
|
return c.postPatch(wsSlug, itemSlug, body, opKey)
|
|
}
|
|
|
|
// postPatch sends a patch with its idempotency key (TASK-3585) and retries
|
|
// it ONCE, with the same key, when the outcome is unknown at the transport
|
|
// (no answer, or a 5xx): the key makes the retry replay a write that
|
|
// committed rather than apply it again. A 4xx answer is never retried.
|
|
func (c *Client) postPatch(wsSlug, itemSlug string, body map[string]any, opKey string) (*PatchResult, error) {
|
|
if opKey != "" {
|
|
body["op_key"] = opKey
|
|
}
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/patch"
|
|
var result PatchResult
|
|
err := c.post(path, body, &result)
|
|
if err != nil && opKey != "" && retryableWithKey(err) && c.serverHonorsKeys() {
|
|
result = PatchResult{}
|
|
err = c.post(path, body, &result)
|
|
}
|
|
if err != nil {
|
|
return nil, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// serverHonorsKeys reports whether this server replays a keyed retry
|
|
// (TASK-3585). An older server ignores op_key, so retrying there could apply
|
|
// a write twice: no capability, no retry.
|
|
func (c *Client) serverHonorsKeys() bool {
|
|
caps, _ := c.serverCapabilities()
|
|
return caps.IdempotencyKeys
|
|
}
|
|
|
|
// retryableWithKey: no API answer at all (a transport error) or a 5xx.
|
|
func retryableWithKey(err error) bool {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
return apiErr.Status >= 500
|
|
}
|
|
return true
|
|
}
|
|
|
|
// UndoPatch inverts a stored patch on the item (PLAN-3578 step 3). The undo
|
|
// is itself a patch, answered the same way.
|
|
func (c *Client) UndoPatch(wsSlug, itemSlug, patchID, opKey string) (*PatchResult, error) {
|
|
return c.postPatch(wsSlug, itemSlug, map[string]any{"undo": patchID}, opKey)
|
|
}
|
|
|
|
// Patch refusal codes (PLAN-3578). Keep in lockstep with internal/mcp's
|
|
// allowedStructuredErrorCodes and its Patch*Hint constants.
|
|
const (
|
|
PatchAnchorMissingCode = "patch_anchor_missing"
|
|
PatchAnchorAmbiguousCode = "patch_anchor_ambiguous"
|
|
PatchRoomOpenCode = "patch_room_open"
|
|
PatchUnstableCode = "patch_unstable_roundtrip"
|
|
PatchNotFoundCode = "patch_not_found"
|
|
// TASK-3585.
|
|
PatchOutcomeUnknownCode = "patch_outcome_unknown"
|
|
IdempotencyInFlightCode = "idempotency_in_flight"
|
|
IdempotencyKeyReusedCode = "idempotency_key_reused"
|
|
IdempotencyTargetGoneCode = "idempotency_target_gone"
|
|
)
|
|
|
|
// PatchOutcomeUnknownHint, IdempotencyInFlightHint and
|
|
// IdempotencyKeyReusedHint are the recovery guidance for TASK-3585's codes.
|
|
// Duplicated in internal/mcp; a test asserts they match.
|
|
const (
|
|
PatchOutcomeUnknownHint = "Nothing new was written. An earlier attempt reached an editor that never confirmed it and is gone, so it is not re-applied: read details.diff (the change since the patch was sent); if your edit is there, it landed. Otherwise send it again with a NEW key."
|
|
IdempotencyInFlightHint = "A request with this key is still running. Wait a moment and retry with the SAME key: you will get its answer, not a second write."
|
|
IdempotencyKeyReusedHint = "This key was already used for a different request. Use a new key for a new request; reuse a key only to retry the identical request."
|
|
IdempotencyTargetGoneHint = "The request this key belongs to already ran and created an item that has since been deleted. Nothing was written now. To create it again, send the request with a NEW key."
|
|
)
|
|
|
|
// PatchNotFoundHint is the recovery guidance for patch_not_found (PLAN-3578
|
|
// step 3). Duplicated in internal/mcp; a test asserts the two match.
|
|
const PatchNotFoundHint = "Nothing was written. That patch id is not a patch on this item: use the patch_id a patch answer returned, on the item it was made on."
|
|
|
|
// PatchUnstableHint is the recovery guidance for patch_unstable_roundtrip
|
|
// (PLAN-3578 step 2). Duplicated in internal/mcp; a test asserts the two
|
|
// match.
|
|
const PatchUnstableHint = "Nothing was written. Applying this patch in the open editor would also change content you did not edit. " +
|
|
"Re-read the item and send a whole-body update with expected_seq instead."
|
|
|
|
// PatchAnchorHint is the recovery guidance for a patch whose anchor no
|
|
// longer matches exactly once. Duplicated in internal/mcp; a test asserts
|
|
// the two match.
|
|
const PatchAnchorHint = "Nothing was written. Re-read the item and quote, as old, text that occurs exactly once in the CURRENT body " +
|
|
"(add surrounding lines to make it unique, or pass line_hint to choose among exact matches)."
|
|
|
|
// PatchRoomOpenHint is the recovery guidance for patch_room_open. Duplicated
|
|
// in internal/mcp; a test asserts the two match.
|
|
const PatchRoomOpenHint = "Nothing was written. The item is open in an editor that cannot apply patches (an older tab). " +
|
|
"Retry after that tab reloads or closes, or send a whole-body update with expected_seq."
|
|
|
|
// IsPatchRefusal reports a patch refusal the server answered.
|
|
func IsPatchRefusal(err error) (*APIError, bool) {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
switch apiErr.Code {
|
|
case PatchAnchorMissingCode, PatchAnchorAmbiguousCode, PatchRoomOpenCode, PatchUnstableCode, PatchNotFoundCode,
|
|
PatchOutcomeUnknownCode, IdempotencyInFlightCode, IdempotencyKeyReusedCode, IdempotencyTargetGoneCode:
|
|
return apiErr, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// WritePatchRefusalError writes the structured marker line for a patch
|
|
// refusal, so the stdio MCP transport reports its code and details instead
|
|
// of inferring one from the prose.
|
|
func WritePatchRefusalError(w io.Writer, apiErr *APIError) {
|
|
hint := PatchAnchorHint
|
|
if apiErr.Code == PatchRoomOpenCode {
|
|
hint = PatchRoomOpenHint
|
|
}
|
|
if apiErr.Code == PatchUnstableCode {
|
|
hint = PatchUnstableHint
|
|
}
|
|
switch apiErr.Code {
|
|
case PatchNotFoundCode:
|
|
hint = PatchNotFoundHint
|
|
case PatchOutcomeUnknownCode:
|
|
hint = PatchOutcomeUnknownHint
|
|
case IdempotencyInFlightCode:
|
|
hint = IdempotencyInFlightHint
|
|
case IdempotencyKeyReusedCode:
|
|
hint = IdempotencyKeyReusedHint
|
|
case IdempotencyTargetGoneCode:
|
|
hint = IdempotencyTargetGoneHint
|
|
}
|
|
body := map[string]any{"code": apiErr.Code, "message": apiErr.Message, "hint": hint}
|
|
if len(apiErr.Details) > 0 {
|
|
body["details"] = apiErr.Details
|
|
}
|
|
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
}
|
|
|
|
// BuiltinText is a built-in's text: the body and the fields an update writes.
|
|
type BuiltinText struct {
|
|
Content string `json:"content"`
|
|
Fields map[string]any `json:"fields"`
|
|
}
|
|
|
|
// UnmarshalJSON keeps the fields' numbers as written (codex r2): the default
|
|
// decoder makes every number a float64, so two integers above 2^53 that
|
|
// differ would compare equal and hide a setting an update overwrites.
|
|
func (b *BuiltinText) UnmarshalJSON(data []byte) error {
|
|
var raw struct {
|
|
Content string `json:"content"`
|
|
Fields json.RawMessage `json:"fields"`
|
|
}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return err
|
|
}
|
|
b.Content = raw.Content
|
|
b.Fields = map[string]any{}
|
|
if len(raw.Fields) > 0 && string(raw.Fields) != "null" {
|
|
if err := models.DecodeJSONKeepingNumbers(raw.Fields, &b.Fields); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BuiltinState is GET /workspaces/{ws}/items/{ref}/builtin (TASK-3462).
|
|
type BuiltinState struct {
|
|
Key string `json:"key"`
|
|
Kind string `json:"kind,omitempty"`
|
|
State string `json:"state"`
|
|
SeedHash string `json:"seed_hash,omitempty"`
|
|
LibraryHash string `json:"library_hash,omitempty"`
|
|
ItemHash string `json:"item_hash,omitempty"`
|
|
Library *BuiltinText `json:"library,omitempty"`
|
|
Seed *BuiltinText `json:"seed,omitempty"`
|
|
Current *BuiltinText `json:"current,omitempty"`
|
|
Seq int64 `json:"seq"`
|
|
}
|
|
|
|
// GetItemBuiltin reads an item's state relative to Pad's current text.
|
|
func (c *Client) GetItemBuiltin(wsSlug, ref string) (*BuiltinState, error) {
|
|
var st BuiltinState
|
|
if err := c.get("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(ref)+"/builtin", &st); err != nil {
|
|
return nil, err
|
|
}
|
|
return &st, nil
|
|
}
|
|
|
|
// UpdateItemBuiltin takes the library's text for an item, guarded by the seq
|
|
// the caller read (TASK-3462). overwritePendingEdits lifts
|
|
// content_pending_flush, as on any PATCH.
|
|
func (c *Client) UpdateItemBuiltin(wsSlug, ref string, expectedSeq int64, overwritePendingEdits bool) (*models.Item, error) {
|
|
body := map[string]any{"expected_seq": expectedSeq}
|
|
if overwritePendingEdits {
|
|
body["overwrite_pending_edits"] = true
|
|
}
|
|
var result models.Item
|
|
if err := c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(ref)+"/builtin/update", body, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ActivateLibraryEntry creates an item from the library entry with this
|
|
// title, server-side (TASK-3462).
|
|
func (c *Client) ActivateLibraryEntry(wsSlug, title string) (*models.Item, error) {
|
|
var result models.Item
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/library/activate", map[string]string{"title": title}, &result)
|
|
}
|
|
|
|
// AttachmentAttachResult is the attach response.
|
|
type AttachmentAttachResult struct {
|
|
ID string `json:"id"`
|
|
ItemID string `json:"item_id"`
|
|
}
|
|
|
|
// AttachAttachment binds an unattached attachment to an item (TASK-2247).
|
|
// itemRef is sent as typed; the server resolves it like the item routes.
|
|
func (c *Client) AttachAttachment(wsSlug, attachmentID, itemRef string) (*AttachmentAttachResult, error) {
|
|
var result AttachmentAttachResult
|
|
err := c.post("/workspaces/"+url.PathEscape(wsSlug)+"/attachments/"+url.PathEscape(attachmentID)+"/attach",
|
|
map[string]string{"item": itemRef}, &result)
|
|
return &result, err
|
|
}
|
|
|
|
// --- Dashboard ---
|
|
|
|
// GetDashboard returns the workspace dashboard as raw JSON.
|
|
// The DashboardResponse type lives in the server package, so we use json.RawMessage.
|
|
func (c *Client) GetDashboard(wsSlug string) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/dashboard", &result)
|
|
}
|
|
|
|
// GetReport returns the windowed project report JSON (PLAN-1628 / TASK-1630).
|
|
// window is one of day|week|2wk|month (empty = server default "week");
|
|
// collections is an optional comma-separated list of collection slugs.
|
|
func (c *Client) GetReport(wsSlug, window, collections string) (json.RawMessage, error) {
|
|
q := url.Values{}
|
|
if window != "" {
|
|
q.Set("window", window)
|
|
}
|
|
if collections != "" {
|
|
q.Set("collections", collections)
|
|
}
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/report"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
var result json.RawMessage
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Bootstrap ---
|
|
|
|
// GetAgentBootstrap returns the consolidated bootstrap blob — workspace +
|
|
// user + collections + always-on conventions + roles + playbook metadata +
|
|
// dashboard + recent activity — in one round-trip. Mirrors the HTTP
|
|
// endpoint at /workspaces/{ws}/agent/bootstrap (PLAN-1377 / TASK-1379).
|
|
// The AgentBootstrap type lives in the server package, so the CLI keeps it
|
|
// as raw JSON and delegates parsing to the caller.
|
|
func (c *Client) GetAgentBootstrap(wsSlug string) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/agent/bootstrap", &result)
|
|
}
|
|
|
|
// --- Playbooks ---
|
|
|
|
// ListPlaybooks returns the workspace's playbook metadata array
|
|
// (PLAN-1377 / TASK-1382). Same shape as bootstrap.playbooks.
|
|
func (c *Client) ListPlaybooks(wsSlug string) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/playbooks", &result)
|
|
}
|
|
|
|
// ShowPlaybook returns the full playbook item identified by ref, slug,
|
|
// or invocation_slug.
|
|
func (c *Client) ShowPlaybook(wsSlug, identifier string) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/playbooks/"+url.PathEscape(identifier), &result)
|
|
}
|
|
|
|
// MatchPlaybook asks the workspace's typed-decision provider whether text
|
|
// asks for one of the workspace's active playbooks (PLAN-3114 / TASK-3120).
|
|
// Read-only and side-effect-free. A 404 means no provider is configured —
|
|
// callers fall back to slug/trigger routing rather than treating it as a
|
|
// hard failure.
|
|
func (c *Client) MatchPlaybook(wsSlug, text string) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
return result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/playbooks/match", map[string]any{"text": text}, &result)
|
|
}
|
|
|
|
// RunPlaybook binds the supplied args to the playbook's declared spec
|
|
// and returns the body + bound args + any unsatisfied required args.
|
|
// Side-effect-free: the server only parses; the agent executes.
|
|
//
|
|
// Callers can pass either a pre-parsed args map OR raw CLI tokens
|
|
// (positional / bareword-flag / key=value). The server applies the
|
|
// strict parsing rules to rawArgs and merges them with args. CLI
|
|
// callers use rawArgs (no client-side spec lookup needed); MCP /
|
|
// programmatic callers use args directly.
|
|
func (c *Client) RunPlaybook(wsSlug, identifier string, args map[string]any, rawArgs []string, allowDraft bool) (json.RawMessage, error) {
|
|
body := map[string]any{}
|
|
if len(args) > 0 {
|
|
body["args"] = args
|
|
}
|
|
if len(rawArgs) > 0 {
|
|
body["raw_args"] = rawArgs
|
|
}
|
|
if allowDraft {
|
|
body["allow_draft"] = true
|
|
}
|
|
var result json.RawMessage
|
|
return result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/playbooks/"+url.PathEscape(identifier)+"/run", body, &result)
|
|
}
|
|
|
|
// --- Search ---
|
|
|
|
// SearchItems performs a cross-workspace search. Pass q, workspace, etc. via params.
|
|
func (c *Client) SearchItems(params url.Values) (json.RawMessage, error) {
|
|
var result json.RawMessage
|
|
path := "/search"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Activity ---
|
|
|
|
func (c *Client) ListActivity(wsSlug string, params url.Values) ([]models.Activity, error) {
|
|
var result []models.Activity
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/activity"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Convention Library ---
|
|
|
|
// ConventionLibraryResponse is the response from the convention-library endpoint.
|
|
type ConventionLibraryResponse struct {
|
|
Categories []LibraryCategory `json:"categories"`
|
|
}
|
|
|
|
// LibraryCategory groups related conventions under a named category.
|
|
type LibraryCategory struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Conventions []LibraryConvention `json:"conventions"`
|
|
}
|
|
|
|
// LibraryConvention holds a pre-built convention definition.
|
|
type LibraryConvention struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Category string `json:"category"`
|
|
Trigger string `json:"trigger"`
|
|
Surfaces []string `json:"surfaces"`
|
|
Enforcement string `json:"enforcement"`
|
|
Commands []string `json:"commands"`
|
|
}
|
|
|
|
// GetConventionLibrary fetches the convention library from the server.
|
|
//
|
|
// category — when non-empty, server-side filter against LibraryCategory.Name
|
|
// (case-sensitive exact match). PLAN-1560 / TASK-1561.
|
|
func (c *Client) GetConventionLibrary(category string) (*ConventionLibraryResponse, error) {
|
|
path := "/convention-library"
|
|
if category != "" {
|
|
params := url.Values{}
|
|
params.Set("category", category)
|
|
path += "?" + params.Encode()
|
|
}
|
|
var result ConventionLibraryResponse
|
|
return &result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Playbook Library ---
|
|
|
|
// PlaybookLibraryResponse is the response from the playbook-library endpoint.
|
|
type PlaybookLibraryResponse struct {
|
|
Categories []PlaybookCategory `json:"categories"`
|
|
}
|
|
|
|
// PlaybookCategory groups related playbooks under a named category.
|
|
type PlaybookCategory struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Playbooks []LibraryPlaybook `json:"playbooks"`
|
|
}
|
|
|
|
// LibraryPlaybook holds a pre-built playbook definition.
|
|
//
|
|
// InvocationSlug and Arguments are PLAN-1377's invocation surface and
|
|
// must round-trip through `pad library activate` so a library entry
|
|
// that declares them produces a `/pad <slug>`-routable workspace item.
|
|
//
|
|
// Content vs Summary: the server returns full Content by default; passing
|
|
// summary=true on the library-list endpoint strips Content and returns a
|
|
// short Summary instead (PLAN-1560 / TASK-1561). Both fields use omitempty
|
|
// so a single struct round-trips both shapes without zero-value noise.
|
|
type LibraryPlaybook struct {
|
|
Title string `json:"title"`
|
|
Content string `json:"content,omitempty"`
|
|
Summary string `json:"summary,omitempty"`
|
|
Category string `json:"category"`
|
|
Trigger string `json:"trigger"`
|
|
Scope string `json:"scope"`
|
|
InvocationSlug string `json:"invocation_slug,omitempty"`
|
|
Arguments []map[string]any `json:"arguments,omitempty"`
|
|
}
|
|
|
|
// GetPlaybookLibrary fetches the playbook library from the server.
|
|
//
|
|
// category — when non-empty, server-side filter against PlaybookCategory.Name
|
|
// (case-sensitive exact match).
|
|
// summary — when true, server strips LibraryPlaybook.Content and injects
|
|
// Summary (first non-heading paragraph, ~240 char cap). Use false for
|
|
// activate/get-by-title flows that need the full body. PLAN-1560 / TASK-1561.
|
|
func (c *Client) GetPlaybookLibrary(category string, summary bool) (*PlaybookLibraryResponse, error) {
|
|
params := url.Values{}
|
|
if category != "" {
|
|
params.Set("category", category)
|
|
}
|
|
if summary {
|
|
params.Set("summary", "true")
|
|
}
|
|
path := "/playbook-library"
|
|
if encoded := params.Encode(); encoded != "" {
|
|
path += "?" + encoded
|
|
}
|
|
var result PlaybookLibraryResponse
|
|
return &result, c.get(path, &result)
|
|
}
|
|
|
|
// --- Library Entry ---
|
|
|
|
// LibraryEntryResponse is the envelope returned by /library/entry. Exactly
|
|
// one of Convention or Playbook is set; Type is "convention" or "playbook"
|
|
// so callers can switch without inspecting which pointer is non-nil.
|
|
type LibraryEntryResponse struct {
|
|
Type string `json:"type"`
|
|
Convention *LibraryConvention `json:"convention,omitempty"`
|
|
Playbook *LibraryPlaybook `json:"playbook,omitempty"`
|
|
}
|
|
|
|
// GetLibraryEntry fetches one library entry by exact title match. Conventions-
|
|
// first precedence — a title that resolves to a convention is returned as
|
|
// one even if a playbook of the same title existed. Returns *APIError with
|
|
// Code="not_found" when the title doesn't match anything; CLI callers can
|
|
// type-assert to detect that case. PLAN-1560 / TASK-1561 (endpoint) +
|
|
// TASK-1562 (CLI plumbing).
|
|
func (c *Client) GetLibraryEntry(title string) (*LibraryEntryResponse, error) {
|
|
params := url.Values{}
|
|
params.Set("title", title)
|
|
var result LibraryEntryResponse
|
|
return &result, c.get("/library/entry?"+params.Encode(), &result)
|
|
}
|
|
|
|
// --- Webhooks ---
|
|
|
|
// ListWebhooks returns all webhooks for a workspace.
|
|
func (c *Client) ListWebhooks(wsSlug string) ([]models.Webhook, error) {
|
|
var result []models.Webhook
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/webhooks", &result)
|
|
}
|
|
|
|
// CreateWebhook registers a new webhook for a workspace.
|
|
func (c *Client) CreateWebhook(wsSlug string, input models.WebhookCreate) (*models.Webhook, error) {
|
|
var result models.Webhook
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/webhooks", input, &result)
|
|
}
|
|
|
|
// DeleteWebhook removes a webhook by ID.
|
|
func (c *Client) DeleteWebhook(wsSlug, webhookID string) error {
|
|
return c.delete("/workspaces/" + url.PathEscape(wsSlug) + "/webhooks/" + url.PathEscape(webhookID))
|
|
}
|
|
|
|
// TestWebhook sends a test payload to a webhook.
|
|
func (c *Client) TestWebhook(wsSlug, webhookID string) error {
|
|
return c.post("/workspaces/"+url.PathEscape(wsSlug)+"/webhooks/"+url.PathEscape(webhookID)+"/test", nil, nil)
|
|
}
|
|
|
|
// --- Workspace Members ---
|
|
|
|
// ListWorkspaceMembers returns all members of a workspace.
|
|
func (c *Client) ListWorkspaceMembers(wsSlug string) ([]models.WorkspaceMember, error) {
|
|
var result struct {
|
|
Members []models.WorkspaceMember `json:"members"`
|
|
}
|
|
if err := c.get("/workspaces/"+url.PathEscape(wsSlug)+"/members", &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result.Members, nil
|
|
}
|
|
|
|
// --- Agent Roles ---
|
|
|
|
// ListAgentRoles returns all agent roles for a workspace.
|
|
func (c *Client) ListAgentRoles(wsSlug string) ([]models.AgentRole, error) {
|
|
var result []models.AgentRole
|
|
return result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/agent-roles", &result)
|
|
}
|
|
|
|
// CreateAgentRole creates a new agent role in a workspace.
|
|
func (c *Client) CreateAgentRole(wsSlug string, input models.AgentRoleCreate) (*models.AgentRole, error) {
|
|
var result models.AgentRole
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/agent-roles", input, &result)
|
|
}
|
|
|
|
// GetAgentRole gets a single agent role by ID or slug.
|
|
func (c *Client) GetAgentRole(wsSlug, idOrSlug string) (*models.AgentRole, error) {
|
|
var result models.AgentRole
|
|
return &result, c.get("/workspaces/"+url.PathEscape(wsSlug)+"/agent-roles/"+url.PathEscape(idOrSlug), &result)
|
|
}
|
|
|
|
// UpdateAgentRole updates an existing agent role.
|
|
func (c *Client) UpdateAgentRole(wsSlug, idOrSlug string, input models.AgentRoleUpdate) (*models.AgentRole, error) {
|
|
var result models.AgentRole
|
|
return &result, c.patch("/workspaces/"+url.PathEscape(wsSlug)+"/agent-roles/"+url.PathEscape(idOrSlug), input, &result)
|
|
}
|
|
|
|
// DeleteAgentRole removes an agent role from a workspace.
|
|
func (c *Client) DeleteAgentRole(wsSlug, idOrSlug string) error {
|
|
return c.delete("/workspaces/" + url.PathEscape(wsSlug) + "/agent-roles/" + url.PathEscape(idOrSlug))
|
|
}
|
|
|
|
// --- Export / Import ---
|
|
|
|
// ExportItemArtifactResult holds the bytes of an exported artifact plus the
|
|
// download filename the server suggested via Content-Disposition. The CLI uses
|
|
// Filename to pick a default output path (`<slug>.pad.md`) when `-o` is omitted.
|
|
type ExportItemArtifactResult struct {
|
|
Body []byte
|
|
Filename string
|
|
}
|
|
|
|
// ExportItemArtifact GETs the single-item artifact export endpoint
|
|
// (GET /workspaces/{ws}/items/{ref}/export) and returns the artifact bytes
|
|
// (Markdown + YAML frontmatter) plus the server-suggested download filename.
|
|
//
|
|
// ref is an issue ID (e.g. PLAYB-3) or slug. A non-playbook/convention ref
|
|
// comes back as a 4xx whose server message is surfaced via parseError.
|
|
func (c *Client) ExportItemArtifact(wsSlug, ref string) (*ExportItemArtifactResult, error) {
|
|
req, err := c.newRequest("GET", "/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(ref)+"/export", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read export body: %w", err)
|
|
}
|
|
return &ExportItemArtifactResult{
|
|
Body: body,
|
|
Filename: filenameFromContentDisposition(resp.Header.Get("Content-Disposition")),
|
|
}, nil
|
|
}
|
|
|
|
// ImportArtifactResult is the JSON body returned by a successful artifact
|
|
// import (POST /workspaces/{ws}/import-artifact). Mirrors the server's
|
|
// artifactImportResponse.
|
|
type ImportArtifactResult struct {
|
|
Ref string `json:"ref"`
|
|
Slug string `json:"slug"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
// ImportArtifact POSTs the raw artifact bytes to the workspace import endpoint
|
|
// (POST /workspaces/{ws}/import-artifact) and decodes the {ref, slug, warnings}
|
|
// JSON. The request body is the raw artifact (Markdown + YAML frontmatter), not
|
|
// a JSON wrapper — the server reads r.Body directly. Server errors (oversized /
|
|
// malformed / over-quota) are surfaced via parseError.
|
|
func (c *Client) ImportArtifact(wsSlug string, body []byte) (*ImportArtifactResult, error) {
|
|
var result ImportArtifactResult
|
|
if err := c.PostRawWithContentType(
|
|
"/workspaces/"+url.PathEscape(wsSlug)+"/import-artifact",
|
|
body,
|
|
"text/markdown; charset=utf-8",
|
|
&result,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// filenameFromContentDisposition extracts the filename token from a
|
|
// Content-Disposition header value (e.g. `attachment; filename="foo.pad.md"`).
|
|
// Returns "" when the header is absent or carries no parseable filename.
|
|
//
|
|
// The result is always reduced to filepath.Base to defuse a hostile or
|
|
// malformed server-supplied filename (e.g. "../../etc/x" or an absolute
|
|
// path) that would otherwise become the export's default output path —
|
|
// mirrors parseAttachmentFilename in the attachment download command.
|
|
// A base that collapses to a path separator, "", ".", or ".." is treated
|
|
// as unusable and "" is returned so the caller falls back to a safe name.
|
|
func filenameFromContentDisposition(header string) string {
|
|
if header == "" {
|
|
return ""
|
|
}
|
|
if _, params, err := mime.ParseMediaType(header); err == nil {
|
|
if fn := params["filename"]; fn != "" {
|
|
// filepath.Base normalizes separators and strips directory
|
|
// components; the remaining special values can't be used as a
|
|
// real filename, so treat them as "no usable name".
|
|
base := filepath.Base(fn)
|
|
switch base {
|
|
case "", ".", "..", "/", `\`:
|
|
return ""
|
|
}
|
|
return base
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// RawGet fetches raw bytes from the API.
|
|
//
|
|
// Buffers the entire response in memory; do NOT use for endpoints that
|
|
// can return arbitrarily large bodies (e.g. workspace export bundles).
|
|
// Reach for RawStream for those callers — see TASK-884 review feedback.
|
|
func (c *Client) RawGet(path string) ([]byte, error) {
|
|
req, err := c.newRequest("GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// RawStream issues a GET and copies the response body into w as it
|
|
// arrives. Returns the number of bytes written and a *http.Response
|
|
// pointer the caller can inspect for trailers (used by the export
|
|
// bundle path to verify X-Bundle-Status). Used for large payloads
|
|
// (workspace export bundles, future S3-backed downloads) where
|
|
// buffering the whole body would defeat the server's streaming
|
|
// design and risk OOM on multi-GB exports.
|
|
//
|
|
// The HTTP status check still consumes the body if non-2xx (so the
|
|
// error message can include the server's response), but only for the
|
|
// error case — the happy path streams directly.
|
|
//
|
|
// IMPORTANT: trailers are only populated AFTER the body has been
|
|
// fully consumed (Go runtime guarantee). Callers that want to read
|
|
// resp.Trailer must wait until after io.Copy returns.
|
|
func (c *Client) RawStream(path string, w io.Writer) (int64, *http.Response, error) {
|
|
req, err := c.newRequest("GET", path, nil)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
resp, err := c.streamClient.Do(req)
|
|
if err != nil {
|
|
return 0, nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return 0, resp, c.parseError(resp)
|
|
}
|
|
n, err := io.Copy(w, resp.Body)
|
|
return n, resp, err
|
|
}
|
|
|
|
// PostRaw sends raw bytes to the API and decodes the JSON response.
|
|
func (c *Client) PostRaw(path string, data []byte, result interface{}) error {
|
|
return c.PostRawWithContentType(path, data, "application/json", result)
|
|
}
|
|
|
|
// PostRawWithContentType is the explicit-content-type variant of
|
|
// PostRaw. Used by the bundle import path to send a tar.gz as
|
|
// application/gzip so the server's content-type dispatch routes the
|
|
// request to the bundle handler instead of the JSON decoder.
|
|
func (c *Client) PostRawWithContentType(path string, data []byte, contentType string, result interface{}) error {
|
|
req, err := c.newRequest("POST", path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", contentType)
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
// PostStreamWithContentType POSTs a streaming body (typically an
|
|
// *os.File for a multi-GiB bundle import) without buffering the full
|
|
// payload in memory client-side. Mirrors the server's streaming
|
|
// import path — together they keep import memory bounded by the
|
|
// largest single blob (~25 MiB) rather than the full bundle size.
|
|
func (c *Client) PostStreamWithContentType(path string, body io.Reader, contentType string, result interface{}) error {
|
|
_, err := c.PostStreamWithContentTypeHeaders(path, body, contentType, result)
|
|
return err
|
|
}
|
|
|
|
// PostStreamWithContentTypeHeaders is PostStreamWithContentType, also returning
|
|
// the response headers.
|
|
//
|
|
// The workspace import reports what its --repair-nul flag changed in a response
|
|
// header rather than in the body, because the success body is the created
|
|
// workspace and its shape is a public contract. A caller that does not need the
|
|
// count keeps using the wrapper above.
|
|
//
|
|
// Headers are captured BEFORE handleResponse, which reads and closes the body;
|
|
// on an error path they are returned alongside the error rather than dropped,
|
|
// so a caller can still read a diagnostic header from a failed request.
|
|
func (c *Client) PostStreamWithContentTypeHeaders(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.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)
|
|
}
|
|
|
|
// --- Auth API ---
|
|
|
|
// LoginResponse is the response from POST /auth/login.
|
|
type LoginResponse struct {
|
|
User LoginUser `json:"user"`
|
|
Token string `json:"token"`
|
|
Requires2FA bool `json:"requires_2fa,omitempty"`
|
|
ChallengeToken string `json:"challenge_token,omitempty"`
|
|
}
|
|
|
|
// LoginUser is the user info returned from auth endpoints.
|
|
type LoginUser struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
// SessionResponse is the response from GET /auth/session.
|
|
type SessionResponse struct {
|
|
Authenticated bool `json:"authenticated"`
|
|
SetupRequired bool `json:"setup_required"`
|
|
SetupMethod string `json:"setup_method"`
|
|
AuthMethod string `json:"auth_method"`
|
|
User LoginUser `json:"user"`
|
|
}
|
|
|
|
// Login authenticates with email and password.
|
|
func (c *Client) Login(email, password string) (*LoginResponse, error) {
|
|
var result LoginResponse
|
|
err := c.post("/auth/login", map[string]string{
|
|
"email": email,
|
|
"password": password,
|
|
}, &result)
|
|
return &result, err
|
|
}
|
|
|
|
// LoginVerify2FA completes a 2FA login by submitting a TOTP or recovery code.
|
|
func (c *Client) LoginVerify2FA(challengeToken, code, recoveryCode string) (*LoginResponse, error) {
|
|
var result LoginResponse
|
|
body := map[string]string{
|
|
"challenge_token": challengeToken,
|
|
}
|
|
if code != "" {
|
|
body["code"] = code
|
|
}
|
|
if recoveryCode != "" {
|
|
body["recovery_code"] = recoveryCode
|
|
}
|
|
err := c.post("/auth/2fa/login-verify", body, &result)
|
|
return &result, err
|
|
}
|
|
|
|
// AccountSecurity is the part of /auth/me that 2FA management reads.
|
|
// PasswordSet is a pointer because a server that predates it omits the key:
|
|
// nil then means "assume a password", the only answer such a server took.
|
|
type AccountSecurity struct {
|
|
TOTPEnabled bool `json:"totp_enabled"`
|
|
PasswordSet *bool `json:"password_set"`
|
|
}
|
|
|
|
// GetAccountSecurity reads whether 2FA is on and whether the account has a
|
|
// password (TASK-403).
|
|
func (c *Client) GetAccountSecurity() (*AccountSecurity, error) {
|
|
var result AccountSecurity
|
|
return &result, c.get("/auth/me", &result)
|
|
}
|
|
|
|
// TOTPSetupResponse is POST /auth/2fa/setup's answer: a fresh secret (not yet
|
|
// enabled) and its otpauth:// URI.
|
|
type TOTPSetupResponse struct {
|
|
Secret string `json:"secret"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// TOTPSetup starts enabling 2FA (TASK-403).
|
|
func (c *Client) TOTPSetup() (*TOTPSetupResponse, error) {
|
|
var result TOTPSetupResponse
|
|
return &result, c.post("/auth/2fa/setup", nil, &result)
|
|
}
|
|
|
|
// TOTPVerifyResponse carries the recovery codes, which the server returns
|
|
// exactly once.
|
|
type TOTPVerifyResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
RecoveryCodes []string `json:"recovery_codes"`
|
|
}
|
|
|
|
// TOTPVerify finishes enabling 2FA with a code from the authenticator app.
|
|
func (c *Client) TOTPVerify(secret, code string) (*TOTPVerifyResponse, error) {
|
|
var result TOTPVerifyResponse
|
|
return &result, c.post("/auth/2fa/verify", map[string]string{"secret": secret, "code": code}, &result)
|
|
}
|
|
|
|
// TOTPDisableResponse carries the replacement session token: disabling 2FA
|
|
// rotates the caller's sessions, so the old bearer stops working.
|
|
type TOTPDisableResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// TOTPDisable turns 2FA off. An account with a password proves itself with
|
|
// it; one without sends a current code or a recovery code (#1879).
|
|
func (c *Client) TOTPDisable(password, code, recoveryCode string) (*TOTPDisableResponse, error) {
|
|
body := map[string]string{}
|
|
if password != "" {
|
|
body["password"] = password
|
|
}
|
|
if code != "" {
|
|
body["code"] = code
|
|
}
|
|
if recoveryCode != "" {
|
|
body["recovery_code"] = recoveryCode
|
|
}
|
|
var result TOTPDisableResponse
|
|
return &result, c.post("/auth/2fa/disable", body, &result)
|
|
}
|
|
|
|
// Bootstrap creates the first admin account on a fresh instance.
|
|
func (c *Client) Bootstrap(email, name, password string) (*LoginResponse, error) {
|
|
return c.BootstrapWithToken(email, name, password, "")
|
|
}
|
|
|
|
// BootstrapWithToken is like Bootstrap but sends token as the
|
|
// X-Bootstrap-Token header when token is non-empty. This is required for
|
|
// self-host deployments where the server generated a first-run token (the
|
|
// "logs_token" setup_method path — TASK-1167). Pass an empty string on
|
|
// pure-loopback deployments where the header is not required.
|
|
func (c *Client) BootstrapWithToken(email, name, password, token string) (*LoginResponse, error) {
|
|
data, err := json.Marshal(map[string]string{
|
|
"email": email,
|
|
"name": name,
|
|
"password": password,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := c.newRequest("POST", "/auth/bootstrap", bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("X-Bootstrap-Token", token)
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var result LoginResponse
|
|
if err := c.handleResponse(resp, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// CLIAuthSessionResponse is the response from POST /auth/cli/sessions.
|
|
type CLIAuthSessionResponse struct {
|
|
SessionCode string `json:"session_code"`
|
|
AuthURL string `json:"auth_url"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
// CLIAuthSessionStatus is the response from GET /auth/cli/sessions/{code}.
|
|
type CLIAuthSessionStatus struct {
|
|
Status string `json:"status"` // "pending", "approved", "expired"
|
|
Token string `json:"token,omitempty"`
|
|
User LoginUser `json:"user,omitempty"`
|
|
}
|
|
|
|
// CreateCLIAuthSession creates a new pending CLI auth session.
|
|
func (c *Client) CreateCLIAuthSession() (*CLIAuthSessionResponse, error) {
|
|
var result CLIAuthSessionResponse
|
|
err := c.post("/auth/cli/sessions", nil, &result)
|
|
return &result, err
|
|
}
|
|
|
|
// PollCLIAuthSession checks the status of a CLI auth session.
|
|
func (c *Client) PollCLIAuthSession(code string) (*CLIAuthSessionStatus, error) {
|
|
var result CLIAuthSessionStatus
|
|
err := c.get("/auth/cli/sessions/"+url.PathEscape(code), &result)
|
|
return &result, err
|
|
}
|
|
|
|
// Logout destroys the current session.
|
|
func (c *Client) Logout() error {
|
|
return c.post("/auth/logout", nil, nil)
|
|
}
|
|
|
|
// GetCurrentUser returns the authenticated user's profile.
|
|
func (c *Client) GetCurrentUser() (*LoginUser, error) {
|
|
var result LoginUser
|
|
return &result, c.get("/auth/me", &result)
|
|
}
|
|
|
|
// CheckSession returns the current auth status.
|
|
func (c *Client) CheckSession() (*SessionResponse, error) {
|
|
var result SessionResponse
|
|
return &result, c.get("/auth/session", &result)
|
|
}
|
|
|
|
// --- Audit Log ---
|
|
|
|
// GetAuditLog fetches the global audit log (admin-only).
|
|
func (c *Client) GetAuditLog(params models.AuditLogParams) ([]models.Activity, error) {
|
|
q := url.Values{}
|
|
if params.Action != "" {
|
|
q.Set("action", params.Action)
|
|
}
|
|
if params.Actor != "" {
|
|
q.Set("actor", params.Actor)
|
|
}
|
|
if params.WorkspaceID != "" {
|
|
q.Set("workspace", params.WorkspaceID)
|
|
}
|
|
if params.Days > 0 {
|
|
q.Set("days", fmt.Sprintf("%d", params.Days))
|
|
}
|
|
if params.Limit > 0 {
|
|
q.Set("limit", fmt.Sprintf("%d", params.Limit))
|
|
}
|
|
if params.Offset > 0 {
|
|
q.Set("offset", fmt.Sprintf("%d", params.Offset))
|
|
}
|
|
path := "/audit-log"
|
|
if qs := q.Encode(); qs != "" {
|
|
path += "?" + qs
|
|
}
|
|
var result []models.Activity
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// --- HTTP helpers ---
|
|
|
|
type APIError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Details json.RawMessage `json:"details,omitempty"`
|
|
|
|
// From the RESPONSE rather than its body, so never part of the JSON
|
|
// (BUG-3147). RetryAfterSeconds is the 429's Retry-After header in whole
|
|
// seconds; zero when the response carried none or it did not parse.
|
|
Status int `json:"-"`
|
|
RetryAfterSeconds int `json:"-"`
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
// OpenChildEntry mirrors the server-side openChildEntry payload returned
|
|
// inside APIError.Details when Code == "open_children" (IDEA-1494). The
|
|
// CLI renders its human error list from these entries; MCP-driven agents
|
|
// can introspect the same data to self-recover.
|
|
type OpenChildEntry struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
}
|
|
|
|
// OpenChildrenDetails is the parsed shape of APIError.Details when
|
|
// Code == "open_children".
|
|
type OpenChildrenDetails struct {
|
|
OpenChildren []OpenChildEntry `json:"open_children"`
|
|
HiddenBlockerCount int `json:"hidden_blocker_count"`
|
|
DoneField string `json:"done_field"`
|
|
AttemptedValue string `json:"attempted_value"`
|
|
}
|
|
|
|
// AsOpenChildren returns the parsed open-children details when this
|
|
// APIError carries them, or nil otherwise. Returns nil for any error
|
|
// other than "open_children" so callers can branch cleanly.
|
|
func (e *APIError) AsOpenChildren() *OpenChildrenDetails {
|
|
if e == nil || e.Code != "open_children" || len(e.Details) == 0 {
|
|
return nil
|
|
}
|
|
var d OpenChildrenDetails
|
|
if err := json.Unmarshal(e.Details, &d); err != nil {
|
|
return nil
|
|
}
|
|
return &d
|
|
}
|
|
|
|
// StructuredErrorMarker is the versioned line prefix the CLI writes
|
|
// to stderr when surfacing a structured error (currently: IDEA-1494's
|
|
// open-children rejection). The JSON line that follows carries the
|
|
// full server-style envelope (code / message / details) so a
|
|
// downstream consumer — the stdio MCP dispatcher's classifyExecError
|
|
// in particular — can detect the rejection and lift the structured
|
|
// payload without parsing free-form human text.
|
|
//
|
|
// Versioned (Codex round-3 P3) so future evolutions of the wire shape
|
|
// don't silently break older parsers — when the payload contract
|
|
// changes incompatibly, bump to `pad-structured-error/v2:` and have
|
|
// the classifier accept both during the transition. The version token
|
|
// is parsed (not just matched as a literal prefix) so older v1-only
|
|
// parsers cleanly ignore unknown versions.
|
|
//
|
|
// IMPORTANT: keep in lockstep with mcp.structuredErrorMarker / the
|
|
// allow-list of structured codes in mcp.allowedStructuredErrorCodes.
|
|
// A change here REQUIRES a corresponding change in
|
|
// internal/mcp/errors.go.
|
|
const StructuredErrorMarker = "pad-structured-error/v1: "
|
|
|
|
// OpenChildrenErrorMarker is the pre-round-3 marker, retained as a
|
|
// deprecated alias for any out-of-tree consumer that may have hard-
|
|
// coded it. New code MUST use StructuredErrorMarker.
|
|
//
|
|
// Deprecated: use StructuredErrorMarker.
|
|
const OpenChildrenErrorMarker = StructuredErrorMarker
|
|
|
|
// WriteOpenChildrenError formats an open-children rejection to w in
|
|
// the canonical two-track shape the project guarantees (IDEA-1494 R2):
|
|
//
|
|
// 1. A single `pad-error: {json}\n` line carrying the full structured
|
|
// payload — consumed by the MCP stdio classifier and anyone else
|
|
// wanting to introspect the rejection programmatically.
|
|
// 2. Human-readable lines: the message, the per-child list (rendered
|
|
// from the SAME details struct the JSON line carries — single
|
|
// source of truth for both views), the hidden-count tag when
|
|
// applicable, and the `Pass --force to override` reminder.
|
|
//
|
|
// Order matters: machine line first so a consumer that reads stderr
|
|
// line-by-line can dispatch on the first line without buffering all
|
|
// of it. Callers should write nothing else between the marker line
|
|
// and the human block.
|
|
func WriteOpenChildrenError(w io.Writer, apiErr *APIError, oc *OpenChildrenDetails) {
|
|
envelope := map[string]any{
|
|
"error": map[string]any{
|
|
"code": apiErr.Code,
|
|
"message": apiErr.Message,
|
|
"details": oc,
|
|
},
|
|
}
|
|
if data, err := json.Marshal(envelope); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
fmt.Fprintln(w, apiErr.Message)
|
|
for _, c := range oc.OpenChildren {
|
|
fmt.Fprintf(w, " %s — %s (status=%s)\n", c.Ref, c.Title, c.Status)
|
|
}
|
|
if oc.HiddenBlockerCount > 0 {
|
|
noun := "child"
|
|
if oc.HiddenBlockerCount != 1 {
|
|
noun = "children"
|
|
}
|
|
fmt.Fprintf(w, " (+%d hidden %s you don't have access to)\n", oc.HiddenBlockerCount, noun)
|
|
}
|
|
fmt.Fprintln(w, "Pass --force to override.")
|
|
}
|
|
|
|
// UpdateConflictDetails is the parsed shape of APIError.Details when
|
|
// Code == "update_conflict" (TASK-2022, optimistic concurrency). The server
|
|
// returns it at HTTP 409 when an update carried `expected_updated_at` and it
|
|
// no longer matched the item's current updated_at — another writer won the
|
|
// race.
|
|
type UpdateConflictDetails struct {
|
|
Ref string `json:"ref"`
|
|
ExpectedUpdatedAt string `json:"expected_updated_at"`
|
|
ActualUpdatedAt string `json:"actual_updated_at"`
|
|
|
|
// BUG-3037 (codex round 4): a conflict on the SEQ token carries these
|
|
// instead of the timestamp pair, and `ConflictType` is "seq". Without them
|
|
// a seq conflict printed two empty timestamps and told the caller to retry
|
|
// with "the current timestamp" — advice for a token they did not send, and
|
|
// the actual token they need dropped on the floor.
|
|
//
|
|
// Pointers so ABSENT and ZERO stay distinguishable: a seq is never 0 on a
|
|
// live row, so a 0 here would mean the server did not report one.
|
|
ExpectedSeq *int64 `json:"expected_seq,omitempty"`
|
|
ActualSeq *int64 `json:"actual_seq,omitempty"`
|
|
ConflictType string `json:"conflict_type,omitempty"`
|
|
|
|
// PLAN-3555: set on a refused BODY write. They must be declared here to
|
|
// survive: the stdio marker re-marshals this struct, not the server's
|
|
// details.
|
|
ContentWrite bool `json:"content_write,omitempty"`
|
|
ChangedAt string `json:"changed_at,omitempty"`
|
|
ChangedByKind string `json:"changed_by_kind,omitempty"`
|
|
}
|
|
|
|
// UpdateConflictContentHint is the recovery guidance for an update_conflict on
|
|
// a body write (PLAN-3555): re-read and merge, never resend. Duplicated in
|
|
// internal/mcp (UpdateConflictContentHint); a test asserts the two match.
|
|
const UpdateConflictContentHint = "Do not resend your body: it would undo the change you missed. " +
|
|
"Re-read the item, apply your edit to the CURRENT content, and send that with expected_seq set to the seq you just read."
|
|
|
|
// AsUpdateConflict returns the parsed conflict details when this APIError
|
|
// carries them, or nil otherwise. Returns nil for any error other than
|
|
// "update_conflict" so callers can branch cleanly.
|
|
func (e *APIError) AsUpdateConflict() *UpdateConflictDetails {
|
|
if e == nil || e.Code != "update_conflict" || len(e.Details) == 0 {
|
|
return nil
|
|
}
|
|
var d UpdateConflictDetails
|
|
if err := json.Unmarshal(e.Details, &d); err != nil {
|
|
return nil
|
|
}
|
|
return &d
|
|
}
|
|
|
|
// WriteUpdateConflictError formats an optimistic-concurrency conflict to w in
|
|
// the canonical two-track shape (TASK-2022), mirroring WriteOpenChildrenError
|
|
// / WritePlanLimitError:
|
|
//
|
|
// 1. A single `pad-structured-error/v1: {json}\n` marker line — consumed by
|
|
// the MCP stdio classifier so it lifts the structured payload instead of
|
|
// falling through to a generic server_error.
|
|
// 2. Human-readable lines: the message plus the expected/actual timestamps.
|
|
func WriteUpdateConflictError(w io.Writer, apiErr *APIError, uc *UpdateConflictDetails) {
|
|
errBody := map[string]any{
|
|
"code": apiErr.Code,
|
|
"message": apiErr.Message,
|
|
"details": uc,
|
|
}
|
|
if uc.ContentWrite {
|
|
errBody["hint"] = UpdateConflictContentHint
|
|
}
|
|
envelope := map[string]any{"error": errBody}
|
|
if data, err := json.Marshal(envelope); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
fmt.Fprintln(w, apiErr.Message)
|
|
// Print the token the caller ACTUALLY SENT, and name it (BUG-3037). The
|
|
// retry line is part of the contract here: telling someone who sent
|
|
// `--expected-seq` to retry with a timestamp sends them to the weaker token,
|
|
// which is how this bug got its foothold in the first place.
|
|
if uc.ConflictType == "seq" || uc.ExpectedSeq != nil {
|
|
if uc.ExpectedSeq != nil {
|
|
fmt.Fprintf(w, " expected seq: %d\n", *uc.ExpectedSeq)
|
|
}
|
|
if uc.ActualSeq != nil {
|
|
fmt.Fprintf(w, " actual seq: %d\n", *uc.ActualSeq)
|
|
if uc.ContentWrite {
|
|
// PLAN-3555: the server's message already says not to resend.
|
|
fmt.Fprintf(w, "Re-read the item (pad item show), apply your edit to the current body, and update with --expected-seq %d.\n", *uc.ActualSeq)
|
|
return
|
|
}
|
|
fmt.Fprintf(w, "Re-read the item (pad item show) and retry with --expected-seq %d.\n", *uc.ActualSeq)
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "Re-read the item (pad item show) and retry with the current seq.")
|
|
return
|
|
}
|
|
fmt.Fprintf(w, " expected updated_at: %s\n", uc.ExpectedUpdatedAt)
|
|
fmt.Fprintf(w, " actual updated_at: %s\n", uc.ActualUpdatedAt)
|
|
fmt.Fprintln(w, "Re-read the item (pad item show) and retry with the current timestamp.")
|
|
}
|
|
|
|
// CommentHasRepliesCode is the structured code for BUG-3252's first
|
|
// refusal: a delete of a comment that still has replies. A server with
|
|
// comment tombstones no longer sends it (such a delete leaves a tombstone);
|
|
// it is kept for servers that predate them. Keep it, and the hint, in
|
|
// lockstep with internal/mcp's allowedStructuredErrorCodes and
|
|
// CommentHasRepliesHint.
|
|
const CommentHasRepliesCode = "comment_has_replies"
|
|
|
|
// CommentHasRepliesHint is the recovery guidance for CommentHasRepliesCode.
|
|
const CommentHasRepliesHint = "Nothing was deleted. Delete the replies first (the comments whose parent_id is this comment's id), " +
|
|
"or edit the comment instead of deleting it."
|
|
|
|
// IsCommentHasReplies reports whether err is BUG-3252's refusal.
|
|
func IsCommentHasReplies(err error) (*APIError, bool) {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Code == CommentHasRepliesCode {
|
|
return apiErr, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// LeaseHeldCode is the structured code for a claim on an item another holder
|
|
// leases (BUG-3496). Keep it, and the hint, in lockstep with internal/mcp's
|
|
// allowedStructuredErrorCodes and LeaseHeldHint.
|
|
const LeaseHeldCode = "lease_held"
|
|
|
|
// LeaseHeldHint is the recovery guidance for LeaseHeldCode.
|
|
const LeaseHeldHint = "Another holder (details.holder) has claimed this item until details.expires_at. " +
|
|
"Pick other work, or wait until the lease expires; claiming again before then is refused the same way."
|
|
|
|
// IsLeaseHeld reports whether err is the lease refusal.
|
|
func IsLeaseHeld(err error) (*APIError, bool) {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Code == LeaseHeldCode {
|
|
return apiErr, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// WriteLeaseHeldError writes the structured marker line for the lease
|
|
// refusal, so the stdio MCP transport reports lease_held with its holder and
|
|
// expiry instead of inferring a code from the prose.
|
|
func WriteLeaseHeldError(w io.Writer, apiErr *APIError) {
|
|
body := map[string]any{
|
|
"code": LeaseHeldCode,
|
|
"message": apiErr.Message,
|
|
"hint": LeaseHeldHint,
|
|
}
|
|
if len(apiErr.Details) > 0 {
|
|
body["details"] = apiErr.Details
|
|
}
|
|
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
}
|
|
|
|
// CommentDeletedCode is the structured code for a write addressed to a
|
|
// comment tombstone (BUG-3252): an edit, a reply or a reaction. Keep it, and
|
|
// the hint, in lockstep with internal/mcp's allowedStructuredErrorCodes and
|
|
// CommentDeletedHint.
|
|
const CommentDeletedCode = "comment_deleted"
|
|
|
|
// CommentDeletedHint is the recovery guidance for CommentDeletedCode.
|
|
const CommentDeletedHint = "The comment was deleted and is kept only as a placeholder for its replies, so it cannot be edited, " +
|
|
"replied to or reacted to. Comment on the item, or reply to one of its replies, instead."
|
|
|
|
// IsCommentDeleted reports whether err is the tombstone refusal.
|
|
func IsCommentDeleted(err error) (*APIError, bool) {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Code == CommentDeletedCode {
|
|
return apiErr, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// WriteCommentDeletedError writes the structured marker line for the
|
|
// tombstone refusal, so the stdio MCP transport reports comment_deleted
|
|
// instead of inferring a code from the prose.
|
|
func WriteCommentDeletedError(w io.Writer, apiErr *APIError) {
|
|
body := map[string]any{
|
|
"code": CommentDeletedCode,
|
|
"message": apiErr.Message,
|
|
"hint": CommentDeletedHint,
|
|
}
|
|
if len(apiErr.Details) > 0 {
|
|
body["details"] = apiErr.Details
|
|
}
|
|
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
}
|
|
|
|
// WriteCommentHasRepliesError writes the structured marker line for
|
|
// BUG-3252's refusal, so the stdio MCP transport reports comment_has_replies
|
|
// with its reply count instead of inferring a code from the prose. Like
|
|
// WriteRateLimitedError it prints no human line; cobra prints the message.
|
|
func WriteCommentHasRepliesError(w io.Writer, apiErr *APIError) {
|
|
body := map[string]any{
|
|
"code": CommentHasRepliesCode,
|
|
"message": apiErr.Message,
|
|
"hint": CommentHasRepliesHint,
|
|
}
|
|
if len(apiErr.Details) > 0 {
|
|
body["details"] = apiErr.Details
|
|
}
|
|
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
}
|
|
|
|
// ContentPendingFlushCode is the structured code for BUG-3133's refusal: a
|
|
// content write carrying a version token while a browser tab holds unflushed
|
|
// edits the token cannot see. Keep in lockstep with internal/mcp's
|
|
// allowedStructuredErrorCodes.
|
|
const ContentPendingFlushCode = "content_pending_flush"
|
|
|
|
// ContentPendingFlushHint rides in the marker envelope so the MCP stdio
|
|
// transport gives the same guidance the HTTP transport does. Must equal
|
|
// internal/mcp's ContentPendingFlushHint; a test asserts it.
|
|
const ContentPendingFlushHint = "Re-reading will not clear this: the stored item is unchanged until the open editor saves its edits. " +
|
|
"Wait for that and re-read, or resend with overwrite_pending_edits=true to replace them."
|
|
|
|
// ContentSetAsideHint replaces ContentPendingFlushHint when the refusal is
|
|
// about edits a collab schema-version rebuild set aside (BUG-3244,
|
|
// details.set_aside_rows > 0). Waiting and re-reading cannot clear those: no
|
|
// editor will write them back. Duplicated in internal/mcp; a test
|
|
// asserts the two match.
|
|
const ContentSetAsideHint = "Re-reading will not clear this, and neither will waiting or opening the item: these edits were set aside by an editor upgrade and no editor will write them back. " +
|
|
"Read them with pad item set-aside <ref>, then resend with overwrite_pending_edits=true to discard them."
|
|
|
|
// ContentSetAsideRows reads details.set_aside_rows from a content_pending_flush
|
|
// refusal: how many set-aside edit rows caused it. Zero when the refusal was
|
|
// about unflushed edits only, or came from a server that predates BUG-3244.
|
|
func (e *APIError) ContentSetAsideRows() int {
|
|
if e == nil || len(e.Details) == 0 {
|
|
return 0
|
|
}
|
|
var d struct {
|
|
SetAsideRows int `json:"set_aside_rows"`
|
|
}
|
|
if json.Unmarshal(e.Details, &d) != nil {
|
|
return 0
|
|
}
|
|
return d.SetAsideRows
|
|
}
|
|
|
|
// AsContentPendingFlush reports whether the server refused the write with
|
|
// content_pending_flush.
|
|
func (e *APIError) AsContentPendingFlush() bool {
|
|
return e != nil && e.Code == ContentPendingFlushCode
|
|
}
|
|
|
|
// WriteContentPendingFlushError renders the content_pending_flush refusal: the
|
|
// structured marker line for the MCP stdio classifier (the same contract as
|
|
// WriteUpdateConflictError), then the server's message and the CLI's own way
|
|
// out. It deliberately does NOT say "re-read and retry" — re-reading returns
|
|
// the same row and seq, so that loop never ends.
|
|
func WriteContentPendingFlushError(w io.Writer, apiErr *APIError) {
|
|
hint, remedy := ContentPendingFlushHint, "Pass --overwrite-pending-edits to replace those edits."
|
|
if apiErr.ContentSetAsideRows() > 0 {
|
|
hint, remedy = ContentSetAsideHint, "Pass --overwrite-pending-edits to discard those edits."
|
|
}
|
|
envelope := map[string]any{
|
|
"error": map[string]any{
|
|
"code": apiErr.Code,
|
|
"message": apiErr.Message,
|
|
"hint": hint,
|
|
"details": apiErr.Details,
|
|
},
|
|
}
|
|
if data, err := json.Marshal(envelope); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
fmt.Fprintln(w, apiErr.Message)
|
|
fmt.Fprintln(w, remedy)
|
|
}
|
|
|
|
// StoredStateUnreadableCode is the structured error code for "the item's
|
|
// STORED value cannot be decoded, so the operation is refused and retrying is
|
|
// pointless" (BUG-2675).
|
|
//
|
|
// Unlike every other code the CLI emits a marker for, this one is generated
|
|
// LOCALLY — models.AppendImplementationNote / AppendDecisionLogEntry refuse
|
|
// before any request is made, so there is no APIError to carry it. Keep in
|
|
// lockstep with internal/mcp's allowedStructuredErrorCodes, which is what makes
|
|
// the stdio transport surface it instead of collapsing it to server_error.
|
|
const StoredStateUnreadableCode = "stored_state_unreadable"
|
|
|
|
// StoredStateUnreadableHint is the recovery guidance that rides along with the
|
|
// code. It is duplicated in internal/mcp (which does not import this package in
|
|
// production code, for the same dependency-graph reason StructuredErrorMarker
|
|
// is duplicated); TestCLIAndMCPAgreeOnTheCodeString asserts the two match.
|
|
//
|
|
// It exists as a constant rather than being left to each transport because a
|
|
// hint the remote transport delivers and the stdio one does not is the same
|
|
// class of gap as a code only one transport emits (Codex round 2).
|
|
const StoredStateUnreadableHint = "Retrying will not help — the item's stored value has been undecodable since it was written, " +
|
|
"so every attempt refuses identically, and the append is refused precisely because completing it " +
|
|
"would overwrite that value. Do not route around it by writing the field another way. What you can " +
|
|
"SEE depends on which is broken: if the whole fields blob fails to parse, `pad_item` action=get shows " +
|
|
"it to you as a raw string, but if one structured key is at fault MCP hides it — the fields blob is " +
|
|
"normalized on this surface and a value that does not decode is dropped from the top-level arrays. " +
|
|
"Either way, report the item to a human, who can read the raw value with `pad item show <ref> --format json` " +
|
|
"and repair it."
|
|
|
|
// WriteStoredStateUnreadableError formats a local refusal to w in the canonical
|
|
// two-track shape, mirroring WriteOpenChildrenError / WriteUpdateConflictError:
|
|
//
|
|
// 1. A single `pad-structured-error/v1: {json}\n` marker line — consumed by
|
|
// the MCP stdio classifier so an agent gets the retry-hostile code rather
|
|
// than a generic server_error it may reasonably retry forever.
|
|
// 2. The human-readable message.
|
|
//
|
|
// The message is the error's own text: the append helpers already phrase the
|
|
// refusal with the inspect-first remedy, and re-wording it here would give the
|
|
// two transports different prose for the same condition.
|
|
func WriteStoredStateUnreadableError(w io.Writer, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
envelope := map[string]any{
|
|
"error": map[string]any{
|
|
"code": StoredStateUnreadableCode,
|
|
"message": err.Error(),
|
|
"hint": StoredStateUnreadableHint,
|
|
},
|
|
}
|
|
if data, mErr := json.Marshal(envelope); mErr == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
fmt.Fprintln(w, err.Error())
|
|
}
|
|
|
|
// PlanLimitDetails is the parsed shape of APIError.Details when
|
|
// Code == "plan_limit_exceeded" (TASK-788).
|
|
type PlanLimitDetails struct {
|
|
Feature string `json:"feature"`
|
|
Limit int `json:"limit"`
|
|
Current int `json:"current"`
|
|
Plan string `json:"plan"`
|
|
UpgradeURL string `json:"upgrade_url"`
|
|
}
|
|
|
|
// AsPlanLimit returns the parsed plan-limit details when this APIError
|
|
// carries them, or nil otherwise. Returns nil for any error other than
|
|
// "plan_limit_exceeded" so callers can branch cleanly.
|
|
func (e *APIError) AsPlanLimit() *PlanLimitDetails {
|
|
if e == nil || e.Code != "plan_limit_exceeded" || len(e.Details) == 0 {
|
|
return nil
|
|
}
|
|
var d PlanLimitDetails
|
|
if err := json.Unmarshal(e.Details, &d); err != nil {
|
|
return nil
|
|
}
|
|
return &d
|
|
}
|
|
|
|
// WritePlanLimitError formats a plan-limit rejection to w in the canonical
|
|
// two-track shape (TASK-788):
|
|
//
|
|
// 1. A single `pad-structured-error/v1: {json}\n` marker line — consumed by
|
|
// the MCP stdio classifier so it lifts the structured payload instead of
|
|
// falling through to a generic server_error.
|
|
// 2. A human-readable line: the message from the server envelope.
|
|
//
|
|
// This mirrors WriteOpenChildrenError exactly in structure so the two paths
|
|
// are easy to reason about together.
|
|
func WritePlanLimitError(w io.Writer, apiErr *APIError) {
|
|
envelope := map[string]any{
|
|
"error": map[string]any{
|
|
"code": apiErr.Code,
|
|
"message": apiErr.Message,
|
|
"details": apiErr.Details,
|
|
},
|
|
}
|
|
if data, err := json.Marshal(envelope); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
fmt.Fprintln(w, apiErr.Message)
|
|
}
|
|
|
|
// --- Attachments ---
|
|
//
|
|
// AttachmentUploadResult mirrors the JSON returned by
|
|
// POST /api/v1/workspaces/{slug}/attachments. It is the API contract
|
|
// callers depend on; do NOT replace it with models.Attachment which has
|
|
// different field names and embeds DB-only fields.
|
|
type AttachmentUploadResult struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
MIME string `json:"mime"`
|
|
Size int64 `json:"size"`
|
|
Width *int `json:"width,omitempty"`
|
|
Height *int `json:"height,omitempty"`
|
|
Filename string `json:"filename"`
|
|
Category string `json:"category"`
|
|
RenderMode string `json:"render_mode"`
|
|
}
|
|
|
|
// UploadAttachment streams the contents of body to
|
|
// POST /api/v1/workspaces/{wsSlug}/attachments as a multipart file
|
|
// part. filename is what the server stores (after basenaming); itemRef
|
|
// is optional and associates the upload with a parent item via the
|
|
// item_id form field — pass empty string for a free-floating upload.
|
|
//
|
|
// The caller is responsible for closing body if it's a *os.File or
|
|
// other io.Closer; this method only reads from it.
|
|
func (c *Client) UploadAttachment(wsSlug, itemRef, filename string, body io.Reader) (*AttachmentUploadResult, error) {
|
|
// Build the multipart envelope into a pipe so we don't have to
|
|
// buffer the entire upload in memory before sending.
|
|
pr, pw := io.Pipe()
|
|
mw := multipart.NewWriter(pw)
|
|
|
|
// Spawn a goroutine that writes the multipart body. We can't write
|
|
// inline because the http.Request.Body needs to be a Reader the
|
|
// transport pulls from in parallel with us writing.
|
|
go func() {
|
|
defer pw.Close()
|
|
defer mw.Close()
|
|
|
|
if itemRef != "" {
|
|
if err := mw.WriteField("item_id", itemRef); err != nil {
|
|
_ = pw.CloseWithError(fmt.Errorf("write item_id field: %w", err))
|
|
return
|
|
}
|
|
}
|
|
part, err := mw.CreateFormFile("file", filename)
|
|
if err != nil {
|
|
_ = pw.CloseWithError(fmt.Errorf("create file part: %w", err))
|
|
return
|
|
}
|
|
if _, err := io.Copy(part, body); err != nil {
|
|
_ = pw.CloseWithError(fmt.Errorf("stream upload body: %w", err))
|
|
return
|
|
}
|
|
}()
|
|
|
|
req, err := c.newRequest("POST", "/workspaces/"+url.PathEscape(wsSlug)+"/attachments", pr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
|
|
// Uploads can be large and slow over a remote link. The default
|
|
// 10s ClientTimeout is too tight for a 25 MiB upload over a
|
|
// constrained connection, so use a fresh client with a generous
|
|
// timeout for this single request only.
|
|
uploadClient := &http.Client{Timeout: 5 * time.Minute}
|
|
resp, err := uploadClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upload attachment: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var result AttachmentUploadResult
|
|
if err := c.handleResponse(resp, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// attachmentIDPathSegment renders an attachment id as a single, inert URL
|
|
// path segment. The id reaches these URLs from item content
|
|
// ("pad-attachment:" refs other workspace members wrote), so it is treated
|
|
// as hostile: PathEscape stops a slash-carrying id from re-routing the
|
|
// request to a different endpoint whose 200 would then vouch for it
|
|
// downstream (codex closing round 3), and the exact dot segments "." and
|
|
// ".." — which PathEscape leaves UNCHANGED, so a proxy or server can still
|
|
// normalize them away — are refused outright rather than sent (codex
|
|
// closing round 4). A real id is a UUID; neither refusal can fire on one.
|
|
func attachmentIDPathSegment(id string) (string, error) {
|
|
if id == "." || id == ".." {
|
|
return "", fmt.Errorf("invalid attachment id %q", id)
|
|
}
|
|
return url.PathEscape(id), nil
|
|
}
|
|
|
|
// DownloadAttachment streams the bytes of an attachment into w. Returns
|
|
// the Content-Type the server set and the number of bytes copied so
|
|
// callers can verify size or render with the right MIME hint.
|
|
//
|
|
// When variant is non-empty, requests ?variant=<variant>; the server
|
|
// silently falls back to the original if the derived row doesn't exist
|
|
// (TASK-872 / TASK-878 contract).
|
|
func (c *Client) DownloadAttachment(wsSlug, attachmentID, variant string, w io.Writer) (mime string, size int64, err error) {
|
|
p, err := attachmentIDPathSegment(attachmentID)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/attachments/" + p
|
|
if variant != "" {
|
|
path += "?variant=" + url.QueryEscape(variant)
|
|
}
|
|
req, err := c.newRequest("GET", path, nil)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
// Use a generous timeout — large blobs over a slow link otherwise
|
|
// trip the default 10s on the package-shared client.
|
|
dlClient := &http.Client{Timeout: 5 * time.Minute}
|
|
resp, err := dlClient.Do(req)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("download attachment: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return "", 0, c.parseError(resp)
|
|
}
|
|
n, copyErr := io.Copy(w, resp.Body)
|
|
if copyErr != nil {
|
|
return resp.Header.Get("Content-Type"), n, fmt.Errorf("stream download: %w", copyErr)
|
|
}
|
|
return resp.Header.Get("Content-Type"), n, nil
|
|
}
|
|
|
|
// AttachmentMetadata mirrors the response headers of
|
|
// HEAD /api/v1/workspaces/{slug}/attachments/{id}. The server doesn't
|
|
// expose a separate JSON metadata endpoint — HEAD returns the same
|
|
// Content-Type / Content-Length / etc. headers a GET would set, with
|
|
// no body. That's enough for the CLI's `pad attachment show` to
|
|
// surface size + MIME without paying for the bytes.
|
|
type AttachmentMetadata struct {
|
|
ID string `json:"id"`
|
|
MIME string `json:"mime"`
|
|
Size int64 `json:"size"`
|
|
ContentDisposition string `json:"content_disposition,omitempty"`
|
|
ETag string `json:"etag,omitempty"`
|
|
LastModified string `json:"last_modified,omitempty"`
|
|
}
|
|
|
|
// HeadAttachment issues a HEAD request and returns the headers as
|
|
// structured metadata. Variant is forwarded the same way as
|
|
// DownloadAttachment — empty string for the original blob.
|
|
func (c *Client) HeadAttachment(wsSlug, attachmentID, variant string) (*AttachmentMetadata, error) {
|
|
p, err := attachmentIDPathSegment(attachmentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/attachments/" + p
|
|
if variant != "" {
|
|
path += "?variant=" + url.QueryEscape(variant)
|
|
}
|
|
req, err := c.newRequest("HEAD", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("head attachment: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return nil, c.parseError(resp)
|
|
}
|
|
meta := &AttachmentMetadata{
|
|
ID: attachmentID,
|
|
MIME: resp.Header.Get("Content-Type"),
|
|
ContentDisposition: resp.Header.Get("Content-Disposition"),
|
|
ETag: resp.Header.Get("ETag"),
|
|
LastModified: resp.Header.Get("Last-Modified"),
|
|
}
|
|
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
|
if n, err := strconv.ParseInt(cl, 10, 64); err == nil {
|
|
meta.Size = n
|
|
}
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
// AttachmentListParams encodes the query-string filters the
|
|
// `GET /api/v1/workspaces/{slug}/attachments` endpoint accepts. Zero
|
|
// values are skipped — the server falls back to its built-in defaults.
|
|
type AttachmentListParams struct {
|
|
// ItemID restricts to attachments parented by this item UUID.
|
|
// The CLI resolves a TASK-5-style ref to a UUID via GetItem
|
|
// before calling this method.
|
|
ItemID string
|
|
// Item is the legacy attached/unattached enum exposed by the
|
|
// list endpoint. Ignored when empty.
|
|
Item string
|
|
// Category filters by MIME bucket: image|video|audio|document|text|archive|other.
|
|
Category string
|
|
// CollectionID restricts to attachments parented by items in
|
|
// the given collection UUID.
|
|
CollectionID string
|
|
// Sort accepts: size|size_desc|filename|filename_desc|created_at|created_at_desc.
|
|
Sort string
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// AttachmentListResponse mirrors the JSON returned by
|
|
// GET /api/v1/workspaces/{slug}/attachments. Rows are typed as
|
|
// json.RawMessage so the CLI can surface the full shape (including
|
|
// joined item title / slug / collection slug) without re-declaring the
|
|
// store.AttachmentListItem struct here.
|
|
type AttachmentListResponse struct {
|
|
Attachments []json.RawMessage `json:"attachments"`
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
// ListAttachments returns a page of attachments in the workspace,
|
|
// applying any filters set on params. Empty fields are omitted from
|
|
// the query string so the server's defaults take over.
|
|
func (c *Client) ListAttachments(wsSlug string, params AttachmentListParams) (*AttachmentListResponse, error) {
|
|
q := url.Values{}
|
|
if params.ItemID != "" {
|
|
q.Set("item_id", params.ItemID)
|
|
}
|
|
if params.Item != "" {
|
|
q.Set("item", params.Item)
|
|
}
|
|
if params.Category != "" {
|
|
q.Set("category", params.Category)
|
|
}
|
|
if params.CollectionID != "" {
|
|
q.Set("collection", params.CollectionID)
|
|
}
|
|
if params.Sort != "" {
|
|
q.Set("sort", params.Sort)
|
|
}
|
|
if params.Limit > 0 {
|
|
q.Set("limit", strconv.Itoa(params.Limit))
|
|
}
|
|
if params.Offset > 0 {
|
|
q.Set("offset", strconv.Itoa(params.Offset))
|
|
}
|
|
path := "/workspaces/" + url.PathEscape(wsSlug) + "/attachments"
|
|
if encoded := q.Encode(); encoded != "" {
|
|
path += "?" + encoded
|
|
}
|
|
var result AttachmentListResponse
|
|
if err := c.get(path, &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// newRequest creates an http.Request with auth and agent headers set.
|
|
func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
|
|
if err := checkRequestPath(path); err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequest(method, c.baseURL+path, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
if c.agentName != "" {
|
|
req.Header.Set("X-Pad-Agent", c.agentName)
|
|
}
|
|
c.workerOnce.Do(func() { c.workerID = ResolveWorkerID() })
|
|
if c.workerID != "" {
|
|
req.Header.Set("X-Pad-Worker", c.workerID)
|
|
}
|
|
// Marks the CLI's sign-ins so the server issues a CLI session, which is
|
|
// never accepted as a browser cookie (BUG-3350).
|
|
req.Header.Set("X-Pad-Client", "cli")
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) get(path string, result interface{}) error {
|
|
_, err := c.getWithHeader(path, result)
|
|
return err
|
|
}
|
|
|
|
// getWithHeader is get that also returns the response headers (nil when no
|
|
// response arrived).
|
|
func (c *Client) getWithHeader(path string, result interface{}) (http.Header, error) {
|
|
req, err := c.newRequest("GET", path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return resp.Header, c.handleResponse(resp, result)
|
|
}
|
|
|
|
func (c *Client) post(path string, body interface{}, result interface{}) error {
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
bodyReader = bytes.NewReader(data)
|
|
}
|
|
req, err := c.newRequest("POST", path, bodyReader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
func (c *Client) patch(path string, body interface{}, result interface{}) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := c.newRequest("PATCH", path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
func (c *Client) delete(path string) error {
|
|
req, err := c.newRequest("DELETE", path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return c.parseError(resp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteWithResult is delete for an endpoint that answers with a body.
|
|
func (c *Client) deleteWithResult(path string, result interface{}) error {
|
|
req, err := c.newRequest("DELETE", path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return c.handleResponse(resp, result)
|
|
}
|
|
|
|
func (c *Client) handleResponse(resp *http.Response, result interface{}) error {
|
|
if resp.StatusCode >= 400 {
|
|
return c.parseError(resp)
|
|
}
|
|
if result != nil {
|
|
return json.NewDecoder(resp.Body).Decode(result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RateLimitedHint is the guidance a stdio MCP caller receives with a
|
|
// rate_limited envelope (BUG-3147). The limiter differs by route, so the wait
|
|
// is the server's, carried in details, never a figure in this prose.
|
|
const RateLimitedHint = "Rate-limited by the server. Wait details.retry_after_seconds when it is present, " +
|
|
"otherwise back off, then retry; space out burst-heavy work."
|
|
|
|
// IsRateLimited reports whether err is, or wraps, the server's 429 refusal.
|
|
func IsRateLimited(err error) (*APIError, bool) {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Code == "rate_limited" {
|
|
return apiErr, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// TooLargeHint is the guidance an MCP caller receives with a too_large
|
|
// envelope (BUG-2829), on either transport. The cap is the server's and
|
|
// differs by producer, so no figure appears here; the server's message names
|
|
// what was measured and the limit.
|
|
const TooLargeHint = "Refused by a server size cap, not a fault: retrying the same call fails the same way. " +
|
|
"Shrink or split the change (details.reason names which cap), then retry."
|
|
|
|
// IsTooLarge reports whether err is, or wraps, a server 413. Keyed on the
|
|
// STATUS, not the code, because three producers refuse with three codes.
|
|
func IsTooLarge(err error) (*APIError, bool) {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Status == http.StatusRequestEntityTooLarge {
|
|
return apiErr, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// WriteTooLargeError writes the structured marker line for a 413 (BUG-2829),
|
|
// with the server's own code in details.reason, so the stdio MCP transport
|
|
// reports too_large instead of inferring server_error from prose that matches
|
|
// nothing. Written once at the root, like WriteRateLimitedError, because
|
|
// several commands can receive one and none of them owns it.
|
|
func WriteTooLargeError(w io.Writer, apiErr *APIError) {
|
|
body := map[string]any{
|
|
"code": "too_large",
|
|
"message": apiErr.Message,
|
|
"hint": TooLargeHint,
|
|
}
|
|
if apiErr.Code != "" {
|
|
body["details"] = map[string]any{"reason": apiErr.Code}
|
|
}
|
|
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
}
|
|
|
|
// IsPermissionDenied reports whether err is a server 403 refusing the caller:
|
|
// code forbidden or permission_denied, or an unverified Cloud email
|
|
// (BUG-3864). Other 403 codes (plan_limit_exceeded and the like) have writers
|
|
// of their own, and a marker from this one would replace theirs.
|
|
func IsPermissionDenied(err error) (*APIError, bool) {
|
|
var apiErr *APIError
|
|
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusForbidden {
|
|
return nil, false
|
|
}
|
|
switch apiErr.Code {
|
|
case "forbidden", "permission_denied", "email_not_verified":
|
|
return apiErr, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// WritePermissionDeniedError writes the structured marker line for a refused
|
|
// caller (BUG-3864), carrying the server's details.reason, so the stdio MCP
|
|
// transport reports which gate refused instead of inferring permission_denied,
|
|
// or server_error, from the message's wording. No hint: the MCP side builds it
|
|
// from the reason, the same way for both transports.
|
|
func WritePermissionDeniedError(w io.Writer, apiErr *APIError) {
|
|
code := "permission_denied"
|
|
if apiErr.Code == "email_not_verified" {
|
|
code = apiErr.Code
|
|
}
|
|
body := map[string]any{"code": code, "message": apiErr.Message}
|
|
var details struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
if len(apiErr.Details) > 0 && json.Unmarshal(apiErr.Details, &details) == nil && details.Reason != "" {
|
|
body["details"] = map[string]any{"reason": details.Reason}
|
|
}
|
|
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
}
|
|
|
|
// WriteRateLimitedError writes the structured marker line for a 429
|
|
// (BUG-3147), so the stdio MCP transport reports rate_limited — with the
|
|
// server's wait — instead of inferring server_error from prose that matches
|
|
// nothing. Unlike the other writers it prints no human line: every command can
|
|
// receive a 429, so this is written once at the root, after cobra has already
|
|
// printed the "Error: <message>" line a person reads.
|
|
func WriteRateLimitedError(w io.Writer, apiErr *APIError) {
|
|
body := map[string]any{
|
|
"code": "rate_limited",
|
|
"message": apiErr.Message,
|
|
"hint": RateLimitedHint,
|
|
}
|
|
if apiErr.RetryAfterSeconds > 0 {
|
|
body["details"] = map[string]any{"retry_after_seconds": apiErr.RetryAfterSeconds}
|
|
}
|
|
if data, err := json.Marshal(map[string]any{"error": body}); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
}
|
|
|
|
func (c *Client) parseError(resp *http.Response) error {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
err := parseErrorBody(resp.StatusCode, body)
|
|
if apiErr, ok := err.(*APIError); ok && resp.StatusCode == http.StatusTooManyRequests {
|
|
apiErr.RetryAfterSeconds = parseRetryAfterSeconds(resp.Header.Get("Retry-After"))
|
|
}
|
|
return err
|
|
}
|
|
|
|
// parseRetryAfterSeconds reads a Retry-After header in either of its two
|
|
// forms — delta-seconds or an HTTP-date — as whole seconds from now. Zero for
|
|
// an absent, malformed or already-past value, which a caller treats as "no
|
|
// suggestion" rather than "retry immediately".
|
|
func parseRetryAfterSeconds(v string) int {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return 0
|
|
}
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
if n < 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
if t, err := http.ParseTime(v); err == nil {
|
|
if d := time.Until(t); d > 0 {
|
|
return int(d.Round(time.Second).Seconds())
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// parseErrorBody decodes an error response whose body has already been
|
|
// read. Split out of parseError so callers that need the raw bytes for
|
|
// their own purposes (see client_items_copy.go) produce byte-identical
|
|
// error values rather than a second, subtly different vocabulary.
|
|
func parseErrorBody(status int, body []byte) error {
|
|
var errResp struct {
|
|
Error APIError `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" {
|
|
if errResp.Error.Code == "csrf_error" {
|
|
errResp.Error.Message = "Session authentication error. Run 'pad auth login' to re-authenticate."
|
|
}
|
|
errResp.Error.Status = status
|
|
return &errResp.Error
|
|
}
|
|
return &StatusError{Status: status, Body: string(body)}
|
|
}
|
|
|
|
// StatusError is a non-2xx answer whose body was not the server's JSON error
|
|
// envelope: a proxy's page, an empty body, a stub. It used to be a plain
|
|
// fmt error, which kept the status only inside its text, so a caller could
|
|
// not tell a 401 from a network failure (BUG-2706). Error() is the text the
|
|
// plain error had.
|
|
type StatusError struct {
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
return fmt.Sprintf("API error: %d %s", e.Status, e.Body)
|
|
}
|
|
|
|
// HTTPStatus is the HTTP status a request error carries, or 0 when the
|
|
// request got no answer at all (the server was unreachable) or the error did
|
|
// not come from an HTTP answer.
|
|
func HTTPStatus(err error) int {
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
return apiErr.Status
|
|
}
|
|
var statusErr *StatusError
|
|
if errors.As(err, &statusErr) {
|
|
return statusErr.Status
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// --- Item reminders (IDEA-2641) ---
|
|
|
|
// ListItemReminders returns every reminder on an item, armed or fired.
|
|
func (c *Client) ListItemReminders(wsSlug, itemSlug string) ([]models.Reminder, error) {
|
|
var result struct {
|
|
Reminders []models.Reminder `json:"reminders"`
|
|
}
|
|
if err := c.get("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/reminders", &result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result.Reminders, nil
|
|
}
|
|
|
|
// CreateItemReminder arms a reminder. remindAt must be an RFC3339 instant —
|
|
// the server refuses a bare date rather than assuming a time of day, and the
|
|
// CLI passes the user's string through so that refusal reaches them with the
|
|
// server's wording rather than a second, differently-worded local one.
|
|
func (c *Client) CreateItemReminder(wsSlug, itemSlug, remindAt string) (*models.Reminder, error) {
|
|
var result models.Reminder
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/items/"+url.PathEscape(itemSlug)+"/reminders",
|
|
map[string]string{"remind_at": remindAt}, &result)
|
|
}
|
|
|
|
// RearmReminder moves a reminder's instant, clearing its fire marks.
|
|
func (c *Client) RearmReminder(wsSlug, reminderID, remindAt string) (*models.Reminder, error) {
|
|
var result models.Reminder
|
|
return &result, c.patch("/workspaces/"+url.PathEscape(wsSlug)+"/reminders/"+url.PathEscape(reminderID),
|
|
map[string]string{"remind_at": remindAt}, &result)
|
|
}
|
|
|
|
// AckReminder acknowledges a fired reminder.
|
|
func (c *Client) AckReminder(wsSlug, reminderID string) (*models.Reminder, error) {
|
|
var result models.Reminder
|
|
return &result, c.post("/workspaces/"+url.PathEscape(wsSlug)+"/reminders/"+url.PathEscape(reminderID)+"/ack", nil, &result)
|
|
}
|
|
|
|
// DeleteReminder disarms a reminder by removing it.
|
|
func (c *Client) DeleteReminder(wsSlug, reminderID string) error {
|
|
return c.delete("/workspaces/" + url.PathEscape(wsSlug) + "/reminders/" + url.PathEscape(reminderID))
|
|
}
|
|
|
|
// MyInvitation is one pending invitation addressed to the signed-in account
|
|
// (GET /api/v1/me/invitations, BUG-2136).
|
|
type MyInvitation struct {
|
|
ID string `json:"id"`
|
|
Role string `json:"role"`
|
|
WorkspaceSlug string `json:"workspace_slug"`
|
|
WorkspaceName string `json:"workspace_name"`
|
|
WorkspaceOwnerUsername string `json:"workspace_owner_username"`
|
|
InvitedByName string `json:"invited_by_name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
// ListMyInvitations returns the pending invitations addressed to the caller,
|
|
// and whether the caller's email is verified (only a verified email is
|
|
// matched).
|
|
func (c *Client) ListMyInvitations() ([]MyInvitation, bool, error) {
|
|
var body struct {
|
|
Invitations []MyInvitation `json:"invitations"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
}
|
|
if err := c.get("/me/invitations", &body); err != nil {
|
|
return nil, false, err
|
|
}
|
|
return body.Invitations, body.EmailVerified, nil
|
|
}
|
|
|
|
// AcceptMyInvitation accepts a pending invitation by id and returns the role
|
|
// held and the workspace slug.
|
|
func (c *Client) AcceptMyInvitation(id string) (role, workspaceSlug string, err error) {
|
|
var body struct {
|
|
Role string `json:"role"`
|
|
WorkspaceSlug string `json:"workspace_slug"`
|
|
}
|
|
if err := c.post("/me/invitations/"+url.PathEscape(id)+"/accept", nil, &body); err != nil {
|
|
return "", "", err
|
|
}
|
|
return body.Role, body.WorkspaceSlug, nil
|
|
}
|
|
|
|
// DeclineMyInvitation declines (deletes) a pending invitation by id.
|
|
func (c *Client) DeclineMyInvitation(id string) error {
|
|
return c.post("/me/invitations/"+url.PathEscape(id)+"/decline", nil, nil)
|
|
}
|