mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
de4d28d576cdbe2c3af7d45844a407fd64bb4838
410 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
de4d28d576 |
feat(attachments): AttachmentStore interface + FSStore (TASK-870) (#287)
* feat(attachments): AttachmentStore interface + FSStore (TASK-870) Introduces the storage backend abstraction described in DOC-865 and ships its first concrete implementation. No call sites yet — TASK-871 (upload API) wires it in. internal/attachments/store.go AttachmentStore interface (Put/Get/Stat/Delete) and ErrNotFound sentinel. Put is documented as idempotent — concurrent Puts of the same hash converge — and required to verify that the streamed bytes actually hash to the supplied value. internal/attachments/registry.go Registry routes "<prefix>:<rest>" keys to the store registered for that prefix (Phase 1 = "fs"; Phase 2 will register "s3" alongside). Convenience Get/Stat/Delete helpers resolve + forward in one call so callers don't have to spell out the two-step pattern everywhere. Register panics if the prefix contains ':' since that would make the store unreachable. internal/attachments/fs_store.go FSStore writes to <baseDir>/<aa>/<bb>/<full-hash> with the first 4 hex chars sharding the directory tree two levels deep. Atomic writes: stream + hash to a randomized .tmp in the destination dir, fsync, then intra-directory rename. The streaming sha256 is verified against the supplied hash before the rename, so a mismatch never leaves a visible file. Idempotent fast path: if the canonical file already exists Put short-circuits (and drains the reader so callers don't get a stuck stream). Get returns wrapped ErrNotFound on missing keys; Delete on a missing key is a no-op (matches what the orphan GC needs). Tests cover put/get/stat/delete, hash mismatch, invalid hash format, idempotency, 16-goroutine concurrent Put of the same hash converging to one on-disk file with no orphan tmp files, registry routing, forward-error semantics, and the prefix-with-colon panic. Parent: PLAN-866. * fix(attachments): validate hash on every FSStore key + verify on fast path per Codex review (round 1) Round 1 raised two issues — both real, both fixed. 1. Path traversal in Get/Stat/Delete. extractHash only checked that the key began with "fs:" and the suffix was non-empty before passing it to pathFor(), which used the suffix as a path component. A key like "fs:../../etc/passwd" would escape baseDir for reads/stats/deletes. Fix: extractHash now requires the canonical 64-char lowercase-hex sha256 form via validHash. Same gate that Put already used; now it covers every public method. 2. Idempotent Put fast path skipped hash verification. If the canonical target file already existed, Put returned the key without checking that the supplied reader's bytes hashed to the supplied hash — violating the AttachmentStore.Put contract that implementations MUST verify on every call. A buggy upload path could associate the wrong bytes with an existing hash and silently succeed. Fix: stream r through a hasher when the target exists (no disk I/O), compare against the supplied hash, and reject on mismatch. Also dropped the dead "_short" branch in pathFor — every caller now goes through validHash. Tests added: - TestFSStore_GetStatDeleteRejectBadKeys covers empty/wrong-prefix/empty- hash/non-hex/wrong-length/path-traversal/path-separator/uppercase keys across all three read methods. - TestFSStore_PutFastPathStillVerifiesHash confirms the contract holds on the fast path: a second Put that lies about the hash is rejected with no corruption of the existing file. |
||
|
|
6461aafd16 |
feat(store): attachments table + Attachment model (TASK-869) (#286)
Adds the schema groundwork for inline images and file uploads — see DOC-865 (Attachments — architecture & migration design). - migrations/047_attachments.sql — SQLite migration. Table + 4 indexes (workspace, item, hash, parent). Partial indexes on workspace/item/parent match the items table convention. The hash index is full (not partial) so dedupe can resurrect a soft-deleted blob if the same bytes are re-uploaded without writing a duplicate. - pgmigrations/026_attachments.sql — Postgres mirror with BIGINT for size_bytes; same partial-index pattern. - internal/models/attachment.go — Go model with all columns. Uses pointer types for nullable columns (item_id, width, height, parent_id, variant, deleted_at) so JSON omitempty works correctly. No call sites yet — purely schema groundwork. Verified the migration runs cleanly on a fresh install and on the live dev DB. Parent: PLAN-866. |
||
|
|
2f58193f22 |
chore(web/connect-modal): point footer + install links at getpad.dev/docs (#285)
Last piece of PLAN-859. The ConnectWorkspaceModal's three footer/install links were placeholders pointing at GitHub README anchors while TASK-863's docs page didn't exist yet. That page is now live at getpad.dev/docs/connect-workspace (pad-web#30 / e472586), so swap the three URLs to the real docs: - "Other install options →" → https://getpad.dev/docs#installation (broader install matrix: Homebrew + Binary + Docker + Source) - "Documentation" → https://getpad.dev/docs/connect-workspace - "Troubleshooting" → https://getpad.dev/docs/connect-workspace#troubleshooting Updated the in-source comment to reflect that the URLs are now the canonical ones, not placeholders. This closes out PLAN-859 (web-first onboarding on-ramp): a user who creates a workspace in the web UI now has a complete in-app + docs path to connecting that workspace to their local project. |
||
|
|
e5eae5e94e |
feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862) (#284)
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)
Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.
Server:
- New store method WorkspaceHasCLISource(workspaceID) — backed by
EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
one cli item flips it on, soft-delete flips it back off, and
cross-workspace isolation.
Web:
- New <ConnectBanner> Svelte 5 component
(web/src/lib/components/ConnectBanner.svelte). Self-contained:
reads dismissed state from localStorage, fetches has_cli_source
itself, mounts <ConnectWorkspaceModal> internally. Two split
$effect blocks per CONVE-606 — one for the localStorage sync, one
for the dashboard fetch — so a workspace change doesn't entangle
the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
onboarding-dismissed pattern. Per-browser only; TODO comment in
source about backing it with a workspace_user_state row if cross-
device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
above {@render children()} so it appears on every workspace page
(dashboard, collection lists, item detail, search, activity, etc.)
and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
has_cli_source: boolean.
Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).
Test plan:
- go build ./... && go test ./... — all green (incl. new
TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.
Parent: PLAN-859. Driving idea: IDEA-750.
* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)
Two findings from Codex review on PR #284:
1. Stale-response race: rapid workspace switches could let a slow
dashboard fetch from workspace A overwrite hasCliSource for
workspace B after the user navigated. Capture the requested slug
at fetch time, ignore the response if wsSlug has changed since.
2. Auto-hide didn't work in-session: if a user opened the banner
modal, copied the command, ran it elsewhere, and closed the modal,
the banner stayed visible because hasCliSource was stale. Refetch
when the modal transitions from open → closed (the natural moment
the user has just connected). Uses $effect.pre with a tracked
previous value, matching the transition pattern in ShareDialog.
The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.
* fix(server/items): persist source from auth context on create (Codex round 2)
Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.
Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).
Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
session token in the header since the auth middleware validates
token format and rejects fake values with 401 before the handler
runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')
* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)
Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.
Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.
* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)
Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.
Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.
* fix(store): visibility-filter has_cli_source query (Codex round 5)
Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.
Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.
Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
|
||
|
|
a28767d323 |
feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) (#283)
* feat(web): ConnectWorkspaceModal + empty-workspace and avatar surfaces (TASK-861) Web side of the web-first onboarding on-ramp from PLAN-859 / IDEA-750. Gives a user who created a workspace via the web UI a one-line copy-paste to connect that workspace to their local repo, exposed in the two zero-state surfaces where they'd look for it. Changes: - New `<ConnectWorkspaceModal>` Svelte 5 component (web/src/lib/components/ConnectWorkspaceModal.svelte). Reusable, no host-page coupling. Matches ShareDialog's modal pattern (overlay + centered modal, native, open = $bindable(), Escape closes). Props: serverUrl, workspaceSlug, workspaceName?. Renders Step 1 (OS-tabbed install — macOS/Linux/Windows/Docker, default tab from detected platform) and Step 2 (pad init --url ... --workspace ... snippet with a copy button on the full snippet). Footer links to docs + troubleshooting. - New web/src/lib/utils/platform.ts — tiny dependency-free OS detection helper. SSR-safe (defaults to "macos" with no navigator). - Mounted in the workspace landing page as a "Connect your local project" card directly under <OnboardingChecklist> in the empty- workspace .onboarding-wrapper. Modal itself is mounted unconditionally at the page root so it survives re-renders of the conditional empty state. - Mounted in TopBar.svelte's user menu (both desktop and mobile branches): "Connect a project..." entry between Theme/Cloud-support links and the Sign-out divider. Modal lives outside the dropdown so it doesn't unmount when the dropdown closes. Both gated on workspaceStore.current?.slug since the modal needs a workspace to interpolate. Docs URLs in the modal footer (getpad.dev/docs/install, getpad.dev/docs/connect-local-project) are placeholders; TASK-863 in PLAN-859 will publish those pages and we'll wire the final URLs then. Test plan: - go build ./... && go test ./... clean - cd web && npm run build clean - make install clean, server restarted - Svelte MCP autofixer ran on all four touched files — no findings Parent: PLAN-859. Driving idea: IDEA-750. * fix(web/connect-modal): correct brew tap + point placeholder docs links to README (Codex round 1) Two findings from Codex review on PR #283: 1. macOS install command was `brew install xarmian/pad/pad`, but the actual tap is `PerpetualSoftware/tap/pad` (per README.md and skills/INSTALL.md). Users would have hit a failing install. 2. Footer links pointed at `getpad.dev/docs/install` and `getpad.dev/docs/connect-local-project` — pages TASK-863 will publish but don't exist yet. Until they do, point at the GitHub README's #installation and #getting-started anchors so clicks at least land somewhere useful instead of 404. The TASK-863 follow-up will swap these back to the dedicated docs URLs once the pages ship. * fix(web/connect-modal): use real install commands from README (Codex round 2) Round 2 caught that Linux/Windows/Docker commands were fabricated: - Linux/Windows pointed at a getpad.dev/install.sh that doesn't exist - Docker used wrong volume mount (/root/.pad vs the image's /data) and didn't publish ports All four tabs now mirror the README's Installation section exactly: - macOS + Linux: brew install PerpetualSoftware/tap/pad - Windows: pointer to the GitHub releases page (no first-party one-liner) - Docker: docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad |
||
|
|
a03c96f9b0 |
feat(cli): pad init --url X --workspace <slug> as web-first cold-start (TASK-860) (#282)
Make `pad init --url <server> --workspace <slug>` a reliable non-interactive cold-start so the web UI can hand users a single copy-paste command to connect a workspace they created on the web to their local project. Keystone CLI work for the web-first onboarding on-ramp under PLAN-859 (driven by IDEA-750). Changes: - `ensureWorkspace` gains a `wsSlug` parameter. When set, it ONLY attaches by slug — looks up the workspace via GetWorkspace, links the CWD if found, and surfaces a clear "not found on <server>" error otherwise. Critically, it never silently falls through to creating a new workspace named after the slug. - Refuses to clobber a CWD that's already linked to a different workspace; idempotent re-run when the existing link matches. - `pad init --url X` on a fresh machine (no config.toml on disk) now persists the config so subsequent commands don't need --url. - When both a positional name and --workspace are supplied, the slug wins and we print a Note: line so the override is visible. - Same wiring applied to `pad workspace init` for consistency. Tests: 5 new unit tests in cmd/pad/init_test.go cover slug-attach, not-found error, clobber refusal, idempotent re-run, and that the legacy name-driven path still works. Smoke-tested end-to-end against the local server: happy path links, missing slug errors cleanly with no `.pad.toml` written, clobber blocked, idempotent re-run silent. |
||
|
|
86a2f3c55b |
fix(web/editor): copy from table puts plain text only on clipboard (TASK-858) (#281)
* fix(web/editor): copy from table puts plain text only on clipboard (TASK-858) ProseMirror's default copy serialization for selections inside a table included the wrapping <table>...</table> in the text/html clipboard payload. Pasting into rich-text apps (or anywhere that prefers HTML over plain text) reproduced the table styling when the user just wanted the cell text. Add a tableCopyPlugin mirroring the existing codeBlockCopyPlugin pattern: when the selection lives entirely inside a table, write a plain-text representation to text/plain and clear text/html. Cut also deletes the range, same as the code-block plugin. Behavior: - Text selection inside a single cell: cell text on text/plain. - CellSelection (multi-cell drag): tab between cells, newline between rows. Pastes correctly into Excel/Sheets/Numbers. - Selection that spans into/out of the table: falls through to default. Trade-off (accepted): re-pasting a multi-cell copy into our own editor yields TSV text, not a reconstructed table. Matches Linear/Notion/Slack. Fixes BUG-855. * fix(web/editor): preserve parent Table plugins + selection-aware cut per Codex review (round 1) Two findings from Codex review of PR #281: 1. Table.extend's addProseMirrorPlugins was returning only [tableCopyPlugin], replacing the parent extension's plugins and silently dropping columnResizing (negating resizable: true) and tableEditing (cell selection / table editing). Now spreads ...(this.parent?.() ?? []) and appends tableCopyPlugin. 2. Cut path used tr.delete(from, to) which is unsafe for CellSelection — a contiguous document range can include unrelated cells (or row structure) between the rectangular cell-selection's endpoints. Switched to tr.deleteSelection(), which routes through prosemirror-tables' CellSelection.replace override and clears each selected cell's content. Still correct for the TextSelection-inside-one-cell case (deletes the text range as before). The codeBlockCopyPlugin's tr.delete(from, to) is intentionally left alone — that path validates the selection sits inside a single code_block, where from/to is a flat text range and no structural risk exists. |
||
|
|
cc4f1c16b6 |
feat(web): let users switch collection inside the Quick Add modal (TASK-857) (#280)
The Quick Add modal previously locked users into the collection they
launched it from. Replace the static `{icon} New {Singular}` header with
a clickable pill that opens a small popover listing every regular
collection in the workspace; selecting one swaps the target collection
without losing the typed title.
Behavior preserved:
- Default collection still comes from the launch entry point (sidebar
`+`, dashboard buttons, Cmd-N).
- Picker excludes agent collections (conventions, playbooks) via the
existing `regularCollections` filter.
- If only one regular collection exists, the pill renders as a non-
interactive label (no caret, no popover).
- `submitQuickAdd` already re-derives default fields and content
template from the current `quickAddCollection`, so swapping mid-flow
Just Works.
Keyboard:
- Enter / Space / ArrowDown on the pill opens the picker.
- ArrowUp/Down/Home/End navigate; Enter selects; Esc closes the picker
only (textarea Esc still closes the modal).
The outside-click handler is kept as its own `$effect` per CONVE-606
(don't combine reactive triggers in a single effect).
Implements IDEA-749.
|
||
|
|
eaae76f667 |
feat(auth): link to /console from CLI auth success state (TASK-856) (#279)
After approving a CLI session at /auth/cli/{code}, the success state
previously dead-ended with "you can close this tab" and no link out.
Adds a primary "Go to your workspaces" CTA linking to /console — the
same destination that / redirects to and that pad-cloud's OAuth flow
lands users on post-login. Universal across self-hosted, Docker, Remote,
and Pad Cloud (which proxies /auth/cli/ to the upstream pad backend
via nginx, no pad-cloud change needed).
The existing "you can close this tab" message stays — some users
(CI runs, headless approvals, teammate's laptop) genuinely just want
to close the tab.
Source: IDEA-848.
Parent: PLAN-833.
|
||
|
|
2b752ba194 |
feat(release): sign + notarize macOS binaries (IDEA-830) (#278)
* feat(release): sign + notarize macOS binaries (IDEA-830)
Adds Developer ID code-signing and Apple notarization to the release
pipeline so users installing via `brew install perpetualsoftware/tap/pad`
or downloading binaries directly no longer hit Gatekeeper's "cannot
verify the developer" warning.
Uses GoReleaser v2's built-in `notarize:` block (Anchore/Quill backend),
which signs and notarizes in-process from the existing ubuntu-latest
runner — no rcodesign install, no macOS runner needed.
Both the .p12 cert and the .p8 App Store Connect notary key are stored
as base64-encoded repo secrets; Quill decodes them in-process. The
notarize block is gated on `MACOS_CERT_P12` being set, so snapshot
builds, fork PRs, and any context where the cert isn't available skip
cleanly without failing.
Verification plan: tag v0.0.1-rc.3, confirm Action goes green end-to-end,
then on a clean Mac run `brew install perpetualsoftware/tap/pad` and
verify `codesign -dv $(which pad)` shows the Developer ID signature and
`spctl -a -t exec -vv $(which pad)` reports "accepted" with the notary
ticket stapled.
* harden(release): isolate npm build from macOS secrets + pin goreleaser
Addresses Codex review findings on PR #278:
MEDIUM — Apple signing secrets were exposed to the npm web build.
The previous `before.hooks` block ran `npm ci && npm run build` inside
the GoReleaser process, which means npm lifecycle scripts and the
SvelteKit build inherited the Developer ID .p12 cert, cert password,
and .p8 notary key from the goreleaser-action's env. Adding a 5-year
signing cert to that environment meaningfully widened the blast radius
of any npm supply-chain compromise.
Fix: move the web build into a dedicated workflow step before the
goreleaser-action invocation. The MACOS_* secrets are scoped only to
the goreleaser env block, so the npm phase no longer sees them.
LOW — GoReleaser binary version was floated as `~> v2`, while every
third-party Action in this workflow is SHA-pinned per the policy at
the top of the file. With Apple signing credentials now in the env, a
compromised or regressed GoReleaser release would carry meaningful
blast radius. Pinned to v2.15.4 (current latest) so bumps go through
explicit review like the rest of the action pins.
No functional change to the signing/notarization itself — same schema,
same gating, same secrets.
* docs(release): document web/build prereq for local snapshot builds
Addresses second-pass Codex review finding on PR #278.
Removing the goreleaser `before.hooks` web build (done in
v0.0.1-rc.3
|
||
|
|
53b5add4e9 |
fix(store): bump SQLite busy_timeout from 5s to 30s (BUG-853) (#277)
TestSQLiteConcurrentWritersNoBusy intermittently fails on the GitHub- hosted Go (SQLite) CI job with `database is locked (5) (SQLITE_BUSY)` under 25 concurrent writers × 5 ops. The test asserts ZERO errors so that BUG-748's `_txlock=immediate` regression stays pinned — but with the DSN's busy_timeout at 5s, the unluckiest writer on a slow shared runner can exceed the timeout: 125 serialized inserts under heavy contention from sibling test packages add up. Bumping busy_timeout to 30s gives a 6× margin over the worst observed CI run and ~50× the normal local p95. Genuine deadlocks don't happen with WAL + BEGIN IMMEDIATE, so the only thing the higher value costs is "how long we wait before declaring lock contention pathological". For Pad's workload, 30s is fine. Surfaced once BUG-851 (PR #276) cleared the rate-limiter goroutine leak that had been masking everything else on main. Verified locally: $ go test -count=20 -run TestSQLiteConcurrentWritersNoBusy \ ./internal/store/ ok github.com/PerpetualSoftware/pad/internal/store 6.102s Note: this is a single-character DSN change plus a doc-comment update; no code path or contract is altered. Read concurrency (WAL) is unchanged — we're not touching SetMaxOpenConns. |
||
|
|
715ec70e94 |
fix(server): drain ipRateLimiter cleanup goroutines on Stop() (BUG-851) (#276)
NewRateLimiters spawned 9 ipRateLimiter cleanup goroutines per Server,
each in an unbounded `for { time.Sleep(5*time.Minute); ... }` loop with
no exit signal (middleware_ratelimit.go:78-89). Every testServer(t)
call leaked all 9, accumulating across the 210-test internal/server
suite. Under -race the goroutine count + sync overhead pushed the run
past the default 10m timeout, which is why the `Run tests with race
detector` step (gated to main pushes) has been failing on every main
run since the step was added on 2026-04-13.
This is the same flavor as BUG-842 part 2 (request-handler
fire-and-forget goroutines drained via Server.bg WaitGroup). The
rate-limiter case wasn't in BUG-842's scope: those goroutines are
spawned at construction time, not at request time, so they need a
different drain primitive.
Changes:
- ipRateLimiter gains stopCh + stopOnce + stopWg. cleanup() rewrites
its loop as a select over stopCh and a 5-minute ticker, deferring
stopWg.Done(). New Stop() closes stopCh once and waits for the
cleanup goroutine to return.
- RateLimiters gains a Stop() that walks all 9 limiters (nil-safe
via the (*ipRateLimiter).Stop receiver guard).
- Server.Stop() now also calls s.rateLimiters.Stop() after
s.bg.Wait(). Test cleanups already call Server.Stop() (added in
BUG-842), so no test-helper changes needed.
- New TestServer_Stop_DrainsRateLimiterCleanup pins the contract:
construct + Stop N servers, assert runtime.NumGoroutine() returns
to baseline ±3.
- .github/workflows/ci.yml: bump the -race timeout from the default
10m to 20m. The full server suite under -race takes ~13m on a dev
laptop after the leak fix; 20m gives margin without papering over
an actual hang. Both `Run tests with race detector` (SQLite) and
`Run tests with race detector against PostgreSQL` are bumped.
Verified locally: go test -race -timeout=1500s ./internal/server/
finishes ok in 776s (12m57s). Without the leak fix, the same command
times out at 600s (10m) with a goroutine dump showing hundreds of
ipRateLimiter.cleanup frames.
|
||
|
|
0fd5d0cdfb |
fix: green up Go (PostgreSQL) CI (BUG-842) (#275)
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)
`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.
`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.
* fix(server): drain background goroutines on Stop() (BUG-842)
`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.
Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:
- middleware_auth.go (TouchUserActivity)
- handlers_auth.go (password reset email)
- handlers_cloud.go (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)
Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.
* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)
The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.
PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.
Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.
The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.
Surfaces:
- dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
placeholders each in the PG dialect.
- items.go: listItemsFTS PG branch + SearchItems PG branch update
args to pass (raw, sanitized) for every PG `?` placeholder.
- search.go: SearchItems main / count / facets PG branches updated
likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
- documents.go: ListDocuments PG branch updated.
Tests:
- TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
case to pin the OR-combined logic — naive hyphen-stripping would
silently regress this.
- New TestSanitizePGFTSQuery unit test.
* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)
The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.
Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:
cmd/pad/configure.go
cmd/pad/main.go
internal/cli/format.go
internal/server/handlers_admin_invitations.go
internal/server/handlers_admin_users.go
internal/server/handlers_grants.go
internal/server/handlers_share_links.go
internal/server/handlers_stars.go
internal/server/middleware_auth.go
internal/store/store.go
internal/store/store_test.go
After this commit `gofmt -l ./cmd ./internal` returns clean.
|
||
|
|
43b2565afe |
fix(web): stop infinite recursion in marked link renderer (BUG-849) (#274)
* fix(web): stop infinite recursion in marked link renderer (BUG-849) The custom link renderer called marked.parseInline on the raw text of a link's child tokens to render the visible text. For autolinks (bare URLs that GFM auto-detects as links) the raw text *is* the URL, so the recursive parseInline re-tokenized it as another autolink and re-entered the same renderer — stack overflow, browser console spammed with "Please report this to https://github.com/markedjs/marked", and the item page rendered as fallback text. Triggered on any item whose content or comments contained a bare URL, e.g. HT-786 had a comment with https://manage.maileroo.app. Use marked's intended API: this.parser.parseInline(tokens) renders the already-parsed inline tokens directly, no re-tokenization. Required: - regular function (not arrow) so `this` binds to the Renderer instance (marked invokes overrides via override.apply(rendererInstance, args)) - import Renderer for the `this: Renderer` annotation - escape the title attribute via escapeHtml() at the source instead of relying on DOMPurify after the fact * fix(web): encode href in markdown link renderer (defense-in-depth) Mirror marked's internal cleanUrl() so the custom link renderer produces well-formed HTML even when href contains spaces, quotes, or other URL-unsafe characters — and degrades gracefully to plain text when encodeURI throws (lone surrogates). Before: an href like `http://x" onclick="alert(1)` (reachable via marked's `[x](<...>)` URL-with-spaces syntax) would land in the attribute verbatim, producing malformed HTML the sanitizer then had to repair. After: encodeURI turns the quotes into %22, so the intermediate HTML is already well-formed before DOMPurify runs. The %25 → % round-trip avoids double-encoding hrefs that already contain percent-encoded bytes (e.g. %20). DOMPurify is still the URL-safety authority — javascript:/data: schemes are stripped by sanitizeMarkdownHtml's ALLOWED_URI_REGEXP. This change is defense-in-depth plus correctness for the intermediate HTML, matching the behavior of marked's default renderer. Flagged in Codex review of #274. |
||
|
|
7cda0d7896 |
feat: rebrand to Perpetual Software + new tagline (IDEA-832) (#273)
Migrates from xarmian/pad to PerpetualSoftware/pad across the entire
repo and updates the product subtitle to "Collaborate with your AI
agents".
Go module rename
- go.mod: github.com/xarmian/pad → github.com/PerpetualSoftware/pad
- All Go imports updated across cmd/pad, internal/{cli,server,store,
models,collections,items,events,metrics,webhooks} (~130 files)
- Test fixtures with the literal repo slug ("xarmian/pad" in JSON
shapes, SSH/HTTPS git URL strings, workspace_context fixtures)
also updated, including the secondary repo entry
(xarmian/pad-web → PerpetualSoftware/pad-web — pad-web was also
moved to the org per branch context)
Docs / config
- README badges, install instructions, brew tap, Docker image, source
build path, sponsor link (sponsor link kept as personal @xarmian)
- Subtitle: "Project management for developers and AI agents." →
"Collaborate with your AI agents." (README, manifests, web layout
meta, .goreleaser homebrew description)
- CONTRIBUTING.md, SECURITY.md, skills/INSTALL.md
- .goreleaser.yaml: homebrew_casks owner, GHCR image, release github
owner, cosign cert-identity regex, comments
- .github/workflows/release.yml: tap/release comments
- deploy/k8s/deployment.yaml: container image
- docs/deployment.md: clone URL
- web/static/{site.webmanifest,manifest.json}: description
- web/src/routes/+layout.svelte: meta description + og:description
Brew tap path is PerpetualSoftware/tap/pad (CamelCase, matches
GitHub user case). GHCR image is ghcr.io/perpetualsoftware/pad
(lowercased per GHCR's URL normalization). CODEOWNERS @xarmian and
FUNDING.yml github: xarmian intentionally retained — those are the
personal maintainer / sponsor account, separate from the org repo.
Verification: go build ./..., go test ./... (all pkgs pass), web
build, and make install all clean (TASK-844, TASK-845).
v0.0.1-rc.2
|
||
|
|
afe721d202 |
feat(cli): add Cloud mode to pad init, drop Docker option (TASK-837, TASK-838) (#272)
Merging despite Go (PostgreSQL) red — those failures (TestListItems_FTS_HyphenatedSearchTerm/task-five + TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly TempDir cleanup race) are pre-existing on main and tracked in BUG-842. Codex reviewed in 3 rounds (round 1 clean → round 2 found a real semantic bug → fix → round 3 clean). Tests, vet, and lint all green; remaining check failures are documented pre-existing. |
||
|
|
0ab6d3ed10 |
feat(web): signed-in account chip on CLI auth approval + switch-accounts (TASK-836) (#271)
* feat(web): show signed-in account chip on CLI auth approval page (TASK-836)
The CLI auth approval page (/auth/cli/{code}) previously showed only an
"Approve" button with no indication of WHICH account was about to grant
the CLI access. For OAuth users on Pad Cloud — most of whom have
multiple GitHub/Google accounts — wrong-account approval was a silent
footgun, recoverable only by revoking the CLI token after the fact.
This change renders an account chip above the Approve button when the
session is pending, showing:
- The user's avatar (when avatar_url is present)
- Display name (or username fallback if name is empty)
- Email
Below the chip, a "I'm not <Name> — switch accounts" link button calls
api.auth.logout() and navigates to /login?redirect=/auth/cli/{code}, so
after re-login the user lands back on this same approval page (the
login page already validates relative-only redirects to prevent open
redirects).
Graceful degradation: api.auth.me() is wrapped in its own try/catch.
If it fails, currentUser stays null and the chip simply doesn't
render — the Approve flow still works. The Approve button is also
disabled while a switch-accounts call is in flight to avoid
double-action races.
Works for both email/password (self-hosted) and OAuth (Cloud)
sessions because api.auth.me() and api.auth.logout() operate on the
unified pad session regardless of how it was established.
Parent: PLAN-833. Source: IDEA-831 issue #3.
* fix(web): plumb redirect through OAuth login + surface logout failures
Codex round-1 findings on TASK-836:
- MEDIUM: The login page already preserved ?redirect= for password and
2FA login but the GitHub/Google OAuth buttons were plain anchors with
hardcoded hrefs. A user clicking "Switch accounts" on the CLI auth
approval page and then signing in via OAuth would land at /console
instead of back at /auth/cli/{code}. Added a $derived oauthRedirectQuery
rune that reuses the existing getRedirectTarget() validation and
appends ?redirect=<encoded> to both OAuth links when the redirect is
non-default. Whether pad-cloud's /auth/github and /auth/google handlers
honor the redirect param is an out-of-tree concern and tracked
separately if needed; the client side now consistently passes it.
- LOW: handleSwitchAccount silently swallowed logout failures and then
navigated to /login. If the server didn't actually invalidate the
session cookie (network/CSRF), login's onMount would see an
authenticated session and bounce the user right back to the approval
page — making "switch accounts" appear to be a no-op. The handler now
surfaces the error in the page error slot and stays on the approval
page, giving the user a clear next step (retry or close the tab) and
also resets switchingAccount so the UI isn't stuck in a "Switching..."
state.
A defensive code check was also added to handleSwitchAccount to mirror
handleApprove's "Missing CLI session code" guard, even though the button
only renders when status === 'pending'.
Parent: PLAN-833.
* fix(web): tighten redirect validation + cover OAuth banner buttons
Codex round-2 findings on TASK-836:
- MEDIUM: getRedirectTarget() accepted protocol-relative URLs (`//host`
and `/\host`) because the bare `startsWith('/')` check passes for
both. Browsers and most server-side redirect handlers treat those as
cross-origin destinations, so a crafted `?redirect=//evil.example`
could become an open redirect once forwarded through the OAuth
handler. Now also rejects strings that start with `//` or `/\`. This
was a pre-existing bug in the password/2FA redirect path; the OAuth
link change made the surface area worth tightening.
- LOW: The "Use a different GitHub/Google account" banner buttons that
appear on `oauth_provider_not_linked` errors hardcoded `?force=1` and
dropped the redirect target. Added a sibling `oauthRedirectAmpQuery`
derived value (`&redirect=...`) so those links compose properly with
`?force=1`. When the redirect is the default `/console` it stays
empty so we don't add redundant query noise.
Both changes live in cmd/pad/... no, in web/src/routes/login/+page.svelte
and don't affect the password / 2FA paths beyond the validation
tightening (which they were already passing through silently).
Parent: PLAN-833.
|
||
|
|
afd3b3c5ee |
feat(cli): explicit cancel + clean SIGINT for pad init prompts (TASK-835) (#270)
* feat(cli): explicit cancel + clean SIGINT for pad init prompts (TASK-835) The interactive prompts in 'pad init' / 'pad workspace init' previously relied on Go's default Ctrl+C behavior (terminate with no message) and offered no in-prompt way to back out. A user who realized mid-init that they were in the wrong directory had no clean exit and risked partial state. This change: - Adds cmd/pad/cancel.go with errCancelled (sentinel), cancelInit() (the canonical "Cancelled." + os.Exit(130) path), and an installable SIGINT/SIGTERM handler. - Template picker accepts c/q/cancel/quit (case-insensitive) and returns errCancelled. Prompt text now mentions the cancel option. - Mode picker (pad configure) gains the same cancel keywords + prompt hint. - Both pad init and pad workspace init RunEs install the signal handler and convert any propagated errCancelled into the same cancelInit() exit, using a named-return + LIFO defer so the existing body is unchanged. - SilenceErrors + SilenceUsage are set on both init commands so cobra doesn't render an "Error: cancelled by user" line on top of our friendly message. State on cancel: the template picker is invoked AFTER step 1 (configure) but BEFORE the workspace is created on the server and BEFORE .pad.toml is written, so an abort at that prompt leaves no half-created workspace, no orphan .pad.toml, and no stale credentials. Tests cover all cancel keyword variants (both via the picker and via errors.Is on wrapped errors), and verify the prompt surface mentions the cancel option. Parent: PLAN-833. Source: IDEA-831 issue #4. * fix(cli): wire cancellation into all init paths per Codex review Round 1 findings: - HIGH: getConfiguredConfig() called os.Exit(1) on errCancelled, bypassing the canonical "Cancelled." + 130 exit. It now recognizes the sentinel and routes through cancelInit() before falling through to its generic Error path. - HIGH: SilenceErrors+SilenceUsage on the init commands silenced every error, hiding real failures (e.g. server connection problems). Removed both — cancelInit() never returns to cobra, so the cancellation case doesn't need silencing, and real errors print normally again. - MEDIUM: doBrowserLogin returned `fmt.Errorf("login cancelled")` on context cancel, which is not errCancelled. If its inner signal listener won the race against the outer init handler on Ctrl+C, the propagated error didn't match isCancellation and the command exited 1 with a generic message. doBrowserLogin now returns errCancelled directly so whichever goroutine wins the race, the exit converges on 130. - MEDIUM: cancel.go cleanup race — if a signal arrived between init completion and the goroutine returning, both sigCh and done could be ready and select could pick sigCh, turning a successful run into a spurious 130 exit. Added a re-check on done inside the sigCh branch so late signals are suppressed once cleanup has run. Also reordered cleanup to call signal.Stop before close(done) so no new signals enter the buffer during shutdown. - MEDIUM: promptForValue (the URL prompt for remote/docker mode) still treated 'c' as URL input and failed validation. It now recognizes c/q/cancel/quit and returns errCancelled, matching the picker and mode-prompt behavior. LOW finding (account-setup prompts) intentionally not addressed: Ctrl+C already covers them via the outer handler, and explicit keyword recognition on the password prompt would risk collision with real passwords. doInteractiveLogin is not on an init path. Parent: PLAN-833. * fix(cli): cancel sentinel handling for pad auth configure / pad auth login Codex round-2 findings: - MEDIUM: pad auth configure RunE returned errCancelled directly to cobra. Now wraps the body with the same isCancellation -> cancelInit() deferred check used in pad init, so 'c' at the mode/URL prompt exits with the canonical "Cancelled." + 130. - LOW: pad auth login RunE called doBrowserLogin (which now returns errCancelled on signal cancellation). The sentinel was leaking to cobra. Added the same deferred check so SIGINT during browser login exits 130 with a friendly message regardless of which goroutine wins the cancellation race. Neither command installs the outer SIGINT handler — pad auth login relies on doBrowserLogin's existing inner listener (avoiding the double-listener race) and pad auth configure's prompts are short enough that Go's default Ctrl+C handling for those is acceptable. The new deferred checks just plug the sentinel-leak holes. Parent: PLAN-833. |
||
|
|
189b22825e |
fix(cli): use cfg.BaseURL()/BrowserURL() in pad init success message + pad open (TASK-834) (#269)
* fix(cli): use cfg.BaseURL() in pad init success message (TASK-834) The "Or open the web UI at http://localhost:7777" line in printOnboardingHints was hardcoded, which is wrong for any non-local connection mode (Remote, Docker, eventual Cloud). The CLI already knows the configured base URL — it just used it to talk to the server. Same hardcoded URL existed in the workspace-onboard skip path ("You can activate conventions from the library: ..."). Both call sites now use cfg.BaseURL(), which yields the correct URL for every mode: - Local: http://127.0.0.1:7777 (default host:port) - Remote/Docker/Cloud: the configured URL (e.g. https://app.getpad.dev) printOnboardingHints now takes a *config.Config; both call sites already had cfg in scope. Parent: PLAN-833 (pad init UX gaps + Pad Cloud onboarding fixes). Source: IDEA-831 issue #5. * fix(config): add BrowserURL() that normalizes 0.0.0.0 to 127.0.0.1 Per Codex review (round 1): when local mode runs with --host 0.0.0.0 (bind-all), cfg.BaseURL() returned "http://0.0.0.0:7777" — a bind address that browsers don't reliably accept. BrowserURL() behaves like BaseURL() except that when constructing from host:port, an unspecified bind-all host (empty, "0.0.0.0", "::", "[::]") is rewritten to "127.0.0.1". Explicit URL configurations (Remote/Docker/Cloud) are returned unchanged. The two onboarding-hint call sites updated in the previous commit now use BrowserURL() so the success message and skip-path show a clickable URL in every supported configuration. Tests cover loopback, named hosts, empty/0.0.0.0/::/[::] normalization, and explicit-URL precedence. Parent: PLAN-833. * fix(cli): use BrowserURL() in pad open for bind-all safety Per Codex review (round 2): the 'pad open' command prints and opens cfg.BaseURL(), which produces 'http://0.0.0.0:7777' when the local server is bound bind-all. Same class of bug as the onboarding hint fix in this PR — switch to cfg.BrowserURL() so the URL is a usable browser destination. A second related issue Codex flagged — the server-issued CLI auth URL in doBrowserLogin (which goes through internal/server/handlers_cli_auth.go using r.Host) — is a different surface with multiple possible fix strategies and overlaps with the post-v0.1.0 OAuth-architecture work. Deferred to TASK-839 with a written-up runbook so it isn't lost. Parent: PLAN-833. |
||
|
|
8ae009fa40 |
feat(admin): add Billing tab and dashboard page (TASK-828) (#267)
* feat(admin): add Billing tab and dashboard page (TASK-828)
Surfaces the Pad Cloud billing metrics in the admin console as a new
tab between "Audit Log" and "Settings". Final piece of PLAN-825.
The page calls GET /api/v1/admin/billing-stats (TASK-827) and renders
six metric cards in a responsive auto-fit grid:
1. MRR (Stripe-derived; greyed when unavailable)
2. ARR (Stripe-derived; greyed when unavailable)
3. Active Subs (Stripe-derived; greyed when unavailable)
4. Customers/Plan (LOCAL — always real; e.g. "Free: 42 · Pro: 7")
5. New Signups 30d (LOCAL — always real)
6. Churn 30d (Stripe-derived; greyed when unavailable;
subtitle shows cancelled count)
Two banners drive the degraded-state UX:
- cloud_unreachable=true → amber warning ("sidecar unreachable, showing
local data only")
- stripe_configured=false → blue info banner explaining that Stripe
metrics will be zero until STRIPE_SECRET_KEY
is set on pad-cloud (the expected pre-launch
steady state)
Header carries a Refresh button (re-fetches without unmounting the page)
and an "Open in Stripe Dashboard ↗" external anchor (rel=noopener).
A subtle footer renders "Updated just now" or "Updated N min ago" from
the cache_age_seconds field.
The Billing tab is hidden from the layout's tab list when
adminStore.stats.cloud_mode is false — self-host operators won't see a
tab that always 404s on click. Used $derived(...) for the tabs array so
the tab list reacts to the cloud_mode flag flipping after stats load.
Svelte 5: runes throughout ($state, $derived, $props), single onMount
for the initial fetch, no combined effect-on-effect chains (CONVE-606).
Visual idiom mirrors the existing /console/admin stats-bar (.stat
cards, --bg-secondary background, --radius-lg, value/label sizing).
Validated with the svelte MCP autofixer (clean) and `npm run build`
(clean, page emitted to entries/pages/console/admin/billing).
Closes PLAN-825's UI work.
* fix(admin): add role=status / aria-live=polite to Stripe info banner
Codex round 1 LOW: the warning banner already carries role=alert because
its message is urgent (sidecar unreachable), but the "Stripe not
configured" info banner appears asynchronously after load with no live-
region semantics, so screen readers never announce that the page is in
a degraded state. Add role=status + aria-live=polite so the announcement
is non-interrupting but happens.
v0.0.1-rc.1
|
||
|
|
8e067c19db |
feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827) (#266)
* feat(admin): add /api/v1/admin/billing-stats proxy + cloud client (TASK-827)
New admin endpoint that powers the upcoming Pad Cloud Billing dashboard:
GET /api/v1/admin/billing-stats merges Stripe-derived metrics from pad-cloud
(active subs, MRR, ARR, churn, 30-day cancellations) with locally-computed
aggregates from the users table (customers_by_plan, new_signups_30d in the
last 30 days for plan='pro').
Architecture (PLAN-825 Option B):
- pad-cloud (TASK-826, already merged) hosts the Stripe API access in one
place; this PR adds the reverse pad → pad-cloud client method.
- Existing internal/billing.CloudClient gains GetBillingMetrics(): GET on
/admin/metrics/billing with the X-Cloud-Secret header (the same secret
pad-cloud already validates inbound calls with).
- New CloudSidecar.GetBillingMetrics() interface method keeps the server
package free of HTTP/Stripe dependencies and lets tests inject fakes.
- Existing fakeSidecar in handlers_account_test.go grows a no-op stub so
the account-delete tests still satisfy the extended interface.
Degradation contract:
- The endpoint always returns 200. Two booleans tell the UI which fallback
to render: cloud_unreachable=true (sidecar errored or unwired) and
stripe_configured=false (sidecar reachable but no STRIPE_SECRET_KEY yet).
- requireCloudMode + requireAdmin gate the route. Self-host gets 404,
non-admin gets 403.
Web glue:
- Added AdminBillingStats type to web/src/lib/types/index.ts.
- Added api.admin.getBillingStats() to web/src/lib/api/client.ts.
The Billing tab and metric cards land in TASK-828.
Tests:
- Billing package: GetBillingMetrics happy path (verifies method, path,
X-Cloud-Secret header, Accept header), Stripe-not-configured pass-through,
non-200 → SidecarError, transport error stays bare, malformed JSON,
nil/unconfigured client guards.
- Server package: self-host 404, non-admin 403, admin happy path
(merges local + remote correctly, handles plan="" → "free", filters
new_signups_30d to plan='pro' AND created_at >30d ago), no-sidecar
degrades to local-only, transport error degrades, sidecar 5xx degrades,
stripe_configured=false propagates verbatim with cloud_unreachable=false.
Part of PLAN-825 (Pad Cloud Admin Billing Dashboard).
* fix(admin): address Codex review (round 1) on billing-stats proxy
- Replace handler-side ListUsers walk with store.CountBillingAggregates
(two scalar SQL queries: COUNT(*) GROUP BY plan + a single COUNT(*)
for new pro signups). Removes the per-row TOTP decrypt overhead that
ListUsers performs and bounds CPU/bandwidth as the user table grows.
- Fix misleading TS comment on AdminBillingStats: clarify that "fully
healthy" requires cloud_unreachable=false AND stripe_configured=true,
not "both flags false" as previously stated.
Adds TestCountBillingAggregates exercising empty store, mixed plans,
empty-plan → "free" bucketing, and the 30-day cutoff filter for new
pro signups.
* fix(store): GROUP BY normalised plan expression in CountBillingAggregates
Codex round 2 caught a real bug: SELECT projected the COALESCE'd plan but
GROUP BY operated on the raw `plan` column, so users with plan='' and
plan='free' produced two distinct result rows that both scanned as "free"
in Go — the second iteration overwrote the first in CustomersByPlan,
silently underreporting the free-tier count.
Fix: GROUP BY COALESCE(NULLIF(plan, ''), 'free') so the grouping matches
the projection. Test updated: insertWithPlanAndDate now seeds an explicit
'' plan alongside two explicit 'free' rows and asserts the aggregate
rolls them up to 3 — the previous test only used CreateUser which always
inserts the column default ('free') and never exercised the empty-string
path.
|
||
|
|
c4b5a36330 |
feat: warn at startup when shipped FTS triggers are missing (TASK-824) (#265)
* feat(store): warn at startup when shipped FTS triggers are missing (TASK-824) Defensive follow-up to BUG-822, where the documents_* triggers had silently drifted off some production DBs and search was broken until a user noticed. The migration runner had no notion that the triggers should exist — the only invariant was "this migration ran without erroring," which is too weak when SQLite's table-rebuild path can leave auxiliary objects in a different state than the migration intended. Add a hardcoded list of expected FTS5 triggers (one row per trigger, naming the table it's attached to) and a one-shot validateFTSInvariants step at the end of Store.migrate(). Each missing trigger emits a structured slog.Warn that points the operator at the recovery migration (046). Choices: - SQLite-only. Postgres uses tsvector update functions in pgmigrations with a different invariant model. - Logging-only, no auto-repair. Auto-creating triggers here would mask legitimate future removals and obscure the source of truth (the migrations directory). The recovery path is a targeted migration like 046_restore_documents_fts_triggers.sql. - Non-fatal. A missing trigger doesn't block startup; the operator may have intentionally removed one and just not updated the list yet, and we'd rather warn loudly than refuse to boot. Tests: - TestStartupInvariants_AllFTSTriggersExist — fresh DB has all 9 expected triggers (forward-looking guard against future migrations that break one). - TestStartupInvariants_LogsOnMissingTrigger — drop a trigger, run validator, capture slog records, assert a warning naming the missing trigger was emitted. Manual verification on the production DB: - Clean DB (after migration 046): no warnings on startup. - After manually `DROP TRIGGER documents_ai`: server logs `level=WARN msg="FTS trigger missing — ..." trigger=documents_ai table=documents` immediately on startup. * test(store): address Codex review on TASK-824 — bidirectional drift + Record.Clone Two LOW findings from Codex's first pass: 1. recordCapturingHandler.Handle stored slog.Record values without cloning. Records have internal shared state; the documented pattern for retaining them is r.Clone() first. Test passed today only because nothing mutated the record after Handle, but the helper was relying on slog internals. 2. TestStartupInvariants_AllFTSTriggersExist only proved every entry in expectedFTSTriggers exists. It didn't catch the inverse: a future migration adding a new FTS-style trigger on items/comments/documents without also adding it to expectedFTSTriggers, leaving the new trigger off the invariant check forever. Add TestExpectedFTSTriggers_MatchesActual which queries sqlite_master for every trigger on items/comments/documents and asserts each is in the expected list. A new trigger that isn't tracked fails this test with a clear "update the list in store.go" message. If a future trigger on these tables is legitimately not FTS-related, the test failure points the developer at this guard and they can either add it to expectedFTSTriggers or extend the exclusion. |
||
|
|
4608108acf |
fix: restore documents_fts triggers + rebuild index (BUG-822) (#264)
* fix(store): restore documents_fts triggers + rebuild index (BUG-822)
Some production DBs ended up missing the documents_ai/au/ad triggers,
even though migration 025 (which rebuilt the documents table for the
doc_type CHECK constraint change) was recorded as applied. Items_fts
and comments_fts triggers were unaffected — issue is isolated to the
documents table-rebuild path.
Without these triggers, INSERT INTO documents never propagates rows
into documents_fts, so newly-created documents are silently invisible
to search. Plain list views still surface them, masking the regression.
Migration 046 is idempotent and safe to apply on any DB:
1. DROP TRIGGER IF EXISTS for the three documents_* triggers — round-
trips for DBs that ran 025 cleanly, recovers DBs missing the
triggers.
2. CREATE TRIGGER for all three (matching the bodies in 001/025).
3. INSERT INTO documents_fts(documents_fts) VALUES ('rebuild') to
repopulate the FTS5 internal index from the current documents
table — recovers searchability for documents created while the
triggers were missing.
Postgres path uses a separate tsvector trigger function and is not
affected (only pgmigrations are applied there; this migration lives
in the SQLite migrations directory).
Tests:
- TestMigration046_DocumentsFTSTriggersExist — assert all three
documents_* triggers exist after migrations run.
- TestCreateDocument_IsSearchableImmediately — regression test for
the failure mode: create a doc, immediately search by a unique
title-keyword, assert it's findable.
Manual verification on the production DB:
- Triggers re-appeared after `make install` (migration 046 applied).
- POST /documents with title "BUG822verify distinctive" → immediately
findable via ?q=BUG822verify (returned 1 result, the new doc).
* test(store): pin the BUG-822 recovery path with a rebuild test
Codex review on the BUG-822 fix flagged that neither existing test would
fail if the `INSERT INTO documents_fts(documents_fts) VALUES('rebuild')`
step were removed from migration 046. The trigger-existence and
post-fix-search-works tests both pass on a clean migration run, but
they don't exercise the historical-recovery half of the migration —
the part that rescues already-broken DBs whose documents were inserted
while the triggers were missing.
Add TestMigration046_RebuildRecoversUnindexedDocs which:
1. Drops the documents_* triggers to simulate the broken state.
2. Inserts a document via the store path — won't reach FTS without
triggers.
3. Asserts the doc is invisible to ListDocuments (sanity-pinning the
broken state).
4. Runs just the rebuild step from migration 046.
5. Asserts the previously-unindexed doc is now searchable.
This locks in the recovery contract: removing the rebuild step from
046 will now make this test fail.
|
||
|
|
dcf7c1d58e |
fix(store): apply Tag and Pinned filters in ListDocuments FTS branch (BUG-820) (#263)
The non-FTS path in ListDocuments applies Tag and Pinned filters (lines 31-42), but when params.Query is non-empty the FTS branch rebuilds query and args from scratch and only re-applies Type and Status — Tag and Pinned were silently dropped. Result: `/documents?q=foo&tag=urgent` returned all docs matching foo regardless of tag, similarly for pinned. Documents-side analog of BUG-812 (which fixed the equivalent issue on the items FTS path). Fix: mirror the Tag (s.dialect.JSONArrayContains on d.tags) and Pinned (d.pinned = TRUE/FALSE) filter blocks into the FTS branch after the existing Type/Status blocks. Backend-only — handlers and DocumentListParams already plumb both params through. Tests: - TestListDocuments_FTS_TagFilter — two docs match the search; only one has the tag; assert exactly the tagged one returned. - TestListDocuments_FTS_PinnedFilter — covers both pinned=true and pinned=false branches, asserting each narrows correctly. Manual verification: with two docs `BUG820scratch alpha` (tagged "urgent", pinned) and `BUG820scratch beta` (untagged, unpinned): - ?q=BUG820scratch → 2 docs - ?q=BUG820scratch&tag=urgent → 1 doc (alpha) - ?q=BUG820scratch&pinned=true → 1 doc (alpha) - ?q=BUG820scratch&pinned=false → 1 doc (beta) |
||
|
|
068c208824 |
fix: sanitize SQLite FTS5 queries + whitespace guards (BUG-818) (#261)
* fix(store): sanitize FTS5 queries in listItemsFTS and SearchItems (BUG-818)
The sanitizeFTSQuery helper in internal/store/search.go wraps each
whitespace-delimited token in double quotes so SQLite FTS5 treats
specials (hyphens, AND/OR/NOT, parens) as literal characters rather
than boolean operators. Store.Search already used it; Store.listItemsFTS
and Store.SearchItems didn't, so any hyphen in `?search=` returned
HTTP 500 with "no such column: <suffix>" — including issue refs like
TASK-5, kebab-case slugs, dates, etc.
Apply sanitizeFTSQuery at the SQLite arg-binding sites in both unfixed
functions. Postgres branches stay unsanitized: plainto_tsquery accepts
arbitrary input safely (matches the existing pattern in Store.Search).
Tests:
- TestListItems_FTS_HyphenatedSearchTerm — exercises the listItems path
on multiple hyphenated queries via a table-driven sub-test.
- TestSearchItems_HyphenatedQuery — same regression on the SearchItems
path used by /api/v1/search.
- TestSanitizeFTSQuery — direct unit test covering empty, whitespace-
only, plain word, hyphenated phrase, multi-token, FTS5 boolean
operators (AND/OR/NOT), parens, embedded quotes (stripped),
surrounding whitespace, and unicode.
Manual verification: previously-500 queries now return 200 with results:
/items?search=match-me → HTTP 200, 2 items
/items?search=TASK-5 → HTTP 200, 8 items
/items?search=pad-cloud → HTTP 200, 103 items
* fix(store): address Codex review on PR for BUG-818
Codex review caught two extensions to the original BUG-818 fix:
1. MEDIUM — Store.ListDocuments (internal/store/documents.go) had the
same FTS5 boolean-parser vulnerability as Store.listItemsFTS and
Store.SearchItems before the original commit. Hyphenated /documents?q=
queries (e.g. ?q=release-notes-q2) returned HTTP 500 with "no such
column" the same way. Apply sanitizeFTSQuery in the SQLite branch;
leave Postgres unchanged.
2. LOW — Whitespace-only queries collapse to empty after FTS sanitization,
and SQLite FTS5 errors on `MATCH ''` with "syntax error near \"\"".
Add TrimSpace guards at the routing/entry points:
- listItems: route to FTS only if TrimSpace(Search) != ""
- SearchItems: short-circuit to empty results
- ListDocuments: same routing guard
- Store.Search: short-circuit to empty results
Tests:
- TestListDocuments_HyphenatedQuery — regression on the documents FTS path
- TestFTS_WhitespaceOnlyQuery_DoesNotCrash — covers all 3 entry points
(ListItems, SearchItems, ListDocuments) for spaces, tabs, mixed
whitespace
Manual verification (all 6 endpoints now HTTP 200):
- /workspaces/{ws}/items?search=task-five
- /workspaces/{ws}/items?search=<3 spaces>
- /workspaces/{ws}/documents?q=release-notes
- /workspaces/{ws}/documents?q=<3 spaces>
- /search?q=task-5
- /search?q=<3 spaces>
|
||
|
|
10e17e0ca1 |
fix(store): apply Tag/ParentID/Assignee/AgentRole/Fields filters in listItemsFTS (BUG-812) (#260)
When `search` is set, ListItems routes through listItemsFTS, which historically only re-applied CollectionSlug, CollectionIDs, ItemIDs, and (post-BUG-734) ParentLinkID. Other filter parameters silently dropped: - Tag - ParentID (legacy items.parent_id column) - AssignedUserID - AgentRoleID (both ID-equality and slug-OR branches) - Fields (custom-field equality / IN-list) Result: combining ?search=foo with any of the above returned more items than the caller asked for. Web UI list filters chained with the search box, the per-collection filter chips, and any API consumer with the same combo were all affected. Fix: mirror the relevant filter blocks from the non-FTS listItems path into listItemsFTS, preserving isValidFieldKey injection guarding on field keys. Tests (internal/store/items_test.go): - TestListItems_FTS_TagFilter - TestListItems_FTS_ParentIDFilter - TestListItems_FTS_AssignedUserFilter - TestListItems_FTS_AgentRoleFilter (covers both role-ID and role-slug branches) - TestListItems_FTS_FieldFilter (single-value, IN-list, and the invalid-key silent-drop) Out of scope: IncludeArchived parity (FTS hardcodes deleted_at IS NULL), Sort parity (FTS deliberately sorts by relevance rank), Offset (FTS honors only Limit). Unrelated to BUG-812; can ship together later if desired. Manual verification: with two tasks `Bug812scratch alpha` (priority=high) and `Bug812scratch beta` (priority=low), `?search=Bug812scratch` returns both, `?search=Bug812scratch&priority=high` returns only alpha. |
||
|
|
0bf710eea5 |
fix: hide item_links pointing to soft-deleted items (BUG-734) (#259)
* fix(store): hide item_links pointing to soft-deleted items (BUG-734)
Item-link queries that JOIN against `items` now also filter on
`deleted_at IS NULL` for both source and target. This prevents
`pad item related`, the lineage breadcrumb, and dashboard enrichment
from surfacing dangling endpoints when one side has been archived.
Affected queries in internal/store/items.go:
- GetItemLinks (powers `pad item related`, lineage, dashboard)
- GetItemLink (singular; fixed for consistency)
- GetParentForItem (breadcrumb / lineage; archived parent reads as none)
Other item_links queries already filtered on deleted_at; export.go
deliberately keeps all rows for backup correctness — left unchanged.
The link rows themselves are preserved on disk, so restoring a
soft-deleted item resurrects its relationships automatically.
Tests:
- TestItemLinks_HidesSoftDeletedEndpoints — delete + restore round-trip
on both source-side and target-side
- TestGetParentForItem_HidesSoftDeletedParent — parent breadcrumb path
Manually verified: PLAN + TASK with `implements` link, soft-delete the
TASK, `pad item related <PLAN>` correctly returns no implementers.
* fix(store): address Codex review findings on PR #259 (BUG-734)
Three follow-ups from Codex's review of the soft-delete filter on item-link
queries:
1. MEDIUM — GetParentMap now JOINs items on both sides and filters on
deleted_at IS NULL. handlers_dashboard.go uses this map directly to
detect orphaned tasks (items not present in the map are flagged), so
without the filter a task whose parent had been soft-deleted would
silently fail to appear as orphaned.
2. LOW — Revert the deleted_at filter on getItemLink (lowercase, private).
Its only caller is the post-insert readback in CreateItemLink, which
means filtering buys nothing user-facing and introduces a delete-race
window where a successful INSERT returns nil. SetParentLink's readback
was switched from GetItemLinks to getItemLink for the same reason.
User-facing surfaces still go through GetItemLinks (plural) and
GetParentForItem, both of which retain the filter.
3. LOW — Add an explicit comment in export.go documenting that item_links
are exported in full (including links to soft-deleted items), and why
that intentionally diverges from the user-facing query behavior.
Tests: TestGetParentMap_ExcludesSoftDeletedEndpoints exercises the
dashboard regression path on both source-side and target-side soft-delete,
plus the restore round-trip.
* fix(store): reject soft-deleted parent in ListItems UUID parent filter (BUG-734)
Codex review on
|
||
|
|
96b3f68b5a |
docs(readme): use pad init as the canonical entry point (#258)
* docs(readme): use pad init as the canonical entry point
The Quick Start and Getting Started sections still walked users
through the deprecated multi-step flow (pad auth configure +
pad workspace init + pad agent install), even though pad init is
a single smart command that orchestrates all six setup steps.
Changes:
- Quick Start: 3 commands -> 2 (brew install + pad init).
- Getting Started: collapsed sections 1-3 ("Configure this client",
"Initialize a workspace", "Install the AI skill") into a single
"Set up Pad" section that uses pad init.
- Template examples updated from pad workspace init --template X
to pad init --template X. --list-templates kept as
pad workspace init --list-templates (the only command that
supports it today).
- Tagline ("No accounts.") + architecture summary ("no accounts.")
-> "No accounts required." Pad supports user accounts with
email/password auth and workspace invitations; the strict claim
contradicted later sections.
- Removed Pad Cloud directive in the Docker section -- Cloud is
not released yet, so the README should not direct users to it.
- Replaced full Docker Compose subsection with a one-line pointer
to docs/deployment.md. Postgres + Redis is an advanced multi-
instance path; the README should keep its binary-first focus.
- Aligned the pad github CLI reference columns (3 lines were
off-spec).
* docs(readme): use pad init in the comparison table too (codex nit)
* docs(readme): reframe Authentication section to point local installs at pad init (codex P2)
* docs(readme): scope pad init bootstrap to local mode (codex P2)
|
||
|
|
29f720c996 |
docs: add real README screenshots (dashboard + board views) (#257)
The README had two TODO placeholders for screenshots that have been
sitting commented-out since the project started. With the launch
imminent, fill them in.
Captures:
- docs/screenshots/dashboard.png — workspace dashboard with Active
Work cards, Active Plans (v0.2 — Collaboration with progress),
collection summaries, recent activity.
- docs/screenshots/board.png — tasks board view, four columns
(Open / In-Progress / Done / Cancelled) with realistic task cards.
- docs/screenshots/list.png — list view (not currently referenced
from the README, but kept as part of the reproducible asset set).
Reproducibility:
web/e2e/screenshots.spec.ts is a gated Playwright spec (skipped
unless PAD_SCREENSHOTS=1) that uses the existing e2e fixture
infrastructure to:
1. Spin up a fresh pad binary against a clean data dir.
2. Bootstrap an admin + workspace seeded with the startup template.
3. Add a realistic demo dataset (1 active plan, 7 tasks across
open/in-progress/done with mixed priorities, 2 ideas).
4. Navigate + capture three views at 1440x900.
To regenerate:
make build
cd web && PAD_SCREENSHOTS=1 PAD_E2E_PORT=17801 \\
npx playwright test screenshots --project=desktop-chromium
Notes:
- Table view (?view=table) was originally in scope but the URL
parser only accepts list/board today; setting via toggle would
require localStorage manipulation. Three screenshots already
cover the README's needs; revisit if/when table view becomes
URL-reachable.
- Dark/light variants were also in scope but the web UI is dark-
mode-only at present, so the captures are dark-only.
Refs: TASK-673
|
||
|
|
e57da62917 |
chore: modernize goreleaser config + wire homebrew-tap token (#256)
* chore: wire HOMEBREW_TAP_GITHUB_TOKEN into release pipeline
The brews block in .goreleaser.yaml targets the separate xarmian/homebrew-tap
repo. Without a token override, goreleaser falls back to the workflow's
GITHUB_TOKEN — which is scoped to xarmian/pad only and cannot push to the
tap repo. At first real tag time the brew publish step would fail with a
permission error.
- Add `repository.token` to the brews block, referencing
`{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}`
- Export `HOMEBREW_TAP_GITHUB_TOKEN` from the workflow `secrets` into the
goreleaser step env, alongside the existing `GITHUB_TOKEN`
- Comment both edits with the rationale + the fine-grained PAT permissions
the secret needs (`Contents: write` + `Metadata: read` on the tap repo)
The secret itself is created on the human side (HT-780). Snapshot mode
skips publishing so this isn't testable locally — the gate is HT-782's
v0.0.1-rc.1 dress rehearsal.
Refs: TASK-806, TASK-778 (audit), HT-780 (operator step)
* chore: migrate goreleaser deprecations (archives.formats, dockers_v2, homebrew_casks)
Three v2 deprecations were flagged by `goreleaser check` while wiring the
tap token. Migrating them now (instead of filing tech debt) because we're
already touching the file and these are part of the same release pipeline
that ships at v0.1.0 — no point landing a "wire the token" commit that
still trips deprecation warnings on the very next CI run.
Changes:
- archives: `format: tar.gz` + `format_overrides[].format: zip` →
`formats: ["tar.gz"]` + `format_overrides[].formats: ["zip"]`
(single-string is still accepted but the list form is the new spec)
- dockers + docker_manifests → dockers_v2:
Single block with `images:` + `tags:` + `platforms:` collapses the
prior per-architecture builds plus separate manifest declarations into
one declaration. buildx + multi-platform are implicit. Snapshot
validates: amd64 + arm64 images both build, manifest list assembled,
binary runs inside the cross-built image.
goreleaser flags dockers_v2 as "experimental and subject to change" —
it's the documented forward path for v2 and the project is already
pinned to `version: "~> v2"`, so we're committed to the roadmap.
- Dockerfile.goreleaser: add `ARG TARGETPLATFORM` and update the COPY to
`${TARGETPLATFORM}/pad`. dockers_v2 organizes pre-built binaries under
`linux/amd64/pad`, `linux/arm64/pad` etc; buildx populates
TARGETPLATFORM per platform during the build.
- brews → homebrew_casks: `directory: Formula` → `directory: Casks`.
The `brews` keyword is fully phased out in v2.10+; goreleaser's
homebrew_casks now natively handles pre-compiled binaries (which used
to require workarounds with the old brews block). End-user UX is
unchanged: `brew install xarmian/tap/pad` works identically because
modern Homebrew auto-detects whether a tap entry is a formula or a
cask. Removed the no-op `test:` stanza that doesn't apply to casks.
Validation: `goreleaser check` clean (zero warnings), `goreleaser
release --snapshot --clean --skip=publish,sign,sbom` builds all six
binaries, six archives, one cask, two cross-platform docker images.
`docker run --rm ghcr.io/xarmian/pad:latest-amd64 --version` returns
the snapshot version as expected.
Refs: TASK-806
|
||
|
|
89a5647543 |
docs: strip 'Hardening for public deployments' from README (#255)
The OSS package defaults to loopback and is positioned as a local-first, single-user product. A polished operator checklist for self-hosting beyond loopback competes directly with the Pad Cloud funnel — the multi-user team segment we want to convert to hosted. - Strip the entire 'Hardening for public deployments' section from README (network boundary, secrets, authentication, observability, CI gates, quick checklist — five subheadings). - Reword the Docker subsection so single-user-on-LAN / Tailscale / home VPN reads as a positive supported path. Multi-user team setups get a soft handoff to Pad Cloud. - Move the npm audit + govulncheck CI guidance to CONTRIBUTING.md as a Quality Gates subsection — that material is contributor-facing, not user-facing, so it stays. - docs/deployment.md unchanged — multi-user Postgres + K8s recipes still exist there for the determined self-hoster, but unpromoted from the README. No new docs/SELF-HOSTING.md created (initially considered) — would have competed with the hosted-product positioning. Refs: TASK-777 |
||
|
|
a1bbfabf67 |
fix: topbar overflow drag/drop and dashboard flicker (IDEA-758) (#254)
Series of regressions found while testing the workspace topbar overflow menu shipped in IDEA-758 / TASK-759: - Layout collapse: `.workspace-list` had no `flex: 1`, so ResizeObserver fed the shrinking content width back into the fitting calc and ratcheted down to "active pill only". Wrap pills, trigger, and add button in a centered `.workspace-row` that owns `flex: 1`; the row's full width now drives the split. - Trigger position + menu anchoring: trigger now sits next to the last visible pill, and the menu opens directly under the trigger via a `position: relative` `.overflow-anchor` wrapper. - Overflow zone not registering as a drop target: switched from `pointer-events: none` / `transform: scale(0)` to `visibility: hidden` for the closed state. svelte-dnd-action's hit-test uses bounding-rect math (not `elementsFromPoint`), and `scale(0)` confuses its transform-undoing on percentage origins. - Pre-mount the menu DOM on mousedown via `dragArmed` so the dndzone is registered before drag starts (mid-drag mount isn't picked up). - Post-drop snap-back: set `dropCooldown = true` synchronously in finalize handlers, before flipping `isDragging`, so the resync effect doesn't clobber the post-drag zones before the persist microtask runs. - Click-after-drop navigation: `dropClickGuard` swallows the synthetic click that fires on the dragged `<a>` after mouseup, preventing `goto()` from firing on every drop. - Dashboard re-fetch flicker: `workspaceStore.setCurrent`'s synchronous `workspaces.find(...)` was leaking a reactive dep on `workspaceStore.workspaces` into both the workspace `+layout` effect and the dashboard `+page` load effect. Wrap both in `untrack(...)` so they only re-run on `wsSlug` change. - Active-pin reject cleanup: rejection paths now call `clearCooldownAfterRejection()` so a stuck `dropCooldown` from the source-zone finalize doesn't gate sync effects forever. - A11y: `aria-expanded` on the trigger now uses a `menuVisible` derived (`overflowOpen || isDragging || dragArmed`) so it matches the visual open state. - Replace `CHROME_RESERVATION = 72` magic number with named parts derived from the actual CSS box model (= 68, was off by 4). |
||
|
|
e5e2bd7b86 |
chore: flip CI only-new-issues=false + scope lint policy (TASK-771) (#253)
* chore: gate CI on full lint, scoped to checks we enforce (TASK-771) Flip golangci-lint-action's only-new-issues from true to false so CI fails on ANY linter finding, not just findings on PR-changed lines. This catches lint regressions on the next push instead of letting them drift into main. The gate flip is paired with a deliberate scope-down of .golangci.yml: 1. errcheck is disabled. The codebase has 325 pre-existing unchecked- error sites where the error is intentionally discarded (best-effort logging writes, defensive parses with zero-valued fallbacks, etc.). Auditing every site is its own project — bigger than IDEA-732 by an order of magnitude. Tracked as a follow-up if/when we want the safety net back. 2. staticcheck is restricted to the SA* check family (real-bug detectors). The ST*/QF*/S* families are stylistic/quick-fix suggestions we don't gate CI on yet — they would have re-flooded the lint output with capitalized error strings, De Morgan's law simplification suggestions, etc., that aren't bug-finding signals. Re-enable selectively if the team wants them. After scoping, the live linters are: govet, ineffassign, staticcheck (SA*), unused, gofmt — exactly the set that IDEA-732 cleaned up. Other changes in this PR: - Drop pull-requests:read permission. It was only required by the golangci-lint-action when only-new-issues=true (the action used it to fetch PR diff metadata). Not needed any more. - Update the Run-golangci-lint comment block to explain the new policy and reference the IDEA-732 cleanup PRs (#247/#249/#251/#252). - Replace the SA4017 //lint:ignore directive in cmd/pad/main.go:4631 with an inline //nolint:staticcheck — the multi-line //lint:ignore block was too far from the if statement for staticcheck's proximity rule, so the directive wasn't taking effect. - Apply gofmt -w on three files where post-deletion blank-line artifacts had drifted (cmd/pad/main.go imports, two trailing newline fix-ups in handlers_items.go and middleware_ratelimit.go). Verified: - `golangci-lint run ./...` reports 0 issues. - `go build ./...` clean. - `go vet ./...` clean. - `go test ./...` all pass. Parent: PLAN-644. * chore: address Codex round 1 on PR #253 (TASK-771) Two LOWs from Codex on the gate-flip PR: 1. //nolint:staticcheck was broader than necessary (suppressed any future staticcheck diagnostic on the line) and didn't self-report when the underlying false positive gets fixed upstream. Codex suggested swapping back to a tightly-placed //lint:ignore SA4017. I tried that, but golangci-lint v2's staticcheck integration does not honour //lint:ignore the way direct staticcheck does — the directive was silently no-op'd via golangci-lint while the same directive worked when staticcheck was invoked directly. So instead of fighting the linter wrapper, sidestep the false positive entirely: rewrite the keepalive check from `strings.HasPrefix(line, ":")` to `len(line) > 0 && line[0] == ':'`. Same observable behaviour for a single-byte ASCII prefix, no suppression directive needed at all, no exposure when staticcheck eventually fixes the false positive. 2. The new lint-step comment in ci.yml said main is "clean of staticcheck SA*/U1000" — but U1000 is reported by the standalone `unused` linter in .golangci.yml, not by staticcheck.checks. Tighten the comment to attribute each enforced check correctly. Verified: - `golangci-lint run ./...` reports 0 issues - `go test ./cmd/pad/...` passes (the SSE watch loop is exercised by reconcile_test.go and the broader integration tests). |
||
|
|
dd381e1066 |
chore: delete 5 unwired document handlers (TASK-769) (#252)
* chore: delete 5 unwired document handlers (TASK-769)
internal/server/handlers_documents.go had 5 dead HTTP handlers that
were drafted as Documents-v1 extensions but never wired into the
router (server.go:509 already labels Documents itself as "v1, will be
replaced by items in Phase 2"):
- handleQuickSave (POST /documents/quick-save) — title-based upsert
- handleBulkRead (POST /documents/bulk-read) — multi-doc fetch by IDs
- handleGetBacklinks (GET /documents/{id}/backlinks)
- handleGetLinks (GET /documents/{id}/links)
- handleGetContext (GET /documents/context?type=)
Investigation confirmed zero consumers:
- Not registered in setupRouter (`grep -n "QuickSave\|BulkRead\|Backlinks\|GetLinks\|GetContext" server.go` → empty).
- Not used by the SvelteKit frontend (`web/src/`).
- Not used by the CLI (`internal/cli/`).
- Pre-launch repo, no fork or downstream that could be relying on them.
Delete scope is intentionally limited to the HTTP handlers. The
underlying `Store.QuickSave / BulkRead / GetBacklinks / GetLinks /
GetContext` methods stay — they're tested at the store level
(internal/store/store_test.go) and preserve optionality if Phase 2
work needs to revive any of these features. `models.QuickSave` stays
for the same reason.
After this lands, IDEA-732's lint catalog is fully cleared on main
(staticcheck SA* + U1000 returns zero). TASK-771 (flip CI
only-new-issues=false) becomes safe.
Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean
- All `import "strings"` etc. still used elsewhere in file
Parent: PLAN-644.
* chore: also delete now-test-only document store helpers (TASK-769)
Codex round 1 on PR #252 flagged that the document-store helpers
retained for "Phase 2 optionality" are now exclusively kept alive by
their own store tests — Store.QuickSave, BulkRead, GetBacklinks,
GetLinks, GetContext are not called by any production code path after
the handler deletions in the previous commit. Same for the
models.QuickSave struct.
Pre-launch with no external consumers, optionality preservation has a
real cost (dead code on main, ongoing test maintenance). When Phase 2
needs any of these capabilities it is cheaper to re-derive them
against the Items model than to drag dead Documents-v1 plumbing
forward. So delete them now.
Removed:
- internal/store/documents.go: QuickSave (38 lines), BulkRead (28),
GetBacklinks (15), GetLinks (28), GetContext (41).
- internal/models/document.go: QuickSave struct.
- internal/store/store_test.go: TestQuickSave (38 lines), TestBulkRead
(16), TestDocumentLinking (29), TestContext (23).
Kept:
- TestDocumentLinkRename — exercises UpdateDocument's internal
link-rewriting path, not any of the deleted helpers.
- GetDocumentByTitle — still used by TestDocumentLinkRename.
- The full CRUD/restore handlers and their store methods — these are
still wired into setupRouter and have their own coverage.
Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (TestDocumentLinkRename and the wider doc
CRUD/version/activity tests still cover the surviving paths).
- `staticcheck -checks "SA*,U1000" ./...` clean
- No new unused imports introduced (links package is still used by
documents.go for ReplaceTitle in UpdateDocument).
Parent: PLAN-644.
* chore: drop GetDocumentByTitle and refactor TestDocumentLinkRename (TASK-769)
Codex round 2 caught the chain — after deleting QuickSave/BulkRead/
GetBacklinks/GetLinks/GetContext, Store.GetDocumentByTitle was kept
alive by exactly one test (TestDocumentLinkRename), which was
re-fetching by title only because the test ignored the *Document
already returned by createTestDoc.
Use the createTestDoc return value instead, then drop GetDocumentByTitle
from the store. Same idea, cleaner test, one fewer test-only API on
the store. The rename behaviour (the actual thing under test) is
unchanged.
Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` still clean
Parent: PLAN-644.
* chore: drop now-orphaned links.Extract (TASK-769)
Codex round 3 caught the next link in the chain: after Store.GetLinks
was deleted, links.Extract had no remaining callers — links.ReplaceTitle
is the only Extract-package function still used (by UpdateDocument's
rename rewrite). The linkPattern regex was only used by Extract.
Drop linkPattern, the regexp import, and Extract itself. Leaves
ReplaceTitle and its private string helpers (replaceAll, indexOf)
intact.
The cleanup chain ends here: ReplaceTitle is still wired into a live
production path (Documents-v1 rename), and the supporting helpers
have no other roles to inherit.
Verified:
- `go build ./...` clean
- `go test ./internal/store` and `./internal/server` pass
- `staticcheck -checks "SA*,U1000" ./...` clean
Parent: PLAN-644.
|
||
|
|
3c3251f5af |
chore: drop dead visibility-filter block in dashboard handler (TASK-765) (#251)
* chore: drop dead visibility-filter block in dashboard handler (TASK-765)
internal/server/handlers_dashboard.go had a 10-line block that built a
visibility-filtered `filtered` collections slice and reassigned it back
to `collections` — but `collections` was never read after the
reassignment, so the filtering had no effect. Staticcheck flagged it
as SA4006 (line 220) + SA4010 (line 217) — same dead block, two
findings.
Investigation confirmed this is leftover refactor scaffolding rather
than a missing-filter bug:
- The summary section (line 236) uses `allItems` from
`ListItems(workspaceID, {CollectionIDs: dashCollIDs, ItemIDs: dashItemIDs})`.
- Active plans, attention items, suggestions all use the same
`dashCollIDs`/`dashItemIDs` parameters plus inline
`isCollectionVisible(child.CollectionID, visibleIDs)` checks.
So visibility is already correctly applied to every dashboard output —
through the `dashCollIDs` / `dashItemIDs` path established earlier at
lines 185-190 — not through the deleted block. The previous comment
("drives the collection-summary section") was misleading; the summary
section reads items, not the `collections` slice.
Replace the dead block with an inline note explaining where visibility
*actually* gets applied so the next reader doesn't reach for the
filter pattern again.
Verified:
- `go build ./...` clean
- `go test ./internal/server/...` all pass (Dashboard tests cover this
path).
- `staticcheck -checks "SA4006,SA4010" ./...` clean
Parent: PLAN-644.
* docs: tighten dashboard visibility comment per Codex round 1 (TASK-765)
Codex review on PR #251 flagged that my replacement comment claimed
dashCollIDs/dashItemIDs is THE filter path for all dashboard outputs,
but for graph-walking outputs (plan progress, blocked attention,
suggested next) final visibility actually comes from a combination of
the ListItems-param filtering and per-item isCollectionVisible /
isItemVisibleToGuest checks.
Tighten the comment to call out both layers so future readers know
the canonical answer is "dashCollIDs/dashItemIDs PLUS per-item
visibility checks", not "dashCollIDs/dashItemIDs alone".
The MEDIUM finding (dashboard visibility tests don't exercise the
filter path) is a pre-existing coverage gap, not a regression from
this PR. Tracked as its own follow-up task under PLAN-644 rather than
expanding the scope of this cleanup. See the PR body for the link.
|
||
|
|
bf5ab5b366 |
chore: clear staticcheck SA + U1000 findings on main (TASK-764) (#249)
* chore: clear cosmetic staticcheck findings (TASK-764)
Apply zero-behavior-change fixes for 8 staticcheck findings on main:
- SA4023 cmd/pad/main.go:431 — drop always-true `if eventBus != nil`
guard. eventBus is wrapped in metrics.NewInstrumentedBus a few lines
above, which returns a concrete *InstrumentedBus that is never nil.
- SA1019 cmd/pad/main.go:3926 — replace deprecated strings.Title with
golang.org/x/text/cases.Title(language.English).String. golang.org/x/text
was already an indirect dep; now promoted to direct.
- SA4031 internal/server/handlers_changes.go:130 — delete dead
`if updatedItems == nil { ... }` block. make([]T, n) always returns
non-nil; the JSON marshalling already produced [] not null.
- SA9003 cmd/pad/init.go:351 — delete empty if branch and fold its
intent into the surrounding comment.
- SA9003 internal/server/handlers_dashboard.go:125 — replace empty
`if err == nil { ... }` branch with `_ = json.Unmarshal(...)` to
match the sibling settings parse and document the best-effort intent.
- SA4006 internal/cli/format.go:153 — drop the dead initial
`titlePart := item.Title` (overwritten in both branches below);
declare titlePart with `var` instead.
- SA4006 internal/store/workspaces.go:70 — drop the dead first call
to s.uniqueSlug; only the workspace-specific uniqueWorkspaceSlug
is meaningful (workspace slugs are globally unique, not workspace-
scoped like collection/item slugs).
- SA4000 internal/store/store_test.go:99 — remove always-true outer
`if idx := len(connStr) - len(connStr); idx >= 0` and unindent the
inner '?' query-string split.
go.mod side effects from `go mod tidy` under Go 1.26: golang.org/x/text
moves to direct (used directly now); pquerna/otp, prometheus/client_*
and trustelem/zxcvbn move from indirect to direct (they were already
used directly — Go 1.26's tidy correctly classifies them).
Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (including the replaceDBName test path)
- `staticcheck -checks "SA1019,SA4000,SA4006,SA4023,SA4031,SA9003"` clean
except for handlers_dashboard.go:221 (SA4006, dashboard visibility-
filter dead block — handled in TASK-765)
Parent: PLAN-644.
* fix: clear SA5011 nil-deref in buildReconcileFindings (TASK-764)
extractItemStatus(item.Fields) on the first line of the function would
have panicked on a nil item before the `if item != nil && item.CodeContext
== nil` guard could fire. Staticcheck SA5011 flagged the inconsistency.
Drop the (item != nil) half of the guard — the function now documents
its non-nil contract in the doc comment. All callers (reconcile.go:204
plus three sites in cmd/pad/reconcile_test.go) already pass non-nil,
so this is documentation, not behaviour change.
Verified:
- `go build ./...` clean
- `go test ./cmd/pad/...` passes (the existing reconcile tests cover the
contract)
- `staticcheck -checks SA5011 ./...` clean
Parent: PLAN-644.
* chore: silence SA4017 false positive in watchCmd SSE loop (TASK-764)
cmd/pad/main.go SSE keepalive branch:
if strings.HasPrefix(line, ":") {
continue
}
Staticcheck SA4017 reports "HasPrefix doesn't have side effects and
its return value is ignored" — but the return value IS used as the
if condition. Two sibling strings.HasPrefix calls earlier in the same
for-loop body (matching "event: " and "data: " prefixes) are not
flagged, which strongly suggests an SSA-analysis quirk specific to
this branch rather than a real defect.
Suppress the finding with a //lint:ignore directive that explains
the false positive in-place. Rewriting to a different form (extract
to a bool var, comma-OK on a synthetic value, etc.) would be uglier
than the suppression comment.
Verified:
- `staticcheck -checks SA4017 ./...` clean
- `go build ./...` clean
Parent: PLAN-644.
* chore: delete dead code flagged by U1000 (TASK-764)
Pre-launch (no external contributors yet) — no consumer fork can be
relying on these unreferenced symbols, so we delete them rather than
carry the maintenance burden into v1.
## Helpers (14 functions, 1 type)
cmd/pad/main.go
- progressBar — never called
internal/cli/format.go
- stripHTMLTags — never called
internal/server/handlers_dashboard_test.go
- updateItem (test helper) — never called from any test
internal/server/handlers_items.go
- publishItemEvent — wrapper over publishItemEventWithName; all 5 call
sites use the *WithName variant directly.
- resolveRelationFields — never called.
- resolveRelationFieldFiltersForWorkspace, resolveRelationFieldFilters,
relationFilterKeys, resolveRelationFilterValue — closed loop of dead
helpers (each one only called by another dead one in the family).
- extractStatus — never called (cmd/pad/reconcile.go has its own copy).
internal/server/handlers_versions.go
- handleGetDiff (HTTP handler) — never wired into setupRouter.
- diffsToChanges, diffChange (type) — only used by handleGetDiff above.
- Removes now-unused imports `strconv` and `dmp` (sergi/go-diff).
internal/server/middleware_ratelimit.go
- writeTooManyRequests — never called; the live ratelimit middleware
uses a dedicated 429 path with Retry-After-Bucket headers.
internal/server/server.go
- guestVisibleItemIDs — never called. handlers_events.go had a
comment cross-reference; updated to drop the reference.
## Constants
internal/events/redis_bus.go
- reconnectDelay — never read.
internal/store/api_tokens.go
- defaultTokenExpiryDays — never read.
## Out of scope
The 5 unwired handlers in internal/server/handlers_documents.go are
left alone: they are the subject of TASK-769 (a product decision —
wire up vs. delete — that may want different treatment per handler).
The two SA4006/SA4010 findings on internal/server/handlers_dashboard.go
visibility-filter block are similarly left for TASK-765.
## Verified
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean except the two TASK-
765 / TASK-769 follow-ups noted above.
Parent: PLAN-644.
* docs: correct caller name in buildReconcileFindings doc (TASK-764)
Codex round 1 caught: the doc comment named the caller `reconcileSingle`
but the actual function is `reconcileItem` (cmd/pad/reconcile.go:204).
Fix the contract comment so it doesn't go stale on the first git blame.
|
||
|
|
157ca4e88f |
chore: bump Go toolchain to 1.26 (TASK-763) (#247)
* chore: bump Go toolchain to 1.26 (TASK-763) Bump Go from 1.25 to 1.26 across all toolchain pins: - go.mod — go 1.25.0 → go 1.26.0 - Dockerfile — golang:1.25-alpine → golang:1.26-alpine - .github/workflows/ci.yml — three setup-go steps (Go, Go-Postgres, E2E jobs) - .github/workflows/release.yml — release pipeline No `toolchain` directive: the repo is pre-launch with no external contributors yet, so we set the floor where we want it (hard requirement). Verified locally before commit: - golangci-lint v2.11.4 builds and runs under Go 1.26.2 (pinned in CI) - golang:1.26-alpine and 1.26.2-alpine images present on Docker Hub - go build ./... clean - go vet ./... clean - go test ./... all pass Parent: PLAN-644 (OSS Repo Hygiene and Launch Polish). * chore: gofmt -w under Go 1.26 (TASK-763) Apply Go 1.26's gofmt to the codebase. ~41 files reformatted, all struct-tag whitespace realignment — no semantic changes. Verified: - gofmt -l ./cmd ./internal returns empty after - go build ./... still clean - go test ./... still passes (run before commit) Bundling the gofmt diff with the toolchain bump in the same PR because the formatting drift is a direct consequence of moving from 1.25 to 1.26; splitting them creates a mandatory two-PR ordering for no value. Parent: PLAN-644. * docs: bump documented Go floor to 1.26 (TASK-763) Match go.mod's hard 1.26.0 requirement in the source-build instructions. Caught by Codex review round 1 on PR #247. - README.md:158 — "Go 1.25+" → "Go 1.26+" - CONTRIBUTING.md:9 — "Go 1.25+" → "Go 1.26+" |
||
|
|
f58290272f |
fix(web): persistent low-opacity expand tabs for hidden sidebar/topbar (TASK-762) (#246)
Implements IDEA-757.
⌘\ toggles BOTH the sidebar and the topbar at once. When they go hidden,
the only on-screen affordances to bring them back are the .topbar-expand-btn
and .sidebar-expand-btn tabs, which were styled `opacity: 0` at idle and
only became visible on `:hover` of the parent container. A user who hits the
shortcut accidentally and stares at a now-mostly-empty screen sees no
affordance at all.
Bump idle opacity to 0.5 on both expand tabs so the affordance is always
faintly visible. Hover amplification to 1 (existing) is unchanged. The
tooltips on the tabs ("Show workspace bar (⌘\)" / "Open sidebar (⌘\)") now
become discoverable, teaching the shortcut on first encounter.
CSS-only change.
|
||
|
|
441f624584 |
feat(web): mobile navbar workspace switcher always present, preserve sidebar state on switch (TASK-761) (#245)
* feat(web): mobile workspace switcher always present, preserve sidebar state on switch (TASK-761)
Implements IDEA-760.
- web/src/routes/+layout.svelte: replace the mobile-header workspace-name link
with <WorkspaceSwitcher mobile /> so the switcher is reachable from both
sidebar states. Add `.mobile-switcher-slot` to flex-fill the gap next to the
hamburger; drop the now-unused `.mobile-title` rules.
- web/src/lib/components/layout/WorkspaceSwitcher.svelte: drop uiStore.onNavigate()
from select() so workspace switching no longer collapses the mobile sidebar —
the user's sidebar state carries over to the new workspace per IDEA-760. Add
same-workspace dashboard parity (mirrors TopBar.handleWsClick) so tapping the
current workspace still gives a one-tap path back to the dashboard.
openCreateModal() retains its uiStore.onNavigate() — separate modal-overlay UX.
* fix(web): tighten WorkspaceSwitcher dashboard URL + a11y on switcher trigger
Codex P2 + nit follow-up to TASK-761:
- WorkspaceSwitcher.select(): same-workspace dashboard branch now reads
owner_username from workspaceStore.current rather than ws.owner_username
(which is typed optional). When isCurrent is true `current` is non-null and
shares the slug, so its owner_username is guaranteed present. Avoids the
edge case where a caller passing a workspace without owner_username would
produce `//slug` (scheme-relative URL) instead of an in-app path.
- WorkspaceSwitcher trigger: add aria-haspopup="menu", aria-expanded={open},
and aria-hidden on the chevron glyph so screen readers get the menu
semantics + open/closed state on the new primary mobile navbar control.
* fix(web): aria-haspopup type matches actual popup (dialog mobile, menu desktop)
Codex follow-up nit on TASK-761: the WorkspaceSwitcher trigger advertised
aria-haspopup="menu" unconditionally, but on mobile the popup is a
role="dialog" BottomSheet and on desktop it's a dropdown of buttons.
Make the hint match the actual surface by deriving from isMobile.
* fix(web): drop aria-haspopup on desktop WorkspaceSwitcher popup
Codex follow-up nit on TASK-761: the desktop popup is a plain dropdown
<div> of buttons without role=menu/menuitem or arrow-key keyboard nav,
so aria-haspopup="menu" overstated the semantics. Mobile keeps
aria-haspopup="dialog" because that branch genuinely renders a
role="dialog" BottomSheet. Desktop falls back to aria-expanded alone,
which is sufficient for "button toggles a popup" without claiming
specific popup type semantics that aren't backed by roles.
|
||
|
|
8346f9348e |
feat(web): replace desktop navbar scroll with overflow menu (TASK-759) (#244)
* feat(web): replace desktop navbar scroll with overflow menu (TASK-759) The desktop top bar's workspace list previously used `overflow-x: auto` with a hidden scrollbar — workspaces past the visible edge were reachable only by horizontal scroll, with no visual cue that anything was hidden. Mobile already solved this via BottomSheet (TASK-637); desktop never got the equivalent. This change implements a "priority+" overflow pattern in TopBar.svelte: - Pills are measured in a hidden ghost row keyed by slug. - A ResizeObserver tracks the visible container's width. - Pills that don't fit move into a `…` overflow menu anchored under the trigger. The active workspace is pinned to the visible row regardless of fit position so the "you are here" cue is never hidden. - The trigger is always rendered (with `visibility: hidden` when empty) to prevent layout oscillation as workspaces are added or removed. Drag-and-drop works to and from the overflow menu on day one. Three dndzones share `type: 'topbar-workspace'`: the visible row, the menu, and the trigger as a single-slot drop target. A 400 ms spring-loaded auto-open lets the user drag onto the trigger and place the dropped item at a precise position inside the menu. Dropping on the trigger without waiting appends to overflow. Active is rejected from overflow finalize and snapped back to visible. Persistence reuses the existing `api.workspaces.reorder()` path. Both zones' finalize events are coalesced into a single persist via queueMicrotask. A 1s `dropCooldown` prevents store→local sync from fighting the just-written order, mirroring BoardView's pattern. Mobile (≤640px) is unchanged — still uses WorkspaceSwitcher BottomSheet. Spec: IDEA-758. * fix(web): address Codex review round 1 (TASK-759) Per Codex review on PR #244, round 1: HIGH — Drop active onto `…` trigger silently dropped active from the persisted order. handleTriggerFinalize stripped active from droppedSafe without restoring it to visibleZone, so persistGlobalOrder rebuilt fullOrder = visibleZone + overflowZone with active missing from both. Now both rejection paths (overflow zone and trigger zone) reset all zones from the un-mutated propVisible/propOverflow derived split and cancel the queued persist via cancelPersist(). MEDIUM — Active-pin rejection in the overflow zone snapped active to the END of visible instead of restoring its original position. Same fix as above — reset from the derived split, which preserves sort order. MEDIUM — Failure rollback was hidden by dropCooldown for ~1s. The catch block now also clears the cooldown timer, immediately resyncs zones from the restored derived split, and unblocks the sync effect. MEDIUM — dropCooldown setTimeouts stacked. Track a single cooldownTimer, clearTimeout it on each new write, and cancel on rollback. MEDIUM — A single long active-workspace name could blow past the bar because active is pinned visible. Cap `.workspace-name` at max-width 200px with ellipsis inside `.workspace-list` and `.workspace-ghost` (not in the overflow menu — full names read better there). LOW — Lost the "click current workspace → workspace dashboard" override during the click-handler refactor. The pre-PR onclick branched on `ws.slug === currentSlug`. Restored. LOW — Pending springLoadTimer / cooldownTimer would survive component destroy. Added an $effect cleanup that cancels both on unmount. * fix(web): address Codex review round 2 (TASK-759) HIGH — Active-pin rejection only worked when the target zone's finalize fired AFTER the source's. svelte-dnd-action does not guarantee the order, so when handleVisibleFinalize ran AFTER handleOverflow/Trigger finalize, it overwrote the freshly-restored visibleZone with its own post-drag items (which excluded active). Added a `dragRejected` flag: target-zone rejection sets it, handleVisibleFinalize early-returns if set so the reset isn't clobbered. Cleared at the start of every consider event so it doesn't bleed across drags. MEDIUM — Cooldown timer race: a prior persist's pending timer was only cleared AFTER awaiting the new persist's reorder/load, so it could fire mid-request and flip dropCooldown false while a newer write was still in flight. Cleared the prior timer at the start of persistGlobalOrder (before the await) instead. * fix(web): address Codex review round 3 (TASK-759) MEDIUM — persistCancelled could leak past a rejected active-pin drag. On pointer DnD svelte-dnd-action finalizes the target zone BEFORE the source. In that order, cancelPersist() runs in the rejection handler when no microtask was queued (the source's schedulePersist hadn't fired yet), then handleVisibleFinalize early-returns on dragRejected without scheduling. The flag was left set, so the next legitimate reorder was silently dropped. Fixed by clearing persistCancelled at the start of schedulePersist — each new schedule begins from a clean slate, regardless of what stale state a prior rejection may have left. |
||
|
|
2c59bfa925 |
fix(web): wire desktop topbar workspace switching through last-route restore (#243)
* fix(web): wire desktop topbar workspace switching through last-route restore (TASK-754 follow-up)
The TASK-754 restore logic only fired from WorkspaceSwitcher.svelte
(used on mobile). On DESKTOP, the workspace switcher is the topbar's
horizontal workspace icon list, which used plain `<a href>` links to
`/{owner}/{slug}` — bypassing restore entirely and silently
overwriting the workspace's saved deep route on every left-click.
Symptom (reported by user): "navigate to a deep page → storage updates
to that page → navigate to another workspace → saved value sticks →
click back via topbar → lands on dashboard, and the saved value gets
overwritten back to dashboard."
Fix:
- Extract the validation+pickup logic into a pure helper at
`web/src/lib/utils/workspace-route.ts` (`workspaceRestoreTarget`).
- WorkspaceSwitcher.svelte's `select()` now delegates to the helper
(no behavior change on mobile).
- TopBar.svelte intercepts plain left-click on each workspace `<a>` to
goto the restore target. `href=` stays pointed at the dashboard so
modifier-clicks (cmd/ctrl/shift/alt) and middle-click still open a
fresh dashboard in a new tab.
Other workspace nav surfaces are left alone on purpose:
- Sidebar Dashboard nav item, mobile-header workspace name, and
/console workspace cards are not "switchers" — semantically they're
Home/breadcrumb/picker navigation that should always land on the
dashboard.
Parent: IDEA-753.
* fix(web): clicking current workspace in topbar goes to dashboard
When the user clicks the workspace they're already in, override the
last-route restore and go straight to the dashboard. Gives users a
way back to the workspace home from any nested route. Clicking a
different workspace still restores its last-visited route.
|
||
|
|
1ee3e5d725 |
feat(web): persist + restore page scroll on collection re-entry (TASK-755) (#241)
* feat(web): persist + restore page scroll on collection re-entry (TASK-755)
Page-level scroll position is now persisted (debounced 200ms) on the
collection list/board/table view, keyed by
'pad-last-scroll-{wsSlug}-{pathname+search}', so the workspace switcher
(TASK-754) brings the user back not just to the same URL but to the
same scroll offset.
Restore semantics:
- Triggers once after data hydrates (loading=false, items present).
- Gate is keyed by pathname (NOT pathname+search), so in-page filter
toggles via replaceState do not re-restore — that would teleport the
user away from where they're currently scrolling. Sidebar nav to a
different collection and back DOES re-restore.
- Top-of-page (scrollY=0) clears the entry to keep storage tidy.
- Two RAFs before scrollTo so layout settles after items render;
behavior is 'instant' (this is a positional restore, not a UX jump).
Out of scope:
- Board view's internal '.board-view' horizontal scroll and per-column
'.column-cards' vertical scroll. BoardView would need to expose
scroll refs; deferred. Page-level vertical scroll still applies and
covers list and table (the dominant views).
Implements IDEA-753.
Parent: IDEA-753.
* fix(web): scroll save race + restore-gate stuck state per Codex review (round 1)
Round 1 Codex findings (TASK-755):
- HIGH: scheduleScrollSave() captured scrollKey at timer fire time, not
scroll-event time. If the user scrolled on URL A then changed
filters/view (replaceState) within the 200ms debounce window, the
pending timer would write A's scroll-y under B's URL key. SvelteKit's
auto-scroll-to-top on real navigations could also clobber a stored
entry by writing y=0 before the restore effect ran.
Fix: capture `key` and `y` synchronously inside scheduleScrollSave
before setTimeout, gate saves on `scrollRestoredFor === scrollGateKey`
(no save until restore has had its window), and clearTimeout the
pending save in onDestroy so a debounced write can't fire post-unmount.
- MEDIUM: The once-per-pathname gate only advanced when a real restore
attempt was made (filteredItems.length > 0). Visiting an empty/error
collection between two visits to A left scrollRestoredFor stuck on
A's gateKey, so re-entry to A would skip restore.
Fix: separate $effect that resets scrollRestoredFor whenever
scrollGateKey changes (CONVE-606 — kept its own clean dep list).
Parent: IDEA-753.
* fix(web): cross-key flush + RAF restore guard per Codex review (round 2)
Round 2 Codex findings (TASK-755):
- LOW: A single shared debounce timer with cross-key cancellation lost
the user's last position on collection A when they navigated to and
scrolled on collection B within the 200ms debounce window — the new
scheduleScrollSave() cleared A's timer to start B's, so A never
flushed. Note: [collection] param changes reuse the same +page.svelte
instance, so onDestroy doesn't fire between them.
Fix: track pending (key, y) explicitly. When scheduleScrollSave is
called with a key different from the pending one, FLUSH the prior
pending save before reseating the timer. Same flush also runs from
onDestroy so the final position survives unmount.
- LOW: The restore effect's queued requestAnimationFrame had no
still-on-the-same-gate check before calling window.scrollTo. A fast
follow-up navigation between effect-run and RAF-fire could scroll the
NEW page to the OLD saved offset (visible jump, even though the save
gate now prevents persistence).
Fix: capture expectedGate = scrollGateKey in the closure; verify
scrollGateKey === expectedGate inside the inner RAF before scrolling.
Parent: IDEA-753.
* fix(web): cancel queued restore RAF on unmount per Codex review (round 3)
Round 3 Codex finding (TASK-755):
- LOW: The expectedGate guard at the inner restore RAF only catches
same-instance gate changes. Once the component is destroyed (e.g.
fast cross-route nav), scrollGateKey settles at its last computed
value inside the closure, so the check passes and window.scrollTo
fires on the next page.
Fix: track the RAF id (scrollRestoreRAF) and cancelAnimationFrame on
onDestroy. Cleared inside the inner RAF too so a successful run
doesn't leave a stale id around.
Parent: IDEA-753.
* fix(web): include showArchived in scroll key per Codex review (round 4)
Round 4 Codex finding (TASK-755):
- LOW: showArchived changes the fetched dataset but isn't synced to the
URL — saving a scroll position while archived view was on would later
be reapplied to the non-archived view, landing the user at an
unrelated/clamped offset.
Fix: append '|archived' to scrollKey when showArchived is true so the
archived and non-archived views maintain separate scroll entries.
showArchived is not added to scrollGateKey on purpose: toggling
archive within a page is a filter-like action, and re-restoring on
every toggle would teleport the user (same rationale as not gating
on pathname+search).
Parent: IDEA-753.
* fix(web): early gate-mark + RAF re-validate scrollKey per Codex review (round 5)
Round 5 Codex findings (TASK-755):
- LOW: The restore effect bailed on filteredItems.length === 0 BEFORE
marking scrollRestoredFor. If the user landed on an empty collection
/ over-restrictive filter and items later appeared on the same
pathname (e.g. user creates an item, or a filter toggle that produces
items but doesn't change the gate-key), the restore would fire as a
surprise teleport.
Fix: set scrollRestoredFor = scrollGateKey BEFORE the empty-items
short-circuit. Empty-state visits still 'consume' the gate so later
items don't re-trigger restore.
- LOW: The queued RAF re-checked scrollGateKey but not scrollKey. A
filter/archive toggle changes scrollKey without changing scrollGateKey
(filters share the same pathname-only gate), so a queued restore
could scroll to the previous filter combo's offset on the new view.
Fix: also re-check scrollKey === expectedKey inside the inner RAF
before scrollTo.
Parent: IDEA-753.
|
||
|
|
b999a7aaee |
feat(web): restore last-visited route on workspace switch (TASK-754) (#240)
* feat(web): restore last-visited route on workspace switch (TASK-754)
The workspace switcher previously always landed on the dashboard. Now
the workspace +layout writes the current pathname to localStorage on
every navigation (keyed by `pad-last-route-{wsSlug}`), and the
switcher reads that key on click and routes there instead — falling
back to the dashboard on miss, storage error, or any saved path that
doesn't belong to the target workspace (guards username changes,
corrupt entries, cross-workspace bleed).
Storage layer:
- Per CONVE-606, the persistence is its own $effect with a clean
dependency list (wsSlug + pathname) — combining with the title
sync above would re-run on async workspace-name resolution.
- Storage failures (private mode, disabled storage) swallowed; the
feature degrades to the previous dashboard-only behavior.
UX:
- Direct Dashboard navigation (sidebar + mobile header use plain
`<a href>` to the workspace root) is unaffected — only the
switcher takes the last-route path.
- Initial page load is unchanged (URL-driven).
- Stale targets (deleted item) take the user to the existing 404
surface; subsequent navs overwrite the bad entry.
Implements IDEA-753.
Parent: IDEA-753.
* fix(web): persist query string + clear cache on item-fetch error per Codex review (round 1)
Round 1 Codex findings (TASK-754):
- MEDIUM: Storing only `pathname` dropped URL-carried collection state
(?view, ?sort, ?group-by, filters, ?q). Now persist
`pathname + search`. Switcher splits on '?' before validating the
path-portion against the target workspace prefix.
- LOW: A restored route to a since-deleted item became a sticky
re-entry target — the leaf page renders an inline error and the
+layout effect re-saves the same dead URL on every visit. Now the
item-detail catch path clears `pad-last-route-{wsSlug}` so the next
switcher click falls back to the dashboard. The cache repopulates
on the user's next nav.
Parent: IDEA-753.
* fix(web): stale-request guard + path canonicalization per Codex review (round 2)
Round 2 Codex findings (TASK-754):
- LOW: The item-page catch path cleared 'pad-last-route-{wsSlug}' with
no stale-request guard. If the user opened a deleted item then
navigated away in the same workspace before the fetch rejected, the
+layout effect would save the new valid route first, then the old
rejected catch would clobber it. Now we capture (username, wsSlug,
collSlug, itemSlug) at loadData entry and only clear the cache if
its current value still points at THAT failed URL. Comparison
strips ?query / #hash before checking.
- LOW: WorkspaceSwitcher's split-on-'?' prefix check could be bypassed
by encoded traversal (e.g. /owner/ws/%2e%2e/other?q=1) — passes
startsWith(fallback + '/') textually but goto() normalizes outside
the workspace path. Now we canonicalize via URL(saved, origin) and
require: same origin, workspace prefix on the normalized pathname,
and no '/..' / '/./' / '//' / percent-encoded chars in the path
(the app never generates any of those).
Parent: IDEA-753.
|
||
|
|
7e56b20d0c |
fix(store): eliminate spurious SQLITE_BUSY on concurrent writes (#239)
* fix(store): eliminate spurious SQLITE_BUSY on concurrent writes
`pad item update --comment ...` (and any concurrent write workload)
intermittently failed with `internal error` and a server log line of
`update item: database is locked (5) (SQLITE_BUSY)`. The skill's CLI
reference even documented a workaround — "use a separate `pad item
comment` call rather than --comment on update" — but that just lowered
the contention probability; both call paths hit the same root cause.
Root cause
Go's default `db.Begin()` issues `BEGIN DEFERRED` on SQLite, which
takes only a SHARED lock at BEGIN time. The first INSERT/UPDATE in
the transaction tries to upgrade to a write lock — and SQLite refuses
that upgrade with SQLITE_BUSY *immediately* if any other connection
already holds the write lock. busy_timeout's wait-and-retry behavior
does NOT apply on lock-upgrade because waiting would risk deadlock
between two connections both holding SHARED locks. Net effect: under
even modest write concurrency, transactions fail in milliseconds
instead of waiting out the 5-second busy_timeout we configured.
Repro before the fix: 20 concurrent CreateItem calls produced ~4
SQLITE_BUSY errors. Under the running server, two PATCHes within a
few ms of each other (e.g. status update + activity-log write) hit
this regularly during workflow tooling like /ship-tasks.
Fix
Set `_txlock=immediate` in the DSN. Every `db.Begin()` now issues
`BEGIN IMMEDIATE`, acquiring the write lock up-front. Lock-acquisition
DOES honor busy_timeout, so concurrent writers wait up to 5 seconds
to serialize cleanly instead of failing fast. Reads are unaffected:
single-statement SELECTs don't open a transaction at the SQL layer.
Also fold `foreign_keys=on` into the DSN's `_pragma` list. FK
enforcement is per-connection in SQLite, so the previous
`db.Exec("PRAGMA foreign_keys=ON")` only configured the one
connection that received the call — every OTHER pool member ran
without FK enforcement. The DSN form applies it to every connection
the driver opens.
`journal_mode=WAL` stays as a `db.Exec` call because WAL is a
database-level setting recorded in the file header; it persists
across connections after the first one applies it.
Validation
- Reproduced the failure under the live binary: 20 concurrent PATCHes
in a tight loop produced 4 SQLITE_BUSY errors. After this change,
same workload: 0 errors.
- New regression test `TestSQLiteConcurrentWritersNoBusy` does 20
concurrent CreateItem calls and asserts zero errors. Skipped under
PAD_TEST_POSTGRES_URL (postgres has different concurrency model).
- Existing `TestConcurrentWritePerformance` benchmark now reports 0
errors at every concurrency level it tests (1, 5, 10, 25, 50
workers). Previously this benchmark was acknowledging non-zero
errors at high concurrency as expected.
- Full test suite green: go test ./... — all 14 packages pass.
* fix(store): document IMMEDIATE tradeoff + tighten regression test (Codex round 1)
Address all three findings from Codex review of #239:
MEDIUM — IMMEDIATE widens the writer critical section because update
flows now hold the write lock during diff/version-throttle reads and
slug-collision checks, not just the final UPDATE. Document this
tradeoff explicitly in the DSN comment block: the pre-fix behaviour
was "fail fast with BUSY" and the post-fix behaviour is "wait briefly
for cleanly serialized work" — strictly better. If a future hot path
produces pathologically long write transactions (>100ms holding the
lock), the right move is to narrow that specific transaction, not to
revert this fix.
LOW — Foreign-key enforcement was previously per-connection, applied
to only one pool member. Latent integrity violations in databases
written through other pool members (with FKs disabled) may now
surface as errors on the next write. Document the rollout note and
mention `PRAGMA foreign_key_check` as the diagnostic.
LOW — Tighten TestSQLiteConcurrentWritersNoBusy: the original 20×1
form gave goroutines no synchronization, so a slow CI runner could
sequentialize the work and let a regression slip through. New form
uses an explicit start gate (sync.WaitGroup acting as a barrier) so
all goroutines try to write at the same moment, plus 25 workers ×
5 ops each (125 total) so each goroutine produces several BEGIN/
COMMIT cycles. Still passes; significantly less prone to false
negatives on CI.
No code-path change beyond test tightening; the comment additions
are docstring-only.
* fix(store): use true barrier in concurrency test (Codex round 2)
Codex round 2 caught that the previous start-gate pattern wasn't a
real barrier:
var startGate sync.WaitGroup
startGate.Add(1)
for ... { go func() { startGate.Wait(); ... }() }
startGate.Done() // <-- fires before all goroutines reach Wait()
`startGate.Done()` runs as soon as the launch loop finishes, with no
guarantee the scheduler has actually run the goroutines yet. Late-
scheduled goroutines reach `startGate.Wait()` after Done() has already
fired and proceed without ever parking — so on a slow CI runner with
goroutine startup spread across tens of milliseconds, the contention
window we wanted to create simply doesn't exist, and a regressed
deferred-transaction build could quietly pass.
Switch to the standard two-WaitGroup barrier: every worker signals
"ready" via `ready.Done()` and parks on `release.Wait()`, the main
thread `ready.Wait()`s for all workers to confirm they're parked,
then `release.Done()`s to fire them all simultaneously. This
guarantees every goroutine reaches BEGIN IMMEDIATE inside the same
narrow contention window regardless of scheduler latency.
Confirmed: `go test -count=20 -run TestSQLiteConcurrentWritersNoBusy
./internal/store` — all 20 invocations green.
* docs(store): be honest about barrier imprecision + add empirical proof (Codex round 2)
Codex round 2 noted the two-WaitGroup pattern still has a small
unobservable gap between ready.Done() and release.Wait() in each
worker. That's technically correct — the barrier isn't mathematically
exact, and a worker descheduled in that gap could miss the simultaneous
release. The previous comment overstated the guarantee by calling it
a "TRUE barrier".
Soften the comment to acknowledge the gap honestly, AND back the test
with empirical proof: with `_txlock=immediate` removed from the DSN
this test reliably FAILS (22/125 errors per run, all SQLITE_BUSY).
With the fix in place, 20 consecutive `go test -count=20` invocations
all pass. So the small theoretical imprecision in the barrier doesn't
impair the test's regression-catching ability — the multiple-ops-per-
worker structure means even slightly-late workers still produce enough
concurrent BEGIN IMMEDIATE attempts to exercise the race.
Documentation-only commit. No code change.
* docs(store): comment-consistency cleanups (Codex round 3)
Two LOW findings, both pure doc:
1. Inline comment on `ready.Wait()` was still asserting "every worker
is parked on release.Wait()", contradicting the softened block
comment above. Change to "every worker has called ready.Done()
(best-effort gate)".
2. Block comment hardcoded "22 errors out of 125 ops per run" as
though it were a standing expectation. The exact rate is host-
and scheduler-dependent; reword as a representative observation
("a representative run on a developer laptop produced ~22 errors
...; the exact rate is host- and scheduler-dependent but
consistently >0").
No code change.
|
||
|
|
fe4ff887a0 |
fix(web): truncate long parent titles on item cards (BUG-630) (#238)
`item.parent_title` is populated by `enrichItemForResponse()` (via
`GetParentForItem()`) for both `parent` AND `implements` link types
— see `childLinkTypes` in `internal/store/items.go:17`. So when a task
implements an idea (a common pattern via the Implements relationship),
the idea's title becomes the task's `parent_title` and renders in the
`.meta-parent` chip on the item card.
That chip had `white-space: nowrap` and no width cap, so a long idea
title (e.g. an idea recorded as a full sentence — "we should add a
'pad info' cli command that provides information about the local
instance" is 89 chars) pushed the card past its column bounds on
Board view.
Fix:
- `.meta-parent`: add `overflow: hidden; text-overflow: ellipsis;
max-width: 100%; min-width: 0;` alongside the existing `nowrap`,
so the chip truncates with an ellipsis at the card-content edge.
- `.card-meta`: add `min-width: 0` so flex children with intrinsic
content wider than the card can actually shrink instead of forcing
the parent to grow.
- Template: bind a single `parentLabel` `@const` and pass it through
to a `title={parentLabel}` attribute on the chip so the full label
is still accessible via hover tooltip after truncation.
Affects both Board and List views (ItemCard is shared); the original
report focused on Board where columns are narrowest.
Verified manually on the running server with the known offending
item (`add-pad-server-info-for-local-and-remote-connection-status`
in docapp/tasks, parent IDEA-322, 89-char title): card now stays
within its column on Board view, chip truncates with ellipsis,
tooltip shows full text on hover.
Verified: web/npm run build clean, go test ./... green.
|
||
|
|
fd0ace48ff |
fix(web): long-press delay on mobile status-header drag (BUG-641) (#237)
ListView's outer dndzone for status groups was missing `delayTouchStart`, so any touch on a group header was immediately interpreted as the start of a group-reorder drag. On mobile this meant trying to scroll the page by touching a header instead grabbed the header and dragged it with the finger — the page wouldn't scroll and the user couldn't reach content below the visible status bands. Mirror the inner item dndzone's `delayTouchStart: touchDragDelayMs` (500ms) on the outer group dndzone so the same long-press gesture is required to start a group reorder. Quick taps (collapse toggle) and short touch-drags (page scroll) now pass through unmolested; the existing drag-to-reorder behaviour is preserved behind the long-press, matching what already works for items inside a group. The `touchDragDelayMs` constant (line 46) was already in scope and already used for the inner dndzone, so this is a one-line addition. Verified manually on iOS at the running server: status headers no longer hijack scroll; long-press still reorders groups; tap-to-collapse unaffected. Verified: web/npm run build clean, go test ./... green. |
||
|
|
b165e5fe7a |
fix(server): summarise structured field changes in activity feed (BUG-748) (#236)
* fix(server): summarise structured field changes in activity feed (BUG-748)
The activity-feed `metadata.changes` string is built by `diffFields()` in
`handlers_documents.go`, which used `fmt.Sprintf("%v", val)` to stringify
each old/new value. For structured fields (implementation_notes,
decision_log, or any other slice/map value in item.fields) Go's default
formatting dumps the raw map repr — e.g.
implementation_notes: → [map[created_at:2026-04-23T... details:Code audit
on 2026-04-23 found Phases 1, 2, and most of Phase 3 already implemented:
- **Phase 1a** ... created_by:user summary:Phases 1-3 verified shipped]]
Activity cards on the item detail page surfaced this verbatim, leaking
internal field shape into the UI.
Replace the bare `%v` with a `formatChangeValue` helper:
- Primitives (string/number/bool): unchanged Go default formatting.
- Slices: counted summary. Known fields get domain-specific phrasing
(`(1 note)` / `(N notes)` for implementation_notes, `(1 entry)` /
`(N entries)` for decision_log); unknown fields fall back to
`(N items)`.
- Maps/objects: `(object)` placeholder.
- nil: empty string.
This is a backend-only change. The frontend `TimelineActivityCard.svelte`
keeps splitting on `→` exactly as before, so the contract is unchanged
beyond the value-formatting.
Companion to PR #235 (frontend `.prose` class fix on TimelineCommentCard).
Together they close BUG-748 — markdown content was unrenderable both
in plain timeline comments AND in activity-feed change pills that
referenced structured field updates.
Tests: 9 new cases in handlers_documents_test.go covering primitives,
added/removed fields, implementation_notes single + plural, decision_log
single + plural, generic slice fallback, object fallback, invalid JSON,
and nil safety. All green.
Verified: go build ./..., go vet ./..., go test ./..., web/npm run build
all clean.
* fix(server): compare values, not display strings, in diffFields (Codex round 1)
Codex flagged two MEDIUM regressions in PR #236 round 1:
1. Object-valued fields (e.g. `convention`, `github_pr`) all stringify to
the same `(object)` label, so an in-place edit produced
oldStr == newStr == "(object)" and `diffFields()` silently dropped the
change from `metadata.changes` — the activity card stopped recording
that the field had been edited.
2. Same problem for slice fields when length is unchanged: replacing one
`implementation_note` with a different one (`{"summary":"original"}`
→ `{"summary":"revised"}`) collapsed both sides to `(1 note)` and
the change vanished from the activity log.
Switch the equality check from string-on-display to `reflect.DeepEqual`
on the raw decoded values. The display strings still go through
`formatChangeValue()` so the activity card stays clean (`(1 note) → (1
note)` for a same-cardinality replacement is coarse but correct — the
user knows something changed and can drill in via the timeline). For
truly identical values the entry is omitted, so no false positives.
`reflect.DeepEqual` is correct for the types `json.Unmarshal` into
`map[string]any` produces: nil, bool, float64, string, []any,
map[string]any.
New tests:
- TestDiffFieldsSameCardinalityArrayChangeStillReported
- TestDiffFieldsObjectMutationStillReported
Each also asserts the no-op case (identical input on both sides emits
nothing).
Verified: go build ./..., go vet ./..., go test -count=1 ./... all green.
|
||
|
|
190d589afe |
fix(web): render markdown in timeline comments via .prose class (BUG-748) (#235)
* fix(web): render markdown in timeline comments via .prose class (BUG-748)
TimelineCommentCard tagged comment + reply bodies with `markdown-body`,
a class with no rules anywhere in the codebase. The global
`* { margin: 0; padding: 0 }` reset in app.css then stripped list
padding, heading margins, code-block backgrounds, blockquote borders,
and table styling — so any comment containing markdown (bullet lists,
headings, fenced code, quotes) rendered as run-on text without its
visual structure.
Switch both bodies to the existing `.prose` class (same one used by
the item-detail content view), and override `max-width: none` in the
scoped style so comments still fill the timeline column instead of
shrinking to the 960px content width that .prose pins for long-form
item bodies.
Comments are sanitized through DOMPurify in renderMarkdown (TASK-647);
this change is purely styling.
Verified: web/npm run build clean, go test ./... green.
* fix(web): explicit font-family + table overflow on comment-body (Codex round 1)
Address two LOW findings from Codex review of #235:
1. `.prose` pins `font-family: var(--font-content)`. The scoped
`.comment-body, .reply-body` rule didn't override font-family, so
comments inherited the .prose font. Currently identical to --font-ui,
but make the relationship explicit (`font-family: inherit`) so a
future divergence between --font-ui and --font-content doesn't
silently change comment typography.
2. `.prose table { width: 100% }` plus padded cells can produce a wider-
than-column table inside the indented `.reply-card` (which sits
inside `.replies` with an extra padding-left + border-left, so its
inner width is significantly narrower than a top-level comment).
Add `overflow-x: auto` to .comment-body/.reply-body so wide tables
scroll horizontally instead of overflowing the card.
Verified: web/npm run build clean, go test ./... green.
|
||
|
|
7478d013cb |
feat(auth): surface ?error= and ?linked= on login + settings (TASK-741) (#234)
* feat(auth): surface ?error= and ?linked= on login + settings (TASK-741)
Before this change, pad-cloud's OAuth redirects with ?error=... and
?linked=... query params were silently ignored. A user who unlinked
GitHub and then hit "Sign in with GitHub" would land on a clean form
with no explanation of why their OAuth didn't work — classic silent
failure.
### Login page (/login)
- readOAuthErrorFromQuery() parses ?error= and the optional ?provider=
hint on mount.
- Five recognised codes map to actionable banners:
* oauth_provider_not_linked — the core recovery path: "That
<Provider> account isn't linked to a Pad account. Sign in with
your password below, or retry with a different account." with
"Use a different GitHub account" / "Use a different Google
account" CTAs wired to /auth/{github,google}?force=1 (shipped in
pad-cloud PR #21). If ?provider is not present, both CTAs render
so the user picks.
* oauth_failed — generic retry prompt.
* no_email — "verify your email with the provider" guidance.
* too_many_attempts — rate-limit language (no client-side Retry-After
countdown; the pad-cloud redirect doesn't carry that info).
* account_disabled — "contact an administrator", no retry.
- Unknown codes fall back to a safe generic message so a future code
never breaks the page.
- After rendering, ?error / ?provider stripped via
history.replaceState so refresh / back-button doesn't re-show.
- Dismiss button on the banner for users who want to clear it
before retrying.
### Settings page (/console/settings)
- readOAuthQueryStatus() on mount handles the three link-flow error
codes and the two success flags:
* ?linked=github / ?linked=google → providerMsg success toast
* ?error=not_logged_in → session-expired guidance
* ?error=email_mismatch → identity-mismatch fix-up
* ?error=link_failed → generic retry prompt
* Unknown → generic fallback
- Same history.replaceState cleanup.
### Why this is a beta blocker
A legit user who unlinks a provider can become silently un-loginable
with no UI path back. Shipping PLAN-645 to beta operators without
this makes every provider-unlink a support ticket.
Parent: PLAN-645. Depends on TASK-742 (?force=1, already merged) for
the "Use a different account" CTAs to actually work. Optional
?provider= hint will be a small pad-cloud follow-up (handler today
emits ?error= only).
* fix(settings): make provider msg/error live regions for screen readers (Codex round 1)
Addresses PR #234 Codex MEDIUM. The settings page's provider-section
banners (providerMsg / providerError) were plain <p> elements, so the
readOAuthQueryStatus() result on mount was silent to screen-reader
users — unlike the login page's oauth-banner which already had
role/aria-live. Added role='status' + aria-live='polite' to the
success element and role='alert' + aria-live='assertive' to the
error element so both get announced on mount and on subsequent
unlink/link form actions.
|
||
|
|
3f58e0badc |
feat(billing): plan comparison matrix on /console/billing (TASK-712) (#233)
* feat(billing): plan comparison matrix on /console/billing (TASK-712) Replaces the single-column Usage section with a side-by-side Free vs Pro comparison table. Before: users saw their own plan's limits but had no visible reason to upgrade — the Upgrade CTA linked to checkout without any explanation of what Pro actually changes. Now: every field from PlanLimits is rendered for both tiers in one table, the current plan's column is highlighted, and a secondary Upgrade CTA lives directly beneath the comparison for Free users. Changes on /console/billing: - PlanLimits interface extended with webhooks + automated_backups so the UI renders every field the server advertises (DefaultFreeLimits and DefaultProLimits in internal/store/limits.go both expose them). - New formatBytes helper — renders storage_bytes in the natural unit (500 MB for Free, 10 GB for Pro) rather than raw byte counts. - New formatCompareCell helper — 0 → "—" (reads as "not included" for Webhooks / Automated backups on the Free tier); -1 → "Unlimited"; undefined → "…" while limits are loading; storage → formatBytes; anything else → locale-formatted integer. - Comparison table component: scoped <th> headers for accessibility, a "Current" tag next to the user's plan column, subtle accent-blue wash on every cell in the current plan's column. Rows driven by a static COMPARE_ROWS array keyed on LimitKey so TypeScript enforces that every column references a real PlanLimits field. - Mobile-friendly padding at the 480px breakpoint. Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 4 — the last bullet of the umbrella. TASK-712 can close after this lands. * fix(billing): match server's negative→unlimited, don't collapse 0, fix formatBytes boundary + badge contrast (Codex round 1) Addresses PR #233 round 1 findings: 1. formatCompareCell collapsed every 0 to "—" to read as "not included". Admin-configured plan limits are arbitrary integers (see /console/admin/settings), so a legitimate zero — "storage_bytes = 0", "workspaces = 0", "api_tokens = 0" — misrepresented as a placeholder. Removed the 0-case; zero now renders as the literal "0". "—" readability on the Free tier's 0-valued webhooks/automated_backups is a small loss compared to the correctness win. 2. formatCompareCell only treated exactly -1 as "Unlimited", but internal/store/limits.go enforces ANY negative value as unlimited (checkLimit returns Allowed=true for limit < 0). A stored -2 would behave unlimited server-side while the billing table showed "-2 B". Changed the check to "value < 0" to match server semantics. 3. formatBytes rounded at each unit tier, so values just below a unit boundary (1,048,575 bytes → "1024 KB", 1,073,741,823 → "1024 MB") overflowed the displayed value. Rewrote to use "bump" thresholds (bumpMB = MB - KB/2, bumpGB = GB - MB/2): a value that would round-display as 1024 of the smaller unit is instead shown as "1.0" of the next unit. Extracted the value/unit rendering into formatUnit() so the tier thresholds stay readable. 4. .current-tag on the comparison table header used accent-blue text on an 18%-alpha accent-blue wash, landing around 3.5-3.9:1 in either theme — below the 4.5:1 target for 0.7rem text. Switched to solid accent-blue background with #fff text, which stays comfortably above 4.5:1 across both themes. |