omp-conductor 0.19.7 → 0.20.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.
Files changed (69) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/arm-challenge.ts +204 -85
  6. package/src/ask.ts +130 -615
  7. package/src/board.ts +7 -1
  8. package/src/brief-upgrade.ts +24 -0
  9. package/src/briefs/console.md +253 -0
  10. package/src/briefs/correction.md +203 -0
  11. package/src/briefs/orchestrator.md +167 -97
  12. package/src/briefs/policy.md +19 -16
  13. package/src/briefs/to-spec.md +76 -9
  14. package/src/briefs/worker.md +50 -16
  15. package/src/cli.ts +4 -0
  16. package/src/command-manifest.ts +54 -8
  17. package/src/commands/arm.ts +113 -49
  18. package/src/commands/console.ts +70 -0
  19. package/src/commands/context.ts +2 -0
  20. package/src/commands/epic.ts +132 -0
  21. package/src/commands/extend.ts +9 -1
  22. package/src/commands/intake.ts +44 -14
  23. package/src/commands/stats.ts +19 -4
  24. package/src/commands/worker.ts +9 -1
  25. package/src/config-schema.ts +13 -0
  26. package/src/config.ts +27 -0
  27. package/src/daemon/ack.ts +159 -0
  28. package/src/daemon/admission-pass.ts +135 -0
  29. package/src/daemon/brief.ts +461 -0
  30. package/src/daemon/deps.ts +539 -0
  31. package/src/daemon/dispatch.ts +1779 -0
  32. package/src/daemon/drain.ts +185 -0
  33. package/src/daemon/groom-pass.ts +412 -0
  34. package/src/daemon/http.ts +417 -0
  35. package/src/daemon/integrity.ts +108 -0
  36. package/src/daemon/panes.ts +180 -0
  37. package/src/daemon/review.ts +1888 -0
  38. package/src/daemon/runtime.ts +736 -0
  39. package/src/daemon/settle-pass.ts +589 -0
  40. package/src/daemon/supervision.ts +438 -0
  41. package/src/daemon/tick.ts +968 -0
  42. package/src/daemon/views.ts +751 -0
  43. package/src/daemon.ts +105 -7923
  44. package/src/dashboard/app.js +58 -0
  45. package/src/dashboard/controls.ts +22 -3
  46. package/src/dashboard/server.ts +4 -0
  47. package/src/diff-flags.ts +24 -3
  48. package/src/failure-class.ts +75 -1
  49. package/src/fleet.ts +290 -164
  50. package/src/groom.ts +461 -0
  51. package/src/http-token.ts +142 -0
  52. package/src/knowledge.ts +229 -0
  53. package/src/mining.ts +316 -0
  54. package/src/orchestrator-tick.ts +428 -1681
  55. package/src/ready-gate.ts +267 -0
  56. package/src/settlement.ts +72 -6
  57. package/src/setup-host.ts +32 -9
  58. package/src/setup-wizard.ts +55 -7
  59. package/src/setup.ts +229 -3
  60. package/src/stats.ts +257 -2
  61. package/src/status-render.ts +158 -7
  62. package/src/store.ts +604 -26
  63. package/src/to-spec.ts +194 -21
  64. package/src/tracker/github.ts +50 -0
  65. package/src/types.ts +416 -15
  66. package/src/verbs/protocol.ts +28 -0
  67. package/src/verbs/server.ts +330 -39
  68. package/src/wake.ts +19 -2
  69. package/src/worker.ts +456 -1
