@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Task 1926 — same-account portal push concurrency guard (design)
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-26
|
|
4
|
+
**Task:** [`.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md`](../../../../.tasks/1926-concurrent-same-account-portal-push-lets-the-pre-stage-clear-delete-the-live-tree.md)
|
|
5
|
+
**Touches:** `platform/plugins/cloudflare/bin/portal-index-push.mjs`, `platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts`
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
`pushAccount` stages an account's file index at `next = currentGeneration + 1`, flips
|
|
10
|
+
the `directory_state` pointer to `next`, then sweeps every other generation. Task 1923
|
|
11
|
+
added a pre-stage `DELETE FROM directory WHERE accountId = ? AND generation = ?` bound
|
|
12
|
+
to `next`, so a stage that died mid-batch could self-heal instead of colliding forever
|
|
13
|
+
on `UNIQUE (accountId, relPath, generation)`.
|
|
14
|
+
|
|
15
|
+
Two pushes of one account break that. The second writer reads `current = 5` before the
|
|
16
|
+
first writer's flip, computes `next = 6`, and by the time its pre-stage DELETE runs the
|
|
17
|
+
pointer already names generation 6. The DELETE removes the live tree. A client listing
|
|
18
|
+
in the gap between that DELETE and the re-insert sees an empty portal.
|
|
19
|
+
|
|
20
|
+
The overlap is reachable: the admin server's 60s loop calls `pushAllAccounts`, and a
|
|
21
|
+
hand-run `node portal-index-push.mjs --account <id>` calls the same `pushAccount` from a
|
|
22
|
+
separate OS process.
|
|
23
|
+
|
|
24
|
+
## What success looks like
|
|
25
|
+
|
|
26
|
+
Two concurrent pushes of one account never leave a reader seeing an empty or partial
|
|
27
|
+
tree, and never delete rows at the pointer's generation.
|
|
28
|
+
|
|
29
|
+
## Why the three candidate approaches in the task file were rejected
|
|
30
|
+
|
|
31
|
+
**A per-account advisory lock.** The two racers are separate OS processes. A JavaScript
|
|
32
|
+
mutex inside the admin server does not exist in the CLI process, so it guards nothing.
|
|
33
|
+
A lock that does work across processes has to live in D1, which means new columns, a
|
|
34
|
+
lease, and a lease expiry. A lease expiry reintroduces the wedge class Task 1923 just
|
|
35
|
+
removed: a holder that dies wedges the account until the lease runs out, and a push
|
|
36
|
+
slower than its lease keeps writing after another writer has taken over.
|
|
37
|
+
|
|
38
|
+
**`next` from `MAX(generation)` alone.** This stops two writers sharing a staging
|
|
39
|
+
generation, but the slower writer still flips the pointer down onto its own generation
|
|
40
|
+
after the faster writer has already swept it. The reader still sees an empty tree, so
|
|
41
|
+
the success criterion is not met.
|
|
42
|
+
|
|
43
|
+
**Scoping the clear to `generation = next AND generation != current`.** `current` is a
|
|
44
|
+
value read at the top of the push. By the time the DELETE runs, the pointer has moved.
|
|
45
|
+
Re-reading the pointer immediately before the DELETE narrows the window without closing
|
|
46
|
+
it, because the peer can flip between the re-read and the DELETE.
|
|
47
|
+
|
|
48
|
+
## Design
|
|
49
|
+
|
|
50
|
+
Three changes to `pushAccount`. No lock, no lease, no schema change, no new table.
|
|
51
|
+
|
|
52
|
+
### 1. Claim a generation above everything that exists
|
|
53
|
+
|
|
54
|
+
Keep the existing pointer read, and add one statement beside it:
|
|
55
|
+
|
|
56
|
+
```sql
|
|
57
|
+
SELECT MAX(generation) AS g FROM directory WHERE accountId = ?
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`next = Math.max(current, maxStagedGeneration) + 1`.
|
|
61
|
+
|
|
62
|
+
A peer that has already inserted at least one row is visible in that MAX, so the two
|
|
63
|
+
writers claim different generations and neither stages into the other's rows.
|
|
64
|
+
|
|
65
|
+
Two statements rather than one nested `UNION ALL`: each is trivially readable, each is
|
|
66
|
+
trivially fakeable in a test, and the pointer read stays the push's first statement,
|
|
67
|
+
which an existing test asserts.
|
|
68
|
+
|
|
69
|
+
### 2. The flip only ever moves the pointer forward
|
|
70
|
+
|
|
71
|
+
```sql
|
|
72
|
+
INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?)
|
|
73
|
+
ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration
|
|
74
|
+
WHERE excluded.currentGeneration > directory_state.currentGeneration
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
SQLite has supported a `WHERE` clause on upsert's `DO UPDATE` since 3.24, and D1 is
|
|
78
|
+
SQLite. This is what makes a losing writer harmless: it cannot drag readers back onto a
|
|
79
|
+
generation the winner has already swept.
|
|
80
|
+
|
|
81
|
+
The D1 client returns rows, not affected-row meta, so the flip is followed by a pointer
|
|
82
|
+
readback. That readback is the branch point:
|
|
83
|
+
|
|
84
|
+
- **Pointer equals `next`.** We won. Log `op=flip`, sweep, verify, return. Unchanged
|
|
85
|
+
behaviour.
|
|
86
|
+
- **Pointer is above `next`.** A peer won. Log
|
|
87
|
+
`op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
|
|
88
|
+
and return without sweeping and without verifying. Both of those would measure the
|
|
89
|
+
peer's tree against our expected row count, and `op=verify` would read as a failure
|
|
90
|
+
when nothing failed.
|
|
91
|
+
|
|
92
|
+
Our staged rows at the losing generation are left in place. They are invisible (no
|
|
93
|
+
reader sees a generation the pointer does not name) and the winner's next sweep collects
|
|
94
|
+
them.
|
|
95
|
+
|
|
96
|
+
### 3. The sweep only ever removes older generations
|
|
97
|
+
|
|
98
|
+
```sql
|
|
99
|
+
DELETE FROM directory WHERE accountId = ? AND generation < ?
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`< next` rather than `!= next`, so a writer never deletes a peer's higher in-flight
|
|
103
|
+
stage. Rows above `next` from an abandoned stage still converge: the following cycle
|
|
104
|
+
claims a generation above them and sweeps them once it flips.
|
|
105
|
+
|
|
106
|
+
### 4. The Task 1923 pre-stage clear is removed
|
|
107
|
+
|
|
108
|
+
Because `next` is now always above every existing generation for the account, the orphan
|
|
109
|
+
`SELECT COUNT(*) ... generation = next` can only ever return 0 and the pre-stage DELETE
|
|
110
|
+
can never match a row. Leaving unreachable code whose comment claims it is the wedge
|
|
111
|
+
heal would be false narration, so the orphan count, the DELETE, the `op=stage-clear` log
|
|
112
|
+
line and its two tests go.
|
|
113
|
+
|
|
114
|
+
Task 1923's outcome is preserved by construction and is stronger than before. A stage
|
|
115
|
+
that died mid-batch leaves rows at generation 6; the next run claims 7, so there is no
|
|
116
|
+
UNIQUE collision to freeze on, and the gen-6 orphans are swept after the flip. The heal
|
|
117
|
+
now happens with no DELETE before the flip at all, which is exactly what removes this
|
|
118
|
+
task's hazard rather than narrowing it.
|
|
119
|
+
|
|
120
|
+
This was confirmed with the operator before the spec was written, because Task 1926
|
|
121
|
+
lists Task 1923's heal as out of scope.
|
|
122
|
+
|
|
123
|
+
## Cases covered, and the one left fail-closed
|
|
124
|
+
|
|
125
|
+
**Peer reads the generation after we have staged a row.** It claims a higher generation.
|
|
126
|
+
Both pushes complete. The forward-only flip picks the winner. The reader sees the old
|
|
127
|
+
complete tree, then one complete new tree. Neither push fails.
|
|
128
|
+
|
|
129
|
+
**Both read the generation before either inserts.** Both claim the same generation. The
|
|
130
|
+
second writer's first INSERT hits `UNIQUE (accountId, relPath, generation)` and that push
|
|
131
|
+
throws. Both walks resolve the same exposed set and `walkExposed` emits the exposed dirs
|
|
132
|
+
in the same order, so the second writer collides on its first row and contributes
|
|
133
|
+
nothing. The reader is safe: the winner's tree is complete and the pointer never moved
|
|
134
|
+
backward. The loser logs `op=failed` and the next 60s cycle succeeds.
|
|
135
|
+
|
|
136
|
+
This second case is left fail-closed rather than retried. The task's success criterion is
|
|
137
|
+
about what a reader can see, and a loud failure that self-corrects one cycle later meets
|
|
138
|
+
it. A retry-on-collision path would be an invented requirement.
|
|
139
|
+
|
|
140
|
+
**The one residual, stated rather than fixed.** The claim above relies on both walks
|
|
141
|
+
starting with the same row. If the account's `SCHEMA.md` or `data-portal.json` changes in
|
|
142
|
+
the window between the two generation reads, the two walks can begin with different rows,
|
|
143
|
+
so the second writer contributes some non-overlapping rows at the shared generation before
|
|
144
|
+
colliding on a shared one. The winner then flips onto its own complete tree plus those few
|
|
145
|
+
extra rows. That is not an empty tree and not a partial one, so it does not breach the
|
|
146
|
+
success criterion, and the next cycle's sweep removes the whole generation. No task file is
|
|
147
|
+
filed for it because nothing is deferred: the case is inside what this design covers, and
|
|
148
|
+
its outcome is a stale row for one cycle rather than a reader-visible break.
|
|
149
|
+
|
|
150
|
+
## Observability
|
|
151
|
+
|
|
152
|
+
`op=stage-contended account=<id> generation=<next> pointer=<actual> action=superseded`
|
|
153
|
+
is emitted when a push's flip was superseded by a peer. This deviates from the task
|
|
154
|
+
file's `action=<waited|skipped>` wording: the design never waits and never skips work, so
|
|
155
|
+
neither value describes what happened. `superseded` names the actual outcome, and
|
|
156
|
+
`pointer=` names the generation that won, which is what an operator needs to correlate
|
|
157
|
+
the two runs.
|
|
158
|
+
|
|
159
|
+
`op=stage-clear` is retired with the code that emitted it. The task file's line that a
|
|
160
|
+
`stage-clear` with `cleared=` equal to the live tree size must never appear is satisfied
|
|
161
|
+
absolutely: no `stage-clear` line can appear at all.
|
|
162
|
+
|
|
163
|
+
## Testing
|
|
164
|
+
|
|
165
|
+
Both new tests drive two interleaved `pushAccount` calls against one shared fake store
|
|
166
|
+
and snapshot the reader's view after every statement. The assertion is the success
|
|
167
|
+
criterion stated directly: the visible tree is never empty and never a strict subset of
|
|
168
|
+
a complete tree.
|
|
169
|
+
|
|
170
|
+
1. **The task file's interleave.** Writer B reads the generation, then pauses. Writer A
|
|
171
|
+
runs to completion. B resumes. Red on current code, because B's pre-stage DELETE binds
|
|
172
|
+
the generation the pointer now names and the snapshot goes empty. Green after, because
|
|
173
|
+
B collides on UNIQUE and A's tree is untouched. This test also asserts that no DELETE
|
|
174
|
+
is ever issued naming the pointer's current generation, which is the other half of the
|
|
175
|
+
success criterion.
|
|
176
|
+
|
|
177
|
+
2. **The higher-claim interleave.** Writer A stages fully but pauses before its flip.
|
|
178
|
+
Writer B reads the generation, sees A's staged rows in the MAX, claims the next one up,
|
|
179
|
+
and completes. A resumes and its flip is a no-op. Asserts the pointer never moves
|
|
180
|
+
backward, that A emits `op=stage-contended` with `action=superseded`, and that A
|
|
181
|
+
issues no sweep.
|
|
182
|
+
|
|
183
|
+
The existing 27 tests stay green apart from the two Task 1923 stage-clear tests, which
|
|
184
|
+
are removed with the code they cover.
|
|
185
|
+
|
|
186
|
+
## Scope boundaries
|
|
187
|
+
|
|
188
|
+
**In scope:** the generation claim, the forward-only flip, the backward-only sweep, the
|
|
189
|
+
removal of the pre-stage clear, and the two-writer regression tests.
|
|
190
|
+
|
|
191
|
+
**Out of scope:** INSERT batching (Task 1842). Cross-account contention (separate
|
|
192
|
+
generation counters, no contention). Any change to `runTargets`, `recordSuccess`, or the
|
|
193
|
+
portal reader in `files.ts`.
|
|
194
|
+
|
|
195
|
+
## Deployment
|
|
196
|
+
|
|
197
|
+
`portal-index-push.mjs` ships inside the installer payload and is inlined into the admin
|
|
198
|
+
server bundle. No D1 migration is required: every statement runs against the existing
|
|
199
|
+
`directory` and `directory_state` tables as defined in
|
|
200
|
+
`platform/plugins/cloudflare/skills/data-portal/schema.sql`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: platform-architecture
|
|
3
3
|
description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
|
|
4
|
-
content-hash: sha256:
|
|
4
|
+
content-hash: sha256:aabbb6dbe5d9f4fe8ff6f7b3e2f06a46bd1c5281ea46b820829ab2b3bc93a0c0
|
|
5
5
|
brand: sitedesk-code
|
|
6
6
|
product-name: SiteDesk
|
|
7
7
|
---
|
|
@@ -3189,8 +3189,8 @@ explicit `?plugin=` parameter, not inferred from `--` in the slug.
|
|
|
3189
3189
|
|
|
3190
3190
|
| Route | Behaviour |
|
|
3191
3191
|
|-------|-----------|
|
|
3192
|
-
| `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `
|
|
3193
|
-
| `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk,
|
|
3192
|
+
| `GET /api/admin/agents` | Session-scoped. Lists the session account's public `agents/*/` dirs (never the `admin` agent), its user-created specialists from `plugins/*/agents/*.md`, and its shipped specialists, each row tagged `kind` and `origin` and carrying `risk`, `riskiestTool`, `unresolved` and `disabled`. Returns `{agents, accountId, skipped, specialistsSkipped, shippedSkipped}`. 401 when the session maps to no account. |
|
|
3193
|
+
| `GET /api/admin/agents/:slug` | Session-scoped. Without `?plugin=` or `?origin=`, returns a public agent's config fields + four owned docs + a `present` map (a missing or unreadable doc is `''`/`present.<role>=false`, never a 500). With `?plugin=`, that plugin's user-created specialist; with `?origin=shipped`, the shipped one. Both specialist shapes add `{risk, riskiestTool, unresolved, byTool, disabled}`, where `byTool` is one class per declared tool. Selection is explicit rather than inferred, because a premium file and a user-created specialist under a plugin of the same name produce the same slug. Unknown 404s, 401 as above. |
|
|
3194
3194
|
| `POST /api/admin/agents/:slug/disable?accountId=` | Shipped agents only, enforced here: a slug present in none of the three shipped directories 404s. Moves the file from `specialists/agents/` to `specialists/agents-disabled/` and records the basename in the 0600 store. Returns `{ok, moved}`; `moved:false` means the agent existed only as a bundled template, so nothing was there to move and the store entry is what stops the next provisioning run delivering it. The store is read strictly before anything moves, so an unreadable store 500s with the file untouched rather than rewriting the file whole from an empty set and dropping every other disabled agent. Same `accountId` contract as delete. |
|
|
3195
3195
|
| `POST /api/admin/agents/:slug/enable?accountId=` | Covers both of disable's outcomes. Returns `{ok, restored}`: a quarantined file moves back (`restored:true`); a bundled-only agent has nothing to move, so clearing the store entry is the whole job (`restored:false`), which is what stops provisioning withholding it. Treating that second case as "nothing to restore" made disable a one-way door. 404 only when neither directory nor store knows the agent. |
|
|
3196
3196
|
| `DELETE /api/admin/agents/:slug?accountId=` | Public agents only, on the named validated account. Removes the dir after `deleteAgentProjection`; refuses the `admin` slug (403) and a missing/unknown `accountId` (400) with no write. Loud-fail: a graph-cleanup throw aborts the file removal. |
|
|
@@ -3220,10 +3220,15 @@ Agents page survived undiagnosed on a multi-account install.
|
|
|
3220
3220
|
`reason=<graph-cleanup-failed|rm-error>` on failure (files preserved on a
|
|
3221
3221
|
graph-cleanup throw).
|
|
3222
3222
|
- `op=classify account=<id8> agent=<name> origin=<shipped|public|specialist>
|
|
3223
|
-
risk=<green|amber|red>
|
|
3223
|
+
risk=<green|amber|red> riskiestTool=<tool> tools=<n> unresolved=<comma-list|none>
|
|
3224
3224
|
disabled=<bool>` — one line per classified agent per listing, so it fires on
|
|
3225
|
-
every page load. `
|
|
3226
|
-
re-deriving it.
|
|
3225
|
+
every page load. `riskiestTool` makes a wrong class diagnosable without
|
|
3226
|
+
re-deriving it. Its value is the FULL canonical tool name, while the card
|
|
3227
|
+
renders only the segment after the plugin prefix. One load
|
|
3228
|
+
showing `riskiest tool: memory-update` on the card and
|
|
3229
|
+
`riskiestTool=mcp__plugin_memory_memory__memory-update` on this line is the
|
|
3230
|
+
confirmation that both derive from one value.
|
|
3231
|
+
**A non-empty `unresolved=` is the one to act on**: the agent
|
|
3227
3232
|
is forced red, and the cause is that the built-in table or the registry
|
|
3228
3233
|
mapping has drifted, not that the agent changed.
|
|
3229
3234
|
- `op=risk-surface status=load-failed reason=<msg>` — the plugin registry did
|
|
@@ -9,6 +9,12 @@ Invoked by the admin agent directly.
|
|
|
9
9
|
|
|
10
10
|
This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
|
|
11
11
|
|
|
12
|
+
## 2026-07-26 (0.1.509)
|
|
13
|
+
|
|
14
|
+
- The Agents page now names the tool that set an agent's risk level, and shows it by its short name instead of the full internal path.
|
|
15
|
+
- Publishing a site twice at the same time can no longer delete the live copy; the later publish now stands down instead of clearing the tree out from under the first.
|
|
16
|
+
- A slow dashboard now records where the time actually went, so a sluggish load can be traced rather than guessed at.
|
|
17
|
+
|
|
12
18
|
## 2026-07-25 (0.1.508)
|
|
13
19
|
|
|
14
20
|
- SiteDesk's screens now use a single, cleaner typeface throughout, and the old olive-green colour is gone.
|
|
@@ -122,7 +122,8 @@ export async function walkExposed(accountDir, exposed) {
|
|
|
122
122
|
* client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
|
|
123
123
|
* log: (line: string) => void, nowIso: string,
|
|
124
124
|
* exposeFolders?: string[], previousExposed?: string[] }} opts
|
|
125
|
-
* @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean
|
|
125
|
+
* @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean,
|
|
126
|
+
* generation?: number, superseded?: boolean }>}
|
|
126
127
|
*/
|
|
127
128
|
export async function pushAccount(opts) {
|
|
128
129
|
const { accountDir, accountId, client, log, nowIso } = opts
|
|
@@ -196,29 +197,26 @@ export async function pushAccount(opts) {
|
|
|
196
197
|
[accountId],
|
|
197
198
|
)
|
|
198
199
|
const current = Number(state[0]?.currentGeneration ?? 0)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
200
|
+
|
|
201
|
+
// Claim a generation above EVERY generation this account already holds, not
|
|
202
|
+
// merely above the pointer (Task 1926). Two pushes of one account are
|
|
203
|
+
// reachable: the admin server's 60s loop and a hand-run `--account` are
|
|
204
|
+
// separate processes, so no in-process lock can see both. `current + 1` let
|
|
205
|
+
// the second writer claim the generation the first had just flipped onto and
|
|
206
|
+
// then delete it. A peer that has inserted even one row is visible in this
|
|
207
|
+
// MAX, so the two runs claim different generations.
|
|
208
|
+
//
|
|
209
|
+
// This also retires the Task 1923 pre-stage clear. That clear existed because
|
|
210
|
+
// a later run recomputed the SAME generation as a dead partial stage and
|
|
211
|
+
// collided on UNIQUE(accountId, relPath, generation); claiming above the
|
|
212
|
+
// orphans leaves nothing to collide with, and the post-flip sweep collects
|
|
213
|
+
// them. The clear was itself the DELETE a concurrent peer could aim at the
|
|
214
|
+
// live tree, so removing it removes the hazard rather than narrowing it.
|
|
215
|
+
const staged = await client.query(
|
|
216
|
+
'SELECT MAX(generation) AS g FROM directory WHERE accountId = ?',
|
|
217
|
+
[accountId],
|
|
213
218
|
)
|
|
214
|
-
const
|
|
215
|
-
if (orphanCount > 0) {
|
|
216
|
-
await client.query('DELETE FROM directory WHERE accountId = ? AND generation = ?', [
|
|
217
|
-
accountId,
|
|
218
|
-
next,
|
|
219
|
-
])
|
|
220
|
-
log(`${TAG} op=stage-clear account=${accountId} generation=${next} cleared=${orphanCount}`)
|
|
221
|
-
}
|
|
219
|
+
const next = Math.max(current, Number(staged[0]?.g ?? 0)) + 1
|
|
222
220
|
|
|
223
221
|
for (const row of rows) {
|
|
224
222
|
await client.query(
|
|
@@ -231,18 +229,56 @@ export async function pushAccount(opts) {
|
|
|
231
229
|
|
|
232
230
|
// THE atomic point. Upsert rather than insert-or-update, so an account
|
|
233
231
|
// publishing for the first time and one republishing take the same path.
|
|
232
|
+
//
|
|
233
|
+
// The WHERE moves the pointer FORWARD ONLY (Task 1926). Without it a writer
|
|
234
|
+
// that was overtaken while staging drags every reader back onto a generation
|
|
235
|
+
// the winner has already swept, which is an empty tree. SQLite has carried a
|
|
236
|
+
// WHERE on upsert's DO UPDATE since 3.24, and D1 is SQLite.
|
|
234
237
|
await client.query(
|
|
235
238
|
'INSERT INTO directory_state (accountId, currentGeneration) VALUES (?, ?) ' +
|
|
236
|
-
'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration'
|
|
239
|
+
'ON CONFLICT (accountId) DO UPDATE SET currentGeneration = excluded.currentGeneration ' +
|
|
240
|
+
'WHERE excluded.currentGeneration > directory_state.currentGeneration',
|
|
237
241
|
[accountId, next],
|
|
238
242
|
)
|
|
243
|
+
|
|
244
|
+
// Read back rather than assume: the D1 client returns rows, not affected-row
|
|
245
|
+
// meta, so a suppressed flip is otherwise indistinguishable from an applied
|
|
246
|
+
// one.
|
|
247
|
+
const flipped = await client.query(
|
|
248
|
+
'SELECT currentGeneration FROM directory_state WHERE accountId = ?',
|
|
249
|
+
[accountId],
|
|
250
|
+
)
|
|
251
|
+
const pointer = Number(flipped[0]?.currentGeneration ?? 0)
|
|
252
|
+
if (pointer !== next) {
|
|
253
|
+
// A peer published while this run was staging. Its tree is live and
|
|
254
|
+
// complete, ours is invisible, and its rows are collected by the peer's
|
|
255
|
+
// next sweep. Sweeping or verifying from here would act on, and measure,
|
|
256
|
+
// the PEER's tree — reporting a failure where nothing failed. Logged rather
|
|
257
|
+
// than silent: a suppressed run is otherwise indistinguishable from one
|
|
258
|
+
// that never started.
|
|
259
|
+
log(
|
|
260
|
+
`${TAG} op=stage-contended account=${accountId} generation=${next} ` +
|
|
261
|
+
`pointer=${pointer} action=superseded`,
|
|
262
|
+
)
|
|
263
|
+
return {
|
|
264
|
+
exposed: r.exposed,
|
|
265
|
+
rows: rows.length,
|
|
266
|
+
schemaPresent: true,
|
|
267
|
+
generation: next,
|
|
268
|
+
superseded: true,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
239
271
|
log(`${TAG} op=flip account=${accountId} generation=${next}`)
|
|
240
272
|
|
|
241
273
|
// Housekeeping only. A failure here leaves stale rows nothing reads, which is
|
|
242
274
|
// waste rather than incorrectness — so it must not fail the publish that has
|
|
243
275
|
// already succeeded.
|
|
244
276
|
try {
|
|
245
|
-
|
|
277
|
+
// Strictly older only (Task 1926): `!= next` would delete a peer's HIGHER
|
|
278
|
+
// in-flight stage. Rows above `next` left by an abandoned stage still
|
|
279
|
+
// converge, because the next cycle claims a generation above them and
|
|
280
|
+
// sweeps them once it flips.
|
|
281
|
+
await client.query('DELETE FROM directory WHERE accountId = ? AND generation < ?', [
|
|
246
282
|
accountId,
|
|
247
283
|
next,
|
|
248
284
|
])
|
|
@@ -291,9 +327,13 @@ export function readState(platformRoot) {
|
|
|
291
327
|
}
|
|
292
328
|
|
|
293
329
|
/**
|
|
294
|
-
* Written
|
|
295
|
-
* succeeding rather than merely stopped logging.
|
|
296
|
-
*
|
|
330
|
+
* Written after a publish that reached the flip, so a stale entry means the
|
|
331
|
+
* push stopped succeeding rather than merely stopped logging. That includes a
|
|
332
|
+
* run superseded by a concurrent peer (Task 1926), which emits
|
|
333
|
+
* `op=stage-contended` instead of `op=verify`: the index WAS refreshed inside
|
|
334
|
+
* this cycle, by the peer, and recording otherwise would report a healthy
|
|
335
|
+
* account as never-ran. Read-modify-write per account so one account's failure
|
|
336
|
+
* does not erase another's record.
|
|
297
337
|
* @param {string} platformRoot @param {string} accountId
|
|
298
338
|
* @param {{lastPushMs: number, rows: number, exposed: string[]}} entry
|
|
299
339
|
*/
|