tickmarkr 2.5.8 → 2.5.9
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/dist/adapters/qwen.js +30 -3
- package/dist/cli/commands/approve.js +15 -3
- package/dist/cli/commands/beat.d.ts +2 -0
- package/dist/cli/commands/beat.js +28 -29
- package/dist/cli/commands/compile.js +11 -0
- package/dist/cli/commands/fleet.js +61 -53
- package/dist/cli/commands/verify.d.ts +4 -1
- package/dist/cli/commands/verify.js +16 -5
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +6 -4
- package/dist/config/config.d.ts +41 -3
- package/dist/config/config.js +48 -17
- package/dist/config/fleet-overlay.js +47 -62
- package/dist/gates/baseline.d.ts +65 -0
- package/dist/gates/baseline.js +163 -9
- package/dist/gates/review.d.ts +6 -4
- package/dist/gates/review.js +62 -21
- package/dist/gates/run-gates.d.ts +4 -1
- package/dist/gates/run-gates.js +21 -11
- package/dist/gates/test-manifest.d.ts +11 -1
- package/dist/gates/test-manifest.js +41 -21
- package/dist/run/daemon.js +84 -41
- package/dist/run/journal.d.ts +17 -2
- package/dist/run/journal.js +99 -11
- package/dist/run/merge.d.ts +13 -2
- package/dist/run/merge.js +74 -12
- package/dist/run/protocol.d.ts +82 -0
- package/dist/run/protocol.js +35 -0
- package/dist/run/receipt-resolver.d.ts +18 -0
- package/dist/run/receipt-resolver.js +132 -0
- package/dist/run/supervision.d.ts +14 -1
- package/dist/run/supervision.js +122 -24
- package/dist/tui/cockpit/evidence-view.d.ts +10 -1
- package/dist/tui/cockpit/evidence-view.js +37 -5
- package/dist/tui/ink/fleet-app.d.ts +12 -22
- package/dist/tui/ink/fleet-app.js +520 -131
- package/package.json +1 -1
- package/skills/tickmarkr-overseer/SKILL.md +61 -36
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
package/dist/run/merge.js
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import { shq } from "../adapters/types.js";
|
|
4
|
-
import { ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
|
|
4
|
+
import { beginGateEvidence, ceilingKillResult, classifyFreshRunnerOutput, classifyRunnerOutput, fileCountDeficit, waitForCalmWindow, effectiveCeilingMs, fingerprint, freshFailures, } from "../gates/baseline.js";
|
|
5
5
|
import { evaluateManifestedTest, isVitestTestCommand } from "../gates/test-manifest.js";
|
|
6
6
|
import { computeVerificationIdentity, formatReusedDetails, getVerdictStore, getWorktreeTree, resolveStateDir, isInfraResult, } from "../gates/cache.js";
|
|
7
7
|
import { tickmarkrDir } from "../graph/graph.js";
|
|
8
8
|
import { describeCapacity, gitHead, linkNodeModules, resolveIntegrationBranch, resolvedCapacity, sameCapacity, sh, shGit, shGitOk, WORKTREES_DIR } from "./git.js";
|
|
9
|
+
import { executionSignal } from "./execution-budget.js";
|
|
10
|
+
// Diagnostic convenience logs are observational, just like receipt persistence.
|
|
11
|
+
function writeTipLog(path, text) {
|
|
12
|
+
try {
|
|
13
|
+
writeFileSync(path, text);
|
|
14
|
+
}
|
|
15
|
+
catch { /* the command verdict remains authoritative */ }
|
|
16
|
+
}
|
|
17
|
+
/** Carry execution evidence without minting an invocation or rebasing its artifact paths. */
|
|
18
|
+
export function reusedTipEvidence(source) {
|
|
19
|
+
return {
|
|
20
|
+
reused: true,
|
|
21
|
+
...(source.evidenceReceipt ? { evidenceReceipt: source.evidenceReceipt } : {}),
|
|
22
|
+
...(source.evidenceReceipts ? { evidenceReceipts: source.evidenceReceipts } : {}),
|
|
23
|
+
...(source.originRunRoot ? { originRunRoot: source.originRunRoot } : {}),
|
|
24
|
+
...(!source.evidenceReceipt
|
|
25
|
+
? { evidenceAbsence: source.evidenceAbsence ?? "historical-cache" }
|
|
26
|
+
: source.evidenceAbsence ? { evidenceAbsence: source.evidenceAbsence } : {}),
|
|
27
|
+
...(source.nonce !== undefined ? { nonce: source.nonce } : {}),
|
|
28
|
+
...(source.stdoutPath !== undefined ? { stdoutPath: source.stdoutPath } : {}),
|
|
29
|
+
...(source.stderrPath !== undefined ? { stderrPath: source.stderrPath } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
9
32
|
export function integrationBranch(cfg, runId) {
|
|
10
33
|
return `${cfg.integrationBranchPrefix}${runId}`;
|
|
11
34
|
}
|
|
@@ -52,7 +75,7 @@ export async function mergeTask(intWt, taskBranch, message, gatedCommit) {
|
|
|
52
75
|
// terminus unreachable by construction and misattributed the red to the last merged task.
|
|
53
76
|
// Fail-closed edges kept: no baseline → strict; baseline green for that gate → strict; any fresh
|
|
54
77
|
// fingerprint, or output with no recognizable failure shape, → failed.
|
|
55
|
-
export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
78
|
+
export async function verifyIntegrationTip(intWt, commands, runDir, baseline, evidenceOptions = {}) {
|
|
56
79
|
const results = [];
|
|
57
80
|
let runStartCommands;
|
|
58
81
|
let journalFound = false;
|
|
@@ -109,7 +132,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
109
132
|
for (const [gate, cmd] of gatesToRun) {
|
|
110
133
|
const artifact = join(runDir, `tip-verify-${gate}.log`);
|
|
111
134
|
const details = `tip verify could not establish command provenance: run-start evidence missing or malformed in journal`;
|
|
112
|
-
|
|
135
|
+
writeTipLog(artifact, details + "\n");
|
|
113
136
|
results.push({
|
|
114
137
|
gate,
|
|
115
138
|
cmd,
|
|
@@ -117,6 +140,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
117
140
|
exitCode: 1,
|
|
118
141
|
fingerprints: [],
|
|
119
142
|
details,
|
|
143
|
+
evidenceAbsence: "provenance-refused",
|
|
120
144
|
artifact,
|
|
121
145
|
});
|
|
122
146
|
}
|
|
@@ -135,7 +159,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
135
159
|
if (expectedCmd === undefined || cmd !== expectedCmd) {
|
|
136
160
|
const artifact = join(runDir, `tip-verify-${gate}.log`);
|
|
137
161
|
const details = `tip verify command "${cmd}" for gate "${gate}" was not named in run-start row`;
|
|
138
|
-
|
|
162
|
+
writeTipLog(artifact, details + "\n");
|
|
139
163
|
results.push({
|
|
140
164
|
gate,
|
|
141
165
|
cmd,
|
|
@@ -143,6 +167,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
143
167
|
exitCode: 1,
|
|
144
168
|
fingerprints: [],
|
|
145
169
|
details,
|
|
170
|
+
evidenceAbsence: "provenance-refused",
|
|
146
171
|
artifact,
|
|
147
172
|
});
|
|
148
173
|
continue;
|
|
@@ -184,7 +209,7 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
184
209
|
gate,
|
|
185
210
|
cmd,
|
|
186
211
|
pass: hit.pass,
|
|
187
|
-
|
|
212
|
+
...reusedTipEvidence(hit.meta ?? {}),
|
|
188
213
|
exitCode: hit.exitCode ?? 0,
|
|
189
214
|
fingerprints: hit.meta?.fingerprints ?? [],
|
|
190
215
|
...(hit.meta?.forgiven ? { forgiven: true } : {}),
|
|
@@ -207,14 +232,21 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
207
232
|
longestFile: entry?.longestFile,
|
|
208
233
|
overallCeilingMs: effectiveCeilingMs(entry),
|
|
209
234
|
artifactDir: runDir,
|
|
235
|
+
evidence: { artifactDir: runDir, runId: runDir, ...evidenceOptions },
|
|
210
236
|
});
|
|
211
237
|
const artifact = join(runDir, `tip-verify-${gate}.log`);
|
|
212
238
|
if (!outcome.pass)
|
|
213
|
-
|
|
239
|
+
writeTipLog(artifact, outcome.details);
|
|
214
240
|
results.push({
|
|
215
241
|
gate,
|
|
216
242
|
cmd,
|
|
217
243
|
pass: outcome.pass,
|
|
244
|
+
evidenceReceipt: outcome.evidenceReceipt,
|
|
245
|
+
evidenceReceipts: outcome.evidenceReceipts,
|
|
246
|
+
...(!outcome.evidenceReceipt ? { evidenceAbsence: "not-started" } : {}),
|
|
247
|
+
nonce: outcome.meta.nonce,
|
|
248
|
+
stdoutPath: outcome.meta.stdoutPath,
|
|
249
|
+
stderrPath: outcome.meta.stderrPath,
|
|
218
250
|
exitCode: outcome.exitCode,
|
|
219
251
|
reportPath: outcome.reportPath,
|
|
220
252
|
spawnedCommand: outcome.meta.spawnedCommand,
|
|
@@ -230,18 +262,41 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
230
262
|
// `sh` defaults to. A suite whose capture measured 600007ms carries a recorded 1800021ms ceiling;
|
|
231
263
|
// running it under 600000ms here SIGKILLed a green tip three times while every per-task gate passed.
|
|
232
264
|
const ceilingMs = effectiveCeilingMs(entry);
|
|
233
|
-
|
|
265
|
+
const evidenceReceipts = [];
|
|
266
|
+
const execute = async () => {
|
|
267
|
+
const evidence = beginGateEvidence(intWt, gate, cmd, { artifactDir: runDir, runId: runDir, ...evidenceOptions });
|
|
268
|
+
try {
|
|
269
|
+
const result = await sh(cmd, intWt, ceilingMs, { env: evidenceOptions.env, onReceipt: receipt => evidence.observe(receipt) });
|
|
270
|
+
evidenceReceipts.push(...evidence.history, evidence.finish(result.stdout, result.stderr));
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
const receipt = evidence.finish();
|
|
275
|
+
if (receipt.termination.kind !== "not-started" || executionSignal()?.aborted)
|
|
276
|
+
throw error;
|
|
277
|
+
evidenceReceipts.push(...evidence.history, receipt);
|
|
278
|
+
return { code: -1, stdout: "", stderr: String(error) };
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
let r = await execute();
|
|
234
282
|
let raw = r.stdout + "\n" + r.stderr;
|
|
235
283
|
let stripped = raw.split(intWt).join("");
|
|
236
284
|
let rerun;
|
|
237
|
-
if (gate === "test" && !r.timedOut && !fileCountDeficit(entry, stripped)
|
|
285
|
+
if (evidenceReceipts.at(-1)?.termination.kind !== "not-started" && gate === "test" && !r.timedOut && !fileCountDeficit(entry, stripped)
|
|
238
286
|
&& classifyFreshRunnerOutput(entry, stripped, r.code) === "infra") {
|
|
239
287
|
const waitedMs = await waitForCalmWindow();
|
|
240
288
|
rerun = `runner-infra rerun after waiting ${waitedMs}ms for a calm load window`;
|
|
241
|
-
r = await
|
|
289
|
+
r = await execute();
|
|
242
290
|
raw = r.stdout + "\n" + r.stderr;
|
|
243
291
|
stripped = raw.split(intWt).join("");
|
|
244
292
|
}
|
|
293
|
+
const evidenceReceipt = evidenceReceipts.at(-1);
|
|
294
|
+
if (evidenceReceipt.termination.kind === "not-started") {
|
|
295
|
+
results.push({ gate, cmd, pass: false, exitCode: r.code, fingerprints: [],
|
|
296
|
+
details: `infra; command failed to launch: ${r.stderr}`, cause: "infra",
|
|
297
|
+
evidenceReceipt, evidenceReceipts, evidenceAbsence: "not-started" });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
245
300
|
const artifact = join(runDir, `tip-verify-${gate}.log`);
|
|
246
301
|
// Battery parity on the ceiling too (baseline.ts Q24): the kill is read BEFORE the exit code is
|
|
247
302
|
// interpreted at all. A SIGKILLed battery never returned a verdict, so no line of its partial
|
|
@@ -249,11 +304,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
249
304
|
// operator cannot act on. The kill reader's own text (ceiling + elapsed) and cause replace it.
|
|
250
305
|
const killed = ceilingKillResult(gate, r, ceilingMs);
|
|
251
306
|
if (killed) {
|
|
252
|
-
|
|
307
|
+
writeTipLog(artifact, raw);
|
|
253
308
|
results.push({
|
|
254
309
|
gate,
|
|
255
310
|
cmd,
|
|
256
311
|
pass: false,
|
|
312
|
+
evidenceReceipt, evidenceReceipts,
|
|
257
313
|
exitCode: r.code,
|
|
258
314
|
fingerprints: [],
|
|
259
315
|
details: `${rerun ? `infra; ${rerun}: ` : ""}${killed.details}`,
|
|
@@ -290,11 +346,12 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
290
346
|
&& comparable;
|
|
291
347
|
const pass = !deficit && (r.code === 0 || greenTeardown || forgiven);
|
|
292
348
|
if (!pass)
|
|
293
|
-
|
|
349
|
+
writeTipLog(artifact, raw);
|
|
294
350
|
const tipResult = {
|
|
295
351
|
gate,
|
|
296
352
|
cmd,
|
|
297
353
|
pass,
|
|
354
|
+
evidenceReceipt, evidenceReceipts,
|
|
298
355
|
exitCode: r.code,
|
|
299
356
|
fingerprints: r.code !== 0 && !greenTeardown ? fingerprint(stripped) : [],
|
|
300
357
|
details: `${cause === "infra" ? "infra; " : ""}${rerun ? `${rerun}: ` : ""}` + (deficit?.replace(/^infra; /, "") ?? (r.code === 0 ? "exit 0"
|
|
@@ -315,12 +372,17 @@ export async function verifyIntegrationTip(intWt, commands, runDir, baseline) {
|
|
|
315
372
|
// spawns in the daemon child, and a command that changed the tree invalidates reuse.
|
|
316
373
|
const completedTree = pending.size ? await getWorktreeTree(intWt) : undefined;
|
|
317
374
|
for (const result of results) {
|
|
375
|
+
if (!result.reused && result.evidenceReceipt)
|
|
376
|
+
result.originRunRoot = resolve(evidenceOptions.artifactDir ?? runDir);
|
|
318
377
|
const id = pending.get(result.gate);
|
|
319
378
|
if (id && id.tree === completedTree && !isInfraResult(result)) {
|
|
320
379
|
store.set(id, { ...result, meta: {
|
|
321
380
|
fingerprints: result.fingerprints, forgiven: result.forgiven,
|
|
322
381
|
reportPath: result.reportPath, spawnedCommand: result.spawnedCommand,
|
|
323
382
|
artifact: result.artifact,
|
|
383
|
+
evidenceReceipt: result.evidenceReceipt, evidenceReceipts: result.evidenceReceipts,
|
|
384
|
+
evidenceAbsence: result.evidenceAbsence, originRunRoot: result.originRunRoot,
|
|
385
|
+
nonce: result.nonce, stdoutPath: result.stdoutPath, stderrPath: result.stderrPath,
|
|
324
386
|
} });
|
|
325
387
|
}
|
|
326
388
|
}
|
package/dist/run/protocol.d.ts
CHANGED
|
@@ -37,6 +37,88 @@ export declare const ShellReceiptSchema: z.ZodObject<{
|
|
|
37
37
|
durationMs: z.ZodOptional<z.ZodNumber>;
|
|
38
38
|
}, z.core.$strict>;
|
|
39
39
|
export type ShellReceipt = z.infer<typeof ShellReceiptSchema>;
|
|
40
|
+
export declare const EVIDENCE_AVAILABILITIES: readonly ["available", "not-started", "capture-failed", "killed", "expired", "missing"];
|
|
41
|
+
export declare const EvidenceArtifactSchema: z.ZodObject<{
|
|
42
|
+
path: z.ZodString;
|
|
43
|
+
availability: z.ZodEnum<{
|
|
44
|
+
available: "available";
|
|
45
|
+
"not-started": "not-started";
|
|
46
|
+
"capture-failed": "capture-failed";
|
|
47
|
+
killed: "killed";
|
|
48
|
+
expired: "expired";
|
|
49
|
+
missing: "missing";
|
|
50
|
+
}>;
|
|
51
|
+
sha256: z.ZodNullable<z.ZodString>;
|
|
52
|
+
retainedBytes: z.ZodNumber;
|
|
53
|
+
droppedBytes: z.ZodNumber;
|
|
54
|
+
truncated: z.ZodBoolean;
|
|
55
|
+
}, z.core.$strict>;
|
|
56
|
+
export declare const GateEvidenceReceiptSchema: z.ZodObject<{
|
|
57
|
+
invocationId: z.ZodString;
|
|
58
|
+
nonce: z.ZodOptional<z.ZodString>;
|
|
59
|
+
subject: z.ZodObject<{
|
|
60
|
+
runId: z.ZodString;
|
|
61
|
+
taskId: z.ZodNullable<z.ZodString>;
|
|
62
|
+
attempt: z.ZodNullable<z.ZodNumber>;
|
|
63
|
+
gate: z.ZodString;
|
|
64
|
+
subjectCommit: z.ZodNullable<z.ZodString>;
|
|
65
|
+
}, z.core.$strict>;
|
|
66
|
+
termination: z.ZodObject<{
|
|
67
|
+
kind: z.ZodEnum<{
|
|
68
|
+
unknown: "unknown";
|
|
69
|
+
signal: "signal";
|
|
70
|
+
"not-started": "not-started";
|
|
71
|
+
exit: "exit";
|
|
72
|
+
timeout: "timeout";
|
|
73
|
+
}>;
|
|
74
|
+
exitCode: z.ZodNullable<z.ZodNumber>;
|
|
75
|
+
signal: z.ZodNullable<z.ZodString>;
|
|
76
|
+
timedOut: z.ZodNullable<z.ZodBoolean>;
|
|
77
|
+
}, z.core.$strict>;
|
|
78
|
+
availability: z.ZodEnum<{
|
|
79
|
+
available: "available";
|
|
80
|
+
"not-started": "not-started";
|
|
81
|
+
"capture-failed": "capture-failed";
|
|
82
|
+
killed: "killed";
|
|
83
|
+
expired: "expired";
|
|
84
|
+
missing: "missing";
|
|
85
|
+
}>;
|
|
86
|
+
redaction: z.ZodObject<{
|
|
87
|
+
material: z.ZodBoolean;
|
|
88
|
+
}, z.core.$strict>;
|
|
89
|
+
stdout: z.ZodObject<{
|
|
90
|
+
path: z.ZodString;
|
|
91
|
+
availability: z.ZodEnum<{
|
|
92
|
+
available: "available";
|
|
93
|
+
"not-started": "not-started";
|
|
94
|
+
"capture-failed": "capture-failed";
|
|
95
|
+
killed: "killed";
|
|
96
|
+
expired: "expired";
|
|
97
|
+
missing: "missing";
|
|
98
|
+
}>;
|
|
99
|
+
sha256: z.ZodNullable<z.ZodString>;
|
|
100
|
+
retainedBytes: z.ZodNumber;
|
|
101
|
+
droppedBytes: z.ZodNumber;
|
|
102
|
+
truncated: z.ZodBoolean;
|
|
103
|
+
}, z.core.$strict>;
|
|
104
|
+
stderr: z.ZodObject<{
|
|
105
|
+
path: z.ZodString;
|
|
106
|
+
availability: z.ZodEnum<{
|
|
107
|
+
available: "available";
|
|
108
|
+
"not-started": "not-started";
|
|
109
|
+
"capture-failed": "capture-failed";
|
|
110
|
+
killed: "killed";
|
|
111
|
+
expired: "expired";
|
|
112
|
+
missing: "missing";
|
|
113
|
+
}>;
|
|
114
|
+
sha256: z.ZodNullable<z.ZodString>;
|
|
115
|
+
retainedBytes: z.ZodNumber;
|
|
116
|
+
droppedBytes: z.ZodNumber;
|
|
117
|
+
truncated: z.ZodBoolean;
|
|
118
|
+
}, z.core.$strict>;
|
|
119
|
+
}, z.core.$strict>;
|
|
120
|
+
export type GateEvidenceReceipt = z.infer<typeof GateEvidenceReceiptSchema>;
|
|
121
|
+
export type EvidenceArtifact = z.infer<typeof EvidenceArtifactSchema>;
|
|
40
122
|
/** A task-build row must carry the entire caller-owned correlation, even before spawn. */
|
|
41
123
|
export declare const CommandReceiptSchema: z.ZodObject<{
|
|
42
124
|
outcome: z.ZodEnum<{
|
package/dist/run/protocol.js
CHANGED
|
@@ -33,6 +33,41 @@ export const ShellReceiptSchema = z.object({
|
|
|
33
33
|
error: z.string().optional(),
|
|
34
34
|
durationMs: z.number().nonnegative().optional(),
|
|
35
35
|
}).strict();
|
|
36
|
+
export const EVIDENCE_AVAILABILITIES = ["available", "not-started", "capture-failed", "killed", "expired", "missing"];
|
|
37
|
+
export const EvidenceArtifactSchema = z.object({
|
|
38
|
+
path: z.string().refine(p => p.length > 0 && !p.startsWith("/") && !p.includes("\\") && !p.split("/").includes("..")),
|
|
39
|
+
availability: z.enum(EVIDENCE_AVAILABILITIES),
|
|
40
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
|
|
41
|
+
retainedBytes: z.number().int().min(0).max(16 * 1024),
|
|
42
|
+
droppedBytes: z.number().int().nonnegative(),
|
|
43
|
+
truncated: z.boolean(),
|
|
44
|
+
}).strict().superRefine((ref, ctx) => {
|
|
45
|
+
if (ref.truncated !== (ref.droppedBytes > 0))
|
|
46
|
+
ctx.addIssue({ code: "custom", message: "truncation must agree with dropped bytes" });
|
|
47
|
+
if (ref.availability === "available" && ref.sha256 === null)
|
|
48
|
+
ctx.addIssue({ code: "custom", message: "available bytes require a hash" });
|
|
49
|
+
});
|
|
50
|
+
export const GateEvidenceReceiptSchema = z.object({
|
|
51
|
+
invocationId: NonEmptyStringSchema,
|
|
52
|
+
nonce: NonEmptyStringSchema.optional(),
|
|
53
|
+
subject: z.object({
|
|
54
|
+
runId: NonEmptyStringSchema,
|
|
55
|
+
taskId: NonEmptyStringSchema.nullable(),
|
|
56
|
+
attempt: AttemptSchema.nullable(),
|
|
57
|
+
gate: NonEmptyStringSchema,
|
|
58
|
+
subjectCommit: NonEmptyStringSchema.nullable(),
|
|
59
|
+
}).strict(),
|
|
60
|
+
termination: z.object({
|
|
61
|
+
kind: z.enum(["exit", "signal", "timeout", "not-started", "unknown"]),
|
|
62
|
+
exitCode: z.number().int().nullable(),
|
|
63
|
+
signal: NonEmptyStringSchema.nullable(),
|
|
64
|
+
timedOut: z.boolean().nullable(),
|
|
65
|
+
}).strict(),
|
|
66
|
+
availability: z.enum(EVIDENCE_AVAILABILITIES),
|
|
67
|
+
redaction: z.object({ material: z.boolean() }).strict(),
|
|
68
|
+
stdout: EvidenceArtifactSchema,
|
|
69
|
+
stderr: EvidenceArtifactSchema,
|
|
70
|
+
}).strict();
|
|
36
71
|
/** A task-build row must carry the entire caller-owned correlation, even before spawn. */
|
|
37
72
|
export const CommandReceiptSchema = ShellReceiptSchema.extend({
|
|
38
73
|
attribution: CommandReceiptAttributionSchema,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type GateEvidenceReceipt } from "./protocol.js";
|
|
2
|
+
export declare const RECEIPT_UNAVAILABLE_REASONS: readonly ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
|
|
3
|
+
export type ReceiptUnavailableReason = (typeof RECEIPT_UNAVAILABLE_REASONS)[number];
|
|
4
|
+
export type ReceiptResolution = {
|
|
5
|
+
readonly ok: true;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly sha256: string;
|
|
8
|
+
} | {
|
|
9
|
+
readonly ok: false;
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly reason: ReceiptUnavailableReason;
|
|
12
|
+
};
|
|
13
|
+
/** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
|
|
14
|
+
export declare function resolveReceipt(reference: unknown, runRoot: string): ReceiptResolution;
|
|
15
|
+
/** Both artifacts of a receipt, stdout first, each resolved independently. */
|
|
16
|
+
export declare function resolveReceiptArtifacts(receipt: GateEvidenceReceipt, runRoot: string): ReceiptResolution[];
|
|
17
|
+
/** One line per artifact for a human surface: the reference, then its verified availability. */
|
|
18
|
+
export declare function formatReceiptResolution(r: ReceiptResolution): string;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { EvidenceArtifactSchema } from "./protocol.js";
|
|
5
|
+
// OBS-1101 retrieval (D-174): the one production reader of an execution-evidence receipt. It answers
|
|
6
|
+
// from the bytes on disk under the run root the receipt was minted in and nothing else — no
|
|
7
|
+
// re-execution, no substitute chosen by name or timestamp, no symlink followed anywhere: not in the
|
|
8
|
+
// reference, not at the root, not above it. The caller hands a canonical root (its own trust anchor);
|
|
9
|
+
// a root reached through any link is refused outright.
|
|
10
|
+
export const RECEIPT_UNAVAILABLE_REASONS = ["outside-root", "symlink", "hash-mismatch", "expired", "missing"];
|
|
11
|
+
/** Resolve one artifact reference against the run root it was recorded under. Fails closed. */
|
|
12
|
+
export function resolveReceipt(reference, runRoot) {
|
|
13
|
+
const parsed = EvidenceArtifactSchema.safeParse(reference);
|
|
14
|
+
const path = typeof reference?.path === "string" ? reference.path : "";
|
|
15
|
+
if (!parsed.success) {
|
|
16
|
+
const escapes = path && (isAbsolute(path) || path.includes("\\") || path.split("/").includes(".."));
|
|
17
|
+
return { ok: false, path, reason: escapes ? "outside-root" : "missing" };
|
|
18
|
+
}
|
|
19
|
+
const ref = parsed.data;
|
|
20
|
+
if (ref.availability === "expired")
|
|
21
|
+
return { ok: false, path: ref.path, reason: "expired" };
|
|
22
|
+
if (ref.availability !== "available" || ref.sha256 === null)
|
|
23
|
+
return { ok: false, path: ref.path, reason: "missing" };
|
|
24
|
+
const root = resolve(runRoot);
|
|
25
|
+
const full = resolve(root, ref.path);
|
|
26
|
+
const rel = relative(root, full);
|
|
27
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
28
|
+
return { ok: false, path: ref.path, reason: "outside-root" };
|
|
29
|
+
const read = readUnderRoot(root, rel.split(sep));
|
|
30
|
+
if (typeof read === "string")
|
|
31
|
+
return { ok: false, path: ref.path, reason: read };
|
|
32
|
+
const sha256 = createHash("sha256").update(read).digest("hex");
|
|
33
|
+
if (sha256 !== ref.sha256 || read.length !== ref.retainedBytes)
|
|
34
|
+
return { ok: false, path: ref.path, reason: "hash-mismatch" };
|
|
35
|
+
return { ok: true, path: ref.path, sha256 };
|
|
36
|
+
}
|
|
37
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
38
|
+
/** Every ancestor of the root, root first excluded — "/" itself cannot be a link. */
|
|
39
|
+
function ancestors(root) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (let p = dirname(root); p !== dirname(p); p = dirname(p))
|
|
42
|
+
out.push(p);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Walk `parts` below `root` one descriptor at a time and return the leaf's bytes. Every component —
|
|
47
|
+
* the root's ancestors, the root, each directory, the leaf — is opened O_NOFOLLOW and must be the
|
|
48
|
+
* same inode lstat reports for that name before AND after the open, so a component swapped for a
|
|
49
|
+
* link mid-walk is refused rather than followed. Every platform takes the same pathname open: an
|
|
50
|
+
* ancestor swap by a same-privilege writer can only ever yield hash-identical bytes (D-269), so no
|
|
51
|
+
* descriptor-anchored traversal is layered on top (D-279: the boundary is the run root).
|
|
52
|
+
*/
|
|
53
|
+
function readUnderRoot(root, parts) {
|
|
54
|
+
for (const p of ancestors(root)) {
|
|
55
|
+
let st;
|
|
56
|
+
try {
|
|
57
|
+
st = lstatSync(p);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return "missing";
|
|
61
|
+
}
|
|
62
|
+
if (st.isSymbolicLink())
|
|
63
|
+
return "symlink";
|
|
64
|
+
}
|
|
65
|
+
const fds = [];
|
|
66
|
+
try {
|
|
67
|
+
let parent = openComponent(root, true);
|
|
68
|
+
if (typeof parent === "string")
|
|
69
|
+
return parent;
|
|
70
|
+
fds.push(parent);
|
|
71
|
+
let at = root;
|
|
72
|
+
for (let i = 0; i < parts.length; i++) {
|
|
73
|
+
at = resolve(at, parts[i]);
|
|
74
|
+
const fd = openComponent(at, i < parts.length - 1);
|
|
75
|
+
if (typeof fd === "string")
|
|
76
|
+
return fd;
|
|
77
|
+
fds.push(fd);
|
|
78
|
+
parent = fd;
|
|
79
|
+
}
|
|
80
|
+
return readFileSync(parent);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return e.code === "ELOOP" ? "symlink" : "missing";
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
for (const fd of fds)
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function openComponent(path, dir) {
|
|
91
|
+
let before;
|
|
92
|
+
try {
|
|
93
|
+
before = lstatSync(path);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return "missing";
|
|
97
|
+
}
|
|
98
|
+
if (before.isSymbolicLink())
|
|
99
|
+
return "symlink";
|
|
100
|
+
if (dir ? !before.isDirectory() : !before.isFile())
|
|
101
|
+
return "missing";
|
|
102
|
+
const flags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK | (dir ? constants.O_DIRECTORY : 0);
|
|
103
|
+
const fd = openSync(path, flags);
|
|
104
|
+
let after;
|
|
105
|
+
try {
|
|
106
|
+
after = lstatSync(path);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
closeSync(fd);
|
|
110
|
+
return "missing";
|
|
111
|
+
}
|
|
112
|
+
const opened = fstatSync(fd);
|
|
113
|
+
if (after.isSymbolicLink() || !same(before, opened) || !same(after, opened)) {
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
return "symlink";
|
|
116
|
+
}
|
|
117
|
+
// A leaf reachable under a second name (a hard link) is an alias the inode check cannot see through;
|
|
118
|
+
// evidence is written once under one name, so any aliased leaf is refused.
|
|
119
|
+
if (!dir && opened.nlink !== 1) {
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
return "symlink";
|
|
122
|
+
}
|
|
123
|
+
return fd;
|
|
124
|
+
}
|
|
125
|
+
/** Both artifacts of a receipt, stdout first, each resolved independently. */
|
|
126
|
+
export function resolveReceiptArtifacts(receipt, runRoot) {
|
|
127
|
+
return [receipt.stdout, receipt.stderr].map(ref => resolveReceipt(ref, runRoot));
|
|
128
|
+
}
|
|
129
|
+
/** One line per artifact for a human surface: the reference, then its verified availability. */
|
|
130
|
+
export function formatReceiptResolution(r) {
|
|
131
|
+
return r.ok ? `${r.path} available sha256=${r.sha256}` : `${r.path} unavailable (${r.reason})`;
|
|
132
|
+
}
|
|
@@ -39,7 +39,20 @@ export interface SupervisionBeatObservation {
|
|
|
39
39
|
export declare const supervisionBeatPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
40
40
|
/** Where a watcher records that it STOOD DOWN. Its own file: the beat keeps meaning only "alive". */
|
|
41
41
|
export declare const supervisionStandDownPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
42
|
-
|
|
42
|
+
/** Durable CLI arm; ticks never advance its epoch or its stand-down fence. */
|
|
43
|
+
export interface SupervisionArm {
|
|
44
|
+
armId: string;
|
|
45
|
+
armEpoch: string;
|
|
46
|
+
markerFence: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const supervisionArmPath: (repoRoot: string, tier: SupervisionTier) => string;
|
|
49
|
+
/** Explicit arming acknowledges the current marker without deleting it. */
|
|
50
|
+
export declare function newSupervisionArm(repoRoot: string, tier: SupervisionTier, armId?: string, initial?: boolean): SupervisionArm;
|
|
51
|
+
export declare function beatSupervision(repoRoot: string, tier: SupervisionTier, seat?: string, observation?: SupervisionBeatObservation, options?: {
|
|
52
|
+
arm?: SupervisionArm;
|
|
53
|
+
armId?: string;
|
|
54
|
+
loop?: boolean;
|
|
55
|
+
}): boolean;
|
|
43
56
|
/** Handle a watcher holds for as long as it is supervising; disarm stands it down and is idempotent. */
|
|
44
57
|
export interface ArmedSupervision {
|
|
45
58
|
disarm: () => void;
|