harnery 0.44.0 → 0.45.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/dist/commander.js +1 -0
- package/dist/commands/files.d.ts +2 -2
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +7 -4
- package/dist/commands/servers.d.ts.map +1 -1
- package/dist/commands/servers.js +11 -17
- package/dist/commands/tunnel.d.ts +15 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +453 -153
- package/dist/commands/turn-rollback.d.ts +5 -0
- package/dist/commands/turn-rollback.d.ts.map +1 -0
- package/dist/commands/turn-rollback.js +76 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +44 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +18 -0
- package/dist/core/agents/state/proc-start.d.ts +5 -6
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +13 -9
- package/dist/core/agents/state/process-handle.d.ts +26 -0
- package/dist/core/agents/state/process-handle.d.ts.map +1 -0
- package/dist/core/agents/state/process-handle.js +121 -0
- package/dist/core/artifacts/delivery-card.d.ts.map +1 -1
- package/dist/core/artifacts/delivery-card.js +2 -1
- package/dist/core/resources/service.d.ts +2 -16
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +1 -78
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +2 -9
- package/dist/core/servers/index.d.ts +12 -11
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +194 -81
- package/dist/core/servers/tunnels.d.ts +5 -3
- package/dist/core/servers/tunnels.d.ts.map +1 -1
- package/dist/core/servers/tunnels.js +45 -2
- package/dist/core/supervisor/service.d.ts +2 -4
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/turn-rollback/content.d.ts +53 -0
- package/dist/core/turn-rollback/content.d.ts.map +1 -0
- package/dist/core/turn-rollback/content.js +396 -0
- package/dist/core/turn-rollback/index.d.ts +72 -0
- package/dist/core/turn-rollback/index.d.ts.map +1 -0
- package/dist/core/turn-rollback/index.js +443 -0
- package/dist/core/workflow/index.d.ts +4 -2
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -1
- package/dist/core/workflow/recovery-audit.d.ts +30 -0
- package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
- package/dist/core/workflow/recovery-audit.js +116 -0
- package/dist/core/workflow/run-state.d.ts +19 -2
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +113 -8
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +70 -14
- package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +24 -0
- package/dist/lib/tunnel/state.d.ts +13 -6
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +58 -12
- package/package.json +6 -1
- package/src/commander.ts +10 -0
- package/src/commands/files.ts +13 -5
- package/src/commands/servers.ts +12 -23
- package/src/commands/tunnel.ts +533 -166
- package/src/commands/turn-rollback.ts +111 -0
- package/src/commands/workflow.ts +59 -0
- package/src/core/agents/coord-client.ts +20 -0
- package/src/core/agents/state/proc-start.ts +14 -11
- package/src/core/agents/state/process-handle.ts +165 -0
- package/src/core/artifacts/delivery-card.ts +7 -1
- package/src/core/resources/service.ts +3 -88
- package/src/core/semantic/service.ts +2 -8
- package/src/core/servers/index.ts +234 -85
- package/src/core/servers/tunnels.ts +55 -3
- package/src/core/supervisor/service.ts +2 -3
- package/src/core/turn-rollback/content.ts +499 -0
- package/src/core/turn-rollback/index.ts +580 -0
- package/src/core/workflow/index.ts +12 -1
- package/src/core/workflow/recovery-audit.ts +181 -0
- package/src/core/workflow/run-state.ts +137 -8
- package/src/core/workflow/workspaces/local-git.ts +86 -15
- package/src/core/workflow/workspaces/reclaim.ts +4 -0
- package/src/core/workflow/workspaces/recovery.ts +23 -0
- package/src/lib/tunnel/state.ts +70 -15
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { readJsonRecord, stableDigest, writeImmutableJson } from "../workflow/durable-record.ts";
|
|
4
|
+
import { acquireNoClobberLease } from "../workflow/workspaces/leases.ts";
|
|
5
|
+
import { createLocalGitWorktreeProvider } from "../workflow/workspaces/local-git.ts";
|
|
6
|
+
import {
|
|
7
|
+
claimOverlapsWorkspace,
|
|
8
|
+
reserveWorkspaceReclaim,
|
|
9
|
+
} from "../workflow/workspaces/ownership.ts";
|
|
10
|
+
import { descriptorBackedPathsSupported } from "../workflow/workspaces/paths.ts";
|
|
11
|
+
import { readWorkflowLiveness } from "../workflow/workspaces/recovery.ts";
|
|
12
|
+
import {
|
|
13
|
+
appendWorkspaceEvent,
|
|
14
|
+
readWorkspaceClaim,
|
|
15
|
+
readWorkspaceClaims,
|
|
16
|
+
workspaceLockPath,
|
|
17
|
+
} from "../workflow/workspaces/state.ts";
|
|
18
|
+
import type { WorkspaceBinding } from "../workflow/workspaces/types.ts";
|
|
19
|
+
import {
|
|
20
|
+
type ContentEntry,
|
|
21
|
+
type ContentSnapshot,
|
|
22
|
+
currentMatches,
|
|
23
|
+
ensureDirectory,
|
|
24
|
+
git,
|
|
25
|
+
lockIndex,
|
|
26
|
+
observe,
|
|
27
|
+
persistSnapshot,
|
|
28
|
+
restoreFile,
|
|
29
|
+
safePaths,
|
|
30
|
+
stageBefore,
|
|
31
|
+
verifySnapshot,
|
|
32
|
+
} from "./content.ts";
|
|
33
|
+
|
|
34
|
+
export type { ContentEntry, ContentSnapshot } from "./content.ts";
|
|
35
|
+
export type TurnIdentity = { turn_id: string; generation: number };
|
|
36
|
+
export interface TurnCapture {
|
|
37
|
+
schema_version: 1;
|
|
38
|
+
capture_id: string;
|
|
39
|
+
binding_sha256: string;
|
|
40
|
+
binding_generation: number;
|
|
41
|
+
turn: TurnIdentity;
|
|
42
|
+
paths: string[];
|
|
43
|
+
before: ContentSnapshot;
|
|
44
|
+
sha256: string;
|
|
45
|
+
end?: ContentSnapshot;
|
|
46
|
+
}
|
|
47
|
+
export interface TurnRollbackPlan {
|
|
48
|
+
schema_version: 1;
|
|
49
|
+
capture_id: string;
|
|
50
|
+
binding_sha256: string;
|
|
51
|
+
turn: TurnIdentity;
|
|
52
|
+
before_sha256: string;
|
|
53
|
+
end_sha256: string;
|
|
54
|
+
paths: Array<{ path: string; before: ContentEntry; end: ContentEntry }>;
|
|
55
|
+
plan_sha256: string;
|
|
56
|
+
}
|
|
57
|
+
export interface TurnRollbackResult {
|
|
58
|
+
schema_version: 1;
|
|
59
|
+
status: "preview" | "applied" | "already_applied" | "unknown";
|
|
60
|
+
capture_id: string;
|
|
61
|
+
plan_sha256: string;
|
|
62
|
+
reason?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface TurnRollbackStatus {
|
|
65
|
+
status: "missing" | "open" | "sealed" | "applied" | "unknown";
|
|
66
|
+
capture_id: string;
|
|
67
|
+
}
|
|
68
|
+
type CaptureInput = { binding: WorkspaceBinding; capture_id: string };
|
|
69
|
+
interface Scope {
|
|
70
|
+
binding: WorkspaceBinding;
|
|
71
|
+
state: string;
|
|
72
|
+
store: string;
|
|
73
|
+
guard(): Promise<void>;
|
|
74
|
+
audit(event: string, data: Record<string, unknown>): void;
|
|
75
|
+
}
|
|
76
|
+
const PROVIDER = "local-git-worktree";
|
|
77
|
+
const CAPTURE = /^turn-[a-f0-9]{32}$/;
|
|
78
|
+
const TOTAL_LIMIT = 64 * 1024 * 1024;
|
|
79
|
+
|
|
80
|
+
function statePath(coordRoot: string, binding: WorkspaceBinding): string {
|
|
81
|
+
if (
|
|
82
|
+
binding?.provider?.id !== PROVIDER ||
|
|
83
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(binding.binding_id)
|
|
84
|
+
)
|
|
85
|
+
throw new Error("exclusive local provider binding is required");
|
|
86
|
+
return join(resolve(coordRoot), ".harnery", "turn-rollback", PROVIDER, binding.binding_id);
|
|
87
|
+
}
|
|
88
|
+
function capturePath(scope: Pick<Scope, "state">, id: string, name: string): string {
|
|
89
|
+
if (!CAPTURE.test(id)) throw new Error("invalid capture id");
|
|
90
|
+
return join(scope.state, "captures", id, name);
|
|
91
|
+
}
|
|
92
|
+
function record<T>(path: string): T {
|
|
93
|
+
if (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())
|
|
94
|
+
throw new Error("rollback record is unsafe");
|
|
95
|
+
return readJsonRecord<T>(path, "turn rollback record");
|
|
96
|
+
}
|
|
97
|
+
function load(scope: Pick<Scope, "state" | "binding">, id: string): TurnCapture {
|
|
98
|
+
if (!existsSync(capturePath(scope, id, "begin.json")))
|
|
99
|
+
throw new Error("capture outcome is unknown; incomplete pending capture blocks further turns");
|
|
100
|
+
const capture = record<TurnCapture>(capturePath(scope, id, "begin.json"));
|
|
101
|
+
const { sha256, end: invalidEnd, ...basis } = capture;
|
|
102
|
+
if (
|
|
103
|
+
invalidEnd ||
|
|
104
|
+
sha256 !== stableDigest(basis) ||
|
|
105
|
+
capture.schema_version !== 1 ||
|
|
106
|
+
capture.capture_id !== id ||
|
|
107
|
+
capture.binding_sha256 !== stableDigest(scope.binding) ||
|
|
108
|
+
capture.binding_generation !== scope.binding.generation ||
|
|
109
|
+
!Number.isSafeInteger(capture.turn?.generation) ||
|
|
110
|
+
capture.turn.generation < 1 ||
|
|
111
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(capture.turn?.turn_id) ||
|
|
112
|
+
stableDigest(safePaths(scope.binding, capture.paths)) !== stableDigest(capture.paths) ||
|
|
113
|
+
stableDigest(capture.before.entries.map((entry) => entry.path)) !== stableDigest(capture.paths)
|
|
114
|
+
)
|
|
115
|
+
throw new Error("capture authority changed");
|
|
116
|
+
const sealed = capturePath(scope, id, "end.json");
|
|
117
|
+
if (existsSync(sealed)) {
|
|
118
|
+
const end = record<{ capture_sha256: string; snapshot: ContentSnapshot }>(sealed);
|
|
119
|
+
if (
|
|
120
|
+
end.capture_sha256 !== sha256 ||
|
|
121
|
+
stableDigest(end.snapshot.entries.map((entry) => entry.path)) !== stableDigest(capture.paths)
|
|
122
|
+
)
|
|
123
|
+
throw new Error("sealed capture authority changed");
|
|
124
|
+
return { ...capture, end: end.snapshot };
|
|
125
|
+
}
|
|
126
|
+
if (existsSync(capturePath(scope, id, "seal-pending.json")))
|
|
127
|
+
throw new Error("seal outcome is unknown; incomplete pending snapshot blocks further turns");
|
|
128
|
+
return capture;
|
|
129
|
+
}
|
|
130
|
+
function allCaptures(scope: Pick<Scope, "state" | "binding">): TurnCapture[] {
|
|
131
|
+
const root = join(scope.state, "captures");
|
|
132
|
+
if (!existsSync(root)) return [];
|
|
133
|
+
const captures = readdirSync(root)
|
|
134
|
+
.map((id) => load(scope, id))
|
|
135
|
+
.sort((a, b) => a.turn.generation - b.turn.generation);
|
|
136
|
+
if (
|
|
137
|
+
captures.length > 64 ||
|
|
138
|
+
new Set(captures.map((c) => c.turn.generation)).size !== captures.length
|
|
139
|
+
)
|
|
140
|
+
throw new Error("capture generation or retention limit is invalid");
|
|
141
|
+
return captures;
|
|
142
|
+
}
|
|
143
|
+
function latest(scope: Pick<Scope, "state" | "binding">, id: string): TurnCapture {
|
|
144
|
+
const capture = allCaptures(scope).at(-1);
|
|
145
|
+
if (!capture || capture.capture_id !== id)
|
|
146
|
+
throw new Error("restore requires the latest turn capture");
|
|
147
|
+
return capture;
|
|
148
|
+
}
|
|
149
|
+
function usage(captures: TurnCapture[]): number {
|
|
150
|
+
return captures.reduce((sum, c) => sum + c.before.total_bytes + (c.end?.total_bytes ?? 0), 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function withScope<T>(
|
|
154
|
+
coordRoot: string,
|
|
155
|
+
binding: WorkspaceBinding,
|
|
156
|
+
operation: (scope: Scope) => Promise<T>,
|
|
157
|
+
): Promise<T> {
|
|
158
|
+
const state = statePath(coordRoot, binding);
|
|
159
|
+
if (!descriptorBackedPathsSupported())
|
|
160
|
+
throw new Error("descriptor-backed containment is unsupported");
|
|
161
|
+
const provider = createLocalGitWorktreeProvider({ coordRoot });
|
|
162
|
+
await provider.readBinding(binding);
|
|
163
|
+
const claim = readWorkspaceClaim(coordRoot, PROVIDER, binding.binding_id)!;
|
|
164
|
+
const authority = stableDigest({
|
|
165
|
+
binding_id: claim.binding_id,
|
|
166
|
+
request_sha256: claim.request_sha256,
|
|
167
|
+
scope: "binding",
|
|
168
|
+
});
|
|
169
|
+
const path = workspaceLockPath(coordRoot, PROVIDER, binding.binding_id);
|
|
170
|
+
const lease = acquireNoClobberLease({
|
|
171
|
+
path,
|
|
172
|
+
scope: "binding",
|
|
173
|
+
authoritySha256: authority,
|
|
174
|
+
staleAfterMs: 300_000,
|
|
175
|
+
metadata: { binding_id: claim.binding_id, request_sha256: claim.request_sha256 },
|
|
176
|
+
validateStaleOwner: (owner) =>
|
|
177
|
+
owner.authority_sha256 === authority &&
|
|
178
|
+
owner.metadata?.binding_id === claim.binding_id &&
|
|
179
|
+
owner.metadata?.request_sha256 === claim.request_sha256,
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
const reservation = reserveWorkspaceReclaim(coordRoot, claim);
|
|
183
|
+
try {
|
|
184
|
+
const guard = async () => {
|
|
185
|
+
reservation.assertCurrent();
|
|
186
|
+
const ownerPath = join(path, lease.owner.owner_file);
|
|
187
|
+
const current = join(path, "current");
|
|
188
|
+
const a = lstatSync(current, { bigint: true });
|
|
189
|
+
const b = lstatSync(ownerPath, { bigint: true });
|
|
190
|
+
if (
|
|
191
|
+
!a.isFile() ||
|
|
192
|
+
a.dev !== b.dev ||
|
|
193
|
+
a.ino !== b.ino ||
|
|
194
|
+
readFileSync(current, "utf8") !== readFileSync(ownerPath, "utf8") ||
|
|
195
|
+
stableDigest(record(current)) !== stableDigest(lease.owner)
|
|
196
|
+
)
|
|
197
|
+
throw new Error("binding lease changed");
|
|
198
|
+
await provider.readBinding(binding);
|
|
199
|
+
const attestation = await provider.attest(binding);
|
|
200
|
+
if (
|
|
201
|
+
attestation.status !== "ok" ||
|
|
202
|
+
attestation.resource_state !== "active" ||
|
|
203
|
+
attestation.repository?.conflicts.length ||
|
|
204
|
+
attestation.repository?.operation_in_progress.length
|
|
205
|
+
)
|
|
206
|
+
throw new Error("workspace ownership is unproven");
|
|
207
|
+
for (const peer of readWorkspaceClaims(coordRoot, PROVIDER)) {
|
|
208
|
+
if (
|
|
209
|
+
peer.binding_id !== binding.binding_id &&
|
|
210
|
+
claimOverlapsWorkspace(peer, claim.workspace_root)
|
|
211
|
+
)
|
|
212
|
+
throw new Error(`retained peer ${peer.binding_id} overlaps rollback workspace`);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
await guard();
|
|
216
|
+
ensureDirectory(state);
|
|
217
|
+
const store = join(state, "content.git");
|
|
218
|
+
if (!existsSync(store)) git(state, ["init", "--bare", "-q", store]);
|
|
219
|
+
ensureDirectory(store);
|
|
220
|
+
return await operation({
|
|
221
|
+
binding,
|
|
222
|
+
state,
|
|
223
|
+
store,
|
|
224
|
+
guard,
|
|
225
|
+
audit: (event, data) => {
|
|
226
|
+
appendWorkspaceEvent(coordRoot, claim, `turn_rollback.${event}`, data);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
reservation.release();
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
lease.release();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Capture the exact named files before a turn, without staging anything. */
|
|
238
|
+
export async function beginTurnRollback(
|
|
239
|
+
coordRoot: string,
|
|
240
|
+
input: {
|
|
241
|
+
binding: WorkspaceBinding;
|
|
242
|
+
turn: TurnIdentity;
|
|
243
|
+
paths: readonly string[];
|
|
244
|
+
onSnapshotStep?: (step: "content_persisted", captureId: string) => void;
|
|
245
|
+
},
|
|
246
|
+
): Promise<TurnCapture> {
|
|
247
|
+
return withScope(coordRoot, input.binding, async (scope) => {
|
|
248
|
+
if (
|
|
249
|
+
!input.turn ||
|
|
250
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(input.turn.turn_id) ||
|
|
251
|
+
!Number.isSafeInteger(input.turn.generation) ||
|
|
252
|
+
input.turn.generation < 1
|
|
253
|
+
)
|
|
254
|
+
throw new Error("invalid turn identity");
|
|
255
|
+
const paths = safePaths(input.binding, input.paths);
|
|
256
|
+
const prior = allCaptures(scope);
|
|
257
|
+
const last = prior.at(-1);
|
|
258
|
+
if (
|
|
259
|
+
last &&
|
|
260
|
+
(!last.end ||
|
|
261
|
+
input.turn.generation <= last.turn.generation ||
|
|
262
|
+
inspectTurnRollback(coordRoot, { binding: input.binding, capture_id: last.capture_id })
|
|
263
|
+
.status === "unknown")
|
|
264
|
+
)
|
|
265
|
+
throw new Error("latest turn is open, unknown, or newer");
|
|
266
|
+
if (prior.length >= 64) throw new Error("capture count limit exceeded");
|
|
267
|
+
const index = lockIndex(input.binding, scope.state);
|
|
268
|
+
try {
|
|
269
|
+
const observed = observe(input.binding, scope.store, paths);
|
|
270
|
+
if (usage(prior) + observed.total > TOTAL_LIMIT)
|
|
271
|
+
throw new Error("binding retention byte limit exceeded");
|
|
272
|
+
await scope.guard();
|
|
273
|
+
const binding_sha256 = stableDigest(input.binding);
|
|
274
|
+
const capture_id = `turn-${stableDigest({ binding_sha256, turn: input.turn, paths }).slice(0, 32)}`;
|
|
275
|
+
ensureDirectory(join(scope.state, "captures", capture_id));
|
|
276
|
+
writeImmutableJson(capturePath(scope, capture_id, "pending.json"), {
|
|
277
|
+
schema_version: 1,
|
|
278
|
+
capture_id,
|
|
279
|
+
binding_sha256,
|
|
280
|
+
turn: input.turn,
|
|
281
|
+
paths,
|
|
282
|
+
phase: "before",
|
|
283
|
+
total_bytes: observed.total,
|
|
284
|
+
});
|
|
285
|
+
const before = persistSnapshot(scope.store, observed, `${capture_id}/before`);
|
|
286
|
+
input.onSnapshotStep?.("content_persisted", capture_id);
|
|
287
|
+
const basis = {
|
|
288
|
+
schema_version: 1 as const,
|
|
289
|
+
capture_id,
|
|
290
|
+
binding_sha256,
|
|
291
|
+
binding_generation: input.binding.generation,
|
|
292
|
+
turn: { ...input.turn },
|
|
293
|
+
paths,
|
|
294
|
+
before,
|
|
295
|
+
};
|
|
296
|
+
const capture = { ...basis, sha256: stableDigest(basis) };
|
|
297
|
+
writeImmutableJson(capturePath(scope, capture_id, "begin.json"), capture);
|
|
298
|
+
scope.audit("captured", {
|
|
299
|
+
capture_id,
|
|
300
|
+
capture_sha256: capture.sha256,
|
|
301
|
+
turn: capture.turn,
|
|
302
|
+
paths,
|
|
303
|
+
});
|
|
304
|
+
return capture;
|
|
305
|
+
} finally {
|
|
306
|
+
index.close();
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Seal the end boundary of the latest captured turn. */
|
|
312
|
+
export async function sealTurnRollback(
|
|
313
|
+
coordRoot: string,
|
|
314
|
+
input: CaptureInput & { onSnapshotStep?: (step: "content_persisted", captureId: string) => void },
|
|
315
|
+
): Promise<TurnCapture> {
|
|
316
|
+
return withScope(coordRoot, input.binding, async (scope) => {
|
|
317
|
+
const capture = latest(scope, input.capture_id);
|
|
318
|
+
verifySnapshot(scope.store, capture.before);
|
|
319
|
+
const index = lockIndex(input.binding, scope.state);
|
|
320
|
+
try {
|
|
321
|
+
if (capture.end) {
|
|
322
|
+
verifySnapshot(scope.store, capture.end);
|
|
323
|
+
if (!currentMatches(input.binding, scope.store, capture.end))
|
|
324
|
+
throw new Error("sealed content changed");
|
|
325
|
+
return capture;
|
|
326
|
+
}
|
|
327
|
+
const observed = observe(input.binding, scope.store, capture.paths);
|
|
328
|
+
if (observed.head !== capture.before.head) throw new Error("HEAD changed during turn");
|
|
329
|
+
if (usage(allCaptures(scope)) + observed.total > TOTAL_LIMIT)
|
|
330
|
+
throw new Error("binding retention byte limit exceeded");
|
|
331
|
+
await scope.guard();
|
|
332
|
+
writeImmutableJson(capturePath(scope, capture.capture_id, "seal-pending.json"), {
|
|
333
|
+
schema_version: 1,
|
|
334
|
+
capture_id: capture.capture_id,
|
|
335
|
+
binding_sha256: stableDigest(input.binding),
|
|
336
|
+
turn: capture.turn,
|
|
337
|
+
paths: capture.paths,
|
|
338
|
+
phase: "end",
|
|
339
|
+
total_bytes: observed.total,
|
|
340
|
+
});
|
|
341
|
+
const end = persistSnapshot(scope.store, observed, `${capture.capture_id}/end`);
|
|
342
|
+
input.onSnapshotStep?.("content_persisted", capture.capture_id);
|
|
343
|
+
writeImmutableJson(capturePath(scope, capture.capture_id, "end.json"), {
|
|
344
|
+
capture_sha256: capture.sha256,
|
|
345
|
+
snapshot: end,
|
|
346
|
+
});
|
|
347
|
+
scope.audit("sealed", {
|
|
348
|
+
capture_id: capture.capture_id,
|
|
349
|
+
capture_sha256: capture.sha256,
|
|
350
|
+
end_sha256: end.sha256,
|
|
351
|
+
});
|
|
352
|
+
return { ...capture, end };
|
|
353
|
+
} finally {
|
|
354
|
+
index.close();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function planFor(capture: TurnCapture): TurnRollbackPlan {
|
|
360
|
+
if (!capture.end) throw new Error("turn capture is not sealed");
|
|
361
|
+
const basis = {
|
|
362
|
+
schema_version: 1 as const,
|
|
363
|
+
capture_id: capture.capture_id,
|
|
364
|
+
binding_sha256: capture.binding_sha256,
|
|
365
|
+
turn: capture.turn,
|
|
366
|
+
before_sha256: capture.before.sha256,
|
|
367
|
+
end_sha256: capture.end.sha256,
|
|
368
|
+
paths: capture.paths.map((path, index) => ({
|
|
369
|
+
path,
|
|
370
|
+
before: capture.before.entries[index]!,
|
|
371
|
+
end: capture.end!.entries[index]!,
|
|
372
|
+
})),
|
|
373
|
+
};
|
|
374
|
+
return { ...basis, plan_sha256: stableDigest(basis) };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Produce a dry-run exact-path proposal bound to both captured boundaries. */
|
|
378
|
+
export async function prepareTurnRollback(
|
|
379
|
+
coordRoot: string,
|
|
380
|
+
input: CaptureInput,
|
|
381
|
+
): Promise<TurnRollbackPlan> {
|
|
382
|
+
return withScope(coordRoot, input.binding, async (scope) => {
|
|
383
|
+
const capture = latest(scope, input.capture_id);
|
|
384
|
+
const plan = planFor(capture);
|
|
385
|
+
verifySnapshot(scope.store, capture.before);
|
|
386
|
+
verifySnapshot(scope.store, capture.end!);
|
|
387
|
+
const index = lockIndex(input.binding, scope.state);
|
|
388
|
+
try {
|
|
389
|
+
if (!currentMatches(input.binding, scope.store, capture.end!))
|
|
390
|
+
throw new Error("selected content or HEAD changed since seal");
|
|
391
|
+
return plan;
|
|
392
|
+
} finally {
|
|
393
|
+
index.close();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function applyTurnRollback(
|
|
399
|
+
coordRoot: string,
|
|
400
|
+
input: {
|
|
401
|
+
binding: WorkspaceBinding;
|
|
402
|
+
plan: TurnRollbackPlan;
|
|
403
|
+
yes?: boolean;
|
|
404
|
+
actor?: string;
|
|
405
|
+
reason?: string;
|
|
406
|
+
/** Observation/fault boundary; does not replace any authority or content guard. */
|
|
407
|
+
onStep?: (step: "started" | "worktree_published" | "index_published") => void;
|
|
408
|
+
},
|
|
409
|
+
): Promise<TurnRollbackResult> {
|
|
410
|
+
if (
|
|
411
|
+
inspectTurnRollback(coordRoot, { binding: input.binding, capture_id: input.plan.capture_id })
|
|
412
|
+
.status === "unknown"
|
|
413
|
+
)
|
|
414
|
+
throw new Error("rollback outcome is unknown; automatic retry is refused");
|
|
415
|
+
return withScope(coordRoot, input.binding, async (scope) => {
|
|
416
|
+
const capture = latest(scope, input.plan.capture_id);
|
|
417
|
+
const expected = planFor(capture);
|
|
418
|
+
if (stableDigest(input.plan) !== stableDigest(expected))
|
|
419
|
+
throw new Error("rollback proposal digest changed");
|
|
420
|
+
verifySnapshot(scope.store, capture.before);
|
|
421
|
+
verifySnapshot(scope.store, capture.end!);
|
|
422
|
+
const result = {
|
|
423
|
+
schema_version: 1 as const,
|
|
424
|
+
capture_id: capture.capture_id,
|
|
425
|
+
plan_sha256: expected.plan_sha256,
|
|
426
|
+
};
|
|
427
|
+
const receiptPath = capturePath(scope, capture.capture_id, "receipt.json");
|
|
428
|
+
const startedPath = capturePath(scope, capture.capture_id, "started.json");
|
|
429
|
+
const index = lockIndex(input.binding, scope.state);
|
|
430
|
+
try {
|
|
431
|
+
if (existsSync(receiptPath)) {
|
|
432
|
+
const receipt = record<TurnRollbackResult>(receiptPath);
|
|
433
|
+
if (
|
|
434
|
+
receipt.schema_version !== 1 ||
|
|
435
|
+
receipt.status !== "applied" ||
|
|
436
|
+
receipt.plan_sha256 !== expected.plan_sha256 ||
|
|
437
|
+
receipt.capture_id !== capture.capture_id ||
|
|
438
|
+
!currentMatches(input.binding, scope.store, capture.before)
|
|
439
|
+
)
|
|
440
|
+
throw new Error("applied receipt or restored content changed");
|
|
441
|
+
return { ...result, status: "already_applied" };
|
|
442
|
+
}
|
|
443
|
+
if (!currentMatches(input.binding, scope.store, capture.end!))
|
|
444
|
+
throw new Error("selected content or HEAD changed since proposal");
|
|
445
|
+
if (!input.yes) return { ...result, status: "preview" };
|
|
446
|
+
if (
|
|
447
|
+
!input.actor?.trim() ||
|
|
448
|
+
!input.reason?.trim() ||
|
|
449
|
+
input.actor.length > 200 ||
|
|
450
|
+
input.reason.length > 1000
|
|
451
|
+
)
|
|
452
|
+
throw new Error("actor and reason are required and bounded");
|
|
453
|
+
if (readWorkflowLiveness(coordRoot, input.binding.run_id) !== "inactive")
|
|
454
|
+
throw new Error("workflow must be proven inactive before rollback");
|
|
455
|
+
await scope.guard();
|
|
456
|
+
scope.audit("apply_requested", {
|
|
457
|
+
...result,
|
|
458
|
+
turn: capture.turn,
|
|
459
|
+
actor: input.actor,
|
|
460
|
+
reason: input.reason,
|
|
461
|
+
paths: capture.paths,
|
|
462
|
+
});
|
|
463
|
+
writeImmutableJson(startedPath, {
|
|
464
|
+
...result,
|
|
465
|
+
turn: capture.turn,
|
|
466
|
+
actor: input.actor,
|
|
467
|
+
reason: input.reason,
|
|
468
|
+
});
|
|
469
|
+
try {
|
|
470
|
+
input.onStep?.("started");
|
|
471
|
+
// Each published path changes the expected worktree for subsequent guards.
|
|
472
|
+
const progress: ContentSnapshot = structuredClone(capture.end!);
|
|
473
|
+
for (const [position, entry] of capture.before.entries.entries()) {
|
|
474
|
+
await scope.guard();
|
|
475
|
+
if (
|
|
476
|
+
readWorkflowLiveness(coordRoot, input.binding.run_id) !== "inactive" ||
|
|
477
|
+
!currentMatches(input.binding, scope.store, progress)
|
|
478
|
+
)
|
|
479
|
+
throw new Error("content or workflow changed during restore");
|
|
480
|
+
restoreFile(input.binding, scope.store, entry);
|
|
481
|
+
progress.entries[position]!.worktree = entry.worktree;
|
|
482
|
+
progress.entries[position]!.untracked =
|
|
483
|
+
!progress.entries[position]!.index && entry.worktree !== null;
|
|
484
|
+
input.onStep?.("worktree_published");
|
|
485
|
+
}
|
|
486
|
+
await scope.guard();
|
|
487
|
+
if (
|
|
488
|
+
readWorkflowLiveness(coordRoot, input.binding.run_id) !== "inactive" ||
|
|
489
|
+
!currentMatches(input.binding, scope.store, progress)
|
|
490
|
+
)
|
|
491
|
+
throw new Error("content or workflow changed before index publication");
|
|
492
|
+
stageBefore(input.binding, scope.store, capture.before, index);
|
|
493
|
+
await scope.guard();
|
|
494
|
+
if (
|
|
495
|
+
readWorkflowLiveness(coordRoot, input.binding.run_id) !== "inactive" ||
|
|
496
|
+
!currentMatches(input.binding, scope.store, progress)
|
|
497
|
+
)
|
|
498
|
+
throw new Error("content changed before index publication");
|
|
499
|
+
index.publish();
|
|
500
|
+
input.onStep?.("index_published");
|
|
501
|
+
await scope.guard();
|
|
502
|
+
if (
|
|
503
|
+
readWorkflowLiveness(coordRoot, input.binding.run_id) !== "inactive" ||
|
|
504
|
+
!currentMatches(input.binding, scope.store, capture.before)
|
|
505
|
+
)
|
|
506
|
+
throw new Error("restored content verification failed");
|
|
507
|
+
const receipt: TurnRollbackResult = { ...result, status: "applied" };
|
|
508
|
+
scope.audit("applied", { ...result, turn: capture.turn, paths: capture.paths });
|
|
509
|
+
writeImmutableJson(receiptPath, receipt);
|
|
510
|
+
return receipt;
|
|
511
|
+
} catch (error) {
|
|
512
|
+
scope.audit("unknown", { ...result, reason: String(error).slice(0, 1000) });
|
|
513
|
+
return { ...result, status: "unknown", reason: String(error) };
|
|
514
|
+
}
|
|
515
|
+
} finally {
|
|
516
|
+
index.close();
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Read recovery evidence without attempting to continue an interrupted restore. */
|
|
522
|
+
export function inspectTurnRollback(coordRoot: string, input: CaptureInput): TurnRollbackStatus {
|
|
523
|
+
const state = statePath(coordRoot, input.binding);
|
|
524
|
+
const scope = { state, binding: input.binding };
|
|
525
|
+
const beginPath = capturePath(scope, input.capture_id, "begin.json");
|
|
526
|
+
if (!existsSync(beginPath)) {
|
|
527
|
+
const pending = capturePath(scope, input.capture_id, "pending.json");
|
|
528
|
+
if (!existsSync(pending)) return { status: "missing", capture_id: input.capture_id };
|
|
529
|
+
const reservation = record<{
|
|
530
|
+
schema_version: number;
|
|
531
|
+
capture_id: string;
|
|
532
|
+
binding_sha256: string;
|
|
533
|
+
}>(pending);
|
|
534
|
+
if (
|
|
535
|
+
reservation.schema_version !== 1 ||
|
|
536
|
+
reservation.capture_id !== input.capture_id ||
|
|
537
|
+
reservation.binding_sha256 !== stableDigest(input.binding)
|
|
538
|
+
)
|
|
539
|
+
throw new Error("pending capture authority changed");
|
|
540
|
+
return { status: "unknown", capture_id: input.capture_id };
|
|
541
|
+
}
|
|
542
|
+
if (
|
|
543
|
+
existsSync(capturePath(scope, input.capture_id, "seal-pending.json")) &&
|
|
544
|
+
!existsSync(capturePath(scope, input.capture_id, "end.json"))
|
|
545
|
+
)
|
|
546
|
+
return { status: "unknown", capture_id: input.capture_id };
|
|
547
|
+
const capture = load(scope, input.capture_id);
|
|
548
|
+
const started = existsSync(capturePath(scope, input.capture_id, "started.json"));
|
|
549
|
+
const receipt = existsSync(capturePath(scope, input.capture_id, "receipt.json"));
|
|
550
|
+
if (started) {
|
|
551
|
+
const pending = record<{
|
|
552
|
+
schema_version: number;
|
|
553
|
+
capture_id: string;
|
|
554
|
+
plan_sha256: string;
|
|
555
|
+
turn: TurnIdentity;
|
|
556
|
+
}>(capturePath(scope, input.capture_id, "started.json"));
|
|
557
|
+
if (
|
|
558
|
+
pending.schema_version !== 1 ||
|
|
559
|
+
pending.capture_id !== capture.capture_id ||
|
|
560
|
+
pending.plan_sha256 !== planFor(capture).plan_sha256 ||
|
|
561
|
+
stableDigest(pending.turn) !== stableDigest(capture.turn)
|
|
562
|
+
)
|
|
563
|
+
throw new Error("started restore authority changed");
|
|
564
|
+
}
|
|
565
|
+
if (receipt) {
|
|
566
|
+
const value = record<TurnRollbackResult>(capturePath(scope, input.capture_id, "receipt.json"));
|
|
567
|
+
if (
|
|
568
|
+
!started ||
|
|
569
|
+
value.schema_version !== 1 ||
|
|
570
|
+
value.status !== "applied" ||
|
|
571
|
+
value.capture_id !== capture.capture_id ||
|
|
572
|
+
value.plan_sha256 !== planFor(capture).plan_sha256
|
|
573
|
+
)
|
|
574
|
+
throw new Error("restore receipt is unproven");
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
status: started ? (receipt ? "applied" : "unknown") : capture.end ? "sealed" : "open",
|
|
578
|
+
capture_id: input.capture_id,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
@@ -39,12 +39,23 @@ export {
|
|
|
39
39
|
rollupAcceptance,
|
|
40
40
|
writeWorkflowProof,
|
|
41
41
|
} from "./proof.ts";
|
|
42
|
-
export type {
|
|
42
|
+
export type {
|
|
43
|
+
WorkflowRecoveryAuthorization,
|
|
44
|
+
WorkflowRecoveryIntent,
|
|
45
|
+
WorkflowRecoveryRecord,
|
|
46
|
+
} from "./recovery-audit.ts";
|
|
47
|
+
export { beginWorkflowRecovery, readWorkflowRecoveryLedger } from "./recovery-audit.ts";
|
|
48
|
+
export type {
|
|
49
|
+
WorkflowResumeRecoveryOptions,
|
|
50
|
+
WorkflowResumeRecoveryResult,
|
|
51
|
+
WorkflowRunManifest,
|
|
52
|
+
} from "./run-state.ts";
|
|
43
53
|
export {
|
|
44
54
|
acquireWorkflowResumeLease,
|
|
45
55
|
assertWorkflowRunResumable,
|
|
46
56
|
assertWorkflowScriptUnchanged,
|
|
47
57
|
readWorkflowRunManifest,
|
|
58
|
+
recoverWorkflowResumeLease,
|
|
48
59
|
WORKFLOW_RUN_MANIFEST_SCHEMA_VERSION,
|
|
49
60
|
workflowRunManifestPath,
|
|
50
61
|
workflowScriptDigest,
|