taskflow-core 0.1.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.
Files changed (115) hide show
  1. package/dist/agents/analyst.md +30 -0
  2. package/dist/agents/critic.md +31 -0
  3. package/dist/agents/doc-writer.md +43 -0
  4. package/dist/agents/executor-code.md +36 -0
  5. package/dist/agents/executor-fast.md +26 -0
  6. package/dist/agents/executor-ui.md +35 -0
  7. package/dist/agents/executor.md +29 -0
  8. package/dist/agents/final-arbiter.md +29 -0
  9. package/dist/agents/plan-arbiter.md +35 -0
  10. package/dist/agents/planner.md +30 -0
  11. package/dist/agents/recover.md +28 -0
  12. package/dist/agents/reviewer.md +37 -0
  13. package/dist/agents/risk-reviewer.md +37 -0
  14. package/dist/agents/scout.md +51 -0
  15. package/dist/agents/security-reviewer.md +39 -0
  16. package/dist/agents/test-engineer.md +31 -0
  17. package/dist/agents/verifier.md +29 -0
  18. package/dist/agents/visual-explorer.md +32 -0
  19. package/dist/agents.d.ts +55 -0
  20. package/dist/agents.d.ts.map +1 -0
  21. package/dist/agents.js +265 -0
  22. package/dist/agents.js.map +1 -0
  23. package/dist/cache.d.ts +57 -0
  24. package/dist/cache.d.ts.map +1 -0
  25. package/dist/cache.js +256 -0
  26. package/dist/cache.js.map +1 -0
  27. package/dist/compile.d.ts +37 -0
  28. package/dist/compile.d.ts.map +1 -0
  29. package/dist/compile.js +324 -0
  30. package/dist/compile.js.map +1 -0
  31. package/dist/context-store.d.ts +142 -0
  32. package/dist/context-store.d.ts.map +1 -0
  33. package/dist/context-store.js +383 -0
  34. package/dist/context-store.js.map +1 -0
  35. package/dist/detached-runner.d.ts +12 -0
  36. package/dist/detached-runner.d.ts.map +1 -0
  37. package/dist/detached-runner.js +68 -0
  38. package/dist/detached-runner.js.map +1 -0
  39. package/dist/flowir/hash.d.ts +51 -0
  40. package/dist/flowir/hash.d.ts.map +1 -0
  41. package/dist/flowir/hash.js +90 -0
  42. package/dist/flowir/hash.js.map +1 -0
  43. package/dist/flowir/index.d.ts +43 -0
  44. package/dist/flowir/index.d.ts.map +1 -0
  45. package/dist/flowir/index.js +62 -0
  46. package/dist/flowir/index.js.map +1 -0
  47. package/dist/flowir/meta.d.ts +103 -0
  48. package/dist/flowir/meta.d.ts.map +1 -0
  49. package/dist/flowir/meta.js +19 -0
  50. package/dist/flowir/meta.js.map +1 -0
  51. package/dist/flowir/phasefp.d.ts +55 -0
  52. package/dist/flowir/phasefp.d.ts.map +1 -0
  53. package/dist/flowir/phasefp.js +123 -0
  54. package/dist/flowir/phasefp.js.map +1 -0
  55. package/dist/flowir/translate.d.ts +37 -0
  56. package/dist/flowir/translate.d.ts.map +1 -0
  57. package/dist/flowir/translate.js +137 -0
  58. package/dist/flowir/translate.js.map +1 -0
  59. package/dist/frontmatter.d.ts +21 -0
  60. package/dist/frontmatter.d.ts.map +1 -0
  61. package/dist/frontmatter.js +107 -0
  62. package/dist/frontmatter.js.map +1 -0
  63. package/dist/host/runner-types.d.ts +98 -0
  64. package/dist/host/runner-types.d.ts.map +1 -0
  65. package/dist/host/runner-types.js +21 -0
  66. package/dist/host/runner-types.js.map +1 -0
  67. package/dist/index.d.ts +26 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +30 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/interpolate.d.ts +52 -0
  72. package/dist/interpolate.d.ts.map +1 -0
  73. package/dist/interpolate.js +418 -0
  74. package/dist/interpolate.js.map +1 -0
  75. package/dist/paths.d.ts +23 -0
  76. package/dist/paths.d.ts.map +1 -0
  77. package/dist/paths.js +43 -0
  78. package/dist/paths.js.map +1 -0
  79. package/dist/runner-core.d.ts +49 -0
  80. package/dist/runner-core.d.ts.map +1 -0
  81. package/dist/runner-core.js +208 -0
  82. package/dist/runner-core.js.map +1 -0
  83. package/dist/runtime.d.ts +234 -0
  84. package/dist/runtime.d.ts.map +1 -0
  85. package/dist/runtime.js +2284 -0
  86. package/dist/runtime.js.map +1 -0
  87. package/dist/schema.d.ts +257 -0
  88. package/dist/schema.d.ts.map +1 -0
  89. package/dist/schema.js +795 -0
  90. package/dist/schema.js.map +1 -0
  91. package/dist/stale.d.ts +72 -0
  92. package/dist/stale.d.ts.map +1 -0
  93. package/dist/stale.js +179 -0
  94. package/dist/stale.js.map +1 -0
  95. package/dist/store.d.ts +237 -0
  96. package/dist/store.d.ts.map +1 -0
  97. package/dist/store.js +761 -0
  98. package/dist/store.js.map +1 -0
  99. package/dist/typebox-helpers.d.ts +15 -0
  100. package/dist/typebox-helpers.d.ts.map +1 -0
  101. package/dist/typebox-helpers.js +19 -0
  102. package/dist/typebox-helpers.js.map +1 -0
  103. package/dist/usage.d.ts +21 -0
  104. package/dist/usage.d.ts.map +1 -0
  105. package/dist/usage.js +33 -0
  106. package/dist/usage.js.map +1 -0
  107. package/dist/verify.d.ts +38 -0
  108. package/dist/verify.d.ts.map +1 -0
  109. package/dist/verify.js +324 -0
  110. package/dist/verify.js.map +1 -0
  111. package/dist/workspace.d.ts +64 -0
  112. package/dist/workspace.d.ts.map +1 -0
  113. package/dist/workspace.js +178 -0
  114. package/dist/workspace.js.map +1 -0
  115. package/package.json +40 -0
