@agent-delivery-harness/cli 0.2.0 → 0.7.3
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/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +5 -5
- package/src/boundary.ts +84 -63
- package/src/check-snapshot.ts +167 -0
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +80 -12
- package/src/commands/gate.ts +64 -13
- package/src/commands/maintain.ts +1 -0
- package/src/commands/managed.ts +105 -34
- package/src/commands/prepare.ts +148 -22
- package/src/commands/record.ts +107 -3
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +461 -30
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +156 -10
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +17 -1
- package/src/main.ts +56 -65
- package/src/ordinary-context.ts +83 -0
- package/src/provider-rails.ts +23 -704
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +202 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +327 -12
- package/src/run-server.ts +403 -149
- package/src/run-surface.ts +196 -9
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- package/src/run-view.ts +343 -0
- package/src/scoped-attempts.ts +86 -0
- package/src/scoped-candidate.ts +28 -0
- package/src/scoped-checks.ts +196 -0
- package/src/scoped-diagnostics.ts +124 -0
- package/src/scoped-observations.ts +51 -0
- package/src/snapshot-inventory-worker.ts +40 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/** Private execution tree. Never shares writable source, objects, index or dependencies. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createExecPort, type ExecOutcome } from "@agent-delivery-harness/kernel";
|
|
7
|
+
|
|
8
|
+
import { snapshotInventoryWorker } from "./snapshot-inventory-worker.ts";
|
|
9
|
+
|
|
10
|
+
const SNAPSHOT_TIMEOUT_MS = 5 * 60_000;
|
|
11
|
+
export class CheckSnapshotError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
constructor(code: string, message: string) { super(message); this.code = code; }
|
|
14
|
+
}
|
|
15
|
+
export interface SnapshotRequest {
|
|
16
|
+
readonly rootDir: string;
|
|
17
|
+
readonly candidate: { readonly treeSha: string; readonly headSha: string; readonly base: { readonly ref: string; readonly tipSha: string; readonly mergeBaseSha: string } };
|
|
18
|
+
readonly outputs: readonly string[];
|
|
19
|
+
readonly gitContext?: "full" | "none";
|
|
20
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
21
|
+
readonly dependencies?: { readonly command: readonly [string, ...string[]]; readonly timeoutMs: number };
|
|
22
|
+
readonly onDependencyResult?: (result: ExecOutcome, durationMs: number) => void;
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
export interface CheckSnapshot {
|
|
26
|
+
readonly commandRoot: string;
|
|
27
|
+
readonly rootDir: string;
|
|
28
|
+
readonly environment: Readonly<Record<string, string>>;
|
|
29
|
+
readonly dependencyDigest: string;
|
|
30
|
+
verify(options?: { readonly timeoutMs: number }): Promise<void>;
|
|
31
|
+
cleanup(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
function inside(root: string, target: string): boolean {
|
|
34
|
+
const relative = path.relative(root, target);
|
|
35
|
+
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
36
|
+
}
|
|
37
|
+
/** Kill the owned read-only process and await close before callers may clean up.
|
|
38
|
+
* AbortSignal is handled here: child_process's early AbortError event is not
|
|
39
|
+
* proof that the process has stopped touching the snapshot. */
|
|
40
|
+
async function snapshotProcess(command: string, args: string[], cwd: string, env: Readonly<Record<string, string>>, deadline: number, signal?: AbortSignal): Promise<{ code: number | null; stdout: string }> {
|
|
41
|
+
if (signal?.aborted) throw new CheckSnapshotError("check_snapshot_interrupted", "Snapshot verification was interrupted.");
|
|
42
|
+
const remaining = deadline - performance.now();
|
|
43
|
+
if (remaining <= 0) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
46
|
+
let failure: CheckSnapshotError | undefined, stdout = "", size = 0;
|
|
47
|
+
const stop = (code: string, message: string) => {
|
|
48
|
+
failure ??= new CheckSnapshotError(code, message);
|
|
49
|
+
child.kill("SIGKILL");
|
|
50
|
+
};
|
|
51
|
+
const abort = () => stop("check_snapshot_interrupted", "Snapshot verification was interrupted.");
|
|
52
|
+
const timer = setTimeout(() => stop("check_snapshot_timeout", "Snapshot verification exceeded its deadline."), remaining);
|
|
53
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
54
|
+
if (signal?.aborted) abort();
|
|
55
|
+
child.stdout.on("data", (bytes: Buffer) => {
|
|
56
|
+
size += bytes.length;
|
|
57
|
+
if (size > 64 * 1024) stop("check_snapshot_unavailable", "Snapshot verifier output exceeded its limit.");
|
|
58
|
+
else stdout += bytes.toString();
|
|
59
|
+
});
|
|
60
|
+
child.stderr.on("data", () => {}); // Drain; never expose private paths or source bytes.
|
|
61
|
+
child.on("error", () => { failure ??= new CheckSnapshotError("check_snapshot_unavailable", "Snapshot verifier could not execute."); });
|
|
62
|
+
child.once("close", code => {
|
|
63
|
+
clearTimeout(timer); signal?.removeEventListener("abort", abort);
|
|
64
|
+
if (!failure && performance.now() >= deadline) failure = new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
|
|
65
|
+
if (failure) reject(failure); else resolve({ code, stdout });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** The inventory's blocking filesystem operations live only in a killable process. */
|
|
70
|
+
export async function snapshotInventory(root: string, outputs: readonly string[], excludeDependencies = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS, signal?: AbortSignal, forbidGit = false): Promise<string> {
|
|
71
|
+
const result = await snapshotProcess(process.execPath, ["--input-type=module", "--eval", snapshotInventoryWorker], root,
|
|
72
|
+
{ PATH: process.env["PATH"] ?? "/usr/bin:/bin", DELIVERY_SNAPSHOT_REQUEST: JSON.stringify({ root, outputs, excludeDependencies, forbidGit }) }, deadline, signal);
|
|
73
|
+
let parsed: { code?: string; digest?: string };
|
|
74
|
+
try { parsed = JSON.parse(result.stdout); } catch { throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot inventory returned no valid result."); }
|
|
75
|
+
if (result.code !== 0 || !/^[a-f0-9]{64}$/.test(parsed.digest ?? "")) throw new CheckSnapshotError(["check_snapshot_escape", "check_snapshot_drift"].includes(parsed.code ?? "") ? parsed.code! : "check_snapshot_unavailable", "Snapshot inventory could not verify private bytes and links.");
|
|
76
|
+
return parsed.digest!;
|
|
77
|
+
}
|
|
78
|
+
export function executionPath(root: string, value: string): string {
|
|
79
|
+
return value.split(path.delimiter).filter(p => p && path.isAbsolute(p) && !inside(root, p) && !p.split(path.sep).includes("node_modules")).join(path.delimiter) || "/usr/bin:/bin";
|
|
80
|
+
}
|
|
81
|
+
export async function createCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
|
|
82
|
+
const rootDir = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-")));
|
|
83
|
+
const cleanEnv = { ...input.environment, PATH: executionPath(input.rootDir, input.environment["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z" };
|
|
84
|
+
let privateControl: string | undefined;
|
|
85
|
+
let gitDirectory: string | undefined;
|
|
86
|
+
const git = async (...args: string[]) => {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
const deadline = performance.now() + SNAPSHOT_TIMEOUT_MS;
|
|
90
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, SNAPSHOT_TIMEOUT_MS);
|
|
91
|
+
const abort = () => controller.abort();
|
|
92
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
93
|
+
if (input.signal?.aborted) abort();
|
|
94
|
+
try {
|
|
95
|
+
const result = await createExecPort().run({ command: "git", args: gitDirectory ? [`--git-dir=${gitDirectory}`, `--work-tree=${rootDir}`, ...args] : args,
|
|
96
|
+
cwd: rootDir, env: cleanEnv, maxBuffer: 128 * 1024 * 1024, signal: controller.signal });
|
|
97
|
+
timedOut ||= performance.now() >= deadline;
|
|
98
|
+
if (timedOut || result.code !== 0) throw new CheckSnapshotError(timedOut ? "check_snapshot_timeout" : input.signal?.aborted ? "check_snapshot_interrupted" : "check_snapshot_unavailable", "Private Git materialization did not complete successfully.");
|
|
99
|
+
return result.stdout.trim();
|
|
100
|
+
} finally { clearTimeout(timer); input.signal?.removeEventListener("abort", abort); }
|
|
101
|
+
};
|
|
102
|
+
const cleanup = async () => {
|
|
103
|
+
const removed = await Promise.allSettled([rootDir, ...(privateControl ? [privateControl] : [])].map(dir => rm(dir, { recursive: true, force: true })));
|
|
104
|
+
if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
await git("init", "-q");
|
|
108
|
+
// Fetch transfers object bytes; unlike worktrees/alternates/local clones it
|
|
109
|
+
// cannot create writable links back to the author's Git metadata.
|
|
110
|
+
// Accept the source's shallow boundary in this private repository. Without
|
|
111
|
+
// it, fetch can reject objects or succeed while dropping required grafts.
|
|
112
|
+
await git("fetch", "--update-shallow", "--no-tags", "--no-write-fetch-head", input.rootDir, input.candidate.headSha, input.candidate.base.tipSha, input.candidate.treeSha);
|
|
113
|
+
await git("update-ref", "HEAD", input.candidate.headSha);
|
|
114
|
+
await git("read-tree", input.candidate.treeSha);
|
|
115
|
+
await git("checkout-index", "--all", "--force");
|
|
116
|
+
if ((await git("ls-tree", "-r", "--name-only", input.candidate.treeSha)).split("\n").some(p => p.split("/").includes("node_modules"))) throw new CheckSnapshotError("check_dependency_source_overlap", "Tracked node_modules cannot be replaced by private dependency setup.");
|
|
117
|
+
const candidateRef = "refs/delivery/candidate", baseRef = "refs/delivery/base";
|
|
118
|
+
const candidateCommit = await git("-c", "user.name=Delivery", "-c", "user.email=delivery@example.invalid", "-c", "commit.gpgsign=false", "commit-tree", input.candidate.treeSha, "-p", input.candidate.headSha, "-m", "Prepared execution snapshot");
|
|
119
|
+
await git("update-ref", candidateRef, candidateCommit);
|
|
120
|
+
await git("update-ref", "refs/delivery/origin-head", input.candidate.headSha);
|
|
121
|
+
await git("update-ref", "HEAD", candidateCommit);
|
|
122
|
+
await git("update-ref", baseRef, input.candidate.base.tipSha);
|
|
123
|
+
await git("update-ref", "refs/delivery/merge-base", input.candidate.base.mergeBaseSha);
|
|
124
|
+
// Conventional branch refs also resolve to the pinned base for existing checks.
|
|
125
|
+
if (/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(input.candidate.base.ref)) await git("update-ref", input.candidate.base.ref.startsWith("refs/") ? input.candidate.base.ref : `refs/remotes/${input.candidate.base.ref}`, input.candidate.base.tipSha);
|
|
126
|
+
if (input.gitContext === "none") {
|
|
127
|
+
privateControl = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-control-")));
|
|
128
|
+
gitDirectory = path.join(privateControl, "repository");
|
|
129
|
+
await rename(path.join(rootDir, ".git"), gitDirectory);
|
|
130
|
+
}
|
|
131
|
+
const controlRoot = privateControl ?? path.join(rootDir, ".git");
|
|
132
|
+
const inventory = (outputs: readonly string[], sourceOnly = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS) => snapshotInventory(rootDir, outputs, sourceOnly, deadline, input.signal, input.gitContext === "none");
|
|
133
|
+
const sourceDigest = await inventory(input.outputs, true);
|
|
134
|
+
const environment: Record<string, string> = { ...input.environment, PATH: `${path.join(rootDir, "node_modules/.bin")}${path.delimiter}${cleanEnv["PATH"]}`, HOME: path.join(controlRoot, "home"), TMPDIR: path.join(controlRoot, "tmp"), GIT_CEILING_DIRECTORIES: path.dirname(rootDir),
|
|
135
|
+
...(input.gitContext === "none" ? {} : {
|
|
136
|
+
DELIVERY_CHECK_BASE_REF: baseRef, DELIVERY_CHECK_CANDIDATE_REF: candidateRef, DELIVERY_CHECK_ORIGIN_HEAD: input.candidate.headSha, DELIVERY_CHECK_ORIGIN_TREE: input.candidate.treeSha, DELIVERY_CHECK_MERGE_BASE: input.candidate.base.mergeBaseSha }) };
|
|
137
|
+
await mkdir(environment["HOME"]!, { recursive: true }); await mkdir(environment["TMPDIR"]!, { recursive: true });
|
|
138
|
+
if (input.dependencies) {
|
|
139
|
+
const started = performance.now();
|
|
140
|
+
const result = await createExecPort().run({ command: input.dependencies.command[0], args: input.dependencies.command.slice(1), cwd: rootDir, env: environment, timeoutMs: input.dependencies.timeoutMs, maxBuffer: 1024 * 1024, ...(input.signal ? { signal: input.signal } : {}) });
|
|
141
|
+
input.onDependencyResult?.(result, Math.round(performance.now() - started));
|
|
142
|
+
if (result.code !== 0 || input.signal?.aborted) throw new CheckSnapshotError("check_dependency_failed", "Private dependency installation did not complete successfully.");
|
|
143
|
+
}
|
|
144
|
+
if (sourceDigest !== await inventory(input.outputs, true)) throw new CheckSnapshotError("check_snapshot_drift", "Dependency setup changed prepared source bytes.");
|
|
145
|
+
const dependencyDigest = await inventory(input.outputs);
|
|
146
|
+
// The second digest covers both source and installed dependency bytes and
|
|
147
|
+
// all links. It intentionally excludes only declared mutable output paths.
|
|
148
|
+
const verify = async (options?: { readonly timeoutMs: number }) => {
|
|
149
|
+
const timeoutMs = options?.timeoutMs ?? SNAPSHOT_TIMEOUT_MS;
|
|
150
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > SNAPSHOT_TIMEOUT_MS) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot deadline must be positive and cannot exceed five minutes.");
|
|
151
|
+
const deadline = performance.now() + timeoutMs;
|
|
152
|
+
// The worker also checks file-only Git metadata, so lstat cannot stall the parent.
|
|
153
|
+
const verifyGit = async (...args: string[]) => {
|
|
154
|
+
const result = await snapshotProcess("git", gitDirectory ? [`--git-dir=${gitDirectory}`, `--work-tree=${rootDir}`, ...args] : args, rootDir, cleanEnv, deadline, input.signal);
|
|
155
|
+
if (result.code !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git identity could not be read.");
|
|
156
|
+
return result.stdout.trim();
|
|
157
|
+
};
|
|
158
|
+
await inventory([], false, deadline);
|
|
159
|
+
if (await verifyGit("write-tree") !== input.candidate.treeSha || await verifyGit("rev-parse", "HEAD") !== candidateCommit || await verifyGit("rev-parse", `${candidateRef}^{tree}`) !== input.candidate.treeSha || await verifyGit("rev-parse", baseRef) !== input.candidate.base.tipSha || dependencyDigest !== await inventory(input.outputs, false, deadline)) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
|
|
160
|
+
};
|
|
161
|
+
return { rootDir, commandRoot: path.join(controlRoot, "commands"), environment, dependencyDigest, verify, cleanup };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
await cleanup();
|
|
164
|
+
if (error instanceof CheckSnapshotError) throw error;
|
|
165
|
+
throw new CheckSnapshotError("check_snapshot_unavailable", "The exact prepared Git tree could not be materialized privately.");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Best-effort CLI observations. No caller uses these results for admission. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { RunEventKind, RunPreparationObservation, WaiverPrompt } from "@agent-delivery-harness/kernel";
|
|
4
|
+
import { buildRunEvent, resolveRunSurface } from "./run-surface.ts";
|
|
5
|
+
|
|
6
|
+
export interface CommandObservation {
|
|
7
|
+
readonly needsCandidate: boolean;
|
|
8
|
+
start(candidateTreeSha: string): Promise<void>;
|
|
9
|
+
prompt(prompt: WaiverPrompt): WaiverPrompt;
|
|
10
|
+
finish(exitCode: number, durationMs: number, digest?: string, preparation?: RunPreparationObservation): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
const absent: CommandObservation = { needsCandidate: false, start: async () => {}, prompt: prompt => prompt, finish: async () => {} };
|
|
13
|
+
function outcome(code: number) { return code === 0 ? "ok" : code === 2 ? "usage" : code === 130 ? "interrupted" : "policy"; }
|
|
14
|
+
|
|
15
|
+
/** Pin the current run once, so a pointer change cannot split an invocation. */
|
|
16
|
+
export async function beginCommandObservation(cwd: string, command: string): Promise<CommandObservation> {
|
|
17
|
+
try {
|
|
18
|
+
const resolved = await resolveRunSurface(cwd);
|
|
19
|
+
if (!resolved.ok) return absent;
|
|
20
|
+
const { store, commonDir, worktreeKey } = resolved.surface;
|
|
21
|
+
const current = await store.current(worktreeKey);
|
|
22
|
+
if (!current.ok || current.runId === undefined) return absent;
|
|
23
|
+
const runId = current.runId;
|
|
24
|
+
const history = await store.read(runId);
|
|
25
|
+
if (!history.ok) return absent;
|
|
26
|
+
// Capability is selected before emission. Never upgrade an existing writer.
|
|
27
|
+
const version = history.events[0]?.version ?? "run-event/1";
|
|
28
|
+
const attemptId = `command-${randomUUID()}`;
|
|
29
|
+
const activityId = attemptId;
|
|
30
|
+
let candidateTreeSha: string | undefined;
|
|
31
|
+
async function emit(kind: RunEventKind, payload: Record<string, unknown>) {
|
|
32
|
+
try {
|
|
33
|
+
await store.append(runId, buildRunEvent({runId,commonDir,kind,role:"cli",version,
|
|
34
|
+
...(version === "run-event/2" ? {eventId:randomUUID()} : {}),payload}));
|
|
35
|
+
} catch { /* Observations never change the command's result. */ }
|
|
36
|
+
}
|
|
37
|
+
const binding = () => ({activityId,attemptId,candidateTreeSha});
|
|
38
|
+
async function activity(state: string, nextStep: string) {
|
|
39
|
+
if (version !== "run-event/2" || candidateTreeSha === undefined) return;
|
|
40
|
+
await emit("activity.observed", {...binding(),state,owner:"delivery-harness.cli",phase:command,nextStep});
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
needsCandidate: version === "run-event/2",
|
|
44
|
+
async start(tree) {
|
|
45
|
+
candidateTreeSha = tree;
|
|
46
|
+
await activity("running", "Await command outcome; intermediate progress is unavailable unless explicitly observed.");
|
|
47
|
+
},
|
|
48
|
+
prompt(prompt) {
|
|
49
|
+
return async (decision, obligations) => {
|
|
50
|
+
if (version !== "run-event/2" || candidateTreeSha === undefined) return prompt(decision, obligations);
|
|
51
|
+
const waitId = `wait-${randomUUID()}`;
|
|
52
|
+
const scope = "This command invocation and the waiver decision's candidate and obligations only; this observation grants no authority.";
|
|
53
|
+
await activity("waiting", "Respond to the native waiver prompt.");
|
|
54
|
+
await emit("wait.started", {...binding(),waitId,owner:"operator",waitingOn:"human",reason:"The executing command opened its native waiver prompt.",nextAction:"Answer the native prompt.",scope});
|
|
55
|
+
try {
|
|
56
|
+
const answer = await prompt(decision, obligations);
|
|
57
|
+
await emit("wait.resolved", {...binding(),waitId,resolution:answer === false ? "Native prompt declined." : "Native prompt answered; approval remains subject to the gate.",scope});
|
|
58
|
+
return answer;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
await emit("wait.resolved", {...binding(),waitId,resolution:"Native prompt ended without a decision.",scope});
|
|
61
|
+
throw error;
|
|
62
|
+
} finally {
|
|
63
|
+
await activity("running", "Await command outcome.");
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
async finish(code, durationMs, digest, preparation) {
|
|
68
|
+
await activity(code === 0 ? "completed" : code === 130 ? "interrupted" : "failed", `Command returned ${outcome(code)} (exit ${code}); continue the declared delivery workflow.`);
|
|
69
|
+
await emit("command.completed", {command,outcome:outcome(code),durationMs,
|
|
70
|
+
...(code === 0 && digest !== undefined ? {digest} : {}),
|
|
71
|
+
...(version === "run-event/2" && command === "prepare" && code === 0 && preparation !== undefined ? {preparation} : {})});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
} catch { return absent; }
|
|
75
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `admit` — carry a concluded host review through the existing admission path.
|
|
3
|
+
*
|
|
4
|
+
* The command does not run reviewers and does not construct an approval. The
|
|
5
|
+
* supplied review-outcome/1 document must already name the digest of the exact
|
|
6
|
+
* review-context/1 document the current prepared candidate produces. Rebuilding
|
|
7
|
+
* that deterministic context after preparation lets the existing emitter retain
|
|
8
|
+
* it with the supplied outcome while refusing any candidate, base, policy,
|
|
9
|
+
* wiring, release, or charter drift.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { commandBlocker, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
|
|
15
|
+
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
16
|
+
import { gateCommand } from "./gate.ts";
|
|
17
|
+
import { prepareCommand } from "./prepare.ts";
|
|
18
|
+
import { recordCommand } from "./record.ts";
|
|
19
|
+
import { reviewContextCommand } from "./review-context.ts";
|
|
20
|
+
import { submitEvidenceCommand } from "./submit-evidence.ts";
|
|
21
|
+
|
|
22
|
+
const USAGE = [
|
|
23
|
+
"Usage: delivery-harness admit --outcome <review-outcome.json>",
|
|
24
|
+
"Prepares the candidate, binds the concluded outcome to its exact current review context, submits it, gates it, and writes the tracked record.",
|
|
25
|
+
"The command never runs reviewers or commits the record.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
function withArgs(context: CommandContext, args: readonly string[]): CommandContext {
|
|
29
|
+
return { ...context, args };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function report(context: CommandContext, label: string, result: CommandResult): CommandResult | undefined {
|
|
33
|
+
if (result.kind !== "ok") return result;
|
|
34
|
+
if (result.summary !== undefined && result.summary !== "") context.write(`${label}: ${result.summary}`);
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function invalidOutcome(message: string): CommandResult {
|
|
39
|
+
return {
|
|
40
|
+
kind: "blocked",
|
|
41
|
+
blockers: [commandBlocker({
|
|
42
|
+
code: "review_outcome_invalid",
|
|
43
|
+
sourceId: "delivery-harness.cli.admit",
|
|
44
|
+
summary: message,
|
|
45
|
+
remediations: [{
|
|
46
|
+
id: "supply-reviewed-current-context",
|
|
47
|
+
kind: "manual_action",
|
|
48
|
+
summary: "Prepare the candidate, retain review-context --json before independent review, and supply the concluded outcome naming that exact digest.",
|
|
49
|
+
}],
|
|
50
|
+
})],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const admitCommand: CommandDescriptor = {
|
|
55
|
+
name: "admit",
|
|
56
|
+
sourceId: "delivery-harness.cli.admit",
|
|
57
|
+
summary: "Prepare and admit a concluded review outcome through the tracked record write.",
|
|
58
|
+
usage: USAGE,
|
|
59
|
+
async run(context: CommandContext): Promise<CommandResult> {
|
|
60
|
+
if (context.args.length !== 2 || context.args[0] !== "--outcome" || !context.args[1] || context.args[1].startsWith("-")) {
|
|
61
|
+
return { kind: "usage", message: `admit requires --outcome <review-outcome.json>.\n${USAGE}` };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let outcome: unknown;
|
|
65
|
+
try {
|
|
66
|
+
outcome = JSON.parse(await readFile(path.resolve(context.rootDir, context.args[1]), "utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return { kind: "usage", message: `The review outcome must be a readable JSON document.\n${USAGE}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const prepared = await prepareCommand.run(withArgs(context, []));
|
|
72
|
+
const stoppedAfterPrepare = report(context, "prepared", prepared);
|
|
73
|
+
if (stoppedAfterPrepare !== undefined) return stoppedAfterPrepare;
|
|
74
|
+
|
|
75
|
+
const reviewed = await reviewContextCommand.run(withArgs(context, ["--json"]));
|
|
76
|
+
if (reviewed.kind !== "ok") return reviewed;
|
|
77
|
+
if (reviewed.summary === undefined) return invalidOutcome("the prepared candidate produced no review context");
|
|
78
|
+
|
|
79
|
+
let originalContext: unknown;
|
|
80
|
+
try {
|
|
81
|
+
originalContext = JSON.parse(reviewed.summary);
|
|
82
|
+
} catch {
|
|
83
|
+
return invalidOutcome("the prepared candidate produced an unreadable review context");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let manifestPath: string;
|
|
87
|
+
try {
|
|
88
|
+
const emitted = await emitReviewEvidence(context, originalContext, outcome);
|
|
89
|
+
manifestPath = emitted.manifestPath;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!(error instanceof OutcomeError)) throw error;
|
|
92
|
+
return invalidOutcome(error.message);
|
|
93
|
+
}
|
|
94
|
+
context.write(`review evidence: ${manifestPath}`);
|
|
95
|
+
|
|
96
|
+
const submitted = await submitEvidenceCommand.run(withArgs(context, ["--manifest", manifestPath]));
|
|
97
|
+
const stoppedAfterSubmission = report(context, "submitted", submitted);
|
|
98
|
+
if (stoppedAfterSubmission !== undefined) return stoppedAfterSubmission;
|
|
99
|
+
|
|
100
|
+
const gated = await gateCommand.run(withArgs(context, []));
|
|
101
|
+
const stoppedAfterGate = report(context, "gate", gated);
|
|
102
|
+
if (stoppedAfterGate !== undefined) return stoppedAfterGate;
|
|
103
|
+
|
|
104
|
+
return recordCommand.run(withArgs(context, []));
|
|
105
|
+
},
|
|
106
|
+
};
|
package/src/commands/check.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* behind. An unresolvable or unwritable store is a typed block, never a crash.
|
|
11
11
|
*/
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { resolveRecordStorage, BlockedError } from "@agent-delivery-harness/kernel";
|
|
13
|
+
import { deliveryRecordPathFor, resolveRecordStorage, BlockedError } from "@agent-delivery-harness/kernel";
|
|
14
14
|
import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
|
|
15
|
+
import { oneLine } from "../run-surface.ts";
|
|
15
16
|
|
|
16
17
|
/** Named so an interrupted run leaves something obviously disposable. */
|
|
17
18
|
const PROBE_FILE = ".delivery-harness-write-probe";
|
|
@@ -20,19 +21,29 @@ export const checkCommand: CommandDescriptor = {
|
|
|
20
21
|
name: "check",
|
|
21
22
|
sourceId: "delivery-harness.cli.check",
|
|
22
23
|
summary: "Confirm the config loads and the evidence store is usable.",
|
|
24
|
+
usage: "Usage: delivery-harness check\nTakes no arguments.",
|
|
23
25
|
async run(context: CommandContext): Promise<CommandResult> {
|
|
26
|
+
const unexpected = context.args[0];
|
|
27
|
+
if (unexpected !== undefined) {
|
|
28
|
+
return { kind: "usage", message: `check takes no arguments, and ${oneLine(unexpected, 64)} is one.\n${checkCommand.usage}` };
|
|
29
|
+
}
|
|
24
30
|
try {
|
|
25
31
|
const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace });
|
|
26
32
|
const probe = path.join(storage.storageDir, PROBE_FILE);
|
|
27
33
|
await context.artifacts.writeTextFile(probe, "probe\n", { mode: 0o600 });
|
|
28
34
|
await context.artifacts.removeFile(probe);
|
|
35
|
+
const recordPath = deliveryRecordPathFor(context.config, "<deliverableDigest>");
|
|
36
|
+
const baseMovement = context.config.deliveryRecordVerification.baseMovement === "stale"
|
|
37
|
+
? "base movement stale: verification refuses base-ref, base-tip, or merge-base drift; admit again against the current base"
|
|
38
|
+
: "base movement allow: verification may accept base drift and names every relaxed drift class";
|
|
29
39
|
return {
|
|
30
40
|
kind: "ok",
|
|
31
41
|
summary: [
|
|
32
42
|
`ok: gate ${context.config.gateId}`,
|
|
33
43
|
` ${context.config.obligations.length} obligation(s), ${context.config.providers.length} provider(s)`,
|
|
34
44
|
` store ${storage.storageDir} (writable)`,
|
|
35
|
-
` delivery record path ${
|
|
45
|
+
` delivery record path ${recordPath}`,
|
|
46
|
+
` ${baseMovement}`,
|
|
36
47
|
].join("\n"),
|
|
37
48
|
};
|
|
38
49
|
} catch (error) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { commandBlocker, type CommandDescriptor } from "../boundary.ts";
|
|
4
|
+
import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
|
|
5
|
+
|
|
6
|
+
export const emitReviewEvidenceCommand: CommandDescriptor = {
|
|
7
|
+
name: "emit-review-evidence",
|
|
8
|
+
sourceId: "delivery-harness.cli.emit-review-evidence",
|
|
9
|
+
summary: "Bind concluded review outcomes to their original prepared context.",
|
|
10
|
+
usage: "Usage: delivery-harness emit-review-evidence --context <review-context.json>\nReads a review-outcome/1 document on stdin.",
|
|
11
|
+
async run(context) {
|
|
12
|
+
if (context.args.length !== 2 || context.args[0] !== "--context" || !context.args[1] || context.args[1].startsWith("-")) {
|
|
13
|
+
return { kind: "usage", message: "emit-review-evidence requires --context <review-context.json> and a review-outcome/1 document on stdin." };
|
|
14
|
+
}
|
|
15
|
+
let original: unknown;
|
|
16
|
+
let document: unknown;
|
|
17
|
+
try {
|
|
18
|
+
original = JSON.parse(await readFile(path.resolve(context.rootDir, context.args[1]), "utf8"));
|
|
19
|
+
const raw = await context.readStdin?.() ?? "";
|
|
20
|
+
if (!raw.trim()) return { kind: "usage", message: "The review-outcome/1 document is required on stdin." };
|
|
21
|
+
document = JSON.parse(raw);
|
|
22
|
+
} catch {
|
|
23
|
+
return { kind: "usage", message: "The original context and review outcome must be readable JSON documents." };
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = await emitReviewEvidence(context, original, document);
|
|
27
|
+
return { kind: "ok", summary: result.manifestPath };
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!(error instanceof OutcomeError)) throw error;
|
|
30
|
+
return { kind: "blocked", blockers: [commandBlocker({
|
|
31
|
+
code: "review_outcome_invalid",
|
|
32
|
+
sourceId: "delivery-harness.cli.emit-review-evidence",
|
|
33
|
+
summary: error.message,
|
|
34
|
+
remediations: [{ id: "supply-reviewed-context", kind: "manual_action", summary: "Use the original review-context --json output and concluded outcomes naming its digest; acquire a new review if the deliverable or review inputs changed." }],
|
|
35
|
+
})] };
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
};
|
package/src/commands/emit.ts
CHANGED
|
@@ -41,13 +41,18 @@ import {
|
|
|
41
41
|
} from "../run-surface.ts";
|
|
42
42
|
import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
|
|
43
43
|
|
|
44
|
-
const USAGE = "Usage: delivery-harness emit <kind> [--run <id>] [--json <payload>] [--force]";
|
|
44
|
+
const USAGE = "Usage: delivery-harness emit <kind> [--run <id>] [--json <payload>] [--force] [--version 1|2] [--event-id <id>]";
|
|
45
|
+
|
|
46
|
+
const PAYLOAD_USAGE =
|
|
47
|
+
"emit needs a JSON payload: use --json <payload>, or omit --json only when piping JSON to stdin until EOF.";
|
|
45
48
|
|
|
46
49
|
interface ParsedArgs {
|
|
47
50
|
readonly kind: string;
|
|
48
51
|
readonly run?: string;
|
|
49
52
|
readonly json?: string;
|
|
50
53
|
readonly force: boolean;
|
|
54
|
+
readonly version?: "run-event/1" | "run-event/2";
|
|
55
|
+
readonly eventId?: string;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
type ArgParse = { readonly ok: true; readonly args: ParsedArgs } | { readonly ok: false; readonly message: string };
|
|
@@ -57,6 +62,8 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
57
62
|
let run: string | undefined;
|
|
58
63
|
let json: string | undefined;
|
|
59
64
|
let force = false;
|
|
65
|
+
let version: "run-event/1" | "run-event/2" | undefined;
|
|
66
|
+
let eventId: string | undefined;
|
|
60
67
|
|
|
61
68
|
for (let index = 0; index < args.length; index += 1) {
|
|
62
69
|
const token = args[index]!;
|
|
@@ -64,11 +71,16 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
64
71
|
force = true;
|
|
65
72
|
continue;
|
|
66
73
|
}
|
|
67
|
-
if (token === "--run" || token === "--json") {
|
|
74
|
+
if (token === "--run" || token === "--json" || token === "--version" || token === "--event-id") {
|
|
68
75
|
const value = args[index + 1];
|
|
69
76
|
if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
|
|
70
77
|
if (token === "--run") run = value;
|
|
71
|
-
else json = value;
|
|
78
|
+
else if (token === "--json") json = value;
|
|
79
|
+
else if (token === "--event-id") eventId = value;
|
|
80
|
+
else {
|
|
81
|
+
if (value !== "1" && value !== "2") return { ok: false, message: `--version must be 1 or 2.\n${USAGE}` };
|
|
82
|
+
version = value === "1" ? "run-event/1" : "run-event/2";
|
|
83
|
+
}
|
|
72
84
|
index += 1;
|
|
73
85
|
continue;
|
|
74
86
|
}
|
|
@@ -78,7 +90,8 @@ function parseArgs(args: readonly string[]): ArgParse {
|
|
|
78
90
|
}
|
|
79
91
|
|
|
80
92
|
if (kind === undefined) return { ok: false, message: `emit needs a kind.\n${USAGE}` };
|
|
81
|
-
return { ok: true, args: { kind, force, ...(run === undefined ? {} : { run }), ...(json === undefined ? {} : { json })
|
|
93
|
+
return { ok: true, args: { kind, force, ...(run === undefined ? {} : { run }), ...(json === undefined ? {} : { json }),
|
|
94
|
+
...(version === undefined ? {} : { version }), ...(eventId === undefined ? {} : { eventId }) } };
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
/**
|
|
@@ -96,6 +109,18 @@ function parsePayload(text: string): unknown {
|
|
|
96
109
|
}
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
async function suppliedPayload(context: ConfigFreeCommandContext, json: string | undefined): Promise<
|
|
113
|
+
{ readonly ok: true; readonly payload: unknown } | { readonly ok: false; readonly message: string }
|
|
114
|
+
> {
|
|
115
|
+
if (json !== undefined) return { ok: true, payload: parsePayload(json) };
|
|
116
|
+
if (context.stdinIsTTY === true) {
|
|
117
|
+
return { ok: false, message: `${PAYLOAD_USAGE}\nstdin is interactive, so emit will not wait for terminal EOF.\n${USAGE}` };
|
|
118
|
+
}
|
|
119
|
+
const text = await context.readStdin();
|
|
120
|
+
if (text.trim() === "") return { ok: false, message: `${PAYLOAD_USAGE}\n${USAGE}` };
|
|
121
|
+
return { ok: true, payload: parsePayload(text) };
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
const noRun = (details: string) =>
|
|
100
125
|
runSurfaceBlocker({
|
|
101
126
|
code: "run_unresolvable",
|
|
@@ -127,6 +152,7 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
127
152
|
name: "emit",
|
|
128
153
|
sourceId: "delivery-harness.cli.emit",
|
|
129
154
|
summary: "Append one run event to the current delivery run's journal.",
|
|
155
|
+
usage: USAGE,
|
|
130
156
|
configFree: true,
|
|
131
157
|
async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
|
|
132
158
|
const parsed = parseArgs(context.args);
|
|
@@ -154,7 +180,18 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
154
180
|
const store = surface.store;
|
|
155
181
|
|
|
156
182
|
if (kind === "run.started") {
|
|
157
|
-
|
|
183
|
+
const version = parsed.args.version ?? "run-event/1";
|
|
184
|
+
if ((version === "run-event/2") !== (parsed.args.eventId !== undefined)) {
|
|
185
|
+
return {
|
|
186
|
+
kind: "usage",
|
|
187
|
+
message: version === "run-event/2"
|
|
188
|
+
? `run.started is creating a version 2 run; every emit needs --event-id.\n${USAGE}`
|
|
189
|
+
: `run.started is creating a version 1 run and does not accept --event-id; drop it.\n${USAGE}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const supplied = await suppliedPayload(context, parsed.args.json);
|
|
193
|
+
if (!supplied.ok) return { kind: "usage", message: supplied.message };
|
|
194
|
+
return startRun(surface, force, supplied.payload, version, parsed.args.eventId);
|
|
158
195
|
}
|
|
159
196
|
|
|
160
197
|
const runId = await resolveRun(store, surface, named);
|
|
@@ -171,8 +208,25 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
171
208
|
};
|
|
172
209
|
}
|
|
173
210
|
|
|
174
|
-
const
|
|
175
|
-
|
|
211
|
+
const history = await store.read(runId);
|
|
212
|
+
if (!history.ok) return { kind: "blocked", blockers: [noRun("the selected run is unreadable")] };
|
|
213
|
+
const version = history.events[0]?.version ?? "run-event/1";
|
|
214
|
+
if (parsed.args.version !== undefined && parsed.args.version !== version) {
|
|
215
|
+
return { kind: "usage", message: "A run's writer version cannot change. End it and start an explicitly linked successor run." };
|
|
216
|
+
}
|
|
217
|
+
if ((version === "run-event/2") !== (parsed.args.eventId !== undefined)) {
|
|
218
|
+
return {
|
|
219
|
+
kind: "usage",
|
|
220
|
+
message: version === "run-event/2"
|
|
221
|
+
? `run ${runId} is version 2; every emit needs --event-id.\n${USAGE}`
|
|
222
|
+
: `run ${runId} is version 1 and does not accept --event-id; drop it, and drop the version-2 payload members roundId, bound, grace and reopensRoundId.\n${USAGE}`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const supplied = await suppliedPayload(context, parsed.args.json);
|
|
226
|
+
if (!supplied.ok) return { kind: "usage", message: supplied.message };
|
|
227
|
+
const payload = supplied.payload;
|
|
228
|
+
const event = buildRunEvent({ runId, commonDir: surface.commonDir, kind, role: "executor", payload, version,
|
|
229
|
+
...(parsed.args.eventId === undefined ? {} : { eventId: parsed.args.eventId }) });
|
|
176
230
|
|
|
177
231
|
// `command.completed` is the CLI's to write. The refusal is recorded in the
|
|
178
232
|
// run's note through the store's one bounded note writer, so an attempt
|
|
@@ -199,7 +253,9 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
199
253
|
};
|
|
200
254
|
}
|
|
201
255
|
|
|
202
|
-
|
|
256
|
+
// Resolve generated timestamps inside the append lock so concurrent retries
|
|
257
|
+
// retain the first observation instant while changed content still refuses.
|
|
258
|
+
const appended = await store.append(runId, event, { reuseExistingTimestamp: true });
|
|
203
259
|
if (!appended.ok) {
|
|
204
260
|
const first = appended.rejections[0];
|
|
205
261
|
return {
|
|
@@ -214,7 +270,7 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
214
270
|
details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
|
|
215
271
|
remediation: {
|
|
216
272
|
id: "correct-the-event",
|
|
217
|
-
summary:
|
|
273
|
+
summary: `Correct the kind or the payload against the ${version} contract and emit again.`,
|
|
218
274
|
},
|
|
219
275
|
}),
|
|
220
276
|
],
|
|
@@ -234,8 +290,17 @@ export const emitCommand: ConfigFreeCommandDescriptor = {
|
|
|
234
290
|
* journal behind. `--force` carries the displaced run's id into the new run's
|
|
235
291
|
* own `run.started` payload, which is what makes a restarted delivery legible
|
|
236
292
|
* afterwards.
|
|
293
|
+
*
|
|
294
|
+
* THE TWO REFUSALS THAT COME AFTER ALLOCATION are the append and the exclusive
|
|
295
|
+
* pointer write, and each takes the journal back through `discard`. Without
|
|
296
|
+
* that, a payload the store refuses leaves an empty journal per retry, and the
|
|
297
|
+
* loser of a pointer race leaves a journal carrying a real `run.started` —
|
|
298
|
+
* both of which `runs list` shows as open runs an operator cannot tell from an
|
|
299
|
+
* abandoned delivery. The removal is best-effort by construction: this command
|
|
300
|
+
* has already decided to refuse, and a store failure may not change that.
|
|
237
301
|
*/
|
|
238
|
-
async function startRun(surface: RunSurface, force: boolean, supplied: unknown
|
|
302
|
+
async function startRun(surface: RunSurface, force: boolean, supplied: unknown,
|
|
303
|
+
version: "run-event/1" | "run-event/2", eventId?: string): Promise<CommandResult> {
|
|
239
304
|
const store = surface.store;
|
|
240
305
|
const existing = await store.current(surface.worktreeKey);
|
|
241
306
|
const displaced = existing.ok ? existing.runId : undefined;
|
|
@@ -279,10 +344,12 @@ async function startRun(surface: RunSurface, force: boolean, supplied: unknown):
|
|
|
279
344
|
|
|
280
345
|
const appended = await store.append(
|
|
281
346
|
runId,
|
|
282
|
-
buildRunEvent({ runId, commonDir: surface.commonDir, kind: "run.started", role: "executor", payload
|
|
347
|
+
buildRunEvent({ runId, commonDir: surface.commonDir, kind: "run.started", role: "executor", payload, version,
|
|
348
|
+
...(eventId === undefined ? {} : { eventId }) }),
|
|
283
349
|
);
|
|
284
350
|
if (!appended.ok) {
|
|
285
351
|
const first = appended.rejections[0];
|
|
352
|
+
await store.discard(runId);
|
|
286
353
|
return {
|
|
287
354
|
kind: "blocked",
|
|
288
355
|
blockers: [
|
|
@@ -292,7 +359,7 @@ async function startRun(surface: RunSurface, force: boolean, supplied: unknown):
|
|
|
292
359
|
details: `run ${runId}: ${first === undefined ? "the store refused the append" : `${first.code} at ${oneLine(first.pointer, 64) || "/"}: ${oneLine(first.message, 200)}`}`,
|
|
293
360
|
remediation: {
|
|
294
361
|
id: "correct-the-event",
|
|
295
|
-
summary:
|
|
362
|
+
summary: `Correct the payload against the ${version} contract and emit again.`,
|
|
296
363
|
},
|
|
297
364
|
}),
|
|
298
365
|
],
|
|
@@ -317,6 +384,7 @@ async function startRun(surface: RunSurface, force: boolean, supplied: unknown):
|
|
|
317
384
|
}
|
|
318
385
|
}
|
|
319
386
|
if (!pointed.ok) {
|
|
387
|
+
await store.discard(runId);
|
|
320
388
|
return {
|
|
321
389
|
kind: "blocked",
|
|
322
390
|
blockers: [
|