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
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
import type { WorkEvent } from "../../work/state.ts";
|
|
2
|
+
import { stableDigest } from "../durable-record.ts";
|
|
3
|
+
import type { WorkflowRunManifest } from "../run-state.ts";
|
|
4
|
+
import type { WorkflowProof } from "../types.ts";
|
|
5
|
+
import type { WorkspaceProviderEvent } from "./state.ts";
|
|
6
|
+
import type {
|
|
7
|
+
IntegrationApplyAttempt,
|
|
8
|
+
IntegrationApplyReceipt,
|
|
9
|
+
IntegrationAuthorization,
|
|
10
|
+
IntegrationPlan,
|
|
11
|
+
WorkspaceBinding,
|
|
12
|
+
WorkspaceCancellationReceipt,
|
|
13
|
+
WorkspaceCancellationResult,
|
|
14
|
+
WorkspaceCleanupAttempt,
|
|
15
|
+
WorkspaceCleanupIntent,
|
|
16
|
+
WorkspaceCleanupReceipt,
|
|
17
|
+
WorkspaceIntegrationState,
|
|
18
|
+
WorkspaceLifecycleState,
|
|
19
|
+
WorkspaceProofOutcome,
|
|
20
|
+
WorkspaceResourceState,
|
|
21
|
+
} from "./types.ts";
|
|
22
|
+
import { isWorkspaceAttestation, isWorkspaceBoundExecutionEvidence } from "./validate.ts";
|
|
23
|
+
|
|
24
|
+
const LIFECYCLE_STATES = new Set<WorkspaceLifecycleState>([
|
|
25
|
+
"shared",
|
|
26
|
+
"unsupported",
|
|
27
|
+
"allocation_recorded",
|
|
28
|
+
"allocating",
|
|
29
|
+
"bound",
|
|
30
|
+
"running",
|
|
31
|
+
"parked",
|
|
32
|
+
"completed_unintegrated",
|
|
33
|
+
"failed_retained",
|
|
34
|
+
"integration_requested",
|
|
35
|
+
"integrating",
|
|
36
|
+
"integrated",
|
|
37
|
+
"cleanup_pending",
|
|
38
|
+
"released",
|
|
39
|
+
"blocked",
|
|
40
|
+
"lost",
|
|
41
|
+
"preserved_dirty",
|
|
42
|
+
"abandoned_dirty",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const LEGAL_TRANSITIONS: Record<WorkspaceLifecycleState, ReadonlySet<WorkspaceLifecycleState>> = {
|
|
46
|
+
shared: new Set(["shared", "completed_unintegrated", "failed_retained"]),
|
|
47
|
+
unsupported: new Set(["unsupported"]),
|
|
48
|
+
allocation_recorded: new Set(["allocation_recorded", "allocating", "blocked", "lost"]),
|
|
49
|
+
allocating: new Set(["allocating", "bound", "blocked", "lost"]),
|
|
50
|
+
bound: new Set([
|
|
51
|
+
"bound",
|
|
52
|
+
"running",
|
|
53
|
+
"parked",
|
|
54
|
+
"completed_unintegrated",
|
|
55
|
+
"failed_retained",
|
|
56
|
+
"blocked",
|
|
57
|
+
"lost",
|
|
58
|
+
]),
|
|
59
|
+
running: new Set([
|
|
60
|
+
"running",
|
|
61
|
+
"parked",
|
|
62
|
+
"completed_unintegrated",
|
|
63
|
+
"failed_retained",
|
|
64
|
+
"cleanup_pending",
|
|
65
|
+
"preserved_dirty",
|
|
66
|
+
"blocked",
|
|
67
|
+
"lost",
|
|
68
|
+
]),
|
|
69
|
+
parked: new Set([
|
|
70
|
+
"parked",
|
|
71
|
+
"running",
|
|
72
|
+
"completed_unintegrated",
|
|
73
|
+
"failed_retained",
|
|
74
|
+
"cleanup_pending",
|
|
75
|
+
"preserved_dirty",
|
|
76
|
+
"blocked",
|
|
77
|
+
"lost",
|
|
78
|
+
]),
|
|
79
|
+
completed_unintegrated: new Set([
|
|
80
|
+
"completed_unintegrated",
|
|
81
|
+
"integration_requested",
|
|
82
|
+
"cleanup_pending",
|
|
83
|
+
"preserved_dirty",
|
|
84
|
+
"blocked",
|
|
85
|
+
"lost",
|
|
86
|
+
]),
|
|
87
|
+
failed_retained: new Set([
|
|
88
|
+
"failed_retained",
|
|
89
|
+
"cleanup_pending",
|
|
90
|
+
"preserved_dirty",
|
|
91
|
+
"blocked",
|
|
92
|
+
"lost",
|
|
93
|
+
]),
|
|
94
|
+
integration_requested: new Set([
|
|
95
|
+
"integration_requested",
|
|
96
|
+
"integrating",
|
|
97
|
+
"cleanup_pending",
|
|
98
|
+
"blocked",
|
|
99
|
+
"lost",
|
|
100
|
+
]),
|
|
101
|
+
integrating: new Set(["integrating", "integrated", "blocked", "lost"]),
|
|
102
|
+
integrated: new Set(["integrated", "cleanup_pending", "blocked", "lost"]),
|
|
103
|
+
cleanup_pending: new Set([
|
|
104
|
+
"cleanup_pending",
|
|
105
|
+
"released",
|
|
106
|
+
"preserved_dirty",
|
|
107
|
+
"abandoned_dirty",
|
|
108
|
+
"blocked",
|
|
109
|
+
"lost",
|
|
110
|
+
]),
|
|
111
|
+
released: new Set(["released"]),
|
|
112
|
+
blocked: new Set([
|
|
113
|
+
"blocked",
|
|
114
|
+
"allocating",
|
|
115
|
+
"bound",
|
|
116
|
+
"running",
|
|
117
|
+
"parked",
|
|
118
|
+
"completed_unintegrated",
|
|
119
|
+
"failed_retained",
|
|
120
|
+
"integration_requested",
|
|
121
|
+
"integrating",
|
|
122
|
+
"cleanup_pending",
|
|
123
|
+
"released",
|
|
124
|
+
"preserved_dirty",
|
|
125
|
+
"abandoned_dirty",
|
|
126
|
+
"lost",
|
|
127
|
+
]),
|
|
128
|
+
lost: new Set(["lost", "blocked"]),
|
|
129
|
+
preserved_dirty: new Set(["preserved_dirty", "cleanup_pending", "released", "blocked"]),
|
|
130
|
+
abandoned_dirty: new Set(["abandoned_dirty"]),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export interface WorkspaceLifecycleProjectionInput {
|
|
134
|
+
allocation_unsupported?: boolean;
|
|
135
|
+
binding?: WorkspaceBinding;
|
|
136
|
+
provider_events?: readonly WorkspaceProviderEvent[];
|
|
137
|
+
manifest?: WorkflowRunManifest;
|
|
138
|
+
workflow_journal?: readonly {
|
|
139
|
+
event: string;
|
|
140
|
+
ok?: boolean;
|
|
141
|
+
status?: "blocked" | "lost" | WorkspaceCancellationResult["status"];
|
|
142
|
+
work_event_seq?: number;
|
|
143
|
+
work_event_sha256?: string;
|
|
144
|
+
}[];
|
|
145
|
+
proof?: WorkflowProof;
|
|
146
|
+
proof_sha256?: string;
|
|
147
|
+
integration_plan?: IntegrationPlan;
|
|
148
|
+
integration_authorization?: IntegrationAuthorization;
|
|
149
|
+
integration_attempts?: readonly IntegrationApplyAttempt[];
|
|
150
|
+
integration_receipt?: IntegrationApplyReceipt;
|
|
151
|
+
cleanup_intent?: WorkspaceCleanupIntent;
|
|
152
|
+
cleanup_attempts?: readonly WorkspaceCleanupAttempt[];
|
|
153
|
+
cleanup_receipt?: WorkspaceCleanupReceipt;
|
|
154
|
+
work_events?: readonly WorkEvent[];
|
|
155
|
+
cancellation_outcome?: WorkspaceCancellationResult;
|
|
156
|
+
cancellation_receipt?: WorkspaceCancellationReceipt;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface WorkspaceLifecycleProjection {
|
|
160
|
+
state: WorkspaceLifecycleState;
|
|
161
|
+
workflow_outcome: WorkspaceProofOutcome | null;
|
|
162
|
+
resource_state: WorkspaceResourceState | null;
|
|
163
|
+
integration_state: WorkspaceIntegrationState;
|
|
164
|
+
provider_event_chain_sha256: string | null;
|
|
165
|
+
cancellation: "none" | "host_requested" | "confirmed";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function isWorkspaceLifecycleState(value: unknown): value is WorkspaceLifecycleState {
|
|
169
|
+
return typeof value === "string" && LIFECYCLE_STATES.has(value as WorkspaceLifecycleState);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function assertWorkspaceLifecycleTransition(
|
|
173
|
+
from: WorkspaceLifecycleState,
|
|
174
|
+
to: WorkspaceLifecycleState,
|
|
175
|
+
): void {
|
|
176
|
+
if (!isWorkspaceLifecycleState(from) || !isWorkspaceLifecycleState(to)) {
|
|
177
|
+
throw new Error("workspace lifecycle state is unsupported");
|
|
178
|
+
}
|
|
179
|
+
if (!LEGAL_TRANSITIONS[from].has(to)) {
|
|
180
|
+
throw new Error(`illegal workspace lifecycle transition: ${from} -> ${to}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function deriveWorkspaceLifecycle(
|
|
185
|
+
input: WorkspaceLifecycleProjectionInput,
|
|
186
|
+
): WorkspaceLifecycleProjection {
|
|
187
|
+
const events = [...(input.provider_events ?? [])];
|
|
188
|
+
validateProviderEventChain(events, input.binding);
|
|
189
|
+
const projectedResourceState = resourceState(input);
|
|
190
|
+
const projectedIntegrationState = integrationState(input);
|
|
191
|
+
const sharedCompatibility =
|
|
192
|
+
input.manifest?.execution.isolation === "shared" ||
|
|
193
|
+
input.manifest?.execution.workspace_fallback !== undefined;
|
|
194
|
+
let state: WorkspaceLifecycleState = sharedCompatibility
|
|
195
|
+
? "shared"
|
|
196
|
+
: input.allocation_unsupported || (!input.binding && events.length === 0)
|
|
197
|
+
? "unsupported"
|
|
198
|
+
: "allocation_recorded";
|
|
199
|
+
|
|
200
|
+
const firstBound = events.findIndex(
|
|
201
|
+
(event) => event.event === "bound" || event.event === "reattached",
|
|
202
|
+
);
|
|
203
|
+
const allocationEvents = firstBound < 0 ? events : events.slice(0, firstBound + 1);
|
|
204
|
+
for (const event of allocationEvents) {
|
|
205
|
+
const next = providerState(event.event);
|
|
206
|
+
if (!next) continue;
|
|
207
|
+
transition(next);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const journal = input.workflow_journal ?? [];
|
|
211
|
+
for (const event of journal) {
|
|
212
|
+
if (input.binding && (event.event === "run.start" || event.event === "run.resume")) {
|
|
213
|
+
transition("running");
|
|
214
|
+
}
|
|
215
|
+
if (event.event === "run.parked") transition("parked");
|
|
216
|
+
}
|
|
217
|
+
const failedReattachment = [...journal]
|
|
218
|
+
.reverse()
|
|
219
|
+
.find((event) => event.event === "workspace.reattach.failed");
|
|
220
|
+
if (failedReattachment) transition(failedReattachment.status === "blocked" ? "blocked" : "lost");
|
|
221
|
+
let workflowOutcome = proofOutcome(input.proof, input.binding);
|
|
222
|
+
const terminalJournal = [...journal].reverse().find((event) => event.event === "run.end");
|
|
223
|
+
if (input.proof && !failedReattachment) transition(workflowOutcome!);
|
|
224
|
+
if (terminalJournal && !input.proof && !failedReattachment) {
|
|
225
|
+
workflowOutcome = terminalJournal.ok === false ? "failed_retained" : null;
|
|
226
|
+
transition("blocked");
|
|
227
|
+
}
|
|
228
|
+
if (projectedIntegrationState !== "none") transition("integration_requested");
|
|
229
|
+
if (projectedIntegrationState === "applying" || projectedIntegrationState === "applied") {
|
|
230
|
+
transition("integrating");
|
|
231
|
+
}
|
|
232
|
+
if (projectedIntegrationState === "applied") transition("integrated");
|
|
233
|
+
const postBindingEvents = firstBound < 0 ? [] : events.slice(firstBound + 1);
|
|
234
|
+
if (
|
|
235
|
+
input.cleanup_intent ||
|
|
236
|
+
(input.cleanup_attempts?.length ?? 0) > 0 ||
|
|
237
|
+
postBindingEvents.some((event) => event.event === "cleanup_pending")
|
|
238
|
+
) {
|
|
239
|
+
transition("cleanup_pending");
|
|
240
|
+
}
|
|
241
|
+
const cleanupResult = input.cleanup_attempts?.at(-1);
|
|
242
|
+
if (cleanupResult?.status === "preserved_dirty") transition("preserved_dirty");
|
|
243
|
+
if (
|
|
244
|
+
cleanupResult?.status === "blocked" ||
|
|
245
|
+
cleanupResult?.status === "partial" ||
|
|
246
|
+
cleanupResult?.status === "unsupported"
|
|
247
|
+
) {
|
|
248
|
+
transition("blocked");
|
|
249
|
+
}
|
|
250
|
+
for (const event of postBindingEvents) {
|
|
251
|
+
if (
|
|
252
|
+
event.event === "blocked" ||
|
|
253
|
+
event.event === "lost" ||
|
|
254
|
+
event.event === "preserved_dirty" ||
|
|
255
|
+
event.event === "abandoned_dirty"
|
|
256
|
+
) {
|
|
257
|
+
transition(providerState(event.event)!);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (input.cleanup_receipt) transition("released");
|
|
261
|
+
|
|
262
|
+
const latestGovernance = [...(input.work_events ?? [])]
|
|
263
|
+
.reverse()
|
|
264
|
+
.find((event) => ["work.accepted", "work.cancelled", "work.reopened"].includes(event.event));
|
|
265
|
+
const hostCancelled = latestGovernance?.event === "work.cancelled";
|
|
266
|
+
const workEventSha256 = latestGovernance ? stableDigest(latestGovernance) : undefined;
|
|
267
|
+
const providerCancelled =
|
|
268
|
+
hostCancelled &&
|
|
269
|
+
((input.cancellation_receipt?.status === "cancelled" &&
|
|
270
|
+
input.cancellation_receipt.work_event_seq === latestGovernance.seq &&
|
|
271
|
+
input.cancellation_receipt.work_event_sha256 === workEventSha256) ||
|
|
272
|
+
journal.some(
|
|
273
|
+
(event) =>
|
|
274
|
+
event.event === "workspace.cancel" &&
|
|
275
|
+
event.status === "cancelled" &&
|
|
276
|
+
event.work_event_seq === latestGovernance.seq &&
|
|
277
|
+
event.work_event_sha256 === workEventSha256,
|
|
278
|
+
));
|
|
279
|
+
return {
|
|
280
|
+
state,
|
|
281
|
+
workflow_outcome: workflowOutcome,
|
|
282
|
+
resource_state: projectedResourceState,
|
|
283
|
+
integration_state: projectedIntegrationState,
|
|
284
|
+
provider_event_chain_sha256: events.at(-1)?.record_sha256 ?? null,
|
|
285
|
+
cancellation: hostCancelled ? (providerCancelled ? "confirmed" : "host_requested") : "none",
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
function transition(next: WorkspaceLifecycleState): void {
|
|
289
|
+
if (state === "unsupported" && input.binding) state = "allocation_recorded";
|
|
290
|
+
assertWorkspaceLifecycleTransition(state, next);
|
|
291
|
+
state = next;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function proofOutcome(
|
|
296
|
+
proof: WorkflowProof | undefined,
|
|
297
|
+
binding: WorkspaceBinding | undefined,
|
|
298
|
+
): WorkspaceProofOutcome | null {
|
|
299
|
+
if (!proof) return null;
|
|
300
|
+
if (!["succeeded", "failed"].includes(proof.run?.status)) {
|
|
301
|
+
throw new Error("workspace proof has an unsupported workflow outcome");
|
|
302
|
+
}
|
|
303
|
+
const execution = proof?.execution;
|
|
304
|
+
if (binding) {
|
|
305
|
+
if (
|
|
306
|
+
!isWorkspaceBoundExecutionEvidence(execution, binding.run_id, proof.run.status) ||
|
|
307
|
+
stableDigest(execution.binding) !== stableDigest(binding)
|
|
308
|
+
) {
|
|
309
|
+
throw new Error("workspace proof does not match the immutable binding");
|
|
310
|
+
}
|
|
311
|
+
return execution.terminal_lifecycle_state;
|
|
312
|
+
}
|
|
313
|
+
if (execution && "binding" in execution) {
|
|
314
|
+
throw new Error("workspace proof has bound execution evidence without a binding");
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
execution &&
|
|
318
|
+
"binding" in execution &&
|
|
319
|
+
["completed_unintegrated", "failed_retained", "blocked", "lost"].includes(
|
|
320
|
+
execution.terminal_lifecycle_state,
|
|
321
|
+
)
|
|
322
|
+
) {
|
|
323
|
+
return execution.terminal_lifecycle_state as WorkspaceProofOutcome;
|
|
324
|
+
}
|
|
325
|
+
return proof
|
|
326
|
+
? proof.run.status === "succeeded"
|
|
327
|
+
? "completed_unintegrated"
|
|
328
|
+
: "failed_retained"
|
|
329
|
+
: null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function integrationState(input: WorkspaceLifecycleProjectionInput): WorkspaceIntegrationState {
|
|
333
|
+
const plan = input.integration_plan;
|
|
334
|
+
const authorization = input.integration_authorization;
|
|
335
|
+
const attempts = [...(input.integration_attempts ?? [])];
|
|
336
|
+
const receipt = input.integration_receipt;
|
|
337
|
+
if (!plan && !authorization && attempts.length === 0 && !receipt) return "none";
|
|
338
|
+
|
|
339
|
+
const binding = input.binding;
|
|
340
|
+
const proof = input.proof;
|
|
341
|
+
const proofSha256 = input.proof_sha256;
|
|
342
|
+
if (
|
|
343
|
+
!binding ||
|
|
344
|
+
!proof ||
|
|
345
|
+
!isWorkspaceBoundExecutionEvidence(proof.execution, binding.run_id, proof.run.status) ||
|
|
346
|
+
stableDigest(proof.execution.binding) !== stableDigest(binding) ||
|
|
347
|
+
!digest(proofSha256)
|
|
348
|
+
) {
|
|
349
|
+
throw new Error("integration evidence requires the exact bound proof authority");
|
|
350
|
+
}
|
|
351
|
+
if (!plan) throw new Error("integration evidence is missing its durable plan");
|
|
352
|
+
validateIntegrationPlan(plan, binding, proof, proofSha256);
|
|
353
|
+
const planSha256 = stableDigest(plan);
|
|
354
|
+
|
|
355
|
+
if (!authorization && (attempts.length > 0 || receipt)) {
|
|
356
|
+
throw new Error("integration apply evidence is missing authorization");
|
|
357
|
+
}
|
|
358
|
+
const authorizationSha256 = authorization
|
|
359
|
+
? validateIntegrationAuthorization(authorization, plan, planSha256)
|
|
360
|
+
: undefined;
|
|
361
|
+
validateIntegrationAttempts(
|
|
362
|
+
attempts,
|
|
363
|
+
binding,
|
|
364
|
+
plan,
|
|
365
|
+
planSha256,
|
|
366
|
+
authorizationSha256,
|
|
367
|
+
proofSha256,
|
|
368
|
+
);
|
|
369
|
+
if (receipt) {
|
|
370
|
+
if (!authorizationSha256) {
|
|
371
|
+
throw new Error("integration receipt is missing authorization authority");
|
|
372
|
+
}
|
|
373
|
+
validateIntegrationReceipt(
|
|
374
|
+
receipt,
|
|
375
|
+
attempts,
|
|
376
|
+
binding,
|
|
377
|
+
plan,
|
|
378
|
+
planSha256,
|
|
379
|
+
authorizationSha256,
|
|
380
|
+
proofSha256,
|
|
381
|
+
);
|
|
382
|
+
return "applied";
|
|
383
|
+
}
|
|
384
|
+
return attempts.some((attempt) => attempt.status === "started") ? "applying" : "planned";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function validateIntegrationPlan(
|
|
388
|
+
plan: IntegrationPlan,
|
|
389
|
+
binding: WorkspaceBinding,
|
|
390
|
+
proof: WorkflowProof,
|
|
391
|
+
proofSha256: string,
|
|
392
|
+
): void {
|
|
393
|
+
const execution = proof.execution!;
|
|
394
|
+
if (!("binding" in execution)) throw new Error("integration plan requires bound proof evidence");
|
|
395
|
+
const bindingSha256 = stableDigest(binding);
|
|
396
|
+
const terminalAttestationSha256 = stableDigest(execution.terminal_attestation);
|
|
397
|
+
const preview = plan.provider_preview;
|
|
398
|
+
const authority = {
|
|
399
|
+
run: binding.run_id,
|
|
400
|
+
binding: bindingSha256,
|
|
401
|
+
proof: proofSha256,
|
|
402
|
+
terminalAttestation: terminalAttestationSha256,
|
|
403
|
+
review: plan.review_sha256,
|
|
404
|
+
target: preview?.target_root,
|
|
405
|
+
targetRef: preview?.target_ref,
|
|
406
|
+
targetCommit: preview?.target_commit,
|
|
407
|
+
sourceCommit: preview?.source_commit,
|
|
408
|
+
operation: preview?.operation,
|
|
409
|
+
};
|
|
410
|
+
if (
|
|
411
|
+
plan.schema_version !== 1 ||
|
|
412
|
+
plan.run_id !== binding.run_id ||
|
|
413
|
+
plan.operation !== "fast_forward" ||
|
|
414
|
+
plan.binding_sha256 !== bindingSha256 ||
|
|
415
|
+
stableDigest(plan.binding) !== bindingSha256 ||
|
|
416
|
+
plan.proof_sha256 !== proofSha256 ||
|
|
417
|
+
plan.terminal_attestation_sha256 !== terminalAttestationSha256 ||
|
|
418
|
+
!digest(plan.review_sha256) ||
|
|
419
|
+
!Array.isArray(plan.accepted_unknowns) ||
|
|
420
|
+
preview?.schema_version !== 1 ||
|
|
421
|
+
preview.provider_id !== binding.provider.id ||
|
|
422
|
+
preview.binding_id !== binding.binding_id ||
|
|
423
|
+
preview.operation !== plan.operation ||
|
|
424
|
+
typeof preview.target_root !== "string" ||
|
|
425
|
+
typeof preview.target_ref !== "string" ||
|
|
426
|
+
typeof preview.target_commit !== "string" ||
|
|
427
|
+
typeof preview.target_tree !== "string" ||
|
|
428
|
+
typeof preview.source_commit !== "string" ||
|
|
429
|
+
typeof preview.source_tree !== "string" ||
|
|
430
|
+
!Array.isArray(preview.changed_paths) ||
|
|
431
|
+
!Array.isArray(preview.blocked) ||
|
|
432
|
+
preview.blocked.length > 0 ||
|
|
433
|
+
!Array.isArray(preview.unknowns) ||
|
|
434
|
+
!validTimestamp(preview.prepared_at) ||
|
|
435
|
+
plan.target_identity_sha256 !==
|
|
436
|
+
stableDigest({
|
|
437
|
+
root: preview.target_root,
|
|
438
|
+
ref: preview.target_ref,
|
|
439
|
+
commit: preview.target_commit,
|
|
440
|
+
tree: preview.target_tree,
|
|
441
|
+
}) ||
|
|
442
|
+
plan.idempotency_sha256 !== stableDigest(authority) ||
|
|
443
|
+
plan.plan_id !== `integration-plan-${stableDigest(authority).slice(0, 24)}` ||
|
|
444
|
+
!validTimestamp(plan.prepared_at)
|
|
445
|
+
) {
|
|
446
|
+
throw new Error("integration plan is corrupt or foreign");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function validateIntegrationAuthorization(
|
|
451
|
+
authorization: IntegrationAuthorization,
|
|
452
|
+
plan: IntegrationPlan,
|
|
453
|
+
planSha256: string,
|
|
454
|
+
): string {
|
|
455
|
+
if (
|
|
456
|
+
authorization.schema_version !== 1 ||
|
|
457
|
+
authorization.run_id !== plan.run_id ||
|
|
458
|
+
authorization.plan_sha256 !== planSha256 ||
|
|
459
|
+
!digest(authorization.policy_sha256) ||
|
|
460
|
+
authorization.decision?.verdict !== "allow" ||
|
|
461
|
+
authorization.decision_sha256 !== stableDigest(authorization.decision) ||
|
|
462
|
+
authorization.journal_anchor?.event !== "integration.plan" ||
|
|
463
|
+
authorization.journal_anchor.plan_sha256 !== planSha256 ||
|
|
464
|
+
!validTimestamp(authorization.authorized_at) ||
|
|
465
|
+
(authorization.approval_id !== undefined &&
|
|
466
|
+
(typeof authorization.approval_actor !== "string" || !digest(authorization.approval_sha256)))
|
|
467
|
+
) {
|
|
468
|
+
throw new Error("integration authorization is corrupt or foreign");
|
|
469
|
+
}
|
|
470
|
+
return stableDigest(authorization);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function validateIntegrationAttempts(
|
|
474
|
+
attempts: readonly IntegrationApplyAttempt[],
|
|
475
|
+
binding: WorkspaceBinding,
|
|
476
|
+
plan: IntegrationPlan,
|
|
477
|
+
planSha256: string,
|
|
478
|
+
authorizationSha256: string | undefined,
|
|
479
|
+
proofSha256: string,
|
|
480
|
+
): void {
|
|
481
|
+
if (attempts.length > 0 && !authorizationSha256) {
|
|
482
|
+
throw new Error("integration attempts are missing authorization authority");
|
|
483
|
+
}
|
|
484
|
+
let prior: IntegrationApplyAttempt | undefined;
|
|
485
|
+
for (const [index, attempt] of attempts.entries()) {
|
|
486
|
+
const { record_sha256: recordSha256, ...basis } = attempt;
|
|
487
|
+
const terminal = attempt.status === "applied" || attempt.status === "already_applied";
|
|
488
|
+
if (
|
|
489
|
+
attempt.schema_version !== 1 ||
|
|
490
|
+
attempt.seq !== index + 1 ||
|
|
491
|
+
attempt.previous_sha256 !== (prior?.record_sha256 ?? null) ||
|
|
492
|
+
recordSha256 !== stableDigest(basis) ||
|
|
493
|
+
attempt.run_id !== binding.run_id ||
|
|
494
|
+
attempt.plan_sha256 !== planSha256 ||
|
|
495
|
+
attempt.authorization_sha256 !== authorizationSha256 ||
|
|
496
|
+
attempt.proof_sha256 !== proofSha256 ||
|
|
497
|
+
attempt.binding_sha256 !== stableDigest(binding) ||
|
|
498
|
+
!["started", "applied", "already_applied"].includes(attempt.status) ||
|
|
499
|
+
(terminal &&
|
|
500
|
+
(attempt.target_commit !== plan.provider_preview.source_commit ||
|
|
501
|
+
attempt.target_tree !== plan.provider_preview.source_tree)) ||
|
|
502
|
+
(!terminal && (attempt.target_commit !== undefined || attempt.target_tree !== undefined)) ||
|
|
503
|
+
!validTimestamp(attempt.recorded_at)
|
|
504
|
+
) {
|
|
505
|
+
throw new Error(`integration attempt ${index + 1} is corrupt or foreign`);
|
|
506
|
+
}
|
|
507
|
+
prior = attempt;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function validateIntegrationReceipt(
|
|
512
|
+
receipt: IntegrationApplyReceipt,
|
|
513
|
+
attempts: readonly IntegrationApplyAttempt[],
|
|
514
|
+
binding: WorkspaceBinding,
|
|
515
|
+
plan: IntegrationPlan,
|
|
516
|
+
planSha256: string,
|
|
517
|
+
authorizationSha256: string,
|
|
518
|
+
proofSha256: string,
|
|
519
|
+
): void {
|
|
520
|
+
const terminal = attempts.at(-1);
|
|
521
|
+
const expectedReceiptId = `integration-apply-${stableDigest({
|
|
522
|
+
plan: planSha256,
|
|
523
|
+
authorization: authorizationSha256,
|
|
524
|
+
target: receipt.target_commit,
|
|
525
|
+
}).slice(0, 24)}`;
|
|
526
|
+
if (
|
|
527
|
+
!terminal ||
|
|
528
|
+
(terminal.status !== "applied" && terminal.status !== "already_applied") ||
|
|
529
|
+
receipt.schema_version !== 1 ||
|
|
530
|
+
receipt.receipt_id !== expectedReceiptId ||
|
|
531
|
+
receipt.run_id !== binding.run_id ||
|
|
532
|
+
receipt.plan_id !== plan.plan_id ||
|
|
533
|
+
receipt.plan_sha256 !== planSha256 ||
|
|
534
|
+
receipt.authorization_sha256 !== authorizationSha256 ||
|
|
535
|
+
receipt.proof_sha256 !== proofSha256 ||
|
|
536
|
+
receipt.binding_sha256 !== stableDigest(binding) ||
|
|
537
|
+
receipt.binding_id !== binding.binding_id ||
|
|
538
|
+
receipt.provider_id !== binding.provider.id ||
|
|
539
|
+
receipt.status !== terminal.status ||
|
|
540
|
+
receipt.target_root !== plan.provider_preview.target_root ||
|
|
541
|
+
receipt.target_ref !== plan.provider_preview.target_ref ||
|
|
542
|
+
receipt.source_commit !== plan.provider_preview.source_commit ||
|
|
543
|
+
receipt.target_commit !== plan.provider_preview.source_commit ||
|
|
544
|
+
receipt.target_tree !== plan.provider_preview.source_tree ||
|
|
545
|
+
receipt.target_commit !== terminal.target_commit ||
|
|
546
|
+
receipt.target_tree !== terminal.target_tree ||
|
|
547
|
+
!validTimestamp(receipt.applied_at)
|
|
548
|
+
) {
|
|
549
|
+
throw new Error("integration receipt is corrupt or foreign");
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function digest(value: unknown): value is string {
|
|
554
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function validTimestamp(value: unknown): value is string {
|
|
558
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function resourceState(input: WorkspaceLifecycleProjectionInput): WorkspaceResourceState | null {
|
|
562
|
+
const binding = input.binding;
|
|
563
|
+
const receiptState = validateCleanupReceiptEvidence(
|
|
564
|
+
input.cleanup_receipt,
|
|
565
|
+
binding,
|
|
566
|
+
input.cleanup_intent,
|
|
567
|
+
);
|
|
568
|
+
const cleanupState = validateCleanupAttemptEvidence(input.cleanup_attempts ?? [], binding);
|
|
569
|
+
const provider = latestPostBindingTerminalEvent(input.provider_events ?? []);
|
|
570
|
+
const providerState = providerResourceState(provider);
|
|
571
|
+
const proofState = terminalProofResourceState(input.proof, binding);
|
|
572
|
+
|
|
573
|
+
if (receiptState) {
|
|
574
|
+
if (cleanupState && cleanupState !== "released") {
|
|
575
|
+
throw new Error("workspace cleanup receipt contradicts the latest cleanup attempt");
|
|
576
|
+
}
|
|
577
|
+
if (providerState && providerState !== "released") {
|
|
578
|
+
throw new Error("workspace cleanup receipt contradicts the terminal provider event");
|
|
579
|
+
}
|
|
580
|
+
const receiptAttestation = input.cleanup_receipt!.attestation;
|
|
581
|
+
const providerAttestationSha256 = provider?.data.attestation_sha256;
|
|
582
|
+
if (
|
|
583
|
+
providerAttestationSha256 !== undefined &&
|
|
584
|
+
providerAttestationSha256 !== stableDigest(receiptAttestation)
|
|
585
|
+
) {
|
|
586
|
+
throw new Error("workspace cleanup receipt contradicts provider attestation authority");
|
|
587
|
+
}
|
|
588
|
+
return receiptState;
|
|
589
|
+
}
|
|
590
|
+
if (cleanupState) {
|
|
591
|
+
if (providerState === "released" && cleanupState !== "released") {
|
|
592
|
+
throw new Error("latest cleanup attempt regresses a released provider resource");
|
|
593
|
+
}
|
|
594
|
+
if (cleanupState === "released" && providerState && providerState !== "released") {
|
|
595
|
+
throw new Error("released cleanup attempt contradicts the terminal provider event");
|
|
596
|
+
}
|
|
597
|
+
return cleanupState;
|
|
598
|
+
}
|
|
599
|
+
if (providerState) return providerState;
|
|
600
|
+
if (proofState) return proofState;
|
|
601
|
+
return binding ? "active" : null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function validateCleanupReceiptEvidence(
|
|
605
|
+
receipt: WorkspaceCleanupReceipt | undefined,
|
|
606
|
+
binding: WorkspaceBinding | undefined,
|
|
607
|
+
intent: WorkspaceCleanupIntent | undefined,
|
|
608
|
+
): "released" | null {
|
|
609
|
+
if (!receipt) return null;
|
|
610
|
+
if (!binding) throw new Error("workspace cleanup receipt has no binding authority");
|
|
611
|
+
const bindingSha256 = stableDigest(binding);
|
|
612
|
+
const operationId = `cleanup-${stableDigest({
|
|
613
|
+
binding: bindingSha256,
|
|
614
|
+
mode: receipt.mode,
|
|
615
|
+
}).slice(0, 24)}`;
|
|
616
|
+
if (
|
|
617
|
+
receipt.schema_version !== 1 ||
|
|
618
|
+
receipt.operation_id !== operationId ||
|
|
619
|
+
receipt.binding_id !== binding.binding_id ||
|
|
620
|
+
receipt.binding_sha256 !== bindingSha256 ||
|
|
621
|
+
receipt.mode !== "normal" ||
|
|
622
|
+
(receipt.status !== "released" && receipt.status !== "already_released") ||
|
|
623
|
+
receipt.branch_deleted !== true ||
|
|
624
|
+
!Number.isFinite(Date.parse(receipt.recorded_at)) ||
|
|
625
|
+
!isWorkspaceAttestation(receipt.attestation, binding) ||
|
|
626
|
+
receipt.attestation.status !== "ok" ||
|
|
627
|
+
receipt.attestation.resource_state !== "released" ||
|
|
628
|
+
receipt.attestation.workspace_exists ||
|
|
629
|
+
(intent !== undefined &&
|
|
630
|
+
(intent.operation_id !== receipt.operation_id ||
|
|
631
|
+
intent.binding_id !== receipt.binding_id ||
|
|
632
|
+
intent.binding_sha256 !== receipt.binding_sha256 ||
|
|
633
|
+
intent.mode !== receipt.mode))
|
|
634
|
+
) {
|
|
635
|
+
throw new Error("workspace cleanup receipt is invalid or mismatched");
|
|
636
|
+
}
|
|
637
|
+
return "released";
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function validateCleanupAttemptEvidence(
|
|
641
|
+
attempts: readonly WorkspaceCleanupAttempt[],
|
|
642
|
+
binding: WorkspaceBinding | undefined,
|
|
643
|
+
): WorkspaceResourceState | null {
|
|
644
|
+
if (attempts.length === 0) return null;
|
|
645
|
+
if (!binding) throw new Error("workspace cleanup attempts have no binding authority");
|
|
646
|
+
const bindingSha256 = stableDigest(binding);
|
|
647
|
+
const operationId = `cleanup-${stableDigest({ binding: bindingSha256, mode: "normal" }).slice(
|
|
648
|
+
0,
|
|
649
|
+
24,
|
|
650
|
+
)}`;
|
|
651
|
+
for (const [index, attempt] of attempts.entries()) {
|
|
652
|
+
const { record_sha256, ...basis } = attempt;
|
|
653
|
+
const terminal = attempt.status !== "started";
|
|
654
|
+
if (
|
|
655
|
+
attempt.schema_version !== 1 ||
|
|
656
|
+
attempt.seq !== index + 1 ||
|
|
657
|
+
attempt.previous_sha256 !== (attempts[index - 1]?.record_sha256 ?? null) ||
|
|
658
|
+
record_sha256 !== stableDigest(basis) ||
|
|
659
|
+
attempt.operation_id !== operationId ||
|
|
660
|
+
attempt.binding_id !== binding.binding_id ||
|
|
661
|
+
attempt.binding_sha256 !== bindingSha256 ||
|
|
662
|
+
attempt.mode !== "normal" ||
|
|
663
|
+
!Number.isFinite(Date.parse(attempt.recorded_at)) ||
|
|
664
|
+
![
|
|
665
|
+
"started",
|
|
666
|
+
"released",
|
|
667
|
+
"already_released",
|
|
668
|
+
"preserved_dirty",
|
|
669
|
+
"blocked",
|
|
670
|
+
"unsupported",
|
|
671
|
+
"partial",
|
|
672
|
+
].includes(attempt.status) ||
|
|
673
|
+
(terminal &&
|
|
674
|
+
(typeof attempt.branch_deleted !== "boolean" ||
|
|
675
|
+
!isWorkspaceAttestation(attempt.attestation, binding))) ||
|
|
676
|
+
(!terminal && (attempt.branch_deleted !== undefined || attempt.attestation !== undefined))
|
|
677
|
+
) {
|
|
678
|
+
throw new Error(`workspace cleanup attempt ${index + 1} is corrupt or mismatched`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const latest = attempts.at(-1)!;
|
|
682
|
+
if (latest.status === "started") return null;
|
|
683
|
+
if (latest.status === "released" || latest.status === "already_released") {
|
|
684
|
+
if (
|
|
685
|
+
latest.branch_deleted !== true ||
|
|
686
|
+
latest.attestation?.status !== "ok" ||
|
|
687
|
+
latest.attestation.resource_state !== "released" ||
|
|
688
|
+
latest.attestation.workspace_exists
|
|
689
|
+
) {
|
|
690
|
+
throw new Error("released cleanup attempt lacks exact resource-absence evidence");
|
|
691
|
+
}
|
|
692
|
+
return "released";
|
|
693
|
+
}
|
|
694
|
+
if (latest.status === "preserved_dirty") {
|
|
695
|
+
if (latest.attestation?.resource_state !== "preserved_dirty") {
|
|
696
|
+
throw new Error("dirty cleanup attempt lacks preserved resource evidence");
|
|
697
|
+
}
|
|
698
|
+
return "preserved_dirty";
|
|
699
|
+
}
|
|
700
|
+
return "blocked";
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function latestPostBindingTerminalEvent(
|
|
704
|
+
events: readonly WorkspaceProviderEvent[],
|
|
705
|
+
): WorkspaceProviderEvent | undefined {
|
|
706
|
+
const firstBound = events.findIndex(
|
|
707
|
+
(event) => event.event === "bound" || event.event === "reattached",
|
|
708
|
+
);
|
|
709
|
+
if (firstBound < 0) return undefined;
|
|
710
|
+
return events
|
|
711
|
+
.slice(firstBound + 1)
|
|
712
|
+
.reverse()
|
|
713
|
+
.find((event) => ["released", "preserved_dirty", "blocked", "lost"].includes(event.event));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function providerResourceState(
|
|
717
|
+
event: WorkspaceProviderEvent | undefined,
|
|
718
|
+
): WorkspaceResourceState | null {
|
|
719
|
+
if (!event) return null;
|
|
720
|
+
if (event.event === "released") return "released";
|
|
721
|
+
if (event.event === "preserved_dirty") return "preserved_dirty";
|
|
722
|
+
if (event.event === "blocked") return "blocked";
|
|
723
|
+
if (event.event === "lost") return "lost";
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function terminalProofResourceState(
|
|
728
|
+
proof: WorkflowProof | undefined,
|
|
729
|
+
binding: WorkspaceBinding | undefined,
|
|
730
|
+
): WorkspaceResourceState | null {
|
|
731
|
+
if (!proof?.execution || !("binding" in proof.execution)) return null;
|
|
732
|
+
if (
|
|
733
|
+
!binding ||
|
|
734
|
+
!isWorkspaceBoundExecutionEvidence(proof.execution, binding.run_id) ||
|
|
735
|
+
stableDigest(proof.execution.binding) !== stableDigest(binding)
|
|
736
|
+
) {
|
|
737
|
+
throw new Error("terminal workflow proof does not match workspace binding authority");
|
|
738
|
+
}
|
|
739
|
+
return proof.execution.terminal_attestation.resource_state;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function validateProviderEventChain(
|
|
743
|
+
events: readonly WorkspaceProviderEvent[],
|
|
744
|
+
binding?: WorkspaceBinding,
|
|
745
|
+
): void {
|
|
746
|
+
for (const [index, record] of events.entries()) {
|
|
747
|
+
const { record_sha256, ...basis } = record;
|
|
748
|
+
const prior = events[index - 1];
|
|
749
|
+
if (
|
|
750
|
+
record.schema_version !== 1 ||
|
|
751
|
+
record.seq !== index + 1 ||
|
|
752
|
+
record.previous_sha256 !== (prior?.record_sha256 ?? null) ||
|
|
753
|
+
record.record_sha256 !== stableDigest(basis) ||
|
|
754
|
+
(binding !== undefined &&
|
|
755
|
+
(record.binding_id !== binding.binding_id ||
|
|
756
|
+
record.workspace_id !== binding.workspace_id ||
|
|
757
|
+
record.request_sha256 !== binding.request_sha256))
|
|
758
|
+
) {
|
|
759
|
+
throw new Error(`workspace provider event ${index + 1} is corrupt or mismatched`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function providerState(event: string): WorkspaceLifecycleState | undefined {
|
|
765
|
+
switch (event) {
|
|
766
|
+
case "allocation_recorded":
|
|
767
|
+
return "allocation_recorded";
|
|
768
|
+
case "allocating":
|
|
769
|
+
case "branch_creation_started":
|
|
770
|
+
case "worktree_creation_started":
|
|
771
|
+
case "worktree_created":
|
|
772
|
+
case "allocation_rolled_back":
|
|
773
|
+
return "allocating";
|
|
774
|
+
case "bound":
|
|
775
|
+
case "reattached":
|
|
776
|
+
return "bound";
|
|
777
|
+
case "cleanup_pending":
|
|
778
|
+
return "cleanup_pending";
|
|
779
|
+
case "released":
|
|
780
|
+
return "released";
|
|
781
|
+
case "blocked":
|
|
782
|
+
return "blocked";
|
|
783
|
+
case "lost":
|
|
784
|
+
return "lost";
|
|
785
|
+
case "preserved_dirty":
|
|
786
|
+
return "preserved_dirty";
|
|
787
|
+
case "abandoned_dirty":
|
|
788
|
+
return "abandoned_dirty";
|
|
789
|
+
default:
|
|
790
|
+
return undefined;
|
|
791
|
+
}
|
|
792
|
+
}
|