harnery 0.22.1 → 0.24.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/README.md +1 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +302 -5
- package/dist/core/work/state.d.ts +7 -0
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +112 -23
- package/dist/core/workflow/child-env.d.ts +10 -6
- package/dist/core/workflow/child-env.d.ts.map +1 -1
- package/dist/core/workflow/child-env.js +15 -7
- package/dist/core/workflow/durable-record.d.ts +7 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -0
- package/dist/core/workflow/durable-record.js +83 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +208 -29
- package/dist/core/workflow/index.d.ts +2 -0
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +1 -0
- package/dist/core/workflow/proof.d.ts +4 -0
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +50 -15
- package/dist/core/workflow/run-state.d.ts +3 -0
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +25 -13
- package/dist/core/workflow/spawn-cursor.d.ts +3 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +8 -4
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/attestation-error.d.ts +7 -0
- package/dist/core/workflow/workspaces/attestation-error.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/attestation-error.js +25 -0
- package/dist/core/workflow/workspaces/cancellation.d.ts +8 -0
- package/dist/core/workflow/workspaces/cancellation.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/cancellation.js +95 -0
- package/dist/core/workflow/workspaces/cleanup.d.ts +9 -0
- package/dist/core/workflow/workspaces/cleanup.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/cleanup.js +223 -0
- package/dist/core/workflow/workspaces/execution.d.ts +25 -0
- package/dist/core/workflow/workspaces/execution.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/execution.js +316 -0
- package/dist/core/workflow/workspaces/git.d.ts +25 -0
- package/dist/core/workflow/workspaces/git.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/git.js +102 -0
- package/dist/core/workflow/workspaces/index.d.ts +15 -0
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/index.js +7 -0
- package/dist/core/workflow/workspaces/inspect.d.ts +85 -0
- package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/inspect.js +255 -0
- package/dist/core/workflow/workspaces/integration.d.ts +32 -0
- package/dist/core/workflow/workspaces/integration.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/integration.js +631 -0
- package/dist/core/workflow/workspaces/leases.d.ts +37 -0
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/leases.js +397 -0
- package/dist/core/workflow/workspaces/lifecycle.d.ts +43 -0
- package/dist/core/workflow/workspaces/lifecycle.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/lifecycle.js +622 -0
- package/dist/core/workflow/workspaces/local-git.d.ts +8 -0
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/local-git.js +1474 -0
- package/dist/core/workflow/workspaces/paths.d.ts +23 -0
- package/dist/core/workflow/workspaces/paths.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/paths.js +191 -0
- package/dist/core/workflow/workspaces/recovery.d.ts +3 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/recovery.js +50 -0
- package/dist/core/workflow/workspaces/state.d.ts +60 -0
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/state.js +296 -0
- package/dist/core/workflow/workspaces/types.d.ts +445 -0
- package/dist/core/workflow/workspaces/types.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/types.js +2 -0
- package/dist/core/workflow/workspaces/validate.d.ts +14 -0
- package/dist/core/workflow/workspaces/validate.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/validate.js +476 -0
- package/package.json +5 -3
- package/src/commands/workflow.ts +366 -8
- package/src/core/work/state.ts +156 -28
- package/src/core/workflow/child-env.ts +18 -7
- package/src/core/workflow/durable-record.ts +94 -0
- package/src/core/workflow/engine.ts +230 -30
- package/src/core/workflow/index.ts +74 -0
- package/src/core/workflow/proof.ts +70 -22
- package/src/core/workflow/run-state.ts +46 -11
- package/src/core/workflow/spawn-cursor.ts +8 -4
- package/src/core/workflow/types.ts +14 -0
- package/src/core/workflow/workspaces/attestation-error.ts +30 -0
- package/src/core/workflow/workspaces/cancellation.ts +137 -0
- package/src/core/workflow/workspaces/cleanup.ts +308 -0
- package/src/core/workflow/workspaces/execution.ts +412 -0
- package/src/core/workflow/workspaces/git.ts +125 -0
- package/src/core/workflow/workspaces/index.ts +89 -0
- package/src/core/workflow/workspaces/inspect.ts +421 -0
- package/src/core/workflow/workspaces/integration.ts +845 -0
- package/src/core/workflow/workspaces/leases.ts +517 -0
- package/src/core/workflow/workspaces/lifecycle.ts +792 -0
- package/src/core/workflow/workspaces/local-git.ts +1892 -0
- package/src/core/workflow/workspaces/paths.ts +247 -0
- package/src/core/workflow/workspaces/recovery.ts +56 -0
- package/src/core/workflow/workspaces/state.ts +481 -0
- package/src/core/workflow/workspaces/types.ts +517 -0
- package/src/core/workflow/workspaces/validate.ts +595 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
chmodSync,
|
|
4
4
|
closeSync,
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
mkdirSync,
|
|
7
7
|
openSync,
|
|
8
8
|
readFileSync,
|
|
9
|
-
renameSync,
|
|
10
9
|
statSync,
|
|
11
10
|
unlinkSync,
|
|
12
11
|
writeFileSync,
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
} from "../policy/index.ts";
|
|
24
23
|
import { assertWorkflowRunId, readWorkflowApproval } from "./approvals.ts";
|
|
25
24
|
import { isCanonicalWorkflowAttemptContext } from "./attempt-context.ts";
|
|
25
|
+
import { readJsonRecord, writeImmutableJson } from "./durable-record.ts";
|
|
26
26
|
import { normalizeWorkflowSpecialists } from "./specialists.ts";
|
|
27
27
|
import type {
|
|
28
28
|
WorkflowAttemptContext,
|
|
@@ -30,6 +30,16 @@ import type {
|
|
|
30
30
|
WorkflowWorkContext,
|
|
31
31
|
} from "./types.ts";
|
|
32
32
|
import { isCanonicalWorkflowWorkContext } from "./work-context.ts";
|
|
33
|
+
import type {
|
|
34
|
+
WorkspaceAllocationRequest,
|
|
35
|
+
WorkspaceBinding,
|
|
36
|
+
WorkspaceUnsupportedExecutionEvidence,
|
|
37
|
+
} from "./workspaces/index.ts";
|
|
38
|
+
import {
|
|
39
|
+
assertWorkspaceManifestAuthority,
|
|
40
|
+
isWorkspaceBinding,
|
|
41
|
+
isWorkspaceUnsupportedExecutionEvidence,
|
|
42
|
+
} from "./workspaces/validate.ts";
|
|
33
43
|
|
|
34
44
|
export const WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION = 1 as const;
|
|
35
45
|
|
|
@@ -59,6 +69,8 @@ export interface WorkflowRunManifest {
|
|
|
59
69
|
network_access: PolicyNetworkAccess;
|
|
60
70
|
policy?: NormalizedPolicy;
|
|
61
71
|
specialists?: Record<string, WorkflowSpecialistProfile>;
|
|
72
|
+
workspace_binding?: WorkspaceBinding;
|
|
73
|
+
workspace_fallback?: WorkspaceUnsupportedExecutionEvidence;
|
|
62
74
|
};
|
|
63
75
|
}
|
|
64
76
|
|
|
@@ -73,6 +85,10 @@ export function workflowScriptDigest(scriptPath: string): string {
|
|
|
73
85
|
|
|
74
86
|
export function writeWorkflowRunManifest(input: WriteWorkflowRunManifestInput): string {
|
|
75
87
|
assertWorkflowRunId(input.manifest.run_id);
|
|
88
|
+
assertWorkspaceManifestAuthority(
|
|
89
|
+
input.manifest,
|
|
90
|
+
readManifestWorkspaceRequest(input.coordRoot, input.manifest.run_id),
|
|
91
|
+
);
|
|
76
92
|
const path = workflowRunManifestPath(input.coordRoot, input.manifest.run_id);
|
|
77
93
|
if (existsSync(path))
|
|
78
94
|
throw new Error(`workflow run ${input.manifest.run_id} already has a manifest`);
|
|
@@ -81,14 +97,7 @@ export function writeWorkflowRunManifest(input: WriteWorkflowRunManifestInput):
|
|
|
81
97
|
throw new Error(`workflow run manifest exceeds ${MANIFEST_LIMIT_BYTES} bytes`);
|
|
82
98
|
}
|
|
83
99
|
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
-
|
|
85
|
-
try {
|
|
86
|
-
writeFileSync(temporary, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
87
|
-
renameSync(temporary, path);
|
|
88
|
-
chmodSync(path, 0o600);
|
|
89
|
-
} finally {
|
|
90
|
-
if (existsSync(temporary)) unlinkSync(temporary);
|
|
91
|
-
}
|
|
100
|
+
writeImmutableJson(path, input.manifest);
|
|
92
101
|
return path;
|
|
93
102
|
}
|
|
94
103
|
|
|
@@ -131,6 +140,7 @@ export function readWorkflowRunManifest(coordRoot: string, runId: string): Workf
|
|
|
131
140
|
) {
|
|
132
141
|
throw new Error(`workflow run manifest at ${path} has an unsupported or mismatched schema`);
|
|
133
142
|
}
|
|
143
|
+
assertWorkspaceManifestAuthority(manifest, readManifestWorkspaceRequest(coordRoot, runId));
|
|
134
144
|
return manifest;
|
|
135
145
|
}
|
|
136
146
|
|
|
@@ -273,10 +283,35 @@ function validExecution(value: WorkflowRunManifest["execution"]): boolean {
|
|
|
273
283
|
["shared", "worktree", "sandbox", "remote"].includes(value.isolation) &&
|
|
274
284
|
["enabled", "disabled", "unknown"].includes(value.network_access) &&
|
|
275
285
|
validSpecialists(value.specialists) &&
|
|
276
|
-
validFrozenPolicy(value.policy, value.cwd)
|
|
286
|
+
validFrozenPolicy(value.policy, value.workspace_binding?.integration_root ?? value.cwd) &&
|
|
287
|
+
validWorkspaceBinding(value.workspace_binding) &&
|
|
288
|
+
validWorkspaceFallback(value.workspace_fallback) &&
|
|
289
|
+
!(value.workspace_binding && value.workspace_fallback) &&
|
|
290
|
+
(value.workspace_fallback === undefined ||
|
|
291
|
+
value.workspace_fallback.requested_isolation === value.isolation)
|
|
277
292
|
);
|
|
278
293
|
}
|
|
279
294
|
|
|
295
|
+
function readManifestWorkspaceRequest(
|
|
296
|
+
coordRoot: string,
|
|
297
|
+
runId: string,
|
|
298
|
+
): WorkspaceAllocationRequest | undefined {
|
|
299
|
+
const path = join(coordRoot, ".harnery", "workflows", runId, "workspace-request.json");
|
|
300
|
+
return existsSync(path)
|
|
301
|
+
? readJsonRecord<WorkspaceAllocationRequest>(path, "workspace request")
|
|
302
|
+
: undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function validWorkspaceBinding(binding: WorkspaceBinding | undefined): boolean {
|
|
306
|
+
return binding === undefined || isWorkspaceBinding(binding);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function validWorkspaceFallback(
|
|
310
|
+
fallback: WorkspaceUnsupportedExecutionEvidence | undefined,
|
|
311
|
+
): boolean {
|
|
312
|
+
return fallback === undefined || isWorkspaceUnsupportedExecutionEvidence(fallback);
|
|
313
|
+
}
|
|
314
|
+
|
|
280
315
|
function validSpecialists(
|
|
281
316
|
specialists: Record<string, WorkflowSpecialistProfile> | undefined,
|
|
282
317
|
): boolean {
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* session_id, …}`).
|
|
11
11
|
* - `--trust` is required: headless runs refuse untrusted workspaces (exit 1,
|
|
12
12
|
* "Workspace Trust Required") — see the argv comment below.
|
|
13
|
+
* - `--force` is required: print mode has no channel for an operator to approve
|
|
14
|
+
* shell commands, so Smart Auto can leave a child able to patch files but
|
|
15
|
+
* unable to test or commit them.
|
|
13
16
|
* - Envelope drift guard: when stdout doesn't parse as JSON but the process
|
|
14
17
|
* exited 0, the raw stdout is returned as the reply text.
|
|
15
18
|
* - No per-run cost surface → undefined. No max-turns equivalent → `maxTurns`
|
|
@@ -51,10 +54,11 @@ export function parseCursorOutput(stdout: string): {
|
|
|
51
54
|
export function buildCursorInvocation(req: SpawnRequest): HarnessInvocation {
|
|
52
55
|
validateHarnessEffort("cursor", req.effort);
|
|
53
56
|
// --trust: headless cursor-agent refuses untrusted workspaces (exit 1,
|
|
54
|
-
// "Workspace Trust Required").
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
57
|
+
// "Workspace Trust Required"). --force: a print-mode child has no interactive
|
|
58
|
+
// approval channel, so host-authorized workflow dispatch must let it run
|
|
59
|
+
// commands. Neither flag maps host policy into child tools; that capability
|
|
60
|
+
// remains explicitly unsupported.
|
|
61
|
+
const argv = ["cursor-agent", "-p", req.prompt, "--output-format", "json", "--trust", "--force"];
|
|
58
62
|
if (req.model) argv.push("--model", req.model);
|
|
59
63
|
return { argv };
|
|
60
64
|
}
|
|
@@ -18,6 +18,11 @@ import type {
|
|
|
18
18
|
PolicySpec,
|
|
19
19
|
} from "../policy/index.ts";
|
|
20
20
|
import type { BillingMode, BillingProber } from "./billing.ts";
|
|
21
|
+
import type {
|
|
22
|
+
WorkspaceBinding,
|
|
23
|
+
WorkspaceExecutionEvidence,
|
|
24
|
+
WorkspaceProvider,
|
|
25
|
+
} from "./workspaces/index.ts";
|
|
21
26
|
|
|
22
27
|
export const WORKFLOW_PROOF_SCHEMA_VERSION = 1 as const;
|
|
23
28
|
export const WORKFLOW_WORK_CONTEXT_SCHEMA_VERSION = 1 as const;
|
|
@@ -170,6 +175,8 @@ export interface WorkflowProof {
|
|
|
170
175
|
agents: WorkflowAgentProof[];
|
|
171
176
|
evidence: WorkflowEvidenceRecord[];
|
|
172
177
|
policy?: WorkflowPolicyProof;
|
|
178
|
+
/** Immutable terminal provider evidence for isolated execution. */
|
|
179
|
+
execution?: WorkspaceExecutionEvidence;
|
|
173
180
|
repository: WorkflowRepoEvidence;
|
|
174
181
|
harnesses: HarnessEvidenceCoverage[];
|
|
175
182
|
unknowns: WorkflowProofUnknown[];
|
|
@@ -438,6 +445,11 @@ export interface EngineOpts {
|
|
|
438
445
|
isolation?: PolicyIsolation;
|
|
439
446
|
/** Network state of spawned harness subprocesses (default unknown). */
|
|
440
447
|
networkAccess?: PolicyNetworkAccess;
|
|
448
|
+
/** Explicit provider and host-owned roots. Omit for shared or declaration-only execution. */
|
|
449
|
+
workspace?: {
|
|
450
|
+
provider: WorkspaceProvider;
|
|
451
|
+
writableRoots: readonly string[];
|
|
452
|
+
};
|
|
441
453
|
}
|
|
442
454
|
|
|
443
455
|
export interface RunReport {
|
|
@@ -462,4 +474,6 @@ export interface RunReport {
|
|
|
462
474
|
billing: Array<{ harness: HarnessName; mode: BillingMode }>;
|
|
463
475
|
/** Policy verdict totals when the host supplied a policy. */
|
|
464
476
|
policy?: WorkflowPolicyProof["summary"];
|
|
477
|
+
/** Isolated workspace binding when this run requested a provider capability. */
|
|
478
|
+
workspaceBinding?: WorkspaceBinding;
|
|
465
479
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { WorkspaceAttestation, WorkspaceBinding } from "./types.ts";
|
|
2
|
+
import { isWorkspaceAttestation } from "./validate.ts";
|
|
3
|
+
|
|
4
|
+
export class WorkspaceAttestationError extends Error {
|
|
5
|
+
readonly attestation: WorkspaceAttestation;
|
|
6
|
+
|
|
7
|
+
constructor(message: string, attestation: WorkspaceAttestation) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "WorkspaceAttestationError";
|
|
10
|
+
this.attestation = attestation;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function workspaceAttestationFromError(
|
|
15
|
+
error: unknown,
|
|
16
|
+
binding: WorkspaceBinding,
|
|
17
|
+
): WorkspaceAttestation | undefined {
|
|
18
|
+
const seen = new Set<unknown>();
|
|
19
|
+
let current = error;
|
|
20
|
+
while (current !== undefined && current !== null && !seen.has(current)) {
|
|
21
|
+
seen.add(current);
|
|
22
|
+
if (isWorkspaceAttestation(current, binding)) return current;
|
|
23
|
+
if (typeof current !== "object") return undefined;
|
|
24
|
+
if ("attestation" in current && isWorkspaceAttestation(current.attestation, binding)) {
|
|
25
|
+
return current.attestation;
|
|
26
|
+
}
|
|
27
|
+
current = "cause" in current ? current.cause : undefined;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acquireWorkEventLease,
|
|
3
|
+
readWorkItemIgnoringLease,
|
|
4
|
+
type WorkEvent,
|
|
5
|
+
} from "../../work/state.ts";
|
|
6
|
+
import { readWorkflowRunManifest } from "../run-state.ts";
|
|
7
|
+
import {
|
|
8
|
+
appendWorkflowJournalEvent,
|
|
9
|
+
readWorkflowSupplement,
|
|
10
|
+
stableDigest,
|
|
11
|
+
writeWorkflowSupplement,
|
|
12
|
+
} from "./state.ts";
|
|
13
|
+
import type {
|
|
14
|
+
WorkspaceBinding,
|
|
15
|
+
WorkspaceCancellationReceipt,
|
|
16
|
+
WorkspaceCancellationResult,
|
|
17
|
+
WorkspaceProvider,
|
|
18
|
+
} from "./types.ts";
|
|
19
|
+
import { isWorkspaceAttestation } from "./validate.ts";
|
|
20
|
+
|
|
21
|
+
export interface CancelWorkspaceInput {
|
|
22
|
+
coordRoot: string;
|
|
23
|
+
runId: string;
|
|
24
|
+
provider: WorkspaceProvider;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function cancelWorkspace(
|
|
28
|
+
input: CancelWorkspaceInput,
|
|
29
|
+
): Promise<WorkspaceCancellationReceipt> {
|
|
30
|
+
const manifest = readWorkflowRunManifest(input.coordRoot, input.runId);
|
|
31
|
+
const binding = manifest.execution.workspace_binding;
|
|
32
|
+
if (binding?.owner.kind !== "work_attempt") {
|
|
33
|
+
throw new Error("workspace cancellation requires a work-linked isolated binding");
|
|
34
|
+
}
|
|
35
|
+
const release = acquireWorkEventLease(input.coordRoot, binding.owner.work_item_id);
|
|
36
|
+
try {
|
|
37
|
+
const cancellation = currentWorkCancellation(input.coordRoot, binding);
|
|
38
|
+
const bindingSha256 = stableDigest(binding);
|
|
39
|
+
const eventSha256 = stableDigest(cancellation);
|
|
40
|
+
const prior = readWorkflowSupplement<WorkspaceCancellationReceipt>(
|
|
41
|
+
input.coordRoot,
|
|
42
|
+
input.runId,
|
|
43
|
+
"cancellation/outcome.json",
|
|
44
|
+
);
|
|
45
|
+
if (prior) {
|
|
46
|
+
validateCancellationReceipt(prior, binding, cancellation);
|
|
47
|
+
return prior;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const providerBinding = await input.provider.readBinding(binding);
|
|
51
|
+
if (stableDigest(providerBinding) !== bindingSha256) {
|
|
52
|
+
throw new Error("workspace cancellation provider binding differs from the frozen manifest");
|
|
53
|
+
}
|
|
54
|
+
const result = await input.provider.cancel(binding);
|
|
55
|
+
validateCancellationResult(result, binding);
|
|
56
|
+
const receipt: WorkspaceCancellationReceipt = {
|
|
57
|
+
schema_version: 1,
|
|
58
|
+
receipt_id: `cancellation-${stableDigest({
|
|
59
|
+
binding: bindingSha256,
|
|
60
|
+
event: eventSha256,
|
|
61
|
+
result,
|
|
62
|
+
}).slice(0, 24)}`,
|
|
63
|
+
run_id: input.runId,
|
|
64
|
+
binding_id: binding.binding_id,
|
|
65
|
+
binding_sha256: bindingSha256,
|
|
66
|
+
work_item_id: binding.owner.work_item_id,
|
|
67
|
+
work_event_seq: cancellation.seq,
|
|
68
|
+
work_event_sha256: eventSha256,
|
|
69
|
+
status: result.status,
|
|
70
|
+
recorded_at: result.recorded_at,
|
|
71
|
+
attestation: result.attestation,
|
|
72
|
+
reason: result.reason,
|
|
73
|
+
};
|
|
74
|
+
writeWorkflowSupplement(input.coordRoot, input.runId, "cancellation/outcome.json", receipt);
|
|
75
|
+
appendWorkflowJournalEvent(input.coordRoot, input.runId, "workspace.cancel", {
|
|
76
|
+
binding_id: binding.binding_id,
|
|
77
|
+
provider_id: binding.provider.id,
|
|
78
|
+
receipt_id: receipt.receipt_id,
|
|
79
|
+
work_event_seq: cancellation.seq,
|
|
80
|
+
work_event_sha256: eventSha256,
|
|
81
|
+
status: receipt.status,
|
|
82
|
+
});
|
|
83
|
+
return receipt;
|
|
84
|
+
} finally {
|
|
85
|
+
release();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function currentWorkCancellation(coordRoot: string, binding: WorkspaceBinding): WorkEvent {
|
|
90
|
+
if (binding.owner.kind !== "work_attempt") {
|
|
91
|
+
throw new Error("workspace cancellation requires a work attempt owner");
|
|
92
|
+
}
|
|
93
|
+
const work = readWorkItemIgnoringLease(coordRoot, binding.owner.work_item_id);
|
|
94
|
+
const governance = [...work.events]
|
|
95
|
+
.reverse()
|
|
96
|
+
.find((event) => ["work.accepted", "work.cancelled", "work.reopened"].includes(event.event));
|
|
97
|
+
if (work.projection.state !== "cancelled" || governance?.event !== "work.cancelled") {
|
|
98
|
+
throw new Error("workspace cancellation requires the current host work cancellation");
|
|
99
|
+
}
|
|
100
|
+
return governance;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateCancellationResult(
|
|
104
|
+
result: WorkspaceCancellationResult,
|
|
105
|
+
binding: WorkspaceBinding,
|
|
106
|
+
): void {
|
|
107
|
+
if (
|
|
108
|
+
result.schema_version !== 1 ||
|
|
109
|
+
result.binding_id !== binding.binding_id ||
|
|
110
|
+
!["cancelled", "unsupported", "preserved_dirty", "blocked"].includes(result.status) ||
|
|
111
|
+
!Number.isFinite(Date.parse(result.recorded_at)) ||
|
|
112
|
+
!isWorkspaceAttestation(result.attestation, binding)
|
|
113
|
+
) {
|
|
114
|
+
throw new Error("workspace cancellation provider returned an invalid result");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function validateCancellationReceipt(
|
|
119
|
+
receipt: WorkspaceCancellationReceipt,
|
|
120
|
+
binding: WorkspaceBinding,
|
|
121
|
+
cancellation: WorkEvent,
|
|
122
|
+
): void {
|
|
123
|
+
if (
|
|
124
|
+
receipt.schema_version !== 1 ||
|
|
125
|
+
receipt.run_id !== binding.run_id ||
|
|
126
|
+
receipt.binding_id !== binding.binding_id ||
|
|
127
|
+
receipt.binding_sha256 !== stableDigest(binding) ||
|
|
128
|
+
receipt.work_item_id !== binding.owner.work_item_id ||
|
|
129
|
+
receipt.work_event_seq !== cancellation.seq ||
|
|
130
|
+
receipt.work_event_sha256 !== stableDigest(cancellation) ||
|
|
131
|
+
!["cancelled", "unsupported", "preserved_dirty", "blocked"].includes(receipt.status) ||
|
|
132
|
+
!Number.isFinite(Date.parse(receipt.recorded_at)) ||
|
|
133
|
+
!isWorkspaceAttestation(receipt.attestation, binding)
|
|
134
|
+
) {
|
|
135
|
+
throw new Error("workspace cancellation outcome does not match current host authority");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readWorkflowProof } from "../proof.ts";
|
|
3
|
+
import { readWorkflowRunManifest } from "../run-state.ts";
|
|
4
|
+
import { acquireNoClobberLease } from "./leases.ts";
|
|
5
|
+
import { containsPath } from "./paths.ts";
|
|
6
|
+
import {
|
|
7
|
+
appendCleanupAttempt,
|
|
8
|
+
appendWorkflowJournalEvent,
|
|
9
|
+
readCleanupAttempts,
|
|
10
|
+
readWorkflowSupplement,
|
|
11
|
+
stableDigest,
|
|
12
|
+
workflowRunDir,
|
|
13
|
+
writeWorkflowSupplement,
|
|
14
|
+
} from "./state.ts";
|
|
15
|
+
import type {
|
|
16
|
+
WorkspaceBinding,
|
|
17
|
+
WorkspaceCleanupAttempt,
|
|
18
|
+
WorkspaceCleanupIntent,
|
|
19
|
+
WorkspaceCleanupMode,
|
|
20
|
+
WorkspaceCleanupReceipt,
|
|
21
|
+
WorkspaceCleanupResult,
|
|
22
|
+
WorkspaceProvider,
|
|
23
|
+
} from "./types.ts";
|
|
24
|
+
import { isWorkspaceAttestation, isWorkspaceBoundExecutionEvidence } from "./validate.ts";
|
|
25
|
+
|
|
26
|
+
const CLEANUP_LEASE_STALE_MS = 5 * 60 * 1_000;
|
|
27
|
+
|
|
28
|
+
export interface CleanupWorkspaceInput {
|
|
29
|
+
coordRoot: string;
|
|
30
|
+
runId: string;
|
|
31
|
+
provider: WorkspaceProvider;
|
|
32
|
+
mode?: WorkspaceCleanupMode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function cleanupWorkspace(
|
|
36
|
+
input: CleanupWorkspaceInput,
|
|
37
|
+
): Promise<WorkspaceCleanupAttempt | WorkspaceCleanupReceipt> {
|
|
38
|
+
const release = acquireCleanupLease(input.coordRoot, input.runId);
|
|
39
|
+
try {
|
|
40
|
+
return await cleanupWorkspaceUnderLease(input);
|
|
41
|
+
} finally {
|
|
42
|
+
release();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function cleanupWorkspaceUnderLease(
|
|
47
|
+
input: CleanupWorkspaceInput,
|
|
48
|
+
): Promise<WorkspaceCleanupAttempt | WorkspaceCleanupReceipt> {
|
|
49
|
+
const manifest = readWorkflowRunManifest(input.coordRoot, input.runId);
|
|
50
|
+
const proof = readWorkflowProof(input.coordRoot, input.runId);
|
|
51
|
+
const binding = manifest.execution.workspace_binding;
|
|
52
|
+
if (
|
|
53
|
+
!binding ||
|
|
54
|
+
!isWorkspaceBoundExecutionEvidence(proof.execution, input.runId) ||
|
|
55
|
+
stableDigest(binding) !== stableDigest(proof.execution.binding)
|
|
56
|
+
) {
|
|
57
|
+
throw new Error("workspace cleanup requires matching terminal execution evidence");
|
|
58
|
+
}
|
|
59
|
+
assertCleanupPathAuthority(binding, manifest.execution.policy?.allowed_paths ?? []);
|
|
60
|
+
|
|
61
|
+
const mode = input.mode ?? "normal";
|
|
62
|
+
const current = await input.provider.attest(binding);
|
|
63
|
+
if (!isWorkspaceAttestation(current, binding)) {
|
|
64
|
+
throw new Error("workspace cleanup provider returned an invalid current attestation");
|
|
65
|
+
}
|
|
66
|
+
const intent = resolveCleanupIntent(input, binding, current, mode);
|
|
67
|
+
for (const attempt of readCleanupAttempts(input.coordRoot, input.runId)) {
|
|
68
|
+
if (
|
|
69
|
+
attempt.operation_id !== intent.operation_id ||
|
|
70
|
+
attempt.binding_id !== binding.binding_id ||
|
|
71
|
+
attempt.binding_sha256 !== intent.binding_sha256 ||
|
|
72
|
+
attempt.mode !== intent.mode ||
|
|
73
|
+
(attempt.attestation !== undefined && !isWorkspaceAttestation(attempt.attestation, binding))
|
|
74
|
+
) {
|
|
75
|
+
throw new Error("workspace cleanup attempt does not match the immutable intent");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const receipt = readWorkflowSupplement<WorkspaceCleanupReceipt>(
|
|
80
|
+
input.coordRoot,
|
|
81
|
+
input.runId,
|
|
82
|
+
"cleanup/receipt.json",
|
|
83
|
+
);
|
|
84
|
+
if (receipt) {
|
|
85
|
+
validateCleanupReceipt(receipt, binding, intent);
|
|
86
|
+
if (!isReleasedAttestation(current)) {
|
|
87
|
+
throw new Error("existing cleanup receipt no longer reattests exact resource absence");
|
|
88
|
+
}
|
|
89
|
+
return receipt;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
appendCleanupAttempt(input.coordRoot, input.runId, {
|
|
93
|
+
schema_version: 1,
|
|
94
|
+
operation_id: intent.operation_id,
|
|
95
|
+
binding_id: binding.binding_id,
|
|
96
|
+
binding_sha256: intent.binding_sha256,
|
|
97
|
+
mode,
|
|
98
|
+
status: "started",
|
|
99
|
+
recorded_at: new Date().toISOString(),
|
|
100
|
+
});
|
|
101
|
+
const result = await input.provider.cleanup(binding, intent);
|
|
102
|
+
validateCleanupResult(result, binding);
|
|
103
|
+
const attempt = appendCleanupAttempt(input.coordRoot, input.runId, {
|
|
104
|
+
schema_version: 1,
|
|
105
|
+
operation_id: intent.operation_id,
|
|
106
|
+
binding_id: binding.binding_id,
|
|
107
|
+
binding_sha256: intent.binding_sha256,
|
|
108
|
+
mode,
|
|
109
|
+
status: result.status,
|
|
110
|
+
branch_deleted: result.branch_deleted,
|
|
111
|
+
attestation: result.attestation,
|
|
112
|
+
reason: result.reason,
|
|
113
|
+
recorded_at: result.recorded_at,
|
|
114
|
+
});
|
|
115
|
+
appendWorkflowJournalEvent(input.coordRoot, input.runId, "workspace.cleanup", {
|
|
116
|
+
binding_id: binding.binding_id,
|
|
117
|
+
provider_id: binding.provider.id,
|
|
118
|
+
operation_id: intent.operation_id,
|
|
119
|
+
attempt: attempt.seq,
|
|
120
|
+
status: attempt.status,
|
|
121
|
+
});
|
|
122
|
+
if (result.status !== "released" && result.status !== "already_released") {
|
|
123
|
+
return attempt;
|
|
124
|
+
}
|
|
125
|
+
if (!result.branch_deleted || !isReleasedAttestation(result.attestation)) {
|
|
126
|
+
throw new Error("workspace cleanup cannot receipt an incomplete resource release");
|
|
127
|
+
}
|
|
128
|
+
const terminal: WorkspaceCleanupReceipt = {
|
|
129
|
+
schema_version: 1,
|
|
130
|
+
receipt_id: `cleanup-${stableDigest({
|
|
131
|
+
operation: intent.operation_id,
|
|
132
|
+
binding: intent.binding_sha256,
|
|
133
|
+
}).slice(0, 24)}`,
|
|
134
|
+
operation_id: intent.operation_id,
|
|
135
|
+
binding_id: binding.binding_id,
|
|
136
|
+
binding_sha256: intent.binding_sha256,
|
|
137
|
+
mode,
|
|
138
|
+
status: result.status,
|
|
139
|
+
branch_deleted: true,
|
|
140
|
+
attestation: result.attestation,
|
|
141
|
+
recorded_at: result.recorded_at,
|
|
142
|
+
};
|
|
143
|
+
writeWorkflowSupplement(input.coordRoot, input.runId, "cleanup/receipt.json", terminal);
|
|
144
|
+
return terminal;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertCleanupPathAuthority(
|
|
148
|
+
binding: WorkspaceBinding,
|
|
149
|
+
allowedPaths: readonly string[],
|
|
150
|
+
): void {
|
|
151
|
+
if (
|
|
152
|
+
allowedPaths.length > 0 &&
|
|
153
|
+
(!allowedPaths.some((root) => containsPath(root, binding.workspace_root)) ||
|
|
154
|
+
!allowedPaths.some((root) => containsPath(root, binding.active_root)) ||
|
|
155
|
+
(binding.repository !== undefined &&
|
|
156
|
+
(!allowedPaths.some((root) =>
|
|
157
|
+
containsPath(root, binding.repository!.source_root.realpath),
|
|
158
|
+
) ||
|
|
159
|
+
!allowedPaths.some((root) =>
|
|
160
|
+
containsPath(root, binding.repository!.common_dir.realpath),
|
|
161
|
+
))))
|
|
162
|
+
) {
|
|
163
|
+
throw new Error("workspace cleanup path is outside frozen policy path authority");
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
!containsPath(binding.writable_root.realpath, binding.workspace_root) ||
|
|
167
|
+
!containsPath(binding.writable_root.realpath, binding.active_root)
|
|
168
|
+
) {
|
|
169
|
+
throw new Error("workspace cleanup path is outside frozen writable-root authority");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function acquireCleanupLease(coordRoot: string, runId: string): () => void {
|
|
174
|
+
const authoritySha256 = stableDigest({ run_id: runId, operation: "cleanup" });
|
|
175
|
+
const lease = acquireNoClobberLease({
|
|
176
|
+
path: join(workflowRunDir(coordRoot, runId), "cleanup", "operation.lease"),
|
|
177
|
+
scope: "cleanup",
|
|
178
|
+
authoritySha256,
|
|
179
|
+
staleAfterMs: CLEANUP_LEASE_STALE_MS,
|
|
180
|
+
metadata: { run_id: runId },
|
|
181
|
+
validateStaleOwner: (owner) =>
|
|
182
|
+
owner.authority_sha256 === authoritySha256 && owner.metadata?.run_id === runId,
|
|
183
|
+
});
|
|
184
|
+
return () => lease.release();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveCleanupIntent(
|
|
188
|
+
input: CleanupWorkspaceInput,
|
|
189
|
+
binding: WorkspaceBinding,
|
|
190
|
+
attestation: WorkspaceCleanupResult["attestation"],
|
|
191
|
+
mode: WorkspaceCleanupMode,
|
|
192
|
+
): WorkspaceCleanupIntent {
|
|
193
|
+
if (!binding.repository) throw new Error("workspace cleanup requires repository identity");
|
|
194
|
+
const bindingSha256 = stableDigest(binding);
|
|
195
|
+
const operationId = `cleanup-${stableDigest({ binding: bindingSha256, mode }).slice(0, 24)}`;
|
|
196
|
+
const prior = readWorkflowSupplement<WorkspaceCleanupIntent>(
|
|
197
|
+
input.coordRoot,
|
|
198
|
+
input.runId,
|
|
199
|
+
"cleanup/intent.json",
|
|
200
|
+
);
|
|
201
|
+
if (prior) {
|
|
202
|
+
if (
|
|
203
|
+
prior.schema_version !== 1 ||
|
|
204
|
+
prior.run_id !== input.runId ||
|
|
205
|
+
prior.binding_id !== binding.binding_id ||
|
|
206
|
+
prior.binding_sha256 !== bindingSha256 ||
|
|
207
|
+
prior.mode !== mode ||
|
|
208
|
+
prior.operation_id !== operationId
|
|
209
|
+
) {
|
|
210
|
+
throw new Error("existing cleanup intent does not match current workspace authority");
|
|
211
|
+
}
|
|
212
|
+
validateCleanupIntentExpected(prior, binding);
|
|
213
|
+
return prior;
|
|
214
|
+
}
|
|
215
|
+
const workspaceRefOid = attestation.repository?.workspace_ref_oid;
|
|
216
|
+
const targetRefOid = attestation.repository?.target_ref_oid;
|
|
217
|
+
if (!workspaceRefOid || !targetRefOid) {
|
|
218
|
+
throw new Error("workspace cleanup cannot freeze exact source and target ref identities");
|
|
219
|
+
}
|
|
220
|
+
const proposed: WorkspaceCleanupIntent = {
|
|
221
|
+
schema_version: 1,
|
|
222
|
+
run_id: input.runId,
|
|
223
|
+
operation_id: operationId,
|
|
224
|
+
binding_id: binding.binding_id,
|
|
225
|
+
binding_sha256: bindingSha256,
|
|
226
|
+
mode,
|
|
227
|
+
expected: {
|
|
228
|
+
worktree_path: binding.workspace_root,
|
|
229
|
+
gitdir: binding.repository.gitdir,
|
|
230
|
+
workspace_ref: binding.repository.workspace_ref,
|
|
231
|
+
workspace_ref_oid: workspaceRefOid,
|
|
232
|
+
target_ref: binding.repository.target_ref,
|
|
233
|
+
target_ref_oid: targetRefOid,
|
|
234
|
+
},
|
|
235
|
+
created_at: new Date().toISOString(),
|
|
236
|
+
};
|
|
237
|
+
writeWorkflowSupplement(input.coordRoot, input.runId, "cleanup/intent.json", proposed);
|
|
238
|
+
return proposed;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function validateCleanupIntentExpected(
|
|
242
|
+
intent: WorkspaceCleanupIntent,
|
|
243
|
+
binding: WorkspaceBinding,
|
|
244
|
+
): void {
|
|
245
|
+
const repository = binding.repository;
|
|
246
|
+
if (
|
|
247
|
+
!repository ||
|
|
248
|
+
intent.expected.worktree_path !== binding.workspace_root ||
|
|
249
|
+
stableDigest(intent.expected.gitdir) !== stableDigest(repository.gitdir) ||
|
|
250
|
+
intent.expected.workspace_ref !== repository.workspace_ref ||
|
|
251
|
+
intent.expected.target_ref !== repository.target_ref ||
|
|
252
|
+
!/^[0-9a-f]{40,64}$/.test(intent.expected.workspace_ref_oid) ||
|
|
253
|
+
!/^[0-9a-f]{40,64}$/.test(intent.expected.target_ref_oid)
|
|
254
|
+
) {
|
|
255
|
+
throw new Error("cleanup intent expected identities do not match the workspace binding");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateCleanupResult(result: WorkspaceCleanupResult, binding: WorkspaceBinding): void {
|
|
260
|
+
if (
|
|
261
|
+
result.schema_version !== 1 ||
|
|
262
|
+
result.binding_id !== binding.binding_id ||
|
|
263
|
+
![
|
|
264
|
+
"released",
|
|
265
|
+
"already_released",
|
|
266
|
+
"preserved_dirty",
|
|
267
|
+
"blocked",
|
|
268
|
+
"unsupported",
|
|
269
|
+
"partial",
|
|
270
|
+
].includes(result.status) ||
|
|
271
|
+
!Number.isFinite(Date.parse(result.recorded_at)) ||
|
|
272
|
+
typeof result.branch_deleted !== "boolean" ||
|
|
273
|
+
!isWorkspaceAttestation(result.attestation, binding) ||
|
|
274
|
+
((result.status === "released" || result.status === "already_released") &&
|
|
275
|
+
!isReleasedAttestation(result.attestation))
|
|
276
|
+
) {
|
|
277
|
+
throw new Error("workspace cleanup provider returned an invalid result");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function validateCleanupReceipt(
|
|
282
|
+
receipt: WorkspaceCleanupReceipt,
|
|
283
|
+
binding: WorkspaceBinding,
|
|
284
|
+
intent: WorkspaceCleanupIntent,
|
|
285
|
+
): void {
|
|
286
|
+
if (
|
|
287
|
+
receipt.schema_version !== 1 ||
|
|
288
|
+
receipt.operation_id !== intent.operation_id ||
|
|
289
|
+
receipt.binding_id !== binding.binding_id ||
|
|
290
|
+
receipt.binding_sha256 !== intent.binding_sha256 ||
|
|
291
|
+
receipt.mode !== intent.mode ||
|
|
292
|
+
(receipt.status !== "released" && receipt.status !== "already_released") ||
|
|
293
|
+
receipt.branch_deleted !== true ||
|
|
294
|
+
!isWorkspaceAttestation(receipt.attestation, binding) ||
|
|
295
|
+
!isReleasedAttestation(receipt.attestation)
|
|
296
|
+
) {
|
|
297
|
+
throw new Error("workspace cleanup receipt is invalid or mismatched");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isReleasedAttestation(attestation: WorkspaceCleanupResult["attestation"]): boolean {
|
|
302
|
+
return (
|
|
303
|
+
attestation.status === "ok" &&
|
|
304
|
+
attestation.provider_drift.length === 0 &&
|
|
305
|
+
attestation.resource_state === "released" &&
|
|
306
|
+
!attestation.workspace_exists
|
|
307
|
+
);
|
|
308
|
+
}
|