@rubytech/create-sitedesk-code 0.1.524 → 0.1.525

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/plugins/admin/skills/superpowers-sprint/SKILL.md +26 -0
  3. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
  4. package/payload/platform/plugins/memory/PLUGIN.md +10 -0
  5. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js +5 -1
  6. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/compiled-truth-revision.test.js.map +1 -1
  7. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.d.ts +2 -0
  8. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.d.ts.map +1 -0
  9. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.js +141 -0
  10. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/temporal-type-preserve.test.js.map +1 -0
  11. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts +1 -1
  12. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.d.ts.map +1 -1
  13. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js +6 -0
  14. package/payload/platform/plugins/memory/mcp/dist/lib/log-ingest.js.map +1 -1
  15. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.d.ts +45 -0
  16. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.d.ts.map +1 -0
  17. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.js +122 -0
  18. package/payload/platform/plugins/memory/mcp/dist/lib/temporal-type-preserve.js.map +1 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.js +26 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-by-name.test.js.map +1 -1
  21. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.d.ts +2 -0
  22. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.d.ts.map +1 -0
  23. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.js +201 -0
  24. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-type-preserve.test.js.map +1 -0
  25. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  26. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +89 -33
  27. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  28. package/payload/platform/plugins/memory/mcp/vitest.config.ts +2 -0
  29. package/payload/platform/plugins/scheduling/PLUGIN.md +6 -0
  30. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.d.ts +2 -0
  31. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.d.ts.map +1 -0
  32. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.js +148 -0
  33. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/meeting-retype.test.js.map +1 -0
  34. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts +83 -6
  35. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.d.ts.map +1 -1
  36. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js +90 -5
  37. package/payload/platform/plugins/scheduling/mcp/dist/lib/booking-reconcile.js.map +1 -1
  38. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/routine-roster-audit.test.js +111 -2
  39. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/routine-roster-audit.test.js.map +1 -1
  40. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +38 -0
  41. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  42. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +116 -0
  43. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  44. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +13 -1
  45. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  46. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js +81 -8
  47. package/payload/platform/plugins/scheduling/mcp/dist/scripts/reconcile-bookings.js.map +1 -1
  48. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-event-agent.test.js +39 -0
  49. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-event-agent.test.js.map +1 -1
  50. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  51. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +9 -1
  52. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  53. package/payload/platform/scripts/__tests__/check-canonical-tool-names.test.sh +162 -0
  54. package/payload/platform/scripts/__tests__/check-no-task-id-leaks.test.sh +53 -0
  55. package/payload/platform/scripts/__tests__/task-id-citation.test.sh +72 -0
  56. package/payload/platform/scripts/check-canonical-tool-names.mjs +36 -4
  57. package/payload/platform/scripts/check-no-task-id-leaks.mjs +80 -62
  58. package/payload/platform/scripts/lib/canonical-tool-names.mjs +130 -26
  59. package/payload/platform/scripts/lib/task-id-citation.mjs +67 -0
  60. package/payload/platform/.docs/search-surface-contract.md +0 -58
  61. package/payload/platform/docs/superpowers/plans/2026-06-02-task-610-follower-202-retry.md +0 -372
  62. package/payload/platform/docs/superpowers/plans/2026-06-04-public-agent-knowledge-delivery.md +0 -230
  63. package/payload/platform/docs/superpowers/plans/2026-06-23-account-filesystem-schema.md +0 -544
  64. package/payload/platform/docs/superpowers/plans/2026-07-11-task-1557-operator-on-behalf-preference-attribution.md +0 -542
  65. package/payload/platform/docs/superpowers/plans/2026-07-13-account-schema-ontology-projection.md +0 -547
  66. package/payload/platform/docs/superpowers/plans/2026-07-14-graph-top-level-labels-ontology-single-source.md +0 -458
  67. package/payload/platform/docs/superpowers/plans/2026-07-17-task-1736-retire-mcp-stderr-tee.md +0 -397
  68. package/payload/platform/docs/superpowers/plans/2026-07-18-graph-native-ledger.md +0 -807
  69. package/payload/platform/docs/superpowers/plans/2026-07-18-ledger-write-path-fix.md +0 -109
  70. package/payload/platform/docs/superpowers/plans/2026-07-20-storage-pages-create.md +0 -82
  71. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1704-data-portal-standing-audit.md +0 -673
  72. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1789-reseat-channel-row-fork.md +0 -1244
  73. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1818-loop-gate-app-routes.md +0 -462
  74. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1819-top-level-label-allowlist.md +0 -321
  75. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1829-platform-ui-typecheck.md +0 -830
  76. package/payload/platform/docs/superpowers/plans/2026-07-20-task-1831-data-portal-class-derived-allowlist.md +0 -2177
  77. package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +0 -124
  78. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +0 -107
  79. package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +0 -205
  80. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1899-reconcile-report-only.md +0 -748
  81. package/payload/platform/docs/superpowers/plans/2026-07-22-task-1910-two-way-portal-exchange.md +0 -1716
  82. package/payload/platform/docs/superpowers/plans/2026-07-23-task-1930-adherence-enforcement.md +0 -422
  83. package/payload/platform/docs/superpowers/plans/2026-07-24-task-1942-subaccount-switcher-brand-head.md +0 -327
  84. package/payload/platform/docs/superpowers/plans/2026-07-25-task-1974-uploads-intake-inbox.md +0 -235
  85. package/payload/platform/docs/superpowers/plans/2026-07-25-task-1976-intra-folder-hygiene.md +0 -215
  86. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1902-account-owned-entry-declarations.md +0 -555
  87. package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +0 -482
  88. package/payload/platform/docs/superpowers/plans/2026-07-26-task-2023-sidebar-sessions-async-reads.md +0 -394
  89. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2016-disabled-agent-routing.md +0 -624
  90. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2028-declared-file-write-deny.md +0 -303
  91. package/payload/platform/docs/superpowers/plans/2026-07-27-task-2052-account-machinery-write-fence.md +0 -250
  92. package/payload/platform/docs/superpowers/plans/2026-07-28-task-2097-email-signature-set.md +0 -65
  93. package/payload/platform/docs/superpowers/specs/2026-06-02-task-610-follower-202-retry-design.md +0 -116
  94. package/payload/platform/docs/superpowers/specs/2026-06-23-account-filesystem-schema-design.md +0 -170
  95. package/payload/platform/docs/superpowers/specs/2026-07-11-task-1557-operator-on-behalf-preference-attribution-design.md +0 -90
  96. package/payload/platform/docs/superpowers/specs/2026-07-13-account-schema-ontology-projection-design.md +0 -178
  97. package/payload/platform/docs/superpowers/specs/2026-07-14-graph-top-level-labels-ontology-single-source-design.md +0 -119
  98. package/payload/platform/docs/superpowers/specs/2026-07-17-task-1736-retire-mcp-stderr-tee-design.md +0 -128
  99. package/payload/platform/docs/superpowers/specs/2026-07-18-graph-native-ledger-design.md +0 -241
  100. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1704-data-portal-standing-audit-design.md +0 -177
  101. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1789-reseat-channel-row-fork-design.md +0 -201
  102. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1818-loop-gate-app-routes-design.md +0 -217
  103. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1819-top-level-label-allowlist-design.md +0 -80
  104. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1820-cpu-triage-admin-tools-design.md +0 -97
  105. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1829-platform-ui-typecheck-design.md +0 -163
  106. package/payload/platform/docs/superpowers/specs/2026-07-20-task-1831-data-portal-class-derived-allowlist-design.md +0 -163
  107. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1899-reconcile-report-only-design.md +0 -121
  108. package/payload/platform/docs/superpowers/specs/2026-07-22-task-1910-two-way-portal-exchange-design.md +0 -103
  109. package/payload/platform/docs/superpowers/specs/2026-07-23-task-1930-adherence-enforcement-design.md +0 -81
  110. package/payload/platform/docs/superpowers/specs/2026-07-24-task-1942-subaccount-switcher-brand-head-design.md +0 -35
  111. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1902-account-owned-entry-declarations-design.md +0 -232
  112. package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +0 -200
  113. package/payload/platform/docs/superpowers/specs/2026-07-26-task-2014-ui-suite-nondeterminism-design.md +0 -169
  114. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2016-disabled-agent-routing-design.md +0 -139
  115. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2028-declared-file-write-deny-design.md +0 -127
  116. package/payload/platform/docs/superpowers/specs/2026-07-27-task-2052-account-machinery-write-fence-design.md +0 -150
  117. package/payload/platform/docs/superpowers/specs/2026-07-28-task-2097-email-signature-set-design.md +0 -155
