taskflow-core 0.2.9 → 0.3.0-beta.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 (130) hide show
  1. package/README.md +139 -355
  2. package/dist/agents.d.ts.map +1 -1
  3. package/dist/agents.js +3 -10
  4. package/dist/agents.js.map +1 -1
  5. package/dist/atomic-rename.d.ts +14 -0
  6. package/dist/atomic-rename.d.ts.map +1 -0
  7. package/dist/atomic-rename.js +35 -0
  8. package/dist/atomic-rename.js.map +1 -0
  9. package/dist/build-info.json +2 -2
  10. package/dist/detached-runner.js +4 -1
  11. package/dist/detached-runner.js.map +1 -1
  12. package/dist/discovery-boundary.d.ts +20 -0
  13. package/dist/discovery-boundary.d.ts.map +1 -0
  14. package/dist/discovery-boundary.js +101 -0
  15. package/dist/discovery-boundary.js.map +1 -0
  16. package/dist/effects/index.d.ts +9 -0
  17. package/dist/effects/index.d.ts.map +1 -0
  18. package/dist/effects/index.js +9 -0
  19. package/dist/effects/index.js.map +1 -0
  20. package/dist/effects/runtime-apply.d.ts +66 -0
  21. package/dist/effects/runtime-apply.d.ts.map +1 -0
  22. package/dist/effects/runtime-apply.js +214 -0
  23. package/dist/effects/runtime-apply.js.map +1 -0
  24. package/dist/effects/schema.d.ts +8 -0
  25. package/dist/effects/schema.d.ts.map +1 -0
  26. package/dist/effects/schema.js +41 -0
  27. package/dist/effects/schema.js.map +1 -0
  28. package/dist/effects/types.d.ts +93 -0
  29. package/dist/effects/types.d.ts.map +1 -0
  30. package/dist/effects/types.js +37 -0
  31. package/dist/effects/types.js.map +1 -0
  32. package/dist/effects/validate.d.ts +103 -0
  33. package/dist/effects/validate.d.ts.map +1 -0
  34. package/dist/effects/validate.js +543 -0
  35. package/dist/effects/validate.js.map +1 -0
  36. package/dist/effects/why.d.ts +94 -0
  37. package/dist/effects/why.d.ts.map +1 -0
  38. package/dist/effects/why.js +365 -0
  39. package/dist/effects/why.js.map +1 -0
  40. package/dist/exec/driver.d.ts +6 -0
  41. package/dist/exec/driver.d.ts.map +1 -1
  42. package/dist/exec/driver.js +3 -0
  43. package/dist/exec/driver.js.map +1 -1
  44. package/dist/exec/kernel-policy.d.ts.map +1 -1
  45. package/dist/exec/kernel-policy.js +4 -0
  46. package/dist/exec/kernel-policy.js.map +1 -1
  47. package/dist/exec/step-kinds.d.ts.map +1 -1
  48. package/dist/exec/step-kinds.js +10 -3
  49. package/dist/exec/step-kinds.js.map +1 -1
  50. package/dist/exec/step.d.ts +8 -0
  51. package/dist/exec/step.d.ts.map +1 -1
  52. package/dist/exec/step.js +23 -2
  53. package/dist/exec/step.js.map +1 -1
  54. package/dist/flowir/canonical-hash.d.ts.map +1 -1
  55. package/dist/flowir/canonical-hash.js +6 -2
  56. package/dist/flowir/canonical-hash.js.map +1 -1
  57. package/dist/flowir/compile.d.ts.map +1 -1
  58. package/dist/flowir/compile.js +42 -2
  59. package/dist/flowir/compile.js.map +1 -1
  60. package/dist/flowir/index.js +1 -1
  61. package/dist/flowir/index.js.map +1 -1
  62. package/dist/flowir/meta.d.ts +6 -0
  63. package/dist/flowir/meta.d.ts.map +1 -1
  64. package/dist/flowir/phasefp.d.ts.map +1 -1
  65. package/dist/flowir/phasefp.js +1 -0
  66. package/dist/flowir/phasefp.js.map +1 -1
  67. package/dist/flowir/schema.d.ts +9 -0
  68. package/dist/flowir/schema.d.ts.map +1 -1
  69. package/dist/flowir/schema.js +8 -0
  70. package/dist/flowir/schema.js.map +1 -1
  71. package/dist/flowir/translate.d.ts.map +1 -1
  72. package/dist/flowir/translate.js +32 -0
  73. package/dist/flowir/translate.js.map +1 -1
  74. package/dist/index.d.ts +1 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +2 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/library/search.d.ts.map +1 -1
  79. package/dist/library/search.js +2 -2
  80. package/dist/library/search.js.map +1 -1
  81. package/dist/resources/execution.d.ts +9 -0
  82. package/dist/resources/execution.d.ts.map +1 -1
  83. package/dist/resources/execution.js +80 -0
  84. package/dist/resources/execution.js.map +1 -1
  85. package/dist/resources/file-transaction.d.ts +105 -0
  86. package/dist/resources/file-transaction.d.ts.map +1 -0
  87. package/dist/resources/file-transaction.js +781 -0
  88. package/dist/resources/file-transaction.js.map +1 -0
  89. package/dist/resources/journal.d.ts +39 -4
  90. package/dist/resources/journal.d.ts.map +1 -1
  91. package/dist/resources/journal.js +140 -11
  92. package/dist/resources/journal.js.map +1 -1
  93. package/dist/resources/types.d.ts +4 -0
  94. package/dist/resources/types.d.ts.map +1 -1
  95. package/dist/resources/types.js.map +1 -1
  96. package/dist/resume.d.ts.map +1 -1
  97. package/dist/resume.js +2 -0
  98. package/dist/resume.js.map +1 -1
  99. package/dist/runtime/phases/script.d.ts +19 -0
  100. package/dist/runtime/phases/script.d.ts.map +1 -1
  101. package/dist/runtime/phases/script.js +31 -1
  102. package/dist/runtime/phases/script.js.map +1 -1
  103. package/dist/runtime.d.ts +14 -3
  104. package/dist/runtime.d.ts.map +1 -1
  105. package/dist/runtime.js +256 -29
  106. package/dist/runtime.js.map +1 -1
  107. package/dist/schema.d.ts +19 -0
  108. package/dist/schema.d.ts.map +1 -1
  109. package/dist/schema.js +34 -0
  110. package/dist/schema.js.map +1 -1
  111. package/dist/store.d.ts +21 -14
  112. package/dist/store.d.ts.map +1 -1
  113. package/dist/store.js +589 -113
  114. package/dist/store.js.map +1 -1
  115. package/dist/verifiers/discover.d.ts.map +1 -1
  116. package/dist/verifiers/discover.js +1 -17
  117. package/dist/verifiers/discover.js.map +1 -1
  118. package/dist/verifiers/effects-lint.d.ts +33 -0
  119. package/dist/verifiers/effects-lint.d.ts.map +1 -0
  120. package/dist/verifiers/effects-lint.js +77 -0
  121. package/dist/verifiers/effects-lint.js.map +1 -0
  122. package/dist/verifiers/index.d.ts +1 -0
  123. package/dist/verifiers/index.d.ts.map +1 -1
  124. package/dist/verifiers/index.js +3 -1
  125. package/dist/verifiers/index.js.map +1 -1
  126. package/dist/verify.d.ts +7 -1
  127. package/dist/verify.d.ts.map +1 -1
  128. package/dist/verify.js +3 -0
  129. package/dist/verify.js.map +1 -1
  130. package/package.json +1 -1