@@ -0,0 +1,417 @@
1
+ /**
2
+ * The loopback HTTP surface: the daemon's only in-band control plane.
3
+ *
4
+ * One module because there is exactly one authorisation rule and it must be
5
+ * impossible to add a route that skips it — every mutating path answers behind
6
+ * the bearer check here, `/healthz` is the single deliberate exception, and
7
+ * `daemonHttpResponse` is the one function that decides which is which.
8
+ *
9
+ * `DaemonHttpDeps` stays with the surface rather than in `deps.ts`: it is not
10
+ * what a tick needs, it is the much smaller set a request needs, and its
11
+ * `health` field types a projection from `views.ts`. Hoisting it into the shared
12
+ * floor would make the seam module depend on the view layer to describe a
13
+ * request.
14
+ */
15
+ import { httpTokenPath, verifyHttpToken } from "../http-token.ts";
16
+ import { log } from "../log.ts";
17
+ import { LIVE_STATES } from "../store.ts";
18
+ import type { Caps, RunState, Store } from "../types.ts";
19
+ import type { TurnLimitRegistry, WorkerControlRegistry } from "./deps.ts";
20
+ import type { DaemonHealth } from "./views.ts";
21
+
22
+ export const TURN_OVERRIDE_STATES: ReadonlySet<RunState> = new Set([
23
+ "failed",
24
+ "killed",
25
+ "orphaned",
26
+ "blocked",
27
+ ]);
28
+
29
+ export async function turnLimitResponse(
30
+ req: Request,
31
+ project: string,
32
+ store: Pick<Store, "latestRun" | "setTurnOverride">,
33
+ registry: TurnLimitRegistry,
34
+ caps: Pick<Caps, "workerMaxTurns" | "workerMaxTurnsCeiling">,
35
+ ): Promise<Response | undefined> {
36
+ const url = new URL(req.url);
37
+ const match = /^\/runs\/(\d+)\/turn-limit$/.exec(url.pathname);
38
+ if (req.method !== "PUT" || match === null) return undefined;
39
+ if (!req.headers.get("content-type")?.startsWith("application/json")) {
40
+ return Response.json({ error: "content-type must be application/json" }, { status: 415 });
41
+ }
42
+
43
+ let body: unknown;
44
+ try {
45
+ body = await req.json();
46
+ } catch {
47
+ return Response.json({ error: "request body must be valid JSON" }, { status: 400 });
48
+ }
49
+ if (body === null || typeof body !== "object") {
50
+ return Response.json({ error: "request body must be a JSON object" }, { status: 400 });
51
+ }
52
+ const requestedProject = Reflect.get(body, "project");
53
+ if (typeof requestedProject !== "string" || requestedProject.length === 0) {
54
+ return Response.json({ error: "project must be a non-empty string" }, { status: 400 });
55
+ }
56
+ if (requestedProject !== project) {
57
+ return Response.json(
58
+ { error: `daemon serves project "${project}", not requested project "${requestedProject}"` },
59
+ { status: 409 },
60
+ );
61
+ }
62
+ const maxTurns = Reflect.get(body, "maxTurns");
63
+ if (!Number.isSafeInteger(maxTurns) || (maxTurns as number) < 1) {
64
+ return Response.json({ error: "maxTurns must be a positive integer" }, { status: 400 });
65
+ }
66
+
67
+ const issue = Number(match[1]);
68
+ if ((maxTurns as number) > caps.workerMaxTurnsCeiling) {
69
+ return Response.json(
70
+ {
71
+ error:
72
+ `#${issue} turn budget ${maxTurns as number} exceeds the ` +
73
+ `${caps.workerMaxTurnsCeiling}-turn caps.workerMaxTurnsCeiling`,
74
+ },
75
+ { status: 422 },
76
+ );
77
+ }
78
+ const outcome = registry.extend(project, issue, maxTurns as number);
79
+ if (outcome.kind === "extended") return Response.json(outcome);
80
+ if (outcome.kind === "not-increase") {
81
+ return Response.json(
82
+ { error: `#${issue} already has a ${outcome.maxTurns}-turn ceiling` },
83
+ { status: 409 },
84
+ );
85
+ }
86
+
87
+ const latest = store.latestRun(project, issue);
88
+ if (latest === undefined) {
89
+ return Response.json({ error: `no run recorded for #${issue}` }, { status: 404 });
90
+ }
91
+ if (!TURN_OVERRIDE_STATES.has(latest.state)) {
92
+ return Response.json(
93
+ {
94
+ error:
95
+ `#${issue} has no live worker controller; its session already settled ` +
96
+ `or belongs to another daemon (stored state: ${latest.state})`,
97
+ },
98
+ { status: 409 },
99
+ );
100
+ }
101
+ if ((maxTurns as number) <= caps.workerMaxTurns) {
102
+ return Response.json(
103
+ {
104
+ error:
105
+ `#${issue} next-attempt turn budget must exceed the ` +
106
+ `${caps.workerMaxTurns}-turn caps.workerMaxTurns base`,
107
+ },
108
+ { status: 409 },
109
+ );
110
+ }
111
+ store.setTurnOverride(project, issue, maxTurns as number);
112
+ return Response.json({
113
+ kind: "next-attempt",
114
+ issue,
115
+ nextAttemptMaxTurns: maxTurns,
116
+ baseMaxTurns: caps.workerMaxTurns,
117
+ });
118
+ }
119
+
120
+ export async function workerControlResponse(
121
+ req: Request,
122
+ project: string,
123
+ store: Pick<Store, "latestRun" | "updateRun" | "recordMaterialEvent">,
124
+ registry: WorkerControlRegistry,
125
+ /** Journal line for every accepted pause/resume — the live half of the
126
+ * audit trail #997 asks for; the durable half is the material event. */
127
+ log: (line: string) => void = () => {},
128
+ now: () => number = Date.now,
129
+ ): Promise<Response | undefined> {
130
+ const url = new URL(req.url);
131
+ const match = /^\/runs\/(\d+)\/(pause|resume|stop)$/.exec(url.pathname);
132
+ if (req.method !== "PUT" || match === null) return undefined;
133
+ if (!req.headers.get("content-type")?.startsWith("application/json")) {
134
+ return Response.json({ error: "content-type must be application/json" }, { status: 415 });
135
+ }
136
+
137
+ let body: unknown;
138
+ try {
139
+ body = await req.json();
140
+ } catch {
141
+ return Response.json({ error: "request body must be valid JSON" }, { status: 400 });
142
+ }
143
+ if (body === null || typeof body !== "object") {
144
+ return Response.json({ error: "request body must be a JSON object" }, { status: 400 });
145
+ }
146
+ const requestedProject = Reflect.get(body, "project");
147
+ if (typeof requestedProject !== "string" || requestedProject.length === 0) {
148
+ return Response.json({ error: "project must be a non-empty string" }, { status: 400 });
149
+ }
150
+ if (requestedProject !== project) {
151
+ return Response.json(
152
+ { error: `daemon serves project "${project}", not requested project "${requestedProject}"` },
153
+ { status: 409 },
154
+ );
155
+ }
156
+ const action = match[2] as "pause" | "resume" | "stop";
157
+ let reason: string | undefined;
158
+ if (action === "stop") {
159
+ const rawReason = Reflect.get(body, "reason");
160
+ if (typeof rawReason !== "string" || rawReason.trim() === "") {
161
+ return Response.json({ error: "reason must be a non-empty string" }, { status: 400 });
162
+ }
163
+ reason = rawReason.trim().replace(/\s+/g, " ");
164
+ if (reason.length > 500) {
165
+ return Response.json({ error: "reason must be at most 500 characters" }, { status: 400 });
166
+ }
167
+ }
168
+ // Who asked. The board, the CLI and the dashboard each name themselves so a
169
+ // pause is attributable afterwards (#997); an older caller that sends no
170
+ // source is recorded as such rather than guessed at.
171
+ const rawSource = Reflect.get(body, "source");
172
+ if (rawSource !== undefined && (typeof rawSource !== "string" || rawSource.trim() === "" || rawSource.trim().length > 40)) {
173
+ return Response.json({ error: "source must be a non-empty string of at most 40 characters when present" }, { status: 400 });
174
+ }
175
+ const source = typeof rawSource === "string" ? rawSource.trim() : "unattributed";
176
+
177
+
178
+ const issue = Number(match[1]);
179
+ const outcome =
180
+ action === "pause"
181
+ ? await registry.pause(project, issue, source)
182
+ : action === "resume"
183
+ ? registry.resume(project, issue)
184
+ : await registry.stop(project, issue, reason!);
185
+ if (outcome.kind === "ok") {
186
+ // The transition really happened — write both halves of the audit trail
187
+ // before answering (#997): one journal line for the live log, one durable
188
+ // material event the digest ledger keeps after the journal rotates. #986
189
+ // was parked 14 minutes by an unlogged keypress and nobody could say why.
190
+ const verb = action === "pause" ? "paused" : "resumed";
191
+ log(`#${issue} worker ${verb} via ${source} (run ${outcome.runId})`);
192
+ try {
193
+ store.recordMaterialEvent({
194
+ project,
195
+ category: "worker-control",
196
+ summary: `#${issue} worker ${verb} via ${source}`,
197
+ evidence: `run ${outcome.runId}, phase ${outcome.phase}`,
198
+ occurredAt: now(),
199
+ recordedAt: now(),
200
+ });
201
+ } catch (err) {
202
+ // The audit must never turn a successful transition into an error
203
+ // answer, but a swallowed write would be a silent audit gap — log it.
204
+ log(`#${issue} worker-control audit write failed: ${err instanceof Error ? err.message : String(err)}`);
205
+ }
206
+ return Response.json({ runId: outcome.runId, phase: outcome.phase });
207
+ }
208
+ if (outcome.kind === "refused") {
209
+ return Response.json({ error: `#${issue}: ${outcome.error}` }, { status: 409 });
210
+ }
211
+ if (outcome.kind === "stopped") {
212
+ const stopped = store.latestRun(project, issue);
213
+ if (stopped === undefined || stopped.id !== outcome.runId) {
214
+ return Response.json(
215
+ { error: `#${issue} stopped, but its terminal run record is unavailable` },
216
+ { status: 500 },
217
+ );
218
+ }
219
+ if (stopped.state !== "stopped") {
220
+ return Response.json({
221
+ outcome: "already-terminal",
222
+ runId: stopped.id,
223
+ state: stopped.state,
224
+ });
225
+ }
226
+ return Response.json({
227
+ outcome: "stopped",
228
+ runId: stopped.id,
229
+ state: stopped.state,
230
+ reason: outcome.reason,
231
+ ...(stopped.salvageSha === undefined ? {} : { salvageSha: stopped.salvageSha }),
232
+ ...(stopped.salvageError === undefined ? {} : { salvageError: stopped.salvageError }),
233
+ worktree: stopped.worktree,
234
+ });
235
+ }
236
+
237
+
238
+ const latest = store.latestRun(project, issue);
239
+ if (latest === undefined) {
240
+ return Response.json({ error: `no run recorded for #${issue}` }, { status: 404 });
241
+ }
242
+ if (action === "stop" && !LIVE_STATES.includes(latest.state)) {
243
+ return Response.json({
244
+ outcome: "already-terminal",
245
+ runId: latest.id,
246
+ state: latest.state,
247
+ });
248
+ }
249
+ // A live-state row whose controller is absent from this process is a ghost
250
+ // (#431): a session controller lives in the daemon that launched it, and once
251
+ // ownership is exclusive (the once lease makes a drill hold the same pidfile
252
+ // as the long dispatcher), a row that is live but has no controller in *this*
253
+ // owner is a row whose owner already died — not work another live daemon is
254
+ // still writing to. Refuse nothing: recover it as an orphan (worktree kept
255
+ // for triage, labels untouched, live slot freed) so the issue can be
256
+ // dispatched again and the row stops counting as a live worker.
257
+ if (action === "stop") {
258
+ store.updateRun(latest.id, {
259
+ state: "orphaned",
260
+ endedAt: Date.now(),
261
+ lastError: reason,
262
+ });
263
+ return Response.json({
264
+ outcome: "stopped",
265
+ runId: latest.id,
266
+ state: "orphaned",
267
+ reason: reason!,
268
+ worktree: latest.worktree,
269
+ });
270
+ }
271
+ return Response.json(
272
+ {
273
+ error:
274
+ `#${issue} has no live worker controller; its session already settled ` +
275
+ `or belongs to another daemon (stored state: ${latest.state})`,
276
+ },
277
+ { status: 409 },
278
+ );
279
+ }
280
+
281
+ export interface DaemonHttpProjectDeps {
282
+ project: string;
283
+ store: Pick<Store, "latestRun" | "setTurnOverride" | "updateRun" | "recordMaterialEvent">;
284
+ caps: () => Pick<Caps, "workerMaxTurns" | "workerMaxTurnsCeiling">;
285
+ /** Project-scoped journal line — the live half of the pause audit (#997). */
286
+ log?: (line: string) => void;
287
+ }
288
+
289
+ export interface DaemonHttpDeps {
290
+ projects: readonly DaemonHttpProjectDeps[];
291
+ turnLimits: TurnLimitRegistry;
292
+ workerControls: WorkerControlRegistry;
293
+ health: () => DaemonHealth;
294
+ /** Request an immediate dispatch pass (`resume`'s wake, #380). */
295
+ wake: () => void;
296
+ /**
297
+ * The bearer token every mutating route requires (Phase 4). Minted by
298
+ * `runDaemon` through {@link ensureHttpToken} before this server exists, so
299
+ * it is a value rather than a thunk: one daemon start, one token, and no
300
+ * route can be answered against a token that arrived later than the request.
301
+ *
302
+ * An empty string authenticates nobody — {@link verifyHttpToken} refuses it —
303
+ * so a deps literal that forgets the token fails closed rather than open.
304
+ */
305
+ token: string;
306
+ /** Journal line for a refused request. Defaults to the daemon's own log. */
307
+ log?: (line: string) => void;
308
+ }
309
+
310
+ /**
311
+ * The whole HTTP surface, in one named function so a test can pin what is *not*
312
+ * on it.
313
+ *
314
+ * Four route families: the health read, the dispatch wake, turn-limit control,
315
+ * and live-worker pause/resume/stop control. Everything else is 404. The
316
+ * controls mutate only daemon-owned live sessions; tracker and repository
317
+ * mutations stay on the authenticated per-run channel described at the
318
+ * `Bun.serve` call (#126).
319
+ *
320
+ * ## Authentication, and the one deliberate asymmetry
321
+ *
322
+ * Everything except `GET /healthz` requires `Authorization: Bearer <token>`
323
+ * (Phase 4). The gate sits here, above route dispatch, rather than in each
324
+ * route: the surface is "authenticated except the health read", and a rule
325
+ * stated once cannot be forgotten by the next route somebody adds. An
326
+ * unmatched path therefore also answers 401 rather than 404 — fail closed, and
327
+ * an unauthenticated caller learns nothing about which paths exist.
328
+ *
329
+ * `GET /healthz` stays open, and MUST stay open: `requireDaemonControl`
330
+ * (`lifecycle.ts`) probes it to decide whether a live unit-owned daemon exists
331
+ * at all, which happens *before* any caller has a reason — or a way — to hold
332
+ * this token. A health read mutates nothing, so the asymmetry costs nothing.
333
+ * Do not "fix" it by authenticating `/healthz`: that breaks daemon discovery
334
+ * for every CLI command, including the ones that would then be told to
335
+ * authenticate.
336
+ */
337
+ export async function daemonHttpResponse(req: Request, d: DaemonHttpDeps): Promise<Response> {
338
+ const url = new URL(req.url);
339
+ if (req.method === "GET" && url.pathname === "/healthz") return Response.json(d.health());
340
+
341
+ // Every remaining route mutates: /wake starts a dispatch pass, /runs/* pauses,
342
+ // resumes, stops or re-ceilings a live worker. Until Phase 4 they were open on
343
+ // the reasoning that loopback is a boundary. On a shared host it is not: any
344
+ // local process — including a worker session this daemon launched itself —
345
+ // could stop any run or raise any turn ceiling. Refuse, and say which route
346
+ // and why, but never the token, not even truncated: a rejected credential is
347
+ // an attacker's guess and a log is a file other things read.
348
+ const authorization = req.headers.get("authorization");
349
+ if (!verifyHttpToken(authorization, d.token)) {
350
+ (d.log ?? log)(
351
+ `401 ${req.method} ${url.pathname} — ` +
352
+ `${authorization === null ? "no Authorization header" : "bearer token rejected"} ` +
353
+ `(the token is ${httpTokenPath()}; the daemon mints it at start)`,
354
+ );
355
+ return Response.json(
356
+ {
357
+ error:
358
+ "unauthorized: this route requires the daemon's bearer token. " +
359
+ `Read it from ${httpTokenPath()} and send it as "Authorization: Bearer <token>".`,
360
+ },
361
+ { status: 401 },
362
+ );
363
+ }
364
+
365
+ if (req.method === "POST" && url.pathname === "/wake") {
366
+ // A resume wake: daemon-wide and loopback-only, like /healthz, but
367
+ // authenticated — waking dispatch in a loop is a denial of service any
368
+ // local process could previously mount. The pass it produces re-reads
369
+ // every project's pause file, so a project that is still held stays held —
370
+ // the wake shortens the wait, it never bypasses a gate.
371
+ d.wake();
372
+ return new Response(null, { status: 204 });
373
+ }
374
+
375
+ let selected = d.projects[0];
376
+ if (
377
+ req.method === "PUT" &&
378
+ /^\/runs\/\d+\/(?:turn-limit|pause|resume|stop)$/.test(url.pathname) &&
379
+ req.headers.get("content-type")?.startsWith("application/json")
380
+ ) {
381
+ try {
382
+ const body = await req.clone().json();
383
+ if (body !== null && typeof body === "object") {
384
+ const requested = Reflect.get(body, "project");
385
+ if (typeof requested === "string" && requested.length > 0) {
386
+ selected = d.projects.find(({ project }) => project === requested);
387
+ if (selected === undefined) {
388
+ return Response.json(
389
+ { error: `daemon does not serve requested project "${requested}"` },
390
+ { status: 409 },
391
+ );
392
+ }
393
+ }
394
+ }
395
+ } catch {
396
+ // The route handler below owns the public malformed-body response.
397
+ }
398
+ }
399
+ if (selected === undefined) return new Response("not found\n", { status: 404 });
400
+
401
+ const turnLimit = await turnLimitResponse(
402
+ req,
403
+ selected.project,
404
+ selected.store,
405
+ d.turnLimits,
406
+ selected.caps(),
407
+ );
408
+ if (turnLimit !== undefined) return turnLimit;
409
+ const workerControl = await workerControlResponse(
410
+ req,
411
+ selected.project,
412
+ selected.store,
413
+ d.workerControls,
414
+ selected.log,
415
+ );
416
+ return workerControl ?? new Response("not found\n", { status: 404 });
417
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The package tripwire: hash every file the running install is made of, once
3
+ * per tick, and stop the fleet the moment one of them changes underneath it.
4
+ *
5
+ * The boundary is drawn around "what was deployed", not around any pass: the
6
+ * manifest is a self-portrait of `src/`, and the two callers — the tick that
7
+ * compares it and `runDaemon` that records the baseline — want the same three
8
+ * pure functions and nothing else. Keeping them here means the tripwire has no
9
+ * `Deps` and therefore nothing a pass could accidentally teach it to ignore.
10
+ *
11
+ * `markPaged` lives here rather than in `supervision.ts` because both latches it
12
+ * serves — this one and the stall gate's — are the same "page once, and only
13
+ * after delivery is confirmed" rule, and one overload set is the only way that
14
+ * rule stays one rule.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { readFileSync, readdirSync } from "node:fs";
18
+ import { join, relative } from "node:path";
19
+ import { PACKAGE_SRC_DIR, type IntegrityGate, type StallGate } from "./deps.ts";
20
+
21
+ /** Enough differing paths to tell a deploy from a tamper at a glance; the full
22
+ * list is on the host, and the answer is always "go look at the host". */
23
+ export const INTEGRITY_SAMPLE = 5;
24
+
25
+ export interface IntegrityVerdict {
26
+ /** Labelled, sorted differences; empty when the package is untouched. */
27
+ diff: string[];
28
+ /** Any difference at all stops the fleet. */
29
+ pause: boolean;
30
+ /** First divergent tick only — a page every five minutes is a page nobody reads. */
31
+ page: boolean;
32
+ }
33
+
34
+ /**
35
+ * sha256 of every source file the running package is made of, keyed by path
36
+ * relative to `root`.
37
+ *
38
+ * `PACKAGE_SRC_DIR` is the installed `src/` of the code executing right now, so
39
+ * this is a self-portrait: what was actually deployed, not what some checkout
40
+ * on disk happens to contain. `.ts` and `.md` because both are executable in
41
+ * this package — the briefs under `src/briefs/` are the sessions' instructions,
42
+ * and rewriting one of those buys more than rewriting the dispatcher does.
43
+ * (A checkout also carries `*.test.ts`, which the published package excludes, so
44
+ * a daemon started from one is watching its tests too. That is the honest
45
+ * answer — its code did change — and it costs nothing on a real install.)
46
+ *
47
+ * Walking and hashing the ~30 files of this package measures 0.6 ms warm, once
48
+ * per five-minute tick, so a tick does it inline. No cache and no mtime
49
+ * shortcut on purpose: a cache is a second thing that can be wrong, and mtime
50
+ * is the first field anyone covering their tracks restores.
51
+ */
52
+ export function packageManifest(root: string = PACKAGE_SRC_DIR): Map<string, string> {
53
+ const out = new Map<string, string>();
54
+ const walk = (dir: string): void => {
55
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
56
+ const full = join(dir, e.name);
57
+ if (e.isDirectory()) walk(full);
58
+ else if (e.isFile() && (e.name.endsWith(".ts") || e.name.endsWith(".md")))
59
+ out.set(relative(root, full), createHash("sha256").update(readFileSync(full)).digest("hex"));
60
+ }
61
+ };
62
+ walk(root);
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * Labelled rather than three arrays because every consumer — the log line, the
68
+ * page, the test — wants one readable list of what moved.
69
+ */
70
+ export function manifestDiff(before: Map<string, string>, after: Map<string, string>): string[] {
71
+ const out: string[] = [];
72
+ for (const [path, hash] of before) {
73
+ const now = after.get(path);
74
+ if (now === undefined) out.push(`removed ${path}`);
75
+ else if (now !== hash) out.push(`changed ${path}`);
76
+ }
77
+ for (const path of after.keys()) if (!before.has(path)) out.push(`added ${path}`);
78
+ return out.sort();
79
+ }
80
+
81
+ /**
82
+ * The tick's decision, split from its effects so the once-only page is a thing
83
+ * a test can hold.
84
+ *
85
+ * `pause` stays true on every divergent tick, deliberately: an operator who
86
+ * resumes without restarting gets re-paused, because the boundary is still
87
+ * broken. `page` asks whether this tick should *try* — the caller latches the
88
+ * gate with {@link markPaged} only once a page actually went out, so a Telegram
89
+ * outage during the one tick that noticed does not buy permanent silence.
90
+ */
91
+ export function checkIntegrity(gate: IntegrityGate, current: Map<string, string>): IntegrityVerdict {
92
+ const diff = manifestDiff(gate.baseline, current);
93
+ if (diff.length === 0) return { diff, pause: false, page: false };
94
+ return { diff, pause: true, page: !gate.paged };
95
+ }
96
+
97
+ /** Latch a page after delivery is confirmed, and never before. */
98
+ export function markPaged(gate: StallGate, delivered: boolean, now: number): void;
99
+ export function markPaged(gate: IntegrityGate, delivered: boolean): void;
100
+ export function markPaged(
101
+ gate: { paged: boolean; lastPagedAt?: number },
102
+ delivered: boolean,
103
+ now?: number,
104
+ ): void {
105
+ if (!delivered) return;
106
+ gate.paged = true;
107
+ if (now !== undefined) gate.lastPagedAt = now;
108
+ }