mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
2c8ddffcb0573927871886a825a23dce75fa845b
1349 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2c8ddffcb0 |
fix(store): cover documents and comment bodies in the attachment reference walks (BUG-2614, BUG-2615) (#1145)
* fix(store): cover documents and comment bodies in the attachment reference walks (BUG-2614, BUG-2615)
Two defects with one shape: a content surface that carries `pad-attachment:`
references was missing from a walk meant to cover every such surface. Both were
found by Codex during BUG-2415 and both predate it.
BUG-2614 — the orphan GC could reclaim a live reference. AttachmentReferenced
counted items and comments; documents.content was never scanned, and neither
document write path stamped. An attachment referenced only from a document was
therefore both invisible to the sweep's scan AND unprotected by the stamp that
covers references landing mid-sweep.
The filing asked whether the documents surface is dead enough to delete instead
of widening the scan. Evidence says widen, and I am not making the deletion
call inside a bug fix: /workspaces/{ws}/documents has full CRUD mounted and
authenticated today (list/create/get/patch/delete plus restore, versions and
activity), so a direct API consumer can still write one. It IS legacy — the
route block says "v1 — will be replaced by items in Phase 2" and no first-party
client reaches it (zero references in the web API client and in cmd/pad) — and
production carries 4 document rows, all soft-deleted, none referencing an
attachment, newest touched 2026-04-27. "Reachable but unused by us" is not
dead, and the conservative fix is a few lines. Retiring the surface belongs
with the Phase 2 migration, deliberately.
CreateDocument had no transaction, so it gains one: the stamp has to commit
atomically with the content carrying the reference or it cannot serialize
against a concurrent claim, which is the whole point. UpdateDocument already
had a transaction and only needed the call — and only when content is actually
written, since a metadata-only PATCH neither adds nor keeps a reference and
must not vouch for one.
BUG-2615 — the bundle import's remap rewrote item content and fields but not
comment bodies, so an imported comment kept the SOURCE workspace's ids: broken
references in the destination, and the rehydrated rows they should point at
left referenced by nothing. Bundles do carry comments (export.go exports them,
ImportWorkspace re-inserts them); they carry no documents, so this stays scoped
to comments.
The remap also now stamps what the rewrites point AT. ImportWorkspace already
stamps each comment body at insert, but the body still holds the source ids
then and the remap runs later in the handler, so those stamps land on nothing
that ends up referenced — leaving a fresh clone referenced only by text the
transaction just wrote and carrying no stamp, which is exactly the shape the
never-attached claim reclaims. The REWRITTEN TEXTS are passed rather than every
id in the map, so a clone nothing references is not vouched for and does not
survive an extra GC window.
Seven negative controls, one mutation at a time, each failing exactly the test
that covers it: the documents scan leg, each of the two stamps, the comment
write-back (at store and end-to-end level), the remap stamp, and an over-broad
stamp-the-whole-map variant that the precision test catches. Per the standing
bar out of BUG-2301, every regression test here was RUN against the unfixed
code and observed to fail — including the end-to-end bundle fixture the filing
asked for, whose item deliberately carries no reference so that the items walk
alone cannot rescue it.
* fix(store): stamp before the remap's content writes, and state the caller precondition (BUG-2615)
Codex round 2, two P1s.
The first is mine and is a straight violation of the protocol I was mirroring:
I stamped AFTER the item and comment UPDATEs. stampAttachmentRefsTx's own
contract says to call it before the content statement, for two reasons that
both bite here. On Postgres the stamp row-locks the attachment rows for the
rest of the transaction, so a concurrent GC claim blocks and re-evaluates
against the fresh stamp — stamping last instead lets a claim delete the target
while the rewritten text is still uncommitted, after which the stamp matches
zero rows and the transaction commits a dangling reference. And every other
writer takes attachments before content rows, so writing content first inverts
the lock order and deadlocks against them. The texts are known as soon as both
scans finish, so the stamp simply moves up.
The second — the scan-then-write over comments has no row lock and no
old-value predicate, so a concurrent edit committed in between is clobbered —
is real as a shape but not reachable at the only call site, and is NOT fixed
here. The bundle import runs this against a workspace it has just created,
which no other session can reach yet: there is no concurrent writer to lose an
edit to, and no contention for the long transaction to hold up. The
pre-existing items walk has the identical shape, so this is a property of the
function rather than of the comment leg. Adding row locks or a compare-and-swap
would be machinery for an unreachable window.
What that argument does require is that the precondition stop being tribal
knowledge, since it is about the CALLER and the next caller is exactly who
would break it. It is now stated at the top of the function, where someone
adding a second call site reads it, rather than in this message.
Also declined, both pre-existing and neither introduced here: the one-transaction
scan of the whole population (same reasoning — one caller, fresh workspace), and
document slug allocation outside the create transaction, which predates the
transaction existing at all and yields a spurious unique-violation rather than
partial state.
NOT COVERED BY A TEST, stated rather than implied: the stamp ORDERING. The
existing guard asserts the stamp is present and fails without it, but it reads
end state, so it cannot distinguish before-the-writes from after. Proving the
order needs a concurrent-session Postgres instrument of the kind BUG-2409 used;
that is not built here. The ordering rests on the reasoning above and on the
contract documented at stampAttachmentRefsTx.
* docs(store): make the scanned-surface set an explicit contract (BUG-2614)
Codex round 3 P2. Both comments a maintainer reads still described the scan as
covering items and comment bodies — AttachmentReferenced's doc, and the
orphan-GC sweep's "Item content references the attachment" branch — so the
change that added documents left the two artifacts that explain it stale. Same
class as the sentinel comment on BUG-2301: the code was right and the text
someone acts on was not.
They now also say the thing neither said before, which is why this defect
happened twice: the SET of scanned surfaces is the contract. Any surface that
persists user-authored text containing a `pad-attachment:` token has to be
listed there, and adding one without adding it here silently makes its
references invisible to the GC. Comments (IDEA-1650) and documents (BUG-2614)
were both found after the fact, which is the argument for writing the rule down
rather than the two instances.
Round 3's P1 — restore paths do not re-stamp, so a reference reclaimed while
archived is dangling after restore — is filed as BUG-2629, not fixed here. It
is pre-existing and uniform: RestoreItem does not stamp either, so fixing only
RestoreDocument would leave the larger hole open while making documents
inconsistently better-protected. The filing records the asymmetry that decides
its priority: items are usually shielded by the claim's own item_id IS NULL
predicate, while a document-referenced attachment has no document_id column to
be shielded by and is always claimable.
* docs(store): mark the unstamped rename cascades in place, pointing at BUG-2629 (BUG-2614)
Codex raised the title-rename cascade's missing stamp in two separate rounds
despite being told it was filed. Being raised twice is the signal that the
disposition was only in a bug tracker and not where a reader of this code
meets the problem — the same correction BUG-2301 ended on.
Both sites now carry it: documents.go::updateLinksInTx and
wiki_links.go::cascadeTitleRename, each naming BUG-2629, why it is not fixed
here (uniform across both surfaces, so half-fixing makes them inconsistent),
and why it is the weakest member of that family (the cascade rewrites link text
in content whose references were already stamped and are still visible to the
scan, so a genuinely new reference needs a title containing a pad-attachment
token).
Comments only.
|
||
|
|
6f16003199 |
fix: surface implementation notes + decision log in the item timeline (BUG-2301) (#1144)
* fix(server): merge implementation notes + decision log into the item timeline (BUG-2301) `pad item note` and `pad item decide` have written structured entries since |
||
|
|
8798de7e99 |
docs: correct the onboard playbook's mode vocabulary in CLAUDE.md (#1142)
The Onboarding section described "four modes: build/audit/revisit/ defaults". The playbook's actual arguments declaration (playbook_library_onboard.go) is a mode enum of auto/build/audit/revisit (auto default, routing any user-created item to revisit) with `defaults` a separate fast-path FLAG, not a mode. This error propagated into BUG-2574's body and from there into a skill rewrite before Codex caught it against the source (PR #1139 round 1); fixing the origin so it can't propagate again. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V |
||
|
|
aa1a4f9d8c |
fix(store): workspace-scope the orphaned-variant GC's parent check (BUG-2622) (#1143)
A live variant whose parent_id resolves to a LIVE row in ANOTHER workspace — malformed data with no FK or same-workspace constraint behind it, the class PLAN-2397 repairs — was shielded by that foreign parent in both halves of the orphaned-variant class: the candidate SELECT's NOT EXISTS and ClaimOrphanedVariantAttachment's delete-time re-assert read any live parent as protective. An item-bound malformed variant then matched no GC class at all, forever (an unbound one eventually ages into the never-attached class, so the item-bound shape is the real leak). Per DR-11a's rule one level down — already enforced by the copy planner's attachmentVariantsInWorkspace for the same reason — a foreign row is not a legitimate parent, so both predicates now require p.workspace_id = attachments.workspace_id; in the claim, ErrNoRows covers hard-gone and foreign alike, and a foreign parent's restore needs no serialization since its liveness is irrelevant to this row either way. Deleting the malformed variant touches nothing in the foreign workspace (the child holds the pointer) and bytes stay behind the hash-protection + in-flight fences downstream. Test: TestOrphanedVariant_ForeignParentDoesNotShield — both legs (candidate SELECT + claim) verified failing pre-fix on the item-bound shape, with a live same-workspace-parent control pinning that the fix did not widen into reclaiming healthy thumbnails, and the foreign parent asserted untouched. Found by Codex round 4 of PR #1139 (out of that PR's scope; filed then fixed separately). Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V |
||
|
|
a963e68395 |
docs(skills): de-assume the slash-command surface + route onboard shortcut through the canonical playbook (BUG-2573/2574/2575) (#1139)
* docs(skills): de-assume the slash-command surface + route onboard shortcut through the canonical playbook (BUG-2573, BUG-2574, BUG-2575)
Three coherent drift fixes across the two skill trees:
BUG-2573 — skills/pad/SKILL.md is the embed source `pad agent install`
writes for Claude Code, Codex, Cursor, Windsurf, OpenCode, Amazon Q,
Junie AND pure-MCP agents, but three sentences presented the Claude Code
slash command as THE invocation ("There is one command: /pad <anything>",
"On every /pad invocation", "the first token after /pad"). Reframed per
the PLAN-1847 house pattern: natural language is canonical, typed forms
are per-surface shortcuts, and a read-/pad-as-shorthand rule covers the
rest of the document. Verified through the installed artifact, not just
the diff: built the binary and ran `pad agent install codex` — the
reframed text reaches the non-Claude skill verbatim.
BUG-2574 — plugin/skills/onboard/SKILL.md (the most direct onboarding
route a plugin user has) inlined its own post-link setup script, silently
opting that surface out of the workspace-owned, user-editable onboard
playbook — a customized playbook never fired via the shortcut, and the
inline copy covered roughly the build mode only. The post-link half now
loads and follows the playbook (with exact-title library activation —
`pad library activate "Onboard a workspace"`, verified against the CLI's
actual arg form) and routes needs_onboarding=false to the playbook's
revisit mode. The pre-link whoami-gated half stays as BUG-2541 left it.
Checked the other dedicated plugin skills for the same class: status and
capture inline nothing playbook-owned — no change needed.
BUG-2575 — plugin/skills/pad/SKILL.md didn't know specs are decomposable:
added the "break SPEC-1 into tasks" routing entry and the plan-or-spec
wording in the decompose workflow, matching decomposePlaybookBody and the
embed source. Also synced the one other surface-agnostic drift found in
the sweep: the convention_index note that a list without --full has no
content field.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* docs(skills): fix onboard-skill mode enum, needs_onboarding semantics, and reactivation path per Codex review (round 1)
Three corrections to the rewritten post-link half, all verified against
playbook_library_onboard.go: the mode enum is auto/build/audit/revisit
(auto default) with `defaults` a separate fast-path flag — the "four
modes incl. defaults" framing came from the tracking bug's own body and
was wrong; needs_onboarding:false only means a user-created item exists,
not that onboarding ever ran, so the skill no longer declares setup
complete on it; and a draft/deprecated onboard playbook must be
reactivated in place, since invocation_slug is workspace-unique and
library activation beside an existing entry duplicates or fails.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* docs(skills): activation before load + honest auto-mode routing per Codex review (round 2)
The post-link half now runs as an ordered three-step: ensure-active
(reactivate in place, library only when absent), THEN load the body,
then mode framing — a literal reader of the previous text ran
`pad playbook show onboard` before the existence check, failing on
missing playbooks and loading stale drafts. And the mode note no longer
claims auto picks "a fuller pass": verified against the playbook's
pre-flight, auto routes ANY user-created item to revisit, so the skill
now says to pass an explicit mode=build/audit override (which the
playbook honors) when the user says the workspace was never really set
up.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
* docs(skills,mcp): propagate activation ordering + auto-mode routing to the sibling onboarding routes per Codex review (round 3)
The round-2 corrections lived only in the focused onboard skill; the
embed skill's Onboarding entry, the plugin pad skill's, and the
pad_onboard MCP prompt still said load-then-activate with a bare
library-activate fallback, and none warned that the playbook's auto mode
routes any workspace with user-created items to revisit. All three now
carry the same semantics: ensure-active first (reactivate a
draft/deprecated entry in place — invocation_slug is workspace-unique,
so library activation beside an existing entry duplicates or fails),
then load, plus the explicit mode=build/audit override note.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
|
||
|
|
08dfbdb318 |
fix(server): rowless-blob GC sweep — reclaim blobs no attachments row references (BUG-2406) (#1137)
* fix(server): rowless-blob GC sweep — reclaim blobs no attachments row references (BUG-2406) Every attachment write path calls AttachmentStore.Put BEFORE inserting the attachments row, so a failure (or crash) between the two leaves a blob on disk that nothing references — and the row-driven orphan sweep, which walks Store.OrphanedAttachments, can never see it. Disk that is never returned; the upload handler's failure comment even claimed the GC would reclaim it. Fix: a rowless-blob sweep that runs after the row sweep on the same GC tick. attachments.Lister is a new OPTIONAL backend capability (ListBlobs → key/hash/size/mtime); FSStore implements it via one WalkDir of the sharded tree with a base-name validHash gate (excludes Put's dot-prefixed temp files and anything the store didn't write). Backends without the capability are skipped with a once-per-process notice. Candidate = blob whose content hash has ZERO rows in ANY state (soft-deleted rows still own their bytes under the row sweep's row-before-bytes claim protocol, BUG-2415) AND whose mtime predates the same operator-configured GC grace the row sweep uses — a young rowless blob is just an upload whose insert hasn't happened yet. Delete-time guards run under inFlightHashesMu: the in-flight fence plus a single-hash row RE-CHECK that closes the subtraction-to-delete TOCTOU (the writer that marked, inserted, and released entirely inside the gap). Cost: O(blobs) per tick, 24h cadence, never on a request path. Also retro-reclaims blobs stranded by past row-sweep delete failures. The wrong claim in handleUploadAttachment's failure path is corrected to point at this sweep. Tests: FSStore.ListBlobs impostor coverage; five sweep legs (aged-rowless reclaimed with a row-sweep-can't-see-it counterfactual, young kept, live/soft-deleted-row kept, in-flight kept then reclaimed after release, hook-injected delete-time row kept) — mutation-verified: removing the re-check, the age gate, or the in-flight fence each fails its leg; the store-level subtraction contract is pinned separately. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * docs(store): state the any-row rule's real rationale per Codex review (round 1) Codex flagged the thumbnail refusal-cleanup's grace-window protection as inconsistent with the sweep comment's claim that deleting bytes under any existing row violates the claim protocol. The cleanup (and the row sweep itself) deliberately end a row's hash-protection when its own grace expires — CountProtectingAttachmentsForHash documents exactly that, and the row machinery may do it because its claim protocol coordinates row and blob fates within a sweep. The overstatement was mine: the rowless sweep's any-row rule is chosen because it holds no claim on any row and has no such coordination, not because past-grace stranding is forbidden to the machinery that does. Comment corrected; no behavior change on either path. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V |
||
|
|
31075d996a |
fix(store): route cross-workspace copy's lock-held reads through the copy transaction (BUG-2409) (#1136)
* fix(store): route cross-workspace copy's lock-held reads through the copy transaction (BUG-2409) The copy transaction holds advisory locks on BOTH workspaces, but the attachment planner (PlanAttachmentCopy) and the server's per-row attachment authorizer read through the connection pool. Under enough concurrent copies every pooled connection can be occupied by a lock-waiter while the lock holder waits for a spare connection — starvation presenting as a hang. Fix: a store.Queryer interface (satisfied by *sql.DB and *sql.Tx) threaded through the planner and the AttachmentAuthorizer callback, so the mutating copy plans and authorizes on its own transaction's connection while the preflight keeps planning through the pool — one implementation, two executors, preserving TASK-2354's no-drift shape. Mechanical *Q variants added for the store reads the authorizer transitively needs (GetItem, GetUser, GetWorkspaceMember, VisibleCollectionIDs, GetMemberCollectionAccess, ListSystemCollectionIDs, GuestVisibleCollectionIDs, GuestVisibleResources(+IncludeDeleted), ResolveBacklinksVisibility) and Q-cores behind existing-signature server wrappers (checkItemVisible, guestResourceFilterCore, resolveAttachmentParentItem, attachmentCallerIsRestricted). No decision logic changed anywhere — executor threading only. GetItem/getItemTx/GetItemIncludeDeleted's three duplicate scan bodies collapse into one getItemScanQ. Regression test: TestCopyItemAcrossWorkspaces_NoPoolIOUnderLocks pins the invariant deterministically — with MaxOpenConns(1) the transaction owns the only connection, so ANY pool read under the locks deadlocks. Fails by timeout on the pre-fix executor (verified); passes in 0.16s fixed. The test's authorizer performs a real read through the handed Queryer, pinning the callback leg too. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(store): quota check reads through the copy transaction too, per Codex review (round 2) Codex's targeted round found the third lock-held pool-read leg: CheckLimitTx routed only the feature COUNT through the caller's transaction while checkLimitOn's owner lookup, GetUser, and resolveLimit's platform-setting read stayed on the pool — the same starvation shape under the copy's advisory locks. checkLimitOn is now parameterized over a single Queryer for every read (CheckLimit passes the pool, CheckLimitTx the transaction), with resolveLimitQ / GetPlatformSettingQ variants behind existing-signature wrappers. The regression test now arms this leg deliberately: a FREE-plan owner with EnforceItemLimit and no plan override drives the full quota read chain under MaxOpenConns(1) — verified deadlocking before this commit, 0.16s after. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V |
||
|
|
cc26288794 |
fix(web): share pages render attachment refs as honest placeholders (BUG-2389) (#1135)
The public share route (/s/{token}) rendered item content with a bare
marked() call, so pad-attachment: references fell through as broken
<img src="pad-attachment:..."> tags and dead links. Two halves:
1. CommentThread.svelte is deleted outright — grep proved it was
unmounted dead code (its only reference was a prose mention in
ItemDetail.svelte), so its half of the bug resolves by deletion
rather than by fixing a component nothing renders.
2. The share route now renders through a new opt-in wrapper,
renderMarkedWithAttachments(), which threads an AttachmentRenderContext
into the existing marked renderer hooks. With a null resolver and the
new renderAttachmentUnavailable() placeholder, every ref becomes an
honest "Attachments aren't available on shared pages yet" chip —
deliberately NOT the "missing or has been deleted" wording, because
the attachment exists; the share surface just cannot serve its bytes.
Sanitization is unchanged: the wrapper returns unsanitized HTML and
the share page keeps its single DOMPurify pass.
The `missing` hook is a parameter (default: renderAttachmentMissing) so
authed surfaces keep their existing wording, and the wrapper clears the
module context in a finally block so bare marked() callers are
unaffected (pinned by test).
The token-scoped byte endpoint that would serve real images on share
pages (2b) is deliberately NOT built here — it adds a new
unauthenticated ACL surface and is tracked separately pending approval.
A real resolver through the same wrapper is the plug-in point (pinned
by test).
Tests: markdown.shareAttachments.test.ts (6 unit legs incl. bare-marked
opt-in control and context-clearing) and
bug-2389-share-attachment-placeholder.spec.ts (e2e: real upload → item
ref → item share link → anonymous visit; verified failing on the
pre-fix build).
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
|
||
|
|
e0c5792ce9 |
fix(store): attachment delete vs thumbnail derivation race — atomic cascade, locked conditional insert, orphaned-variant GC class (BUG-2388) (#1134)
* fix(store): attachment delete vs thumbnail derivation race — atomic cascade, conditional variant insert, orphaned-variant GC class (BUG-2388) Deleting an attachment while thumbnails were still deriving could mint a live, unreachable variant row under a tombstoned parent: the delete cascade tombstoned original and variants in separate statements, and derivation checked parent liveness once, then inserted uncondition- ally. The leaked row was invisible in the UI, counted toward quota forever, and no GC class could reclaim it (the old code's comment claimed a 'deleted-parent path' existed; it did not). Three parts, all the BUG-2415 claim-by-statement discipline: - SoftDeleteAttachment tombstones original + variants in ONE transaction. - CreateAttachmentVariantIfParentLive makes the parent-liveness check part of the variant INSERT itself (INSERT..SELECT WHERE EXISTS parent live); persistThumbnail cleans up the just-Put blob on refusal under the in-flight hash fence it already holds, honoring the same hash-dedupe protections as the sweep. - Orphan GC gains the orphaned-variant class: live variant whose parent is tombstoned/gone, tried FIRST for live parented candidates (an item_id-NULL leak would otherwise hide behind a content reference to its dead parent in the never-attached scan). The claim re-asserts parent-not-live at delete time, so a concurrent parent restore wins and a restored original keeps its thumbnails. This class also retro-reclaims rows already leaked. Tests: the filed race pinned deterministically (persistThumbnail with a pre-delete parent snapshot — control build mints the leaked row verbatim); retro-reclaim sweep test with a restore-wins leg, its leak fixture deliberately ATTACHED so only the new class can reclaim it (control build: row survives). * fixup: codex round 1 — parent row-locks on the conditional insert + variant claim (CreateAttachmentForLiveItem precedent), fenced+config-aware refusal blob cleanup, store-level restore-refusal claim test, blob-cleanup assertion * fixup: count inside the in-flight fence — a completed upload lifecycle could stale an outside count (codex round 2) |
||
|
|
2e4f3d5dc2 |
fix(server): refuse a PATCH carrying both a fields hierarchy key and top-level parent_id (BUG-2594) (#1133)
* fix(server): refuse a PATCH carrying both a fields/fields_patch hierarchy key and top-level parent_id (BUG-2594) extractParentLink staged the item_links write (including the empty- string clear) while ItemUpdate.ParentID stamped the parent_id column unconditionally in the same transaction — one request could clear the link AND re-parent the column, leaving silently inconsistent hierarchy state (unparentedItemPredicate still saw a parent). The shape is raw-HTTP-only: no first-party client sends top-level parent_id on item update (CLI resolves --parent into the patch; the web client and MCP catalog never carry it). Both update paths (full fields + fields_patch) now refuse the pair with a validation error naming both keys — refused, not silently resolved, per the clear_parent contract family's standing rule (v0.19). Solo parent_id and solo fields-patch hierarchy writes are deliberately unchanged (BUG-2379 tracks the adjacent undeclared- override family). Six handler tests: refusal on clear+id, set+id, the plan alias, and the full-fields sibling path — each verified failing (200) on the unguarded control build — plus both solo-write controls. * fixup: assert the validation_error code + plan alias in the refusal envelope (codex round 1) |
||
|
|
2521e3e1c7 |
fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610) (#1132)
* fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610)
In split view the item pane is an overflow-y:auto scroll container,
which computes overflow-x:auto too — the quick-actions (⚡) and ⋯
menus were ANCHORED panels inside it, and a right-aligned panel
opening from the pane's action bar extends left past the pane's edge,
so the container clipped it mid-text (Dave's screenshots: 'age
actions' for 'Manage actions', truncated tagline).
Both menus now use the Menu component's portal mode — built precisely
to escape overflow containment (fixed coords portaled to <body>,
viewport clamping, flip-when-cramped, scroll dismissal), and already
the mode of every board-card menu. Widths cover each menu's content
(QA: the 230px qa-body min-width + chrome; ⋯: the longest row).
Regression e2e uses a PAINT-level oracle — clipping doesn't shrink
getBoundingClientRect, so elementFromPoint just inside the panel's
left edge must resolve to the panel; verified failing on the anchored
control build with the exact reported symptom, plus a geometry
precondition so the probe can't pass vacuously. Existing e2e + unit
lookups that scoped menu rows under .item-pane / the master column
are page-scoped now (the portaled panel lives in <body>; only one
menu is ever open, and the scoped trigger click is what ties it to
its column).
* fixup: scope portal scroll-dismiss to anchor-moving containers — any-scroll dismissal closed pane menus under live SSE churn (found via parallel e2e instability vs a clean control build)
* fixup: codex round 1 — route nav-key bail includes portaled [role=menu] (pre-existing leak for board menus too), stopPropagation on handled menu keys, exempt-aware scroll dismiss, per-menu e2e geometry preconditions
* fixup: page-scope the two graph-drawer menu lookups in pane-content-link-anchors (codex round 2)
|
||
|
|
fbea9484d6 |
fix(web): source-identity guards on the SSE non-sync listeners (BUG-2611) (#1131)
close() does not retract already-queued event tasks, so on a fast workspace switch a torn-down EventSource's queued events could fire after the next workspace's source existed. BUG-2540 guarded onopen / onerror / 'connected'; the remaining listeners had no guard, so workspace A's stale events dispatched into workspace B's callbacks — spurious sync passes and cross-workspace item events fanned onto B's BroadcastChannel, and the sharp member: a stale 'unauthorized' closed B's LIVE EventSource, flipped the status indicator, and cleared currentWorkspace over A's auth state, with nothing reconnecting until a navigation. Same one-line guard on all four (sync_required, items_bulk_updated, unauthorized, the ITEM_EVENTS loop); unauthorized additionally closes its OWN source rather than whatever eventSource currently points at — the guard has just proven they are the same, and the old shape is what made the stale path destructive. Unit harness (BUG-2540's stubbed-EventSource pattern): fast A→B switch, fire each event type on the torn-down source — zero dispatch into B — with a live-source control arm per leg so a guard silencing both cannot pass. All four legs verified failing on the unguarded build. |
||
|
|
d68474f775 |
feat(server): armed-session declaration + push delivery filter (PLAN-2613 S1, TASK-2616) (#1130)
Adds a server-side consent gate for push delivery ahead of the plugin/CLI version flip: a stream now declares armed=true at connect (query param) to receive KindPush notifications, while legacy (unarmed) streams keep ordinary watch-matched delivery during the skew window. LiveSession exposes the armed bit so the web target picker can eventually show honest accepting-pushes counts, and push delivery counts are now armed-aware end to end (broadcast, targeted, and the pre-publish snapshot used to skip a guaranteed no-op). |
||
|
|
8cdeeb166b |
fix(store): orphan GC claim protocol — writer reference stamps + conditional row-first deletes (BUG-2415) (#1129)
* fix(store): orphan GC claim protocol — writer reference stamps + conditional row-first deletes (BUG-2415) The sweep scanned content for pad-attachment: references, then deleted the BLOB, then the row — with nothing serializing it against content writers. A reference committing between scan and reclaim left either a dangling id or, worse, a surviving row whose bytes were already gone. Claim protocol: - attachments.last_referenced_at (dual-dialect migration): every content writer that persists a pad-attachment: reference stamps the rows INSIDE its own write transaction (stampAttachmentRefsTx), wired at the four store chokepoints every surface funnels through — CreateItem, the UpdateItem core (item PATCH, collab-snapshot flush, version restore, bulk update), CreateComment, UpdateComment (both now transactional). Workspace-scoped; covers content AND fields, matching AttachmentReferenced's scan surface. - The sweep's row deletion is now the atomic claim: a conditional DELETE re-asserting reclaimable state in the statement itself (ClaimNeverAttachedAttachment: unattached + live + no fresh stamp; ClaimSoftDeletedAttachment: still deleted + still past grace, so a mid-sweep restore survives too). Writer stamp and claim serialize at the database; whichever commits first wins and the loser observes it. - Row BEFORE bytes: the blob is reclaimed only after a successful claim, so a surviving row implies surviving bytes — the old order's worst failure mode (row without content) is structurally impossible. - orphanGCRefStaleWindow (15m) is documented as a correctness parameter: the stamp only covers references landing after the scan, so the window bounds scan-to-claim latency plus a maximally stalled writer transaction — not a lease on long-lived references (the LIKE scan still guards those). Sweep-level test pins the filed race (fresh stamp survives sweep, row AND blob) with a counterfactual arm (aged stamp reclaims); verified discriminating against a compiling control build of the old sweep order. Store tests cover every claim predicate leg, stamp wiring on all four chokepoints, and workspace scoping. * fixup: codex round 1 — stamp move-override + workspace-import paths, parent-aware variant protection (scan by parent id + claim NOT EXISTS fresh parent stamp), variant test with total-loss control * fixup: codex round 3 — stamps ordered BEFORE content statements (PG row-lock makes the claim wait out the writer tx), chunked stamp IN-lists, BlobDeleteFailures counter * fixup: codex round 4 — stamp variants of referenced originals (own-row lock protects concurrently-claimed thumbnails), bounded-duration residual + irrevocability docs |
||
|
|
54526c5b33 |
fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) (#1128)
* fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) Seven sites assigned ItemDetail's collection snapshot under fences that ordered STARTS, and loadData's cross-collection escape hatch admitted any generation-stale write that fetched a different collection — so a loadData continuation spanning a cross-collection MOVE restored the SOURCE collection over the freshly adopted TARGET (the live item, itemGen-fenced, kept the move: the pane rendered the item against the wrong collection's schema). All writes now route through adoptCollection, backed by the pure shouldAdoptCollection decision: (1) a snapshot disagreeing with the LIVE item's collection_id is vetoed regardless of freshness — id, not slug, so renames still apply and a reused slug can't satisfy it (this also closes a latent foreign-write in the SSE collection_updated refresh, which fetched by slug); (2) same-collection refreshes keep newest-started-wins; (3) the legitimate cross-collection correction the old hatch existed for still lands when the live item agrees. On the veto path, an embedded pane whose collection was still null (fresh mount — refreshCollectionIfMoved's !collection guard skips there too) converges on the live item's collection instead of being left schema-less (adoptOrConvergeToLiveCollection); non-embedded masters stay route-authoritative per the existing policy. e2e reproduces the filed race deterministically (route-hold on the realColl fetch, API move mid-hold, release): the control build renders the moved item against SrcMarkerField's schema verbatim; the fixed build converges on the target's. * fixup: codex round 1 — post-converge myGen re-checks, schema-less error surfacing, pre-fetch convergeGen, hard collection_id oracle in e2e * fixup: codex round 3 — empty-string collection_id normalizes to no-anchor, else-branch schema-less surfacing, itemGen re-check before singleton claims * fixup: two stale comments (codex round 5 docs) |
||
|
|
904878522a |
fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) (#1127)
* fix(web): heal collection renames missed by SSE — sync-pass route reconcile + localIndex retag (BUG-2601) Two stranding layers, both from the same root: a collection rename changes the slug without touching items, so nothing item-shaped ever re-announces it. 1. ROUTE: delta-sync catch-up covers item changes only, and /changes says nothing about renames — a rename-only gap even reports caught_up — so a client that missed the collection_updated SSE (replay gap, disconnect) kept a dead route slug; slug-keyed fetches 404'd until a manual navigation. The collection route now reconciles its slug against the live collections list by STABLE collection id on every sync pass (resolveSyncRenameTarget — pure, unit-tested — wired via reconcileRouteCollectionSlug), mirroring the BUG-2272 SSE/reorder-404 heals and sharing the renameNav intent tracker. 2. DATA (discovered by this fix's own e2e, present on the LIVE SSE path too): cached localIndex rows keep the old collection_slug — rows only re-stamp when the item itself changes — so EVERY rename-healed route rendered an empty board while the sidebar counted the items. New localIndex.retagCollection re-stamps rows by stable collection id (search + IDB write-through, upsert pattern), called from the workspace layout's global collection_updated subscriber (any route, live SSE) and from the sync heal (missed SSE, where the layout subscriber also missed the event). e2e covers both paths with an aborted-SSE missed-event leg and a live SSE leg; both specs fail on the pre-fix build (verified) and the missed-SSE spec guards its own vacuity (asserts the strand before triggering the heal). * fixup: codex round 1 — foreign-snapshot gate on sync heal, pendingRetags for pre-hydration renames, layout loadCollections widened, goto-failure renameNav reset, worker-unique e2e slugs * fixup: stamp pendingRetags with recording user's identity; discard on mismatch at warm-hydrate apply (codex round 2) * fixup: heal the full-page item route's collection segment on sync pass too (codex round 3 — the bug body's own example route) * fixup: reconcileCollectionSegment switch-safety — pre-await fence, destroyed guard, identity-compared bridge cleanup (codex round 4) * fixup: compare the read-back $state proxy, not the raw literal (codex round 5) * fixup: codex round 7 — navigating guard on both healers, replaceState on the list heal, null-owner adoption for pendingRetags, in-window vacuity pins in e2e |
||
|
|
f0cbcb5df4 |
fix(mcp): route backlinks/history/report over HTTP transport + catalog↔route parity test (BUG-2304) (#1126)
* fix(mcp): route backlinks/history/report over HTTP transport + catalog↔route parity test (BUG-2304) Three catalog actions were advertised on the remote /mcp transport but had no route: pad_item.backlinks, pad_item.history, pad_project.report all answered 'not yet implemented over HTTP transport' on Pad Cloud. All three have working REST endpoints — the gap was mappers, and the absence of any catalog↔routeTable parity check is how they shipped silently. - item backlinks + project report: plain routeSpecs (their CLI JSON is the endpoint response verbatim, so a GET reproduces the stdio shape). - item history: hand-written dispatchItemHistory, because the versions endpoint returns full content bodies while the CLI projects to the token-light itemVersionSummary; full=true opts back in, matching the stdio --full path. - The special-case dispatch switch is now an introspectable specialRoutes() map, and a new parity test drives EVERY catalog action through its real ActionFn, captures the dispatched cmdPath, and fails on any action missing from routeTable ∪ specialRoutes ∪ itemLinkSpecs — or any action the fixture can no longer exercise. No ToolSurfaceVersion bump: no tool names, action enums, or parameter shapes changed — advertised actions now work as documented. Also folds in CLAUDE.md catalog-version drift (still said v0.19; 0.20 shipped in BUG-2302/2305). * fixup: cache specialRoutes map (sync.Once); kind-aware item_not_found on history 404 (codex round 1) * fixup: single request path for history — full=true keeps the kind-aware 404 envelope (codex round 2) * fixup: version.go no-bump changelog note, instructions full:true note, parity-test scope comment (codex round 3) |
||
|
|
f756e853fe |
fix(oauth): keep zero-workspace consent authorizable via the wildcard path (BUG-2303) (#1124)
The consent template gated the whole workspace fieldset on the user having memberships; with zero workspaces no access radio rendered and the inline script permanently disabled Authorize — a dead end, even though parseConsentPayload's wildcard path accepts a zero-workspace workspace_access=all consent with no membership validation. Render the 'All my workspaces' radio unconditionally (force-checked when memberships are zero — it is the only option, and an unchecked radio group would re-disable the button), keep the specific radio + picker gated on memberships, and replace the dead-end copy with a pointer at the workspace-creation checkbox so the client can create the user's first workspace. |
||
|
|
94441b4eb2 |
chore(nix): bump package version to 0.14.0 ahead of the release tag
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dtv0.14.0-rc.1 v0.14.0 |
||
|
|
cada8777e7 |
fix(web): full-page pane host gets its own navigate-away handling (BUG-2178) (#1123)
The full-page item host reused the shared controller's handlePaneNavigateAway, whose goto targets are collection-host-shaped (the base route IS the collection page). On this host both emits abandoned the master: a pane collection-rename landed on the renamed collection's root page, and a pane item-move hard-navigated to the moved item's full page. The host now wires planFullPageNavigateAway (new pure planner in paneController.ts, next to planPaneDrill and friends): - COLLECTION RENAME (keeps-pane emit): IGNORE. Nothing the host owns is invalidated by the emit alone — if the renamed collection is the MASTER's, the master ItemDetail's own BUG-2272 SSE rename handler already gotos the new-slug URL with the full search string (?item= included), so the route self-heals with the pane intact; a foreign collection never touched the master route. The embedded pane needs no URL change either — it trusts item.collection_slug. - ITEM MOVE (no ?item=): RETARGET the pane to the moved item's slug via the existing drill machinery (navigatePaneTo), which preserves the master pathname by construction and handles depth/ownership/ focus. When ?item= already held the slug (a same-workspace move keeps slugs, so the drill same-ref-guards to a noop) the pane self-heals via the item_updated SSE refetch instead. - Malformed/pathless URLs: IGNORE — staying on the master beats navigating somewhere unparseable. (decodeURIComponent throws on malformed percent sequences; the hostile-input unit test caught that crash before it shipped.) The controller's handlePaneNavigateAway is annotated collection-host- only; every property of its spec comment is untouched for that host. Tests: - planFullPageNavigateAway unit table (both real emit shapes verbatim, encoded slugs, trailing slash, malformed/empty/foreign-origin). - E2E (pane-full-page-capstone.spec.ts): move the PANE item to another collection from the docked pane; assert the pathname never leaves the master route and ?item= retargets to the moved slug. Mutation- verified against a control binary with the old wiring: it fails with ?item= gone and the master abandoned — the reported bug, verbatim. The spec header's BUG-2178 deferred note updated to covered. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
900b0c428a |
fix(mcp): summary-shaped item list on the HTTP transport (BUG-2305) (#1122)
A bare pad_item.list over remote /mcp returned up to 50 items with
FULL content bodies — the exact token blowup TASK-2000's limit was
written to prevent, unmitigated on the transport the zero-CLI plugin
makes primary. The limit was symmetric (actionItemList injects it
before dispatch); the SHAPE was not: the exec path projects through
cli.ToItemSummaries in the CLI, while the HTTP path forwarded the raw
handler response.
Fix follows the trail's pre-committed approach (the body's "add a
summary param to mapItemList" is refuted there — the server has no
projection parameter and a RouteMapper has no response hook): a
hand-written dispatchItemList, same shape and same cli.ToItemSummaries
projection as HTTPResourceFetcher.fetchItemList. mapItemList stays the
single URL/filter builder; the routeTable entry is removed so the
hand-written method is the one path. full:true opts into complete
bodies on both transports (stdio forwards it as the CLI's --full).
Audited the other list-shaped actions per the body's ask: pad_search
is symmetric (the STORE zeroes Item.Content in search results —
internal/store/search.go); pad_project.activity returns enrichment
metadata, no content bodies, same endpoint on both transports. Only
item list had the asymmetry.
Also rewrites the misleading actionItemList comment ("summary vs full
is a CLI-side concern") that misdescribed the HTTP path.
Old scope/verified-email fixtures stubbed `{"items":[]}` — an object
shape the real endpoint never returns (it writes a bare array); they
only passed because the routeTable path packaged blindly. Fixtures
corrected to the real shape.
Tests: TestHTTPItemList_DefaultIsSummaryShape +
TestHTTPItemList_FullOptsIntoCompleteBodies drive the REAL server +
store as a counterfactual pair — the marker sits past the
content_preview cut, the full leg proves it flows through the same
path, the default leg proves the projection strips it.
Mutation-verified: removing the dispatch case fails (route gone);
skipping the projection fails (leak caught).
No ToolSurfaceVersion bump here: BUG-2302's PR carries the single
0.19→0.20 bump; this PR appends its lines to that changelog entry
after it lands (lead's sequencing ruling — every changelog sentence
true at its own merge time).
Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM
|
||
|
|
727cd80927 |
fix(mcp): explicit tool annotations from catalog write-shape knowledge (BUG-2302) (#1121)
mcp-go's NewTool injects default annotations on every tool — ReadOnlyHint:false, DestructiveHint:true, OpenWorldHint:true — and buildToolFromDef never overrode them, so every Pad tool advertised itself as destructive, including pure reads like pad_search and pad_project. Hosts use destructiveHint to decide whether to prompt; mislabeling reads trains users to click through prompts. Derive the block in buildToolFromDef from the catalog's own knowledge (readOnlyActions — the same single source the tool-surface serializer uses — plus a new sibling additiveWriteActions allowlist): - every action read-only → ReadOnlyHint:true, DestructiveHint:false, IdempotentHint:true (pad_search, pad_project, pad_attachment, pad_meta, pad_playbook); - writes all purely ADDITIVE → ReadOnlyHint:false, DestructiveHint:false (pad_workspace: invite/create/claim/restore; pad_library: activate — codex round 1: marking additive writes destructive reintroduces the prompt-training harm at tool level); - any overwrite/delete-capable action → the conservative ReadOnlyHint:false, DestructiveHint:true (pad_item, pad_collection, pad_role) — unchanged on the wire from the old defaults; - OpenWorldHint:false everywhere (pad tools are closed-world). pad_set_workspace gets a hand-written block (write, non-destructive, idempotent, closed-world). Also adds the missing pad_item.history entry to readOnlyActions — documented read-only since v0.14 but reported read_only:false on the tool-surface descriptor. ToolSurfaceVersion 0.19 → 0.20 (behavior bump, v0.9/v0.16 precedent): no tool names, action enums, or param shapes changed. instructions.md and README headings retitled per the drift tests. The changelog entry describes only this change; BUG-2305 appends to it if it ships in the same window (one bump total). Tests: TestCatalogTools_AnnotationsExplicit pins a literal per-tool read/additive/destructive table (deliberate second enumeration — a new tool, or a write action added to an all-read or all-additive tool, fails loudly until someone decides its class); TestAdditiveWriteActions_NoStaleEntries guards the new allowlist (real catalog pairs only, never overlapping readOnlyActions); TestSetWorkspaceTool_AnnotationsExplicit covers both deployment variants; pad_item.history joins the read spot-checks. Mutation-verified both directions: destructive-polarity flip fails 10 tools; always-destructive fails the two additive rows. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
aa33dc407e |
fix(server): serve RFC 9728 PRM at path-aware well-known (BUG-2266) (#1120)
A client configured with the path-suffixed transport URL (https://mcp.getpad.dev/mcp — the shape every FastMCP example uses) constructs its protected-resource-metadata URL per RFC 9728 §3.1 by inserting the well-known segment before the path: /.well-known/oauth-protected-resource/mcp. Pad only registered the exact-match root route, so that request fell through to the SPA catch-all and OAuth discovery died JSON-parsing HTML (Kimi CLI / FastMCP 3.2.4). Register the path-aware route for the two shapes a pasted transport URL actually produces (/mcp and trailing-slash /mcp/), serving the identical canonical document. Bounded rather than a wildcard: the handler emits Cache-Control public max-age, and a wildcard would hand a CDN one cacheable object per attacker-chosen suffix (codex round 2). Deliberately NOT touched: NormalizeAudience / audienceMatchingStrategy (the body's "secondary" fix) — shared by the AS-side strategy and the RS-side token check; widening it is a separate security-boundary item. For the same reason the suffixed doc keeps the canonical bare-host `resource`: echoing .../mcp would steer compliant clients into an audience the AS still rejects (codex round 1, declined — doc-following clients converge on the canonical audience and work end-to-end). Test: TestMCP_DiscoveryDoc_PathAwareWellKnown decodes both suffixed variants into the typed doc and compares field-by-field against the root response (SPA HTML cannot satisfy it), pins that an arbitrary suffix does NOT get the doc, and the path-aware URL joins the cloud-mode-off 404 list. Mutation-verified: with the route lines removed the test fails 404. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
9c155ac185 |
fix(cli): gate promptAndBootstrap on canPromptForConfig() (BUG-2597) (#1119)
Third member of the BUG-2577 family (offerSkillInstall #1111, installInteractive #1116): promptAndBootstrap — the legacy --cli-prompt admin bootstrap — guarded its prompts on stdin-only term.IsTerminal, so a pty-backed harness with a redirected stdout got " Email: " printed into the pipe and then blocked on the read. Swap to canPromptForConfig() (stdin AND stdout) with the family's boundary comment; the BUG-988 refuse-with-headless-hint behavior is unchanged. The error message no longer blames stdin specifically ("not running in an interactive terminal") since the widened gate can fire when stdin IS a terminal and stdout isn't; the existing non-TTY test's assertion updated to match. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
d843752091 |
docs: worktree web-tooling rules in CLAUDE.md; fix vitest.config.ts's dangling pointer (TASK-2590) (#1118)
CLAUDE.md gains the "Working in a git worktree" section that web/vitest.config.ts:41 has pointed at since the fs.allow fix — it never existed (grep worktree/npm ci/node_modules: zero hits). Content per the corrected day-38 ruling on TASK-2590, not the task's original body: the symlink stays fine and stays the recommendation; the real prerequisite is `npx svelte-kit sync` (a fresh worktree has no generated web/.svelte-kit, and vitest fails on the missing tsconfig either way — the 2x2 on the trail shows the symlink was never the variable); and npm ci through a symlinked node_modules is the one genuinely destructive move (deletes the shared tree, stalls every session), which the original "npm ci, never symlink" rule would have instructed agents to do. Both documented legs verified as written in this very worktree: fresh + symlink -> vitest fails with the exact quoted TSCONFIG_ERROR; npx svelte-kit sync -> same test passes through the symlink (and through this edited config file). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
3098a1f569 |
fix(web): search Enter go-to accepts full refs like TASK-1345 (BUG-2128) (#1117)
* fix(web): search Enter go-to accepts full refs like TASK-1345 (BUG-2128) The palette's Enter fast-path only matched bare digits (/^\d+$/, BUG-910), so typing a full ref + Enter fell through to the arrow-selection guard and did nothing. Extract the routing decision as parseGoToTarget() beside REF_PATTERN_RE (pure, unit-tested): bare number keeps its match-any-collection semantics; a PREFIX-N ref (case-insensitive) must match prefix AND number via formatItemRef, so a typo'd prefix is an honest no-op rather than a cross-collection jump, and TASK-007 deliberately matches nothing rather than guessing TASK-7. The server-search fallback queries the bare number for both forms — the query shape the item_number path has always relied on. Live-verified against a sandboxed build (playwright, 4 legs): TASK-9 and task-9 navigate to /tasks/TASK-9, bare 9 still navigates (regression leg), TASH-9 stays put (control leg — the instrument detects non-navigation, which is exactly what the pre-fix build does on a full ref). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * fix(web): ref miss probes without clobbering results; stale-query fence (codex r1) Three findings from review: (1) a ref-form miss overwrote the palette's results/total/facets with the bare-number probe's result set — query and display diverged, and loadMore() would page the typed ref against numeric results; the ref probe now reads into a local and leaves displayed state alone. (2) the async fallback had no guard against the user typing past the pending probe (pre-existing on the numeric path, newly exposed for refs) — fenced on the typed-at-Enter query. (3) the numeric miss fallback now searches exactly what was typed again (leading-zero queries had silently switched to the canonical number). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
4a2c4c1a39 |
fix(cli): suppress pad agent install's dangling (Y/n) prompt in non-interactive contexts (BUG-2593) (#1116)
installInteractive gated its prompt on cli.IsTerminal() (stdin only), so a pty-backed harness whose stdin looks like a char device — with nobody able to answer — got "Install /pad skill for all N? (Y/n): " printed and then hung at readChoice. Same shape and same fix as offerSkillInstall's BUG-2577 (PR #1111): swap to canPromptForConfig() (stdin AND stdout), document the both-pty undetectable boundary, keep the auto-install behavior unchanged. Test mirrors #1111's offerSkillInstall test and pins the closed-stdin no-prompt path; the discriminating pty-stdin case is live-verified on the trail (pre-fix binary prints the prompt and hangs to a 10s kill, fixed binary installs silently and exits 0 — identical undriven-pty harness). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
2580c2c8bb |
fix(cli): gate pad init's Step-4 login on canPromptForConfig() (BUG-2592) (#1115)
* fix(cli): gate pad init's Step-4 login on canPromptForConfig() (BUG-2592) A configured-but-unauthenticated non-interactive `pad init` fell into doBrowserLogin and blocked on the poll wait (wall-clock-bounded since BUG-2572, still minutes of hang nobody can complete) instead of failing fast — Step 3 has had this exact gate since init.go:205, and cmd_workspace.go got it in PR #1111 (BUG-2538). The gate sits AFTER the saved-credentials check so a headless run with valid stored credentials proceeds untouched. Remedy text per the corrected trail ruling (the r1 constraint was refuted by r2): piped `pad auth login --interactive` IS a working non-interactive login (doInteractiveLogin reads a plain bufio.Reader, piped-bytes-safe since BUG-1886), so the message points there — and deliberately not at pad init's --email/--name/--password, which only fire when SetupRequired. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * docs(plugin): pad init no longer hangs in the session-expired case — update the three claims + plugin 0.2.1 The BUG-2592 gate makes three plugin-skill passages stale (same shape as PR #1111's codex r3 self-invalidation): capture and onboard said `pad init` can still hang on the browser flow when configured-but- unauthenticated, and the pad skill's whoami-guidance said the same at its "not a safer probe" sentence. All three now state the fixed truth, live-verified this session: fixed binary fails fast in 0.1s with the piped-login remedy; pre-fix control binary hangs to the timeout kill in the identical sandbox state; the remedy itself (piped `pad auth login --interactive`) logs in and restores credentials. Plugin 0.2.1 — text reaches nobody without a bump (version-pinned at install). Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
1882206bce |
docs(plugin): push-targeting etiquette + assignment-is-watch-only wording; plugin 0.2.0 (TASK-2591) (#1114)
* docs(plugin): push-targeting etiquette + assignment-is-watch-only wording; plugin 0.2.0 (TASK-2591) PLAN-2558 S6, the plugin-visible half that TASK-2551 deferred and S5 (PR #1108) made necessary: - monitors.json + SKILL.md no longer call assignment an addressed-to-you event (Phase 2 removed it from the addressed stream; assignment now arrives only via explicit watches) — the exact stale lines TASK-2564 recorded from PR #1092's codex round. - SKILL.md push etiquette covers S5 targeting: a push may be broadcast or targeted at one session (web composer picker / target_session_id; CLI always broadcasts), the notification line is identical either way, delivered_sessions is a pre-publish presence prediction (never a receipt, ~30s staleness on ungraceful drops), and pushes are never auto-retried — with the targeted-miss exception (delivered_sessions=0 on a targeted push means the publish was skipped, so a resend is safe by construction). - plugin.json 0.1.0 -> 0.2.0: the plugin is version-pinned at install (day-33, HANDO-120 delta (e)), so no text lands without the bump. - handlers_watch_events.go: the KNOWN-STALE pointer comment now records the fix instead of promising it. No behavior change. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * docs(plugin): delivered_sessions is API-response-only — CLI reports acceptance only (codex r1 P2) The sender-side bullet claimed the count was visible via pad push --format json; cli.PushResult omits DeliveredSessions, so CLI JSON cannot show it. State the truth instead: the API response carries it, the CLI surfaces nothing about delivery. Whether the CLI should surface it is a separate item, not a midnight scope expansion. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM * docs(plugin): watches deliver item events, not pushes (codex r2 P3) "cover every event on the watched item" implied a watcher sees pushes on that item; a push is addressed dispatch (the KindPush branch returns before the watch map) and reaches only its addressee. Claude-Session: https://claude.ai/code/session_018qREYgDd6Ag1X1SDmqhyFM |
||
|
|
ef903f0b22 |
feat(cli,mcp): --clear-parent / clear_parent to detach an item's parent (BUG-2078) (#1113)
* feat(cli,mcp): add --clear-parent / clear_parent to detach an item's parent (BUG-2078)
The server has honoured a present-but-empty "parent" key in fields_patch
as "clear the link" since BUG-2013, but neither the CLI (--parent ""
silently no-ops) nor MCP (parent is a plain string with the usual
"empty means not provided" convention) could reach it. Mirrors the
clear_assigned_user/clear_agent_role shape from IDEA-2584: a boolean
that carries its destructive meaning in its name and survives the trip
to local stdio MCP via BuildCLIArgs' snake_case-to-flag mapping.
Bumps ToolSurfaceVersion 0.18 -> 0.19 and updates the drift-pinned docs
(instructions.md, README.md) accordingly.
* test(cli,mcp): cover --clear-parent / clear_parent on both transports (BUG-2078)
CLI: --clear-parent sends fields_patch{"parent":""}; is absent when not
passed; conflicts with --parent and refuses without issuing a PATCH;
item create pins the deliberate create/update asymmetry.
MCP: clear_parent detaches through the real store+server (not a
recording handler) so the assertion is "item ends up unparented", not
just "payload shaped correctly"; clear_parent=false is inert; a plain
empty `parent` string stays a no-op (control leg); a simultaneous
parent + clear_parent is refused via both the direct param and the
--field-lifted route.
* fix(cli,mcp): close --clear-parent bypass via --field parent/plan aliases (BUG-2078, codex r1 P1)
extractParentLink (internal/server/handlers_items.go) resolves the parent
link from either a "parent" or a "plan" key in fields_patch, with no
early exit, so the later key in its own loop wins. The clear_parent
conflict check only covered one path each on the two client surfaces:
- CLI: the check ran BEFORE the --field overlay and only compared
against --parent's own value, so `--clear-parent --field parent=X`
(or `--field plan=X`) reached the wire unrejected — the --field loop
ran after clearParent's own `patch["parent"] = ""` and silently
overwrote it.
- MCP HTTP dispatcher: the check ran after the --field overlay (correct
ordering) but only inspected `patch["parent"]`, missing the "plan"
alias route.
Both surfaces now run the clear_parent check after every patch-building
step (named flags, --field overlay, column lift) and check both
"parent" and "plan" for a competing non-empty value.
* fix(cli,mcp): refuse --clear-parent/clear_parent when schema shadows "parent"/"plan" (BUG-2078, codex r2 #2)
extractParentLink (internal/server/handlers_items.go ~L606-610) is a
pre-existing, deliberate policy: it skips hierarchy handling entirely
when a collection's schema declares its own field literally named
"parent" or "plan", letting the value fall through as an ordinary
field write instead. Once {"parent":""} reaches the server it can no
longer distinguish clear-hierarchy intent from a legitimate
blank-my-schema-field write, so a client-side clear_parent request
against a shadowed collection used to report success while silently
blanking the data field AND leaving the real hierarchy link untouched
-- reproduced empirically before this guard existed.
The ambiguity is created at the surface that accepted the clear
request, so that surface refuses rather than pushing the decision
server-side (server-side refusal would also break legitimate blanking
of a real schema field).
CLI: the check is free -- collSchema is already fetched for --field
type parsing whenever any field change (including a bare
--clear-parent) happens.
MCP HTTP dispatcher: adds one conditional collection lookup, paid only
when clear_parent=true -- the common update path fetches no schema
today and doesn't start.
* docs: sync repo CLAUDE.md tool-surface contract to v0.19 (BUG-2078, codex r3 P2)
CLAUDE.md's MCP tool-surface prose still said "currently v0.18" and its
changelog omitted clear_parent -- a consumed-artifact gap, same rule as
the SKILL.md case: the doc a diff invalidates ships with the diff.
Synced three spots (intro paragraph, Tools bullet, ToolSurfaceVersion
stability-contract changelog) to v0.19, matching internal/mcp/version.go's
in-code entry's wording, plus the schema-shadow refusal (BUG-2078's
second follow-up commit) at the same level of detail the changelog
already gives the parent/plan alias conflict-refusal.
Grepped the rest of CLAUDE.md for any other 0.18/tool-surface reference
-- none found outside these three lines.
* docs: add schema-shadow refusal to version.go's v0.19 changelog entry (BUG-2078, codex r3 follow-up)
The in-code changelog is the canonical source; it was missing the
codex r2 schema-shadow refusal that a later commit added, which is
why CLAUDE.md and version.go briefly disagreed. Completes version.go
instead of letting CLAUDE.md drift ahead of it.
|
||
|
|
d895418ea2 |
fix(server): gate RequireAuth's cloud-secret bypass on validated session (BUG-1944) (#1112)
Sibling of TASK-1932's CSRFProtect fix: RequireAuth's isCloudAdminPath + hasCloudSecretMarker bypass fired on marker presence, not validated secret. Mirror TASK-1932's currentUser(r) == nil gate exactly. Concretely closes a disabled-admin gap: without the gate, a marker with the wrong secret let RequireAuth's own user.IsDisabled() check be skipped whenever a session was present, reaching handlers that trust a resolved admin session as an alternative to validateCloudSecret. In-handler validation for every cloudAdminPaths handler is unchanged and remains the independent layer for the genuine no-session sidecar case. |
||
|
|
ac05d8a2b1 |
fix(cli): fail fast and quiet on non-interactive workspace init (BUG-2538, BUG-2577) (#1111)
* Fail fast and quiet on non-interactive `pad workspace init` BUG-2538: initCmd drove runBrowserSetup/doBrowserLogin unconditionally when the instance needed first-run setup or login, blocking a non-interactive caller (script, CI, headless agent) on a browser handoff nobody can complete. Gate both branches on canPromptForConfig(), mirroring the precedent already used by `pad init` (init.go:205-206), and fail fast with a hint pointing at `pad init --email/--name/--password` or `pad auth setup`/`pad auth login`. BUG-2577: offerSkillInstall (shared by workspace init and workspace link) printed a "(Y/n): " prompt even when the answer would be auto-defaulted rather than read, because it gated on cli.IsTerminal() (stdin only). Switch to canPromptForConfig() (stdin AND stdout), which is the same predicate now used for BUG-2538 and the more robust of the two checks already in the codebase. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * Fix wrong remedy in BUG-2538's !Authenticated error message codex r1: the !Authenticated branch suggested `pad init --email/--name/--password`, but those headless flags only bootstrap the first admin account and only fire when SetupRequired — for an already-set-up-but-unauthenticated instance, `pad init` falls through to its own ungated Step 4 re-auth (BUG-2592), so the suggestion relocated the hang instead of avoiding it. Drop the pad-init suggestion in this branch only; point at `pad auth login` and note there's no non-interactive login path yet. SetupRequired branch is unchanged — its pad-init suggestion is correct for that state. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * Fix two more inaccurate remedies flagged by codex r2 1. SetupRequired branch: `pad init --email/--name/--password` silently eats the caller's workspace name/--template — pad init creates its own CWD-named workspace as a side effect, so a re-run of the original `pad workspace init <name> --template <t>` short-circuits on the link pad init just made with no signal <name>/<t> were ignored. Switch the remedy to `pad auth setup --email/--name/--password`, which bootstraps the admin account only (no workspace side effects), then re-run the original command. 2. !Authenticated branch: the "no non-interactive login path exists" claim was false — `pad auth login --interactive` reads email/password off a plain, TTY-ungated bufio.Reader (doInteractiveLogin, cmd_auth.go:554+; BUG-1886 made it piped-bytes-safe), so it works fine when credentials are piped in. Reworded to point at it and dropped the incorrect BUG-2592 reference (that bug tracks pad init's ungated Step 4, not a missing login mechanism). TestWorkspaceInitNonTTYSetupRequired's assertion updated from "pad init" to "pad auth setup" to match; TestWorkspaceInitNonTTYNotAuthenticated needed no change (still asserts "pad auth login"). Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * Update skill docs invalidated by the non-interactive fast-fail fix codex r3: BUG-2538/BUG-2577 made this diff's own docs stale. Four files (skills/pad/SKILL.md, plugin/skills/pad/SKILL.md, plugin/skills/onboard/SKILL.md, plugin/skills/capture/SKILL.md) still say non-interactive `pad workspace init` on a configured-but- unauthenticated machine "blocks for minutes with no non-interactive fallback" — that was true pre-fix (per BUG-2541's verification) and is false now. Reworded the WHY without dropping the underlying do-not-run-blind guidance: an agent's tool call is always non-interactive, so it now gets a fast, actionable error instead of a hang, but the error still just says a human needs an interactive terminal — `pad auth whoami` remains the right check to run instead. Where the docs' `pad init` claims are about the still-unfixed session-expired path (BUG-2592, this diff's Step-4 sibling, left untouched), those claims are unchanged and now cite BUG-2592 explicitly. skills/INSTALL.md:24 updated separately (P3): notes the non-interactive silent-install branch of `pad workspace init`'s skill offer, alongside the existing interactive-prompt description. Docs only, no Go changes — go build/test and embed.go's //go:embed skills/pad/SKILL.md still resolve; no test asserts the old wording. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
30274057eb |
fix(release): keep RC tags off brew and docker :latest (BUG-2524) (#1110)
A prerelease tag (vX.Y.Z-rc.N) published the RC to both mainstream install channels: homebrew_casks had no skip_upload (goreleaser's default uploads the cask for prereleases too) and dockers_v2 listed "latest" unconditionally, so every tag moved the floating tag. Both were verified serving 0.12.0-rc.1 during the v0.12.0 cut. skip_upload: auto skips cask upload for prereleases; the conditional latest template evaluates empty on RCs, and goreleaser ignores empty tags (documented behavior, and the docs' own conditional-tag example uses this exact shape). Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
7d5d3bd672 |
fix(cli): give the CLI auth poll loop its own wall-clock timeout (BUG-2572) (#1109)
* fix(cli): bound pollAndSaveCLIAuth with its own wall-clock timeout (BUG-2572) pollAndSaveCLIAuth had no wall-clock limit of its own — the ~5m bound users rely on was purely the server-side session TTL, so an unreachable server after session creation left the poll loop spinning forever on Ctrl-C alone. Add a 20m timer (matching the longer of the two server TTLs, since this helper is shared by both the plain login and first-run setup flows) plus a consecutive-transient-error bound so a permanently unreachable server fails fast with a network-shaped error instead of waiting out the full timeout. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * fix(cli): make poll-error headline accurate for HTTP-error servers (BUG-2572 r2) The consecutive-error bail-out message claimed "could not reach server", but client.get returns an error for both transport failures and non-2xx HTTP responses, so a server that's reachable but persistently returning 500 got misreported as unreachable. Bailing out fast is still correct for that case; only the headline was wrong. Switch to a cause-neutral message and let the wrapped error carry the specifics (codex round 2). Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
00a91dfcf4 |
feat(push): session targeting — target_session_id + delivered_sessions (TASK-2588) (#1108)
* watchevents: add session-targeted push delivery predicate
PLAN-2558 S5 (TASK-2588). Notification gains TargetSessionID,
evaluated in the existing per-connection KindPush predicate in
watchNotificationVisible alongside TargetUserID — one delivery path,
targeted is broadcast-with-a-predicate, no bus changes. Empty
TargetSessionID (the pre-S5 shape) still matches every one of the
target user's sessions.
* server: accept target_session_id on push, report delivered_sessions
PLAN-2558 S5 (TASK-2588). POST .../items/{slug}/push accepts an
optional target_session_id (an id from GET /api/v1/sessions) and the
response gains delivered_sessions — a prediction read from the S1
presence registry at push time, scoped to the caller's own
ListForUser(userID) so a vanished id and one belonging to a different
user are both an honest 200/0 with no existence oracle across users.
Omitting the field keeps the exact pre-S5 request/response shape.
* web: session picker in the push composer, targeted-miss handling
PLAN-2558 S5 (TASK-2588). PushToAgentDialog gains a target picker
(broadcast default + one option per live session), reusing the
presence read already fetched for the count — no second GET
/api/v1/sessions. Selecting a session passes target_session_id;
leaving it untouched keeps the exact pre-S5 3-argument push() call.
A targeted miss (delivered_sessions === 0) toasts "that session is
gone — refresh the list", drops the selection back to broadcast, and
re-polls presence instead of closing — zero delivery means nothing
was sent, so nothing is duplicated by resending.
* server: bound target_session_id, skip publish on a targeted miss
Codex round 1 fixes for TASK-2588:
- Cap target_session_id at 256 runes (400 over-cap) so an authenticated
caller can't park arbitrary garbage in the bus's shared replay buffer;
a registry-issued id (36 runes) can never hit this bound.
- Snapshot presence BEFORE publish instead of counting after: the old
order raced a target disconnecting between publish and count, which
could report delivered_sessions=0 on a push that had already landed
once. A targeted push now skips the publish entirely when its id
isn't in the pre-publish snapshot — session ids are per-connection
and never reused, so a target absent now can never be matched later,
making the 0 a guarantee rather than a race. Broadcast is unaffected
(still publish-always, pre-publish count).
Strengthened the targeted-miss and cross-user tests to assert the bus
does not grow (not just that the notification fails to arrive
downstream) — verified this fails if the skip-on-miss guard is
reverted.
* push targeting: document the pushed ruling, fix stale picker selection, guard mixed-version responses
Codex round 2 dispositions for TASK-2588:
- pushed:true on a skipped publish is RULED, not a bug (dispatcher):
moved the ruling from a test comment onto the contract itself —
pushResponse.Pushed's own doc comment in Go, mirrored in the TS
ItemPushResult doc comment.
- Fixed a real sharp edge: when a presence refresh drops the selected
session, a <select> can visually fall back to "All connected
sessions" while the bound value stays the stale id, so the wire
would carry a dead target the UI no longer shows as selected.
Added reconcileSelectedSession(), called at every point `sessions`
is reassigned outside the fresh-open reset (a live poll, a failed
read, and the staleness-expiry path).
- Guarded the mixed-version hazard with a cheap check, not capability
negotiation (the deployment shape — web assets embedded in the
server binary — bounds this to a transient stale tab, argument
recorded in the comment): delivered_sessions is now optional on the
wire type, and a targeted send whose response omits it entirely is
treated as UNKNOWN (info toast, dismiss like a normal success) —
never inferred as a confirmed miss.
Verified all three new/changed legs actually catch their regression
by temporarily reverting each fix and confirming the corresponding
test fails, then restoring.
* push targeting: fix stale publish-guarantee comments (codex round 3)
Two doc-comment remnants of round 2's skip-on-miss fix, both claiming
push unconditionally publishes:
- watchevents.KindPush's doc comment ("publishes exactly one of
these") now notes handlePushToItem decides whether to publish at
all, and points at TargetSessionID / pushResponse.DeliveredSessions
for why.
- api.items.push()'s JSDoc in client.ts no longer claims a resolved
promise means "published to the bus" unconditionally — a targeted
miss resolves with delivered_sessions: 0 and nothing published.
Comment-only; no behavior change.
|
||
|
|
d7da237198 |
feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584) (#1107)
* feat(mcp,cli): clear_assigned_user / clear_agent_role — a discoverable unassign (IDEA-2584)
v0.16 and v0.17 made unassigning WORK. Nothing advertised it. The params
that do it — `assigned_user_id` / `agent_role_id` — were never in the
catalog, so an agent reading the tool schema to find out how saw only
`assign` (a name) and reached for `assign: ""`, which is a no-op and
deliberately stays one. The capability existed with no name an agent
could find.
`clear_assigned_user` / `clear_agent_role` booleans on `pad_item`, backed
by new `--clear-assigned-user` / `--clear-agent-role` bareword flags on
`pad item update`.
WHY BOOLEANS rather than declaring the existing string params. Two
reasons, and the second decided it:
1. An empty DECLARED string is inert everywhere else on this tool
(title, content, comment, tags), so a client that pads optional
params with "" instead of omitting them is harmless today. Giving
one a destructive meaning would turn that same client into one that
silently unassigns every item it touches. A boolean carries its
meaning in its name and can't be tripped that way.
2. Only a boolean can REACH local stdio. BuildCLIArgs emits the CLI's
real flags, so a catalog param with no flag behind it is dropped
before dispatch — declaring `assigned_user_id` would have left the
direct form remote-only, i.e. would not have closed the gap this
change exists to close. That fact reframed the design fork and is
what the ruling turned on.
Server-side this is WIRING, not new semantics:
models.ItemUpdate.ClearAssignedUser / ClearAgentRole already existed and
the store has honoured them since BUG-2566, on the same branch as the
empty-string form. The older forms keep working and are NOT deprecated;
they're just not what the schema advertises.
UPDATE ONLY, deliberately asymmetric with create, and recorded in-place
at both the flag registration and the catalog description so a
symmetry-minded reader meets the reasoning before the "fix": clearing at
create is a request to not-set something never set, whose only honest
behaviour is a no-op — it teaches a wrong affordance and pads every
create call's schema. A test fails if someone adds them there.
CLI precedence is the OPPOSITE of the --field lift's, deliberately: an
explicit `--clear-assigned-user` beats `--assign`, because that
combination is a contradiction the user typed and the reading that
cannot silently assign somebody is the safer one. Tested.
The dispatcher forwards the booleans VERBATIM rather than only-when-true.
A `&& b` guard would read as the thing protecting a param-padding client
and would be lying: what makes `false` inert is the store. Same call I
made on #1106's `len(patch) > 0` — a guard that reads as load-bearing
while doing nothing is worse than none.
ToolSurfaceVersion 0.17 -> 0.18, ADDITIVE bump per the v0.5 / v0.6
precedent: no existing tool, action or param changed shape.
Consumed artifacts moved in the same commit, which is the whole point of
this change — the schema IS the deliverable: catalog_item.go (the schema
agents read, plus an `assign` description that now says where to find the
clear), instructions.md (leads with the boolean, mentions the older forms
as still-working), version.go, README, CLAUDE.md.
VERIFIED LIVE, five legs, both transports:
CLI --clear-assigned-user -> assigned=None, role intact
CLI --clear-agent-role -> role=None
stdio clear_assigned_user:false -> assignment SURVIVES and the
update still applied (title
changed) — the control that
makes the boolean safe to
declare at all
stdio clear_assigned_user:true -> assigned=None
stdio clear_agent_role:true -> role=None
Three mutations, each failing only its own tests: dropping the dispatcher
forwarding; hardcoding true in the dispatcher (fails the false-control);
dropping the CLI flag wiring.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Closes IDEA-2584.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(mcp,cli): refuse a simultaneous set-and-clear (codex round 1)
Codex found a real bug, and the more useful half of the finding is that
MY OWN TEST FOR IT WAS VACUOUS.
The store's branch order is `if AssignedUserID != "" { set } else if
ClearAssignedUser { clear }`. So `--assign wren --clear-assigned-user`
assigned Wren and the clear evaporated. My in-place comment claimed the
opposite ("an explicit clear wins"), and the test I wrote to prove it
asserted `body["clear_assigned_user"] == true` — that the FLAG was set,
not that the item ended up unassigned. The flag was set. The behaviour
was backwards. A test that asserts a field is present says nothing about
which field wins.
Both surfaces now REFUSE the contradiction rather than silently resolving
it. Rejecting beats picking a winner here: the store already picks one
silently, which is the bug; and a caller who typed both wants to be told,
not guessed at. Precedent in the same command family — `item list`
already makes `--parent` and `--unparented` mutually exclusive.
PLACEMENT IS THE LOAD-BEARING PART, and I got it wrong first. There are
two routes to a competing value: `--assign` / `assigned_user_id`, which
resolve early, and `field: ["assigned_user_id=<uuid>"]`, which reaches
the payload via liftFieldsToColumns LATER. My first version checked
between them and its comment asserted the lift "has already" run — it
hadn't. That version rejects the direct case and lets the lifted case
through: a half-fix that reads as complete. The check now runs after
both, in the CLI after --assign/--role resolution and the lift, in the
dispatcher immediately before the body marshal.
That mutation is now a test: moving the dispatcher check back to the
pre-lift view fails ONLY the two `lifted …` subtests and passes the
direct one — the exact shape of the bug I nearly shipped.
Tests assert the OUTCOME, not the message: a refused conflict must leave
the item's assignment AND role untouched, and the CLI must issue no PATCH
at all. An error string alone wouldn't prove the write didn't happen.
Agent-facing text moved with it (the consumed-artifact step): both
catalog descriptions, instructions.md, and the v0.18 version entry now
say the combination is refused. An agent that pairs them gets a
structured refusal, so the schema has to say so.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
312f28dd14 |
chore(deps)(deps-dev): bump vitest from 3.2.6 to 4.1.10 in /web (#1045)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.6 to 4.1.10. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
847ee73327 |
fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583) (#1106)
* fix(cli): lift assigned_user_id / agent_role_id onto their columns (BUG-2583)
`pad item update TASK-9 --field assigned_user_id=<uuid>` wrote the pair
into the item's FIELDS JSON BLOB while the column stayed stale, and then
printed "Updated TASK-9". Two defects in one line: a success message for
a write that did nothing the caller asked for, and a blob key shadowing a
real column's name, so the CLI surface diverged from store/HTTP/MCP
truth. The empty-string case was the same defect wearing a worse hat —
it was the only route an agent had to unassign an item.
Blast radius beyond the CLI: local stdio MCP (`pad mcp serve` — Claude
Desktop, Cursor, Windsurf) dispatches through ExecDispatcher, which
shells out to this CLI. So TASK-2571's fix reached the remote /mcp
transport only, and the transport most agents actually use still could
not unassign. This closes that half.
`cmd/pad/cmd_item.go` now lifts `columnFieldKeys` out of the --field map
onto the column pointers, on CREATE and UPDATE both, mirroring
internal/mcp/dispatch_http.go's liftFieldsToColumns — including its
INVARIANT, which is the part that matters: only keys with defined
clear-to-NULL semantics for "" belong in the list, and `tags` never does
(an empty write corrupts a JSONB column rather than clearing it). A test
fails if anyone adds it.
Two compat changes, ruled separately by the lead:
Q1 non-empty values move to the COLUMN and stop writing the blob key.
Accepted: relying on the old behaviour is relying on a shadowing
defect.
Q2 empty values clear the column. Falls out of the lift, inheriting
BUG-2566's store semantics.
`agent_role_id` gets identical treatment. Existing stray blob keys are
left alone per the ruling — this stops minting new ones; a sweep would
be its own change.
Precedence is explicit and tested: `--assign` / `--role` win over a
lifted --field value, matching liftFieldsToColumns' "caller-supplied
top-level values win". It is delivered by the ORDER of two blocks in the
command, which is exactly the kind of thing that gets reordered by
accident, so there is a test whose only job is to fail when it does.
A non-string --field value is deliberately NOT lifted: a collection that
genuinely declares a field with one of these names makes parseFieldFlag
return a typed value, which cannot address a column. It stays in the
blob — today's behaviour and the only lossless option.
ToolSurfaceVersion 0.16 -> 0.17, and v0.16's transport-scope paragraph
now points forward rather than claiming a limitation that no longer
holds. Behaviour-only bump again, same grounds as v0.16 and v0.9. The
CLI's own marker, CmdhelpVersion, deliberately does NOT move: its
contract is flag/arg SCHEMAS, and no flag or argument changed shape.
instructions.md — the text agents receive at handshake — drops the
"remote only" caveat it carried since TASK-2571. That file is the reason
this PR exists in the shape it does: it is the artifact the actor reads,
and it was the one place the previous PR overclaimed.
VERIFIED LIVE against a running server, with a negative control, because
the claim is about a transport rather than a function:
legs, fixed binary
--field assigned_user_id= -> column CLEARED, blob clean
--field assigned_user_id=<uuid> -> column SET, blob clean
--field agent_role_id= / <uuid> -> same, sibling column untouched
stdio MCP tools/call pad_item
action=update field=["assigned_user_id="]
-> column CLEARED, blob clean
control, PRE-FIX binary, same server + same item + same JSON-RPC bytes
-> column UNCHANGED, blob polluted
with {"assigned_user_id":""}
Six unit tests in cmd/pad/item_column_fields_test.go, four mutations each
failing only its own test (no lift; drop non-strings; flip the
lift/assign precedence; add `tags` to the list). One assertion was
rewritten after mutation testing showed it was VACUOUS: `len(fields_patch)
!= 0` passes whether the key is absent or present-and-empty, so it now
asserts key PRESENCE — confirmed by mutating `omitempty` off the model
field and watching the old form stay green. The redundant `len(patch) > 0`
guard that assertion was meant to cover is gone too; `omitempty` already
does that job, and a guard that reads as load-bearing while doing nothing
is worse than no guard.
go test ./cmd/pad ./internal/mcp — pass. gofmt clean.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* test(cli): cover the create half of the column lift (BUG-2583)
Codex came back CLEAN, but the review reminded me I'd changed `item
create` and only tested it through `liftColumnFields` directly — no test
asserted what create actually puts on the wire. That's the weaker half to
leave uncovered, not the stronger one: on update a wrong write contradicts
a visible prior value, while on create the column-named key is simply
baked into the blob at birth with nothing to contradict it.
The assertion has to parse rather than index, because ItemCreate.Fields is
a JSON-encoded STRING and not a nested object — a body["fields"]["…"]
lookup would have been vacuous in a way that looks fine.
Mutation-tested like the rest: neutralizing the create-side lift fails
this test and only this test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): say WHICH form of the unassign works on which transport (codex round 2)
Codex round 2, and it is the same class of defect as the previous PR's
round 2 — an overclaim in the artifact agents actually read. My
instructions.md said "works on BOTH transports" of two forms that do not
behave the same:
field: ["assigned_user_id="] clears on BOTH transports
assigned_user_id: "" clears on REMOTE ONLY
The direct params are not declared in pad_item's schema. They reach the
remote mapper only by riding the verbatim input map; on stdio,
BuildCLIArgs drops unknown keys, so the call does nothing.
VERIFIED, not accepted on the reviewer's word, and the verification
corrected my own first reading. My initial probe appeared to show the
stdio call CORRUPTING the fields blob — but that blob key was leftover
state from the earlier pre-fix control leg, not something the probe
wrote. Re-run against a freshly created item, the two forms separate
cleanly:
before assigned=b6786b13... fields={priority,status}
after assigned_user_id:"" assigned=b6786b13... fields={priority,status} (clean no-op)
after field:["assigned_user_id="] assigned=None fields={priority,status} (cleared)
So the stdio behaviour of the direct param is a DROP, not a corruption —
worth stating precisely, because "it corrupts the blob" would have sent
the next reader hunting a bug that isn't there. (Identity-doc rule: a
guessed mechanism stated as the reason is a claim, not a hedge.)
instructions.md now leads with the form that works everywhere and names
the remote-only limitation of the other; version.go and CLAUDE.md say the
same. IDEA-2584 — declare the params properly — is the fix that would
collapse this distinction, and is now cited from all three.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(cli): don't lift a field the collection actually DECLARES (codex round 3)
Nothing reserves `assigned_user_id` or `agent_role_id` as field names, so a
collection may legally declare a field with one of those keys. For that
collection `--field assigned_user_id=foo` means the DECLARED field — and
the lift I just added would redirect it to the assignment column while
dropping the value the user set. Two wrongs from one line: the intended
write vanishes and an unintended one happens.
liftColumnFields is now schema-aware and never lifts a declared key. Cheap
to do here because both call sites already fetch the collection schema for
parseFieldFlag. The check is PER-KEY — an undeclared sibling still lifts,
so one collision doesn't disable the feature — and a schema-fetch failure
degrades toward lifting, matching how the rest of --field handling degrades.
This makes the CLI deliberately STRICTER than the MCP dispatcher it
otherwise mirrors. liftFieldsToColumns has the identical collision and
can't make the same check as written: it builds its fields map straight
from the tool input without fetching a schema. Filed as IDEA-2587 rather
than fixed here, because closing it costs a round-trip on a hot path while
the CLI fix was free — and recorded so the divergence is KNOWN, in the safe
direction, rather than something a later reader "fixes" by loosening the
CLI to match.
The old non-string branch stays as belt-and-braces: parseFieldFlag only
returns a non-string for a declared field, which the new check already
catches, but if that stops being true a non-string still can't address a
column.
Mutation-tested: ignoring the schema declaration fails the new test and
only that test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
b887b0bfe1 |
test(web): capture pristine DOM probes at module load in mockOpenModals helpers (#1105)
vitest 4 hands back the SAME spy when vi.spyOn targets an already-spied method, so a helper that re-captures "the real function" mid-test captures the spy itself and the pass-through branch recurses (swallowed by the :modal probe's guard, which then reads as ':modal unsupported'). Capturing document.querySelectorAll / Element.prototype.matches once at module load is correct under both vitest 3 and 4; suite measured 1609/1609 on each. Unblocks the vitest 3->4 major (dependabot #1045), whose merged-tree run failed 3 Lightbox drag-abort tests (TASK-2458) through this pattern. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
a7b70c2092 |
chore(deps)(deps-dev): bump @testing-library/jest-dom in /web (#1044)
Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.1. - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.1) --- updated-dependencies: - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
a8989a0ad3 |
chore(ci)(deps): bump actions/attest-build-provenance (#1071)
Bumps the actions-minor-and-patch group with 1 update in the / directory: [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance). Updates `actions/attest-build-provenance` from 4.1.1 to 4.2.2 - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/0f67c3f4856b2e3261c31976d6725780e5e4c373...4d101475d8b20a2381f78447822ac1eab6504dd8) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: 4.2.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
f1bd144b02 |
chore(ci)(deps): bump actions/checkout from 6.0.2 to 7.0.1 (#1073)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.1. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6.0.2...3d3c42e5aac5ba805825da76410c181273ba90b1) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
8ba08c7164 |
chore(deps)(deps): bump the npm-minor-and-patch group (#1072)
Bumps the npm-minor-and-patch group in /web with 7 updates: | Package | From | To | | --- | --- | --- | | [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `3.0.0` | `3.1.0` | | [mermaid](https://github.com/mermaid-js/mermaid) | `11.16.0` | `11.16.1` | | [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.77` | `0.9.78` | | [yjs](https://github.com/yjs/yjs) | `13.6.31` | `13.6.32` | | [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` | | [svelte-check](https://github.com/sveltejs/language-tools) | `4.7.4` | `4.7.5` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.0` | `8.2.1` | Updates `@dagrejs/dagre` from 3.0.0 to 3.1.0 - [Release notes](https://github.com/dagrejs/dagre/releases) - [Changelog](https://github.com/dagrejs/dagre/blob/master/changelog.md) - [Commits](https://github.com/dagrejs/dagre/compare/v3.0.0...v3.1.0) Updates `mermaid` from 11.16.0 to 11.16.1 - [Release notes](https://github.com/mermaid-js/mermaid/releases) - [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.0...mermaid@11.16.1) Updates `svelte-dnd-action` from 0.9.77 to 0.9.78 - [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md) - [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits) Updates `yjs` from 13.6.31 to 13.6.32 - [Release notes](https://github.com/yjs/yjs/releases) - [Commits](https://github.com/yjs/yjs/compare/v13.6.31...v13.6.32) Updates `marked` from 18.0.7 to 18.0.9 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9) Updates `svelte-check` from 4.7.4 to 4.7.5 - [Release notes](https://github.com/sveltejs/language-tools/releases) - [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.7.4...svelte-check@4.7.5) Updates `vite` from 8.2.0 to 8.2.1 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.2.1/packages/vite) --- updated-dependencies: - dependency-name: "@dagrejs/dagre" dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-minor-and-patch - dependency-name: mermaid dependency-version: 11.16.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: svelte-dnd-action dependency-version: 0.9.78 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: yjs dependency-version: 13.6.32 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: marked dependency-version: 18.0.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: svelte-check dependency-version: 4.7.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch - dependency-name: vite dependency-version: 8.2.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> |
||
|
|
ee05c58446 |
fix(mcp): let an agent clear an item assignment (TASK-2571) (#1104)
* fix(mcp): let an agent clear an item assignment (TASK-2571)
Two filters in the MCP dispatch path dropped an empty-string assignment
value before the request body was built, so an MCP agent had no way to
UNASSIGN an item — `assigned_user_id=""` was a silent no-op rather than a
clear or an error:
- mapItemUpdate's top-level pass-through (dispatch_http_advanced.go)
- liftFieldsToColumns (dispatch_http.go), which lifts `--field` entries
onto their columns. This is the path an agent actually reaches: the
catalog exposes `assign` (a name) and `field`, but no
`assigned_user_id` param, so `field: ["assigned_user_id="]` is the
only schema-visible way to ask.
Both were right when written — `""` had no defined meaning at the store
and bound an empty string into a FK column. BUG-2566 gave `""`
clear-to-NULL semantics for exactly these two columns and the HTTP
surface inherited it, which left MCP the odd surface out. Uniformity
restoration, not a new feature.
Compat posture ACCEPTED per the lead's ruling: a caller sending `""`
today gets a no-op, and after this gets a clear. That is the correct
reading of the input — nobody sends an empty assignment ID meaning
"leave it alone" — and the no-op is the surprising half. Option (b)'s
clear_assigned_user / clear_agent_role schema flags are deliberately
skipped as additive sugar.
The empty-string filter on `tags` three lines above STAYS (codex #547 r3
P2): `tags: ""` is not a clear, it is a corrupt write into a JSONB
column on Postgres and TEXT on SQLite. Same-looking guard, opposite
justification — the new test's control leg fails if someone "unifies"
them.
ToolSurfaceVersion 0.15 -> 0.16. No tool, action, or parameter shape
changed, so this is a BEHAVIOUR bump on the v0.9 precedent (which moved
for a return shape with an unchanged signature). Flagging it for the
lead as my call, not theirs — it is a one-line revert if they read the
contract differently.
TRANSPORT SCOPE, established live rather than assumed: this fixes the
REMOTE /mcp transport, where both filters lived. LOCAL STDIO MCP still
cannot clear, because ExecDispatcher shells out to the CLI and the CLI
has no unassign at all — `--assign`/`--role` skip on empty, and
`pad item update TASK-9 --field assigned_user_id=` writes
{"assigned_user_id":""} into the item's FIELDS BLOB while the column
stays set (observed against a running server). Separate defect, CLI-wide
blast radius, filed separately rather than riding along on a ruled-scope
PR. The version-history entry says so explicitly so the note can't be
read as covering it.
Tests: internal/mcp/dispatch_http_clear_assignment_test.go drives the
REAL server + store, not a recording handler — asserting the dispatcher
merely puts `""` in the payload would restate the fix rather than test
it. Three mutations, each failing only its own test: restoring the
top-level filter fails the two direct-param tests; restoring the lift
filter fails the --field test; removing the tags filter fails the
control leg.
go test ./internal/mcp ./internal/store ./internal/server — all pass.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): record why an empty `assign` alias still doesn't clear (codex round 1)
Codex's finding is REAL: the catalog exposes `assign` / `role`, not
`assigned_user_id` / `agent_role_id`, so an agent reading the schema will
reach for `assign: ""` to unassign and get a no-op. The fix as shipped
only covers the params an agent has to already know exist.
Its suggested remedy — map the empty aliases to a clear — is the riskier
of the two it lists, and I've deliberately not taken it.
`assign` is SCHEMA-DECLARED. Every other schema-declared string on this
mapper (title, content, comment, tags) follows one convention: empty
means NOT PROVIDED. An MCP client that fills declared optional params
with "" instead of omitting them is harmless today; making `assign: ""`
mean "clear" would turn that same client into one that silently
unassigns every item it touches — destructive, silent, and inconsistent
with the four params beside it. That is exactly why the same change IS
safe for `assigned_user_id`: an agent can only send it deliberately.
The remedy that closes the gap without that hazard is the other one
codex names — explicit clear_assigned_user / clear_agent_role params,
i.e. option (b) on TASK-2571, which the lead deferred as additive sugar.
This finding is new evidence for revisiting that, so it goes to the lead
as a decision rather than being taken unilaterally in a ruled-scope PR.
Adds the reasoning at both call sites and a test that pins the limit, so
a future "finish the job" edit fails a test and has to be a decision
rather than a drive-by. The MCP instructions already name the working
form meanwhile.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): scope the unassign instructions to the transport where it works (codex round 2)
Codex round 2, and it caught a defect in my own round-1 documentation
fix. instructions.md is the text sent to agents at handshake, and BOTH
transports serve the same string — so telling agents "pass
assigned_user_id: '' to unassign" was true on remote /mcp and a lie on
local stdio, where ExecDispatcher shells out to a CLI that has no
unassign path. I had scoped the claim carefully in version.go and the
commit message and then overclaimed in the one place agents actually
read.
The instructions now name the transport, say plainly that stdio ignores
the value, and tell the agent to verify rather than assume. An agent can
act on a conditional; it cannot act on a claim that is false half the
time.
Both gaps are now filed rather than merely described:
BUG-2583 — the CLI has no unassign at all, and `--field
assigned_user_id=` writes into the item's FIELDS BLOB
while the column stays set (verified live: fields became
{"assigned_user_id":"", ...} and the CLI printed
"Updated TASK-9"). This is what makes stdio MCP fail.
IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
so an agent reading the schema still cannot discover the
clear. Reopens option (b) with codex's evidence.
version.go and CLAUDE.md now cite both refs, so the version-history
entry can't be read as covering more than it does.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* docs(mcp): cite BUG-2583 / IDEA-2584 in the version history and CLAUDE.md
Follow-up to the previous commit: its scripted edits to version.go and
CLAUDE.md silently no-op'd (a gofmt rewrap moved the anchor text), so
only instructions.md actually changed. Caught by grepping for the refs
rather than trusting the commit.
Both files now name the two filed gaps, so the v0.16 entry cannot be
read as covering more than it does:
BUG-2583 — the CLI has no unassign, which is why local stdio MCP
still can't clear.
IDEA-2584 — the catalog exposes `assign` / `role`, not the ID params,
so the clear stays undiscoverable from the schema.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
bfa90826e9 |
feat(web): quick actions push to a connected agent session (TASK-2562) (#1103)
* feat(web): quick actions push to a connected agent session (TASK-2562)
PLAN-2558 S4. `resolvePrompt()` already did the templating push needed
(`{ref} {title} {status} {priority} {collection} {content} {fields} {plan}
{phase}`); only the last hop was a clipboard ferry. That hop is now a push,
and the clipboard becomes the fallback rather than the mechanism.
Zero-touch migration, as the task required: quick actions are still
`{label, prompt, scope, icon}` in collection settings. No settings rewrite,
no schema change, no per-action opt-in.
Routing, per the plan's ruling — with zero live sessions, fall back to the
clipboard with an honest toast, never a hard error and never a queue:
session(s) live push the collapsed prompt; toast hedges ("delivery isn't
confirmed") because a push gets no ack
zero sessions copy, "No agent session connected — copied to clipboard
instead"
can't tell copy. This is where S4 DIVERGES from S3's dialog, which
leaves Send enabled on an unreadable presence answer. The
dialog is right to: the warning is on screen and the user
chooses with it in front of them. A quick action asks
nobody, so the tie goes to the lossless branch — copying
when we could have pushed costs one paste, pushing into
nothing loses the instruction outright.
no item to collection-scope actions keep the pre-S4 behavior exactly.
address The endpoint is POST .../items/{slug}/push and there is
nothing to point it at, so they don't even spend a
presence read finding that out.
PRESENCE IS READ WHEN THE MENU OPENS, NOT ON THE CLICK, and that is the one
non-obvious thing in this diff. Both clipboard APIs want the user gesture
that is live during the click handler and gone after a network round-trip
(Safari strictest, Firefox too). Deciding from a read issued on the click
would put an await in front of the very fallback this slice promises. The
cost is a small window — click before the first read lands and presence is
null, which routes to the clipboard with an honest toast — and that is the
right way round.
The menu's footer line now says which way the next click will go, so the
routing is visible before it happens rather than only in the toast after.
A logged-out or workspace-token viewer gets a 401 from /sessions, which is
"can't tell", which is the clipboard — today's behavior, no gating needed.
Push failure splits on the same line CopyItemDialog and the S3 composer draw
(DR-13): a recognised pre-publish refusal means nothing went out, so the copy
is OFFERED as a toast action (a fresh gesture, which is what makes a
clipboard write work this long after the original). An unrecognised failure
leaves the outcome unknown — the handler publishes BEFORE it writes its
response — so nothing is offered, because a paste would be the duplicate the
message is warning about on an endpoint with no idempotency key.
PRE_PUBLISH_ERROR_CODES moved out of PushToAgentDialog into
$lib/push/dispatch so the two surfaces can't drift on it.
Also: the local `copyToClipboard` is replaced by `$lib/utils/clipboard`'s.
The local one returned true from the promise path WITHOUT awaiting it, so a
rejected write reported success and never reached the execCommand fallback —
harmless when copying was a convenience, not harmless now that "we copied
instead" is a load-bearing claim.
Verified live against a throwaway instance (built binary, real browser),
three legs, with the SSE stream as the receipt:
no session tagline "No agent session connected — actions copy to your
clipboard"; toast matches the ruling; clipboard holds
"Implement TASK-9: Ship the thing (status open)"
one session tagline "Pushes to your connected agent session"; the
connected stream RECEIVED {"kind":"push","item_ref":
"TASK-9","summary":"Implement TASK-9: ..."}; clipboard
untouched
presence 503 same live session still connected, /sessions aborted: copies
instead, and the stream's push count did NOT increase — the
counterfactual, not just the end state
Each of the five behaviours is mutation-tested 1:1 against its test: an
await before the copy fails ONLY the synchronous-gesture test; routing
'unknown' to push fails only the two uncertainty tests; dropping the collapse
fails only the raw-vs-collapsed test; offering a copy on an unconfirmed push
fails only that test; copying on the happy path fails three.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): expire a stale presence answer in the quick-actions menu (codex round 1)
Codex's one finding, and it is real. A FAILED poll already degrades to
'unknown'; a poll that HANGS does not — it simply never writes, so the last
count stayed in place indefinitely while the menu went on offering a push
into a session that may have dropped minutes earlier. That is the one
direction that loses the user's instruction, which is the whole thing this
slice exists to prevent.
A 'known' answer now expires after 30s without a refresh — the server's own
worst-case presence staleness (watchEventsKeepaliveInterval), the same bound
and the same reasoning as PushToAgentDialog's round-2 fix. Requests already
in flight at the moment of expiry are retired (presenceAppliedSeq advances to
presenceSeq) so one issued BEFORE the expiry cannot land after it and restore
the very count we just declared too old to trust.
The expiry is checked in TWO places, and the second is the one worth noting:
the poll tick rewrites the state (so the footer line stops claiming a
connection), but the ROUTING decision reads through `currentPresence()` at
click time. A tick-only expiry leaves a window of up to one whole poll
interval in which the menu still pushes against a count it has already
outlived — and a click is exactly what lands in that window.
Both halves mutation-tested: disabling the expiry fails the two staleness
tests and leaves the control leg (polls still landing → no downgrade) green;
reading raw `presence` at click time instead of `currentPresence()` fails
ONLY the between-ticks test.
npm run check 0 errors · web unit suite 1607 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): the offered copy reports its own outcome (codex round 2)
Round 2's one finding, and it is real. `Copy instead` on a push-failure toast
discarded the `copyToClipboard()` result, and taking the offer dismisses the
toast that carried it — so a failed copy said nothing at all. That silence is
worst on exactly this path: a pre-publish refusal means the instruction was
never sent, so a silently-failed copy leaves it neither sent NOR copied, with
the user believing they rescued it.
The offer now routes through the same `copyAndAnnounce` every other clipboard
path uses, under a new `'offered'` ClipboardReason that renders the plain
"Copied to clipboard" — the user asked for the copy, so there is no absent
push to explain — and the ordinary error on failure.
Mutation-tested: reverting to the discarded-result form fails the new test
and the existing pre-publish test, and nothing else.
npm run check 0 errors · web unit suite 1608 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): flip the quick-actions footer line when the answer comes due (codex round 3)
Round 3's finding, and it is a self-inconsistency round 1 introduced. The
click-time expiry check made the ROUTING correct immediately, but the footer
line still read raw `presence` until the next 10s poll tick — so for up to a
full interval the menu said "Pushes to your connected agent session" while the
next click would copy. That line's entire job is to say what the next click
will do.
A successful read now arms a one-shot timeout at the exact expiry, so the
display flips when the answer comes due rather than when a poll happens to
notice.
`currentPresence()` STAYS, and the redundancy is the point: a timer is a
request, not a guarantee. Browsers throttle timers hard in a backgrounded tab,
so the expiry can fire long after it came due — including after the user has
returned and clicked. The timer keeps the DISPLAY honest; the click-time check
keeps the DECISION correct, and only the decision can lose a message. The poll
tick keeps its expiry check for the same reason.
Both halves mutation-tested, and they fail different tests: dropping
`armExpiry()` fails ONLY the comes-due test; dropping `currentPresence()` at
the click fails ONLY the throttled-timer test. That second test models
throttling by moving the CLOCK without running any timer — which is exactly
what a throttled tab looks like from the component's side, and is not
reachable with advanceTimersByTime.
npm run check 0 errors · web unit suite 1609 passed.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
79b3220c61 |
test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570) (#1102)
* test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570) The reload-fault closure now narrows the member's access on faulting tick 1 and lifts the fault on faulting tick 2, so consecutive reload failures stop at exactly 2 — strictly below the clear-the-watch-set bound — and the green path carries no timing bet at any load. The 300ms sleep is gone; readiness is signaled by the tick sequence itself. Codex round 1 on this fix surfaced that regression DETECTION still has a window (a successful tick 3 masks a hypothetical reset-skipped-on- fault regression), so the interval is set to 500ms to give the revoked PATCH ~10x headroom over measured loaded-runner request latency, and the control-leg wait — the one that timed out in both CI instances — is widened to 10s since it asserts delivery-at-all, not latency. Verified: 5x -race green at both 50ms and 500ms; counterfactual mutant (reset moved to the reload success path) leaks 3/3; full suite + lint green; Postgres leg 2x -race green. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt * test(server): drive reval ticks through a seam — deterministic in both directions (BUG-2570) Codex rounds on the first fix found two regression-DETECTION windows the interval-tuned shape could not close: a stray successful tick before fault installation or after the tick-2 lift resets visCache / reloads the watch list, masking the reset-skipped-on-fault regression this test exists to catch. Interval tuning trades green-determinism against detection-determinism; a free-running ticker cannot give both. So the handler gains watchRevalTickOverride — a test seam mirroring watchPredicatesLoadFault (atomic pointer, read once at stream setup) that lets a test substitute the reval tick source. The test now drives exactly ONE tick, after the access change, with the reload fault active: no early tick can mask via a pre-fault reset, no late tick can mask via a post-lift reload, and one faulting tick can never reach the clear-the-watch-set bound. No sleeps, no interval mutation, no wall- clock bets in either direction. Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt |
||
|
|
2c5803a204 |
fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) (#1101)
* fix(web): arm the connect-time sync on FIRST connect, not only on leader promotion (BUG-2540) A page reads its items and only then subscribes to SSE. A mutation landing in that window reaches nobody: no subscription exists yet so the frame is never received, and nothing reconciles the gap afterwards — the row stays stale until some unrelated event happens to trigger a sync. `pendingSyncOnConnect` is exactly the right mechanism and already existed, but was armed only on leader promotion and the lock-failure fallback. The FIRST connect — the one every page load performs — was uncovered. Arms it on every EventSource open instead. Cursor-advance semantics are untouched: the delta is asked from whatever cursor syncService already holds. IDEA-2535 owns that question. Also removes the "first leader vs promoted follower" classification (a `navigator.locks.query` probe plus a >100ms grant-delay heuristic from TASK-1359 rounds 2-3). It existed solely to gate this flag; with the flag armed unconditionally nothing reads it, and keeping it would mean a `locks.query()` round-trip per connect producing a value no one consumes. Strictly more coverage, not a trade — every case it classified as "promoted" still arms. VERIFICATION — and three instruments that did NOT discriminate before one did: - Unit tests (5) pin the mechanism: armed on the lock path, on the no-leader-election fallback, dispatched AFTER open rather than before (the TASK-1359 round-4 ordering property this must not lose), claimed once across the onopen/`connected` arms, and re-armed for the next connect. Reverting the fix reddens all 5. - Collection page + "is the row visible": CANNOT discriminate. That page runs its own deltaSync on mount, which covers the same window either way. Fixed and unfixed both "passed". - Graph page + "is the node's title in the page text": BLIND. A positive control — item created with no race at all — is also "not present", so every reading was measuring nothing. - Graph page + /graph response bodies, natural timing: still cannot discriminate. The write consistently lands before the page's own first read, so nothing is ever missed and both builds "recover". - What finally worked: builds with the EventSource open delayed 3s so the window is wide enough to aim at, write timed into it. 3/3 LOST on unfixed, 3/3 RECOVERED on fixed, both binaries confirmed serving and differing only in the arming sites. One hypothesis was refuted along the way rather than written down as fact: I suspected the graph subscribed too late to receive the connect dispatch. An instrumented run showed `dispatchSyncRequired subscribers=2` — both syncService and the graph are registered before it fires. The real reason those runs failed was that a stale server process was still bound to the port, so they were served by an unrelated binary. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * fix(web): fence the connect-sync against a stale EventSource (codex review) `pendingSyncOnConnect` is shared state and the open/connected handlers closed over no identity, so on a fast workspace switch source A's already-queued handler could still run after B existed — clearing the flag and broadcasting on B's channel, at which point B's own open would find it false and SKIP the sync it needed. `close()` does not retract queued event tasks, so this is reachable. It would silently reopen the exact gap this branch closes, on the switch path where a fresh read is most likely to be stale. Each handler now returns unless its own source is the current one. Those guards also stop a torn-down source writing `status` / broadcasting, which they never did before. Listener registration uses the local `source` throughout rather than re-reading module state (behaviour-identical; the two are the same object at registration). `disconnect()` also clears the flag. Labelled in-code as belt-and-braces rather than implied load-bearing: mutation-testing shows removing that line ALONE changes nothing observable, while removing the identity guards reddens the stale-source test. Kept because leaving per-connection state set after the connection is gone is how this bug arose. Three tests added, and the mutation testing is worth recording because the first attempt was a false green: dropping the identity check inside `claimPendingSync` reddened NOTHING, since the handler-level guards still caught it — a layered-guard mask. Only removing every guard isolates which layer acts. The tests now discriminate at that granularity. Also covers the lock-failure fallback arming path, which had no test at all. Codex's other finding — follower tabs have the same uncovered window and cannot use this mechanism, since they never open an EventSource — is real and filed as BUG-2576, along with the adjacent unguarded listeners this commit had no reason to touch. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd |
||
|
|
403a6de19d |
docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541) (#1100)
* docs(skill): structured bootstrap-failure branch + onboarding precondition in the embed source (BUG-2541) `skills/pad/SKILL.md` — the //go:embed source `pad agent install` writes into user projects — had TASK-2537's minimal safety note but not the structured branch the plugin copy carries. Ports it, minus the Claude-Code-specific shell advice (the embed source also serves Codex, Cursor, Windsurf, OpenCode and pure-MCP agents with no shell at all), so it now names the two stderr signatures as separate cases with opposite handling. Also adds the Onboarding routing entry's missing precondition. It sent the agent to load the onboard playbook, which lives IN a workspace — so on the unlinked path `pad playbook show onboard` fails exactly the way bootstrap just did, and the entry routed into a dead end. Ride-along from the lead's citation sweep: the stale "hangs indefinitely / no timeout" wording still shipped in plugin/skills/onboard/SKILL.md and plugin/skills/pad/SKILL.md (two places). All three now carry the bounded wording. RE-VERIFIED RATHER THAN INHERITED, per this item's own requirement — and the inherited correction needed one too: - Read the code myself. First-admin setup is capped at 20m (bootstrap.go::bootstrapPollTimeout). The auth poll (cmd_auth.go::pollAndSaveCLIAuth) has NO wall-clock limit of its own: it exits only on ctx.Done, `approved`, or `expired`, and `continue`s past transient errors. So the ~5m bound is entirely the server-side session TTL (cli_auth_sessions.go::cliAuthSessionTTL) — if the server becomes unreachable after the session is created, it polls forever. "Bounded, not indefinite" is right for the ordinary case and wrong for that one; the skill text now says both. Filed separately. - Observed it, not just read it. On a configured-but-unauthenticated HOME, `pad workspace init` printed the browser URL and was still waiting when a 25s cap killed it. `pad auth whoami` returned in 0.106s in that state AND in the unconfigured one, with distinguishable output — the "fast, safe" claim the whole branch rests on. - The exact stderr strings were wrong in both copies: the not-configured case has NO `Error:` prefix (`Pad is not configured. Run 'pad auth configure' first.`), only the unlinked one does. Corrected from captured output. Verified through `pad agent install claude` into a scratch project and read back off the installed file, not the diff. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * docs(skill): `pad auth whoami` blocks in a TTY when unconfigured — qualify the claim (codex review) Both plugin copies said `pad auth whoami` "never blocks waiting on input". It does, in one state: `whoamiCmd` → `getConfiguredConfig()`, and on an unconfigured machine that enters the interactive configure flow whenever `canPromptForConfig()` is true — i.e. stdin AND stdout are both terminals (configure.go:357). My own measurement (0.106s) was non-interactive, so it could never have caught this; Codex found it by reading the call path. The embed copy already had the right qualification ("in non-interactive use it returns immediately"); this brings the two plugin copies in line and says why the qualification is the operative one for an agent. Codex's other finding — that the embed source's opening still frames `/pad` as THE command, for surfaces with no slash commands — is real but pre-existing and editorial rather than part of this port. Filed as BUG-2573. The auth-poll timeout gap found while re-verifying the hang is BUG-2572. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * docs(skill): `pad init` does not fail fast either — correct both plugin copies (codex round 2) Both plugin copies told agents that in the configured-but-unauthenticated state, `pad init` "fails fast" and merely "needs a TTY to complete" — offered as the contrast to `pad workspace init`'s browser-poll block. It is false. `cmd/pad/init.go`'s Step 4 calls `doBrowserLogin` with no TTY guard when the server is already initialized but the client isn't authenticated. Observed: on a configured-but-unauthenticated HOME, non- interactive, `pad init` printed the same browser URL and was still waiting when a 20s cap killed it — identical to `pad workspace init`. That parenthetical was the one thing in the paragraph that could have made an agent run a command instead of handing back, so it was the worst line to have wrong. Both copies now say it is no safer as a probe. Third claim in these three files this task that was wrong because it was reasoned rather than run — the first two being "hangs indefinitely" (bounded, mostly) and "whoami never blocks" (it prompts in a TTY). Codex's other two round-2 findings are real but out of this port's scope and filed: BUG-2574 (the plugin onboard skill inlines its own script instead of loading the canonical onboard playbook) and BUG-2575 (plugin decompose entries omit SPEC targets the embed source and playbook both support). Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd * docs(skill): narrow the `pad init` claim to the state it is actually true in (codex round 3) The previous commit replaced one over-broad claim with the opposite one. "Fails fast" was wrong for the configured-but-unauthenticated case; "no safer as a probe, blocks identically" is wrong for the genuinely-unconfigured one. Both measured, non-TTY, same binary: unconfigured → 0.106s, "Error: Pad is not configured..." configured-but-unauthenticated → browser URL, still waiting at 20s The instruction ("don't run it yourself") was right in both readings, so this is the justification being wrong rather than the advice — which is exactly the failure mode this whole item is about, and I reproduced it while fixing it. Both plugin copies now say which state each behaviour belongs to. Codex's other round-3 finding — that the plugin onboard skill gates its recovery branch on `.pad.toml` being absent, so a STALE link skips it entirely and dead-ends at the same bootstrap failure — is real and is the pre-link half of the same skill's problem. Added to BUG-2574 rather than widened into this port. Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd |
||
|
|
e03ba45b5c |
feat(web): push-to-agent composer in the item view (TASK-2561) (#1099)
* feat(web): push-to-agent composer in the item view (TASK-2561)
PLAN-2558 S3 — the web half of IDEA-2544's push-to-harness. Adds
`api.items.push`, a new `api.sessions.list`, and a "Push to agent…" row
in the item pane's ⋯ menu that opens a small composer.
The deliverable is the presence line, not the textarea. `pad push` is
fire-and-forget — no durable inbox, no ack, no "nobody was listening"
warning — which is defensible for a CLI verb typed by someone who knows
their own session is running, and indefensible for a button. So the
dialog answers "is anything listening?" before the click, and keeps
three states apart rather than two:
N > 0 send, worded "N session connected", never "will be
delivered" — the registry can name a session that died up
to ~30s ago and no push gets a receipt
N == 0 send DISABLED. Nothing listening means the message is
lost, not queued; the empty state offers the clipboard
instead (the fallback S4 rules for quick actions)
can't tell send ENABLED, uncertainty stated. A 503/401/network
failure is not zero — rendering it as zero is the exact
lie handleListSessions returns 503 rather than an empty
list to avoid
The menu row is gated on a resolved user, not on canEdit: push is
self-addressed, so a viewer pushing an item into their own session is a
read. Without a user the endpoint 401s.
$lib/push/message mirrors the server's rune-after-collapse accounting so
an over-length message is caught in the composer instead of coming back
as a 400. It deliberately does not use JS `\s`: Go's unicode.IsSpace and
`\s` disagree in both directions (U+0085 is whitespace to Go only,
U+FEFF to JS only), so a `\s` client under-counts a pasted BOM and
over-counts a pasted NEL. The agreement is pinned by a shared fixture
(internal/server/testdata/push_message_cases.json) read by BOTH
internal/server/push_message_collapse_test.go and the web unit test — a
TS-only table would assert a belief about Go rather than Go's behaviour.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): close the push composer's races and ambiguity gaps (codex review)
Round-1 review findings on the S3 composer, all real:
- ItemDetail did not reset `pushDialogOpen` on an item switch. The dialog
is {#key itemSlug}-remounted while `open` is owned by the parent, so a
stale `true` silently REOPENED the composer pointed at the new item.
The reset block's existing comment (written for copyDialogOpen)
describes this exact failure. Verified live, with the counterfactual:
reverting the one-line fix reopens the dialog on item B after a
client-side navigation. (The typed draft does NOT carry over — the
{#key} remount clears it — so the defect is the silent reopen, not a
retargeted message.)
- Presence polls shared one generation counter, which fences OPENINGS,
not requests. A stalled poll could resolve after a later one and
overwrite a fresh count with a stale one, re-arming Push against a
session list already known to be empty. Added a per-request sequence;
only a strictly newer response is applied.
- Nothing bounded a `/sessions` read, and 'checking' disables Push, so a
request that never settled stranded the composer with a dead button and
no explanation. It now degrades to the honest "can't tell" state after
5s; a later response still lands and upgrades the answer.
- A failed send re-armed Push unconditionally. The handler publishes
BEFORE writing its response, so an unstructured failure (rejected
fetch, non-JSON 502) leaves the outcome genuinely unknown and a second
click can deliver the instruction twice on an endpoint with no
idempotency key. Split on the same line CopyItemDialog draws (DR-13):
a structured PadApiError means the server refused before publishing —
re-arm; anything else latches an outcome-unknown state.
- `willCollapse` compared against `String.trim()`, reintroducing the very
JS-vs-Go whitespace mismatch $lib/push/message exists to avoid (JS
trims a leading U+FEFF the server keeps; it leaves a U+0085 the server
strips). Added `trimPushMessage`, which trims with Go's class.
- The textarea described only the counter, so the collapse note and the
over-length error reached no screen reader. Both now live in one stable
referenced node that swaps text rather than mounting and unmounting —
an aria-describedby pointing at an absent id resolves to nothing.
- Positive presence wording implied the count was current. It now says
"as of the last check" and names the ~30s window.
Test changes: the Go fixture test duplicated `strings.Fields` rather than
invoking the handler, so a change to the handler's normalization would
have left BOTH suites green — demonstrated by mutating the join
separator, which the copied-expression test did not notice and the new
handler-driven test caught on 22 cases. The bound is likewise now
asserted through the endpoint at 4096/4097 instead of comparing the
constant to a copy of itself.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): fence the push composer against destroyed instances, unrecognised errors, and a frozen count (codex round 2)
Three findings, one of them introduced by round 1's own fix:
- The send/copy continuation fence used the generation counter, which
cannot see a keyed REMOUNT. `{#key itemSlug}` gives item B a fresh
instance with its own counter, so item A's in-flight send still saw its
own `gen` unchanged and called the SHARED parent `onclose` — closing the
composer the user had just opened for B. Added a per-instance
`destroyed` flag, which is what actually distinguishes "still mine to
close" from "I no longer exist".
- The outcome-unknown split treated any PadApiError as proof the server
refused before publishing. It isn't: the API client turns EVERY JSON
error envelope into one, including a gateway 5xx invented after the
handler published. Replaced with a whitelist of codes the handler and
its middleware actually emit pre-publish; everything unrecognised is
now ambiguous. The asymmetry is deliberate — an unnecessary "we can't
tell" costs the user a check, a wrong re-arm delivers twice.
- PRESENCE_STALL_MS only rescued the FIRST read. A later poll that hung
froze the count at its last value indefinitely while the UI kept
rendering "1 session connected" as fact. A known answer now expires to
"can't tell" after 30s without a refresh — the server's own presence
staleness bound, so past it our answer carries no more authority.
Also dropped the status→alert role swap on the composer's live region:
changing a live region's role and its text together is not reliably
honoured, so the escalation was a promise the markup couldn't keep. The
blocking condition rides `aria-invalid` on the textarea instead.
The "latest ARRIVED, not latest ISSUED" behaviour of the sequence fence
is kept and now documented as a choice: dropping an early-arriving
response because a newer request exists strands the UI when that newer
request is the one that never settles.
Each fix mutation-tested 1:1 against its new test.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
* fix(web): complete the pre-publish whitelist and retire in-flight polls on expiry (codex round 3)
Two of round 3's three findings were real:
- `csrf_error` and `email_not_verified` are middleware refusals, written
strictly before the handler runs, so they belong in
PRE_PUBLISH_ERROR_CODES. Without them a CSRF mismatch told the user we
couldn't tell whether their message was sent, when nothing had been.
- The 30s staleness expiry didn't fence requests already in flight. A
poll issued before the expiry could land after it and reinstate the
very count we had just declared too old to trust. Expiry now advances
`presenceAppliedSeq` to the current `presenceSeq`, retiring those
responses; the poll issued in the same tick carries a newer seq and
still applies.
The third finding — that `archived` belongs in the whitelist, and that
the launcher should be hidden for archived items because "the endpoint
always rejects them" — is REFUTED. handlePushToItem has no archived gate
(`requireItemVisible` admits archived items), and pushing to an archived
item against a running server returns 200 with `pushed: true`. There is
no `archived` error code on this path to whitelist, and hiding the
launcher would remove a capability that works. Recorded rather than
silently skipped so the next reader doesn't re-derive it.
Claude-Session: https://claude.ai/code/session_01QCMLhHQBrMHVML3YKdm4Cd
|
||
|
|
7943234dd7 |
fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566) (#1098)
* fix(store): treat empty assignment IDs as clear-to-NULL (BUG-2566)
PATCH with {"assigned_user_id": ""} 500'd with a raw FK constraint
error and left the item assigned. An explicit empty string is what a
JSON client sends when a user blanks the field (JSON null on a *string
decodes to nil = "don't change", so it can't express clearing), and
validateAssignmentScope already skips "" as nothing-to-validate — the
SQL builder just never learned the same convention and bound it
verbatim into the FK column.
Store-level fix so every surface (HTTP, bulk, MCP, CLI) inherits it:
"" now clears assigned_user_id / agent_role_id exactly like
ClearAssignedUser / ClearAgentRole on update, and binds NULL on
create. The mutation signal keeps the ClearAssignedUser shape (tested)
so watch notifications are unaffected. parent_id deliberately NOT
given the same coercion: parent relations also live in item_links, and
clearing the column alone would desync them.
Web UI is unaffected either way — it already sends
clear_assigned_user: true; only API clients hit this.
Tests reproduce the exact FK failure pre-fix (verified by stash-run)
and pass on both SQLite and PostgreSQL post-fix.
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
* fix(store): relocate nullIfEmptyID out of createItemTx's doc comment
Codex round 2 P3: the helper was inserted between createItemTx's doc
block and the function, orphaning the doc comment.
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
|