harnery 0.44.0 → 0.45.1

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.
Files changed (138) hide show
  1. package/dist/commands/workflow.d.ts.map +1 -1
  2. package/dist/commands/workflow.js +44 -0
  3. package/dist/core/agents/coord-client.d.ts.map +1 -1
  4. package/dist/core/agents/coord-client.js +18 -0
  5. package/dist/core/agents/live-authority-v3.d.ts +0 -1
  6. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  7. package/dist/core/agents/live-authority-v3.js +0 -11
  8. package/dist/core/agents/live-lifecycle-v3.d.ts +2 -4
  9. package/dist/core/agents/live-lifecycle-v3.d.ts.map +1 -1
  10. package/dist/core/agents/live-lifecycle-v3.js +7 -28
  11. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  12. package/dist/core/agents/state/heartbeat-writer.js +14 -25
  13. package/dist/core/agents/state/live-coordination-writer.d.ts.map +1 -1
  14. package/dist/core/agents/state/live-coordination-writer.js +0 -5
  15. package/dist/core/agents/state/pidmap.d.ts.map +1 -1
  16. package/dist/core/agents/state/pidmap.js +0 -5
  17. package/dist/core/agents/state/proc-start.d.ts +5 -6
  18. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  19. package/dist/core/agents/state/proc-start.js +13 -9
  20. package/dist/core/agents/state/process-handle.d.ts +26 -0
  21. package/dist/core/agents/state/process-handle.d.ts.map +1 -0
  22. package/dist/core/agents/state/process-handle.js +121 -0
  23. package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
  24. package/dist/core/agents/state/stale-sweep.js +70 -180
  25. package/dist/core/artifacts/activity.d.ts +4 -1
  26. package/dist/core/artifacts/activity.d.ts.map +1 -1
  27. package/dist/core/artifacts/activity.js +8 -2
  28. package/dist/core/artifacts/index.d.ts.map +1 -1
  29. package/dist/core/artifacts/index.js +38 -12
  30. package/dist/core/events/v3/authority-outbox.d.ts.map +1 -1
  31. package/dist/core/events/v3/authority-outbox.js +0 -29
  32. package/dist/core/events/v3/producers/coordination-recorder.d.ts +0 -1
  33. package/dist/core/events/v3/producers/coordination-recorder.d.ts.map +1 -1
  34. package/dist/core/events/v3/producers/coordination-recorder.js +0 -7
  35. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  36. package/dist/core/events/v3/producers/recorder.js +64 -77
  37. package/dist/core/hooks/cli.js +78 -48
  38. package/dist/core/resources/service.d.ts +2 -16
  39. package/dist/core/resources/service.d.ts.map +1 -1
  40. package/dist/core/resources/service.js +1 -78
  41. package/dist/core/semantic/service.d.ts.map +1 -1
  42. package/dist/core/semantic/service.js +2 -9
  43. package/dist/core/servers/index.d.ts +4 -0
  44. package/dist/core/servers/index.d.ts.map +1 -1
  45. package/dist/core/servers/index.js +21 -3
  46. package/dist/core/storage/durable-history.js +56 -22
  47. package/dist/core/storage/segments.d.ts.map +1 -1
  48. package/dist/core/storage/segments.js +73 -17
  49. package/dist/core/supervisor/service.d.ts +2 -4
  50. package/dist/core/supervisor/service.d.ts.map +1 -1
  51. package/dist/core/work/state.js +7 -7
  52. package/dist/core/workflow/engine.d.ts.map +1 -1
  53. package/dist/core/workflow/engine.js +21 -65
  54. package/dist/core/workflow/index.d.ts +4 -2
  55. package/dist/core/workflow/index.d.ts.map +1 -1
  56. package/dist/core/workflow/index.js +2 -1
  57. package/dist/core/workflow/live-session-v3.d.ts +0 -6
  58. package/dist/core/workflow/live-session-v3.d.ts.map +1 -1
  59. package/dist/core/workflow/live-session-v3.js +5 -126
  60. package/dist/core/workflow/proof.d.ts +4 -4
  61. package/dist/core/workflow/proof.d.ts.map +1 -1
  62. package/dist/core/workflow/proof.js +4 -12
  63. package/dist/core/workflow/recovery-audit.d.ts +30 -0
  64. package/dist/core/workflow/recovery-audit.d.ts.map +1 -0
  65. package/dist/core/workflow/recovery-audit.js +116 -0
  66. package/dist/core/workflow/run-state.d.ts +19 -2
  67. package/dist/core/workflow/run-state.d.ts.map +1 -1
  68. package/dist/core/workflow/run-state.js +113 -8
  69. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  70. package/dist/core/workflow/spawn-claude.js +0 -19
  71. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  72. package/dist/core/workflow/spawn-codex.js +0 -13
  73. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  74. package/dist/core/workflow/spawn-cursor.js +0 -19
  75. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  76. package/dist/core/workflow/spawn-opencode.js +0 -14
  77. package/dist/core/workflow/types.d.ts +11 -54
  78. package/dist/core/workflow/types.d.ts.map +1 -1
  79. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  80. package/dist/core/workflow/workspaces/local-git.js +70 -14
  81. package/dist/core/workflow/workspaces/reclaim.d.ts +6 -1
  82. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  83. package/dist/core/workflow/workspaces/recovery.d.ts +2 -0
  84. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  85. package/dist/core/workflow/workspaces/recovery.js +24 -0
  86. package/dist/lib/identities/assume.d.ts.map +1 -1
  87. package/dist/lib/identities/assume.js +0 -4
  88. package/package.json +1 -1
  89. package/src/commands/workflow.ts +59 -0
  90. package/src/core/agents/coord-client.ts +20 -0
  91. package/src/core/agents/live-authority-v3.ts +0 -17
  92. package/src/core/agents/live-lifecycle-v3.ts +14 -52
  93. package/src/core/agents/state/heartbeat-writer.ts +12 -29
  94. package/src/core/agents/state/live-coordination-writer.ts +0 -29
  95. package/src/core/agents/state/pidmap.ts +0 -13
  96. package/src/core/agents/state/proc-start.ts +14 -11
  97. package/src/core/agents/state/process-handle.ts +165 -0
  98. package/src/core/agents/state/stale-sweep.ts +82 -247
  99. package/src/core/artifacts/activity.ts +9 -2
  100. package/src/core/artifacts/index.ts +45 -12
  101. package/src/core/events/v3/authority-outbox.ts +0 -56
  102. package/src/core/events/v3/producers/coordination-recorder.ts +0 -15
  103. package/src/core/events/v3/producers/recorder.ts +68 -94
  104. package/src/core/hooks/cli.ts +85 -47
  105. package/src/core/resources/service.ts +3 -88
  106. package/src/core/semantic/service.ts +2 -8
  107. package/src/core/servers/index.ts +30 -3
  108. package/src/core/storage/durable-history.ts +52 -20
  109. package/src/core/storage/segments.ts +79 -17
  110. package/src/core/supervisor/service.ts +2 -3
  111. package/src/core/work/state.ts +7 -7
  112. package/src/core/workflow/engine.ts +21 -73
  113. package/src/core/workflow/index.ts +12 -1
  114. package/src/core/workflow/live-session-v3.ts +5 -146
  115. package/src/core/workflow/proof.ts +5 -21
  116. package/src/core/workflow/recovery-audit.ts +181 -0
  117. package/src/core/workflow/run-state.ts +137 -8
  118. package/src/core/workflow/spawn-claude.ts +0 -18
  119. package/src/core/workflow/spawn-codex.ts +0 -13
  120. package/src/core/workflow/spawn-cursor.ts +0 -18
  121. package/src/core/workflow/spawn-opencode.ts +0 -14
  122. package/src/core/workflow/types.ts +11 -41
  123. package/src/core/workflow/workspaces/local-git.ts +86 -15
  124. package/src/core/workflow/workspaces/reclaim.ts +4 -0
  125. package/src/core/workflow/workspaces/recovery.ts +23 -0
  126. package/src/lib/identities/assume.ts +0 -4
  127. package/dist/core/agents/state/unchanged-file.d.ts +0 -44
  128. package/dist/core/agents/state/unchanged-file.d.ts.map +0 -1
  129. package/dist/core/agents/state/unchanged-file.js +0 -390
  130. package/dist/core/events/v3/generation-custody.d.ts +0 -5
  131. package/dist/core/events/v3/generation-custody.d.ts.map +0 -1
  132. package/dist/core/events/v3/generation-custody.js +0 -50
  133. package/dist/core/workflow/failure-evidence.d.ts +0 -16
  134. package/dist/core/workflow/failure-evidence.d.ts.map +0 -1
  135. package/dist/core/workflow/failure-evidence.js +0 -114
  136. package/src/core/agents/state/unchanged-file.ts +0 -456
  137. package/src/core/events/v3/generation-custody.ts +0 -59
  138. package/src/core/workflow/failure-evidence.ts +0 -141