package/dist/store.js ADDED
@@ -0,0 +1,761 @@
1
+ /**
2
+ * Persistence for taskflow definitions and run state.
3
+ *
4
+ * Definitions: .pi/taskflows/<name>.json (project)
5
+ * ~/.pi/agent/taskflows/<name>.json (user)
6
+ * Run state: .pi/taskflows/runs/<sanitizedFlowName>/<runId>.json
7
+ * Index: .pi/taskflows/runs/index.json (lookup accelerator)
8
+ *
9
+ * Legacy layout (v0.0.8 and earlier):
10
+ * .pi/taskflows/runs/<runId>.json (flat, still readable)
11
+ *
12
+ * v0.0.9 refactor: per-flow subdirectory layout + lightweight index + file
13
+ * lock + TTL/cap cleanup. Full backward compatibility with the flat layout
14
+ * is maintained: loadRun and listRuns still discover legacy flat files.
15
+ */
16
+ import * as crypto from "node:crypto";
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import { getAgentDir } from "./paths.js";
21
+ // ---------------------------------------------------------------------------
22
+ // File-lock constants
23
+ // ---------------------------------------------------------------------------
24
+ /** Lock file considered stale after 30 s (orphaned from crash / kill -9). */
25
+ const LOCK_STALE_MS = 30_000;
26
+ /** Lock acquisition busy-wait interval. */
27
+ const LOCK_POLL_MS = 50;
28
+ /** Default acquisition timeout before throwing. */
29
+ const LOCK_TIMEOUT_MS = 10_000;
30
+ // ---------------------------------------------------------------------------
31
+ // Cleanup throttle
32
+ // ---------------------------------------------------------------------------
33
+ /** Minimum ms between opportunistic cleanup runs (called inside saveRun). */
34
+ const CLEANUP_INTERVAL_MS = 60_000;
35
+ /** Retain at most this many terminal runs by default. */
36
+ const DEFAULT_MAX_KEPT_TERMINAL = 100;
37
+ /** Remove terminal runs older than this (days). */
38
+ const DEFAULT_MAX_AGE_DAYS = 30;
39
+ // Re-exported for use in TaskflowSettings defaults (agents.ts).
40
+ export const DEFAULT_KEPT_RUNS = DEFAULT_MAX_KEPT_TERMINAL;
41
+ export const DEFAULT_RUN_AGE_DAYS = DEFAULT_MAX_AGE_DAYS;
42
+ /** Last cleanup timestamp — module-level so it persists across calls. */
43
+ let lastCleanupAt = 0;
44
+ /** Shared buffer for Atomics.wait in acquireLock busy-wait (Finding 6). */
45
+ const LOCK_WAIT_BUF = new Int32Array(new SharedArrayBuffer(4));
46
+ // ---------------------------------------------------------------------------
47
+ // Internal helpers — path construction & sanitisation
48
+ // ---------------------------------------------------------------------------
49
+ /**
50
+ * Sanitise a flow name into a safe directory name. Same regex used by
51
+ * saveFlow/newRunId — but that regex keeps `.` in its allow-list, so a
52
+ * flowName of "." or ".." would pass through unchanged and let `flowRunDir`
53
+ * resolve OUTSIDE the runs root (write-side path traversal). `def.name` is
54
+ * internally derived and TypeBox only enforces Type.String() with no charset,
55
+ * so a Taskflow literally named ".." is schema-valid. We therefore reject
56
+ * bare-dot / leading-dot components after the character substitution so the
57
+ * write path can never escape runs/ (risk-reviewer v0.0.9 audit, H1).
58
+ */
59
+ export function safeFlowDirName(flowName) {
60
+ let safe = flowName.replace(/[^\w.-]+/g, "_");
61
+ // Collapse leading dots: blocks ".", "..", and hidden-dir names like ".git".
62
+ safe = safe.replace(/^\.+/, "_");
63
+ return safe || "_";
64
+ }
65
+ /** Return the per-flow run directory: runs/<sanitisedFlowName>. */
66
+ function flowRunDir(runsRoot, flowName) {
67
+ return path.join(runsRoot, safeFlowDirName(flowName));
68
+ }
69
+ /** Return the full path for a run file in the new subdirectory layout. */
70
+ function runFilePath(runsRoot, flowName, runId) {
71
+ return path.join(flowRunDir(runsRoot, flowName), `${runId}.json`);
72
+ }
73
+ /** Return the path to the run index file. */
74
+ function indexPath(runsRoot) {
75
+ return path.join(runsRoot, "index.json");
76
+ }
77
+ /** Return the lock-file path guarding all index.json read-modify-write cycles. */
78
+ function indexLockPath(runsRoot) {
79
+ return path.join(runsRoot, "index.json.lock");
80
+ }
81
+ /** Return the lock-file path for a given runId (placed next to the run file). */
82
+ function lockPathForRun(runsRoot, flowName, runId) {
83
+ return path.join(flowRunDir(runsRoot, flowName), `${runId}.json.lock`);
84
+ }
85
+ /**
86
+ * Validate that a runId looks safe before performing any filesystem access.
87
+ * Legitimate runIds are produced by newRunId() and contain only [A-Za-z0-9._-].
88
+ */
89
+ export function validateRunId(runId) {
90
+ return (typeof runId === "string" &&
91
+ runId.length > 0 &&
92
+ !runId.includes("/") &&
93
+ !runId.includes("\\") &&
94
+ !runId.includes("\0") &&
95
+ !runId.includes(".."));
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // File-lock primitives — zero-dependency, using O_CREAT|O_EXCL (atomic)
99
+ // ---------------------------------------------------------------------------
100
+ /**
101
+ * Acquire a file lock by atomically creating a lock file.
102
+ *
103
+ * Uses O_CREAT|O_EXCL (`wx` flag) which is atomic on POSIX and NTFS.
104
+ * Stale locks (> LOCK_STALE_MS) are stolen via an atomic rename rather than a
105
+ * naive unlink-then-create: a plain `unlinkSync` + `openSync('wx')` has a
106
+ * TOCTOU window where two processes both unlink the same stale lock and both
107
+ * then create a fresh one, yielding two simultaneous holders (risk-reviewer
108
+ * v0.0.9 audit, L1). `rename` is atomic and removes the *specific* inode the
109
+ * caller observed: only one racing process can win the rename of that exact
110
+ * stale file, so at most one process proceeds to re-create the lock.
111
+ * Throws on timeout.
112
+ */
113
+ function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
114
+ const start = Date.now();
115
+ // Ensure parent directory exists (lock file lives inside the flow subdir).
116
+ const dir = path.dirname(lockPath);
117
+ fs.mkdirSync(dir, { recursive: true });
118
+ while (true) {
119
+ try {
120
+ const fd = fs.openSync(lockPath, "wx");
121
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
122
+ fs.closeSync(fd);
123
+ return; // lock acquired
124
+ }
125
+ catch (e) {
126
+ if (e.code !== "EEXIST")
127
+ throw e;
128
+ // Lock file exists — check if stale.
129
+ try {
130
+ const stat = fs.statSync(lockPath);
131
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
132
+ // Stale lock — steal it via atomic rename so only one racing
133
+ // stealer can win (L1). The "graveyard" name is unique per
134
+ // process+attempt; the winner unlinks it, losers see ENOENT
135
+ // on their own rename and simply retry the acquire loop.
136
+ const grave = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
137
+ try {
138
+ fs.renameSync(lockPath, grave);
139
+ // We won the steal — discard the graveyard copy and retry
140
+ // the loop, where openSync('wx') will create a fresh lock.
141
+ try {
142
+ fs.unlinkSync(grave);
143
+ }
144
+ catch { /* ignore */ }
145
+ }
146
+ catch { /* lost the steal race (ENOENT) — just retry */ }
147
+ continue;
148
+ }
149
+ }
150
+ catch {
151
+ // ENOENT: another process released it between openSync and statSync — retry.
152
+ continue;
153
+ }
154
+ // Lock is held and not stale — wait and retry.
155
+ if (Date.now() - start > timeoutMs) {
156
+ throw new Error(`Lock timeout after ${timeoutMs}ms waiting for ${path.basename(lockPath)}`);
157
+ }
158
+ // Busy-wait with Atomics.wait (CPU-efficient sleep).
159
+ Atomics.wait(LOCK_WAIT_BUF, 0, 0, LOCK_POLL_MS);
160
+ }
161
+ }
162
+ }
163
+ /**
164
+ * Release a file lock by deleting the lock file. Ignores ENOENT (already
165
+ * released by another process or stolen due to staleness).
166
+ */
167
+ function releaseLock(lockPath) {
168
+ try {
169
+ fs.unlinkSync(lockPath);
170
+ }
171
+ catch { /* ENOENT or other — ignore */ }
172
+ }
173
+ /**
174
+ * Execute `fn` while holding a file lock. Guarantees release even on throw.
175
+ */
176
+ export function withLock(lockPath, fn) {
177
+ acquireLock(lockPath);
178
+ try {
179
+ return fn();
180
+ }
181
+ finally {
182
+ releaseLock(lockPath);
183
+ }
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // Index CRUD
187
+ // ---------------------------------------------------------------------------
188
+ /**
189
+ * Extract a RunIndexEntry from a RunState + computed relative path.
190
+ */
191
+ function extractIndexEntry(state, relPath) {
192
+ return {
193
+ runId: state.runId,
194
+ flowName: state.flowName,
195
+ status: state.status,
196
+ createdAt: state.createdAt,
197
+ updatedAt: state.updatedAt,
198
+ relPath,
199
+ };
200
+ }
201
+ /** Read the index file; return [] on any error (missing, corrupt, etc.). */
202
+ function readIndex(runsRoot) {
203
+ try {
204
+ const raw = fs.readFileSync(indexPath(runsRoot), "utf-8");
205
+ const parsed = JSON.parse(raw);
206
+ if (!Array.isArray(parsed))
207
+ return [];
208
+ // Validate each entry minimally.
209
+ return parsed.filter((e) => e && typeof e.runId === "string" && typeof e.relPath === "string");
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ }
215
+ /** Write the full index atomically. */
216
+ function writeIndex(runsRoot, entries) {
217
+ writeFileAtomic(indexPath(runsRoot), JSON.stringify(entries, null, 2));
218
+ }
219
+ /** Upsert a single entry by runId (read → mutate → write). */
220
+ /**
221
+ * Upsert a single entry by runId (read → mutate → write).
222
+ *
223
+ * Guarded by a dedicated index lock so concurrent saveRun calls for *different*
224
+ * runIds (each holding only its own per-run lock) cannot interleave their
225
+ * read-modify-write of the shared index and lose each other's entries
226
+ * (risk-reviewer v0.0.9 audit, M1). The per-run lock protects the run file;
227
+ * this index lock protects the shared index.
228
+ */
229
+ function updateIndexEntry(runsRoot, entry) {
230
+ withLock(indexLockPath(runsRoot), () => {
231
+ const entries = readIndex(runsRoot);
232
+ const idx = entries.findIndex((e) => e.runId === entry.runId);
233
+ if (idx >= 0) {
234
+ entries[idx] = entry;
235
+ }
236
+ else {
237
+ entries.push(entry);
238
+ }
239
+ writeIndex(runsRoot, entries);
240
+ });
241
+ }
242
+ // Note: removeIndexEntry is available but not currently called; cleanupTerminalRuns
243
+ // rewrites the full index instead. Kept as a comment for future use.
244
+ /**
245
+ * Scan all subdirectories + legacy flat files and rebuild the full index.
246
+ * Called when the index is missing or corrupt (self-healing).
247
+ *
248
+ * Deduplicates by runId: subdirectory entry wins over flat.
249
+ */
250
+ function rebuildIndex(runsRoot) {
251
+ const entries = new Map();
252
+ let dirs;
253
+ try {
254
+ dirs = fs.readdirSync(runsRoot, { withFileTypes: true })
255
+ .filter((d) => d.isDirectory())
256
+ .map((d) => d.name);
257
+ }
258
+ catch {
259
+ dirs = [];
260
+ }
261
+ // Scan per-flow subdirectories.
262
+ for (const dirName of dirs) {
263
+ const dirPath = path.join(runsRoot, dirName);
264
+ let files;
265
+ try {
266
+ files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".json") && !f.includes(".lock"));
267
+ }
268
+ catch {
269
+ continue;
270
+ }
271
+ for (const file of files) {
272
+ try {
273
+ const raw = fs.readFileSync(path.join(dirPath, file), "utf-8");
274
+ const state = JSON.parse(raw);
275
+ if (state && typeof state.runId === "string") {
276
+ entries.set(state.runId, extractIndexEntry(state, `${dirName}/${file}`));
277
+ }
278
+ }
279
+ catch { /* skip corrupt */ }
280
+ }
281
+ }
282
+ // Scan legacy flat files (runs/*.json, skip index.json).
283
+ let flatFiles;
284
+ try {
285
+ flatFiles = fs.readdirSync(runsRoot).filter((f) => f.endsWith(".json") && f !== "index.json" && !f.includes(".lock"));
286
+ }
287
+ catch {
288
+ flatFiles = [];
289
+ }
290
+ for (const file of flatFiles) {
291
+ if (entries.has(file.replace(/\.json$/, "")))
292
+ continue; // prefer subdir entry
293
+ try {
294
+ const raw = fs.readFileSync(path.join(runsRoot, file), "utf-8");
295
+ const state = JSON.parse(raw);
296
+ if (state && typeof state.runId === "string" && !entries.has(state.runId)) {
297
+ entries.set(state.runId, extractIndexEntry(state, file));
298
+ }
299
+ }
300
+ catch { /* skip corrupt */ }
301
+ }
302
+ const scanned = Array.from(entries.values());
303
+ // Persist the rebuilt index under the index lock. Re-read the current
304
+ // index inside the lock and merge by runId so concurrent writes are not
305
+ // clobbered — scanned entries win on conflict (Finding 5).
306
+ withLock(indexLockPath(runsRoot), () => {
307
+ const currentIndex = readIndex(runsRoot);
308
+ const merged = new Map();
309
+ for (const e of currentIndex)
310
+ merged.set(e.runId, e);
311
+ for (const e of scanned)
312
+ merged.set(e.runId, e); // scanned wins
313
+ writeIndex(runsRoot, Array.from(merged.values()));
314
+ });
315
+ return scanned;
316
+ }
317
+ // ---------------------------------------------------------------------------
318
+ // TTL / cap cleanup
319
+ // ---------------------------------------------------------------------------
320
+ /**
321
+ * Remove excess and expired terminal (completed/failed) runs.
322
+ *
323
+ * Called opportunistically at the end of saveRun. Throttled to at most once
324
+ * per CLEANUP_INTERVAL_MS. Active runs (running/paused/blocked) are never
325
+ * touched.
326
+ *
327
+ * The index read-modify-write is performed under the index lock so it cannot
328
+ * race a concurrent updateIndexEntry and clobber a freshly-added entry (M1).
329
+ * We re-read the index *inside* the lock (rather than trusting a snapshot read
330
+ * before locking) so the rewrite reflects the latest committed state. File and
331
+ * directory unlinks happen after the lock is released to keep the critical
332
+ * section short; deleting a file that is no longer in the index is harmless.
333
+ */
334
+ function cleanupTerminalRuns(runsRoot, maxKeep = DEFAULT_MAX_KEPT_TERMINAL, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
335
+ const cleanupStarted = Date.now();
336
+ const now = cleanupStarted;
337
+ if (now - lastCleanupAt < CLEANUP_INTERVAL_MS)
338
+ return;
339
+ lastCleanupAt = now;
340
+ const maxAgeMs = maxAgeDays * 86_400_000;
341
+ let toRemove = [];
342
+ withLock(indexLockPath(runsRoot), () => {
343
+ const entries = readIndex(runsRoot);
344
+ const terminal = [];
345
+ const active = [];
346
+ for (const e of entries) {
347
+ if (e.status === "completed" || e.status === "failed") {
348
+ terminal.push(e);
349
+ }
350
+ else {
351
+ active.push(e);
352
+ }
353
+ }
354
+ // Sort terminal by updatedAt desc (newest first).
355
+ // Filter out entries with corrupt updatedAt (non-numeric/NaN) BEFORE sorting
356
+ // to prevent NaN from corrupting sort order. Corrupt entries cannot be
357
+ // reliably aged, so they are always moved to toRemove.
358
+ const cleanTerminal = [];
359
+ for (const e of terminal) {
360
+ if (typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt)) {
361
+ cleanTerminal.push(e);
362
+ }
363
+ else {
364
+ toRemove.push(e);
365
+ }
366
+ }
367
+ cleanTerminal.sort((a, b) => b.updatedAt - a.updatedAt);
368
+ for (let i = 0; i < cleanTerminal.length; i++) {
369
+ const e = cleanTerminal[i];
370
+ const expiredByAge = now - e.updatedAt > maxAgeMs;
371
+ const excessByCount = i >= maxKeep;
372
+ if (expiredByAge || excessByCount) {
373
+ toRemove.push(e);
374
+ }
375
+ }
376
+ if (toRemove.length === 0)
377
+ return;
378
+ // Commit the pruned index while holding the lock so a concurrent
379
+ // updateIndexEntry cannot interleave and lose entries.
380
+ const remaining = cleanTerminal.filter((e) => !toRemove.includes(e));
381
+ writeIndex(runsRoot, [...active, ...remaining]);
382
+ });
383
+ if (toRemove.length === 0)
384
+ return;
385
+ console.warn(`[taskflow] Cleaning up ${toRemove.length} old run(s) ` +
386
+ `(max ${maxKeep} runs, ${maxAgeDays} day age limit). ` +
387
+ `Configure 'taskflow.maxKeptRuns' / 'taskflow.maxRunAgeDays' in settings.json (0 = keep all).`);
388
+ // Delete run files + lock files (outside the index lock).
389
+ for (const e of toRemove) {
390
+ const filePath = path.join(runsRoot, e.relPath);
391
+ // Race guard: skip files modified after cleanup started (Finding 2).
392
+ try {
393
+ if (fs.statSync(filePath).mtimeMs > cleanupStarted)
394
+ continue;
395
+ }
396
+ catch {
397
+ continue;
398
+ }
399
+ try {
400
+ fs.unlinkSync(filePath);
401
+ }
402
+ catch { /* already gone */ }
403
+ // Also remove any orphaned lock file.
404
+ try {
405
+ fs.unlinkSync(filePath + ".lock");
406
+ }
407
+ catch { /* ignore */ }
408
+ // Also remove the per-run Shared Context Tree directory (C6). Orphaned
409
+ // ctx dirs would otherwise accumulate under runs/ctx/ over many runs.
410
+ try {
411
+ fs.rmSync(path.join(runsRoot, "ctx", e.runId), { recursive: true, force: true });
412
+ }
413
+ catch { /* ignore */ }
414
+ // Also remove the per-run isolated-workspace dir tree (cwd:"dedicated").
415
+ // `dedicated` workspaces are persistent by design; reclaim them once the
416
+ // run is pruned. The dir name uses the same sanitization as workspace.ts.
417
+ try {
418
+ const wsSeg = e.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase";
419
+ fs.rmSync(path.join(runsRoot, "ws", wsSeg), { recursive: true, force: true });
420
+ }
421
+ catch { /* ignore */ }
422
+ }
423
+ // Remove empty flow subdirectories.
424
+ for (const e of toRemove) {
425
+ const dirPath = path.dirname(path.join(runsRoot, e.relPath));
426
+ try {
427
+ fs.rmdirSync(dirPath);
428
+ }
429
+ catch { /* ENOTEMPTY or ENOENT — ignore */ }
430
+ }
431
+ }
432
+ // ---------------------------------------------------------------------------
433
+ // Original helpers (unchanged)
434
+ // ---------------------------------------------------------------------------
435
+ function userFlowsDir() {
436
+ return path.join(getAgentDir(), "taskflows");
437
+ }
438
+ function findProjectFlowsDirInternal(cwd, create = false) {
439
+ // Prefer an existing .pi dir up the tree; else use cwd/.pi when creating.
440
+ // **Never treat `~/.pi/` as a project flow dir** — the home directory is
441
+ // the user-scope boundary, and the user's `~/.pi/` is the agent dir, not a
442
+ // project. We skip the home entry entirely during the walk-up, so even a
443
+ // deeply nested cwd under home will return null (create=false) when no
444
+ // project `.pi` exists on the path.
445
+ const home = os.homedir();
446
+ let dir = cwd;
447
+ while (true) {
448
+ if (dir !== home) {
449
+ const candidate = path.join(dir, ".pi");
450
+ if (fs.existsSync(candidate))
451
+ return path.join(candidate, "taskflows");
452
+ }
453
+ const parent = path.dirname(dir);
454
+ if (parent === dir)
455
+ break;
456
+ dir = parent;
457
+ }
458
+ return create ? path.join(cwd, ".pi", "taskflows") : null;
459
+ }
460
+ function readFlowFile(filePath, scope) {
461
+ try {
462
+ const raw = fs.readFileSync(filePath, "utf-8");
463
+ const def = JSON.parse(raw);
464
+ if (!def?.name)
465
+ return null;
466
+ return { name: def.name, scope, filePath, def };
467
+ }
468
+ catch {
469
+ return null;
470
+ }
471
+ }
472
+ /** List all saved flows (project overrides user on name collision). */
473
+ /** Internal-but-exported for tests: walk-up `.pi` finder with home-dir stop. */
474
+ export function findProjectFlowsDir(cwd, create = false) {
475
+ return findProjectFlowsDirInternal(cwd, create);
476
+ }
477
+ export function listFlows(cwd) {
478
+ const map = new Map();
479
+ const dirs = [{ dir: userFlowsDir(), scope: "user" }];
480
+ const projDir = findProjectFlowsDir(cwd);
481
+ if (projDir)
482
+ dirs.push({ dir: projDir, scope: "project" });
483
+ for (const { dir, scope } of dirs) {
484
+ if (!fs.existsSync(dir))
485
+ continue;
486
+ let entries;
487
+ try {
488
+ entries = fs.readdirSync(dir);
489
+ }
490
+ catch {
491
+ continue;
492
+ }
493
+ for (const name of entries) {
494
+ if (!name.endsWith(".json"))
495
+ continue;
496
+ const flow = readFlowFile(path.join(dir, name), scope);
497
+ if (flow)
498
+ map.set(flow.name, flow); // project after user → overrides
499
+ }
500
+ }
501
+ return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
502
+ }
503
+ export function getFlow(cwd, name) {
504
+ return listFlows(cwd).find((f) => f.name === name) ?? null;
505
+ }
506
+ let _piCreationHinted = false;
507
+ export function saveFlow(cwd, def, scope = "project") {
508
+ const dir = scope === "user" ? userFlowsDir() : (findProjectFlowsDir(cwd, true) ?? path.join(cwd, ".pi", "taskflows"));
509
+ if (!def.name || def.name.trim().length === 0)
510
+ throw new Error("Flow name must not be empty");
511
+ fs.mkdirSync(dir, { recursive: true });
512
+ const safe = safeFlowDirName(def.name);
513
+ const filePath = path.join(dir, `${safe}.json`);
514
+ const fileLockPath = filePath + ".lock";
515
+ withLock(fileLockPath, () => { writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`); });
516
+ // One-shot: let the user know about .pi/ directory on first save (Finding 8).
517
+ if (!_piCreationHinted) {
518
+ _piCreationHinted = true;
519
+ const piExisted = fs.existsSync(path.join(dir, "..", ".."));
520
+ console.warn(`[taskflow] ${piExisted ? "Using" : "Created"} .pi/taskflows/ for project-scoped flow storage. ` +
521
+ `Add .pi/ to .gitignore if desired.`);
522
+ }
523
+ return { filePath };
524
+ }
525
+ // --- Run state ---
526
+ export function runsDir(cwd) {
527
+ // Safe non-null assertion: create=true guarantees a non-null return because
528
+ // findProjectFlowsDirInternal falls back to path.join(cwd, ".pi", "taskflows").
529
+ const projDir = findProjectFlowsDir(cwd, true);
530
+ return path.join(projDir, "runs");
531
+ }
532
+ /** Root dir for the cross-run memoization cache (sibling of `runs`). */
533
+ export function cacheDir(cwd) {
534
+ const projDir = findProjectFlowsDir(cwd, true);
535
+ return path.join(projDir, "cache");
536
+ }
537
+ export function newRunId(flowName) {
538
+ // Collapse to a safe charset AND fold any dot-runs so the result can never
539
+ // contain a '..' traversal token (validateRunId rejects '..').
540
+ const safe = flowName.replace(/[^\w.-]+/g, "_").replace(/\.{2,}/g, "_").slice(0, 24);
541
+ return `${safe}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
542
+ }
543
+ /**
544
+ * Persist a run state to disk.
545
+ *
546
+ * v0.0.9: writes to `runs/<sanitisedFlowName>/<runId>.json` (per-flow
547
+ * subdirectory) and updates the lightweight index. Uses a per-run file lock
548
+ * to prevent concurrent writes to the same runId. After the write, runs
549
+ * opportunistic cleanup of expired terminal runs.
550
+ *
551
+ * F-009: shallow-clones state before stamping updatedAt to avoid mutating the
552
+ * caller's reference.
553
+ */
554
+ export function saveRun(state, cleanup) {
555
+ // Reject unsafe runIds before any filesystem access (Finding 1).
556
+ if (!validateRunId(state.runId))
557
+ return;
558
+ const root = runsDir(state.cwd);
559
+ const flowDir = flowRunDir(root, state.flowName);
560
+ fs.mkdirSync(flowDir, { recursive: true });
561
+ // Clone before stamping updatedAt so the caller's RunState reference is not
562
+ // mutated as a hidden side effect (v0.0.6 audit, F-009). Shallow clone is
563
+ // sufficient: saveRun only serializes; it does not mutate nested objects.
564
+ const toSave = { ...state, updatedAt: Date.now() };
565
+ const filePath = runFilePath(root, state.flowName, state.runId);
566
+ const lockPath = lockPathForRun(root, state.flowName, state.runId);
567
+ withLock(lockPath, () => {
568
+ writeFileAtomic(filePath, JSON.stringify(toSave, null, 2));
569
+ updateIndexEntry(root, extractIndexEntry(toSave, path.basename(flowDir) + "/" + path.basename(filePath)));
570
+ });
571
+ // Opportunistic cleanup — throttled to once per CLEANUP_INTERVAL_MS.
572
+ const maxKeep = cleanup?.maxKeep ?? DEFAULT_MAX_KEPT_TERMINAL;
573
+ const maxAgeDays = cleanup?.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
574
+ if (maxKeep > 0 || maxAgeDays > 0) {
575
+ cleanupTerminalRuns(root, maxKeep, maxAgeDays);
576
+ }
577
+ }
578
+ /**
579
+ * Load a single run by runId.
580
+ *
581
+ * Lookup chain (fast → slow):
582
+ * 1. INDEX — read index.json, find entry with matching runId, read via relPath.
583
+ * 2. SUBDIR SCAN — for each subdirectory in runsDir, check <subdir>/<runId>.json.
584
+ * 3. FLAT FALLBACK — check runsDir/<runId>.json directly (legacy layout).
585
+ *
586
+ * All existing path-traversal, symlink, and realpath guards are preserved for
587
+ * every path touched.
588
+ */
589
+ export function loadRun(cwd, runId) {
590
+ if (!validateRunId(runId))
591
+ return null;
592
+ const root = runsDir(cwd);
593
+ // ---- Try index first ----
594
+ const indexEntries = readIndex(root);
595
+ const entry = indexEntries.find((e) => e.runId === runId);
596
+ if (entry) {
597
+ const filePath = path.join(root, entry.relPath);
598
+ const state = tryReadRunFile(root, filePath);
599
+ if (state)
600
+ return state;
601
+ // Index entry exists but file is gone or corrupt — fall through.
602
+ }
603
+ // ---- Try subdirectory scan ----
604
+ let dirs;
605
+ try {
606
+ dirs = fs.readdirSync(root, { withFileTypes: true })
607
+ .filter((d) => d.isDirectory())
608
+ .map((d) => d.name);
609
+ }
610
+ catch {
611
+ dirs = [];
612
+ }
613
+ for (const dirName of dirs) {
614
+ const filePath = path.join(root, dirName, `${runId}.json`);
615
+ const state = tryReadRunFile(root, filePath);
616
+ if (state)
617
+ return state;
618
+ }
619
+ // ---- Try legacy flat fallback ----
620
+ const flatPath = path.join(root, `${runId}.json`);
621
+ const state = tryReadRunFile(root, flatPath);
622
+ if (state)
623
+ return state;
624
+ return null;
625
+ }
626
+ /**
627
+ * Safely read a run file, performing all path-traversal / symlink guards.
628
+ * Returns null on any violation or read error.
629
+ */
630
+ function tryReadRunFile(runsRoot, filePath) {
631
+ // Lexical traversal guard.
632
+ const rel = path.relative(runsRoot, filePath);
633
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
634
+ return null;
635
+ // Resolve symlinks on both runsRoot and the file so the containment check
636
+ // uses consistent physical paths (macOS /var → /private/var etc.).
637
+ let realDir;
638
+ let realFilePath;
639
+ try {
640
+ realDir = fs.realpathSync(runsRoot);
641
+ realFilePath = fs.realpathSync(filePath);
642
+ }
643
+ catch {
644
+ return null;
645
+ }
646
+ const realRel = path.relative(realDir, realFilePath);
647
+ if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel))
648
+ return null;
649
+ try {
650
+ const raw = fs.readFileSync(realFilePath, "utf-8");
651
+ return JSON.parse(raw);
652
+ }
653
+ catch {
654
+ return null;
655
+ }
656
+ }
657
+ /**
658
+ * List recent runs, sorted by updatedAt descending.
659
+ *
660
+ * v0.0.9: reads from index first, then merges any legacy flat files not yet in
661
+ * the index. If the index is missing/corrupt, calls rebuildIndex for
662
+ * self-healing.
663
+ *
664
+ * F-010: drops records with non-numeric/NaN updatedAt before sorting.
665
+ */
666
+ export function listRuns(cwd, limit = 20) {
667
+ const root = runsDir(cwd);
668
+ if (!fs.existsSync(root))
669
+ return [];
670
+ // Index-first path.
671
+ let entries = readIndex(root);
672
+ if (entries.length === 0) {
673
+ // Index missing or corrupt — rebuild from filesystem.
674
+ entries = rebuildIndex(root);
675
+ }
676
+ // Collect runIds from index for deduplication.
677
+ const indexRunIds = new Set(entries.map((e) => e.runId));
678
+ // Merge legacy flat files not yet in the index.
679
+ let flatFiles;
680
+ try {
681
+ flatFiles = fs.readdirSync(root).filter((f) => f.endsWith(".json") && f !== "index.json" && !f.includes(".lock"));
682
+ }
683
+ catch {
684
+ flatFiles = [];
685
+ }
686
+ for (const file of flatFiles) {
687
+ const runIdFromName = file.replace(/\.json$/, "");
688
+ if (indexRunIds.has(runIdFromName))
689
+ continue;
690
+ try {
691
+ const raw = fs.readFileSync(path.join(root, file), "utf-8");
692
+ const state = JSON.parse(raw);
693
+ if (state && typeof state.runId === "string" && !indexRunIds.has(state.runId)) {
694
+ entries.push(extractIndexEntry(state, file));
695
+ indexRunIds.add(state.runId);
696
+ }
697
+ }
698
+ catch { /* skip corrupt */ }
699
+ }
700
+ // Sort by updatedAt desc, slice to limit.
701
+ // Filter out entries with non-numeric/NaN updatedAt BEFORE sorting to
702
+ // prevent NaN from corrupting V8's sort order (which can displace valid
703
+ // entries when a limit is applied).
704
+ const valid = entries.filter((e) => typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt));
705
+ valid.sort((a, b) => b.updatedAt - a.updatedAt);
706
+ const sliced = valid.slice(0, limit);
707
+ // Read full RunState for each entry.
708
+ const runs = [];
709
+ for (const e of sliced) {
710
+ try {
711
+ const raw = fs.readFileSync(path.join(root, e.relPath), "utf-8");
712
+ runs.push(JSON.parse(raw));
713
+ }
714
+ catch { /* file may have been deleted since index was built — skip */ }
715
+ }
716
+ // F-010: filter out records with non-numeric/NaN updatedAt.
717
+ return runs.filter((r) => typeof r.updatedAt === "number" && !Number.isNaN(r.updatedAt));
718
+ }
719
+ /** Stable hash of a phase's resolved task + inputs, for resume caching. */
720
+ export function hashInput(...parts) {
721
+ return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
722
+ }
723
+ /**
724
+ * Check whether a process with the given PID is still alive.
725
+ * Uses signal 0 (no signal sent) — succeeds if the process exists and we have
726
+ * permission to signal it, throws ESRCH if it doesn't exist.
727
+ */
728
+ export function isProcessAlive(pid) {
729
+ try {
730
+ process.kill(pid, 0);
731
+ return true;
732
+ }
733
+ catch {
734
+ return false;
735
+ }
736
+ }
737
+ /**
738
+ * Write a file atomically: write to a unique temp file in the same directory,
739
+ * then rename over the target (rename is atomic on the same filesystem). Prevents
740
+ * a crash or concurrent write from leaving a half-written, corrupt JSON file.
741
+ */
742
+ export function writeFileAtomic(filePath, data) {
743
+ // Ensure parent directory exists.
744
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
745
+ const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
746
+ try {
747
+ fs.writeFileSync(tmp, data, "utf-8");
748
+ fs.renameSync(tmp, filePath);
749
+ }
750
+ catch (e) {
751
+ try {
752
+ if (fs.existsSync(tmp))
753
+ fs.unlinkSync(tmp);
754
+ }
755
+ catch {
756
+ /* ignore cleanup failure */
757
+ }
758
+ throw e;
759
+ }
760
+ }
761
+ //# sourceMappingURL=store.js.map