mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 23:15:40 +00:00
b437cc582d
* feat(store): item reminders — the fire-at-an-instant primitive (IDEA-2641) Adds the storage, the scheduler tick, and the canonical event for one-shot item reminders (GitHub #1010). Nothing in Pad acted at a target time before this: a due_date makes an item show up as overdue once somebody asks the dashboard, so "revisit TASK-X on the 1st" had to live in an external cron. A TABLE, NOT A SCHEMA-FIELD ANNOTATION. The design sketch proposed marking schema date fields with a `reminds: true` key on models.FieldDef; recon overturned it. Such a key does not survive an ordinary collection edit, two independent ways: the web editor destructures each field into an EditableField and rebuilds a fresh definition key-by-key on save, so unknown keys are dropped (`pattern` and `unique_scope` survive only because two lines were hand-added for them), and models.CollectionSchema has fixed fields with no catch-all, so any Go unmarshal+marshal round-trip strips unknown properties — the hazard retargetRelationFieldsTx mutates raw JSON to avoid. Both failures are silent and both disarm a whole collection's reminders at once. It is the same defect class that moved traits out of the schema column in TASK-2657. The table also gives the lifecycle a home. A reminder is armed, then fired, then acknowledged, and a re-arm returns it to armed — per-reminder state a field definition has nowhere to keep. remind_at is an RFC3339 UTC instant, deliberately not a `date` schema value: those admit both YYYY-MM-DD and full RFC3339 and are compared against the SERVER'S LOCAL calendar day. A fire-at time cannot carry that ambiguity. The remaining timezone question for due_date is filed separately. Firing is one transaction per reminder carrying BOTH the fired_at write and the outbox insert. That pairing is the point: a fired_at committed without its event is a reminder that silently notifies nobody and can never be retried, because the row has left the armed set; an event without fired_at fires every tick forever. The UPDATE's own `fired_at IS NULL` predicate is the arbiter, so two instances ticking at once produce exactly one winner. item.reminder_due is admitted to the closed events/1 set as v1.2, with a new PayloadReminder family and no SSE name. The subject is the REMINDER, not the item: two reminders can be armed on one item, so an item-subject event could not say which fired, and the reminder id is what an acknowledgement addresses. A new payload family rather than reusing the item snapshot for the same reason — a snapshot would validate and still not answer the only question the event exists to answer. No SSE name in v1 because the poll surface is the contract; adding one later is additive, removing one is not. Ack is explicit and nothing else acks. An item reaching a terminal status deliberately does NOT ack: that would make every status write a reminder mutation, and it would silently consume a reminder set to fire after the work was done. * feat(server): reminder surfaces, and one shared overdue rule for all four Second half of IDEA-2641: the HTTP surface, the scheduler tick's wiring, and the fix for the finding that justified the unit — `ready` / `next` did no date handling at all. OVERDUE NOW HAS ONE IMPLEMENTATION. It used to live inline in the dashboard's attention loop, which meant `pad project stale` inherited it (it filters that very list) and the recommendation surface never saw it. So a deadline reached the two surfaces that REPORT on work and never the one an agent PULLS from. overdue.go is now the only place that decides, and all four call it. Two behaviour changes fall out, both deliberate: - An overdue item bypasses the orphan branch's high/critical priority gate. That gate was where a deadline quietly stopped: a low-priority item three weeks late was reported by `stale` and never suggested by `next`. - Overdue sorts above in-progress. The list is capped at three, so a rank below in-progress would not merely order the deadline lower — on any workspace with three things in flight it would keep an overdue item off the surface entirely, which is indistinguishable from not shipping this. The server-local-today comparison is UNCHANGED and known to be wrong for multi-timezone deployments; it is filed as its own item with the cloud case stated. Changing what "overdue" means on every existing instance inside a change about where the rule LIVES is the kind of behaviour change nobody reviews. Fired reminders reach `next` / `ready` two ways, from one filtered list: PendingReminders is the addressable form (it carries the id an ack needs), and a prepended suggestion is the rendered form. They are prepended AFTER the cap rather than entered as ranking candidates — a reminder is not a task competing on priority, and whether it appeared should not depend on how busy the workspace is. Terminal-item reminders are FILTERED from the surface, never acked. Acking on terminal status would couple every status write to reminder state and would consume a reminder armed to fire after the work was done. The row stays exactly as the user left it; the distinction is observable, and asserted. Three guard tests caught this change and each was answered rather than silenced: - The request-body reader guard was right: the handlers now go through decodeJSON, inheriting the NUL refusal and the size cap. - The canonical-events guard was right: item.reminder_due is admitted to the duplicated contract table as SPEC-3 v1.7, with the reminder subject kind and the new payload family. SPEC-3's own text owes the same amendment. - The NUL census asked for a decision on eight new columns. None carries caller text: ids and FKs are server-generated, four are the server clock, and remind_at is now re-parsed and re-formatted in the STORE as well as at the edge — so the stored value is always machine-produced from a parsed time and no caller bytes reach the column. The doc comment that used to say "the caller normalizes" protected nothing. Regenerating the baseline also found that GEN_NUL_BASELINE=1, which the test's own instructions name, was never implemented — the flag did nothing, so the documented path was hand-editing the file. Implemented, so the next reader gets the mechanism the instructions promise. * test(reminders): the lifecycle, the four surfaces, and 22 killed mutants Every test here was designed against a specific mutation and the mutation was RUN. A green suite proves nothing about a suite nobody tried to break, and three of the mutants I first wrote were not experiments at all. Store (10 mutants, all killed): candidate predicate <= flipped to >=; the event emission lifted out of the fire transaction; the fire UPDATE's `fired_at IS NULL` arbiter removed; the RowsAffected check ignored; re-arm clearing fired_at but not acked_at; ack losing `fired_at IS NOT NULL`; the poll surface losing `acked_at IS NULL`; normalizeRemindAt no longer refusing; it dropping .UTC(); GetReminder losing its workspace scope. Surfaces (12, all killed): the priority gate no longer bypassing on overdue; the sort no longer ranking overdue first; attention leaving the shared helper; the reason losing its OVERDUE prefix; the comparison flipped to >; terminal items no longer skipped; terminal reminders no longer filtered; the filter ACKING instead of hiding; reminders appended instead of prepended; the tick running on a far-future clock; ack answering 200 for an unfired reminder; parseRemindAt accepting a bare date. THREE MUTANTS DID NOT COUNT ON THE FIRST PASS and were rewritten. Two failed to compile (`if false` orphaned a variable; deleting a parse orphaned an import) and one had an anchor matching two call sites. A non-compiling mutant emits zero FAIL lines and reads exactly like a surviving one — it invents a hole that is not there — so the harness reports BUILD-FAIL and ANCHOR-BAD as outcomes distinct from SURVIVED. It also restores files from an in-memory copy rather than `git checkout`, which would delete uncommitted work in the tree. ONE MUTANT GENUINELY SURVIVED and the test was at fault, not the mutant: appending rather than prepending reminder suggestions was undetectable because the fixture had a single item, so the reminder sat at index 0 either way. The fixture now fills the three-item cap with in-progress work, where an appended reminder lands fourth and vanishes. Faithful mutant, weak test — checked in that order. The same lesson shapes the four-surface fixture: it is a LOW-priority open orphan, because that is the case the old code handled worst. A high-priority task would have made the ready/next leg pass against the unfixed tree, which is a green that measures nothing. Negative controls throughout: a future deadline is not overdue and does not reach the gate bypass; a tick with nothing due fires nothing; a completed item is neither overdue nor suggested. Without them a helper that reported every date, or a tick that fired everything, would satisfy every positive leg. The lead's pin is asserted in both directions: a fired reminder on a done item is ABSENT from the surface and PRESENT and still unacknowledged in the table. Asserting only the absence would pass against an implementation that consumed the row, which is the behaviour the pin exists to forbid. * feat(mcp): pad_item.remind + ack-reminder, ToolSurfaceVersion 0.28 An agent that can RECEIVE a reminder but not set one has half the primitive. The poll surface is pad_project.next / ready, both long exposed, so reminders already reached agents — what was missing is the other half: deferring a piece of work is exactly the moment an agent knows when it wants to be asked again, and it had no way to say so. Two additive actions, two optional params. Nothing existing moved, so a v0.27 consumer enumerating neither is unaffected — the v0.13 / v0.11 / v0.8 disposition, which likewise wired existing CLI verbs onto the catalog. remind_at REFUSES a bare date rather than reading it as midnight. Worth stating because the `date` schema type accepts YYYY-MM-DD and a caller will reasonably try it here: a bare date names a 24-hour span, and choosing an hour inside it would fire at a time nobody picked. Re-arm and disarm stay CLI-only. Both address a reminder by an id the agent would have to list first, and no listing action exists on this surface — a door with no handle. Adding them later is additive. Five guards had to be taught, and each was answered on its merits rather than excluded: the HTTP parity test (route mappers added, so the actions work on the remote transport rather than being advertised and unrouted), the read-only catalog's cmdhelp fixture and expected cmdPath map, the field- conflict classifier (remind_at / reminder_id are NOT field writers — a reminder is a row in its own table addressed by its own id, so listing them as classified sources would have pointed detectFieldConflicts at something that is not a field source), and the instructions.md / README action tables. That machinery is why the version bump is safe to make now, and it earned its keep on this change: every one of the five failed on the first build after the catalog entry landed. CONVE-23 sweep for prose this falsifies: - SPEC-3 (DOC-2653) amended to v1.7 in the room, recording item.reminder_due with its new subject kind and payload family — the first canonical event with no user mutation behind it, since a scheduler tick produces it. - CLAUDE.md gains the reminder routes, the CLI verbs, and the v0.28 entry. It was also stale at 0.26 with NO v0.27 entry at all: the 0.27 unit swept instructions.md and README.md and missed this file. Both added. - skills/pad/SKILL.md gains the verbs and a routing entry, including the two things an agent will get wrong — the time is an instant, so ask for a time of day rather than picking one, and finishing the item does not acknowledge the reminder. * fix(reminders): codex round 1 — four findings, all real, all with a pin Round 1 found four defects and refuted none of them. Each fix carries a test that fails against the code as it was, and each of those was mutation-checked. **P1 — pending reminders bypassed item-level visibility.** Every other dashboard section reads `allItems`, which the store already scoped to the caller's collections AND their granted item ids. The pending-reminder list is a direct workspace-wide query and inherited none of that, so a guest holding a grant on ONE item could read the refs and titles of every other item in the collection through its reminders — an item-level leak wearing a notification's clothes. Now filtered with the same `isItemVisibleToGuest` call the sibling sections use. The test's two items share a COLLECTION on purpose: a collection-level filter was already applied, so separate collections would have made it pass against the unfixed code. **P1 — soft-deleted items could starve the queue permanently.** Candidate selection ignored `deleted_at`, and `fireOneReminder` rolls back when it finds the item gone — which leaves the reminder ARMED and therefore a candidate again on the next pass. Candidates are ordered oldest-first and bounded by a limit, so enough archived reminders fill every batch and no live reminder ever fires. Silent, too: the tick reports zero fired and looks idle. Excluded in the candidate query rather than skipped downstream, so those rows never occupy a slot; the reminders themselves are kept, so restoring an item restores its reminder with it — asserted, because a fix that reaped them would pass the starvation test alone. **P2 — the pass stopped at the first failing reminder.** The per-reminder transaction exists precisely so one unfireable row cannot hold back the rest, and `return fired, err` made that comment false — with candidates oldest-first, one persistently broken old reminder blocks every newer one forever. Now continues and joins the errors, so a pass that fired seven and failed three reports both halves rather than reading as clean. The loop is split behind an injected seam because a real mid-transaction failure is not reachable from outside: the database refuses the corrupt rows that would cause one (verified — invalid JSON in items.fields is rejected by the schema). **P2 — suggestions dropped the reminder id.** The docs tell an agent to acknowledge what it sees in next/ready, and the payload carried no handle: a stateless poller could read the reminder and had no way to retire it, so it would be shown the same item forever. `DashboardSuggestion` now carries `reminder_id` (omitempty), `pad project next` prints the exact ack command, and the test acks with the id the surface handed out rather than merely checking the field is populated — a wrong-but-present id satisfies equality with itself. Four mutants, four killed; one was rewritten first because its anchor matched two call sites and was therefore not an experiment. * fix(reminders): codex round 2 — four findings, all real **`--rearm` was unusable.** `ExactArgs(1)` forced an item ref that the rearm branch then ignored, so the flag could not be reached without supplying a ref that was silently discarded. Now `MaximumNArgs(1)`, with each mode checked explicitly: a ref is required to arm, and a ref supplied ALONGSIDE `--rearm` is refused rather than ignored — it names an item the reminder may not even belong to, and quietly dropping it is how a user learns nothing about the reminder they just moved. **`unremind --format json` emitted plain text**, breaking the parseable-output contract every sibling command honours. **The MCP `ref` param did not list `remind`.** Agents read that flat description to decide what to send, so an action missing from it is an invalid call waiting to happen. It now also says what `ack-reminder` takes instead, and why: a reminder is addressed by its own id because an item can carry several. **Fractional seconds fired early.** `time.Parse` accepts `09:00:00.900Z` and `Format(RFC3339)` drops the fraction, so it was stored as `09:00:00Z` and fired 900ms BEFORE the moment the caller named — silently, having rewritten their value on the way in. Seconds are genuinely the stored resolution (the column is compared as a string against a whole-second clock, and the tick runs every 30s), so the only question was which way to resolve it, and truncation resolved it the wrong way. `NormalizeInstant` now rounds UP: at most a second of lateness, in exchange for a guarantee that can be stated — a reminder never fires before the instant it was set for. Late is a reminder; early is a wrong answer. Whole seconds round-trip exactly, which is asserted, because an implementation that added a second unconditionally would otherwise pass. Three mutants for this round, three killed (round-up→truncate, round-up→unconditional-add, MaximumNArgs→ExactArgs). Thirty across the unit. Two fixes carry no dedicated test and it is worth being explicit rather than implying coverage: the `--format json` branch on `unremind` is a one-line output change with no server-free way to drive it, and the MCP `ref` description is prose the drift tests do not read — they assert an action is DOCUMENTED, not that a param's sentence lists it. * docs(reminders): the ack id is on the surface an agent polls, not only on the arm response CONVE-23 follow-through on the round-1 fix. Both agent-facing docs told a caller to acknowledge a reminder with the id "returned when you armed it" — true, and useless to the caller that matters: a poller reading next/ready never armed anything. The suggestion now carries reminder_id and `pad project next` prints the exact ack command, so the docs say that instead. The prose was written before the fix existed, which is exactly the case CONVE-23 is about: a change that makes an instruction stale without touching the file the instruction lives in. * test(reminders): bind the tick LOOP to the work, not just the pass (CONVE-19) Every other test in this file calls runReminderTick directly. That vouches for the component and says nothing about whether anything ever calls it — a tick that is never started is indistinguishable, from those tests, from one that is. It is the convention's exact case, and the failure I recorded on my own identity doc three times in one unit: I test the component and not the binding. Driven through the injectable tick channel so the assertion pins a SPECIFIC pass instead of racing a 30-second ticker, and polled to a bounded deadline so a loop that never runs FAILS rather than hanging the suite. Mutant: drop `s.runReminderTick()` from the select and this goes red while every direct-call test stays green. Killed. The idempotence leg exists because a second Start spawning a second loop would leave one running after Stop, making the BUG-842 drain invariant false for this sweeper specifically — the one property a copied lifecycle is most likely to get right by accident and least likely to be checked. The cmd/pad call site (cmd_server.go, alongside StartTokenReaper) stays verified by inspection: a source-scanning guard for it would be an instrument asserting facts about source, which is code with an adversary and not worth it for one line that sits in the middle of five identical neighbours. * fix(reminders): codex round 3 — a deferred reminder fired anyway, and the poll surface was unbounded **A re-arm mid-pass did not stop the fire.** The candidate scan selects an id; before the UPDATE runs, a `--rearm` can move that reminder into the future. Re-arm clears `fired_at`, so a predicate checking only `fired_at IS NULL` still matched — the pass fired a reminder the user had just deferred and emitted its event. The re-arm cannot undo that: it can clear the mark, but the event is already on the outbox and at-least-once means a consumer has seen it. The fire UPDATE now revalidates `remind_at <= nowTS` against the SAME nowTS the candidate scan used. Same-value deliberately: the arbiter and the scan must agree about when this pass is, or a reminder could pass one and fail the other for no reason but clock drift inside a single pass. **The poll surface was unbounded.** Every fired-and-unacknowledged reminder was loaded and turned into a suggestion prepended to a list that is otherwise capped at three, so a workspace with five hundred unacknowledged reminders returned five hundred suggestions — in the dashboard response, the hottest read in the product, growing until somebody acknowledged them. Two bounds, because they are two different guarantees: the query takes a window (default 50, oldest-fired first, so it holds what has waited longest), and the prepended suggestions are capped at 5 so `suggested_next` stays a recommendation rather than a second inbox. The full set stays addressable in `pending_reminders`. Truncation is REPORTED as a boolean, not a count. A count would have to be post-visibility-filter to be true for the caller reading it, and the store cannot compute that — the filter runs per item, above. "There are more than you can see here" is the strongest claim the data supports, so it is the one made. Four mutants; two killed outright, two survived and were run down under CONVE-28: - **Uncapped suggestions survived because the fixture had ONE reminder** — capped and uncapped are the same list at n=1. That is the SECOND time a single-item fixture hid a count-or-order property in this file. Fixture now arms eight; it also asserts all eight remain in `pending_reminders`, so the cap is pinned to the recommendation and not to the data. - **Removing the SQL LIMIT survived, correctly, and the test comment now says so.** The Go slice cap bounds the PAYLOAD; the SQL LIMIT bounds the DATABASE'S work. Only the first is observable at this level — with the LIMIT gone the response is still bounded, while the query silently goes back to materialising every pending row before discarding most of them. That is a memory and I/O property with no assertion available here, so it is stated as a coverage boundary rather than papered over with a green that would not have measured it. * docs(reminders): the fire predicate arbitrates against two actors, not one CONVE-23 inside the file the round-3 fix touched. The comment described the UPDATE as an arbiter for concurrent TICKS, which is what it was written for and is why I did not re-read it when asked whether a user edit could race the pass. It now says what it actually defends against, and names the general shape: an arbiter is only an arbiter with respect to the writers it can see. * fix(reminders): codex round 4 — the round-3 bound recreated the round-1 starvation Round 3 bounded the poll surface. Round 4 caught what that bound did: the query took the first N rows and the dashboard then discarded the ones it could not show — hidden items, unauthorised items, completed items — so N such rows hide a visible reminder behind them indefinitely, with no continuation to reach it. That is the SAME defect I had removed from the fire path one round earlier, reintroduced in the read path within the hour. The general form is worth stating because I clearly did not hold it: **a bounded window is only safe when the discarding happens BEFORE the bound.** Filtering above a limit is a starvation every time, and it does not matter what the filter is for. Two halves, because the two filters are not the same kind of thing: **Visibility is now scoped IN SQL**, using the same collection-id / item-id sets every other dashboard section gets through `allItems` — the same three-way shape as ItemListParams, where holding both collection grants and item grants is an OR. Invisible rows no longer occupy the window at all, which is strictly better than filtering them out afterwards and is what the sibling sections have always done. **Terminality is paged**, because SQL cannot evaluate it — a collection's schema defines which statuses are terminal. The collector refills from the next page when a page comes back short, bounded by a max scan so a workspace full of completed items cannot turn a dashboard read into a table scan. The bound is 10x the window: the common shape fills on the first page, and the pathological shape terminates in a fixed number of indexed reads. Stopping at the scan bound reports truncation, which is honest — there may be more, and we did not look. The empty-scope case is a THIRD state that reads like the second: nil CollectionIDs means unrestricted, a non-nil EMPTY slice means this caller sees no collections. Without an explicit guard they collapse, because the switch matches none of its cases at length zero and adds no clause at all — so "nothing visible" would return the whole workspace. Three mutants, one survived: the empty-scope guard, because no dashboard-level test produces that state (callers that would are refused earlier by workspace access). Faithful mutant, missing test — it now has a direct one, with a sanity leg so a build returning nothing cannot pass it by accident. A guard for a state nothing exercises is exactly the one that rots. * fix(reminders): codex round 5 — the MCP action I shipped did not work over stdio **P1: local stdio MCP `remind` was unusable.** cmdhelp derives positionals by regex from a command's `Use` string, and `<instant>` inside `remind <ref> --remind-at <instant>` matched — it became a second REQUIRED positional, so dispatch failed with `missing required argument "instant"`. The action was advertised on a transport where it could not run. **The MCP catalog's own tests did not catch it, and the reason is the finding.** That suite builds its cmdhelp document BY HAND: I wrote `Args: mkArgs("ref")` in it, so the fixture agreed with what I meant rather than with what the CLI says. Five parity and drift tests passed against a document I authored to match my own intention — the "a test that agrees with whatever the table says is not a test of the table" shape, which the canonical-events test warns about in its own comment two packages away. The new test reads the REAL command tree via cmdhelp.Build, which is the only thing in this repo that can disagree with me about what the CLI declares. **P2: `pad project ready` withheld the ack handle** that `next` prints. Showing a fired reminder on the surface an agent polls while withholding the id it needs to retire it means the same entry comes back on every poll, forever. **P2: suggestions asserted a collection they did not have.** The orphan branch admits ANY collection — its own comment claimed it gated on tasks "mirroring the active-plan branch", and that comment was simply false — while the output hardcoded `Collection: "tasks"` and the reason said "Open task". Pre-existing for high-priority items since BUG-1082; my overdue bypass widened it to any overdue item, which is how it surfaced. Fixed by carrying the item's REAL collection rather than by narrowing the branch: narrowing would silently drop the non-task items this has surfaced for a year, and the defect is the mislabelling, not the inclusion. The false comment is replaced with what the code actually does. The first version of that test used an overdue IDEA and SKIPPED — ideas use `new`, and the branch requires `open` or an active status, so it never became a candidate. A test that cannot fire is a failed reconstruction, not a pass; the fixture is now a bug-like collection whose vocabulary contains `open`, which is the population the defect can actually reach. Three mutants, three killed. Forty-one across the unit. * fix(reminders): codex round 6 — reminders fired from soft-deleted workspaces **P1, and the only defect in this unit whose consequence leaves the process.** Workspace soft-delete deliberately keeps items for the 30-day restore window, so the candidate query's filter on the ITEM's deleted_at found nothing wrong — and the tick kept firing, emitting outbound webhook events for a workspace whose owner had deleted it, possibly while deleting their account. Both queries now join workspaces and require `w.deleted_at IS NULL`. Nothing is destroyed: a restored workspace resumes firing, which the test asserts, because "stops firing" and "is destroyed" are very different answers to someone who restores a workspace and only one of them is right. That test first failed for the WRONG REASON and the fixture was at fault: it counted every outbox row in the workspace, and item creation writes its own, so the assertion was satisfiable by the fixture itself and discriminated nothing. Scoped to the reminder event type. **`Use: "remind <ref>"` declared a requirement the command contradicts.** cmdhelp derives the machine-readable arg spec from that string, and `--rearm` takes no ref — so the published contract said "required" for something optional. The requirement is CONDITIONAL, which cmdhelp cannot express, so the honest declaration is `[ref]` plus the explicit check that names both call shapes. The round-5 test grew a `required` column, which is what makes this observable at all: asserting only the arg NAMES would have passed. **The pad_item tool description omitted both new actions.** The params were declared and the actions dispatched, but the prose an agent reads to decide what a tool can do did not mention them — discoverable only by someone who already knew to look. It now describes both, including the two things an agent gets wrong: remind_at is an instant, and nothing but an explicit ack retires a fired reminder. Three mutants, three killed. Forty-four across the unit. * fix(reminders): codex round 7 — one predicate for the scan and the arbiter Third instance of one class, so this fixes the SHAPE rather than the instance. The class: the candidate scan filters on something the fire transaction does not revalidate, so a change committed between them fires a reminder that no longer qualifies. Round 3 was a re-armed instant. Round 1's soft-deleted item was the same thing caught from the other side. Round 7 is a workspace deleted between the scan and the fire — the round-6 fix added the condition to the SCAN only, and the arbiter went on not knowing about it. Fixing those one at a time is what let the third happen. `reminderFireable` is now a single string that both sites reference: the scan asks it and the fire UPDATE re-asks it, so they cannot disagree, and a fourth condition is one edit in one place rather than two edits someone has to remember are paired. Written as a correlated EXISTS on item_reminders.item_id rather than a JOIN precisely so the identical text is valid in both a SELECT and an UPDATE, and the scan drops its table alias so the two uses are the same characters. What deliberately stays outside it: `fired_at IS NULL` and `remind_at <= ?` live on the reminder row itself, are already spelled identically at both sites, and folding them in would need a parameter order the shared form cannot express. Said in the comment so the omission reads as a decision. Both directions are now tested at the arbiter — a workspace deleted mid-pass and an item deleted mid-pass — because the item case previously relied on the item load coming back nil, and someone simplifying the EXISTS down to the workspace check alone would otherwise still see green. Three mutants, three killed: the arbiter dropping the shared predicate, and the predicate dropping each of its two halves. Forty-seven across the unit. * fix(reminders): codex round 8 — workspace export silently dropped every reminder WorkspaceExport is a hand-maintained field list, so a new table joins it only if someone remembers. Reminders did not: a backup/restore, or a SQLite→Postgres migration via `pad db migrate-to-pg`, dropped every pending reminder with nothing in the destination to show anything had gone. The line that list has always drawn is item-scoped workspace CONTENT (comments, links, versions — exported) versus per-user state (stars, watches — not). A reminder has no user column and hangs off an item, which puts it on the exported side. Stating the rule rather than just adding the field, because the next person adding a table needs to know which side they are on. LIFECYCLE MARKS ARE CARRIED, not reset. A fired-and-unacknowledged reminder is still owed to whoever armed it, so it arrives pending; an armed one whose instant has passed fires once on the destination's first tick, which is what would have happened had the workspace never moved. Re-arming everything on import would invent a schedule the user did not set. NULL rather than empty string for the unset marks — the lifecycle is defined by NULL-ness, and "" would make a never-fired reminder read as fired at "". TestMigratedTablesCoversTheExport caught the second half, which I would have missed: `pad db migrate-to-pg`'s NUL preflight decides what to REFUSE on from MigratedTables, so a table the migration copies and the preflight does not know about is a gap in exactly the guard that exists to prevent one. Added there too, with the reason it can never actually fire — every column is machine-produced, so it is listed for coverage rather than expectation — and the "six tables" prose it falsified is now seven. Two mutants, two killed: export dropping the block, and import discarding the marks. Forty-nine across the unit. * test(reminders): state the fire-path invariant and pin it from the invariant The lead's read on why rounds 4 and 7 were the same class: the fire path had no stated invariant, so each fix defended an instance. This states it, and derives the pin from the paragraph rather than from the bug history. THE INVARIANT: the candidate scan is a hint and may be assumed to prove nothing. Every condition that made a row a candidate is re-asserted inside the transaction that marks it fired, in the same statement that does the marking, so checking and writing are one atomic act. Worded as "the scan proves nothing" rather than as a list on purpose — a list invites the next person to add a condition to the scan and stop, which is exactly what happened four times here. TestFirePathInvariant is the pin: one table, one row per scan-side condition, each invalidating that condition in the window between the scan and the fire and asserting the same three things — nothing fires, no event leaves, the reminder is not consumed. The earlier per-defect tests are folded in as rows; they said the same thing one instance at a time, which is how four of these shipped. Adding a fifth condition to the scan without a row here should feel like an omission. It carries a positive control, because four cases that all assert nothing happens would pass against a build that never fires at all. The matrix immediately falsified a claim in the paragraph I had just written. I wrote that the item load inside the transaction is "for the payload, not for the check"; removing the item half of reminderFireable alone changes no observable behaviour, because the load then returns nil and the deferred rollback undoes the write. Item liveness is defended TWICE and a single-mutant experiment cannot say which guard is carrying it — removing both is what kills the test. Both are kept, the predicate is named as primary (the row never matches, so no write happens at all), and the asymmetry is stated: workspace liveness has no second line, which is why dropping ITS half does fail the pin. Six mutants: five singles plus the pair. Five killed alone; the item single survives by design and is documented as such rather than left as an unexplained green. Fifty-five across the unit. * fix(reminders): codex round 9 — one legacy row could hide every reminder **P1: items.item_number is NULLABLE and I scanned it into an int.** Migration 006 added the column to existing rows, so a pre-numbering item still carries NULL — and scanning NULL into an int fails the Scan, which fails the QUERY, which degrades the whole pending-reminder section. One old row, and the feature is dark for everyone in that workspace. ListWatchesForUser, which this query was modelled on, uses sql.NullInt64 for exactly this column. I copied its shape and dropped the part that handles the column's actual nullability — the same way of being wrong as the round-5 cmdhelp fixture: borrowing a form without borrowing what it knows. The legacy row now carries no ref rather than a fabricated "PREFIX-0", which would name a different item. **P1: export shipped reminders that import could only discard.** The items section filters on deleted_at IS NULL, so a soft-deleted item is not in the bundle and its reminder can never be reunited with it. My comment claimed the item_links rationale — round-trip the raw graph so a restore reunites them — which is true for links and false here, because links keep soft-deleted endpoints in the bundle and items do not. A link is a row ABOUT two items; a reminder whose item is absent is a dangling schedule. **P2: import wrote remind_at raw.** Import is a writer, and a bundle is not necessarily one this server produced — hand-edited, or from another instance. A local offset or a bare date would land in the one column every comparison downstream treats as a UTC instant, firing early, late, or never. It now normalizes like every other door. An unparseable value is SKIPPED with a warning rather than failing the restore, matching the lenient import-side precedent already in this file, and the raw value's LENGTH is logged rather than its content. Three mutants, three killed; two needed rewriting because the single-line form did not compile — reverting the nullable scan also requires reverting the render, and dropping the normalization orphans a variable. PROCESS FAULT, recorded because it makes this round's findings weaker than they look: I edited the tree while this review was reading it — committed the invariant work and ran five mutation experiments, which write and restore source, over the same files. A review binds to the tree it read and I moved it underneath. Every finding above was re-verified against the current tree before being acted on, and the next round runs with no concurrent edits. * fix(reminders): codex round 10 — one orphaned item aborted a whole restore An ORPHANED item — one whose collection is missing from the bundle — still gets an itemMap entry. It has to: the entry is written before the skip because parent resolution inside the same loop reads the map for items it has not reached yet. So `itemMap[x] != ""` is satisfied by an id that names no row, and inserting a foreign key to it fails (SQLite enforces FKs here via the DSN's `_pragma=foreign_keys(on)`; Postgres always does). The pre-existing mapping is the sharp edge. The aggravating half was mine: this loop treated a failed reminder insert as FATAL, where item_links and item_versions both skip, so one orphaned item carrying a reminder rolled back an entire 900-item workspace restore. A reminder is the least critical thing in a bundle and it had the strictest failure handling in the file. Both halves fixed: the loop gates on items that actually landed, and a failed insert warns and skips like its siblings. TWO GUARDS THAT ONLY DIE TOGETHER, and this is measured rather than assumed. Reverting either alone leaves the test green — with the map gate restored the skip survives the FK failure, and with the fatal return restored the gate means the insert never fails. Removing both is what fails it. They are kept as a pair because they defend the same failure at different depths (prevent the bad write / survive a bad write arriving some other way), and the pair is recorded in the code so a future reader does not delete one as dead after watching its mutant survive. Second time this shape appeared today; the first was item liveness on the fire path. The bundle in the test is hand-built, because ExportWorkspace cannot produce an orphan — which is the reason it needed a test. That shape only arrives from a hand-edited or foreign bundle, and surviving those is what import is for. Three mutants: two singles that survive by design, plus the pair that kills. Sixty-one across the unit. * fix(reminders): codex round 11 — four contract slips, one of them another unit's **suggested_next returned up to eight entries against a cap of three.** Round 3 prepended reminders PAST the list's own cap, reasoning they should not compete for slots. Every consumer — the web dashboard, `pad project next`, `pad project ready` — is written for three. Worse, it silently falsified a decision recorded elsewhere: BootstrapDashboard deliberately has no suggested_next_overflow_count BECAUSE this list is capped at three upstream, and its comment names raising that cap as the moment to add one. My change made another unit's reasoning wrong in a file I never opened. The combined list is now trimmed back to three, reminders still leading — a reminder can push a task suggestion out, which is the right way round, and the full set stays addressable in pending_reminders. My first version of that trim used `limit`, which is REASSIGNED above to len(candidates) — so on a workspace whose only entries are reminders it would have truncated to zero, killing precisely the case the surface exists for. Caught by reading the surrounding lines before running anything; it has its own test now. **pending_reminders was uncapped in the bootstrap projection.** BootstrapDashboard embeds *DashboardResponse, so every new field joins the boot payload automatically — here, a window of up to 50, which is the budget PLAN-1410 spent a unit trimming. Capped at 5 with an overflow count, under its own constant rather than borrowing bootstrapAttentionCap: they answer different questions and a future change to one must not silently move the other. **Truncation was reported from the wrong question.** The collector used the store's `more` flag, which answers "is there another PAGE", not "did I read all of THIS one" — so a window filling part way through the final page reported that the caller had seen everything while unread rows sat behind the fill point. The paging bounds are now injectable so the case is testable at all: building it with a window of 50 needs ~75 rows in a specific pattern, with a window of 3 it is four. **Import accepted acked-without-fired**, which is not one of the lifecycle's three states. Such a row fires, is excluded from the pending surface because it is already acked, and can never be acknowledged because AckReminder requires acked_at IS NULL — an event emitted into permanent invisibility. The acknowledgement is dropped and the schedule kept, since an ack of something that never fired means nothing. Five mutants, five killed (one rewritten — removing the flag orphans a variable). Sixty-six across the unit. * fix(reminders): codex round 12 — a read is not a hold; scope the arm; ack from the ack Four P2s from round 12 (two independent runs, both landing on the same line of the fire path), each closed at the layer where it lives: - fireOneReminder pins the item and workspace rows FOR NO KEY UPDATE on Postgres before the arbiter UPDATE. reminderFireable re-asserted liveness at the predicate's instant and nothing held it to the commit instant; under READ COMMITTED an archival could commit in between and the event left the process about a deleted resource. Same idiom and same lock strength as CreateAttachmentForLiveItem; SQLite is excluded by its BEGIN IMMEDIATE, not skipped for convenience. Two PG-only pins verify "blocked" in pg_stat_activity, not by elapsed time; the pin-removed mutant fails both. - CreateReminder asserts "live item of THIS workspace" in the INSERT's own SELECT and returns ErrReminderItemGone otherwise. The table had an FK and no same-workspace constraint; a mismatched pair fed another workspace's title to this one's dashboard and webhooks. Handler maps it to 404. - AckReminder matches every fired row (COALESCE keeps the first ack, updated_at moves only when acked_at does), so a no-match means exactly "not fired at the instant of the ack". The handler no longer decides 409-vs-200 from the row it read before the UPDATE. - The invariant paragraph gains its missing sentence: "at that instant" means the commit instant, and the pin is what makes the predicate's instant and the commit instant the same one. Round-12 caveat carried: both runs were static reads (sandbox blocked Go's build cache), so "four" is a floor, not a measurement. Refs IDEA-2641 * fix(reminders): codex round 13 — a reminder's workspace must agree with its item's, at every read Every reader scoped by r.workspace_id and then joined the item without asserting the two agree. No door writes a disagreeing row today (CreateReminder derives the pair from the item; import maps within the workspace), and the table has nothing that forbids one — so a hand-edited bundle, a future move door, or a direct write would carry one workspace's item into another's dashboard, export, and webhooks. The identity goes into reminderFireable (scan + arbiter), the Postgres row pin, ListPendingReminders and the export query. One test writes the row raw — the only way one can exist — and asserts it is inert at each site; the predicate-removed mutant scans and fires it. Refs IDEA-2641 * fix(reminders): codex round 14 — the by-id and by-item reads assert the same identity as every other read GetReminder scoped by the row's own workspace_id and ListRemindersForItem by item_id alone, so a row whose two columns disagree — the class rounds 12 and 13 closed at the scan, the arbiter, the pin, the pending surface and the export — was still readable through the two reads that reach a single row. reminderOwned is that identity on its own, without the liveness half those two reads must not have (a fired reminder on an archived item is history worth showing). The write paths reach a row only through GetReminder, so scoping it scopes them; a row no door can write needs no door to delete it. ListRemindersForItem now takes the workspace its caller already resolved the item in. The raw-row test asserts both reads refuse the row from both sides; the reminderOwned-removed mutant surfaces it through GetReminder. Refs IDEA-2641 * fix(reminders): codex round 16 — an archived item's reminders are readable, and its verbs say "archived" The doors resolved the item live. Listing an archived item's reminders answered 409 from a GET, and ack/re-arm/delete answered a bare 404 for a reminder that exists on an item that exists — while the store, since round 14, deliberately keeps that history readable. The API already has a posture for archived items: GET reads them, mutations answer 409 "archived … restore it before editing" (writeItemResolveError). The list now follows handleGetItem; the lifecycle verbs load the item include-deleted, run the visibility check first, and then answer the same 409 every other item mutation does. One test walks archive → list 200 / ack 409 / arm 409 → restore → ack 200 on the same rows. Refs IDEA-2641 * fix(reminders): codex round 17 — one suggestion per item, the archived 409 by slug, and the door courtesy named Three findings on the server pass. (1) An item that was both a fired reminder and an ordinary candidate appeared in suggested_next twice; the ordinary entry is dropped, the reminder entry (which carries the ack id) stays, and two reminders on one item remain two entries. (2) Round 16's 409 for an archived item's reminder was written by re-resolving item.Ref, which is derived and empty for a legacy item with no item_number — so the class most likely to be legacy fell through to a bare 404. The slug is handed over instead. (3) The archived check in resolveReminderForWrite is check-then-write, and an archive landing in between lets the verb through: accepted and documented — it is the posture of every item mutation here (UpdateItem's UPDATE has no liveness clause), the outcome is benign, and putting liveness in AckReminder's WHERE would re-create the no-match ambiguity round 12 removed. Refs IDEA-2641
2077 lines
78 KiB
Go
2077 lines
78 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"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
|
|
|
|
// capMu guards the lazy, cached probe of GET /server/capabilities behind
|
|
// CollectionNotFoundIsAuthoritative. capProbed is set only once a DEFINITIVE
|
|
// answer is cached (a 200 with the flag, or a clean 404); a transient probe
|
|
// failure leaves capProbed false so a later call re-probes rather than
|
|
// poisoning the cache. capResolves is the cached definitive verdict.
|
|
capMu sync.Mutex
|
|
capProbed bool
|
|
capResolves bool
|
|
}
|
|
|
|
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 := EnvToken(); 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/"+slug, &result)
|
|
}
|
|
|
|
func (c *Client) UpdateWorkspace(slug string, input models.WorkspaceUpdate) (*models.Workspace, error) {
|
|
var result models.Workspace
|
|
return &result, c.patch("/workspaces/"+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/"+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/"+wsSlug+"/collections", &result)
|
|
}
|
|
|
|
func (c *Client) CreateCollection(wsSlug string, input models.CollectionCreate) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.post("/workspaces/"+wsSlug+"/collections", input, &result)
|
|
}
|
|
|
|
func (c *Client) GetCollection(wsSlug, collSlug string) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.get("/workspaces/"+wsSlug+"/collections/"+collSlug, &result)
|
|
}
|
|
|
|
func (c *Client) UpdateCollection(wsSlug, collSlug string, input models.CollectionUpdate) (*models.Collection, error) {
|
|
var result models.Collection
|
|
return &result, c.patch("/workspaces/"+wsSlug+"/collections/"+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/" + wsSlug + "/collections/" + collSlug)
|
|
}
|
|
|
|
// --- 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) {
|
|
var result []models.Item
|
|
path := "/workspaces/" + wsSlug + "/items"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// 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/"+wsSlug+"/tags", &result)
|
|
}
|
|
|
|
// ListCollectionItems returns items within a specific collection.
|
|
func (c *Client) ListCollectionItems(wsSlug, collSlug string, params url.Values) ([]models.Item, error) {
|
|
var result []models.Item
|
|
path := "/workspaces/" + wsSlug + "/collections/" + collSlug + "/items"
|
|
if len(params) > 0 {
|
|
path += "?" + params.Encode()
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
func (c *Client) CreateItem(wsSlug, collSlug string, input models.ItemCreate) (*models.Item, error) {
|
|
var result models.Item
|
|
return &result, c.post("/workspaces/"+wsSlug+"/collections/"+collSlug+"/items", input, &result)
|
|
}
|
|
|
|
// 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 {
|
|
c.capMu.Lock()
|
|
defer c.capMu.Unlock()
|
|
if c.capProbed {
|
|
return c.capResolves
|
|
}
|
|
resolves, definitive := c.probeCollectionResolution()
|
|
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
|
|
}
|
|
c.capResolves = resolves
|
|
c.capProbed = true
|
|
return resolves
|
|
}
|
|
|
|
// probeCollectionResolution issues the one GET /server/capabilities probe and
|
|
// classifies the outcome. definitive is true only when the server gave a clear
|
|
// answer — HTTP 200 (resolves = the advertised flag) or HTTP 404 (resolves =
|
|
// false: a build with no capabilities endpoint has no resolver). A transport
|
|
// error, a 200 whose body will not decode, or any other status (e.g. a 5xx) is
|
|
// NOT definitive.
|
|
func (c *Client) probeCollectionResolution() (resolves, definitive bool) {
|
|
req, err := c.newRequest("GET", "/server/capabilities", nil)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return false, false
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
var caps struct {
|
|
CollectionResolution bool `json:"collection_resolution"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil {
|
|
return false, false
|
|
}
|
|
return caps.CollectionResolution, true
|
|
case http.StatusNotFound:
|
|
return false, true
|
|
default:
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
func (c *Client) GetItem(wsSlug, itemSlug string) (*models.Item, error) {
|
|
var result models.Item
|
|
if err := c.get("/workspaces/"+wsSlug+"/items/"+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
|
|
if err := c.patch("/workspaces/"+wsSlug+"/items/"+itemSlug, input, &result); err != nil {
|
|
return nil, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Client) DeleteItem(wsSlug, itemSlug string) error {
|
|
return wrapItemNotFound(c.delete("/workspaces/"+wsSlug+"/items/"+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/" + wsSlug + "/items/" + 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/"+wsSlug+"/items/"+itemSlug+"/restore", nil, &result)
|
|
}
|
|
|
|
// StarItem stars an item for the current user.
|
|
func (c *Client) StarItem(wsSlug, itemSlug string) error {
|
|
return c.post("/workspaces/"+wsSlug+"/items/"+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/" + wsSlug + "/items/" + itemSlug + "/star")
|
|
}
|
|
|
|
// ListStarredItems returns the current user's starred items in a workspace.
|
|
func (c *Client) ListStarredItems(wsSlug string, includeTerminal bool) ([]models.Item, error) {
|
|
var result []models.Item
|
|
path := "/workspaces/" + wsSlug + "/starred"
|
|
if includeTerminal {
|
|
path += "?include_terminal=true"
|
|
}
|
|
return result, c.get(path, &result)
|
|
}
|
|
|
|
// 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/"+wsSlug+"/items/"+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/" + wsSlug + "/items/" + 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) {
|
|
body := map[string]string{"message": message}
|
|
var result PushResult
|
|
return &result, c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/push", body, &result)
|
|
}
|
|
|
|
// 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/" + wsSlug + "/items/" + itemSlug + "/move"
|
|
if force {
|
|
path += "?force=true"
|
|
}
|
|
return &result, c.post(path, input, &result)
|
|
}
|
|
|
|
// --- Links ---
|
|
|
|
func (c *Client) GetItemLinks(wsSlug, itemSlug string) ([]models.ItemLink, error) {
|
|
var result []models.ItemLink
|
|
return result, c.get("/workspaces/"+wsSlug+"/items/"+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/"+wsSlug+"/items/"+itemSlug+"/links", input, &result)
|
|
}
|
|
|
|
func (c *Client) DeleteItemLink(wsSlug, linkID string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/links/" + 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/"+wsSlug+"/items/"+itemSlug+"/backlinks"+q, &result)
|
|
}
|
|
|
|
// --- Comments ---
|
|
|
|
func (c *Client) ListComments(wsSlug, itemSlug string) ([]models.Comment, error) {
|
|
var result []models.Comment
|
|
return result, c.get("/workspaces/"+wsSlug+"/items/"+itemSlug+"/comments", &result)
|
|
}
|
|
|
|
func (c *Client) CreateComment(wsSlug, itemSlug string, input models.CommentCreate) (*models.Comment, error) {
|
|
var result models.Comment
|
|
err := c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/comments", input, &result)
|
|
return &result, wrapItemNotFound(err, itemSlug, wsSlug)
|
|
}
|
|
|
|
func (c *Client) DeleteComment(wsSlug, commentID string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/comments/" + commentID)
|
|
}
|
|
|
|
// --- 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/"+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/" + 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/"+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/"+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/"+wsSlug+"/playbooks/"+identifier, &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/"+wsSlug+"/playbooks/"+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/" + 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/"+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/"+wsSlug+"/webhooks", input, &result)
|
|
}
|
|
|
|
// DeleteWebhook removes a webhook by ID.
|
|
func (c *Client) DeleteWebhook(wsSlug, webhookID string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/webhooks/" + webhookID)
|
|
}
|
|
|
|
// TestWebhook sends a test payload to a webhook.
|
|
func (c *Client) TestWebhook(wsSlug, webhookID string) error {
|
|
return c.post("/workspaces/"+wsSlug+"/webhooks/"+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/"+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/"+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/"+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/"+wsSlug+"/agent-roles/"+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/"+wsSlug+"/agent-roles/"+idOrSlug, input, &result)
|
|
}
|
|
|
|
// DeleteAgentRole removes an agent role from a workspace.
|
|
func (c *Client) DeleteAgentRole(wsSlug, idOrSlug string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/agent-roles/" + 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/"+wsSlug+"/items/"+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/"+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
|
|
}
|
|
|
|
// Register creates a new user account.
|
|
func (c *Client) Register(email, name, password string) (*LoginResponse, error) {
|
|
var result LoginResponse
|
|
err := c.post("/auth/register", map[string]string{
|
|
"email": email,
|
|
"name": name,
|
|
"password": password,
|
|
}, &result)
|
|
return &result, err
|
|
}
|
|
|
|
// 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/"+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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
// 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) {
|
|
envelope := map[string]any{
|
|
"error": map[string]any{
|
|
"code": apiErr.Code,
|
|
"message": apiErr.Message,
|
|
"details": uc,
|
|
},
|
|
}
|
|
if data, err := json.Marshal(envelope); err == nil {
|
|
fmt.Fprintln(w, StructuredErrorMarker+string(data))
|
|
}
|
|
fmt.Fprintln(w, apiErr.Message)
|
|
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.")
|
|
}
|
|
|
|
// 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/"+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/" + 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/" + 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/" + 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) {
|
|
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)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) get(path string, result interface{}) error {
|
|
req, err := c.newRequest("GET", 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) 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (c *Client) parseError(resp *http.Response) error {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return parseErrorBody(resp.StatusCode, body)
|
|
}
|
|
|
|
// 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."
|
|
}
|
|
return &errResp.Error
|
|
}
|
|
return fmt.Errorf("API error: %d %s", status, string(body))
|
|
}
|
|
|
|
// --- 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/"+wsSlug+"/items/"+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/"+wsSlug+"/items/"+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/"+wsSlug+"/reminders/"+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/"+wsSlug+"/reminders/"+reminderID+"/ack", nil, &result)
|
|
}
|
|
|
|
// DeleteReminder disarms a reminder by removing it.
|
|
func (c *Client) DeleteReminder(wsSlug, reminderID string) error {
|
|
return c.delete("/workspaces/" + wsSlug + "/reminders/" + reminderID)
|
|
}
|