mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 03:16:43 +00:00
dfd3811eee5083995823dde03b40cd618a540baa
782 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dfd3811eee |
feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668) (#669)
* feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668)
Add POST /workspaces/{ws}/items/bulk accepting item IDs + a verb
(archive, move, tag, untag, set-priority, assign). The lane-header
bulk actions operate on a whole filtered lane, so the endpoint emits
ONE items_bulk_updated SSE event and ONE item.bulk_updated webhook for
the batch instead of per-item fan-out.
Reuses the existing store paths (UpdateItemWithPreCheck / MoveItem /
DeleteItem) rather than re-implementing writes; the open-children
guard runs per status-bearing move exactly as the single PATCH path
does (force-overridable). Per-row failures are collected into the
response envelope (updated/failed/total) rather than aborting the
batch. Editor/owner gated.
Frontend client + TS types follow in TASK-1669; UI wiring in TASK-1672.
Parent: PLAN-1667.
* fix(api): per-item visibility + collection-move guard on bulk endpoint per Codex review (round 1)
- Enforce per-item collection visibility (checkItemVisible) in the bulk
loop so a member with collection_access="specific" can't bulk-mutate
items in hidden collections by guessing refs; report invisible rows as
not-found. Also gate the move target collection on visibility.
- Route bulk collection moves through MoveItemWithPreCheck with the
open-children guard (destination schema), closing the bypass where a
collection move + terminal status could mark a parent terminal with
open children. Status-only moves already ran the guard.
- Tests: status-move + collection-move guard coverage (reject + force
override + mutation-safety).
* fix(web): consume items_bulk_updated SSE event per Codex review (round 2)
The bulk endpoint emits one items_bulk_updated event, but the SSE
service only listened for the fixed ITEM_EVENTS list — so a bulk
mutation left other tabs/sessions stale until an unrelated sync fired.
Route the batch event through the existing sync_required path: it
carries item_ids + a max seq but no per-item field payload, so an
incremental /items-changes delta reconciles every affected row by seq.
Broadcast so peer tabs reconcile too.
* fix(api): scope bulk SSE event per-collection, drop item_ids per Codex review (round 3)
The batch event published with an empty Collection, which the SSE
filter treats as workspace-level: restricted members received bulk
events for hidden collections (leaking item_ids/op/count) while guests
with grants were dropped entirely and stayed stale.
Emit one items_bulk_updated event per affected collection with
Collection set, so the existing visibility filter routes it like any
collection-scoped event. Drop per-item IDs from the SSE payload — a
batch can't be item-grant-filtered for guests on a broadcast bus, so
IDs would leak; recipients reconcile via the /items-changes delta,
which is visibility-filtered server-side (Seq carries the cursor). The
webhook (a trusted workspace integration) keeps the full id list.
Test asserts the event is collection-scoped and carries no item_ids.
* fix(api): bulk collection move notifies both source and target scopes per Codex review (round 4)
A cross-collection move only emitted a batch event for the target
collection, so a restricted member watching the source lane wouldn't
reconcile the item leaving it. Notify both the source and target
collection scopes for moves (still no per-item IDs). Test asserts both
events fire.
* fix(api): suppress itemless batch SSE events for item-grant-only subscribers per Codex review (round 5)
A guest/restricted member with only item-level grants in a collection
could still receive the collection-scoped items_bulk_updated event
(itemless), learning op/count/timing for items they can't see. Extract
the SSE visibility filter into sseEventVisibleFor and add a rule:
itemless collection-scoped events go only to subscribers with FULL
collection access; item-grant-only subscribers reconcile their granted
items via the next resume/reconnect /items-changes sync instead.
Adds a unit test covering the visibility matrix.
* fix(api): validate status override against target schema on bulk collection move per Codex review (round 6)
A status override on a collection move was applied after MigrateFields
but never validated against the target schema, so an out-of-options
value (e.g. status=bogus) could be written. Run ValidateFields on the
final field map before the move. Test asserts the invalid value is
rejected per-row and the item stays put.
|
||
|
|
1be42e5406 |
feat(collections): multi-select tag filter + per-collection tag counts (TASK-1666) (#668)
Add a TagFilter chip row to the collection page FilterBar — one toggle chip per tag present in the collection, each showing its item count, with OR selection semantics. Tag counts are derived client-side from the local index (no network round-trip) and honor the showArchived toggle. Selection round-trips through the `tags` URL param and the saved-view ViewConfig (rides along as a single `op: 'in'` filter). Implements TASK-1666. |
||
|
|
0d5f96657b |
feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) (#667)
* feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) Completes PLAN-1662. Adds inline edit mode for comments and replies, built on the backend (TASK-1663) + CommentEditor (TASK-1664). - Edit affordance (hover pencil) next to Delete on each comment and reply, shown only when editable: comment.user_id === currentUserId || isAdmin — distinct from canEdit (item perm, which gates delete/reply/react). Mirrors the server's canEditComment; null user_id → admin-only. - Clicking Edit swaps the rendered body for a CommentEditor seeded with the current body; Save calls api.comments.update via an onEdit callback (throws on failure so the editor keeps the draft); Cancel restores. One reply edits at a time (editingReplyId). - Image removal falls out: removing an image is just deleting its node in the editor and saving — no separate endpoint/button. - "edited" marker (· edited, title=updated_at) when updated_at is meaningfully after created_at. Reactions live in separate tables and don't bump updated_at, so it's edit-specific. - handleReply now re-throws so the reply editor also preserves its draft on failure; comment_updated SSE refresh was wired in TASK-1663. Parent: PLAN-1662. * fix(comments): flag edited on any positive updated_at delta per Codex review (round 1) created_at and updated_at are set identically on create, so a strict inequality (not a >1000ms threshold) is the correct 'edited' signal at RFC3339 second precision — the old threshold missed edits exactly 1s later. Same-second edits remain undetectable without an explicit edited_at column; acceptable for v1. |
||
|
|
6ed16ef930 |
feat(comments): lean Tiptap CommentEditor — inline image thumbnails (TASK-1664) (#666)
Replaces the plain-textarea comment composer and reply box with a small WYSIWYG editor so pasted/dropped images render as inline thumbnails instead of `` markdown text. - web/src/lib/components/CommentEditor.svelte: a purpose-built Tiptap instance (NOT the heavy Editor.svelte) — StarterKit basics + Link + Placeholder + tiptap-markdown + the shared attachment pipeline (AttachmentUpload plugin + AttachmentImage/AttachmentChip nodes). No tables/slash/collab/URL-modal. Emits markdown via the markdown storage (round-trips through the nodes' addStorage serializers), so comment.body stays markdown — display, lightbox, search, and orphan-GC are untouched. Wraps the upload fn to track in-flight uploads and gate submit (the plugin doesn't expose its placeholder count). Ctrl/Cmd+Enter submits, Esc cancels (reply mode). - ItemTimeline composer + TimelineCommentCard reply box now render CommentEditor; submitComment/submitReply take the markdown string and throw on failure so the editor preserves the draft. Removed the textarea + commentAttachments paste/drop wiring and now-dead CSS. Inline thumbnails in the editor are capped to match the rendered-comment display. Parent: PLAN-1662. Unblocks TASK-1665 (edit mode reuses this). |
||
|
|
076fb9b2e7 |
feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663) (#665)
* feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663)
Foundation for comment editing (PLAN-1662). No migration — comments.user_id
already exists (012_users.sql) but was never written or exposed.
- Populate user_id on create/reply: CreateComment takes an explicit userID
param (passed from currentUserID by the handlers, not via the request body
so it can't be spoofed). Expose user_id on models.Comment + all comment
SELECTs/scans. The workspace export path is left as-is — imported comments
keep NULL user_id (admin-only edit), matching the pre-identity fallback.
- Store.UpdateComment(id, body): replaces body + bumps updated_at; the
comments_fts_update trigger re-indexes.
- PATCH /workspaces/{ws}/comments/{commentID}: author-or-admin only
(canEditComment), rejects empty body. Editing is an authorship op, distinct
from delete (item editors). NULL user_id → admin-only.
- comment_updated SSE event: broadcast from the handler; added to the web
sse allowlist + ItemTimeline refresh set.
- web: api.comments.update(), Comment.user_id type.
Tests: author edits own (200), non-author non-admin (403), admin edits
anyone (200), empty body (400), NULL-user_id comment is admin-only.
Parent: PLAN-1662.
* fix(account): detach authored comments on account deletion per Codex review (round 1)
Now that TASK-1663 populates comments.user_id (FK to users.id),
DeleteAccountAtomic would fail on the FK for any user who authored a
comment. Null comments.user_id for the user before deleting the row —
comments live on in soft-deleted/other workspaces; the display-name
author is preserved and the comment just becomes admin-only to edit.
Regression test added.
|
||
|
|
4c2c4d1e1d |
feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660) (#664)
* feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660)
Tier 1 + Tier 2 of the IDEA-1650 follow-up.
Tier 1 — thumbnails:
- Thread an image `variant` through the markdown render pipeline
(renderMarkdown → AttachmentRenderContext → resolveAttachmentImage →
renderAttachmentImage), defaulting to thumb-md. Comment + reply bodies
pass thumb-sm (256px) so pasted screenshots fetch the small variant.
- CSS caps inline attachment images to a 280×180 box with cursor:zoom-in.
Tier 2 — lightbox:
- New reusable Lightbox.svelte (common/) — full-resolution overlay,
Esc/backdrop close, ←/→ paging, image counter. Loads the original
(un-variant) blob for full detail.
- ItemTimeline attaches a delegated click handler (via a use: action, so
no a11y lint on the static container) that opens the lightbox on any
img[data-attachment-id], collecting sibling images in the same
comment/reply body for paging.
No backend changes. Tier 3 (dims-via-headers, multi-image grid,
loading=lazy) intentionally deferred.
* fix(comments): keyboard-activatable comment image thumbnails per Codex review (round 1)
Thumbnails come from sanitized {@html}, so they can't be wrapped in a
<button> at render time. Instead make each img[data-attachment-id] a
focusable role=button with an aria-label imperatively (re-applied on
entries change, covering SSE-added comments), and add a delegated
keydown (Enter/Space) alongside the existing click so keyboard users
can open the lightbox.
* fix(comments): re-run thumbnail focusability pass when attachment metadata resolves per Codex review (round 2)
An attachment renders as <img> only after its HEAD-probe metadata
resolves (before that it's a 'missing' placeholder span). The
focusability pass depended only on entries, so it missed images that
appeared on metadata resolution. Add attMeta as a dependency.
|
||
|
|
e179c595e4 |
feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) (#663)
* feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) Comment composers were plain textareas with no upload path, and comment bodies rendered markdown without an attachment resolver — so a `pad-attachment:UUID` reference would never display. This wires both halves end to end: - Compose: paste or drop files into the comment composer (ItemTimeline) and the reply box (TimelineCommentCard). A shared helper (commentAttachments.ts) splices an "Uploading…" placeholder at the caret, uploads concurrently via the existing attachment API, and swaps each placeholder for its `pad-attachment:UUID` markdown ref (image syntax for image MIMEs, link/chip syntax otherwise — mirrors the editor's split). Submit is gated while uploads are in flight. - Display: ItemTimeline lazily HEAD-probes each referenced UUID (reusing the editor's fetchAttachmentMetadata cache), builds a reactive resolver, and threads it into renderMarkdown for comments and replies so refs render as inline images / file chips. - Orphan GC: comment uploads leave attachments.item_id NULL (like the editor), but the GC reference scan only checked items.content/fields. Renamed AttachmentReferencedInItems -> AttachmentReferenced and extended it to scan comments.body, so a screenshot referenced only from a comment isn't reclaimed after the grace period. Added TestOrphanGC_KeepsAttachmentReferencedFromComment. Follow-up refinement (thumbnails + click-to-expand lightbox) captured as IDEA-1660. * fix(comments): escape markdown-significant chars in attachment filenames per Codex review (round 1) Filenames containing [ ] or backslash could break the generated  markdown. P2 (grant-aware upload auth) is a pre-existing endpoint-wide gap shared with the rich editor — tracked as BUG-1661, not fixed here to keep the PR focused. * fix(comments): preventDefault on dragover for file drops per Codex review (round 2) Browsers only deliver a file drop to a custom target if its dragover cancels the default; without it the page navigates to the file. Gated on isFileDrag so in-textarea text drag-drop is unaffected. |
||
|
|
b9ddd02ae7 |
feat(cli): add --tag filter to pad item list (TASK-1658) (#662)
The HTTP item-list endpoints already parse ?tag= (cross-collection on the workspace route), but the CLI had no flag to forward it. Add --tag, wired as a query param alongside --status/--role/--parent. The MCP pad_item list action inherits it via the cmdhelp passthrough (no catalog change). Parent: PLAN-1652. |
||
|
|
5c0d367c12 |
feat(tags): list/board view toggle on the tag page (TASK-1656) (#661)
* feat(tags): list/board view toggle on the tag page (TASK-1656) Adds a board (kanban) view to the per-tag page: one lane per collection, reusing ItemCard (compact). Read-only — no drag/status changes, since moving a card between collection lanes would mean reclassifying the item. Implemented as a focused layout on the tag page rather than overloading the shared DnD/status-grouping BoardView (582 lines), whose semantics don't fit a read-only cross-collection view. Reuses the existing collection-grouped `groupedItems` derived; view mode persists per workspace via localStorage. Parent: PLAN-1652. * fix(tags): guard localStorage access on the tag view toggle per Codex review (round 1) localStorage get/set can throw when storage is disabled or blocked; wrap both so the page loads and the toggle works (in-session) regardless. |
||
|
|
1e3b3254d7 |
feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655) (#660)
* feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655) - lib/types: shared parseTags() — defensive JSON-array parse + case-insensitive dedupe; reused by ItemCard and the detail page (which drops its inline dupe). - ItemCard (shared by BoardView + ListView): a clickable tag-chip row. The card is an <a>, so chips are buttons that goto the tag page with stopPropagation — same pattern as the existing star/PR/status controls — avoiding nested anchors. - Item detail header: tag chips as real <a> links to the tag page. Chips link to /[ws]/tags/[tag]; those pages arrive in TASK-1657. Parent: PLAN-1652. * feat(tags): dedicated tag pages (index + per-tag aggregated view) + nav (TASK-1657) Folded into the chip-display PR because the chip links require the routes to exist — without them /[ws]/tags/[tag] is swallowed by the generic [collection]/[slug] route and hits the item error path (Codex PR #660 round 1). - /[ws]/tags — index: tag cloud with per-tag item counts (GET /tags), each linking to its tag page. - /[ws]/tags/[tag] — aggregated view: cross-collection items for the tag, grouped by collection (the shared axis across heterogeneous status enums), modeled on the starred page. A static `tags` segment takes routing precedence over [collection], so the collision is resolved. - Sidebar: a Tags nav entry; `tags` added to the reserved-slug guard so the route isn't mistaken for a collection. Parent: PLAN-1652. * fix(tags): reserve 'tags' collection slug + tighten Tags nav active-match per Codex review (round 2) - backend: add 'tags' to reservedCollectionSlugs so a collection can't be created with a slug that the /tags routes would shadow. - Sidebar: isTagsPage now matches exactly /tags or the /tags/ boundary, not any /tags* prefix (e.g. a 'tags-collection' path no longer marks it active). * fix(tags): don't drop item-granted rows whose collection isn't listable per Codex review (round 3) A restricted member can see an item via item-level grant without being able to list its collection, so getCollection() missed and the row was dropped while still counted (count != rendered). Synthesize a minimal collection from the item's embedded collection_* metadata so those rows render (empty schema = ItemCard omits status/priority). |
||
|
|
feb068a91f |
feat(tags): tag chip editor on the item detail page (TASK-1654) (#659)
* feat(tags): tag chip editor on the item detail page (TASK-1654) Tags live on item.tags (a JSON-array string), not the collection schema, so this adds a TagInput sibling to FieldEditor rather than a field type. - TagInput.svelte: chip editor — Enter/comma to add, Backspace/× to remove, case-insensitive dedupe (stored as typed), autocomplete dropdown sourced from the workspace tag set; readonly mode renders plain chips. - Item detail page: derive `tags` from item.tags (defensive parse), load `tagSuggestions` via a workspace-keyed $effect kept separate from the item-load path (Svelte 5 effect-splitting convention), and updateTags() mirrors updateField() — optimistic with revert-on-failure, PATCHing `tags`. The Tags row renders between the schema fields and the Assignment section. api.items.update already accepted `tags` via ItemUpdate, so no client change was needed there. Parent: PLAN-1652. * fix(tags): guard overlapping tag saves with a sequence counter per Codex review (round 1) Rapid chip edits can issue overlapping PATCHes; a late-resolving older request could clobber the newer tag set with stale data or an errant revert. Only the latest save (by monotonic seq) applies its result or reverts. * fix(tags): drop stale tag-suggestion results across workspace navigation per Codex review (round 2) loadTagSuggestions now only assigns when the in-flight workspace still matches the current one, so a slower old-workspace /tags response can't overwrite the new workspace's autocomplete. * fix(tags): dedupe tags + key chips by index per Codex review (round 3) An item can carry duplicate tags (e.g. ["ux","ux"]) since the write path doesn't enforce per-item uniqueness, which would collide value-based Svelte keys. Key chips by index, and dedupe case-insensitively at the source so the cleaned set persists on the next save. * fix(tags): gate tag-save completion UI on item freshness per Codex review (round 4) If the user navigates to another item while a tag save is in flight (no further edit, so the seq guard doesn't trip), skip showSaved()/toast/refresh so completion UI can't fire on an unrelated page. * fix(tags): serialize+coalesce tag saves, revert to last confirmed per Codex review (round 5) Replace the concurrent-PATCH-with-seq-guard approach with a single in-flight, coalescing saver scoped per item. Eliminates the overlap class structurally: no stale completion clobbers a newer set, and `confirmed` tracks the last server-acknowledged tags so a failed save reverts to server truth rather than an optimistic unconfirmed value. Subsumes the round-1 race guard and round-4 navigation gate. * fix(tags): key tag savers by item id to prevent cross-navigation concurrency per Codex review (round 6) A single saver slot let navigating away from an item mid-save and back spawn a second concurrent saver for it. Hold savers in a Map keyed by item id so edits coalesce into the existing in-flight saver; evict on drain. * fix(tags): reapply in-flight desired tags after item reload per Codex review (round 7) Navigating away and back mid-save reloaded stale server tags; a follow-up edit computed from that stale set could drop the in-flight edit. The saver now tracks the latest desired set and loadData reapplies it when a save is still in flight for the reloaded item. * fix(tags): keep save indicator active across reload so refresh guards hold per Codex review (round 8) loadData reset saveStatus to idle while a tag PATCH was still in flight, letting SSE/sync snapshot adoption bypass the saveStatus==='saving' guard and land stale tags. Restore 'saving' when reapplying an in-flight saver so the existing refresh guards keep skipping until the save drains. * fix(tags): overlay in-flight tags at every server-snapshot assignment per Codex review (round 9) The saveStatus guard is racy (checked before the refresh handlers' own await, not after), so a concurrent snapshot could still drop optimistic tags. Extract withInflightTags() and route ALL item = <server snapshot> sites through it (realtime SSE/sync, initial load, content-save echoes, title/field/assignment/ role update echoes, post-action refresh, version restore). Overlaying the saver's desired set at assignment time is race-free regardless of the guard. * fix(tags): overlay tags on field-save + forced-retry echoes per Codex review (round 10) updateField's success echo (item = fresh) and the forced open-children retry (item = forced) were the last two un-overlaid server-snapshot assignments; route both through withInflightTags so a concurrent tag save isn't clobbered. * fix(tags): preserve unsaved content when reconciling tag-save echo per Codex review (round 11) flushTagSaver adopted the full tag PATCH response (item = fresh), which carries server content and could clobber unsaved editor edits. Route it through adoptServerItem so local content is preserved (non-collab) like the other snapshot adoption sites. |
||
|
|
1b1068537c |
feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653) (#658)
* feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653)
Foundation for the tags feature (PLAN-1652 / IDEA-1649). The write path and
per-collection ?tag= filter already existed; this adds tag enumeration and a
verified cross-collection read so a single tag can group items of any type.
- store: dialect.JSONArrayElements unnests a JSON text-array column
(json_each on SQLite, jsonb_array_elements_text on Postgres);
Store.ListWorkspaceTags returns distinct tags + item counts, ordered by
count desc then tag asc, with the same collection/item ACL filters as
ListItems so counts never leak hidden items.
- server: GET /workspaces/{ws}/tags (handleListTags), respecting collection
visibility + guest item grants.
- models: TagCount{tag,count}.
- cli: client.ListTags + `pad tag list`.
- web: api.tags.list + TagCount type (items.list already forwards `tag`).
- tests: store-level (cross-collection aggregation, collection scoping,
non-nil-empty = empty, archived excluded) and handler-level (a Task + an
Idea sharing one tag; GET /tags counts + ordering).
Parent: PLAN-1652.
* fix(tags): count distinct items per tag, not tag occurrences per Codex review (round 1)
COUNT(DISTINCT i.id) so an item with duplicate tags (e.g. ["ux","ux"]) is
counted once — the write path doesn't enforce per-item tag uniqueness.
Adds a regression test.
|
||
|
|
9870f364ac |
feat(insights): "What shipped" card with linked item IDs on the dashboard (#657)
Mirror the print report's completed-items list on the live Insights
dashboard, grouped by collection. Each item ID is a link to the item
(/{user}/{ws}/{collection}/{ref}) — the route resolves PREFIX-NUMBER refs
via ResolveItem, so no slug lookup is needed. Fetches the report with
include_items=true; the card is toggleable via Customize and persists like
the other cards (new "completed_items" id, defaults visible).
|
||
|
|
a6667152cc |
fix(insights): professional print formatting for the exec report (TASK-1647) (#656)
* fix(insights): professional print formatting for the exec report (TASK-1647) Polish the /insights/print @media print block (screen view unchanged): - Print typography: 10pt base so em sizes cascade down; capped headings (title 16pt, section 12pt, stat values 14pt, labels 8–9pt). - Charts: cap canvas height to 150px in print (overrides the inline 200–240px); smaller legend. - Page breaks: break-inside:avoid on status tables + rows + stat cards + chart canvases; section headings kept with their content (break-after:avoid). - Density: tighter report/section/stat/list gaps for a compact report. Parent: PLAN-1628 (IDEA-1627). * redesign(insights): charts-up-front dashboard layout + fix LayerCake print overlap (TASK-1647) - Move the three charts to a compact 2-up grid right after the headline stats (charts up front, half-width, not full-bleed). - Fix the chart-overlap bug: charts were sized via an inline screen height that LayerCake measured and cached, then an @media-print rule shrank the box afterwards, leaving the SVG drawn at the old range and spilling onto the next panel. Now pass small heights (130-150px) identical for screen + print so the measurement matches; add overflow:hidden as a clip-safety. - Cycle time becomes a full-width band: metrics rail + short chart. - What shipped flows into two columns (dense, fills page width) and is no longer kept whole — .shipped-group/.block/.status-table dropped from the break-inside:avoid set so a tall section no longer jumps to a fresh page and leaves the big blank gap on page 1. Only small atoms (tr, shipped line) stay unsplit. - Compact table cell padding + repeat thead across page breaks. * fix(insights): stop print charts clipping + shorten throughput X labels (TASK-1647) - Charts were measured at the 960px app width on screen (~450px per 2-col panel) then printed into ~360px columns, so LayerCake's cached width drew the SVG too wide and overflow:hidden clipped the right edge (only ~3 of 4 collection bars showed). Constrain the print page's on-screen report to a paper column (720px) so the measured width already fits the printed sheet — WYSIWYG, no clipping, all bars visible. - Throughput X-axis used full ISO dates (2026-05-23) that overlap once several buckets share a narrow chart; render compact M/D (or 'M/D Hh' for hourly) labels via fmtBucket. maxTicks=8 already thins longer windows. |
||
|
|
333f509274 |
fix(store): monotonic seq tiebreak for same-second status transitions (TASK-1643) (#655)
* fix(store): monotonic seq tiebreak for same-second status transitions (TASK-1643) Resolves the documented historical-snapshot precision edge: created_at is second-precision and ids are random UUIDs, so "latest transition <= T" was nondeterministic for 2+ same-second hops on one item. Add a monotonic `seq` (MAX+1 at insert, like items.seq; assigned inside the workspace seq lock so per-item order is serialized — cross-row dupes are harmless) and order the as-of-T reconstruction by created_at DESC, seq DESC, id DESC. - migrations 065 / 044: add seq + backfill existing rows chronologically (ROW_NUMBER over created_at,id), dual-dialect. - seq set on all four insert sites (update/move/create-seed hooks + backfill). - reportSnapshotAsOf ORDER BY uses seq; doc caveat updated (now fixed). - Test inserts same-created_at rows where id-order and seq-order DISAGREE, proving seq is the tiebreak. Parent: PLAN-1628. * fix(store): insert backfill create-seeds before hops so seq stays chronological per Codex review (round 1) The backfill inserted activity hops first, then create-seeds, so a seed got a HIGHER seq than a same-second hop — and since the as-of query orders by created_at DESC, seq DESC, the seed (the initial state) wrongly won "latest" for same-second create-and-change histories. Buffer the hops during the activity scan and insert them AFTER the seeds, so every hop's seq exceeds its item's seed seq (a seed is always chronologically first; on a created_at tie the hop's higher seq correctly wins). Adds a seed-seq-below-hop test. |
||
|
|
619465a24b |
feat(onboard): suggest an independent AI code reviewer (model != implementer) (TASK-1645) (#654)
Encode the independent-reviewer principle (a reviewer model different from the implementer catches more than self-review) as opt-in onboarding guidance — no tool-specific operational lore (that stays ours). - New generic library convention "Independent AI code review" (quality, on-pr-create, nice-to-have): states the principle; names review tools as examples (a review CLI, claude review, a GitHub bot) without operational depth. - /pad onboard build (B3) + audit (A3) steps: the agent notes its own model (the implementer), probes for / asks about a review tool, and when a different-model reviewer is available, proposes activating the convention, naming the detected tool (e.g. codex) as the concrete suggestion. Skips when the only reviewer would be the same model. Never blocks. Parent: PLAN-1628. |
||
|
|
24707fc2ad |
chore(templates): make seeded ship playbook review loop tool-neutral (TASK-1644) (#653)
The seeded ship playbook ships into every new workspace, so it must not carry our local Codex operational lore. Strip the Codex name + the < /dev/null / --full-auto / stdin-wedge details PR #646 added → a tool-neutral review loop ("use whatever synchronous review tool you have"), with a pointer that /pad onboard can wire up an independent reviewer. Our Codex specifics stay only in this workspace's PLAYB-1405 + the personal ship-tasks skill. |
||
|
|
0d46842cf7 |
feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642) (#652)
* feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642) Add /[username]/[workspace]/insights/print — a clean, shareable report of "what's been done this period" for the active selection, exported via browser print (no server-side PDF dependency). A "Print report" link on the Insights page passes the current window/offset/collections as query params. The report renders: header (workspace, period label honoring offset, generated-on), headline stats (completed/created/net flow/median cycle-time), "what shipped" (completed_items grouped by collection, ref + title, +N more on overflow), all charts (throughput, completed-by-collection, cycle-time) as SVG, and a compact status table. A layout reset (+layout@) drops the workspace chrome; @media print + :global hides the root shell/sidebar + the Print button, with sensible page breaks. Loading/error/empty states handled. Parent: PLAN-1628. * fix(insights): hide mobile topbar in print report CSS per Codex review (round 1) The print @media rules only hid chrome inside .app-layout, but the mobile TopBar (<header class="topbar topbar-mobile">) renders as a sibling before .app-layout, so a Save-as-PDF from a mobile viewport included the app bar. Hide :global(.topbar-mobile) in print too. |
||
|
|
b68164a714 |
feat(report): opt-in 'what shipped' completed-items list (TASK-1641) (#651)
* feat(report): opt-in 'what shipped' completed-items list (TASK-1641)
Add ?include_items=true → completed_items[{ref,title,collection,completed_at}]
on the report: items that reached a positive terminal in the window, deduped
by item (newest completion first), capped at 500 with
completed_items_overflow_count. Same positive-terminal source as
totals.completed (joins live items, deleted_at IS NULL), so the list reconciles
with the count. Opt-in so the interactive dashboard stays count-only; the
print/export report (TASK-1642) requests it.
Web ReportData gains completed_items + the api.report.get includeItems flag.
Parent: PLAN-1628.
* fix(report): scope completed-items list to the item's current visible collection per Codex review (round 1)
The list scoped transitions by st.collection_id (visible at completion) but
returned the item's CURRENT title/ref/collection — so an item completed while
visible then moved to a hidden collection could leak its hidden collection
slug/prefix + current title to a restricted caller. Require i.collection_id to
be in the resolved (scoped) collection set on both the count and list queries.
Adds a move-to-hidden-collection visibility test.
|
||
|
|
4368c576c5 |
feat(insights): reconstruct historical WIP + status snapshot for past periods (TASK-1640) (#650)
* feat(insights): reconstruct historical WIP + status snapshot for past periods (TASK-1640)
For a past period (offset>0), reconstruct the status-distribution and WIP
snapshot as-of the window end from status_transitions, instead of hiding them.
An item's status as-of-T = the to_status of its latest transition on the
collection's done field with created_at <= T (NOT EXISTS "no later transition",
dual-dialect, no window functions); only items that existed at T (created_at
<= T) and weren't deleted by T are counted. WIP age is measured against T.
offset 0 keeps the live-from-items path (ground truth for now). Frontend drops
the interim hide so the cards show in the past, with a note that they're
reconstructed (older periods approximate — the backfill parsed the
debounce-coalesced activity log).
Parent: PLAN-1628.
* fix(insights): items-first historical snapshot + period-aware WIP subtitle per Codex review (round 1)
1. reportSnapshotAsOf started from status_transitions, so an item that existed
at T but had no done-field transition (e.g. created with fields={}) was
dropped — undercounting historical WIP vs the live path, which treats a
missing done value as open. Start from items and correlated-subquery the
latest transition <= T (NULL → empty/open). Adds a no-transition-open test.
2. WIP card subtitle "Open items right now" → "Open as of this period" when
viewing a past period (offset>0), matching the reconstructed snapshot.
* fix(store): record done-field CLEARS as transitions so historical snapshot is accurate per Codex review (round 2)
The capture hook only recorded a transition when newStatus != "", so clearing
a done field (e.g. done → unset) wasn't logged — and as-of-T reconstruction
would still show the old terminal value, undercounting historical open work.
input.Fields is the full merged blob (CLI/handler merge before write), so
newStatus == "" genuinely means cleared, not omitted. Drop the `!= ""` guard
in both the update and move hooks so X → "" is recorded. Adds a clear test.
* docs(store): document current-collection attribution as an accepted historical-snapshot limitation (Codex review round 3)
reportSnapshotAsOf attributes items to their CURRENT collection for past
periods (not the collection at t). Reconstructing historical collection
membership needs move-history replay (status-preserving moves record no
transition) — disproportionate, and consistent with the same current-collection
attribution the backfill + completed-by-collection already use. Documented as a
deliberate best-effort limitation rather than built; items rarely move.
* docs(store): document same-second transition-ordering limitation + file follow-up (Codex review round 4)
reportSnapshotAsOf's "latest transition <= t" is nondeterministic for 2+
same-second status hops on one item (second-precision created_at + random
UUID ids). Rare; documented as a known limitation and tracked as a follow-up
task (monotonic ordering column / sub-second timestamps).
|
||
|
|
0d0c660565 |
feat(insights): navigate to past periods (offset + prev/next) (TASK-1639) (#649)
Add an `offset` to the report (periods back; 0 = current, clamped >= 0): window becomes [now - (offset+1)*lookback, now - offset*lookback]. Throughput and cycle-time shift automatically; response echoes `offset` + shifted range. Backend: ReportOptions.Offset + ReportData.offset; handler parses ?offset=. Web: api.report.get passes offset; ReportData.offset typed. Insights page: ◀ Previous / Next ▶ controls (Next disabled at offset 0) + a period label; offset is session-only (not persisted to the layout); resets on window or workspace change. Interim: WIP + status-distribution are hidden when viewing a past period (they're as-of-now) with a note — TASK-1640 reconstructs them historically. Parent: PLAN-1628. |
||
|
|
cd8ac9b618 |
feat(charts): per-category hover tooltips + a11y titles (TASK-1638) (#648)
* feat(charts): per-category hover tooltips + a11y titles (TASK-1638) Hovering a bar chart shows exact numbers. Shared BarChart gains a per-category tooltip (hover a bucket → label + all series with color swatches), so it lands on throughput, aging, and completed-by-collection at once. - Bars layer: invisible full-height hit-rect per band reports the hovered index + band center; lifted to BarChart which renders an edge-clamped, absolutely-positioned HTML tooltip. Grouped inner-band layout unchanged. - A11y: native <title> per visible rect (and a band-summary title on the hit-rect); chart keeps role=img + aria-label. - Sparkline: concise native <title> (latest / min–max). Parent: PLAN-1628. * fix(charts): cap tooltip width to the chart so it can't overflow per Codex review (round 1) min-width:max-content prevented the tooltip from shrinking, so long (user-controlled) collection names overflowed the canvas/viewport on narrow screens — the center clamp only repositioned. Cap max-width to the measured canvas width (box-sizing:border-box), let the header wrap (overflow-wrap), ellipsis-truncate long series labels (min-width:0), and keep the value column unshrunk (flex-shrink:0). |
||
|
|
d2bcbd3b9b |
chore(deps,docs): bump x/image v0.41.0 + sync seed ship-playbook codex guidance (#646)
* chore(deps,docs): bump x/image to v0.41.0 (GO-2026-5031/5032) + sync seed ship-playbook codex guidance - golang.org/x/image v0.39.0 → v0.41.0: clears GO-2026-5031/5032 (reachable via attachment image decode). go mod tidy. govulncheck clean. - templates_startup_ship.go (seeded ship playbook for new workspaces): drop the deprecated `codex exec --full-auto`, add `< /dev/null` + a codex-specific note that open stdin causes the zero-output "wedge" (not prompt length), and the stdin-first rule-out in the wedge/safety notes. Matches the ship-tasks skill + PLAYB-1405. * fix(docs): show review prompt as positional arg in seed ship-playbook example per Codex review (round 1) codex exec reads the prompt from stdin when none is passed as an argument, so the `-o <file> < /dev/null` example without a prompt would review nothing. Show the prompt positional and note the gotcha. |
||
|
|
eeff78118b |
feat(insights): per-user layout customization + persistence (TASK-1634) (#645)
* feat(insights): per-user layout customization + persistence (TASK-1634)
Let users personalize the Insights surface, persisted per-user per-workspace:
toggle which metric cards show, and remember the window + collection filter.
Backend:
- migrations 064/043: user_report_layouts (user_id, workspace_id, config JSON,
PK(user_id,workspace_id), ON DELETE CASCADE) — dual-dialect.
- models.ReportLayout (hidden_cards/default_window/default_collections) +
ReportCardIDs/ValidReportWindow validation.
- store.GetReportLayout / SaveReportLayout (ON CONFLICT upsert, both dialects).
- GET/PUT /workspaces/{ws}/report/layout — per-user; PUT sanitizes window +
filters hidden_cards to the known card set. web client + TS type.
Frontend (Insights page):
- loads the saved layout, hydrates window/collections/hidden cards
- a "Customize" panel toggles each card (SvelteSet-backed); each section gated
on !hiddenCards.has(id); Totals always shown
- debounced auto-save, gated on a per-workspace `hydrated` flag so it never
saves during load or stomps another workspace's layout on switch
Single config per user (no named/multiple layouts — deliberate v1 scope).
Parent: PLAN-1628.
* fix(insights): save layout only on explicit user changes, not on load per Codex review (round 1)
The auto-save $effect ran once after hydration (loadLayout assigns reactive
state, then flips hydrated=true), firing a PUT /report/layout on mere page
view — which 401s on no-user/legacy-token sessions and bounces the user to
/login. Replace the effect with a scheduleSave() called only from explicit
handlers (toggleCard, selectWindow, toggleCollection, clearCollectionFilter);
hydration never saves. Also capture wsSlug at schedule time and drop the
pending save if the workspace changes mid-debounce, so A's edit can't land
on B.
|
||
|
|
1a728fe51a |
feat(web): add Insights sidebar nav link (TASK-1636) (#644)
Add an "Insights" nav item to the workspace sidebar directly under Dashboard, routing to /[username]/[workspace]/insights (the Reports surface, TASK-1633). Mirrors the existing nav-item pattern with an isInsightsPage active state. No dashboard widget/CTA (owner directive — keep the dashboard uncluttered). Parent: PLAN-1628. |
||
|
|
5afacaf478 |
feat(web): Insights analytics page (TASK-1633) (#643)
* feat(web): Insights analytics page (TASK-1633)
Add /[username]/[workspace]/insights — the Reports surface consuming
GET /workspaces/{ws}/report via api.report.get + the LayerCake chart library:
- window segmented control (day/week/2wk/month) + collection filter chips,
both driving refetch via a single $effect
- totals (created/completed/net flow), throughput BarChart (created vs
completed per bucket), cycle-time (median/p90 + per-collection), WIP
(open count, median age, aging-band BarChart, per-collection), completed-
by-collection BarChart, status-distribution bar rows
- loading/error/empty states; responsive grid; titleStore "Insights"
Route is /insights (owner directive); sidebar link is TASK-1636. Card
toggling + saved layouts are TASK-1634. Svelte 5 runes; MCP-validated;
npm run check 0 errors. Parent: PLAN-1628.
* fix(web): reset insights collection filter on workspace change per Codex review (round 1)
SvelteKit reuses the route component across workspace param changes, so a
collection filter selected in workspace A leaked into B — sending A's slugs to
B's /report, which the server scopes to an empty set (no match) → empty report
for a non-empty workspace. Track the previous wsSlug in a plain (non-reactive)
var and clear selectedCollections on an actual workspace change before
snapshotting, so the new workspace starts unfiltered. Loop-safe.
* fix(web): guard insights report fetch against stale/out-of-order responses per Codex review (round 2)
loadReport left the previous report visible during an in-flight fetch and wrote
responses unconditionally, so switching workspace A→B showed A's data under B's
URL, and a slow older request could overwrite a newer selection. Add a plain
request-sequence counter: capture seq at fetch start, commit report/error only
when seq is still the latest, and only the latest request clears `loading`.
Also clear report/collections on an actual workspace change so A's data doesn't
linger under B while B loads.
* fix: reserve 'insights' collection slug to avoid route shadowing per Codex review (round 3)
The static /[username]/[workspace]/insights route shadows the dynamic
/{collection} route, so an 'insights'-slugged collection would be unreachable.
Add 'insights' to reservedCollectionSlugs (server, blocks creation) and to the
Sidebar's reserved-slug filter, matching the existing activity/starred/library/
ref precedent (which likewise reserve UI routes without migrating pre-existing
data — an 'insights' collection on this new feature is not expected).
|
||
|
|
d9fab3ea02 |
feat(report): cycle-time + WIP/aging metrics (TASK-1631) (#642)
* feat(report): cycle-time + WIP/aging metrics (TASK-1631)
Extend GET /workspaces/{ws}/report with two metric blocks:
- cycle_time: created→positive-terminal duration for completions in the
window — overall median + p90 + per-collection medians.
- wip: point-in-time open items (done field NOT a terminal value), open count,
median age, fixed aging bands (<1d/1-7d/7-30d/>30d), per-collection median age.
Medians/percentiles computed in Go from raw durations (dual-dialect: neither
SQLite nor Postgres has a portable percentile). Completed/WIP queries join live
items (deleted_at IS NULL), consistent with the rest of the report.
Wires through web ReportData TS types + `pad project report` rendering.
Tests: cycle-time median (backdated 48h), WIP open-count + aging bands,
percentile helper. Parent: PLAN-1628.
* fix(report): well-formed cycle_time/wip arrays on empty-scope path per Codex review (round 1)
The no-visible-collections early return left cycle_time.by_collection,
wip.aging_buckets, and wip.by_collection as nil → marshaled null, violating
the TS array contract for guests/restricted callers. Initialize those nested
slices in the ReportData literal so every path (including the early return) is
well-formed. Adds a JSON-shape regression test for the empty-scope case.
|
||
|
|
949ae03c88 |
feat(cli,mcp): pad project report + pad_project report action (TASK-1635) (#641)
Expose the report aggregation (TASK-1630) to agents:
- CLI: `pad project report [--window day|week|2wk|month] [--collections a,b]`
fetches GET /workspaces/{ws}/report and renders a colored summary (totals,
per-bucket throughput, completed-by-collection, status distribution);
--format json prints the raw payload.
- client.GetReport HTTP method.
- MCP: pad_project gains action=report (passThrough to `project report`) with
window + collections params; catalog-readonly test stub + expected maps
updated.
Parent: PLAN-1628.
|
||
|
|
0c8c066dd6 |
feat(web): LayerCake chart component library for Reports (TASK-1632) (#639)
Add a reusable charting library under web/src/lib/components/charts/ to power the upcoming Reports surface (TASK-1633): - BarChart / LineChart / Sparkline (public) + Bars/Lines/AxisX/AxisY layers - theme.ts: CSS-var palette with hex fallbacks + typed LayerCake context - Svelte 5 runes; role=img + aria-label; "No data" empty states Library only (not wired into a page yet). Deps: layercake@10.0.2 + d3-scale@4.0.2 (+@types/d3-scale); npm override lets layercake accept the repo's TypeScript 6. ~8-12KB gzipped added when the Reports bundle imports them; Sparkline is dependency-free SVG. Parent: PLAN-1628. |
||
|
|
a1d09c90df |
feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630) (#638)
* feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630)
GET /workspaces/{ws}/report?window=week&collections=tasks,bugs returns a
time-bucketed report: created-vs-completed throughput, net flow,
completed-by-collection, and a current status-distribution snapshot.
- Dialect.DateBucket(column, granularity) — day/hour bucketing via fixed-width
substring on the UTC RFC3339 TEXT (identical + exact on SQLite + Postgres;
avoids SQLite 'Z'-parsing fragility). Routes all report date math through it.
- store.GetReport: resolves per-collection done field + positive terminals
(terminal options minus rejected/cancelled/etc.), counts completions from
status_transitions and created from items.created_at, zero-fills buckets.
- HTTP handler + route; web ReportData type + api.report.get client.
- Tests: throughput/totals, negative-terminal exclusion, status distribution,
collection filter, non-status done-field, out-of-window exclusion, hourly
day-window, DateBucket per granularity. Dual-dialect via testStore.
Fixes the response contract that TASK-1632/1633/1635 consume (noted on them).
Parent: PLAN-1628.
* fix(report): scope report to caller's visible collections per Codex review (round 1)
The endpoint sits under RequireWorkspaceAccess (members, restricted members,
guests), but GetReport resolved ALL workspace collections — letting a caller
with access to one collection infer hidden collections' slugs, created/
completed counts, and status distribution. Mirror the dashboard: the handler
computes visibleCollectionIDs() and GetReport restricts to that set
(ScopeToVisible). Empty visible set → empty report. Aggregate reports are a
full-collection-visibility feature; item-level grants aren't surfaced in
workspace-wide counts.
* fix(report): correct visibility scoping for all-access + item-grant callers per Codex review (round 2)
Round 1's scoping had two bugs in how it read visibleCollectionIDs:
1. nil means "all-access" (admin / collection_access=all), but the handler
treated nil as an empty visible set → all-access users got an EMPTY report.
Now nil → ScopeToVisible stays false (full workspace report).
2. For guests, visibleCollectionIDs includes collections visible only via
item-level grants; passing those to the aggregate report leaked the whole
collection's counts. Now mirror the dashboard: when item-level grants are
present, scope to fullCollIDs (full-access collections only).
Adds report handler tests (owner full report + default window) alongside the
store-level scoping test.
* fix(report): bearer-aware admin visibility scoping per Codex review (round 3)
visibleCollectionIDs grants ANY platform admin an unrestricted (nil) view, but
RequireWorkspaceAccess suppresses the platform-admin bypass for bearer auth and
falls through to membership (BUG-1616/1617). So a bearer admin (PAT/CLI/OAuth)
who is only a restricted workspace member could read the full workspace report.
Extract reportVisibleCollections(): gate the admin bypass on cookie auth; for
everyone else resolve actual member/guest visibility, and when item-level
grants exist scope to the full-access collection set only. Adds a cookie-vs-
bearer scoping test (cookie admin unrestricted, bearer restricted-member scoped
to the granted collection, end-to-end through GetReport).
* fix(report): exclude soft-deleted items from completion counts per Codex review (round 4)
status_transitions rows survive a soft delete (only a HARD delete cascades
them), so a completed-then-soft-deleted item still counted toward completed /
completed_by_collection while created and status_distribution (which filter
deleted_at IS NULL) excluded it — inconsistent totals. Join live items in both
completed queries. Adds a regression test.
|
||
|
|
5dfc2921b2 |
feat(store): structured status-transition log + backfill (TASK-1637) (#637)
* feat(store): structured status-transition log + backfill (TASK-1637)
Add a status_transitions table capturing every item status change as a
structured, queryable row — written in the same tx as the item update and
never debounced — so the Reports surface (PLAN-1628) can reliably compute
the completed-throughput and cycle-time series.
- migrations/063 + pgmigrations/042: status_transitions table (dual-dialect),
indexed on (workspace_id, created_at) and (item_id, created_at)
- write-path hook in UpdateItemWithPreCheck records from→to on status change
- BackfillStatusTransitions: one-time startup replay parsing the historical
activities.metadata.changes blob (mirrors BackfillWikiLinks), gated on an
empty table; wired into cmd/pad/main.go
- models.StatusTransition + tests (capture, multi-hop, no-op, parser, backfill)
Spike (TASK-1629) found the activity log records status changes only as a
human-readable, debounce-coalesced metadata string — unusable for aggregation.
This is the foundation TASK-1630 (report aggregation) builds on.
* fix(store): record status transitions on item move too per Codex review (round 1)
MoveItemWithPreCheck rewrites fields outside UpdateItemWithPreCheck, so a
status-changing move override (pad item move ... --field status=done) was
not recorded in status_transitions, making the table non-canonical. Insert
the from→to row in the move tx as well, stamped with the target collection.
Adds move-path capture tests (status override + status-preserving move).
* fix(store): make status-transition backfill idempotent per Codex review (round 2)
The empty-table gate isn't atomic, so concurrent replays (a future
multi-replica Postgres deploy; single-instance today) could double-insert
historical rows and overcount reports. Give backfilled rows a deterministic,
activity-derived primary key ("bf_" + activity id) and a dialect-aware
conflict clause (ON CONFLICT DO NOTHING / INSERT OR IGNORE) so a re-run
no-ops instead of duplicating. Count only rows that actually land.
Write-path rows keep using a random newID(), so live data never collides.
* fix(store): accurate from_status under lock + document backfill caveats per Codex review (round 3)
1. from_status was read from the pre-lock `existing` snapshot. When no
precheck ran, a concurrent update (serialized behind the locks we hold)
could make it stale. Capture the status from a fresh in-tx read BEFORE
the UPDATE (reading after would see the new value and drop the hop).
Applied to both UpdateItemWithPreCheck and MoveItemWithPreCheck.
2. Backfill stamps historical rows with the item's current collection_id;
reconstructing the collection at each past status change would require
replaying move history. Documented as a best-effort, historical-only
caveat (exact for the common never-moved case; live write/move paths
stamp the collection at transition time).
* feat(store): track collection done-field + seed create-time transitions per Codex review (round 4)
1. Generalize capture from hard-coded "status" to each collection's done
field (DoneFieldKey: status, or BoardGroupBy field like stage/result for
hiring/interviewing). Add a field_key column recording which field the
row tracks (robust to later BoardGroupBy changes). Applied to update,
move, and backfill paths.
2. Seed a create-time "entered initial status" transition on CreateItem and
in the backfill (Pass 2), so an item created directly in a terminal value
still counts as a completion. Initial value reconstructed from the item's
earliest recorded change, else its current value.
Also: item_id FK is ON DELETE CASCADE so hard-deletes clean up transitions.
Tests cover non-status done-field, create-in-terminal, create-seed, and
cascade-on-delete; full store suite green.
|
||
|
|
a88f7755c9 |
chore(ci): bump Node 20 actions to Node 24 ahead of June 2026 deadline (TASK-1165) (#635)
* chore(ci): bump Node 20 actions to Node 24 ahead of June 2026 deadline (TASK-1165) GitHub deprecated Node 20 in Actions runners; the hard cutoff is June 2nd, 2026. Pre-emptively bumps the three remaining Node 20 holdouts to their latest Node 24 versions, SHA-pinned per the existing convention: - actions/setup-node v4.4.0 → v6.4.0 (using: node24) - actions/upload-artifact v4.6.2 → v7.0.1 (using: node24) - anchore/sbom-action/download-syft v0.18.0 → v0.24.0 (using: node24) Breaking-change review (all clear for our usage): - setup-node v5/v6: only behavioral change is "limit automatic caching to npm" — we already pass cache: "npm" explicitly. node-version: "24" + cache-dependency-path: web/package-lock.json continue to work. - upload-artifact v5/v6: v5 treats the Node 24 bump as breaking; v6 requires Actions Runner ≥ 2.327.1 (GitHub-hosted runners are auto-updated, so no concern). Our single-fixed-name failure-only upload is unaffected. - upload-artifact v7: adds optional archive: false single-file unzipped uploads + ESM internals. Our usage (name/path/retention-days) is unchanged. - sbom-action 0.18→0.24: minor 0.x bumps; v0.24 release notes explicitly cite "update to node 24 + deps". Post-audit: every uses: spec in .github/workflows/ now reports node24 or composite. No Node 20 actions remain. Per TASK-1165 verification: this PR touches .github/workflows/release.yml, so the playbook's RC decision rule (PLAYB-1160 step 1) triggers — the next release will warrant a vX.Y.Z-rc.1 to confirm the deprecation annotation is gone before shipping stable. * chore(ci): cap golangci-lint cache to 1 day to avoid poisoning recurrences (BUG-1624) PR #635's first CI run failed on 30+ SA5011/SA4023 false positives against unchanged code; local cold-cache lint reported 0 issues. Diagnosis: golangci-lint-action's cache stores the prior pass's resolved issue list, and once a pass writes degenerate results (analyzer upgrade, plugin reset, sub-package drift), every downstream restore replays that list verbatim until the cache key rotates. The cache key hashes go.mod/go.sum/.golangci.yml plus an action-internal prefix, so in steady state the key is stable for days and the poisoned content propagates across PRs. Default invalidation is 7 days. This change cuts it to 1 day. Most runs still hit warm cache (lint runs back-to-back within hours of each other are common); we guarantee a daily fresh full pass that overwrites any bad cached state. Estimated cost: ~30-60s extra on one CI run per day. Hand-mitigated the immediate occurrence by deleting the two poisoned cache entries via the GH cache API; rerun then went green. BUG-1624 captures the full diagnosis + alternatives considered. * chore(ci): cache Playwright browsers to dodge CDN slow-paths (BUG-1625) PR #635 hit two consecutive 10-minute timeouts on the E2E job, both dying inside `npx playwright install --with-deps chromium` while downloading Chrome from cdn.playwright.dev. The apt portion completed in ~10s; the CDN download hung for ~7 minutes before the `timeout-minutes: 10` ceiling killed the job. Same code earlier in the day ran E2E green in 1m07s — it's a CDN slowness event, not a behavioral regression. But two-runs-in-a-row timeouts mean the steady state is fragile. Fix: cache `~/.cache/ms-playwright` per resolved @playwright/test version. Splits the install step in two: - Cache miss: `npx playwright install --with-deps chromium` — full apt + browser download (current behavior). - Cache hit: `npx playwright install-deps chromium` — apt system libraries only (~10s); browser binary is already on disk. Cache key reads the resolved version from package-lock.json so a Playwright bump auto-invalidates. Pinned to actions/cache v5.0.5 (node24) per the workflow's SHA-pinning convention. After the first warm run on each Playwright version, the CDN is out of the critical path; an outage there can only burn one CI run before steady state recovers. BUG-1625 has the full diagnosis. |
||
|
|
08f76f3486 |
fix(deps): bump go-jose/v3 to v3.0.5 to clear GO-2026-4945 (BUG-1619) (#634)
* fix(deps): bump go-jose/v3 to v3.0.5 to clear GO-2026-4945 (BUG-1619)
GO-2026-4945 — Go JOSE panics in JWE decryption. Reachable via
github.com/ory/fosite v0.49.0 from internal/server/handlers_oauth.go's
handleOAuthAuthorize (govulncheck call chain into jose.ParseSigned /
JSONWebSignature.Verify / etc).
Drop-in dependency bump, no API changes. Post-bump govulncheck reports
0 reachable vulnerabilities.
Discovered during v0.6.0 release pre-flight (PLAYB-1160 step 1) — CI
run 26518095093 failed govulncheck after the most recent main push,
even though nothing about that change touched OAuth. Same shape as
TASK-1583 (the golang.org/x/net bump earlier this cycle).
* test(open-children-guard): filter readParentRef by SourceRef to fix race flake (BUG-1621)
readParentRef was matching the first LinkType=="parent" link in
GET /items/{ref}/links's response — but GetItemLinks returns links
in BOTH directions (WHERE source_id = ? OR target_id = ?). After
TestOpenChildrenGuard_PatchAtomicRejectionPreservesParentLink attaches
a child task to its target plan, the response contains two parent
links: target→oldParent AND child→target. ORDER BY created_at DESC
ties at sub-microsecond resolution; SQLite tiebreaks on rowid; under
race-scheduler CI load the child→target link sometimes lands first,
the helper returns the target's OWN ref as the "parent", and the
setup-sanity assertion fails with "parent should start as PLAN-1,
got PLAN-3" (where PLAN-3 = target itself).
Filter on SourceRef == itemRef so only the item's OUTGOING parent link
matches. Test-file-only change.
Latent since PR #571 (IDEA-1494,
v0.6.0
|
||
|
|
83716a65fc |
fix(backlinks): scope cross-workspace admin enumeration to membership for bearer auth (BUG-1617) (#633)
Companion to BUG-1616. The admin platform role granted unrestricted
cross-workspace visibility at the STORE layer too: `GetCrossWorkspaceBacklinks`
ran `ListWorkspaces()` for any user with `Role=admin`, and
`ResolveBacklinksVisibility` short-circuited to `(nil, nil)` for the
same role check. Both fired BEFORE the BUG-1616 middleware gate could
deny the request, so a bearer-borne admin (CLI / PAT / MCP) could
enumerate cross-workspace backlinks from every workspace on the server.
Policy: bearer-borne admin gets STRICT membership enumeration — no
guest-grants fallback. Matches RequireWorkspaceAccess's membership-only
stance from BUG-1616. Cookie-session admin keeps the global view
(preserved web-UI affordance).
Threads `authIsBearer bool` from the HTTP boundary (via the new
isBearerAuth helper) into the store layer:
- `Store.ResolveBacklinksVisibility` — admin bypass now gated on
`!authIsBearer`; bearer-admin falls through to the regular
member/grants pipeline. Also tightens the "no visibility" return
shape from `(nil, nil)` to non-nil empty slices so callers can
distinguish "unrestricted" from "explicit empty" — closes a
latent ambiguity that doesn't fire in current callers but would
if any future caller bypassed the upstream membership filter.
- `Store.GetCrossWorkspaceBacklinks` — new switch:
- cookie admin → ListWorkspaces (unchanged)
- bearer admin → GetUserMemberWorkspaces (NEW, strict
membership; no grants fallback)
- non-admin → GetUserWorkspaces (unchanged; memberships
∪ guest-grant workspaces)
- `Server.guestResourceFilterCore` — admin short-circuit now gated
on `!isBearerAuth(r)`; bearer-admin delegates to the store-side
helper with the bearer signal threaded through.
- `handlers_backlinks.go` — pass `isBearerAuth(r)` to
`GetCrossWorkspaceBacklinks`.
New `Store.GetUserMemberWorkspaces` helper — the first half of
`GetUserWorkspaces` without the UNION-with-grants block. Used by the
bearer-admin path; existing callers continue to use `GetUserWorkspaces`
unchanged.
Tests:
- `wiki_links_xws_test.go`:
- Updated `TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces` to
cover both `authIsBearer=false` (cookie, sees all) and
`authIsBearer=true` (bearer, sees none) cases.
- NEW `TestWikiLinks_CrossWorkspaceBearerAdminGrantOnlyWorkspaceFiltered`
— bearer-admin with a guest grant on workspace C still gets ZERO
cross-ws rows from C (Codex round-2 finding).
- NEW `TestWikiLinks_CrossWorkspaceBearerAdminSeesMemberWorkspaces`
— positive control: bearer-admin who IS a member sees the row.
- Extended `TestResolveBacklinksVisibility_RoleMatrix` with two
bearer-admin subtests (non-member workspace → empty; member
workspace → unrestricted).
- NEW `handlers_backlinks_admin_bearer_test.go::TestCrossWorkspaceBacklinks_AdminBearer_OnlySeesMembershipWorkspaces`
— full HTTP integration test, both cookie and PAT-bearer subtests.
- All existing callers updated to pass `false` for `authIsBearer`
(preserves current cookie-session / non-admin behavior).
Verification: full `go test ./...` green; `make lint` clean;
Codex round 2 review CLEAN.
🤖 BUG-1617
|
||
|
|
f48c99e421 |
fix(auth): scope admin platform role to cookie session auth (BUG-1616) (#632)
The admin platform role granted owner-level access to every workspace on every surface, including bearer-borne callers (PATs on /api/v1, CLI session bearers, PATs/OAuth on /mcp). A user with "All current workspaces" consent on an MCP client — or a leaked admin token of any kind — could reach data the admin never joined. Policy: the admin global bypass now fires only for cookie session auth (web UI / SPA / /console/admin). Bearer-borne callers fall back to a strict workspace_members check (membership-only; no guest-grants fallback either). Gated four sites in lockstep: - RequireWorkspaceAccess (internal/server/middleware_auth.go) — covers /api/v1/* routes; emits the existing not_a_member MCP authz denial metric on bearer-admin denials. - handleSSE entry (internal/server/handlers_events.go) — adds an explicit GetWorkspaceMember check for bearer-borne admin after resolveWorkspace's global slug lookup. - sseSubscriberStillHasAccess (internal/server/handlers_events.go) — per-tick revalidation now matches entry-time policy. - computeSSEVisibility (internal/server/handlers_events.go) — bearer-admin gets a real VisibleCollectionIDs filter instead of "no filtering"; a bearer-admin who's a member with collection_access=specific is now correctly scoped. - authorizeCollabAccess (internal/server/handlers_collab.go) — WebSocket collab upgrade gate; same membership-only stance. New shared helper isBearerAuth(r) folds two signals (Authorization: Bearer header OR ctxIsAPIToken stash) so MCP-dispatcher synthesized requests and CLI session bearers are both covered. Mirrors the dual check middleware_csrf.go already uses. Tests (9 total): - middleware_auth_admin_token_gate_test.go (5) — PAT denied/allowed permutations, CLI session-bearer denial, cookie-session bypass preserved. - handlers_admin_bearer_gate_test.go (4) — SSE revalidation, visibility filter, collab WebSocket auth. Companion BUG-1617 (store-layer admin bypass in backlinks visibility) tracked separately. |
||
|
|
225fb4a53f |
Wire upgrade CTAs with Stripe-ready billing flow (TASK-800) (#629)
* feat(billing): add billing_available session flag gated on PAD_BILLING_AVAILABLE (TASK-800)
Add Server.billingAvailable field set by SetBillingAvailable(), called from
cmd/pad/main.go when PAD_BILLING_AVAILABLE=true|1. Expose the flag as
billing_available in both the setup-state and authenticated session payloads
(value: cloudMode && billingAvailable) so the web UI can gate Stripe CTAs
without a code change at deploy time. False by default.
* feat(billing): wire upgrade CTAs, checkout POST flow, plan section, clickable limit toasts (TASK-800)
Frontend prep work gated on authStore.billingAvailable (from billing_available
session field). When false, upgrade buttons remain hidden and the "coming soon"
note stays in place — flip PAD_BILLING_AVAILABLE=true at deploy time.
Changes:
- client.ts: add billing_available to AuthSession; add api.billing.createCheckoutSession()
(POST /billing/checkout → parse {url} → caller does window.location.href)
- auth.svelte.ts: billingAvailable getter
- console/billing: replace STRIPE_AVAILABLE=false with $derived(authStore.billingAvailable);
fix GET→POST on upgrade buttons; add ?checkout=cancelled banner; add cancelled style
- console/settings: new cloud-mode-gated "Plan" section with current plan + upgrade/manage link
- All 11 limit-hit sites: replace plain-text '/console/billing' appendage with
toastStore.show(msg, 'error', 6000, '/console/billing') so the toast is clickable
* docs(billing): document pad-cloud CSRF and error-envelope contract divergences in createCheckoutSession (TASK-800)
|
||
|
|
342679a364 |
Standardize plan-limit error envelope across HTTP/MCP/CLI/UI (TASK-788) (#628)
* fix: limit-hit responses were actively broken — garbled toasts, no upgrade signal
The limit enforcement responses (plan_limit_exceeded on 403) used a flat
body shape {"error": "plan_limit_exceeded", ...} that is incompatible with
every consumer: the frontend PadApiError parser, the CLI parseError path,
and the MCP classifyHTTPStatusKind all expect {"error": {"code": ...,
"message": ...}}. As a result, hitting any of the 5 plan limits (items,
members, workspaces, api_tokens, webhooks) produced garbled toasts with
undefined message text and zero upgrade signal.
Fix:
- writePlanLimitError now emits the standard nested error envelope with a
human-readable message sentence and limit details in error.details.
- CLI parseError now correctly surfacing the message (net positive, no
code change needed).
- MCP classifyHTTPStatusKind: adds ErrPlanLimitExceeded to the taxonomy
and the allowedStructuredErrorCodes whitelist so 403 plan-limit errors
pass through with code + details rather than collapsing to
ErrPermissionDenied (TASK-788).
- Frontend: exports isPlanLimitError() type-guard and planLimitMessage()
formatter from client.ts; all 4 limit-hit write call sites (item create,
member invite, workspace create, token create) now branch on the code and
show an upgrade-signal message pointing at /console/billing.
- Test: updates handlers_workspace_cap_test.go to the new body shape; adds
TestPlanLimitError_ResponseShape covering members_per_workspace limit hit.
TASK-788
* fix(R1): cover MCP stdio path, 5 more item-create sites, polish message wording
Finding A — MCP stdio transport was missing plan-limit coverage:
- cli/client.go: add PlanLimitDetails struct, AsPlanLimit() helper, and
WritePlanLimitError() that emits the pad-structured-error/v1 marker so
the MCP stdio classifier can lift code + details instead of falling
through to ErrServerError.
- cmd/pad/main.go: wire the WritePlanLimitError branch into all three
CreateItem call sites (item create, convention activate, playbook activate).
- internal/mcp: add TestClassifyHTTPStatus_PlanLimitPreservesCodeAndDetails,
TestClassifyHTTPStatus_Generic403FallsToPermissionDenied,
TestClassifyExecError_PlanLimitMarkerLiftsStructuredPayload, and
TestClassifyExecError_PlanLimitWithoutMarkerFallsThrough.
Note: extractUpstreamErrorEnvelope already parses details (json.RawMessage
field) — the codex concern about it being silently empty was a false alarm;
no fix needed there.
Finding B — 5 more item-create entry points were unguarded:
- EditorBubbleMenu.svelte (inline wiki-link capture)
- Sidebar.svelte (quick-add)
- roles/+page.svelte (board new-item, was console.error only; adds toastStore)
- conventions/+page.svelte
- playbooks/+page.svelte (both create and duplicate paths)
B1/B2 polish — server message is now statement-of-fact only, no doubled
upgrade verb. planLimitMessage() drops "Upgrade to Pro to add more." (each
surface appends its own CTA). limitStr uses hyphenated adjective form
"3-member" / "10-item" (compound modifier before "limit").
TASK-788
* feat(task-788): extend MCP-stdio plan-limit coverage to workspace, invite, webhook
Wire WritePlanLimitError into three additional CLI command error paths so
the MCP stdio classifier surfaces ErrPlanLimitExceeded with details instead
of falling through to ErrServerError:
- workspaceCreateCmd: check before fmt.Errorf wraps the APIError
- inviteCmd: check before returning the raw error
- webhooksCreateCmd: check before returning the raw error
Add TestClassifyExecError_PlanLimitWorkspaceCreate to exercise the full
workspace-create stdio round-trip through classifyExecError, asserting
ErrPlanLimitExceeded code, feature="workspaces", limit, and upgrade_url.
Token create intentionally left bare (agents don't drive token creation).
|
||
|
|
a7fb14ee14 |
Reduce free-tier workspace cap from 5 to 3 (TASK-1609) (#627)
Lower DefaultFreeLimits.Workspaces from 5 to 3 to sharpen the price-discrimination delta between Free and Pro ahead of Stripe Live (PLAN-1570 Phase a). Add IDEA-1611 comment on the soft-deleted workspace count behavior. Add store-level and handler-level tests covering the new boundary, pro-tier bypass, override path, and self-hosted no-limit path. |
||
|
|
3a04c06684 |
fix(backlinks): suppression must query item_links, not items.parent_id (TASK-1607 followup) (#626)
* fix(backlinks): suppression must query item_links, not items.parent_id (TASK-1607 followup)
The initial TASK-1607 fix routed the parent↔child "Mentioned in"
suppression through `items.parent_id`. That column is empty in
production — 0 of 3,923 items in a live workspace had it set —
because the API path (handlers_items.go::handleCreateItem) writes
parent relationships via Store.SetParentLink, which only touches
the `item_links` table with link_type='parent' (source=child,
target=parent — see migration 023). The deprecated parent_id
column is vestigial. Result: the filter was a no-op everywhere
the user actually encountered the duplication.
Caught when testing on fhir-core/TASK-397 — a task with ~20
children all wiki-linking it, none filtered. Direct sqlite
inspection confirmed: parent_id NULL on every row, but 2,022
'parent' rows in item_links workspace-wide.
The fix:
- Replace the SQL predicate in GetBacklinks and CountBacklinks
with two NOT EXISTS subqueries against item_links — one for
"source is not a child of target", one for "source is not the
target's parent". Both use idx_links_source / idx_links_target
for O(1) lookups per candidate row.
- Drop the TargetParentID field from BacklinksVisibility. The
store no longer needs the handler to plumb it through — the
parent relationship is queried from item_links directly. Same
pattern as children-suppression: both are unconditional and
self-contained.
- Update handlers_backlinks.go to remove the dead plumbing.
- Rewrite createChildItem test helper to use SetParentLink (the
production path) instead of CreateItem with ParentID (the dead
column path). The original test helper made all the
suppression tests pass falsely against the wrong storage.
- Add TestWikiLinks_SuppressionUsesItemLinksNotParentIDColumn:
sets up the parent ONLY via SetParentLink, asserts parent_id
column stays NULL, asserts suppression still works. Regression
guard against routing the filter back through the dead column.
The existing 4 suppression tests still pass against the corrected
mechanism (they now exercise SetParentLink under the hood).
Cross-workspace path remains untouched — item_links is workspace-
scoped same as parent_id was, so the doc comment on
GetCrossWorkspaceBacklinks stays valid.
Refs IDEA-1601.
* fix(backlinks): consult items.parent_id alongside item_links for suppression (Codex P2)
Codex review of the prior commit raised a P2: ItemCreate.ParentID
and ItemUpdate.ParentID still write to items.parent_id directly
without creating an item_links row (handlers_items.go only calls
SetParentLink for the `parent` field path, not for direct
ParentID JSON). The HTTP/CLI path is unaffected — empirically
zero items in production have parent_id set — but a direct
store-API caller (test, future import path, or any code that
bypasses the handler) could leave the suppression invisible
to one of the two storage shapes.
Belt-and-suspenders: the relClause now AND's two pairs of
predicates so suppression triggers whichever shape the parent
relationship lives in.
- Children-suppression: NOT EXISTS item_links AND items.parent_id
comparison
- Parent-suppression: NOT EXISTS item_links AND a correlated
subquery against items (NOT EXISTS items t WHERE t.id = ?
AND t.parent_id = s.id) so we don't have to plumb target's
parent_id through
New regression test (TestWikiLinks_SuppressionFallsBackToItemsParentIDColumn)
exercises the column-only path: CreateItem with ParentID, NO
SetParentLink, asserts items.parent_id IS set and item_links is
empty, then asserts suppression still works in both directions.
CountBacklinks updated identically to stay in lockstep with
GetBacklinks pagination math.
* fix(backlinks): suppression must cover childLinkTypes ('parent' AND 'implements') (Codex round 2)
Codex round 2 caught: the Child Items panel and GetChildItems
both inflate `childLinkTypes = {"parent", "implements"}` (see
items.go:18). My filter only suppressed link_type='parent'. An
'implements' child therefore still appeared in Mentioned in
even though it was visually duplicated in the Children section
above.
Use childLinkTypeSQL() in both NOT EXISTS subqueries (children
direction and parent direction) so the filter stays in lockstep
with the canonical inclusion rule. If a future link type is
added to childLinkTypes, the suppression picks it up automatically.
New regression test
TestWikiLinks_SuppressionCoversImplementsChildLinkType wires a
child via CreateItemLink(link_type=implements) and asserts the
mention is suppressed — pins the lockstep with childLinkTypes.
This was the actual mechanism for the original fhir-core/TASK-397
report: TASK-441 et al. carry "**Implements:** [[TASK-397]]" in
their bodies AND are linked as 'parent' (Pad uses 'parent' for the
explicit hierarchy in that workspace), but a sibling case where
only 'implements' was set would have leaked through round 1's fix.
|
||
|
|
4aff8c70a0 |
feat(backlinks): suppress parent↔child mentions from "Mentioned in" panel (TASK-1607) (#625)
On a parent's page (typically a PLAN with many child TASKs), the "Mentioned in" panel was dominated by child tasks that wiki-link back to their parent. Those children are already listed in the Children section directly above the panel, so the same items appeared twice on screen and buried genuine cross-references (sibling plans, retro docs, etc.). Symmetric problem on a child's page: the parent shows up in "Mentioned in" even though it's already in the "Parent: …" header. Suppression is server-side in two parts, modeled on the existing self-link filter in GetBacklinks (s.id != targetItemID): 1. Children-suppression — always-on. Source rows where s.parent_id = targetItemID are dropped. Uses the existing targetItemID parameter; no API change. NULL-safe form so orphan items still surface (raw `s.parent_id != ?` would silently drop NULL parent_id rows under SQL three-valued logic). 2. Parent-suppression — opt-in. New TargetParentID *string field on BacklinksVisibility (zero value = nil = no parent suppression, keeping the ~50 existing test callsites valid). When set, the source row whose id == TargetParentID is dropped. The handler passes item.ParentID from the resolved target. Both apply to CountBacklinks identically so the handlers_backlinks.go same-ws/cross-ws pagination math (which depends on count-vs-fetch agreeing) stays correct. GetCrossWorkspaceBacklinks is unaffected — parent_id is workspace-scoped, so a cross-ws source can't be the target's parent or child by construction. Doc comment added noting this. Tests: - ChildMentionOfParentSuppressed: headline case - ParentMentionOnChildPageSuppressed: symmetric case with/without TargetParentID - SiblingMentionsNotSuppressed: control — siblings of a shared parent that wiki-link each other still surface - OrphanBacklinksUnaffected: regression for the NULL-safe form - PaginationStableAfterSuppression: page 1 + page 2 hit all filtered rows exactly once with no duplicates Promoted from IDEA-1601. Follow-up to [[PLAN-1593]] (which shipped the original wiki-link backlinks reverse index). |
||
|
|
c1c9f2ef97 |
fix(share): guard nil grants/share-link slices to prevent ShareDialog TypeError (BUG-1598) (#624)
The Share dialog threw "Cannot read properties of null (reading 'length')"
on any item/collection with zero grants. Store.ListCollectionGrants /
ListItemGrants return a nil slice when there are no rows, which writeJSON
encoded as JSON null; the client then hit grants.length on null.
Server: nil-to-[]{} guard in handleListCollectionGrants and
handleListItemGrants, mirroring the existing pattern in
handlers_share_links.go.
Client: defensive ?? [] in ShareDialog.svelte's loadGrants and
loadShareLinks so a null body can't reach .length checks.
|
||
|
|
35ac7552eb |
feat(backlinks): UI panel + mention badge + CLI + MCP (Phase 3) (#623)
* feat(backlinks): UI panel + mention badge + CLI + MCP (Phase 3)
Phase 3 of PLAN-1593 (TASK-1596). Surfaces the backlinks index
shipped in Phases 1/2a/2b across every place users live: web UI,
CLI, MCP.
What changed
Web UI
- New BacklinksPanel.svelte at web/src/lib/components/. Fetches via
api.items.backlinks (new method) and renders inbound `[[...]]`
references grouped by source collection. Per-row: collection icon,
ref + title, snippet, relative timestamp, optional `(displayed as)`
override, faint workspace badge on cross-ws rows. Pagination via
"Show older" when the page is full. Collapses entirely when the
count is zero — no header, no whitespace, no empty surface for
items with no inbound links.
- Mention badge ("📎 N") in the item-page action bar next to the
Timeline button. Hidden when N=0; smooth-scrolls to the panel.
Wired via onCountChange callback so badge + panel stay in sync.
- New Backlink TypeScript type at web/src/lib/types/index.ts mirroring
internal/models/backlink.go; new api.items.backlinks(ws, slug, opts)
client method.
CLI
- `pad item show <ref>` enriched with inline top-5 "Mentioned in"
section in TTY mode (skipped when empty), and a backlinks_top
array in JSON output. Hint at the dedicated `pad item backlinks`
command when the inline list hits the 5-row cap.
MCP
- New `pad_item.action: backlinks` — passes through to
`pad item backlinks <ref>` with optional `limit` (default 50,
max 300) + `offset` params. Bumps ToolSurfaceVersion 0.5 → 0.6
with a backwards-compatible additive note in version.go. Updated
the catalog_readonly_test fixtures so the cmdhelp drift check
passes.
Test plan
- [x] go build ./... + go test ./internal/mcp/ green
- [x] make check (lint + Go + web) green
- [x] make install + restart
- [x] pad item show TASK-1596 shows --- Mentioned in --- inline
- [x] pad item show TASK-1596 --format json includes backlinks_top
- [x] svelte-check 0 errors
- [ ] /codex review --loop → CLEAN
Out of scope (filed as separate ideas if anyone asks)
- Force-directed graph visualization of the link network
- Broken-links report (target_item_id IS NULL feeds it but it's its
own feature)
PLAN-1593 / TASK-1596.
* fix(backlinks): unique each-block key for multi-occurrence rows (Codex round 1)
Codex round 1 P1: BacklinksPanel keyed each row by source_item_id,
but the server preserves multiplicity — a source body that mentions
the target three times produces three Backlink rows (Phase 1 design
decision, covered by TestWikiLinks_RepeatedRefStoresMultipleRows).
Duplicate keys in Svelte's #each are rejected at dev time and
silently reuse DOM in prod, so the panel would render only one of
the N rows from a multi-mention source.
Fix: compose a unique key per row via new rowKey(bl, index) helper:
`${source_item_id}|${snippet}|${index}`. The snippet usually
differs across positions (centered on the bracket byte offset);
the index suffix is the unconditional tie-breaker.
PLAN-1593 / TASK-1596.
|
||
|
|
905876af04 |
feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) (#622)
* feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) Phase 2b of PLAN-1593 (TASK-1597). Completes the wiki-link reverse index by indexing and surfacing `[[workspace::REF]]` cross-workspace references. Builds on Phase 2a's title work (PR #621). Phase 3 (TASK-1596) owns the UI/MCP/CLI rendering changes. What changed - internal/store/backlinks_visibility.go (new): request-independent ACL helper `Store.ResolveBacklinksVisibility(userID, workspaceID, includeDeletedItems)`. Mirrors the role-determination + collection- merge logic from server.guestResourceFilterCore but doesn't depend on a request context, so cross-ws traversal can compute per-source- workspace ACLs without a `workspaceRole(r)` lookup. The Codex planning-round review caught the prior plan reusing the request- scoped helper as a hidden architectural cost; this is the resolution. - internal/server/server.go: guestResourceFilterCore refactored to delegate to the new store helper. Keeps the request-scoped wrapper signature stable for all existing handler call sites; only the internals move. - internal/links/extract.go: lift the Phase-2a workspace_ref emit gate. WikiLinkKindWorkspaceRef now flows through ExtractWikiLinks alongside ref and title kinds. parseBody recognition was already in place from earlier rounds. - internal/store/wiki_links.go: WikiLinkKindWorkspaceRef branch in replaceWikiLinks stores (target_workspace_id, target_ref) verbatim, resolving the slug→ID via new resolveWorkspaceSlugTx (with per-call cache so repeated `[[ws::X]]` in one body don't re-query). Unknown slugs persist with target_workspace_id=NULL — broken-link semantics, identical to existing ref/title patterns. - internal/store/wiki_links.go: new `Store.GetCrossWorkspaceBacklinks` enumerates accessible workspaces via Store.GetUserWorkspaces (which includes guest-only access — broader than membership query), then per-workspace computes visibility via ResolveBacklinksVisibility and runs the SQL backlinks query with the per-ws (FullCollectionIDs, GrantedItemIDs) predicate inline. Results sorted by updated_at DESC in Go, paginated globally. Per-workspace safety cap (offset+limit) prevents one workspace from dominating the global slice. - internal/store/wiki_links.go: new `Store.CountBacklinks` for same-ws pagination boundary detection. Needed so the handler knows where the cross-ws tier begins for pages 2+. - internal/models/backlink.go: new `SourceWorkspaceSlug string` (omitempty) field. Populated only by cross-ws rows; same-ws rows leave it empty so the existing wire shape is preserved. - internal/server/handlers_backlinks.go: union pagination across same-ws and cross-ws tiers. Same-ws first (matches the renderer's UI mental model — your own workspace's links at the top of the panel). Count-based slice math handles pages 2+ correctly when same-ws is exhausted. Tests - internal/links/extract_test.go: workspace_ref forms emit correctly (bare, display alias, mixed case, invalid-slug fallback to title). - internal/store/wiki_links_xws_test.go (new): six cross-ws scenarios plus a role-matrix test: - end-to-end cross-ws index + query - non-member sees nothing - guest with collection grant sees only that collection - guest with item grant sees only the granted item - unknown workspace slug → broken row, no query results - same-ws rows leave SourceWorkspaceSlug empty - ResolveBacklinksVisibility role matrix (admin/full member/guest with grants/non-member non-grant) Out of scope (Phase 3 / TASK-1596) UI rendering of cross-ws backlinks (workspace badge + workspace- prefixed ref), MCP `pad_item.action: backlinks` cross-ws fields, CLI display tweaks. PLAN-1593 / TASK-1597. * fix(backlinks): admin enumeration + cross-prefix ref fallback + unbounded perWsCap (Codex round 1) Three P2 findings from Codex round 1 against PR #622: Finding 1 — admin users miss cross-ws backlinks. `GetUserWorkspaces` returns only memberships + grant-only guest workspaces, but RequireWorkspaceAccess (middleware_auth.go:481) gives admins implicit access to every workspace. An admin querying for backlinks would silently miss links from workspaces they're not explicitly a member of. Fix: in GetCrossWorkspaceBacklinks, branch on user.Role: - admin → s.ListWorkspaces() (every non-deleted workspace) - non-admin → s.GetUserWorkspaces (memberships + grants) Stale user IDs return empty result rather than erroring. Finding 2 — cross-ws ref matching doesn't handle cross-prefix moves. Same-ws is immune because target_item_id is resolved at parse time and survives renames/moves; cross-ws resolves at query time, so a `[[other-ws::OLD-42]]` row written before the target moved from OLD→NEW collection wouldn't match a query under the NEW ref. Fix: in queryCrossWorkspaceBacklinksForWorkspace, dual ref-match clause: exact `LOWER(wl.target_ref) = LOWER(?)` OR `LOWER(wl.target_ref) LIKE LOWER('%-N')` where N is the item_number from the target ref. Pad prefixes are alphanumeric with no internal `-`, so trailing `-N` uniquely identifies the number suffix — no false positives like "TASK-142" matching "%-42" (LIKE anchors to the trailing literal). Finding 3 — per-workspace cap of 1000 silently broke pagination beyond offset>=1000. The 1000 ceiling was defensive paranoia; the correct math is offset+limit per workspace (worst case all rows come from one workspace and the global slice still needs that many). Fix: drop the 1000 ceiling. perWsCap = offset+limit unconditionally. For runaway offsets the per-workspace transfer cost is proportional; documented as a known characteristic (callers shouldn't be paging past offset=10000 anyway). Regression tests: - TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces: admin sees cross-ws backlink without being a workspace member. - TestWikiLinks_CrossWorkspaceRefNumberFallback: move target to new collection, query under new ref, old-ref-stored row still surfaces. PLAN-1593 / TASK-1597. * fix(backlinks): honor OAuth/MCP token workspace allow-list (Codex round 2) Codex round 2 P1: cross-workspace backlinks bypassed the OAuth/MCP token's workspace allow-list (TASK-952). A token consented for workspace A but with the underlying user having access to B would still surface source rows from B via the cross-ws query — leaking data outside the token's consent scope. Fix: thread `allowedWorkspaceSlugs []string` through GetCrossWorkspaceBacklinks. Handler populates it from TokenAllowedWorkspacesFromContext(r.Context()): - nil → no token gate (PAT or pre-TASK-952 token, allow all) - "*" wildcard → allow all - explicit list → strict slug membership Workspace enumeration skips any source workspace whose slug isn't in the allowlist. The same-ws path is unchanged because RequireWorkspaceAccess already gated the target workspace against the allow-list (so we only reach this handler when the target IS in the list). Regression test in wiki_links_xws_test.go covers four shapes: nil, wildcard, target-only (blocks cross-ws), explicit source-workspace (allows cross-ws). PLAN-1593 / TASK-1597. * fix(backlinks): normalize limit at handler boundary (Codex round 3) Codex round 3 P2: the backlinks handler parsed ?limit=N but didn't normalize it before computing the same-ws/cross-ws pagination split. GetBacklinks and GetCrossWorkspaceBacklinks each clamp >300 internally, but the handler's 'remaining := limit - len(sameWs)' used the original (potentially huge) value. With ?limit=301 and more than 50 same-ws backlinks, the first page would mix cross-ws in before same-ws was exhausted, violating the documented tier order. Fix: clamp 'limit' to <=300 at the handler boundary, before any pagination math runs. PLAN-1593 / TASK-1597. * fix(backlinks): normalize same-workspace [[ws::REF]] to ref-kind (Codex round 4) Codex round 4 P2: `[[<current-ws>::TASK-1]]` was being indexed as a workspace_ref row with target_workspace_id = current workspace. But the same-ws GetBacklinks query requires target_item_id (workspace_ref rows leave it NULL), AND GetCrossWorkspaceBacklinks explicitly skips the target workspace — so the link rendered and navigated correctly in the UI but no backlink ever surfaced. The renderer's L307 short-circuits same-workspace fully-qualified form to behave identically to `[[REF]]`; the index must follow. Fix: in replaceWikiLinks, normalize a workspace_ref link to ref-kind when its slug resolves to the current workspace. The promotion canonicalizes the ref (via new links.CanonicalizeRef exported alias) so `[[ws::task-5]]` stores the same canonical shape as `[[TASK-5]]`. Tests: - TestWikiLinks_CrossWorkspaceSameWorkspaceQualifiedNormalized: same-ws fully-qualified `[[ws::REF]]` surfaces in same-ws backlinks and is absent from cross-ws backlinks. PLAN-1593 / TASK-1597. * fix(backlinks): same-ws qualified ref miss doesn't title-fallback (Codex round 5) Codex round 5 P2: my round-4 normalization was too aggressive. It promoted `[[<current-ws>::REF]]` to ref-kind and let the regular ref branch handle it — including the title-fallback path that runs on ref miss. But the renderer's same-ws qualified branch (markdown.ts:472-481) does NOT title-fallback: a ref miss in that path returns the wiki-link verbatim (broken). Only the bare `[[REF]]` path (markdown.ts:513) falls through to title lookup. So my normalization could create ghost backlinks for source bodies like `[[ws::ISO-9001]]` when an item titled "ISO-9001" exists but no ISO collection — the renderer renders broken text, but the index would point at the title-matching item. Fix: handle same-ws qualified refs inline at the top of the loop, BEFORE the switch dispatches. Insert as ref-kind row (resolved or NULL) and `continue` past the switch. Bypasses the title-fallback path entirely, mirroring the renderer's behavior. Regression test in wiki_links_xws_test.go pairs same-ws qualified miss (must NOT title-fallback) with bare ref miss (SHOULD title-fallback) to lock the asymmetry in. PLAN-1593 / TASK-1597. |
||
|
|
c67c167c43 |
feat(backlinks): title-form wiki-links + rename cascade (Phase 2a) (#621)
* feat(backlinks): title-form wiki-links + rename cascade (Phase 2a) Phase 2a of PLAN-1593 (TASK-1595). Extends the server-side wiki-link reverse index from Phase 1's `[[REF-N]]` coverage to also handle `[[Title]]` and `[[collection/Title]]`. Cross-workspace `[[ws::REF]]` forms stay gated until TASK-1597 (Phase 2b) ships the request- independent ACL helper. What changed - internal/links/extract.go: lift the Phase-1 emit gate for WikiLinkKindTitle; keep WikiLinkKindWorkspaceRef gated. - internal/store/wiki_links.go: title branch in replaceWikiLinks (verbatim target_title storage + case-insensitive resolution), resolveTitleTx with full-key match first / `/`-split fallback (mirrors renderer order — Codex review caught the inverse on the planning round), cascadeTitleRename + resolveBrokenTitleLinks. - internal/store/items.go: rename cascade fires in-tx on title change; create path flips pre-existing broken title rows. - internal/store/migrations/062 + pgmigrations/041: one-shot `DELETE FROM item_wiki_links` so the startup backfill repopulates with the Phase 2a vocabulary. - Tests cover title round-trip, case-insensitive resolution, broken- link persistence + later resolution, full rename cascade with content rewrite, collection-qualified form, full-key-beats-split precedence (regresses Codex finding #3), broken-row flip on rename, no-op rename, and self-reference filtering. PLAN-1593 / TASK-1595. * fix(backlinks): rename cascade preserves display aliases (Codex round 1) Codex round 1 against PR #621 caught: the title-rename cascade selects sources via target_item_id (correctly hitting all rows that resolve to the renamed item — including aliased and mixed-case shapes), but the literal `strings.ReplaceAll` rewrite step only matched `[[Old Title]]` / `[[<slug>/Old Title]]`. Rows from `[[Old Title|alias]]`, `[[old title]]` (mixed case), or `[[<slug>/Old Title|alias]]` slipped past the rewrite; the trailing replaceWikiLinks re-parse then saw the same body, failed to resolve under the new title, and converted the row to broken — exactly the regression the cascade exists to prevent. Fix: new internal/links/RewriteWikiTitle helper handles all four title-form shapes with case-insensitive title matching and verbatim display-alias preservation. Cascade swaps from ReplaceAll to this helper. Unit tests in links_test.go cover the matrix; integration test in wiki_links_test.go regresses the original failure mode by mixing all four shapes in one source and asserting all four stay resolved after rename. Known limitation documented in the helper: titles containing wiki- link escape characters (`]`, `|`, `\`) — stored escaped in source content — don't match the regex's literal old-title segment. Same limitation exists in the legacy ReplaceTitle helper; promotable if a real user hits it. PLAN-1593 / TASK-1595. * fix(backlinks): retarget qualified-fallback rows on literal-title arrival (Codex round 2) Codex round 2 against PR #621 (P2): resolveBrokenTitleLinks only flipped target_item_id IS NULL rows, missing the arrival-order case where a literal `[[tasks/Setup]]` should win stage 1 over a row previously resolved via stage 2 (qualified fallback to item "Setup" in collection "tasks"). The index would stay stale until the source's content was rewritten — a latent inconsistency between the persisted backlink and what the renderer would actually show. Fix: drop the IS NULL constraint on the stage-1 UPDATE. Stage 1 ALWAYS wins per the renderer's order at markdown.ts:541, so any row with a matching literal title flips to the new item — including rows currently pointing at a qualified-fallback target. Added a target_item_id != ? guard so we don't churn rows that already point at us. Stage 2 keeps the NULL constraint so already-resolved qualified rows don't churn when another fallback candidate appears. Regression test in wiki_links_test.go reproduces the exact scenario Codex described: fallback resolves first, literal arrival steals the row from the fallback target. PLAN-1593 / TASK-1595. * fix(backlinks): renderer parity for [[A|B]] + scope arrival retarget (Codex round 3) Two findings from Codex round 3 against PR #621: P1 — parser/renderer parity for [[A|B]] matching literal title "A|B". The renderer (web/src/lib/utils/markdown.ts:516-525) tries the FULL body as a title FIRST when a pipe is present, only falling through to the split interpretation on miss. Our parseBody always split on the first unescaped pipe, so an item literally titled "A|B" was indexed as title="A" with display="B" — the index would miss backlinks the UI shows, or point them at a different "A" item. Fix: in replaceWikiLinks for title kind, when HasDisplay, try the full body (Title+"|"+Display) as a title FIRST via resolveTitleTx; on hit, store target_title=fullBody and drop the display override (the display segment was actually part of the title). On miss, fall back to the existing split-key interpretation. The cascade's RewriteWikiTitle regex correctly handles both storage shapes via QuoteMeta on the title. P2 — stage-1 UPDATE was too aggressive after the round-2 fix. The broad UPDATE (no IS NULL constraint) silently stole backlinks from legitimately-resolved rows when a SECOND item was created/renamed to the same title. Titles aren't unique, the renderer's Array.find() is order-dependent, and silent churn is worse than no-op stability. Fix: split resolveBrokenTitleLinks into three updates: (1) Plain literal flip — NULL only. (2) Qualified literal flip — NULL only. (3) Literal-arrival retarget — gated on title containing `/`. Only fires for `[[<slug>/Title]]` rows, which are the only ones that COULD have been stage-2 qualified-fallback resolved. Rows with target_title='Foo' (no slash) can only have been stage-1 literal — we don't steal those. Regression tests: - TestWikiLinks_LiteralPipeInTitleResolves — `[[A|B]]` resolves to item literally titled "A|B". - TestWikiLinks_LiteralPipeInTitleFallsThroughToSplit — `[[A|B]]` falls back to item "A" when no "A|B" item exists. - TestWikiLinks_SecondItemSameTitleDoesNotStealBacklinks — adding a second "Foo" item doesn't redirect the existing backlink. PLAN-1593 / TASK-1595. * fix(backlinks): broken pipe-in-body rows keyed on full body (Codex round 4) Codex round 4 against PR #621: when a source body `[[A|B]]` is written before any matching item exists, the broken row was stored with target_title="A" (the split key). If an item literally titled "A|B" was later created, resolveBrokenTitleLinks looking for target_title="A|B" couldn't find the row — index went stale while the renderer's preferred full-body interpretation would correctly resolve the link. Fix: when nothing resolves AND a pipe was present (HasDisplay), key the broken row on the FULL body (Title+"|"+Display) instead of the split key. resolveBrokenTitleLinks then naturally finds it via the literal-arrival path. The remaining asymmetry — a broken row keyed on full body won't pick up a future split-fallback resolution to a new item titled "A" — is documented in the code as a v3-promotable limitation. The full-body path is the renderer's PREFERRED interpretation (markdown.ts:516), so prioritizing it is the right tradeoff in the rare case both interpretations could apply. Regression test in wiki_links_test.go reproduces the scenario: source written first with `[[A|B]]`, item titled "A|B" created later, backlink should resolve. PLAN-1593 / TASK-1595. * fix(backlinks): scope stage-3 retarget + cascade self-refs (Codex round 5) Two findings from Codex round 5 against PR #621: Finding 1 — stage-3 literal-arrival retarget could still steal backlinks from a legitimate stage-1 literal-match row when a SECOND item with the same slash-containing title is created. The previous fix (round 3) gated stage-3 on title containing `/`, which let through the qualified-fallback retarget case correctly but didn't distinguish stage-1-resolved rows pointing at a literal twin from stage-2-resolved rows pointing at the fallback target. Fix: add an EXISTS check that scopes the flip to rows whose CURRENT target has a title NOT matching ours. Stage-1 (literal) resolutions point at items literally titled the same as the row's target_title; stage-2 (qualified-fallback) resolutions point at items titled just the trailing segment. The EXISTS clause picks out only the latter. Finding 2 — self-references on the renamed item went stale on title-only renames. The cascade's `s.id != renamedItemID` filter excluded self, but items.go only re-indexes content when input.Content != nil. So a title-only rename of an item whose body mentions itself by its old title kept the body's now-broken `[[Old Title]]` literal in place while the index still recorded a "working" backlink — drift between renderer state and index state. Fix: drop the self-exclusion from the cascade SELECT. RewriteWikiTitle rewrites the renamed item's own content along with everyone else's; GetBacklinks still hides self-links at query time, so the backlinks panel behavior is unchanged. Tests: - TestWikiLinks_DuplicateSlashTitleNoTheft regresses Finding 1. - TestWikiLinks_TitleRenameRewritesSelfReferences asserts Finding 2's new correct behavior (replaces the prior test that asserted the old buggy behavior). PLAN-1593 / TASK-1595. * fix(backlinks): ref→title fallback + reorder cascade self-ref (Codex round 6) Two findings from Codex round 6 against PR #621: Finding 1 — ref-shaped title fallback missing. parseBody returns WikiLinkKindRef for `[[ISO-9001]]` (matches refPattern), but if no ISO-9001 ref-item exists, the renderer falls through to legacy title lookup (markdown.ts:513) and resolves to an item literally titled "ISO-9001". The store inserted only a broken ref-kind row with target_title=NULL, so GetBacklinks never surfaced the backlink even when the renderer rendered it. Fix: in replaceWikiLinks' WikiLinkKindRef branch, when resolveRefTx misses, try resolveTitleTx on the same body. If title resolves, INSERT as title-kind row with target_title=ref-shaped-body. The rename cascade catches these correctly via target_kind='title' + target_item_id. The asymmetry — a future ref-item creation can't auto-retarget these title-stored rows — is documented as a v3 limitation. Finding 2 — combined title+content update broke self-ref cascade. The original order (main UPDATE → replaceWikiLinks(self) → cascade) wiped self's `target_item_id=renamedItemID` row before cascade ran: when input.Content contains `[[Old Title]]`, re-indexing self resolved it as broken (target_item_id=NULL), so cascade's SELECT missed self for the title+content path. Fix: reorder so cascade runs BEFORE the self re-index — the pre-existing wl rows are still intact at cascade time. The final re-index re-reads items.content from the DB (since cascade may have rewritten it in-band) rather than using *input.Content directly; otherwise the re-index would undo the cascade's self-ref rewrite. Tests: - TestWikiLinks_RefShapedFallsThroughToTitle — `[[ISO-9001]]` resolves to an item titled "ISO-9001". - TestWikiLinks_TitleAndContentRenameCascadesSelfRef — combined title+content rename with self-ref in new content gets the self-ref rewritten by cascade. PLAN-1593 / TASK-1595. * fix(backlinks): ref+pipe→title parity, position-based cascade, scoped self-rewrite (Codex round 7) Three intertwined fixes addressing Codex round 7 findings against PR #621: Finding 1 — ref→title fallback missed pipe-bodies. For `[[ISO-9001|Spec]]`, renderer tries full-body title "ISO-9001|Spec" BEFORE falling to bare "ISO-9001" (markdown.ts:516). Our ref-fallback only tried bare. Extended ref-branch's fallback to try full body first when HasDisplay, then bare — same order as the title-branch's stage (a)/(b) pattern. Finding 2 — cascade corrupted UNRELATED literal-pipe titles. Items A "Old Title" and B "Old Title|alias" both referenced from one source; renaming A previously triggered RewriteWikiTitle's regex `(?i:Old Title)((?:\|...)?)` which matched BOTH A's `[[Old Title]]` AND B's `[[Old Title|alias]]` — corrupting the B link. Refactored cascadeTitleRename to be POSITION-BASED: SELECT each individual wl row with its position + target_title (no longer DISTINCT sources). Per-row, rewrite the bracket AT THAT EXACT POSITION via new links.RewriteBracketAt helper. Process rows in descending position order per source so earlier offsets don't shift. Brackets whose wl row doesn't resolve to the renamed item are never visited. Scoped self-rewrite — title-only renames cascade self (input.Content == nil); combined title+content renames EXCLUDE self (input.Content != nil). User-supplied content is authoritative; auto-rewriting their just-submitted brackets would surprise them. Mirrors documents.go::updateLinksInTx, which also leaves the renamed entity's own content alone. Codex round 6 finding 2 is fully addressed: title-only path still rewrites self-refs, combined path respects user submission and the index correctly records the bracket as broken (matching what the renderer would render). New links.RewriteBracketAt helper with full unit-test coverage: plain/aliased/qualified/qualified+aliased shapes, case-insensitive matching, slug-prefix preservation, full-body vs split-key target disambiguation, out-of-bounds defensive guards. Integration test TestWikiLinks_CascadeDoesNotCorruptLiteralPipeNeighbor reproduces Codex round 7 finding 2's scenario. PLAN-1593 / TASK-1595. * fix(backlinks): retarget rows pointing at soft-deleted targets (Codex round 8) Codex round 8 P2: resolveBrokenTitleLinks only considered target_item_id IS NULL rows as eligible for flip. A row that resolved to item A and then had A soft-deleted stayed pointing at deleted A; creating a new item B titled the same as A wouldn't flip the row, so GetBacklinks(B) missed the backlink the renderer would actually show (renderer hides deleted-target links). Fix: introduce a "broken-in-practice" predicate (target_item_id IS NULL OR NOT EXISTS ( SELECT 1 FROM items t WHERE t.id = item_wiki_links.target_item_id AND t.deleted_at IS NULL )) applied to stages 1 (plain literal flip) and 2 (qualified literal flip). Stage 3 (slash-title literal-arrival retarget) already considered "current target deleted" implicitly via its title- mismatch EXISTS check. Regression test in wiki_links_test.go covers the exact scenario: A "Foo" resolves a backlink → A soft-deleted → B "Foo" created → backlink flips to B. Known limitation deferred to v3: the symmetric case (delete A while B with same title already exists) doesn't fire any hook that re-resolves the row. A dedicated DeleteItem hook would close that gap; not blocking Phase 2a. PLAN-1593 / TASK-1595. * fix(backlinks): preserve title whitespace to match renderer (Codex round 9) Codex round 9 P2: parseBody trimmed whitespace from the body BEFORE deciding it was a title kind. The renderer doesn't trim before title matching (markdown.ts:541-543) — `[[ Foo ]]` is matched against items.title with the surrounding spaces intact, so an item titled "Foo" wouldn't match. Trimming server-side created index entries the UI couldn't click — backlinks showed in the panel for links that the renderer rendered as broken. Fix: keep an UNTRIMMED unescaped body for title-kind fallthrough, and a trimmed copy only for ref / workspace_ref SHAPE detection (refs are whitespace-free by construction, the renderer's key.trim() at L503 is just typing forgiveness for the ref form). Tests in extract_test.go: - `[[ Foo ]]` emits title-kind with Title=" Foo " (whitespace preserved). - `[[ TASK-5 ]]` still parses as ref (shape detection trims). - `[[Project Goals]]` (two spaces inside) preserves internal whitespace. PLAN-1593 / TASK-1595. * fix(backlinks): ref→title fallback uses raw untrimmed key (Codex round 10) Codex round 10 P2: after round 9's title-kind whitespace fix, the ref→title FALLBACK path still used canonical-trimmed link.Ref for its title lookup. The renderer's fallback at markdown.ts:541-543 uses the UNTRIMMED key (no .trim() on the title-lookup path), so `[[ TASK-5 ]]` falling through to title would search " TASK-5 " (with whitespace) — an item literally titled " TASK-5 " resolves in the UI but not in our index. Fix: add a RawKey field to WikiLinkRef capturing the untrimmed unescaped key for ref kinds. parseBody populates it alongside the canonical Ref. replaceWikiLinks' ref→title fallback path uses RawKey (with defensive fallback to Ref for old rows) when constructing title candidates. Mirrors the renderer's untrimmed title lookup across both bare and pipe forms. Regression test in wiki_links_test.go covers the exact scenario: item titled " TASK-5 " (with whitespace), source body `[[ TASK-5 ]]`, backlink resolves via the untrimmed ref→title fallback. PLAN-1593 / TASK-1595. |
||
|
|
8e7d4040fd |
feat(backlinks): server-side reverse index for [[...]] (Phase 1) (#620)
* feat(backlinks): server-side reverse index for [[...]] wiki-links (Phase 1)
First phase of PLAN-1593. Today [[REF]] is parsed only at render time
on the client and there's no way to ask "who links to TASK-5?" without
a full-text scan. This change adds a materialized reverse index
(item_wiki_links) that's written every time an item's content changes
and exposes it via REST + CLI.
Phase 1 covers ref-form links only (`[[TASK-5]]` / `[[TASK-5|Display]]`).
Phase 2 (TASK-1595) will extend to titles + cross-workspace; Phase 3
(TASK-1596) adds the web UI panel + MCP action.
What lands here:
* Migrations 061 (SQLite) and 040 (Postgres) create item_wiki_links
with partial indexes on target_item_id, (target_workspace_id, target_ref),
and target_title — the schema accommodates all 5 wiki-link forms
up-front so Phase 2 doesn't ALTER.
* internal/links/extract.go is the canonical parser. It strips fenced
and inline code regions before extracting [[...]] occurrences, so
example refs in docs / code blocks don't pollute the index. Phase 1
emits only WikiLinkKindRef rows; title and workspace_ref kinds parse
successfully but are gated out until Phase 2.
* internal/store/wiki_links.go (replaceWikiLinks + GetBacklinks +
helpers) handles write-time bookkeeping and the read query. Resolution
to target_item_id happens at parse time inside the same transaction
as the items INSERT/UPDATE, so partial state never lands. Broken refs
(target_item_id IS NULL) intentionally persist — they feed a future
broken-links report.
* internal/store/wiki_links_backfill.go + cmd/pad/main.go hook the
idempotent backfill into server startup. Existing items get indexed
on first boot after the migration; subsequent boots are near-no-ops
via an EXISTS short-circuit.
* internal/store/items.go is amended in two places: tryCreateItem
always calls replaceWikiLinks (empty content → no-op DELETE), and
UpdateItemWithPreCheck re-parses whenever input.Content was supplied.
* internal/server/handlers_backlinks.go serves
`GET /api/v1/workspaces/{ws}/items/{itemSlug}/backlinks` with
visibility + guest-grant filtering on the source items.
* internal/cli/client.go adds GetBacklinks; cmd/pad/main.go adds the
`pad item backlinks <ref>` command (registered in groups.go).
Behavior decisions (per PLAN-1593):
- code blocks excluded (fenced + inline)
- self-links filtered at query time (kept in storage)
- repeated mentions stored as separate rows by position
- ordering: source updated_at DESC, position ASC
Tests:
- internal/links/extract_test.go: 26 sub-cases covering ref/title/
workspace-ref discrimination, code-block exclusion (fenced + inline +
unclosed fence), position-is-byte-offset (UTF-8 safety), and edge
inputs.
- internal/store/wiki_links_test.go: 8 integration tests covering the
create/update/delete/self-link/broken-ref/repeated/code-block
scenarios plus backfill idempotence.
All pass. `make check` clean (lint + go test + web build).
Refs: TASK-1594, PLAN-1593, IDEA-1577
* fix(backlinks): visibility-aware pagination + case-insensitive refs per Codex review (round 1)
Two fixes from Codex code review:
P1 — GetBacklinks now takes a visibleCollectionIDs []string argument
that's applied INSIDE the SQL WHERE clause. Previously the handler
fetched LIMIT raw rows and filtered visible ones in Go, so a
restricted user asking for limit=50 could receive an empty page even
when later visible backlinks existed. Pushing visibility into SQL
makes LIMIT/OFFSET count visible rows.
nil → no restriction (owners, editors, root tokens)
[] → see nothing (returns early, no SQL)
[..] → AND s.collection_id IN (?, ?, ...)
Item-level guest grants still apply post-fetch — they're rare enough
that the residual page shrink is acceptable and pushing them into SQL
would balloon the query.
P2 — refPattern now accepts mixed/lowercase refs and parseBody
canonicalizes the prefix to uppercase at the single chokepoint.
Previously the renderer accepted `[[task-5]]` as a real link (its
REF_PATTERN is case-insensitive) but the indexer's ^[A-Z]... pattern
silently dropped it — divergent parsing on the same input. Storage
shape is canonical uppercase so the (workspace, prefix, number)
lookup against collections.prefix (also uppercase) has one shape.
New helper: canonicalizeRef("task-5") → "TASK-5".
Regressions:
internal/links/extract_test.go
+ TestCanonicalizeRef — helper unit tests
+ TestExtractWikiLinks_RefVsTitleFallback updated to assert
mixed/lowercase parses-as-ref-and-uppercases
+ edge-case test renamed from "lowercase ref" to "number-led
not a ref" (lowercase IS a ref now per Codex P2)
internal/store/wiki_links_test.go
+ TestWikiLinks_MixedCaseRefIndexed — `[[task-5]]` produces a
backlink row whose target_ref is "TASK-5"
+ TestWikiLinks_VisibilityAwarePagination — three sub-cases:
nil → all 3, visible-only limit=2 → 2 visible rows (not 1 with
hidden one consuming a slot), empty → 0
All call sites updated (8 in tests + 1 in handler).
`make check` clean (lint + tests + web build).
Refs: TASK-1594, PLAN-1593
* fix(backlinks): SQL-level item-grant filter per Codex review (round 2)
Round 1 fixed pagination for collection-level visibility but Codex
round 2 correctly flagged the same class of bug at the item-grant
layer: `visibleCollectionIDs` returns the UNION (full grants ∪
collections containing granted items), and the handler then
filtered each row's item-level visibility in Go AFTER fetching —
letting hidden rows in a granted-item's collection consume LIMIT
slots.
The refactor moves the precise predicate into SQL. New shape:
type BacklinksVisibility struct {
Unrestricted bool // admin / full-access member
FullCollectionIDs []string // direct collection grants
GrantedItemIDs []string // item-level grants
}
// SQL predicate when Unrestricted=false:
// AND (s.collection_id IN (?...) OR s.id IN (?...))
This matches `guestResourceFilter` (which returns the precise
primitives), so the handler now passes them straight through and
drops the post-fetch filter loop entirely. Pagination is correct
for guests, restricted members, and unrestricted users alike.
New test:
TestWikiLinks_ItemGrantPagination — guest with item-grant on ONE
item in an otherwise-hidden collection sees exactly that one item;
hidden siblings in the same collection do NOT leak in, and limit=2
returns 1 row (not silently shrunken).
Other call sites updated:
- TestWikiLinks_VisibilityAwarePagination → uses
BacklinksVisibility{FullCollectionIDs: ...} and
BacklinksVisibility{} for the no-access case.
- 8 existing tests → BacklinksVisibility{Unrestricted: true}.
- handlers_backlinks.go → no longer calls visibleCollectionIDs;
uses guestResourceFilter exclusively and skips the Go-side filter.
Verification:
- make check clean
- All TestWikiLinks_* pass
Refs: TASK-1594, PLAN-1593
* fix(backlinks): scan EXISTS into bool not int for Postgres parity (Codex round 3)
`SELECT EXISTS(...)` returns boolean on Postgres but integer 0/1 on
SQLite. Scanning into `int` happened to work on SQLite (the modernc.org
driver coerces) but would fail on Postgres — silently disabling the
backfill short-circuit there and meaning upgraded Postgres installs
wouldn't populate backlinks for pre-existing content until each item
got edited.
Fix: scan into bool. Both database/sql drivers in use (modernc.org/
sqlite and lib/pq) coerce their native representation into Go's bool,
so this single shape works on both engines.
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): allow CommonMark 0-3 space indented fences per Codex (round 5)
Round 5 flagged two edge cases in the code-stripping pass:
1. Multi-backtick inline code (``see [[X]]``) — traced through the
parser; my permissive close-on-next-backtick logic already covers
it correctly (range = [opener-start, after-closer-run]). Added
a regression test to lock this in:
TestExtractWikiLinks_CodeBlocksExcluded /
"multi-backtick inline code excludes ref"
2. Indented fenced blocks — CommonMark allows 0-3 leading spaces of
indentation before a fence opener (4+ spaces makes it an indented
code block, a different construct). My fencedCodeRanges only
matched fences at column 0, so ` ```\n[[X]]\n```` ` would
render as code in the UI but leak a false backlink. Fixed both
fencedCodeRanges (opener) and findFenceCloser (closer) to skip
up to 3 leading spaces, with a hard cap at 4 (which would be
indented-code, not a fence). Regression test:
TestExtractWikiLinks_CodeBlocksExcluded /
"indented fenced block (CommonMark 0-3 spaces)"
Not addressed:
- Round-4 escape-body parity finding. extract.go mirrors
renderMarkdown's regex (web/src/lib/utils/markdown.ts:300), which
is the actual render-time link parser; wikiLinksToMarkdown's more
permissive escape grammar is editor-serializer-side and the
renderer can't even consume its escaped output. Indexing what the
user actually sees as a link is the correct invariant.
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): tilde fences + strict closer lines per Codex (round 6)
Two CommonMark conformance gaps in the code-block stripping pass:
1. Tilde-fenced code blocks (~~~) were ignored. marked() treats them
the same as backtick fences, so a [[REF]] inside a tilde block
would render as code in the UI but leak as a false backlink.
Fixed by parameterizing fenceChar across fencedCodeRanges and
findFenceCloser, with separate handling for the backtick-specific
"no backtick in info string" rule (CommonMark §4.5).
2. Closer-line strictness — CommonMark requires the closing fence
line to contain only the fence + optional trailing spaces. The
previous accept-any-fence-prefixed-line check would terminate
a still-open fence prematurely on a line like ```not-closed,
leaking later refs in the still-rendered code block.
Refs reside in 4 new sub-tests under TestExtractWikiLinks_CodeBlocksExcluded:
- tilde fence excludes refs inside
- tilde fence with language tag
- mixed fence types don't pair
- closer-line strictness — backticks plus other text is not a closer
- closer-line strictness — trailing spaces OK
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): inline code closer must match opener length per Codex (round 7)
CommonMark §6.1 requires an inline-code span opened with N backticks
to close on a run of EXACTLY N backticks. The previous "close on next
backtick run of any length" logic would prematurely end the excluded
range on a stray single backtick inside a ``...`` span, leaking any
[[REF]] in the latter half of the code text as a false backlink.
Concrete failure case:
``has ` inside [[X-1]] and more``
→ old: range [0, 7], [[X-1]] indexed (bug)
→ new: range [0, end-of-closer], [[X-1]] excluded (correct)
Fix: track the opener-run length and scan only for matching-length
closer runs. Wrong-length runs in between are code text.
Two new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
- inline code closer matches opener length — the main case
- single-backtick span unaffected by adjacent multi-backtick run —
asserts the opposite direction (opener=1 doesn't close on ``)
Not addressed:
- Re-flagged round-4/round-7 escape-body parity finding. extract.go
intentionally mirrors renderMarkdown's regex (markdown.ts:300), not
wikiLinksToMarkdown's more permissive escape grammar (markdown.ts:461).
renderMarkdown is the actual link parser at display time; its regex
rejects escaped-`]` bodies, so any link with an escaped `]` in its
body is NOT shown as a clickable link in the UI. Indexing it would
produce phantom backlinks the user can't see. The wikiLinksToMarkdown
permissive grammar is paranoid serialization that the renderer can't
consume — that's a pre-existing inconsistency in the editor pipeline,
not a backlinks bug.
make check clean (lint + tests + web build).
Refs: TASK-1594, PLAN-1593
* fix(backlinks): rune-align snippet end-edge to keep UTF-8 valid (Codex round 8)
The previous snippetAround() trimmed `start` to a rune boundary (so
the leading edge of the snippet was always at a valid codepoint) but
left `end` as a raw +40-byte clamp. When that landed in the middle of
a multi-byte rune — common around emoji or accented text — the
resulting slice was invalid UTF-8 and the JSON encoder would emit
replacement characters in backlink snippets.
Fix: same forward-advance pattern at the end as at the start.
Continuation bytes (10xxxxxx) get skipped until we land on a leading
byte. Going forward keeps the snippet anchored slightly past the
match rather than slightly before it, which is a small UX win
(emoji or accented text right after the link survives intact).
Regression test:
TestWikiLinks_SnippetIsValidUTF8 — pads body with enough 4-byte
emoji on each side that the ±40-byte window cuts through one;
asserts utf8.ValidString on the resulting snippet.
make check clean (lint + tests + web build).
Refs: TASK-1594, PLAN-1593
* fix(backlinks): inline code spans cross newlines, break on blank lines (Codex round 9)
CommonMark §6.1: an inline-code span can cross single newlines but
terminates at a blank line (a line containing no chars or only
whitespace, which ends the enclosing paragraph). My previous scanner
broke at every newline, so multi-line spans like
`pre
[[INSIDE-1]]
post`
would treat the opener as unclosed and leak [[INSIDE-1]] as a false
backlink. Fixed by:
1. The newline branch in the closer scan now peeks ahead via the
new isBlankLineAt() helper. Same-paragraph newlines are
traversed; blank-line breaks terminate the span unmatched.
2. isBlankLineAt() treats any line with only space/tab as blank
(mirroring CommonMark's blank-line definition).
Three new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
- inline code spans single newline (CommonMark §6.1)
- inline code breaks at blank line (paragraph boundary)
- inline code breaks at whitespace-only blank line
Trade-off: a truly-unclosed inline backtick now consumes from the
opener up to the next blank line instead of just the rest of the
line. False-positive on wiki-links in that span, but the surface
area is small (unclosed backticks are rare in published prose) and
matches the renderer's behavior.
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): accept escaped wiki-link bodies per editor grammar (Codex round 10)
After 3 rounds of disagreement, capitulating on the escape-body parity
finding. My position was technically correct for the CURRENT
renderMarkdown behavior (which uses [^\]]+ and can't parse escaped-
bracket bodies), but the editor's wikiLinksToMarkdown grammar at
markdown.ts:461 explicitly produces such bodies — making the
renderer's regex the inconsistent half of the pipeline, not mine.
Mirroring the editor's grammar in the extractor makes the index
forward-compatible: when the renderer eventually gets fixed, no
change here is needed. The cost is a few "phantom" rows in the
interim (indexed links the renderer doesn't currently display as
clickable), but those are harmless and aligned with author intent.
Changes:
- wikiLinkPattern now uses `\[\[((?:\\.|[^\]\\])+)\]\]` — mirrors
markdown.ts:461 verbatim.
- New splitOnUnescapedPipe() helper — scans for the first `|`
that isn't preceded by `\`. Mirrors splitWikiBody at
markdown.ts:664.
- New unescapeWikiBody() helper — undoes `\]`, `\|`, `\\` escapes
in display text and key. Mirrors unescapeWikiBody at markdown.ts:657.
- parseBody() now uses both helpers — split on unescaped `|`,
unescape both sides.
Regression coverage:
- TestExtractWikiLinks_EscapedBodyChars (5 sub-cases): escaped `]`,
escaped `|`, escaped `\`, non-escape backslash passes through,
Position still points at opening `[[` despite escapes.
- TestSplitOnUnescapedPipe + TestUnescapeWikiBody: direct unit
tests for the helpers (round-trip safety vs the editor's
escape/unescape pair).
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): preserve display text verbatim per Codex round 11 P3
The previous parseBody trimmed the display side of [[X|Display]] but
the WikiLinkRef.Display contract promises verbatim storage and the
renderer at markdown.ts doesn't trim either. Trimming would silently
diverge on padded display text like [[TASK-1| spaces ]] (renderer
keeps the spaces, extractor stripped them).
Fix: drop TrimSpace from the suffix half of the split. Keep trimming
the key/ref side because refPattern is anchored — a leading or
trailing space in the key would force the body to fall through to
the title kind even though the renderer resolves it as a ref.
Regression test:
TestExtractWikiLinks_EscapedBodyChars / "display text preserved
verbatim (no TrimSpace)"
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): distinguish empty display override from no-pipe per Codex round 12
[[REF|]] (explicit empty display) and [[REF]] (no display) are distinct
shapes in the editor: splitWikiBody returns displayOverride="" for the
former, null for the latter, and the renderer uses `displayOverride ??
title` (nullish coalescing, NOT empty-string fallback) so "" is
preserved. The previous extractor collapsed both into display_text=NULL,
violating verbatim-display preservation for the empty-string edge case.
Fix:
- WikiLinkRef gains a HasDisplay bool. parseBody sets HasDisplay=true
iff splitOnUnescapedPipe found a pipe; downstream uses HasDisplay
(not Display!="") to decide whether to persist the override.
- replaceWikiLinks in store: NullString.Valid is keyed off HasDisplay.
display_text='' for explicit empty, NULL for no override.
Regression coverage:
- internal/links/extract_test.go:
"explicit empty display override is distinguished from no pipe"
- internal/store/wiki_links_test.go:
TestWikiLinks_EmptyDisplayDistinct (two-source assert: NOT NULL
for [[REF|]], NULL for [[REF]])
make check clean.
Refs: TASK-1594, PLAN-1593
* fix(backlinks): pointer-typed DisplayText to preserve empty distinction over JSON (Codex round 13)
Round 12 added HasDisplay on the parser side and made the store
preserve display_text='' vs NULL on the DB row, but the wire model
collapsed the distinction at JSON-serialization time:
DisplayText string `json:"display_text,omitempty"`
`omitempty` drops empty strings, so [[REF|]] (empty override) and
[[REF]] (no override) serialized identically on the API and CLI JSON
output. The end-to-end goal of round 12 wasn't reached.
Fix: change DisplayText to *string. nil → no override (field omitted
from JSON via omitempty), pointer to "" → explicit empty override
(field present with empty value). The store's NullString.Valid drives
the assignment, so the SQL round-trip matches the JSON shape.
Knock-on: the CLI's `pad item backlinks` now dereferences the pointer
and prints both populated and empty overrides ("displayed as: ").
Regression coverage:
- TestWikiLinks_EmptyDisplayDistinct extended to assert
withBL.DisplayText is non-nil-pointing-at-"" and noBL.DisplayText
is nil after a GetBacklinks round-trip.
make check clean.
Refs: TASK-1594, PLAN-1593
|
||
|
|
a6ca1f3910 |
docs: add cross-link nav row to README (site, blog, changelog, X, Bluesky) (#617)
A single centered nav line right under the badges so readers can reach the marketing site, docs, blog, changelog, and social accounts from the top of the repo without scrolling. Covers TASK-1569 (blog/X/Bluesky added) and TASK-1574 (getpad.dev/docs/ changelog quick-nav). Bundled into one PR since both edit the same file and TASK-1574 explicitly called out the overlap. Refs: PLAN-1571, TASK-1569, TASK-1574 |
||
|
|
8cf460b381 |
fix(ci): unbreak Go (PostgreSQL) test + clear x/net govulncheck findings (#618)
* fix(test): encode bools as bools in TestGetUserWorkspacesDetailed (BUG-1582) The raw INSERT in this test passed integer literals `0, 0, 1` for `sort_order, is_default, is_system`. SQLite coerces int → bool but pgx refuses, so the Go (PostgreSQL) CI job has failed every run since #600 landed: workspace_members_admin_detail_test.go:42: seed system collection: failed to encode args[10]: unable to encode 0 into binary format for bool (OID 16): cannot find encode plan Pass `false, true` for the two bool columns so both drivers accept the args. * chore(deps): bump golang.org/x/net to v0.55.0 (TASK-1583) Clears 5 govulncheck findings (GO-2026-5025..5030) reachable via internal/urlimport/generic.go's call to html.Parse. The Go CI job has been failing on every main run since these advisories were published. Vulnerability #1: GO-2026-5030 — XSS via duplicate attributes Vulnerability #2: GO-2026-5029 — character refs in DOCTYPE Vulnerability #3: GO-2026-5028 — DoS parsing arbitrary HTML Vulnerability #4: GO-2026-5027 — HTML elements in foreign content Vulnerability #5: GO-2026-5025 — namespaced elements in foreign content `go mod tidy` pulls along the usual x/* sibling bumps. Local govulncheck after the bump: *No vulnerabilities found.* Full `go test ./...` passes. * fix(store): is_system check uses NOT bool, not = 0 (BUG-1582) GetUserWorkspacesDetailed's collections_count subquery had `c.is_system = 0`. SQLite stores BOOLEAN as INTEGER so the comparison worked there, but Postgres' boolean column rejects the integer literal: ERROR: operator does not exist: boolean = integer STATEMENT: SELECT ... AND c.is_system = 0) The first push at this BUG only fixed the test-side encoder issue; this commit fixes the production query that the test exercises. `NOT c.is_system` evaluates correctly on both drivers without needing to thread another placeholder through the args. Verified locally against a real Postgres 17 instance: TestGetUser- WorkspacesDetailed and the full ./internal/store/... suite pass. |
||
|
|
66dce32c7e |
chore(mcp): point library-activate not-found hint at pad_library (TASK-1564) (#616)
The dispatcher's `library activate` not-found error referenced a
`pad_project action=library-list` action that never existed — leftover
from an earlier design draft for IDEA-1514 (the now-shipped
`pad_library` tool, PLAN-1560 / TASK-1563). The correct hint is
`pad_library action=list`.
One-line hint fix; no behavior change. Closes the remaining scope of
TASK-1564 — the onboard playbook body update shipped in PR #615
(commit
|
||
|
|
6433cc51ea |
feat(mcp): pad_library catalog tool + ToolSurfaceVersion 0.5 (TASK-1563) (#615)
* feat(mcp): pad_library catalog tool + ToolSurfaceVersion 0.5 (TASK-1563)
MCP catalog wiring for PLAN-1560 (`pad_library` MCP tool + matching CLI
surface). Closes IDEA-1514 — pure-MCP agents (notably the /pad onboard
playbook from PLAN-1496) can now browse and activate library entries
without shelling out.
## New tool
`pad_library` joins the v0.5 catalog as the ninth resource × action tool.
Three actions, all passThrough to the `pad library` CLI:
- `list` — Browse conventions + playbooks. Defaults to summary mode
for playbooks (compact bodies via the ?summary=true
endpoint flag); conventions always carry full content.
Optional type / category / full inputs.
- `get` — Full body of one entry by exact title. Conventions-first
precedence mirrors `activate`.
- `activate` — Create a workspace item from a library entry by title.
`Workspace: true` on the tool — list/get ignore it; activate validates
and uses it. The schema-level declaration gives activate automatic
pad_set_workspace session-default resolution (same precedent as
pad_meta's mixed-workspace actions).
## Dispatcher extensions
- `dispatchLibraryList` forwards `category` to BOTH endpoints and
passes `summary=true` to the playbook endpoint by default (unless
input.full=true). MCP-default summary mode keeps agent context
budgets tight; CLI default already aligned in TASK-1562.
- `library get` added to the routeTable as a clean GET to
/api/v1/library/entry with `title` mapped to the query string.
Cleaner than another explicit dispatcher case — matches
playbook list / playbook show shape.
## Version bump
ToolSurfaceVersion bumped from 0.4 → 0.5. Pure addition; no existing
tool/action/param/bootstrap shapes changed. Backwards-compatible for
any v0.4 consumer that doesn't enumerate the new tool. Documented in
version.go with the same comment-block structure as prior bumps.
## Test coverage
- catalog_readonly_test.go — pad_library added to the want{} map; three
library action → cmdPath entries in expected{}; library list / get /
activate added to liveCmdhelpDoc stubs.
- dispatch_http_project_test.go — 4-case table test (defaults, category,
full=true, category+full) pins category/summary query-param forwarding;
library get routing test confirms the routeTable entry resolves.
## Live MCP verification
- `initialize` handshake advertises padToolSurface.version=0.5.
- `pad_meta version` returns tool_surface_version=0.5.
- `pad_library list type=playbooks category=agent-workflows` returns
4 playbooks in summary mode (content stripped, summary populated,
invocation_slug + arguments present).
- `pad_library get title='Ship tasks'` returns
{type: playbook, playbook: {…, content (9512 chars), invocation_slug: ship}}.
Parent: PLAN-1560. Unblocks TASK-1564 (cleanups).
* fix(onboard): update playbook body to use pad_library MCP tool per Codex review (round 1)
Codex P2 on PR #615: the /pad onboard playbook body in
internal/collections/playbook_library_onboard.go still told MCP-only
agents that the library catalog was "not yet exposed as an MCP tool"
and to work from memory — directly contradicting the pad_library tool
this PR just landed and breaking the main advertised consumer of the
new surface.
Updated step B3 (conventions) to mention both surfaces side-by-side
(`pad library list --type conventions` / `pad_library` with
`action: list, type: conventions`), and rewrote step B5 (playbooks)
the same way so the activate path doesn't drift either.
Pre-PLAN-1560 IDEA-1514 reference removed from the body — the idea
is now closed.
No test pins the playbook body content; `make check` passes; the
playbook seed still validates against the playbooks collection schema
since trigger/scope/invocation_slug/arguments are unchanged.
Closes the onboard-side scope of TASK-1564 (stale dispatch_http_slice4
hint + CHANGELOG still pending there).
|
||
|
|
9a47c36ea6 |
chore(store): rephrase comment to unblock gofmt (BUG-1565) (#614)
Go 1.26's gofmt rewrites paired ASCII apostrophes (''), used here as a
literal SQL empty string, to a typographic right-curly double quote
(U+201D). The rewrite is applied even inside markdown backticks, so
escaping the SQL fragment in code-span syntax doesn't help.
Rephrase the comment to describe the COALESCE/LOWER pattern in words
instead of embedding the SQL literal, preserving the original meaning
while sidestepping the heuristic. The behavior of
adminOpenItemsCountClause is unchanged — comment-only edit.
Unblocks `make check` for local pre-commit and CI gates. Surfaced
during PLAN-1560's TASK-1561 ship loop.
|