@@ -1,456 +0,0 @@
1
- import {
2
- closeSync,
3
- constants,
4
- fstatSync,
5
- fsyncSync,
6
- linkSync,
7
- lstatSync,
8
- mkdtempSync,
9
- openSync,
10
- readdirSync,
11
- readFileSync,
12
- readlinkSync,
13
- realpathSync,
14
- renameSync,
15
- rmdirSync,
16
- unlinkSync,
17
- writeFileSync,
18
- } from "node:fs";
19
- import { hostname } from "node:os";
20
- import { basename, dirname, join, resolve } from "node:path";
21
- import { stateFileMode } from "../../storage/modes.ts";
22
-
23
- export interface FileRevision {
24
- readonly bytes: Buffer;
25
- readonly dev: bigint;
26
- readonly ino: bigint;
27
- readonly size: bigint;
28
- readonly mtimeNs: bigint;
29
- readonly ctimeNs: bigint;
30
- }
31
-
32
- export type DeleteFileResult =
33
- | { state: "deleted" | "changed" | "missing" | "rejected" }
34
- | { state: "recovery-required"; recoveryPath: string; error?: string }
35
- | { state: "error"; recoveryPath?: string; error: string };
36
-
37
- export interface FileRecoveryResult {
38
- state: "restored" | "retained" | "error";
39
- recoveryPath: string;
40
- sourcePath?: string;
41
- error?: string;
42
- }
43
-
44
- const PRIVATE_PREFIX = ".harnery-delete-";
45
- const MANIFEST = "intent.json";
46
- const PIN = "pinned";
47
-
48
- interface PinIntent {
49
- version: 1;
50
- source: string;
51
- ownerPid: number;
52
- ownerHost: string;
53
- ownerPlatform: string;
54
- ownerProcessNamespace: string;
55
- state: "active" | "retained";
56
- }
57
-
58
- interface PrivateDirectoryRevision {
59
- path: string;
60
- dev: bigint;
61
- ino: bigint;
62
- birthtimeNs: bigint;
63
- }
64
-
65
- function errorMessage(error: unknown): string {
66
- return error instanceof Error ? error.message : String(error);
67
- }
68
-
69
- function errorCode(error: unknown): string | undefined {
70
- return (error as NodeJS.ErrnoException | undefined)?.code;
71
- }
72
-
73
- function sameRevision(a: FileRevision, b: FileRevision, includeCtime = true): boolean {
74
- return (
75
- a.dev === b.dev &&
76
- a.ino === b.ino &&
77
- a.size === b.size &&
78
- a.mtimeNs === b.mtimeNs &&
79
- (!includeCtime || a.ctimeNs === b.ctimeNs) &&
80
- a.bytes.equals(b.bytes)
81
- );
82
- }
83
-
84
- function sameCanonicalPath(a: string, b: string): boolean {
85
- return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
86
- }
87
-
88
- function processNamespace(): string {
89
- if (process.platform === "linux") {
90
- try {
91
- const namespace = readlinkSync("/proc/self/ns/pid");
92
- return /^pid:\[\d+\]$/.test(namespace) ? namespace : "unknown";
93
- } catch {
94
- return "unknown";
95
- }
96
- }
97
- return process.platform === "win32" || process.platform === "darwin" ? "host" : "unknown";
98
- }
99
-
100
- /** Refuse symlink parents and protocol-private paths rather than following them. */
101
- function safePath(filePath: string): string | null {
102
- const path = resolve(filePath);
103
- if (basename(path).startsWith(PRIVATE_PREFIX)) return null;
104
- try {
105
- if (!sameCanonicalPath(realpathSync(dirname(path)), dirname(path))) return null;
106
- return path;
107
- } catch {
108
- return null;
109
- }
110
- }
111
-
112
- /** A stable read of one regular file, including its exact bytes and revision. */
113
- export function captureFileRevision(filePath: string): FileRevision | null {
114
- const path = safePath(filePath);
115
- if (!path) return null;
116
- let fd: number | undefined;
117
- try {
118
- const namedBefore = lstatSync(path, { bigint: true });
119
- if (!namedBefore.isFile()) return null;
120
- fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
121
- const before = fstatSync(fd, { bigint: true });
122
- const bytes = readFileSync(fd);
123
- const after = fstatSync(fd, { bigint: true });
124
- const namedAfter = lstatSync(path, { bigint: true });
125
- const snapshot: FileRevision = { ...before, bytes };
126
- if (
127
- !after.isFile() ||
128
- !namedAfter.isFile() ||
129
- before.size !== BigInt(bytes.length) ||
130
- !sameRevision(snapshot, { ...after, bytes }) ||
131
- !sameRevision(snapshot, { ...namedBefore, bytes }) ||
132
- !sameRevision(snapshot, { ...namedAfter, bytes })
133
- ) {
134
- return null;
135
- }
136
- return {
137
- bytes,
138
- dev: before.dev,
139
- ino: before.ino,
140
- size: before.size,
141
- mtimeNs: before.mtimeNs,
142
- ctimeNs: before.ctimeNs,
143
- };
144
- } catch {
145
- return null;
146
- } finally {
147
- if (fd !== undefined) closeSync(fd);
148
- }
149
- }
150
-
151
- export function fileRevisionMatches(filePath: string, expected: FileRevision): boolean {
152
- const actual = captureFileRevision(filePath);
153
- return actual !== null && sameRevision(actual, expected);
154
- }
155
-
156
- function capturePrivateDirectory(path: string): PrivateDirectoryRevision | null {
157
- try {
158
- const stat = lstatSync(path, { bigint: true });
159
- // Windows mode bits do not describe its ACL. The caller-owned parent's
160
- // inherited ACL defines authorized principals there; random private names
161
- // separate these operations from cooperating publishers on every platform.
162
- if (
163
- !/^\.harnery-delete-[A-Za-z0-9]{6}$/.test(basename(path)) ||
164
- !stat.isDirectory() ||
165
- (process.platform !== "win32" && (stat.mode & 0o077n) !== 0n) ||
166
- !sameCanonicalPath(realpathSync(path), path)
167
- ) {
168
- return null;
169
- }
170
- return { path, dev: stat.dev, ino: stat.ino, birthtimeNs: stat.birthtimeNs };
171
- } catch {
172
- return null;
173
- }
174
- }
175
-
176
- function privateDirectoryMatches(expected: PrivateDirectoryRevision): boolean {
177
- const actual = capturePrivateDirectory(expected.path);
178
- return (
179
- actual !== null &&
180
- actual.dev === expected.dev &&
181
- actual.ino === expected.ino &&
182
- actual.birthtimeNs === expected.birthtimeNs
183
- );
184
- }
185
-
186
- function writeIntent(
187
- directory: PrivateDirectoryRevision,
188
- intent: PinIntent,
189
- create: boolean,
190
- ): void {
191
- if (!privateDirectoryMatches(directory)) throw new Error("unsafe_file_deletion_directory");
192
- const target = join(directory.path, create ? MANIFEST : "intent.next");
193
- const fd = openSync(
194
- target,
195
- constants.O_WRONLY | constants.O_NOFOLLOW | constants.O_CREAT | constants.O_EXCL,
196
- stateFileMode(),
197
- );
198
- try {
199
- writeFileSync(fd, JSON.stringify(intent));
200
- fsyncSync(fd);
201
- } finally {
202
- closeSync(fd);
203
- }
204
- if (!create) renameSync(target, join(directory.path, MANIFEST));
205
- }
206
-
207
- function removeEmptyPrivateDirectory(directory: PrivateDirectoryRevision): void {
208
- if (!privateDirectoryMatches(directory)) throw new Error("unsafe_file_deletion_directory");
209
- unlinkSync(join(directory.path, MANIFEST));
210
- rmdirSync(directory.path);
211
- }
212
-
213
- function present(path: string): boolean {
214
- try {
215
- lstatSync(path);
216
- return true;
217
- } catch (error) {
218
- if (errorCode(error) === "ENOENT") return false;
219
- throw error;
220
- }
221
- }
222
-
223
- /**
224
- * Restore by a no-clobber hard link. A newer publication always wins its name;
225
- * an unresolved pin stays available as evidence and for restart recovery.
226
- */
227
- function restore(directory: PrivateDirectoryRevision, source: string): FileRecoveryResult {
228
- const pin = join(directory.path, PIN);
229
- const result = { recoveryPath: pin, sourcePath: source };
230
- try {
231
- if (!privateDirectoryMatches(directory) || !safePath(source)) {
232
- return { ...result, state: "retained", error: "unsafe_file_deletion_path" };
233
- }
234
- const pinned = captureFileRevision(pin);
235
- if (!pinned) return { ...result, state: "retained", error: "unsafe_file_deletion_pin" };
236
- try {
237
- linkSync(pin, source);
238
- } catch (error) {
239
- // A previous recovery may have linked successfully before it was killed.
240
- const current = captureFileRevision(source);
241
- if (errorCode(error) !== "EEXIST" || !current || !sameRevision(current, pinned)) {
242
- return { ...result, state: "retained", error: errorMessage(error) };
243
- }
244
- }
245
- if (!privateDirectoryMatches(directory)) {
246
- return { ...result, state: "retained", error: "unsafe_file_deletion_directory" };
247
- }
248
- unlinkSync(pin);
249
- removeEmptyPrivateDirectory(directory);
250
- return { ...result, state: "restored" };
251
- } catch (error) {
252
- return { ...result, state: "error", error: errorMessage(error) };
253
- }
254
- }
255
-
256
- /**
257
- * Writers must publish by atomic rename, never by an open descriptor to the old
258
- * inode. The private directory is outside their publication namespace. Moving
259
- * the row gives deletion custody over that inode without owning its live name.
260
- * The caller owns the state parent and keeps its topology stable. Its platform
261
- * permissions define which principals can modify it; this protocol does not
262
- * exclude principals already authorized to change that parent.
263
- */
264
- export function deleteFileIfUnchanged(
265
- filePath: string,
266
- expected: FileRevision,
267
- validate: (pinned: FileRevision) => boolean,
268
- ): DeleteFileResult {
269
- const path = safePath(filePath);
270
- if (
271
- !path ||
272
- path.split(/[\\/]/).some((part) => part.startsWith(PRIVATE_PREFIX)) ||
273
- typeof validate !== "function"
274
- ) {
275
- return { state: "rejected" };
276
- }
277
- const observed = { ...expected, bytes: Buffer.from(expected.bytes) };
278
- const before = captureFileRevision(path);
279
- if (!before) {
280
- try {
281
- return { state: present(path) ? "rejected" : "missing" };
282
- } catch (error) {
283
- return { state: "error", error: errorMessage(error) };
284
- }
285
- }
286
- if (!sameRevision(before, observed)) return { state: "changed" };
287
-
288
- let directory: PrivateDirectoryRevision | undefined;
289
- let moved = false;
290
- let intent: PinIntent | undefined;
291
- const retain = (state: "changed" | "rejected" | "error", error?: unknown): DeleteFileResult => {
292
- if (!directory || !intent) return { state: "error", error: errorMessage(error) };
293
- try {
294
- writeIntent(directory, { ...intent, state: "retained" }, false);
295
- } catch {
296
- // The still-active manifest remains recoverable when its process exits.
297
- }
298
- const recovered = restore(directory, path);
299
- if (recovered.state !== "restored") {
300
- return {
301
- state: "recovery-required",
302
- recoveryPath: recovered.recoveryPath,
303
- error: error === undefined ? recovered.error : errorMessage(error),
304
- };
305
- }
306
- return state === "error" ? { state, error: errorMessage(error) } : { state };
307
- };
308
- try {
309
- const privatePath = mkdtempSync(join(dirname(path), PRIVATE_PREFIX));
310
- directory = capturePrivateDirectory(privatePath) ?? undefined;
311
- if (!directory) throw new Error("unsafe_file_deletion_directory");
312
- intent = {
313
- version: 1,
314
- source: basename(path),
315
- ownerPid: process.pid,
316
- ownerHost: hostname(),
317
- ownerPlatform: process.platform,
318
- ownerProcessNamespace: processNamespace(),
319
- state: "active",
320
- };
321
- writeIntent(directory, intent, true);
322
- const pin = join(directory.path, PIN);
323
- renameSync(path, pin);
324
- moved = true;
325
- const pinned = captureFileRevision(pin);
326
- // rename may update ctime; the pre-move check included the observed ctime.
327
- if (!pinned || !sameRevision(pinned, observed, false)) return retain("changed");
328
- if (!validate({ ...pinned, bytes: Buffer.from(pinned.bytes) })) return retain("rejected");
329
- if (!privateDirectoryMatches(directory) || !fileRevisionMatches(pin, pinned) || present(path)) {
330
- return retain("changed");
331
- }
332
- unlinkSync(pin);
333
- moved = false;
334
- removeEmptyPrivateDirectory(directory);
335
- // A publication at the unlink boundary belongs to the writer, even though
336
- // the obsolete private inode was successfully removed.
337
- return { state: present(path) ? "changed" : "deleted" };
338
- } catch (error) {
339
- if (moved) return retain("error", error);
340
- if (directory && privateDirectoryMatches(directory)) {
341
- try {
342
- removeEmptyPrivateDirectory(directory);
343
- } catch {
344
- // Keep any unknown private contents rather than remove them recursively.
345
- }
346
- }
347
- return errorCode(error) === "ENOENT"
348
- ? { state: "missing" }
349
- : { state: "error", error: errorMessage(error) };
350
- }
351
- }
352
-
353
- /**
354
- * Recover same-host/platform/PID-namespace pins after process termination. File fsync
355
- * protects manifest contents; directory entries are not fsynced, so this does
356
- * not promise recovery after an OS crash or power loss.
357
- */
358
- export function recoverInterruptedFileDeletions(
359
- directoryPath: string,
360
- validate: (sourcePath: string) => boolean,
361
- ): FileRecoveryResult[] {
362
- const directory = resolve(directoryPath);
363
- if (typeof validate !== "function") return [];
364
- try {
365
- if (!sameCanonicalPath(realpathSync(directory), directory)) return [];
366
- const results: FileRecoveryResult[] = [];
367
- for (const entry of readdirSync(directory).sort()) {
368
- const privatePath = join(directory, entry);
369
- const privateRevision = capturePrivateDirectory(privatePath);
370
- if (!privateRevision) continue;
371
- const unknownPin = (): FileRecoveryResult => ({
372
- state: "error",
373
- recoveryPath: join(privatePath, PIN),
374
- error: "file_deletion_manifest_invalid",
375
- });
376
- const manifest = captureFileRevision(join(privatePath, MANIFEST));
377
- if (!manifest) {
378
- if (present(join(privatePath, PIN))) results.push(unknownPin());
379
- continue;
380
- }
381
- let intent: PinIntent;
382
- try {
383
- intent = JSON.parse(manifest.bytes.toString("utf8"));
384
- } catch {
385
- if (present(join(privatePath, PIN))) results.push(unknownPin());
386
- continue;
387
- }
388
- if (
389
- intent?.version !== 1 ||
390
- typeof intent.source !== "string" ||
391
- intent.source.length === 0 ||
392
- intent.source !== basename(intent.source) ||
393
- intent.source === "." ||
394
- intent.source === ".." ||
395
- intent.source.startsWith(PRIVATE_PREFIX) ||
396
- !Number.isSafeInteger(intent.ownerPid) ||
397
- intent.ownerPid <= 0 ||
398
- (intent.state !== "active" && intent.state !== "retained")
399
- ) {
400
- if (present(join(privatePath, PIN))) results.push(unknownPin());
401
- continue;
402
- }
403
- const source = join(directory, intent.source);
404
- const retained: FileRecoveryResult = {
405
- state: "retained",
406
- recoveryPath: join(privatePath, PIN),
407
- sourcePath: source,
408
- };
409
- if (
410
- typeof intent.ownerHost !== "string" ||
411
- !intent.ownerHost ||
412
- typeof intent.ownerPlatform !== "string" ||
413
- !intent.ownerPlatform ||
414
- typeof intent.ownerProcessNamespace !== "string" ||
415
- !intent.ownerProcessNamespace ||
416
- intent.ownerProcessNamespace === "unknown" ||
417
- processNamespace() === "unknown"
418
- ) {
419
- results.push({ ...retained, error: "file_deletion_owner_unknown" });
420
- continue;
421
- }
422
- if (
423
- intent.ownerHost !== hostname() ||
424
- intent.ownerPlatform !== process.platform ||
425
- intent.ownerProcessNamespace !== processNamespace()
426
- ) {
427
- results.push({ ...retained, error: "file_deletion_owner_foreign" });
428
- continue;
429
- }
430
- if (intent.state === "active") {
431
- try {
432
- process.kill(intent.ownerPid, 0);
433
- results.push({ ...retained, error: "file_deletion_owner_alive" });
434
- continue;
435
- } catch (error) {
436
- if (errorCode(error) !== "ESRCH") {
437
- results.push({ ...retained, error: "file_deletion_owner_unknown" });
438
- continue;
439
- }
440
- }
441
- }
442
- if (!validate(source)) {
443
- results.push({ ...retained, error: "file_deletion_recovery_rejected" });
444
- continue;
445
- }
446
- if (!present(join(privatePath, PIN))) {
447
- removeEmptyPrivateDirectory(privateRevision);
448
- continue;
449
- }
450
- results.push(restore(privateRevision, source));
451
- }
452
- return results;
453
- } catch (error) {
454
- return [{ state: "error", recoveryPath: directory, error: errorMessage(error) }];
455
- }
456
- }
@@ -1,59 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { hostname } from "node:os";
3
- import { join, resolve } from "node:path";
4
- import { acquireNoClobberLease } from "../../workflow/workspaces/leases.ts";
5
- import { readHookProducerStateByInstanceV3 } from "./producers/recorder.ts";
6
-
7
- const held = new Set<string>();
8
-
9
- /** Serialize synchronous authority work with generation changes for these instances only. */
10
- export function withGenerationCustodyV3<T>(
11
- coordRoot: string,
12
- instanceIds: readonly string[],
13
- operation: () => T,
14
- ): T {
15
- const ids = [...new Set(instanceIds)].sort();
16
- for (const id of ids) {
17
- if (!/^inst_[a-zA-Z0-9._-]{1,128}$/.test(id)) {
18
- throw new Error("generation custody instance is invalid");
19
- }
20
- }
21
- const enter = (index: number): T => {
22
- const id = ids[index];
23
- if (id === undefined) return operation();
24
- const key = `${resolve(coordRoot)}\0${id}`;
25
- if (held.has(key)) return enter(index + 1);
26
- const authority = createHash("sha256").update(key).digest("hex");
27
- const lease = acquireNoClobberLease({
28
- // Custody spans epoch rotation; the ledger directory itself is archived.
29
- path: join(coordRoot, ".harnery", "private", "event-v3-generation-custody", authority),
30
- scope: "event-v3-generation-custody",
31
- authoritySha256: authority,
32
- // Expiry never permits stealing a live/unknown owner. A dead local process
33
- // can be recovered immediately, including after a cache-handoff crash.
34
- staleAfterMs: 1,
35
- validateStaleOwner: (owner) =>
36
- owner.host === hostname() && owner.authority_sha256 === authority,
37
- });
38
- held.add(key);
39
- try {
40
- return enter(index + 1);
41
- } finally {
42
- held.delete(key);
43
- lease.release();
44
- }
45
- };
46
- return enter(0);
47
- }
48
-
49
- /** The event already durably binds the generation; replay must not rebind its actor. */
50
- export function assertCurrentGenerationV3(
51
- coordRoot: string,
52
- instanceId: string,
53
- generationId: string | undefined,
54
- ): void {
55
- const current = readHookProducerStateByInstanceV3(coordRoot, instanceId as `inst_${string}`);
56
- if (!generationId || !current || current.generation_id !== generationId) {
57
- throw new Error("authority_generation_changed");
58
- }
59
- }
@@ -1,141 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import type { AdapterRawResult } from "../adapters/types.ts";
3
- import type { SpawnFailureEvidence, SpawnOutputSource, SpawnResult } from "./types.ts";
4
-
5
- export function unknownFailureEvidence(): SpawnFailureEvidence {
6
- return {
7
- effect: { state: "unknown" },
8
- refusal: { state: "unknown" },
9
- partial_output: { state: "unknown" },
10
- };
11
- }
12
-
13
- /** Built-in subprocess adapters observe output and process-start failures.
14
- * Their current vendor envelopes do not prove effects or native refusal.
15
- * In particular, blank output and 429/5xx statuses leave both unknown. */
16
- export function observeSpawnFailure(
17
- raw: Pick<AdapterRawResult, "spawnErrno" | "timedOut"> &
18
- Partial<Pick<AdapterRawResult, "stdout" | "resultFileText">>,
19
- text: string,
20
- source: SpawnOutputSource,
21
- ): SpawnFailureEvidence {
22
- const bytes = Buffer.byteLength(text);
23
- return {
24
- effect:
25
- raw.spawnErrno === "ENOENT" &&
26
- !raw.timedOut &&
27
- bytes === 0 &&
28
- !raw.stdout?.trim() &&
29
- !raw.resultFileText?.trim()
30
- ? { state: "none", source: "spawn_errno", reference: "ENOENT" }
31
- : { state: "unknown" },
32
- refusal: { state: "unknown" },
33
- partial_output:
34
- bytes > 0
35
- ? {
36
- state: "present",
37
- source,
38
- bytes,
39
- sha256: createHash("sha256").update(text).digest("hex"),
40
- }
41
- : { state: "absent", source },
42
- };
43
- }
44
-
45
- function record(value: unknown): value is Record<string, unknown> {
46
- return typeof value === "object" && value !== null && !Array.isArray(value);
47
- }
48
-
49
- function keys(value: Record<string, unknown>, expected: readonly string[]): boolean {
50
- return (
51
- Object.keys(value).length === expected.length &&
52
- expected.every((key) => Object.hasOwn(value, key))
53
- );
54
- }
55
-
56
- function reference(value: unknown): value is string {
57
- return typeof value === "string" && value.trim().length > 0 && value.length <= 512;
58
- }
59
-
60
- function validEffect(value: unknown): boolean {
61
- if (!record(value)) return false;
62
- if (value.state === "unknown") return keys(value, ["state"]);
63
- if (!keys(value, ["state", "source", "reference"])) return false;
64
- if (value.state === "none" && value.source === "spawn_errno") {
65
- return value.reference === "ENOENT";
66
- }
67
- return (
68
- (value.state === "none" || value.state === "observed") &&
69
- value.source === "adapter_evidence" &&
70
- reference(value.reference)
71
- );
72
- }
73
-
74
- function validRefusal(value: unknown): boolean {
75
- if (!record(value)) return false;
76
- if (value.state === "unknown") return keys(value, ["state"]);
77
- return (
78
- keys(value, ["state", "source", "reference"]) &&
79
- value.state === "refused" &&
80
- value.source === "vendor_envelope" &&
81
- reference(value.reference)
82
- );
83
- }
84
-
85
- function validOutput(value: unknown): boolean {
86
- if (!record(value)) return false;
87
- if (value.state === "unknown") return keys(value, ["state"]);
88
- if (
89
- value.source !== "final_message" &&
90
- value.source !== "vendor_envelope" &&
91
- value.source !== "stdout"
92
- ) {
93
- return false;
94
- }
95
- if (value.state === "absent") return keys(value, ["state", "source"]);
96
- return (
97
- value.state === "present" &&
98
- keys(value, ["state", "source", "bytes", "sha256"]) &&
99
- Number.isSafeInteger(value.bytes) &&
100
- (value.bytes as number) > 0 &&
101
- typeof value.sha256 === "string" &&
102
- /^[a-f0-9]{64}$/.test(value.sha256)
103
- );
104
- }
105
-
106
- export function isSpawnFailureEvidence(value: unknown): value is SpawnFailureEvidence {
107
- return (
108
- record(value) &&
109
- keys(value, ["effect", "refusal", "partial_output"]) &&
110
- validEffect(value.effect) &&
111
- validRefusal(value.refusal) &&
112
- validOutput(value.partial_output)
113
- );
114
- }
115
-
116
- /** Missing observations are unknown; malformed positive claims are rejected.
117
- * This is also used when reading persisted workflow proof. */
118
- export function resolveFailureEvidence(value: unknown): SpawnFailureEvidence {
119
- if (value === undefined) return unknownFailureEvidence();
120
- if (!isSpawnFailureEvidence(value)) throw new Error("invalid spawn failure evidence");
121
- return value;
122
- }
123
-
124
- export function failureEvidenceAllowsUncharged(value: unknown): boolean {
125
- const evidence = resolveFailureEvidence(value);
126
- return (
127
- evidence.effect.state === "none" &&
128
- evidence.refusal.state !== "refused" &&
129
- evidence.partial_output.state === "absent"
130
- );
131
- }
132
-
133
- /** A provider retry requires positive no-effect evidence. A missing binary is
134
- * known not to have started, but retrying that unchanged environment cannot help. */
135
- export function canRetryFailedSpawn(result: SpawnResult): boolean {
136
- return (
137
- !result.ok &&
138
- result.class !== "environment" &&
139
- failureEvidenceAllowsUncharged(result.failureEvidence)
140
- );
141
- }