@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.
@@ -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