@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.
- package/CHANGELOG.md +7 -0
- package/app/contracts.ts +18 -2
- package/app/migration049.test.ts +112 -0
- package/app/service.test.ts +16 -0
- package/app/service.ts +47 -4
- package/app/world/checkpoint.test.ts +193 -0
- package/app/world/checkpoint.ts +142 -0
- package/app/world/effect-ledger.test.ts +86 -0
- package/app/world/effect-ledger.ts +103 -0
- package/app/world/git.ts +53 -0
- package/app/world/index.ts +26 -0
- package/app/world/store.test.ts +443 -0
- package/app/world/store.ts +320 -0
- package/app/world-marker.test.ts +79 -0
- package/db/migrations/049_world_checkpoint.sql +84 -0
- package/package.json +1 -1
- package/test/worldDb.ts +103 -0
- package/workers/persist-round/worker.ts +68 -0
|
@@ -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.
|
|
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",
|
package/test/worldDb.ts
ADDED
|
@@ -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
|
+
}
|