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.
- package/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/arm-challenge.ts +204 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +253 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +113 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +412 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +736 -0
- package/src/daemon/settle-pass.ts +589 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +24 -3
- package/src/failure-class.ts +75 -1
- package/src/fleet.ts +290 -164
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +428 -1681
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +72 -6
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +158 -7
- package/src/store.ts +604 -26
- package/src/to-spec.ts +194 -21
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +416 -15
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- 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
|
+
}
|