@rubytech/create-sitedesk-code 0.1.508 → 0.1.509
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.
- package/dist/index.js +53 -0
- package/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-26-task-1926-portal-push-concurrency-guard.md +482 -0
- package/payload/platform/docs/superpowers/specs/2026-07-26-task-1926-portal-push-concurrency-guard-design.md +200 -0
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -6
- package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +6 -0
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +68 -28
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +271 -29
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +5 -4
- package/payload/platform/plugins/docs/references/admin-ui.md +10 -5
- package/payload/platform/scripts/__tests__/check-brand-cache.test.sh +231 -0
- package/payload/platform/scripts/check-brand-cache.sh +138 -0
- package/payload/server/maxy-edge.js +15 -10
- package/payload/server/public/activity.html +5 -5
- package/payload/server/public/agents.html +4 -4
- package/payload/server/public/assets/{AdminLoginScreens-DKeCt1uf.js → AdminLoginScreens-8Jk7hnWH.js} +1 -1
- package/payload/server/public/assets/{AdminShell-HeELCCjJ.js → AdminShell-BMPEWNZk.js} +1 -1
- package/payload/server/public/assets/{activity-BEIDAxDA.js → activity-ByWYF5dC.js} +1 -1
- package/payload/server/public/assets/admin-CnUWbKcx.js +1 -0
- package/payload/server/public/assets/agents-C0rTfrYd.js +1 -0
- package/payload/server/public/assets/{browser-GkVTGqlb.js → browser-4tTaem8N.js} +1 -1
- package/payload/server/public/assets/{calendar-CSqJeFEg.js → calendar-BEjnkKG1.js} +1 -1
- package/payload/server/public/assets/chat-DBgzjDIE.js +1 -0
- package/payload/server/public/assets/chevron-left-DqYv3oFh.js +1 -0
- package/payload/server/public/assets/chevron-right-CQfPGsFb.js +1 -0
- package/payload/server/public/assets/clock-Dn6FHB51.js +1 -0
- package/payload/server/public/assets/data-D_e6Vdfd.js +1 -0
- package/payload/server/public/assets/{file-text-DiscM2pP.js → file-text-DMKw3nIk.js} +1 -1
- package/payload/server/public/assets/{graph-CRP5DM8C.js → graph-C3vW8lvw.js} +1 -1
- package/payload/server/public/assets/{graph-labels-Du-6KoBE.js → graph-labels-BZgd0L6n.js} +1 -1
- package/payload/server/public/assets/{maximize-2-D-H_uIV-.js → maximize-2-la3IBA3M.js} +1 -1
- package/payload/server/public/assets/{operator-D5rA76YY.js → operator-RRRdqoRQ.js} +1 -1
- package/payload/server/public/assets/{page-CHK1415C.js → page-BWFHRIAH.js} +1 -1
- package/payload/server/public/assets/{page-DZE3H_n4.js → page-DC11gesX.js} +1 -1
- package/payload/server/public/assets/{public-DFHXVkdD.js → public-DQbbLHQN.js} +1 -1
- package/payload/server/public/assets/{rotate-ccw-CesEJsuO.js → rotate-ccw-OQD5si8N.js} +1 -1
- package/payload/server/public/assets/{routines-DYMYY_r8.js → routines-asrBGZUR.js} +1 -1
- package/payload/server/public/assets/{skills-67jtJ16D.js → skills-D4ECwoxa.js} +1 -1
- package/payload/server/public/assets/{tasks-D2C2a-uk.js → tasks-U3QdbUsO.js} +1 -1
- package/payload/server/public/assets/{time-entry-format-BixTGQAd.js → time-entry-format-BgdTKgYr.js} +1 -1
- package/payload/server/public/assets/{triangle-alert-Co9RWb9q.js → triangle-alert-Ck_3VhT9.js} +1 -1
- package/payload/server/public/assets/{useCopyFeedback-7zwf9_w4.js → useCopyFeedback-BdUwyNpa.js} +1 -1
- package/payload/server/public/assets/{useSubAccountSwitcher-DE4v5_Fz.css → useSubAccountSwitcher-DOWQXDT8.css} +1 -1
- package/payload/server/public/assets/{useVoiceRecorder-CZDDiwMD.js → useVoiceRecorder-ChnBrzbZ.js} +1 -1
- package/payload/server/public/assets/{wrench-CjQqdj4P.js → wrench-Cpee3C3J.js} +1 -1
- package/payload/server/public/browser.html +4 -4
- package/payload/server/public/calendar.html +7 -7
- package/payload/server/public/chat.html +13 -13
- package/payload/server/public/data.html +11 -11
- package/payload/server/public/graph.html +9 -9
- package/payload/server/public/index.html +14 -14
- package/payload/server/public/operator.html +14 -14
- package/payload/server/public/public.html +13 -13
- package/payload/server/public/routines.html +6 -6
- package/payload/server/public/skills.html +5 -5
- package/payload/server/public/tasks.html +6 -6
- package/payload/server/server.js +364 -283
- package/payload/server/public/assets/admin-C0-WU1sf.js +0 -1
- package/payload/server/public/assets/agents-DjUvMFTj.js +0 -1
- package/payload/server/public/assets/chat-C0Ps84U5.js +0 -1
- package/payload/server/public/assets/chevron-left-DQ2uNlu9.js +0 -1
- package/payload/server/public/assets/chevron-right-C4QCiRGD.js +0 -1
- package/payload/server/public/assets/clock-BEeQ7JxX.js +0 -1
- package/payload/server/public/assets/data-CGuNfym7.js +0 -1
- /package/payload/server/public/assets/{useSubAccountSwitcher-Bg72hdQ8.js → useSubAccountSwitcher-CJKjEPNN.js} +0 -0
package/dist/index.js
CHANGED
|
@@ -1472,6 +1472,55 @@ function redactInstallLogs() {
|
|
|
1472
1472
|
if (r.status !== 0 && r.stderr)
|
|
1473
1473
|
logFile("[redact-install-logs] WARN " + r.stderr.trim());
|
|
1474
1474
|
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Task 2019 — reconcile the brand assets the public hostname returns against
|
|
1477
|
+
* the ones just deployed to disk. An upgrade is the only event that can
|
|
1478
|
+
* diverge the two, and this runs on every upgrade, so post-install is the only
|
|
1479
|
+
* caller.
|
|
1480
|
+
*
|
|
1481
|
+
* Never fatal. The exit code is reported, not acted on: the first upgrade
|
|
1482
|
+
* carrying the cache-header fix will legitimately report STALE for entries
|
|
1483
|
+
* Cloudflare poisoned before it shipped, and no token in the install can purge
|
|
1484
|
+
* them. Failing the install over state the installer cannot clear would block
|
|
1485
|
+
* every upgrade behind a four-hour wait.
|
|
1486
|
+
*/
|
|
1487
|
+
function checkBrandCache() {
|
|
1488
|
+
const script = resolve(INSTALL_DIR, "platform/scripts/check-brand-cache.sh");
|
|
1489
|
+
if (!existsSync(script)) {
|
|
1490
|
+
logFile("[brand-cache] script not found at " + script + " — skipping");
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const configDir = resolve(process.env.HOME ?? "/root", BRAND.configDir);
|
|
1494
|
+
const r = spawnSync("bash", [script, "--install-dir", INSTALL_DIR, "--config-dir", configDir], {
|
|
1495
|
+
stdio: "pipe",
|
|
1496
|
+
encoding: "utf-8",
|
|
1497
|
+
timeout: 120_000,
|
|
1498
|
+
});
|
|
1499
|
+
const out = r.stdout?.trim();
|
|
1500
|
+
if (out) {
|
|
1501
|
+
console.log(" " + out);
|
|
1502
|
+
logFile(out);
|
|
1503
|
+
}
|
|
1504
|
+
const err = r.stderr?.trim();
|
|
1505
|
+
if (err) {
|
|
1506
|
+
// The script's own split is preserved: failures on stderr, the clean and
|
|
1507
|
+
// skipped lines on stdout. Collapsing them would blind an outer harness
|
|
1508
|
+
// grepping installer stderr for STALE.
|
|
1509
|
+
console.error(" " + err);
|
|
1510
|
+
logFile(err);
|
|
1511
|
+
}
|
|
1512
|
+
// A run that neither completed nor said why is the one state that reads
|
|
1513
|
+
// identically to the check not being wired in at all. The 120 s budget covers
|
|
1514
|
+
// thirteen sequential fetches over a tunnel; a Pi that exceeds it gets
|
|
1515
|
+
// SIGTERMed mid-loop with no clean line and possibly no stderr, so the
|
|
1516
|
+
// outcome is named here rather than inferred from silence.
|
|
1517
|
+
if (r.error || r.signal || (r.status !== 0 && !err)) {
|
|
1518
|
+
const why = r.error ? r.error.message : r.signal ? `signal=${r.signal}` : `status=${r.status}`;
|
|
1519
|
+
const line = `[brand-cache] check-did-not-complete ${why}`;
|
|
1520
|
+
console.error(" " + line);
|
|
1521
|
+
logFile(line);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1475
1524
|
/** Check Neo4j has a working password. Called AFTER deploy so config is in place. */
|
|
1476
1525
|
function ensureNeo4jPassword() {
|
|
1477
1526
|
const passwordFile = join(INSTALL_DIR, "platform/config/.neo4j-password");
|
|
@@ -5331,6 +5380,10 @@ try {
|
|
|
5331
5380
|
const versionMarkerPath = join(INSTALL_DIR, "platform/config", `.${BRAND.hostname}-version`);
|
|
5332
5381
|
writeFileSync(versionMarkerPath, PKG_VERSION, "utf-8");
|
|
5333
5382
|
console.log(` [install] version-marker written path=${versionMarkerPath} version=${PKG_VERSION}`);
|
|
5383
|
+
// Task 2019 — the last thing the install does: confirm the brand bytes the
|
|
5384
|
+
// public hostname returns are the ones just written. Non-fatal by design;
|
|
5385
|
+
// see checkBrandCache.
|
|
5386
|
+
checkBrandCache();
|
|
5334
5387
|
console.log("");
|
|
5335
5388
|
console.log("================================================================");
|
|
5336
5389
|
console.log("");
|
package/package.json
CHANGED
|
@@ -0,0 +1,482 @@
|
|
|
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.
|