@nanobpm/nano-workforce 0.100.0 → 0.101.0

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.
@@ -0,0 +1,320 @@
1
+ // nano-workforce — the durable world-checkpoint + effect-ledger store (issue #324, ADR 0062 Slice
2
+ // 4/5, the WORLD half). Backs the ./checkpoint.ts orchestration onto the app's SQLite DataLayer via
3
+ // the RAD `Table<T>` surface (`data.table(...)`) — NOT hand-written SQL — over the two tables added
4
+ // by `db/migrations/049_world_checkpoint.sql`.
5
+ //
6
+ // The store is the ONE canonical home of the world's durable resume state: the push-checkpoints
7
+ // (`world_checkpoints`, a per-PR monotonic offset → commit SHA) and the effect ledger + fence
8
+ // (`world_effects`). It exposes exactly what the forward path (record a checkpoint at a push) and the
9
+ // restore path (read the last checkpoint, fence-replay its effect tail) need — nothing derivable is
10
+ // duplicated (AGENTS.md "derivation over duplication": the tree is derived from `remote SHA +
11
+ // effect-tail`, never snapshot into a log).
12
+ import type { DataLayer, Table } from "@nanobpm/urban";
13
+ import type { Effect, EffectKind, Fence } from "./effect-ledger.ts";
14
+
15
+ /** A persisted push-checkpoint row (`world_checkpoints`). */
16
+ interface WorldCheckpointRow {
17
+ id: number;
18
+ pr_key: string;
19
+ round_no: number;
20
+ checkpoint_offset: number;
21
+ commit_sha: string;
22
+ created_at: string;
23
+ }
24
+
25
+ /** A persisted effect-ledger row (`world_effects`). */
26
+ interface WorldEffectRow {
27
+ id: number;
28
+ pr_key: string;
29
+ checkpoint_offset: number;
30
+ seq: number;
31
+ kind: EffectKind;
32
+ idempotency_key: string;
33
+ description: string | null;
34
+ applied: number;
35
+ created_at: string;
36
+ }
37
+
38
+ /** The newest push-checkpoint for a PR — the durable resume boundary a replacement activation
39
+ * reconstructs the working tree to. `offset` is the shared mind/world turn boundary. */
40
+ export interface LastCheckpoint {
41
+ readonly offset: number;
42
+ readonly commitSha: string;
43
+ readonly roundNo: number;
44
+ }
45
+
46
+ /** The inputs recording a world checkpoint at a push: the pushed SHA plus the irreversible effects
47
+ * that round performed (the push itself, any PR comment, any merge). */
48
+ export interface RecordCheckpointInput {
49
+ readonly prKey: string;
50
+ readonly roundNo: number;
51
+ readonly commitSha: string;
52
+ /** Irreversible effects performed this round, in the order they occurred. Defaults to a single
53
+ * `push` effect keyed by the commit SHA when omitted. Each is recorded through the fence, so a
54
+ * re-record of an already-known idempotency key is a no-op (never a second real effect). */
55
+ readonly effects?: readonly Effect[];
56
+ /** Whether the recorded effects have already been realised on the forward path (default `true` —
57
+ * a checkpoint records what a round already DID). A pending tail effect (recorded before it is
58
+ * performed, for crash-precise replay) is recorded with `applied: false`. */
59
+ readonly applied?: boolean;
60
+ }
61
+
62
+ /** A durable store over the `world_checkpoints` + `world_effects` tables. */
63
+ export class WorldStore {
64
+ readonly #data: DataLayer;
65
+
66
+ constructor(data: DataLayer) {
67
+ this.#data = data;
68
+ }
69
+
70
+ #checkpoints() {
71
+ return this.#data.table<WorldCheckpointRow>("world_checkpoints", "id");
72
+ }
73
+
74
+ #effects() {
75
+ return this.#data.table<WorldEffectRow>("world_effects", "id");
76
+ }
77
+
78
+ /** The next per-PR monotonic checkpoint offset (0 for the first, else `max + 1`). Derived from the
79
+ * durable rows so it survives a process restart — the offset is never held in memory. */
80
+ async nextOffset(prKey: string): Promise<number> {
81
+ return WorldStore.#nextOffsetOn(this.#checkpoints(), prKey);
82
+ }
83
+
84
+ /** The next offset computed against a specific checkpoints `Table` handle — so it can be read on the
85
+ * SAME transaction connection the checkpoint row is then inserted on (see {@link recordCheckpoint}),
86
+ * keeping the allocate-then-insert atomic. */
87
+ static async #nextOffsetOn(checkpoints: Table<WorldCheckpointRow>, prKey: string): Promise<number> {
88
+ const rows = await checkpoints.find({ pr_key: prKey });
89
+ if (rows.length === 0) return 0;
90
+ return Math.max(...rows.map((r) => r.checkpoint_offset)) + 1;
91
+ }
92
+
93
+ /** The next intra-checkpoint `seq` for `{prKey, offset}` — `0` for a fresh offset, else `max + 1`.
94
+ * When a re-record REUSES an existing offset (idempotent on the commit SHA), any newly-supplied
95
+ * effect must be appended AFTER the tail already recorded there, not restart at `seq 0` and collide
96
+ * with it. Read on the same transaction handle the effects are then appended on. */
97
+ static async #nextSeqOn(effects: Table<WorldEffectRow>, prKey: string, offset: number): Promise<number> {
98
+ const rows = await effects.find({ pr_key: prKey, checkpoint_offset: offset });
99
+ if (rows.length === 0) return 0;
100
+ return Math.max(...rows.map((r) => r.seq)) + 1;
101
+ }
102
+
103
+ /** True when `err` is the durable fence firing — a SQLite `UNIQUE constraint failed` raised because a
104
+ * concurrent/duplicate writer inserted the row BETWEEN our `findOne` and our `insert`. Every insert
105
+ * in this store guards a UNIQUE constraint (`UNIQUE(pr_key, commit_sha)` / `(pr_key, checkpoint_offset)`
106
+ * on checkpoints, `UNIQUE(pr_key, idempotency_key)` on effects), so a check-then-insert is inherently
107
+ * racy under the at-least-once persist-round delivery + a distributed fleet. This is the ONE place the
108
+ * store classifies a fence collision, so the three insert sites below turn it into the SAME intended
109
+ * idempotent outcome instead of each re-encoding the driver's error shape (a drift surface). Matched on
110
+ * the message substring the RAD `Table` surface propagates verbatim — the same one the schema tests
111
+ * assert on — because that surface hides the concrete driver error type. */
112
+ static #isFenceCollision(err: unknown): boolean {
113
+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
114
+ }
115
+
116
+ /** Reconcile an existing ledger row's `applied` flag toward a LATER record's knowledge: flip a
117
+ * still-pending row (`applied=0`) to realised (`applied=1`) once we know the effect has now landed
118
+ * (`applied=true`). This is monotone — it NEVER un-applies a row (`1` never goes back to `0`) and
119
+ * never marks a still-pending record applied — so a re-record can only ever advance the fence, not
120
+ * retreat it. It is the ONE place a pending→applied transition is written, so the record path
121
+ * (`#appendEffect`) and the replay path (`markApplied`) reconcile identically instead of each
122
+ * re-encoding the flip (a drift surface). Without it, `#appendEffect`'s duplicate-key short-circuit
123
+ * would leave a pending row pending forever even after the effect landed, so a later `restoreWorld`
124
+ * would re-apply an already-executed side effect. */
125
+ static async #reconcileApplied(
126
+ effects: Table<WorldEffectRow>,
127
+ row: WorldEffectRow,
128
+ applied: boolean,
129
+ ): Promise<void> {
130
+ if (applied && row.applied !== 1) await effects.update(row.id, { applied: 1 });
131
+ }
132
+
133
+ /** Insert the checkpoint row for a not-yet-seen `{prKey, commitSha}`, allocating the next offset, and
134
+ * tolerate the fence firing. `recordCheckpoint`'s `findOne`-then-insert is racy: a concurrent/duplicate
135
+ * persist-round can insert the SAME SHA between our read and our write, so the insert hits
136
+ * `UNIQUE(pr_key, commit_sha)`. Rather than surface it as a spurious job failure, re-read the winner's
137
+ * row and REUSE its offset — the exact idempotent-on-SHA outcome the non-racing path yields. If the
138
+ * collision was instead a pure offset race with a DIFFERENT SHA (`UNIQUE(pr_key, checkpoint_offset)`),
139
+ * the SHA re-read misses and we rethrow, letting the job retry allocate a fresh offset. Returns the
140
+ * offset the SHA is durably bound to. */
141
+ static async #insertCheckpointFenced(
142
+ checkpoints: Table<WorldCheckpointRow>,
143
+ prKey: string,
144
+ roundNo: number,
145
+ commitSha: string,
146
+ now: string,
147
+ ): Promise<number> {
148
+ const offset = await WorldStore.#nextOffsetOn(checkpoints, prKey);
149
+ try {
150
+ await checkpoints.insert({
151
+ pr_key: prKey,
152
+ round_no: roundNo,
153
+ checkpoint_offset: offset,
154
+ commit_sha: commitSha,
155
+ created_at: now,
156
+ });
157
+ return offset;
158
+ } catch (err) {
159
+ if (!WorldStore.#isFenceCollision(err)) throw err;
160
+ const raced = await checkpoints.findOne({ pr_key: prKey, commit_sha: commitSha });
161
+ if (!raced) throw err;
162
+ return raced.checkpoint_offset;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Record a push-checkpoint: allocate the next offset, persist `{commitSha}`, and append the round's
168
+ * irreversible effects to the ledger (each fenced by `UNIQUE(pr_key, idempotency_key)`). Returns
169
+ * the allocated offset — the SAME offset the mind's `session.checkpoint(...)` is joined to (see
170
+ * ./checkpoint.ts), which is what keeps mind + world from diverging.
171
+ *
172
+ * ATOMIC: the checkpoint row and its whole effect ledger are written inside ONE transaction, so a
173
+ * crash/error part-way can never leave a checkpoint whose fence is missing some/all of its ledger
174
+ * rows — which would let a restore re-apply an effect that already landed. Any throw rolls the whole
175
+ * write back (offset allocation included).
176
+ *
177
+ * Idempotent on the effect fence: an effect whose idempotency key is already in the ledger is not
178
+ * re-inserted (so a duplicate record can never manufacture a second real effect). The checkpoint
179
+ * row itself is guarded by `UNIQUE(pr_key, checkpoint_offset)`.
180
+ *
181
+ * Idempotent on the commit SHA: a re-record of the SAME `{prKey, commitSha}` (a retried/duplicate
182
+ * persist-round job) REUSES the existing checkpoint's offset instead of allocating a fresh one.
183
+ * Allocating a new offset would make a duplicate the newest `lastCheckpoint` while its effect tail
184
+ * is empty (the global `(pr_key, idempotency_key)` fence skips the already-recorded effects), so a
185
+ * later `restoreWorld` would read the empty tail and IGNORE genuinely-pending effects recorded on
186
+ * the earlier offset — silent effect loss. Reusing the offset keeps the pending tail attached to
187
+ * the surviving newest checkpoint; any newly-supplied effect is appended at that same offset.
188
+ */
189
+ async recordCheckpoint(input: RecordCheckpointInput): Promise<number> {
190
+ const { prKey, roundNo, commitSha } = input;
191
+ const effects: readonly Effect[] = input.effects ?? [{ kind: "push", idempotencyKey: commitSha }];
192
+ const applied = input.applied ?? true;
193
+ const now = new Date().toISOString();
194
+ return this.#data.open().tx(async (t) => {
195
+ const checkpoints = t.table<WorldCheckpointRow>("world_checkpoints", "id");
196
+ const effectsTable = t.table<WorldEffectRow>("world_effects", "id");
197
+ const existing = await checkpoints.findOne({ pr_key: prKey, commit_sha: commitSha });
198
+ const offset = existing
199
+ ? existing.checkpoint_offset
200
+ : await WorldStore.#insertCheckpointFenced(checkpoints, prKey, roundNo, commitSha, now);
201
+ let seq = await WorldStore.#nextSeqOn(effectsTable, prKey, offset);
202
+ for (const effect of effects) {
203
+ await WorldStore.#appendEffect(effectsTable, prKey, offset, seq++, effect, applied, now);
204
+ }
205
+ return offset;
206
+ });
207
+ }
208
+
209
+ /** Append one effect to the ledger via `effects`, collapsing a re-record of an idempotency key
210
+ * already present to the durable fence's no-op (the second record of one real effect is never a
211
+ * second row). A re-record still RECONCILES the surviving row's `applied` flag: a tail effect first
212
+ * recorded pending (`applied=false`) and later re-recorded once it landed (`applied=true`) must
213
+ * advance the fence to realised, or a later `restoreWorld` would re-apply an already-executed side
214
+ * effect. Static so a transaction-scoped `Table` can be threaded in (see {@link recordCheckpoint}). */
215
+ static async #appendEffect(
216
+ effects: Table<WorldEffectRow>,
217
+ prKey: string,
218
+ offset: number,
219
+ seq: number,
220
+ effect: Effect,
221
+ applied: boolean,
222
+ now: string,
223
+ ): Promise<void> {
224
+ const existing = await effects.findOne({ pr_key: prKey, idempotency_key: effect.idempotencyKey });
225
+ if (existing) {
226
+ await WorldStore.#reconcileApplied(effects, existing, applied);
227
+ return;
228
+ }
229
+ try {
230
+ await effects.insert({
231
+ pr_key: prKey,
232
+ checkpoint_offset: offset,
233
+ seq,
234
+ kind: effect.kind,
235
+ idempotency_key: effect.idempotencyKey,
236
+ description: effect.description ?? null,
237
+ applied: applied ? 1 : 0,
238
+ created_at: now,
239
+ });
240
+ } catch (err) {
241
+ // A concurrent/duplicate writer recorded this idempotency key between our `findOne` and our
242
+ // `insert` — the fence firing IS the intended outcome (one real effect → exactly one row), so
243
+ // treat the collision as the same no-op the `existing` short-circuit above already is, not a
244
+ // surfaced error that fails the persist-round. Still reconcile the raced row's `applied` flag,
245
+ // exactly as the `existing` branch does, so a landed effect isn't left pending on the winner row.
246
+ if (!WorldStore.#isFenceCollision(err)) throw err;
247
+ const raced = await effects.findOne({ pr_key: prKey, idempotency_key: effect.idempotencyKey });
248
+ if (raced) await WorldStore.#reconcileApplied(effects, raced, applied);
249
+ }
250
+ }
251
+
252
+ /** The newest push-checkpoint for a PR, or `null` when the PR has none (nothing pushed yet, so
253
+ * nothing to reconstruct — the caller keeps the freshly-provisioned worktree). */
254
+ async lastCheckpoint(prKey: string): Promise<LastCheckpoint | null> {
255
+ const rows = await this.#checkpoints().find({ pr_key: prKey });
256
+ if (rows.length === 0) return null;
257
+ const newest = rows.reduce((a, b) => (b.checkpoint_offset > a.checkpoint_offset ? b : a));
258
+ return { offset: newest.checkpoint_offset, commitSha: newest.commit_sha, roundNo: newest.round_no };
259
+ }
260
+
261
+ /** The effect tail recorded at a checkpoint offset, in `seq` order — the sequence the restore path
262
+ * fence-replays after checking the working tree out to that checkpoint's SHA. `seq` is the intended
263
+ * order, but it is allocated by a racy read-max-plus-one (`#nextSeqOn`) and the schema has no
264
+ * `UNIQUE(pr_key, checkpoint_offset, seq)`, so two concurrent inserts at one offset CAN land the same
265
+ * `seq`. The monotonic autoincrement `id` breaks that tie so the tail is deterministic (a stable
266
+ * insertion order) even under a `seq` collision — never a non-deterministic replay/audit order. */
267
+ async effectTail(prKey: string, offset: number): Promise<Effect[]> {
268
+ const rows = await this.#effects().find({ pr_key: prKey, checkpoint_offset: offset });
269
+ return rows
270
+ .sort((a, b) => a.seq - b.seq || a.id - b.id)
271
+ .map((r) => ({
272
+ kind: r.kind,
273
+ idempotencyKey: r.idempotency_key,
274
+ ...(r.description == null ? {} : { description: r.description }),
275
+ }));
276
+ }
277
+
278
+ /** The durable {@link Fence} bound to a PR: `isApplied` is true once an effect's idempotency key is
279
+ * in the ledger AND realised (`applied=1`); `markApplied` flips a pending effect to realised (or
280
+ * records a newly-applied one). This is the seam `fenceReplay` folds a tail over. */
281
+ fenceFor(prKey: string, offset: number): Fence {
282
+ const effects = this.#effects();
283
+ return {
284
+ async isApplied(idempotencyKey: string): Promise<boolean> {
285
+ const row = await effects.findOne({ pr_key: prKey, idempotency_key: idempotencyKey });
286
+ return !!row && row.applied === 1;
287
+ },
288
+ async markApplied(effect: Effect): Promise<void> {
289
+ const row = await effects.findOne({ pr_key: prKey, idempotency_key: effect.idempotencyKey });
290
+ if (row) {
291
+ await WorldStore.#reconcileApplied(effects, row, true);
292
+ return;
293
+ }
294
+ try {
295
+ await effects.insert({
296
+ pr_key: prKey,
297
+ checkpoint_offset: offset,
298
+ // Append AFTER the tail already recorded at this offset — restarting at `seq 0` would
299
+ // collide with a sibling effect at the same offset and make `effectTail`'s tie-sort
300
+ // (`a.seq - b.seq`) non-deterministic, destabilising restore/audit ordering.
301
+ seq: await WorldStore.#nextSeqOn(effects, prKey, offset),
302
+ kind: effect.kind,
303
+ idempotency_key: effect.idempotencyKey,
304
+ description: effect.description ?? null,
305
+ applied: 1,
306
+ created_at: new Date().toISOString(),
307
+ });
308
+ } catch (err) {
309
+ if (!WorldStore.#isFenceCollision(err)) throw err;
310
+ // A concurrent restore/replay recorded this key between our `findOne` and our `insert`. The
311
+ // desired end-state is simply "applied", which is already (or nearly) achieved — reconcile to
312
+ // it by re-reading and flipping `applied`, rather than failing the restore over a fence we
313
+ // WANTED to hold.
314
+ const raced = await effects.findOne({ pr_key: prKey, idempotency_key: effect.idempotencyKey });
315
+ if (raced) await WorldStore.#reconcileApplied(effects, raced, true);
316
+ }
317
+ },
318
+ };
319
+ }
320
+ }
@@ -0,0 +1,79 @@
1
+ // Red/green for the persist-round world-marker contract boundary (issue #324, ADR 0062 Slice 4/5).
2
+ //
3
+ // `worldMarker` arrives from the c8ctl harness OUT-OF-PROCESS, so both the fence key and the effect
4
+ // kind are untrusted. `worldMarkerOf` must (a) TRIM `idempotencyKey` so whitespace variants of one
5
+ // real effect collapse to a single fence key (not distinct ledger rows that defeat the fence) and
6
+ // (b) reject an effect whose `kind` is not one of the canonical `EFFECT_KINDS`, so an unexpected kind
7
+ // can never enter the durable ledger.
8
+ import { test } from "node:test";
9
+ import { assertEquals } from "#test-assert";
10
+ import { worldMarkerOf } from "../workers/persist-round/worker.ts";
11
+
12
+ // A well-formed 40-hex commit SHA — the ONLY shape `worldMarkerOf` now accepts for `commitSha`,
13
+ // since it is used as an EXACT checkout target on restore.
14
+ const SHA = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
15
+
16
+ test("worldMarkerOf returns null when there is no marker or no commit SHA", () => {
17
+ assertEquals(worldMarkerOf({}), null);
18
+ assertEquals(worldMarkerOf({ worldMarker: { effects: [] } }), null);
19
+ assertEquals(worldMarkerOf({ worldMarker: { commitSha: " " } }), null);
20
+ });
21
+
22
+ test("worldMarkerOf rejects a commitSha that is not a well-formed 40-hex SHA", () => {
23
+ // A branch name / arbitrary ref — would reconstruct to a moved tip, not an exact tree.
24
+ assertEquals(worldMarkerOf({ worldMarker: { commitSha: "main" } }), null);
25
+ // An abbreviated / short SHA — ambiguous, not a full object name.
26
+ assertEquals(worldMarkerOf({ worldMarker: { commitSha: "a1b2c3d" } }), null);
27
+ // 39 hex (one short) and 41 hex (one long) — length must be exactly 40.
28
+ assertEquals(worldMarkerOf({ worldMarker: { commitSha: "a".repeat(39) } }), null);
29
+ assertEquals(worldMarkerOf({ worldMarker: { commitSha: "a".repeat(41) } }), null);
30
+ // A non-hex character in an otherwise 40-char string.
31
+ assertEquals(worldMarkerOf({ worldMarker: { commitSha: `g${"a".repeat(39)}` } }), null);
32
+ });
33
+
34
+ test("worldMarkerOf trims a whitespace-tainted idempotencyKey so the fence key is canonical", () => {
35
+ const m = worldMarkerOf({
36
+ worldMarker: { commitSha: ` ${SHA} `, effects: [{ kind: "pr-comment", idempotencyKey: " c-1\n" }] },
37
+ });
38
+ assertEquals(m, { commitSha: SHA, effects: [{ kind: "pr-comment", idempotencyKey: "c-1" }] });
39
+ });
40
+
41
+ test("worldMarkerOf drops an effect whose kind is not a known EffectKind", () => {
42
+ const m = worldMarkerOf({
43
+ worldMarker: {
44
+ commitSha: SHA,
45
+ effects: [
46
+ { kind: "push", idempotencyKey: "sha-a" },
47
+ { kind: "delete-branch", idempotencyKey: "x-1" }, // unknown kind — must be rejected
48
+ { kind: "merge", idempotencyKey: " " }, // blank key — must be rejected
49
+ ],
50
+ },
51
+ });
52
+ assertEquals(m, { commitSha: SHA, effects: [{ kind: "push", idempotencyKey: "sha-a" }] });
53
+ });
54
+
55
+ test("worldMarkerOf omits effects entirely when every effect is invalid", () => {
56
+ const m = worldMarkerOf({
57
+ worldMarker: { commitSha: SHA, effects: [{ kind: "bogus", idempotencyKey: "y-1" }] },
58
+ });
59
+ assertEquals(m, { commitSha: SHA });
60
+ });
61
+
62
+ test("worldMarkerOf keeps a trimmed non-empty description and drops a blank one", () => {
63
+ const m = worldMarkerOf({
64
+ worldMarker: {
65
+ commitSha: SHA,
66
+ effects: [
67
+ { kind: "push", idempotencyKey: "sha-a", description: " landed the fix " },
68
+ { kind: "merge", idempotencyKey: "m-1", description: " " },
69
+ ],
70
+ },
71
+ });
72
+ assertEquals(m, {
73
+ commitSha: SHA,
74
+ effects: [
75
+ { kind: "push", idempotencyKey: "sha-a", description: "landed the fix" },
76
+ { kind: "merge", idempotencyKey: "m-1" },
77
+ ],
78
+ });
79
+ });
@@ -0,0 +1,84 @@
1
+ -- 049_world_checkpoint.sql — issue #324 (ADR 0062, Slice 4/5): the **world** half of durable
2
+ -- agent-session resume. Durable resume splits into `mind` (the harness conversation; Slices 1–3)
3
+ -- and `world` (the git working tree + irreversible side effects; this slice). A replacement
4
+ -- activation lands on a FRESH worktree, so restoring the mind is useless unless the world is
5
+ -- reconstructed to the same turn boundary.
6
+ --
7
+ -- THE INVERSION OF OPERATION. The authoritative durable store for committed work is ALREADY the
8
+ -- git remote — the round `git push`ed a SHA. So world-restore INVERTS the forward op: the round's
9
+ -- outbound `git push` becomes an inbound `git fetch && git checkout <sha>` on resume. We DERIVE the
10
+ -- tree from `remote SHA + effect-tail`; we never snapshot it into a log (no duplicate source of
11
+ -- truth — AGENTS.md "derivation over duplication"). The lossy frontier is work after the last push,
12
+ -- which is exactly why the resume boundary is a PUSH-checkpoint.
13
+ --
14
+ -- Two tables, both keyed by the PR under convergence (`pr_key` = `<owner>/<repo>#<n>`):
15
+ --
16
+ -- world_checkpoints — one row per push-checkpoint (a durable resume boundary). `checkpoint_offset`
17
+ -- is a per-PR monotonic counter (0,1,2,…). At each push the app records `{commit_sha}` AND joins
18
+ -- it to the mind's `session.checkpoint(...)` at the SAME offset, so mind + world always commit at
19
+ -- one turn boundary — the divergence guard (harness thinks it hasn't pushed but the push landed,
20
+ -- or vice-versa) is closed structurally by the shared offset.
21
+ --
22
+ -- world_effects — the EFFECT LEDGER + FENCE. One row per irreversible action (git push → commit
23
+ -- SHA; PR comment → comment id; `gh merge` → merge key), each with an `idempotency_key`. On
24
+ -- restore we replay the post-checkpoint effect tail THROUGH THE FENCE so an already-applied effect
25
+ -- is SKIPPED, not repeated. `UNIQUE(pr_key, idempotency_key)` is the durable fence: an effect
26
+ -- recorded once cannot be double-applied, and `applied` records whether its side effect has been
27
+ -- realised (1) or is a pending tail entry to replay (0).
28
+ --
29
+ -- EXPAND (additive) phase: two new FK-free tables and their indexes; nothing is dropped or renamed.
30
+ -- The store is FK-free by design (like the 045 admission-staging twins): a checkpoint may be recorded
31
+ -- for an in-flight PR whose `pull_requests` row a store desync momentarily lost, and the heal path
32
+ -- must not FK-fail. Numbered after the current highest prefix on origin/main (048). The runner wraps
33
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
34
+
35
+ CREATE TABLE IF NOT EXISTS world_checkpoints (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ pr_key TEXT NOT NULL, -- "<owner>/<repo>#<number>" the checkpoint belongs to
38
+ round_no INTEGER NOT NULL, -- convergence round that produced the push
39
+ checkpoint_offset INTEGER NOT NULL, -- per-PR monotonic resume boundary (0,1,2,…)
40
+ commit_sha TEXT NOT NULL, -- the pushed SHA the fresh worktree is reconstructed to
41
+ created_at TEXT NOT NULL,
42
+ -- One checkpoint per (pr, offset): the join records mind + world at the SAME offset, so a duplicate
43
+ -- offset for one PR would mean two worlds claiming one turn boundary — reject it.
44
+ UNIQUE(pr_key, checkpoint_offset),
45
+ -- One checkpoint per (pr, commit SHA): `recordCheckpoint` is idempotent on `{pr_key, commit_sha}`
46
+ -- (it `findOne`s the existing row and REUSES its offset rather than allocating a fresh one), but the
47
+ -- application check alone is racy — a concurrent/duplicate persist-round could still land two rows
48
+ -- for one SHA, and `findOne` would then pick an arbitrary offset, reintroducing the "newest
49
+ -- checkpoint shadows the real effect tail" silent-effect-loss class. This constraint makes the
50
+ -- invariant durable: a second row for the same SHA is rejected at the schema, so the offset a SHA
51
+ -- maps to is unique and stable.
52
+ UNIQUE(pr_key, commit_sha)
53
+ );
54
+
55
+ -- The newest checkpoint for a PR is `MAX(checkpoint_offset)`; index the lookup the restore path runs.
56
+ CREATE INDEX IF NOT EXISTS idx_world_checkpoints_pr
57
+ ON world_checkpoints(pr_key, checkpoint_offset);
58
+
59
+ CREATE TABLE IF NOT EXISTS world_effects (
60
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
61
+ pr_key TEXT NOT NULL, -- the PR whose ledger this effect belongs to
62
+ checkpoint_offset INTEGER NOT NULL, -- the exact checkpoint boundary this effect was recorded at
63
+ seq INTEGER NOT NULL, -- intra-checkpoint order the fence replays effects in
64
+ kind TEXT NOT NULL, -- push | pr-comment | merge (an irreversible action class)
65
+ idempotency_key TEXT NOT NULL, -- commit SHA / comment id / merge key — the fence key
66
+ description TEXT, -- human audit note (nullable)
67
+ applied INTEGER NOT NULL DEFAULT 0, -- 1 once its side effect is realised; 0 = pending tail
68
+ created_at TEXT NOT NULL,
69
+ -- `applied` is a strict boolean domain — the fence reads it as "already realised?", so a stray
70
+ -- value (a future writer bug, a corrupt row on this externalised durability boundary) would make
71
+ -- the replay silently mis-skip or re-apply an effect. Pin it to {0,1} at the schema. (`kind` is
72
+ -- deliberately NOT CHECK-constrained here: its valid set is the canonical `EFFECT_KINDS` tuple in
73
+ -- app/world/effect-ledger.ts and is already enforced at the write boundary by `isEffectKind`;
74
+ -- re-listing the values in SQL would be a second source of truth that drifts when a kind is added.)
75
+ CHECK (applied IN (0, 1)),
76
+ -- The durable FENCE: an effect's idempotency key is unique within a PR, so replaying a tail can
77
+ -- never re-record — and hence never re-apply — an effect that already landed.
78
+ UNIQUE(pr_key, idempotency_key)
79
+ );
80
+
81
+ -- The restore path reads the effect tail recorded at a checkpoint's exact `checkpoint_offset`, in
82
+ -- `seq` order (an exact-offset lookup, not a range scan).
83
+ CREATE INDEX IF NOT EXISTS idx_world_effects_pr_offset
84
+ ON world_effects(pr_key, checkpoint_offset, seq);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.100.0",
3
+ "version": "0.101.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -0,0 +1,103 @@
1
+ // A test-only DataLayer over a real in-memory `node:sqlite` db with the world-restore schema
2
+ // (`db/migrations/049_world_checkpoint.sql`) applied, for exercising `app/world/store.ts` and the
3
+ // `app/world/checkpoint.ts` orchestration against a REAL SQLite engine — so the durable fence (the
4
+ // `UNIQUE(pr_key, idempotency_key)` constraint) and the monotonic offset are proven, not mocked.
5
+ import { readFileSync } from "node:fs";
6
+ import { DatabaseSync, type SQLInputValue } from "node:sqlite";
7
+ import { afterEach } from "node:test";
8
+ import { fileURLToPath } from "node:url";
9
+ import type { DataLayer } from "@nanobpm/urban";
10
+
11
+ const openDbs = new Set<DatabaseSync>();
12
+ afterEach(() => {
13
+ for (const raw of openDbs) {
14
+ if (openDbs.delete(raw)) raw.close();
15
+ }
16
+ });
17
+
18
+ function coerce(p: unknown): SQLInputValue {
19
+ if (p === null || p === undefined) return null;
20
+ if (typeof p === "boolean") return p ? 1 : 0;
21
+ return p as SQLInputValue;
22
+ }
23
+
24
+ const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
25
+
26
+ /** A minimal async `Table<T>`-shaped gateway over one real SQLite table — the insert/find/findOne/
27
+ * update/get subset the world store uses. Mirrors the runtime `Table<T>` semantics: `insert` omits
28
+ * `undefined` keys (schema default governs), `update` skips `undefined` keys and clears on `null`. */
29
+ function gateway(db: DatabaseSync, name: string, pk: string) {
30
+ return {
31
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
32
+ async insert(row: any): Promise<number | bigint> {
33
+ const keys = Object.keys(row).filter((k) => row[k] !== undefined);
34
+ const cols = keys.map(quote).join(", ");
35
+ const placeholders = keys.map(() => "?").join(", ");
36
+ const r = db.prepare(`INSERT INTO ${quote(name)} (${cols}) VALUES (${placeholders})`).run(...keys.map((k) => coerce(row[k])));
37
+ return pk === "id" ? r.lastInsertRowid : (row[pk] as number);
38
+ },
39
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
40
+ async find(where: any = {}): Promise<any[]> {
41
+ const keys = Object.keys(where);
42
+ const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
43
+ return db.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => coerce(where[k])));
44
+ },
45
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
46
+ async findOne(where: any = {}): Promise<any> {
47
+ return (await this.find(where))[0];
48
+ },
49
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
50
+ async get(id: any): Promise<any> {
51
+ return db.prepare(`SELECT * FROM ${quote(name)} WHERE ${quote(pk)} = ?`).get(coerce(id));
52
+ },
53
+ // biome-ignore lint/suspicious/noExplicitAny: test-only gateway over dynamic row shapes.
54
+ async update(id: any, patch: any): Promise<number> {
55
+ const keys = Object.keys(patch).filter((k) => patch[k] !== undefined);
56
+ if (keys.length === 0) return 0;
57
+ const set = keys.map((k) => `${quote(k)} = ?`).join(", ");
58
+ const r = db.prepare(`UPDATE ${quote(name)} SET ${set} WHERE ${quote(pk)} = ?`).run(...keys.map((k) => coerce(patch[k])), coerce(id));
59
+ return Number(r.changes);
60
+ },
61
+ };
62
+ }
63
+
64
+ /** A minimal self-referential `DataSource`-shaped gateway over the real db: the `table` surface plus
65
+ * a real `tx()` that BEGIN/COMMIT/ROLLBACKs on the underlying SQLite connection, so the world store's
66
+ * atomic `recordCheckpoint` (checkpoint + effects in one transaction) is exercised against genuine
67
+ * transaction semantics rather than mocked. `tx(fn)` passes the SAME source object to `fn`, so a test
68
+ * that decorates `table` on the returned source sees its decoration inside the transaction too. */
69
+ type MemDataSource = {
70
+ table: (name: string, pk?: string) => ReturnType<typeof gateway>;
71
+ tx<T>(fn: (t: MemDataSource) => Promise<T>): Promise<T>;
72
+ };
73
+
74
+ function openDataSource(db: DatabaseSync): MemDataSource {
75
+ const ds: MemDataSource = {
76
+ table: (name, pk = "id") => gateway(db, name, pk),
77
+ async tx(fn) {
78
+ db.exec("BEGIN");
79
+ try {
80
+ const r = await fn(ds);
81
+ db.exec("COMMIT");
82
+ return r;
83
+ } catch (e) {
84
+ db.exec("ROLLBACK");
85
+ throw e;
86
+ }
87
+ },
88
+ };
89
+ return ds;
90
+ }
91
+
92
+ /** A `DataLayer` stub over a fresh in-memory db with the world schema applied. */
93
+ export function memWorldData(): { data: DataLayer; db: DatabaseSync } {
94
+ const db = new DatabaseSync(":memory:");
95
+ openDbs.add(db);
96
+ const sql = readFileSync(fileURLToPath(new URL("../db/migrations/049_world_checkpoint.sql", import.meta.url)), "utf8");
97
+ db.exec(sql);
98
+ const data = {
99
+ table: (name: string, pk = "id") => gateway(db, name, pk),
100
+ open: () => openDataSource(db),
101
+ } as unknown as DataLayer;
102
+ return { data, db };
103
+ }