@@ -1,482 +0,0 @@
1
- # Task 1926 Portal Push Concurrency Guard Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** Make two concurrent pushes of one account's portal index safe, so a reader never sees an empty or partial tree and no DELETE ever names the generation the pointer is on.
6
-
7
- **Architecture:** Three changes to `pushAccount`, no lock and no schema change. A push claims a generation above every generation the account already has, the pointer flip only moves forward, and the sweep only removes strictly older generations. The Task 1923 pre-stage clear is removed because the new claim makes it unreachable.
8
-
9
- **Tech Stack:** Node ESM (`.mjs`), vitest 4, Cloudflare D1 over HTTP (SQLite).
10
-
11
- ## Global Constraints
12
-
13
- - Design spec: `platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md`.
14
- - Only two files change: `platform/plugins/cloudflare/bin/portal-index-push.mjs` and `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts`. Schema comments in `platform/plugins/cloudflare/skills/data-portal/schema.sql` are updated to match.
15
- - Tests run from `platform/plugins/cloudflare/mcp` with `npx vitest run __tests__/portal-index-push.test.ts`.
16
- - No change to `runTargets`, `recordSuccess`, `walkExposed`, or the portal reader in `files.ts`.
17
- - Every comment added states why, not what. No comment may describe behaviour the code does not have.
18
- - Commit trailer on every commit: `Session: 7117b00c-9cf0-4045-99e8-d8405e34a7c6`.
19
-
20
- ---
21
-
22
- ### Task 1: Two-writer harness and the task file's interleave
23
-
24
- **Files:**
25
- - Modify: `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts` (append)
26
- - Modify: `platform/plugins/cloudflare/bin/portal-index-push.mjs:194-199` (generation claim), `:234-239` (flip), `:245-248` (sweep)
27
-
28
- **Interfaces:**
29
- - Produces: `sharedStore()` returning `{ exec, visible, pointerValue, pointerHistory, snapshots, deletesAtPointer, rows, seed }`; `gate()` returning `{ reached, opened, hit, open }`; `pausingClient(store, gate, {before, after})` returning `{ query, calls }`. Task 2 consumes all three.
30
- - Consumes: the existing `push(client, overrides)` helper and the 7-row fixture already in this file.
31
-
32
- - [ ] **Step 1: Write the failing test**
33
-
34
- Append to `__tests__/portal-index-push.test.ts`:
35
-
36
- ```ts
37
- // Task 1926. Two pushes of ONE account are reachable: the admin server's 60s
38
- // loop (pushAllAccounts) and a hand-run `--account <id>` are separate OS
39
- // processes calling the same pushAccount. These two tests drive both writers
40
- // against ONE store and snapshot the reader's view after every statement, so
41
- // "never a half-built tree" stays a measured property rather than a claim.
42
- //
43
- // Statement-level atomicity matches SQLite: each exec() applies whole.
44
- function sharedStore() {
45
- const rows: { accountId: string; relPath: string; generation: number }[] = []
46
- let pointer: number | null = null
47
- const pointerHistory: number[] = []
48
- const snapshots: string[][] = []
49
- const deletesAtPointer: number[] = []
50
-
51
- const visible = () =>
52
- pointer === null
53
- ? []
54
- : rows.filter((r) => r.generation === pointer).map((r) => r.relPath).sort()
55
-
56
- // The three DELETE shapes this file can issue. Order matters: `!=` and `<`
57
- // must be tested before `=`, or `generation != ?` would read as an equality.
58
- const deleteMatcher = (sql: string, g: number) => {
59
- if (/generation\s*<\s*\?/i.test(sql)) return (n: number) => n < g
60
- if (/generation\s*!=\s*\?/i.test(sql)) return (n: number) => n !== g
61
- return (n: number) => n === g
62
- }
63
-
64
- return {
65
- rows,
66
- snapshots,
67
- deletesAtPointer,
68
- pointerHistory,
69
- visible,
70
- pointerValue: () => pointer,
71
- seed(generation: number, relPaths: string[]) {
72
- for (const relPath of relPaths) rows.push({ accountId: 'acc-1', relPath, generation })
73
- pointer = generation
74
- pointerHistory.push(generation)
75
- },
76
- async exec(sql: string, params: unknown[] = []) {
77
- const out = await (async () => {
78
- if (/SELECT\s+currentGeneration/i.test(sql)) {
79
- return pointer === null ? [] : [{ currentGeneration: pointer }]
80
- }
81
- if (/SELECT\s+MAX\(generation\)/i.test(sql)) {
82
- const mine = rows.filter((r) => r.accountId === String(params[0]))
83
- return [{ g: mine.length ? Math.max(...mine.map((r) => r.generation)) : null }]
84
- }
85
- if (/INSERT INTO directory_state/i.test(sql)) {
86
- const g = Number(params[1])
87
- // The upsert's WHERE, modelled: with the guard present the pointer
88
- // moves forward only. Without it, every flip lands.
89
- const forwardOnly = /excluded\.currentGeneration\s*>\s*directory_state\.currentGeneration/i.test(sql)
90
- if (pointer === null || !forwardOnly || g > pointer) {
91
- pointer = g
92
- pointerHistory.push(g)
93
- }
94
- return []
95
- }
96
- if (/INSERT INTO directory \(/i.test(sql)) {
97
- const accountId = String(params[0])
98
- const relPath = String(params[1])
99
- const generation = Number(params[6])
100
- if (rows.some((r) => r.accountId === accountId && r.relPath === relPath && r.generation === generation)) {
101
- throw new Error(
102
- 'd1 query failed: HTTP 500 — UNIQUE constraint failed: directory.accountId, directory.relPath, directory.generation',
103
- )
104
- }
105
- rows.push({ accountId, relPath, generation })
106
- return []
107
- }
108
- if (/DELETE FROM directory\b/i.test(sql)) {
109
- const g = Number(params[1])
110
- const matches = deleteMatcher(sql, g)
111
- // The bug's signature: a DELETE that removes rows at the generation
112
- // the pointer names is a reader-visible deletion of the live tree.
113
- if (pointer !== null && matches(pointer) && rows.some((r) => r.generation === pointer)) {
114
- deletesAtPointer.push(pointer)
115
- }
116
- for (let i = rows.length - 1; i >= 0; i--) if (matches(rows[i].generation)) rows.splice(i, 1)
117
- return []
118
- }
119
- if (/SELECT COUNT/i.test(sql)) {
120
- if (/generation\s*=\s*\?/i.test(sql)) {
121
- const g = Number(params[1])
122
- return [{ n: rows.filter((r) => r.generation === g).length }]
123
- }
124
- return [{ n: rows.filter((r) => r.generation === pointer).length }]
125
- }
126
- return []
127
- })()
128
- snapshots.push(visible())
129
- return out
130
- },
131
- }
132
- }
133
-
134
- /** A one-shot rendezvous: `reached` settles when the writer parks, `open()` frees it. */
135
- function gate() {
136
- let hit!: () => void
137
- let open!: () => void
138
- const reached = new Promise<void>((r) => (hit = r))
139
- const opened = new Promise<void>((r) => (open = r))
140
- return { reached, opened, hit: () => hit(), open: () => open() }
141
- }
142
-
143
- /** A client that parks once, either before or after the matching statement. */
144
- function pausingClient(
145
- store: ReturnType<typeof sharedStore>,
146
- g: ReturnType<typeof gate>,
147
- at: { before?: (sql: string) => boolean; after?: (sql: string) => boolean },
148
- ) {
149
- let armed = true
150
- const calls: { sql: string; params: unknown[] }[] = []
151
- return {
152
- calls,
153
- async query(sql: string, params: unknown[] = []) {
154
- calls.push({ sql, params })
155
- if (armed && at.before?.(sql)) {
156
- armed = false
157
- g.hit()
158
- await g.opened
159
- }
160
- const out = await store.exec(sql, params)
161
- if (armed && at.after?.(sql)) {
162
- armed = false
163
- g.hit()
164
- await g.opened
165
- }
166
- return out
167
- },
168
- }
169
- }
170
-
171
- const plainClient = (store: ReturnType<typeof sharedStore>) => ({
172
- query: (sql: string, params: unknown[] = []) => store.exec(sql, params),
173
- })
174
-
175
- describe('two concurrent pushes of one account (Task 1926)', () => {
176
- // The task file's own interleave. B reads the pointer, then stalls. A stages,
177
- // flips and sweeps a whole publish. B resumes holding a stale `current`.
178
- //
179
- // Before the guard: B recomputed next = current + 1 = the generation the
180
- // pointer now names, its pre-stage COUNT returned A's live rows, and its
181
- // DELETE emptied the tree a client was reading.
182
- it('a peer holding a stale pointer never empties or halves the live tree', async () => {
183
- const store = sharedStore()
184
- store.seed(5, ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'])
185
- const g = gate()
186
- const b = pausingClient(store, g, { after: (sql) => /SELECT\s+currentGeneration/i.test(sql) })
187
-
188
- const bRun = push(b, { log: () => {} }).catch((e: unknown) => e)
189
- await g.reached
190
- await push(plainClient(store), { log: () => {} })
191
- g.open()
192
- await bRun
193
-
194
- // 4 is the seeded tree, 7 is a complete walk of the fixture. Any other
195
- // count is a tree a client could see half-built.
196
- expect(store.snapshots.every((s) => s.length === 4 || s.length === 7)).toBe(true)
197
- expect(store.deletesAtPointer).toEqual([])
198
- expect(store.visible().length).toBe(7)
199
- })
200
- })
201
- ```
202
-
203
- - [ ] **Step 2: Run test to verify it fails**
204
-
205
- Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts -t 'never empties or halves'`
206
- Expected: FAIL. `store.snapshots.every(...)` is `false` (a zero-length snapshot appears after B's pre-stage DELETE) and `deletesAtPointer` is `[6]`.
207
-
208
- - [ ] **Step 3: Write minimal implementation**
209
-
210
- In `bin/portal-index-push.mjs`, replace the `const next = current + 1` line and the two statements around it:
211
-
212
- ```js
213
- const state = await client.query(
214
- 'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
215
- [accountId],
216
- )
217
- const current = Number(state[0]?.currentGeneration ?? 0)
218
-
219
- // Claim a generation above EVERY generation this account already holds, not
220
- // merely above the pointer (Task 1926). Two pushes of one account are
221
- // reachable: the admin server's 60s loop and a hand-run `--account` are
222
- // separate processes, so no in-process lock can see both. `current + 1` let
223
- // the second writer claim the generation the first had just flipped onto and
224
- // then delete it. A peer that has inserted even one row is visible in this
225
- // MAX, so the two runs claim different generations.
226
- const staged = await client.query(
227
- 'SELECT MAX(generation) AS g FROM directory WHERE accountId = ?',
228
- [accountId],
229
- )
230
- const next = Math.max(current, Number(staged[0]?.g ?? 0)) + 1
231
- ```
232
-
233
- Replace the flip with a forward-only upsert plus a readback:
234
-
235
- ```js
236
- // THE atomic point. Upsert rather than insert-or-update, so an account
237
- // publishing for the first time and one republishing take the same path.
238
- //
239
- // The WHERE moves the pointer FORWARD ONLY (Task 1926). Without it a writer
240
- // that was overtaken while staging drags every reader back onto a generation
241
- // the winner has already swept, which is an empty tree. SQLite has carried a
242
- // WHERE on upsert's DO UPDATE since 3.24, and D1 is SQLite.
243
- await client.query(
244
- 'INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?) ' +
245
- 'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration ' +
246
- 'WHERE excluded.currentGeneration > directory_state.currentGeneration',
247
- [accountId, next],
248
- )
249
- log(`${TAG} op=flip account=${accountId} generation=${next}`)
250
- ```
251
-
252
- Replace the sweep predicate:
253
-
254
- ```js
255
- // Strictly older only (Task 1926): `!= next` would delete a peer's HIGHER
256
- // in-flight stage. Rows above `next` left by an abandoned stage still
257
- // converge, because the next cycle claims a generation above them and
258
- // sweeps them once it flips.
259
- await client.query('DELETE FROM directory WHERE accountId = ? AND generation < ?', [
260
- accountId,
261
- next,
262
- ])
263
- ```
264
-
265
- - [ ] **Step 4: Run test to verify it passes**
266
-
267
- Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts`
268
- Expected: PASS, 28 tests. The two Task 1923 stage-clear tests still pass at this point (the clear is unreachable but harmless).
269
-
270
- - [ ] **Step 5: Commit**
271
-
272
- ```bash
273
- git add platform/plugins/cloudflare/bin/portal-index-push.mjs platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
274
- git commit -m "fix(portal): claim a generation above every staged row, flip forward only"
275
- ```
276
-
277
- ---
278
-
279
- ### Task 2: The superseded writer
280
-
281
- **Files:**
282
- - Modify: `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts` (append inside the Task 1926 describe)
283
- - Modify: `platform/plugins/cloudflare/bin/portal-index-push.mjs` (flip readback branch)
284
-
285
- **Interfaces:**
286
- - Consumes: `sharedStore`, `gate`, `pausingClient`, `plainClient` from Task 1.
287
- - Produces: `pushAccount` returns `superseded: true` on the losing path.
288
-
289
- - [ ] **Step 1: Write the failing test**
290
-
291
- Append inside the `two concurrent pushes of one account (Task 1926)` describe block:
292
-
293
- ```ts
294
- // The other order: A stages a whole generation, then stalls before its flip.
295
- // B starts fresh, sees A's staged rows in the MAX, claims the generation
296
- // above, and publishes. A wakes holding a generation that is now behind.
297
- it('a writer overtaken while staging leaves the winner live and says so', async () => {
298
- const store = sharedStore()
299
- store.seed(5, ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'])
300
- const g = gate()
301
- const a = pausingClient(store, g, { before: (sql) => /INSERT INTO directory_state/i.test(sql) })
302
- const lines: string[] = []
303
-
304
- const aRun = push(a, { log: (l: string) => lines.push(l) })
305
- await g.reached
306
- await push(plainClient(store), { log: () => {} })
307
- g.open()
308
- const aResult = await aRun
309
-
310
- expect(store.snapshots.every((s) => s.length === 4 || s.length === 7)).toBe(true)
311
- expect(store.deletesAtPointer).toEqual([])
312
- // The pointer never goes backward, which is what keeps the loser harmless.
313
- expect(store.pointerHistory).toEqual([...store.pointerHistory].sort((x, y) => x - y))
314
- expect(store.pointerValue()).toBe(7)
315
-
316
- const contended = lines.find((l) => l.includes('op=stage-contended'))!
317
- expect(contended).toContain('account=acc-1')
318
- expect(contended).toContain('generation=6')
319
- expect(contended).toContain('pointer=7')
320
- expect(contended).toContain('action=superseded')
321
- expect(lines.some((l) => l.includes('op=flip'))).toBe(false)
322
- // No sweep and no verify from the loser: both would measure the WINNER's
323
- // tree against this run's expected row count and read as a failure.
324
- expect(a.calls.some((c) => /DELETE FROM directory/i.test(c.sql))).toBe(false)
325
- expect(lines.some((l) => l.includes('op=verify'))).toBe(false)
326
- expect(aResult.superseded).toBe(true)
327
- })
328
- ```
329
-
330
- - [ ] **Step 2: Run test to verify it fails**
331
-
332
- Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts -t 'overtaken while staging'`
333
- Expected: FAIL on `lines.find(...)` returning undefined, because nothing emits `op=stage-contended` yet.
334
-
335
- - [ ] **Step 3: Write minimal implementation**
336
-
337
- In `bin/portal-index-push.mjs`, put the readback between the flip statement and the `op=flip` log line, and move the log into the winning branch:
338
-
339
- ```js
340
- // Read back rather than assume: the D1 client returns rows, not affected-row
341
- // meta, so a suppressed flip is otherwise indistinguishable from an applied
342
- // one.
343
- const flipped = await client.query(
344
- 'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
345
- [accountId],
346
- )
347
- const pointer = Number(flipped[0]?.currentGeneration ?? 0)
348
- if (pointer !== next) {
349
- // A peer published while this run was staging. Its tree is live and
350
- // complete, ours is invisible, and its rows are collected by the peer's
351
- // next sweep. Sweeping or verifying from here would act on, and measure,
352
- // the PEER's tree — reporting a failure where nothing failed. Logged
353
- // rather than silent: a suppressed run is otherwise indistinguishable from
354
- // one that never started.
355
- log(
356
- `${TAG} op=stage-contended account=${accountId} generation=${next} ` +
357
- `pointer=${pointer} action=superseded`,
358
- )
359
- return {
360
- exposed: r.exposed,
361
- rows: rows.length,
362
- schemaPresent: true,
363
- generation: next,
364
- superseded: true,
365
- }
366
- }
367
- log(`${TAG} op=flip account=${accountId} generation=${next}`)
368
- ```
369
-
370
- Update the `pushAccount` JSDoc return type to
371
- `Promise<{ exposed: string[], rows: number, schemaPresent: boolean, generation?: number, superseded?: boolean }>`.
372
-
373
- - [ ] **Step 4: Run test to verify it passes**
374
-
375
- Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts`
376
- Expected: PASS, 29 tests.
377
-
378
- - [ ] **Step 5: Commit**
379
-
380
- ```bash
381
- git add platform/plugins/cloudflare/bin/portal-index-push.mjs platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts
382
- git commit -m "fix(portal): a superseded push logs stage-contended instead of sweeping"
383
- ```
384
-
385
- ---
386
-
387
- ### Task 3: Retire the unreachable Task 1923 pre-stage clear
388
-
389
- **Files:**
390
- - Modify: `platform/plugins/cloudflare/bin/portal-index-push.mjs` (delete the orphan COUNT, the DELETE, the `op=stage-clear` log)
391
- - Modify: `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts` (delete the `a partial stage self-heals (Task 1923)` describe, replace with one test of the heal's new mechanism)
392
- - Modify: `platform/plugins/cloudflare/skills/data-portal/schema.sql` (the `generation` column comment)
393
-
394
- **Interfaces:**
395
- - Consumes: `wedgeableClient` already in the test file, kept because it is the only client that models the UNIQUE constraint.
396
-
397
- - [ ] **Step 1: Write the failing test**
398
-
399
- Replace the whole `describe('a partial stage self-heals (Task 1923)')` block with:
400
-
401
- ```ts
402
- describe('a partial stage self-heals (Task 1923, by Task 1926 generation claim)', () => {
403
- // The wedge: a stage that died mid-batch left rows at current + 1 while the
404
- // pointer stayed at current, so every later run recomputed the SAME target
405
- // and its first INSERT hit UNIQUE(accountId, relPath, generation). The index
406
- // froze at the stale generation until an operator hand-cleared the orphans,
407
- // which is what held gls-data for ~6 h on 2026-07-22.
408
- //
409
- // Claiming above the highest EXISTING generation heals it with no DELETE
410
- // before the flip at all, which is what removed Task 1926's hazard rather
411
- // than narrowing it.
412
- it('claims above the orphans instead of colliding with them, and sweeps them after the flip', async () => {
413
- const c = wedgeableClient({
414
- pointer: 5,
415
- current: ['output', 'output/report.pdf', 'quotes', 'quotes/CR2969'],
416
- orphans: ['output', 'output/report.pdf', 'quotes'],
417
- })
418
- const lines: string[] = []
419
- const r = await push(c, { log: (l: string) => lines.push(l) })
420
-
421
- expect(r.rows).toBe(7)
422
- // 7, not 6: the orphans sat at 6, so this run claims the one above them.
423
- expect(c.pointerValue()).toBe(7)
424
- expect(c.rows.every((row) => row.generation === 7)).toBe(true)
425
- // No DELETE runs before the flip any more, so the window that let a peer
426
- // empty the live tree does not exist.
427
- expect(lines.some((l) => l.includes('op=stage-clear'))).toBe(false)
428
- })
429
- })
430
- ```
431
-
432
- - [ ] **Step 2: Run test to verify it fails**
433
-
434
- Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts -t 'claims above the orphans'`
435
- Expected: FAIL on `lines.some(op=stage-clear)` being `true`, because the pre-stage clear still emits its line (its COUNT now returns 0, so this assertion is the only one that fails).
436
-
437
- - [ ] **Step 3: Write minimal implementation**
438
-
439
- Delete from `bin/portal-index-push.mjs` the whole Task 1923 block: its comment, the `orphan` query, `orphanCount`, the `if (orphanCount > 0)` DELETE and the `op=stage-clear` log line. Add to the comment above the generation claim:
440
-
441
- ```js
442
- // This also retires the Task 1923 pre-stage clear. That clear existed because
443
- // a later run recomputed the SAME generation as a dead partial stage and
444
- // collided on UNIQUE(accountId, relPath, generation); claiming above the
445
- // orphans means there is nothing to collide with, and the post-flip sweep
446
- // collects them. The clear was itself the DELETE that a concurrent peer could
447
- // aim at the live tree, so removing it removes the hazard rather than
448
- // narrowing it.
449
- ```
450
-
451
- In `skills/data-portal/schema.sql`, replace the `generation` paragraph's second sentence so it reads:
452
-
453
- ```
454
- -- `generation` is which staging round a row belongs to (Task 1910). The push
455
- -- claims a generation above every generation the account already holds, writes
456
- -- the whole tree there, moves the pointer below in ONE forward-only statement,
457
- -- then deletes strictly older generations (Task 1926). Uniqueness is scoped by
458
- -- generation because two of them hold the same relPath between the write and
459
- -- the flip; UNIQUE (accountId, relPath) would reject the staged write outright.
460
- ```
461
-
462
- - [ ] **Step 4: Run test to verify it passes**
463
-
464
- Run: `cd platform/plugins/cloudflare/mcp && npx vitest run __tests__/portal-index-push.test.ts`
465
- Expected: PASS, 28 tests (29 minus the two removed stage-clear tests, plus this one).
466
-
467
- - [ ] **Step 5: Commit**
468
-
469
- ```bash
470
- git add platform/plugins/cloudflare/bin/portal-index-push.mjs platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts platform/plugins/cloudflare/skills/data-portal/schema.sql
471
- git commit -m "refactor(portal): retire the pre-stage clear the generation claim makes unreachable"
472
- ```
473
-
474
- ---
475
-
476
- ## Self-review
477
-
478
- **Spec coverage.** Generation claim (Task 1 step 3), forward-only flip (Task 1 step 3), backward-only sweep (Task 1 step 3), superseded branch and `op=stage-contended` (Task 2), removal of the pre-stage clear and its tests (Task 3), the two-writer regression tests (Tasks 1 and 2). The spec's residual case (a schema change between the two generation reads) is documented, not coded, and needs no task.
479
-
480
- **Placeholders.** None. Every code step carries the code.
481
-
482
- **Type consistency.** `sharedStore`, `gate`, `pausingClient`, `plainClient` are defined once in Task 1 and used by the same names in Tasks 2 and 3. `next`, `current`, `pointer`, `staged` are the only new locals in the module and none shadow an existing name.