@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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
8
8
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
9
9
|
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
10
|
+
import { type Effect, isCommitSha, isEffectKind, recordWorldCheckpoint, WorldStore } from "../../app/world/index.ts";
|
|
10
11
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
11
12
|
|
|
12
13
|
// Input is typed off the model data envelope (`PrPersistRoundIn` in convergence-loop.bpmn),
|
|
@@ -32,6 +33,54 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
|
32
33
|
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/** The world-restore marker (issue #324, ADR 0062 Slice 4/5, the WORLD half). When a round pushed
|
|
37
|
+
* changes, the c8ctl harness reports `{commitSha, effects?}` under this reserved key so the app
|
|
38
|
+
* records a durable push-checkpoint: the pushed SHA a replacement activation reconstructs the working
|
|
39
|
+
* tree to (inverting `git push` → `git fetch && git checkout <sha>`), plus the round's irreversible
|
|
40
|
+
* effect ledger (each fence-keyed) so a resume skips an already-applied effect. Absent (a `waiting`
|
|
41
|
+
* round, or a harness predating #324) → no checkpoint is recorded (nothing was pushed). */
|
|
42
|
+
const WORLD_MARKER_KEY = "worldMarker";
|
|
43
|
+
|
|
44
|
+
interface WorldMarker {
|
|
45
|
+
readonly commitSha: string;
|
|
46
|
+
readonly effects?: readonly Effect[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Normalize one externally-supplied effect from the harness `worldMarker`, or `null` when it is not
|
|
50
|
+
* a usable effect. This is a CONTRACT BOUNDARY: the marker arrives from out-of-process, so both the
|
|
51
|
+
* fence key AND the effect kind are untrusted. We (a) TRIM `idempotencyKey` so whitespace variants of
|
|
52
|
+
* one real effect collapse to a single fence key rather than manufacturing distinct ledger rows that
|
|
53
|
+
* defeat the fence, and (b) validate `kind` against the canonical {@link EFFECT_KINDS} so an unknown
|
|
54
|
+
* kind can never enter the durable ledger. `description` is trimmed to a non-empty note or dropped. */
|
|
55
|
+
function normalizeEffect(raw: unknown): Effect | null {
|
|
56
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
57
|
+
const e = raw as { kind?: unknown; idempotencyKey?: unknown; description?: unknown } | null | undefined;
|
|
58
|
+
if (!e || !isEffectKind(e.kind)) return null;
|
|
59
|
+
if (typeof e.idempotencyKey !== "string") return null;
|
|
60
|
+
const idempotencyKey = e.idempotencyKey.trim();
|
|
61
|
+
if (idempotencyKey === "") return null;
|
|
62
|
+
const description = typeof e.description === "string" ? e.description.trim() : "";
|
|
63
|
+
return { kind: e.kind, idempotencyKey, ...(description !== "" ? { description } : {}) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function worldMarkerOf(vars: Record<string, unknown>): WorldMarker | null {
|
|
67
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
68
|
+
const m = vars[WORLD_MARKER_KEY] as { commitSha?: unknown; effects?: unknown } | undefined;
|
|
69
|
+
if (!m || typeof m.commitSha !== "string") return null;
|
|
70
|
+
// `commitSha` is used as an EXACT checkout target on restore (`git fetch && git checkout <sha>`),
|
|
71
|
+
// so validate it is a well-formed 40-hex SHA — the SAME guard the emit boundary `repoEnvelopeVars`
|
|
72
|
+
// applies (via `isCommitSha`) so the two boundaries can't drift. An arbitrary ref (e.g. `main`) or
|
|
73
|
+
// an abbreviated SHA would reconstruct to a moved branch tip or fail restore, undermining the
|
|
74
|
+
// "reconstruct the exact tree at <sha>" contract. Trim first so a whitespace-tainted valid SHA
|
|
75
|
+
// still passes; a value that isn't a full object name degrades to no checkpoint (a `waiting` round).
|
|
76
|
+
const commitSha = m.commitSha.trim();
|
|
77
|
+
if (!isCommitSha(commitSha)) return null;
|
|
78
|
+
const effects = Array.isArray(m.effects)
|
|
79
|
+
? m.effects.map(normalizeEffect).filter((e): e is Effect => e !== null)
|
|
80
|
+
: undefined;
|
|
81
|
+
return { commitSha, ...(effects && effects.length > 0 ? { effects } : {}) };
|
|
82
|
+
}
|
|
83
|
+
|
|
35
84
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
36
85
|
// This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
|
|
37
86
|
// domain values. `summary` is left undefined when absent: the write boundary omits it so the
|
|
@@ -75,6 +124,25 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
75
124
|
updated_at: now,
|
|
76
125
|
});
|
|
77
126
|
|
|
127
|
+
// World checkpoint (issue #324, ADR 0062 Slice 4/5): when this round pushed, record the durable
|
|
128
|
+
// push-checkpoint — the pushed SHA a replacement activation reconstructs the tree to, plus the
|
|
129
|
+
// round's fence-keyed effect ledger. The JOIN to the mind's `session.checkpoint(...)` (Slice 1)
|
|
130
|
+
// happens harness-side (out of process); here we persist the WORLD marker so restore can invert
|
|
131
|
+
// the push. Best-effort: a checkpoint-store failure must not fail an already-recorded round.
|
|
132
|
+
const marker = worldMarkerOf(job.variables);
|
|
133
|
+
if (marker) {
|
|
134
|
+
try {
|
|
135
|
+
await recordWorldCheckpoint(new WorldStore(app.data), {
|
|
136
|
+
prKey,
|
|
137
|
+
roundNo: round,
|
|
138
|
+
commitSha: marker.commitSha,
|
|
139
|
+
...(marker.effects ? { effects: marker.effects } : {}),
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
app.log.warn("world checkpoint record failed", { prKey, round, err: String(err) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
78
146
|
return {};
|
|
79
147
|
};
|
|
80
148
|
|