@@ -0,0 +1,781 @@
1
+ /**
2
+ * Resource-controlled file mutation transaction.
3
+ *
4
+ * This module is deliberately internal to `resources/execution.ts`: callers
5
+ * receive transactions only after host authority and PathRef resolution. It
6
+ * owns the data-plane sequence beneath that boundary:
7
+ *
8
+ * durable snapshots -> atomic multi-scope lease -> durable intent/permit
9
+ * -> stage -> promote -> commit-content | restore + abort-restored
10
+ *
11
+ * The local backend provides taskflow-exclusive isolation. It does not claim
12
+ * hostile external-process fencing; concurrent Taskflow writers are excluded
13
+ * by the persistent lease and unexpected external changes fail closed.
14
+ */
15
+ import * as crypto from "node:crypto";
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import { ensureDirectory, fsyncDirectory, writeJsonAtomicDurable } from "./persistence.js";
19
+ function hash(parts) {
20
+ const digest = crypto.createHash("sha256");
21
+ for (const part of parts)
22
+ digest.update(part);
23
+ return `sha256:${digest.digest("hex")}`;
24
+ }
25
+ function contentId(content) {
26
+ return content === undefined ? hash(["missing\0"]) : hash(["regular-file\0", content]);
27
+ }
28
+ function isWithin(root, candidate) {
29
+ const rel = path.relative(root, candidate);
30
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
31
+ }
32
+ /** Stage blob basenames must be path-safe; effect ids are attacker-influenced IR. */
33
+ function assertSafeEffectId(effectId) {
34
+ if (!effectId || effectId !== path.basename(effectId) || effectId === "." || effectId === "..") {
35
+ throw new Error(`TFWS_INVALID_EFFECT_ID: effect id is not a safe path segment: ${JSON.stringify(effectId)}`);
36
+ }
37
+ if (/[\\/]/.test(effectId) || (process.platform === "win32" && effectId.includes(":"))) {
38
+ throw new Error(`TFWS_INVALID_EFFECT_ID: effect id must not contain path separators: ${JSON.stringify(effectId)}`);
39
+ }
40
+ }
41
+ function stageBlobPath(stagingDirectory, effectId) {
42
+ assertSafeEffectId(effectId);
43
+ return path.join(stagingDirectory, "staged", `${effectId}-${crypto.randomUUID()}.blob`);
44
+ }
45
+ /** Re-validate containment for commit: no intermediate symlinks; parents stay in scope. */
46
+ function assertCommitContainment(snapshots, authorizationScopeRoot) {
47
+ const root = fs.realpathSync(authorizationScopeRoot);
48
+ for (const snapshot of snapshots) {
49
+ assertWritablePathInScope(snapshot.physicalPath, root);
50
+ }
51
+ }
52
+ /**
53
+ * Ensure each path segment from root→file is either missing or a real directory
54
+ * (never a symlink). Prevents prepare→commit TOCTOU where a body plants mid-path links.
55
+ */
56
+ function assertWritablePathInScope(filePath, authorizationRoot) {
57
+ const root = path.resolve(authorizationRoot);
58
+ const absolute = path.resolve(filePath);
59
+ if (!isWithin(root, path.dirname(absolute)) && path.dirname(absolute) !== root) {
60
+ // dirname must be inside root (file itself may be direct child)
61
+ const parent = path.dirname(absolute);
62
+ if (!isWithin(root, parent) && parent !== root) {
63
+ throw new Error(`TFWS_PATH_ESCAPE: parent ${parent} outside scope ${root}`);
64
+ }
65
+ }
66
+ const rel = path.relative(root, absolute);
67
+ if (rel.startsWith(`..`) || path.isAbsolute(rel)) {
68
+ throw new Error(`TFWS_PATH_ESCAPE: ${absolute} outside scope ${root}`);
69
+ }
70
+ const segments = rel.split(path.sep).filter(Boolean);
71
+ let current = root;
72
+ // Walk all parent segments (exclude final filename).
73
+ for (let i = 0; i < segments.length - 1; i++) {
74
+ current = path.join(current, segments[i]);
75
+ try {
76
+ const st = fs.lstatSync(current);
77
+ if (st.isSymbolicLink()) {
78
+ throw new Error(`TFWS_PATH_ESCAPE: intermediate symlink at ${current}`);
79
+ }
80
+ if (!st.isDirectory()) {
81
+ throw new Error(`TFWS_PATH_ESCAPE: intermediate path is not a directory: ${current}`);
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (error.code === "ENOENT") {
86
+ // Remaining segments will be created without following links (see ensureDirectoryNoFollow).
87
+ break;
88
+ }
89
+ throw error;
90
+ }
91
+ }
92
+ try {
93
+ const st = fs.lstatSync(absolute);
94
+ if (st.isSymbolicLink()) {
95
+ throw new Error(`TFWS_PATH_ESCAPE: target is a symlink: ${absolute}`);
96
+ }
97
+ }
98
+ catch (error) {
99
+ if (error.code !== "ENOENT")
100
+ throw error;
101
+ }
102
+ }
103
+ /** mkdir -p that refuses to traverse symlinks (segment-wise, lstat each existing). */
104
+ function ensureDirectoryNoFollow(directory, authorizationRoot) {
105
+ const root = path.resolve(authorizationRoot);
106
+ const absolute = path.resolve(directory);
107
+ if (!isWithin(root, absolute) && absolute !== root) {
108
+ throw new Error(`TFWS_PATH_ESCAPE: mkdir outside scope: ${absolute}`);
109
+ }
110
+ const rel = absolute === root ? "" : path.relative(root, absolute);
111
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
112
+ throw new Error(`TFWS_PATH_ESCAPE: mkdir outside scope: ${absolute}`);
113
+ }
114
+ let current = root;
115
+ if (rel) {
116
+ for (const seg of rel.split(path.sep).filter(Boolean)) {
117
+ current = path.join(current, seg);
118
+ try {
119
+ const st = fs.lstatSync(current);
120
+ if (st.isSymbolicLink())
121
+ throw new Error(`TFWS_PATH_ESCAPE: mkdir hits symlink ${current}`);
122
+ if (!st.isDirectory())
123
+ throw new Error(`TFWS_PATH_ESCAPE: mkdir hits non-dir ${current}`);
124
+ }
125
+ catch (error) {
126
+ if (error.code !== "ENOENT")
127
+ throw error;
128
+ fs.mkdirSync(current, { mode: 0o700 });
129
+ }
130
+ }
131
+ }
132
+ }
133
+ function nearestExistingAncestor(candidate) {
134
+ let current = path.dirname(candidate);
135
+ while (true) {
136
+ try {
137
+ const real = fs.realpathSync(current);
138
+ if (!fs.statSync(real).isDirectory())
139
+ throw new Error(`ancestor is not a directory: ${current}`);
140
+ return real;
141
+ }
142
+ catch (error) {
143
+ if (error.code !== "ENOENT")
144
+ throw error;
145
+ const parent = path.dirname(current);
146
+ if (parent === current)
147
+ throw new Error(`no existing ancestor for ${candidate}`);
148
+ current = parent;
149
+ }
150
+ }
151
+ }
152
+ function inspectRegularFile(filePath) {
153
+ try {
154
+ const stat = fs.lstatSync(filePath);
155
+ if (!stat.isFile())
156
+ throw new Error(`TFWS_INVALID_PATH: target is not a regular file: ${filePath}`);
157
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
158
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
159
+ try {
160
+ return { exists: true, content: fs.readFileSync(fd), mode: stat.mode & 0o777 };
161
+ }
162
+ finally {
163
+ fs.closeSync(fd);
164
+ }
165
+ }
166
+ catch (error) {
167
+ if (error.code === "ENOENT")
168
+ return { exists: false };
169
+ throw error;
170
+ }
171
+ }
172
+ function writeBufferDurable(filePath, content, mode = 0o600) {
173
+ ensureDirectory(path.dirname(filePath));
174
+ const fd = fs.openSync(filePath, "wx", mode);
175
+ try {
176
+ let offset = 0;
177
+ while (offset < content.length)
178
+ offset += fs.writeSync(fd, content, offset, content.length - offset);
179
+ fs.fsyncSync(fd);
180
+ }
181
+ finally {
182
+ fs.closeSync(fd);
183
+ }
184
+ fsyncDirectory(path.dirname(filePath));
185
+ }
186
+ function replaceFileAtomic(filePath, content, mode = 0o600, authorizationRoot) {
187
+ const parent = path.dirname(filePath);
188
+ if (authorizationRoot !== undefined)
189
+ ensureDirectoryNoFollow(parent, authorizationRoot);
190
+ else
191
+ ensureDirectory(parent);
192
+ const temp = path.join(parent, `.taskflow-write-${process.pid}-${crypto.randomUUID()}`);
193
+ try {
194
+ writeBufferDurable(temp, content, mode);
195
+ fs.renameSync(temp, filePath);
196
+ if (process.platform !== "win32")
197
+ fs.chmodSync(filePath, mode);
198
+ fsyncDirectory(parent);
199
+ }
200
+ catch (error) {
201
+ try {
202
+ fs.unlinkSync(temp);
203
+ }
204
+ catch { /* best effort; transaction restore remains authoritative */ }
205
+ throw error;
206
+ }
207
+ }
208
+ function pruneCreatedParents(snapshot) {
209
+ let current = path.dirname(snapshot.physicalPath);
210
+ while (current !== snapshot.nearestExistingAncestor && isWithin(snapshot.nearestExistingAncestor, current)) {
211
+ try {
212
+ fs.rmdirSync(current);
213
+ }
214
+ catch (error) {
215
+ if (error.code === "ENOENT") {
216
+ current = path.dirname(current);
217
+ continue;
218
+ }
219
+ break;
220
+ }
221
+ current = path.dirname(current);
222
+ }
223
+ }
224
+ function currentContentId(snapshot) {
225
+ const current = inspectRegularFile(snapshot.physicalPath);
226
+ return contentId(current.exists ? current.content : undefined);
227
+ }
228
+ function restoreSnapshot(snapshot) {
229
+ if (!snapshot.exists) {
230
+ try {
231
+ const stat = fs.lstatSync(snapshot.physicalPath);
232
+ if (stat.isDirectory())
233
+ throw new Error(`cannot restore over directory target: ${snapshot.physicalPath}`);
234
+ fs.unlinkSync(snapshot.physicalPath);
235
+ fsyncDirectory(path.dirname(snapshot.physicalPath));
236
+ }
237
+ catch (error) {
238
+ if (error.code !== "ENOENT")
239
+ throw error;
240
+ }
241
+ pruneCreatedParents(snapshot);
242
+ return;
243
+ }
244
+ if (!snapshot.blobPath || snapshot.mode === undefined)
245
+ throw new Error(`snapshot artifact is incomplete: ${snapshot.artifactId}`);
246
+ const bytes = fs.readFileSync(snapshot.blobPath);
247
+ if (contentId(bytes) !== snapshot.contentId)
248
+ throw new Error(`snapshot artifact hash mismatch: ${snapshot.artifactId}`);
249
+ replaceFileAtomic(snapshot.physicalPath, bytes, snapshot.mode);
250
+ }
251
+ function restoredEvidence(snapshots) {
252
+ return snapshots.map((snapshot) => ({
253
+ canonicalPrefix: snapshot.physicalPath,
254
+ scopeDigest: snapshot.scopeDigest,
255
+ effectId: snapshot.effectId,
256
+ capabilityBindingId: snapshot.capabilityBindingId,
257
+ beforeContentId: snapshot.contentId,
258
+ afterContentId: currentContentId(snapshot),
259
+ }));
260
+ }
261
+ function assertExactPreState(snapshots) {
262
+ for (const snapshot of snapshots) {
263
+ if (currentContentId(snapshot) !== snapshot.contentId) {
264
+ throw new Error(`declared final path '${snapshot.logicalSubpath}' changed outside the resource transaction`);
265
+ }
266
+ }
267
+ }
268
+ function assertExactPostState(snapshots, expected) {
269
+ for (const snapshot of snapshots) {
270
+ if (currentContentId(snapshot) !== expected.get(snapshot.effectId)) {
271
+ throw new Error(`post-state changed before durable commit for '${snapshot.logicalSubpath}'`);
272
+ }
273
+ }
274
+ }
275
+ async function releaseBestEffort(lease) {
276
+ for (let attempt = 0; attempt < 3; attempt++) {
277
+ try {
278
+ await lease.release();
279
+ return true;
280
+ }
281
+ catch {
282
+ // A terminal journal record must remain the operation result. Keep the
283
+ // authenticated handle for a later admission drain instead of making a
284
+ // committed mutation appear retryable.
285
+ }
286
+ }
287
+ return false;
288
+ }
289
+ function removeTransactionDirectoryBestEffort(transactionDirectory, label) {
290
+ try {
291
+ fs.rmSync(transactionDirectory, { recursive: true, force: true });
292
+ }
293
+ catch (error) {
294
+ console.warn(`[taskflow] resource transaction ${label} deferred for ${path.basename(transactionDirectory)}: ` +
295
+ (error instanceof Error ? error.message : String(error)));
296
+ }
297
+ }
298
+ function transactionIdFromArtifact(artifactId) {
299
+ return /^workspace-snapshot:([0-9a-f-]{36}):[0-9a-f]{64}$/i.exec(artifactId)?.[1];
300
+ }
301
+ const ORPHAN_TRANSACTION_GRACE_MS = 5 * 60_000;
302
+ /** Remove terminal and pre-intent orphan before-images while retaining any
303
+ * pending/dirty transaction needed for recovery or explicit reconciliation. */
304
+ export function garbageCollectResourceFileTransactions(controlDirectory, intents) {
305
+ const retained = new Set();
306
+ const terminal = new Set();
307
+ for (const intent of intents) {
308
+ for (const artifactId of intent.restorableSnapshotArtifactIds ?? []) {
309
+ const transactionId = transactionIdFromArtifact(artifactId);
310
+ if (!transactionId)
311
+ continue;
312
+ if (intent.status === "pending" || intent.status === "dirty-unknown")
313
+ retained.add(transactionId);
314
+ else
315
+ terminal.add(transactionId);
316
+ }
317
+ }
318
+ const root = path.join(controlDirectory, "file-transactions");
319
+ let entries;
320
+ try {
321
+ entries = fs.readdirSync(root);
322
+ }
323
+ catch (error) {
324
+ if (error.code === "ENOENT")
325
+ return;
326
+ console.warn(`[taskflow] resource transaction orphan GC deferred: ${error instanceof Error ? error.message : String(error)}`);
327
+ return;
328
+ }
329
+ for (const entry of entries) {
330
+ if (retained.has(entry))
331
+ continue;
332
+ if (!terminal.has(entry)) {
333
+ try {
334
+ const stat = fs.lstatSync(path.join(root, entry));
335
+ if (stat.mtimeMs > Date.now() - ORPHAN_TRANSACTION_GRACE_MS)
336
+ continue;
337
+ }
338
+ catch {
339
+ // A concurrently removed entry is already collected. Any other inspection
340
+ // failure stays fail-safe: the best-effort remove below may still decline.
341
+ }
342
+ }
343
+ removeTransactionDirectoryBestEffort(path.join(root, entry), "orphan GC");
344
+ }
345
+ }
346
+ function readManifestFile(filePath) {
347
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
348
+ const fd = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
349
+ try {
350
+ const stat = fs.fstatSync(fd);
351
+ if (!stat.isFile() || stat.size <= 0 || stat.size > 64 * 1024) {
352
+ throw new Error(`invalid snapshot manifest: ${filePath}`);
353
+ }
354
+ return JSON.parse(fs.readFileSync(fd, "utf8"));
355
+ }
356
+ finally {
357
+ fs.closeSync(fd);
358
+ }
359
+ }
360
+ function loadRecoverySnapshots(controlDirectory, intent) {
361
+ const authorityRoot = intent.authorizationScopeRoot;
362
+ if (!authorityRoot || !path.isAbsolute(authorityRoot)) {
363
+ throw new Error(`intent ${intent.intentId} lacks an authorized recovery root`);
364
+ }
365
+ const canonicalAuthorityRoot = fs.realpathSync(authorityRoot);
366
+ const artifacts = intent.restorableSnapshotArtifactIds;
367
+ if (!artifacts || artifacts.length !== intent.scopes.length) {
368
+ throw new Error(`intent ${intent.intentId} lacks an exact snapshot artifact set`);
369
+ }
370
+ const byEffect = new Map(intent.scopes.map((scope) => [scope.effectId, scope]));
371
+ if (byEffect.size !== intent.scopes.length || byEffect.has(undefined)) {
372
+ throw new Error(`intent ${intent.intentId} lacks unique effect attribution`);
373
+ }
374
+ const snapshots = [];
375
+ let expectedTransactionId;
376
+ for (const artifactId of artifacts) {
377
+ const match = /^workspace-snapshot:([0-9a-f-]{36}):([0-9a-f]{64})$/i.exec(artifactId);
378
+ if (!match)
379
+ throw new Error(`invalid snapshot artifact id: ${artifactId}`);
380
+ const [, transactionId, artifactStem] = match;
381
+ expectedTransactionId ??= transactionId;
382
+ if (transactionId !== expectedTransactionId)
383
+ throw new Error("snapshot artifacts span multiple transactions");
384
+ const transactionDirectory = path.join(controlDirectory, "file-transactions", transactionId);
385
+ const canonicalTransactionDirectory = fs.realpathSync(transactionDirectory);
386
+ if (!isWithin(fs.realpathSync(controlDirectory), canonicalTransactionDirectory)) {
387
+ throw new Error("snapshot transaction directory escapes trusted control storage");
388
+ }
389
+ const raw = readManifestFile(path.join(canonicalTransactionDirectory, `${artifactStem}.json`));
390
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
391
+ throw new Error(`invalid snapshot manifest ${artifactId}`);
392
+ const manifest = raw;
393
+ if (manifest.version !== 1 || manifest.artifactId !== artifactId ||
394
+ typeof manifest.effectId !== "string" || typeof manifest.physicalPath !== "string" ||
395
+ typeof manifest.logicalSubpath !== "string" || typeof manifest.exists !== "boolean" ||
396
+ typeof manifest.contentId !== "string" || typeof manifest.nearestExistingAncestor !== "string" ||
397
+ (manifest.mode !== undefined && (!Number.isInteger(manifest.mode) || Number(manifest.mode) < 0 || Number(manifest.mode) > 0o777)))
398
+ throw new Error(`invalid snapshot manifest ${artifactId}`);
399
+ const scope = byEffect.get(manifest.effectId);
400
+ if (!scope || path.normalize(manifest.physicalPath) !== scope.canonicalPrefix) {
401
+ throw new Error(`snapshot ${artifactId} does not match its durable intent scope`);
402
+ }
403
+ const physicalPath = path.normalize(manifest.physicalPath);
404
+ const ancestor = path.normalize(manifest.nearestExistingAncestor);
405
+ if (!isWithin(canonicalAuthorityRoot, physicalPath) ||
406
+ !isWithin(canonicalAuthorityRoot, ancestor) ||
407
+ !isWithin(ancestor, physicalPath)) {
408
+ throw new Error(`snapshot ${artifactId} escapes its authorized recovery root`);
409
+ }
410
+ const exists = manifest.exists;
411
+ const blobPath = exists ? path.join(canonicalTransactionDirectory, `${artifactStem}.blob`) : undefined;
412
+ const beforeBytes = exists ? fs.readFileSync(blobPath) : undefined;
413
+ if (contentId(beforeBytes) !== manifest.contentId ||
414
+ hash(["workspace-snapshot\0", physicalPath, "\0", String(manifest.contentId)]) !== `sha256:${artifactStem}`) {
415
+ throw new Error(`snapshot ${artifactId} failed content-address verification`);
416
+ }
417
+ snapshots.push({
418
+ version: 1,
419
+ artifactId,
420
+ effectId: manifest.effectId,
421
+ physicalPath,
422
+ logicalSubpath: manifest.logicalSubpath,
423
+ exists,
424
+ contentId: manifest.contentId,
425
+ ...(manifest.mode === undefined ? {} : { mode: Number(manifest.mode) }),
426
+ nearestExistingAncestor: ancestor,
427
+ ...(blobPath === undefined ? {} : { blobPath }),
428
+ capabilityBindingId: scope.capabilityBindingId ?? "",
429
+ scopeDigest: scope.scopeDigest,
430
+ });
431
+ }
432
+ return snapshots;
433
+ }
434
+ /** Restore a stale content intent while journal recovery holds its mutex. */
435
+ export async function recoverResourceFileIntent(options) {
436
+ const { intent } = options;
437
+ if (intent.commitMode !== "content-snapshot" || intent.externalMutation !== "taskflow-managed")
438
+ return undefined;
439
+ const snapshots = loadRecoverySnapshots(options.controlDirectory, intent);
440
+ const owner = {
441
+ runId: `recovery-${crypto.randomUUID()}`,
442
+ phaseId: "resource-recovery",
443
+ attemptId: crypto.randomUUID(),
444
+ unitId: intent.intentId,
445
+ ancestry: [],
446
+ };
447
+ const lease = await options.leases.acquire(intent.scopes.map((scope) => ({
448
+ key: { resourceDomainId: intent.resourceDomainId, canonicalPrefix: scope.canonicalPrefix },
449
+ access: "read-write",
450
+ owner,
451
+ })), { timeoutMs: options.leaseTimeoutMs, signal: options.signal });
452
+ try {
453
+ for (const snapshot of snapshots) {
454
+ const currentAncestor = nearestExistingAncestor(snapshot.physicalPath);
455
+ if (!isWithin(intent.authorizationScopeRoot, currentAncestor)) {
456
+ throw new Error(`recovery parent for '${snapshot.logicalSubpath}' escaped its authorized root`);
457
+ }
458
+ }
459
+ for (const snapshot of [...snapshots].reverse())
460
+ restoreSnapshot(snapshot);
461
+ const scopes = restoredEvidence(snapshots);
462
+ if (scopes.some((scope) => scope.afterContentId !== scope.beforeContentId)) {
463
+ throw new Error("crash recovery did not reproduce the durable pre-state");
464
+ }
465
+ return {
466
+ scopes,
467
+ reason: "startup recovery restored durable file-transaction pre-state",
468
+ restorableSnapshotArtifactIds: snapshots.map((snapshot) => snapshot.artifactId),
469
+ };
470
+ }
471
+ finally {
472
+ if (!(await releaseBestEffort(lease))) {
473
+ console.warn(`[taskflow] resource recovery lease cleanup deferred for lease ${lease.leaseId}`);
474
+ }
475
+ }
476
+ }
477
+ export class PreparedResourceFileTransaction {
478
+ intentId;
479
+ #snapshots;
480
+ #mutation;
481
+ #lease;
482
+ #journal;
483
+ #stagingDirectory;
484
+ #onDeferredLeaseRelease;
485
+ #cleanupStaging;
486
+ #settled = false;
487
+ #busy = false;
488
+ #authorizationScopeRoot;
489
+ #knownCleanTerminal = false;
490
+ constructor(input) {
491
+ this.intentId = input.mutation.intent.intentId;
492
+ this.#snapshots = input.snapshots;
493
+ this.#mutation = input.mutation;
494
+ this.#lease = input.lease;
495
+ this.#journal = input.journal;
496
+ this.#stagingDirectory = input.stagingDirectory;
497
+ this.#onDeferredLeaseRelease = input.onDeferredLeaseRelease;
498
+ this.#authorizationScopeRoot = input.authorizationScopeRoot;
499
+ this.#cleanupStaging = input.cleanupStaging ?? ((stagingDirectory) => {
500
+ fs.rmSync(path.join(stagingDirectory, "staged"), { recursive: true, force: true });
501
+ });
502
+ }
503
+ async commit(payloads) {
504
+ this.#assertOpen();
505
+ this.#enterBusy();
506
+ try {
507
+ return await this.#commitLocked(payloads);
508
+ }
509
+ finally {
510
+ this.#leaveBusy();
511
+ }
512
+ }
513
+ async #commitLocked(payloads) {
514
+ const byId = new Map(payloads.map((payload) => [payload.effectId, payload]));
515
+ if (byId.size !== this.#snapshots.length || this.#snapshots.some((snapshot) => !byId.has(snapshot.effectId))) {
516
+ return this.#rejectAndFinish("commit-rejected", "payload ids do not exactly match the admitted effects");
517
+ }
518
+ try {
519
+ assertExactPreState(this.#snapshots);
520
+ assertCommitContainment(this.#snapshots, this.#authorizationScopeRoot);
521
+ }
522
+ catch (error) {
523
+ return this.#rejectAndFinish("declared-path-bypass", error instanceof Error ? error.message : String(error));
524
+ }
525
+ const expected = new Map();
526
+ try {
527
+ for (const snapshot of this.#snapshots) {
528
+ const payload = byId.get(snapshot.effectId);
529
+ const bytes = typeof payload.content === "string" ? Buffer.from(payload.content, "utf8") : payload.content;
530
+ const stagedPath = stageBlobPath(this.#stagingDirectory, snapshot.effectId);
531
+ writeBufferDurable(stagedPath, bytes);
532
+ expected.set(snapshot.effectId, contentId(bytes));
533
+ }
534
+ for (const snapshot of this.#snapshots) {
535
+ await this.#journal.assertActive(this.#mutation.permit, this.#mutation.intent.owner);
536
+ const payload = byId.get(snapshot.effectId);
537
+ const bytes = typeof payload.content === "string" ? Buffer.from(payload.content, "utf8") : payload.content;
538
+ replaceFileAtomic(snapshot.physicalPath, bytes, snapshot.mode ?? 0o600, this.#authorizationScopeRoot);
539
+ }
540
+ const evidence = this.#snapshots.map((snapshot) => ({
541
+ canonicalPrefix: snapshot.physicalPath,
542
+ scopeDigest: snapshot.scopeDigest,
543
+ effectId: snapshot.effectId,
544
+ capabilityBindingId: snapshot.capabilityBindingId,
545
+ beforeContentId: snapshot.contentId,
546
+ afterContentId: expected.get(snapshot.effectId),
547
+ }));
548
+ const committed = await this.#journal.commitContent(this.intentId, evidence, this.#snapshots.map((snapshot) => snapshot.artifactId), { preCommitGuard: () => assertExactPostState(this.#snapshots, expected) });
549
+ this.#settled = true;
550
+ this.#knownCleanTerminal = true;
551
+ return {
552
+ ok: true,
553
+ intentId: this.intentId,
554
+ committedPaths: this.#snapshots.map((snapshot) => snapshot.logicalSubpath),
555
+ commitGeneration: committed.commitGeneration,
556
+ };
557
+ }
558
+ catch (error) {
559
+ return await this.#rejectAndRestore("commit-rejected", error instanceof Error ? error.message : String(error));
560
+ }
561
+ finally {
562
+ await this.#finish();
563
+ }
564
+ }
565
+ async reject(reason) {
566
+ this.#assertOpen();
567
+ this.#enterBusy();
568
+ try {
569
+ try {
570
+ return await this.#rejectAndRestore("commit-rejected", reason || "phase rejected");
571
+ }
572
+ finally {
573
+ await this.#finish();
574
+ }
575
+ }
576
+ finally {
577
+ this.#leaveBusy();
578
+ }
579
+ }
580
+ async #rejectAndRestore(code, reason) {
581
+ try {
582
+ for (const snapshot of [...this.#snapshots].reverse())
583
+ restoreSnapshot(snapshot);
584
+ const evidence = restoredEvidence(this.#snapshots);
585
+ if (evidence.some((scope) => scope.afterContentId !== scope.beforeContentId)) {
586
+ throw new Error("restored filesystem state does not match the durable pre-state snapshot");
587
+ }
588
+ await this.#journal.abortRestored(this.intentId, evidence, reason, this.#snapshots.map((snapshot) => snapshot.artifactId));
589
+ this.#settled = true;
590
+ this.#knownCleanTerminal = true;
591
+ return { ok: false, intentId: this.intentId, code, reason, restored: true };
592
+ }
593
+ catch (restoreError) {
594
+ const current = await this.#journal.getIntent(this.intentId);
595
+ if (current?.status === "pending") {
596
+ await this.#journal.markUnknown(this.intentId, `restore failed after ${reason}: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`);
597
+ }
598
+ this.#settled = true;
599
+ throw restoreError;
600
+ }
601
+ }
602
+ async #rejectAndFinish(code, reason) {
603
+ try {
604
+ return await this.#rejectAndRestore(code, reason);
605
+ }
606
+ finally {
607
+ await this.#finish();
608
+ }
609
+ }
610
+ #assertOpen() {
611
+ if (this.#settled)
612
+ throw new Error(`resource file transaction ${this.intentId} is already settled`);
613
+ }
614
+ #enterBusy() {
615
+ if (this.#busy)
616
+ throw new Error(`resource file transaction ${this.intentId} is already in progress`);
617
+ this.#busy = true;
618
+ }
619
+ #leaveBusy() {
620
+ this.#busy = false;
621
+ }
622
+ async #finish() {
623
+ try {
624
+ try {
625
+ this.#cleanupStaging(this.#stagingDirectory);
626
+ }
627
+ catch (error) {
628
+ console.warn(`[taskflow] resource transaction staging cleanup deferred for ${this.intentId}: ` +
629
+ (error instanceof Error ? error.message : String(error)));
630
+ }
631
+ if (this.#knownCleanTerminal) {
632
+ removeTransactionDirectoryBestEffort(this.#stagingDirectory, "snapshot GC");
633
+ }
634
+ }
635
+ finally {
636
+ if (!(await releaseBestEffort(this.#lease))) {
637
+ try {
638
+ this.#onDeferredLeaseRelease?.(this.#lease);
639
+ }
640
+ catch (error) {
641
+ console.warn(`[taskflow] resource transaction deferred lease release callback failed for ${this.#lease.leaseId}: ` +
642
+ (error instanceof Error ? error.message : String(error)));
643
+ }
644
+ console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${this.#lease.leaseId}`);
645
+ }
646
+ }
647
+ }
648
+ }
649
+ export async function prepareResourceFileTransaction(options) {
650
+ if (options.targets.length === 0)
651
+ throw new Error("a resource file transaction requires at least one target");
652
+ const seenEffects = new Set();
653
+ const seenPaths = new Set();
654
+ for (const target of options.targets) {
655
+ if (!target.effectId || seenEffects.has(target.effectId))
656
+ throw new Error(`duplicate or empty effect id: ${target.effectId}`);
657
+ assertSafeEffectId(target.effectId);
658
+ if (target.ref.capability.resourceDomainId !== options.resourceDomainId) {
659
+ throw new Error(`TFWS_ACCESS_ESCALATION: effect '${target.effectId}' resolved to a foreign resource domain`);
660
+ }
661
+ if (target.ref.capability.access !== "read-write") {
662
+ throw new Error(`TFWS_ACCESS_ESCALATION: effect '${target.effectId}' lacks read-write capability`);
663
+ }
664
+ if (seenPaths.has(target.ref.physicalPath))
665
+ throw new Error(`duplicate file target: ${target.ref.logicalSubpath}`);
666
+ seenEffects.add(target.effectId);
667
+ seenPaths.add(target.ref.physicalPath);
668
+ }
669
+ let lease;
670
+ let mutation;
671
+ const transactionId = crypto.randomUUID();
672
+ const transactionDirectory = path.join(options.controlDirectory, "file-transactions", transactionId);
673
+ try {
674
+ lease = await options.leases.acquire(options.targets.map((target) => ({
675
+ key: { resourceDomainId: options.resourceDomainId, canonicalPrefix: target.ref.physicalPath },
676
+ access: "read-write",
677
+ owner: options.owner,
678
+ })), {
679
+ timeoutMs: options.leaseTimeoutMs,
680
+ signal: options.signal,
681
+ });
682
+ ensureDirectory(transactionDirectory);
683
+ const snapshots = [];
684
+ for (const target of options.targets) {
685
+ const inspected = inspectRegularFile(target.ref.physicalPath);
686
+ const before = inspected.exists ? inspected.content : undefined;
687
+ const beforeId = contentId(before);
688
+ const artifactHash = hash(["workspace-snapshot\0", target.ref.physicalPath, "\0", beforeId]);
689
+ const artifactStem = artifactHash.slice("sha256:".length);
690
+ const artifactId = `workspace-snapshot:${transactionId}:${artifactStem}`;
691
+ const blobPath = inspected.exists ? path.join(transactionDirectory, `${artifactStem}.blob`) : undefined;
692
+ if (inspected.exists && blobPath)
693
+ writeBufferDurable(blobPath, inspected.content);
694
+ const snapshot = {
695
+ version: 1,
696
+ artifactId,
697
+ effectId: target.effectId,
698
+ physicalPath: target.ref.physicalPath,
699
+ logicalSubpath: target.ref.logicalSubpath,
700
+ exists: inspected.exists,
701
+ contentId: beforeId,
702
+ ...(inspected.exists ? { mode: inspected.mode } : {}),
703
+ nearestExistingAncestor: nearestExistingAncestor(target.ref.physicalPath),
704
+ ...(blobPath ? { blobPath } : {}),
705
+ capabilityBindingId: target.ref.capability.bindingId,
706
+ scopeDigest: hash([
707
+ options.resourceDomainId,
708
+ "\0",
709
+ target.ref.capability.bindingId,
710
+ "\0",
711
+ target.ref.logicalSubpath,
712
+ ]),
713
+ };
714
+ writeJsonAtomicDurable(path.join(transactionDirectory, `${artifactStem}.json`), {
715
+ version: snapshot.version,
716
+ artifactId: snapshot.artifactId,
717
+ effectId: snapshot.effectId,
718
+ physicalPath: snapshot.physicalPath,
719
+ logicalSubpath: snapshot.logicalSubpath,
720
+ exists: snapshot.exists,
721
+ contentId: snapshot.contentId,
722
+ ...(snapshot.mode === undefined ? {} : { mode: snapshot.mode }),
723
+ nearestExistingAncestor: snapshot.nearestExistingAncestor,
724
+ ...(snapshot.blobPath === undefined ? {} : { blobPath: snapshot.blobPath }),
725
+ });
726
+ snapshots.push(snapshot);
727
+ }
728
+ mutation = await options.journal.prepare({
729
+ resourceDomainId: options.resourceDomainId,
730
+ providerInstanceId: "root",
731
+ scopes: snapshots.map((snapshot) => ({
732
+ canonicalPrefix: snapshot.physicalPath,
733
+ scopeDigest: snapshot.scopeDigest,
734
+ effectId: snapshot.effectId,
735
+ capabilityBindingId: snapshot.capabilityBindingId,
736
+ beforeContentId: snapshot.contentId,
737
+ })),
738
+ owner: options.owner,
739
+ commitMode: "content-snapshot",
740
+ externalMutation: "taskflow-managed",
741
+ restorableSnapshotArtifactIds: snapshots.map((snapshot) => snapshot.artifactId),
742
+ authorizationPrincipalId: options.authorizationPrincipalId,
743
+ authorizationScopeRoot: options.authorizationScopeRoot,
744
+ permitTtlMs: options.permitTtlMs,
745
+ });
746
+ await options.journal.activate([mutation.permit], options.owner);
747
+ return new PreparedResourceFileTransaction({
748
+ snapshots,
749
+ mutation,
750
+ lease,
751
+ journal: options.journal,
752
+ stagingDirectory: transactionDirectory,
753
+ onDeferredLeaseRelease: options.onDeferredLeaseRelease,
754
+ authorizationScopeRoot: options.authorizationScopeRoot,
755
+ cleanupStaging: options.cleanupStaging,
756
+ });
757
+ }
758
+ catch (error) {
759
+ let journalError;
760
+ try {
761
+ if (mutation) {
762
+ const current = await options.journal.getIntent(mutation.intent.intentId);
763
+ if (current?.status === "pending")
764
+ await options.journal.markUnknown(current.intentId, "file transaction preparation failed");
765
+ }
766
+ }
767
+ catch (cleanupError) {
768
+ journalError = cleanupError;
769
+ }
770
+ finally {
771
+ if (lease && !(await releaseBestEffort(lease))) {
772
+ options.onDeferredLeaseRelease?.(lease);
773
+ console.warn(`[taskflow] resource transaction lease cleanup deferred for lease ${lease.leaseId}`);
774
+ }
775
+ if (!mutation)
776
+ removeTransactionDirectoryBestEffort(transactionDirectory, "pre-intent GC");
777
+ }
778
+ throw journalError ?? error;
779
+ }
780
+ }
781
+ //# sourceMappingURL=file-transaction.js.map