mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
22fce21132
* fix(web): workspace identity recovers after a failed cold load, on the condition rather than a signal (TASK-2200) Unit A of TASK-2200 — the shell brick. A cold load while the server is unreachable left the shell permanently navigation-less: the sidebar builds its links from `workspaceStore.current`, and when the server came back the board recovered (its own Retry, plus the items cache) inside a shell with no links at all. Only F5 fixed it and nothing said so. WHAT NOTHING RETRIED. `workspaces` and `current` are each acquired exactly once — `loadAll` from the root layout's per-auth-resolution attempt, `setCurrent` from an effect keyed on a workspace slug that does not change — and every other caller of either is a user action: a topbar reorder, the workspace switcher, the create-workspace modal. `setCurrent` cannot self-heal either: with an empty array it falls back to a single-workspace fetch and CATCHES the failure into `current = null`. The audit that filed this blamed `loadCollections`, which is the half that does have a recovery path. The permanent half is identity, and it is in a file the item never cites. GATED ON THE CONDITION, NOT ON `full_refresh`, and that is the measured part. The obvious home for recovery is the layout's existing `full_refresh` branch. It is the wrong one: when the server comes back, `/changes` SUCCEEDS, and because the cursor was seeded during the outage a quiet workspace answers with nothing to report — so the result is `caught_up`. The type meaning "nothing was missed" is exactly the one delivered when everything was. A `full_refresh` arrives only when `/changes` itself fails, which is the case where the server is still down and recovery cannot work anyway. `syncPostOutageResultType.svelte.test.ts` pins that reading, with a control leg proving `full_refresh` is still emitted when it should be — so if a future change makes a returning server emit it after all, the gating decision gets revisited rather than inherited. That correction also lands on this unit's own recon, which claimed collections "now recover on their own" via TASK-2921's `full_refresh` subscriber. True only when `/changes` also fails. So the collection list gets the same condition gate, using the `collectionsAreFreshFor` predicate that already exists to tell "this workspace's list" from a stale previous one — and a genuinely empty workspace stamps its slug on success, so it does not re-fire. THE ROOT LAYOUT FLAG IS RENAMED, NOT RE-SEMANTICS'D. `workspacesLoaded` said a load had SUCCEEDED while the code set it before the call and never reset it, so a rejected `loadAll` read afterwards as a completed one. It is now `workspacesRequested`, which is what it has always meant. Deliberately still set before the call and deliberately not reset on failure: setting it only on success would re-arm an effect whose guard READS `workspaceStore.loading`, so every failed attempt would flip that dependency and re-run the effect — a hot retry loop against a server that is down. Recovery belongs where it can be gated on a condition, which is where it now is. The logged-out-mid-redirect guard above it is untouched. Tests: `workspaceRecovery.svelte.test.ts` pins the audit's own sequence (failed cold load, server returns, recover) plus a CONTROL leg proving an intact session issues NO request — a recovery that fired unconditionally would re-list workspaces on every sync result of every healthy session, which is worse than the defect and would pass any test that only checked the first leg. Also pinned: a still-down server leaves the condition true so the next result retries, a `current` naming a DIFFERENT workspace is re-pointed (presence is not the property), and no second list request stacks on an in-flight one. Neutering both conditions fails three legs; the control and the in-flight leg pass either way, which is what they are for. Wiring pinned in the existing source-pin file, with its limits unchanged, and one leg asserting the recovery sits OUTSIDE the full_refresh branch — the placement is the fix, so it is what a pin has to catch. Gates: svelte-check 0 errors; vitest 2254 passed / 142 files. Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8 * fix(web): one collection load, two reasons to want it (codex round 1) Round 1 caught that a `full_refresh` arriving while the list was also stale fired `loadCollections` twice — the recovery `if` and the changed-signal `if` are separate tests of non-exclusive conditions. The store's load-generation guard drops the older response, so this was a wasted request and a superseded one rather than a wrong list, but it is a request nobody needed and it is the accretion shape: two guards where the question is one. Consolidated into one call with the two reasons named — `collectionsMissing` (we do not have this workspace's list) and `collectionsChanged` (the server says the list moved) — rather than adding a third condition to suppress the duplicate. Answering accretion by removing a branch, per the working rule this plan's neighbour established. The wiring pin moves with it: anchoring on `loadCollections(ws)` alone would now pass with the recovery term deleted and the changed-signal term left standing, which is exactly the pre-fix state and the one a returning server does not reach. It anchors on the MISSING term and on the combined condition. Gates: svelte-check 0 errors; vitest 2254 passed / 142 files. Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8 * fix(web): ensure-a-list and fetch-a-fresh-one are different requests (codex round 2) Round 2: the round-1 consolidation still raced the workspace effect's OWN in-flight `loadCollections`. A sync result arriving before that request settles sees `collectionsAreFreshFor(ws) === false` and issues a second one; the store's generation guard discards the stale RESPONSE but nothing prevents the duplicate CALL. Second finding of the shape "duplicate collection request", so this answers the population rather than the instance (CONVE-18). The population is the ~19 `collectionStore.loadCollections` call sites, and the enumeration is the useful part: EIGHTEEN of them are reacting to a known change — an SSE rename, a settings save, a server `collections_changed`, a reorder 404 — and for those, joining an in-flight request would be WRONG, because that request was issued before the change they are reacting to and cannot answer them. Exactly one call site, the new recovery path, is asking "does a list exist". So the coalescing is a separate method rather than a behaviour of `loadCollections`: `ensureCollections(ws)` no-ops when the list is already this workspace's, joins an in-flight load for the SAME workspace, and otherwise issues a real one. The join slot is per-workspace, because a workspace switch can leave A's request in flight while B's starts and a joiner asking about A must not be handed B's promise; and it is released under the same generation-ownership rule the `loading` flag already uses, so an older load settling late cannot clear a newer one's slot. The layout now picks by INTENT — `loadCollections` when the server says the list changed, `ensureCollections` otherwise — which is one call either way rather than one call plus a suppression condition. Tests: `collectionsEnsure.svelte.test.ts`. The load-bearing leg is the CONTROL — `loadCollections` is NOT coalesced — because moving the join down into it would look like a tidy simplification and would pass every other assertion in the file while quietly serving pre-change data to a rename. Also pinned: no request when already fresh, no join across workspaces, and the slot released after a FAILED load so a later ensure retries rather than resolving against the dead request — the unit's own failure mode, one level down. Removing the join fails the join leg; removing the freshness check fails the no-op leg. One fixture bug found and fixed while writing: holding a single `release` across two `mockImplementation` calls leaves the first promise pending forever, which times out and reads exactly like the product hanging. Gates: svelte-check 0 errors; vitest 2259 passed / 143 files. Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8 * docs(web): the in-flight slot is one tagged slot, not a per-workspace map (codex round 3) Round 3 is right about the prose and wrong about the remedy, and both halves are worth recording. Right: the comment claimed per-workspace tracking and the code is a single slot tagged with its workspace. That is a comment describing a structure the code does not have, which is the kind of thing a successor reuses without re-deriving. Wrong: the proposed fix — a per-workspace map of in-flight promises — would be the defect rather than the cure. `loadCollections` commits only the LATEST call, so in the sequence round 3 names (alpha, beta, then ensure alpha), beta's start has already killed alpha's first request: its response is dropped by the generation guard. A map would let the ensure JOIN that dead request and resolve its caller against a result that never lands — a quieter version of the bug this unit exists to fix. Issuing a fresh alpha request is the correct answer and is what the single slot already produces. So: the comment now says what the slot is and why a map would be worse, and the ordering round 3 named is pinned as a test asserting THREE requests — the behaviour, not the proposal. The workspace tag keeps doing the job it always did, which is the opposite mistake: without it a joiner asking about alpha would be handed beta's promise. Gates: svelte-check 0 errors; vitest 2260 passed / 143 files. Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8 * fix(web): recoverIfMissing JOINS an in-flight loadAll instead of skipping it (codex round 4) Round 4: `recoverIfMissing` skipped `loadAll` when one was already in flight and went straight to `setCurrent`. With `workspaces` still empty that takes the single-workspace fallback, and if THAT failed while the in-flight list succeeded moments later, `current` stayed null and the shell stayed broken. Recoverable — the next sync result retries — but a wasted round, and it made the recovery depend on the fallback endpoint in a case where the list was about to answer. The thing worth recording is that this is the SAME QUESTION `ensureCollections` answers three files away — "a request for this is already in flight, do I skip or join?" — and I answered it by joining there and by skipping here, in one unit, an hour apart. Reviewer-named instances are a sample; this one had a sibling I wrote myself. Both now join, and `loadAll` publishes its in-flight promise the way `loadCollections` already did. The `!loading` condition is gone rather than repaired: with the join, "is one in flight" is answered by the promise slot, and a second way to ask the same question is what let the two sites drift. The in-flight test leg is rewritten to assert the OUTCOME, not just the request count — round 4's second point, and the fair one. It now holds the single-workspace fallback DOWN, which is what discriminates: the old skip path took that fallback and left `current` null, and no call-count assertion could see it. Reverting the join to the skip fails that leg. Gates: svelte-check 0 errors; vitest 2260 passed / 143 files. Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8 * fix(web): loadAll's cleanup is owned by the generation that set it (codex round 5) Round 5: `loadAll` cleared its join slot unconditionally, so an older overlapping request finishing first clears a NEWER one's slot — after which a concurrent `recoverIfMissing` starts a third request instead of joining the load still running. Round 4's race, reintroduced by round 4's own cleanup. Guarded by a generation counter, which is the instrument `collections.svelte.ts` already uses for exactly this on its `loading` flag and its own join slot. `loading` moves under the same guard for the same reason: an older load flipping it off while a newer one runs is the spinner half of the same mistake. Promise identity would read more directly but forces a self-reference the type checker cannot prove is assigned before use. **Third time in this unit that a rule was applied at one door and not its sibling** — join-vs-skip in round 4, and now ownership-of-cleanup — and both siblings were in files I had open. Recording it here rather than only on the trail, because the pattern is the finding. NAMED, NOT FIXED: `loadAll` still has no guard on which RESPONSE commits, so two overlapping calls can leave the OLDER list in `workspaces` if it resolves last. `collections.svelte.ts` has that guard and this store does not. It is pre-existing and cannot be reached through the recovery path, which only ever joins and never issues a competing call — so it is a separate fix with its own test rather than something to fold in here. Written into the store's own comment so the next reader finds it at the code rather than in a commit message. The new leg had to be rewritten before it was worth anything. Its first draft resolved the older request SUCCESSFULLY, which populated `workspaces`, sent the recovery straight past its list branch, and passed against the mutant too — a fixture that could not fail, caught by running it against unconditional cleanup rather than by reading it. The older request now FAILS, which is what keeps the array empty and gives the recovery something to recover. Gates: svelte-check 0 errors; vitest 2261 passed / 143 files. Claude-Session: https://claude.ai/code/session_01Xk9M5UVPdc84xL5E1mZkm8
Pad Web UI
SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.
Development
npm install
npm run dev # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build # Production build to build/
npm run check # Type checking with svelte-check
When developing, run the Go backend separately with make dev from the project root.
Building for Production
Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.
Stack
- Svelte 5 with runes (
$state,$derived,$effect) - SvelteKit 2 with
adapter-static(SPA mode) - Tiptap block editor with markdown round-trip
- svelte-dnd-action for drag-and-drop in board/list views
- SSE for real-time updates
- TypeScript throughout
Structure
src/
routes/ SvelteKit pages
+layout.svelte App shell (sidebar + main)
+page.svelte Landing/redirect
[workspace]/
+page.svelte Dashboard (collections, phases, activity)
+layout.svelte SSE connection per workspace
[collection]/
+page.svelte Collection view (board/list)
[collection]/[item]/
+page.svelte Item detail + editor
conventions/ Purpose-built conventions page
playbooks/ Purpose-built playbooks page
settings/ Workspace settings
lib/
api/client.ts HTTP API client
components/
layout/ Sidebar, navigation
editor/ Tiptap editor, raw markdown editor
fields/ FieldEditor, relation picker
items/ ItemCard, ItemDetail
collections/ BoardView, ListView
common/ StatusBadge, badges, modals
search/ CommandPalette
stores/ Svelte 5 reactive stores
workspace.svelte.ts Workspace state
collections.svelte.ts Collection + item state
ui.svelte.ts Sidebar, mobile state
types/index.ts TypeScript types and constants
app.css Global styles and design tokens