pi-crew 0.6.1 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/README.md +81 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/docs/ui-optimization-plan.md +447 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +213 -35
- package/src/extension/registration/brief-tool-overrides.ts +400 -0
- package/src/extension/registration/commands.ts +27 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +9 -4
- package/src/extension/registration/team-tool.ts +17 -10
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +64 -14
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/session-state-map.ts +51 -0
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/powerbar-publisher.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +5 -1
- package/src/ui/theme-adapter.ts +80 -1
- package/src/ui/tool-progress-formatter.ts +9 -5
- package/src/ui/tool-render.ts +4 -0
- package/src/ui/tool-renderers/brief-mode.ts +207 -0
- package/src/ui/tool-renderers/index.ts +627 -0
- package/src/ui/widget/index.ts +224 -0
- package/src/ui/widget/widget-formatters.ts +148 -0
- package/src/ui/widget/widget-model.ts +90 -0
- package/src/ui/widget/widget-renderer.ts +130 -0
- package/src/ui/widget/widget-types.ts +37 -0
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/guards.ts +110 -0
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/state/locks.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
|
4
4
|
import type { TeamRunManifest } from "./types.ts";
|
|
5
5
|
import { DEFAULT_LOCKS } from "../config/defaults.ts";
|
|
6
6
|
import { sleepSync } from "../utils/sleep.ts";
|
|
7
|
+
import { isSymlinkSafePath } from "./atomic-write.ts";
|
|
7
8
|
|
|
8
9
|
export interface RunLockOptions {
|
|
9
10
|
staleMs?: number;
|
|
@@ -52,8 +53,13 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
52
53
|
return true; // Signal 0 succeeded — process is alive
|
|
53
54
|
} catch (error) {
|
|
54
55
|
const code = (error as NodeJS.ErrnoException).code;
|
|
55
|
-
// EPERM: process exists but we don't have permission to signal it
|
|
56
|
-
|
|
56
|
+
// EPERM: process exists but we don't have permission to signal it.
|
|
57
|
+
// Since we cannot verify liveness, treat the holder as potentially
|
|
58
|
+
// stale so the lock can be stolen rather than blocking indefinitely.
|
|
59
|
+
// This is an acceptable trade-off: EPERM requires elevated privileges,
|
|
60
|
+
// and blocking indefinitely would be worse. The risk is low.
|
|
61
|
+
// Other errors (ESRCH — process doesn't exist) also mean holder is dead.
|
|
62
|
+
return false;
|
|
57
63
|
}
|
|
58
64
|
} catch {
|
|
59
65
|
return true; // Can't read — assume alive to be safe
|
|
@@ -72,7 +78,24 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
72
78
|
export type LockKind = "run" | "file";
|
|
73
79
|
|
|
74
80
|
function writeLockFile(filePath: string, token: string, kind: LockKind = "file"): void {
|
|
75
|
-
|
|
81
|
+
// Reject a pre-existing symlink at the lock path before O_CREAT|O_EXCL.
|
|
82
|
+
// O_EXCL fails with EEXIST if the path exists (including as a symlink),
|
|
83
|
+
// but the failure mode is confusing — an explicit lstatSync gives a
|
|
84
|
+
// clean, distinguishable error instead.
|
|
85
|
+
try {
|
|
86
|
+
const stat = fs.lstatSync(filePath);
|
|
87
|
+
if (stat.isSymbolicLink()) {
|
|
88
|
+
throw new Error(`Refusing to create lock file over symlink: ${filePath}`);
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
92
|
+
// ENOENT means the path doesn't exist yet — that's fine, proceed.
|
|
93
|
+
// Anything else (EACCES, EPERM, etc.) should surface immediately.
|
|
94
|
+
if (code !== "ENOENT") throw error;
|
|
95
|
+
}
|
|
96
|
+
// Ensure parent directory exists (may have been cleaned up by a concurrent process)
|
|
97
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
98
|
+
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
|
|
76
99
|
try {
|
|
77
100
|
fs.writeSync(fd, JSON.stringify({ kind, pid: process.pid, createdAt: new Date().toISOString(), token }));
|
|
78
101
|
} finally {
|
|
@@ -86,6 +109,9 @@ function writeLockFile(filePath: string, token: string, kind: LockKind = "file")
|
|
|
86
109
|
*/
|
|
87
110
|
function readLockToken(filePath: string): string | undefined {
|
|
88
111
|
try {
|
|
112
|
+
// Refuse to read a symlink — prevents reading a target an attacker placed
|
|
113
|
+
const stat = fs.lstatSync(filePath);
|
|
114
|
+
if (stat.isSymbolicLink()) return undefined;
|
|
89
115
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
90
116
|
const parsed = JSON.parse(raw) as { token?: unknown };
|
|
91
117
|
return typeof parsed.token === "string" ? parsed.token : undefined;
|
|
@@ -106,18 +132,34 @@ function readLockToken(filePath: string): string | undefined {
|
|
|
106
132
|
function timingSafeTokenMatch(a: string, b: string): boolean {
|
|
107
133
|
const bufA = Buffer.from(String(a));
|
|
108
134
|
const bufB = Buffer.from(String(b));
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
const len = Math.max(bufA.length, bufB.length);
|
|
136
|
+
const safeA = Buffer.alloc(len);
|
|
137
|
+
const safeB = Buffer.alloc(len);
|
|
138
|
+
bufA.copy(safeA);
|
|
139
|
+
bufB.copy(safeB);
|
|
140
|
+
return timingSafeEqual(safeA, safeB);
|
|
111
141
|
}
|
|
112
142
|
|
|
113
143
|
function releaseLock(filePath: string, token: string): void {
|
|
144
|
+
// FIX: Do not delete a symlink — it may have been planted by an attacker
|
|
145
|
+
// after our lock was released. A legitimate lock file should never be a
|
|
146
|
+
// symlink since writeLockFile uses O_CREAT|O_EXCL which fails on symlinks.
|
|
147
|
+
let isSymlink = false;
|
|
148
|
+
try {
|
|
149
|
+
isSymlink = fs.lstatSync(filePath).isSymbolicLink();
|
|
150
|
+
} catch { /* file doesn't exist — that's fine, we'll handle ENOENT below */ }
|
|
151
|
+
if (isSymlink) return;
|
|
152
|
+
|
|
114
153
|
const stored = readLockToken(filePath);
|
|
115
154
|
if (stored === undefined || timingSafeTokenMatch(stored, token)) {
|
|
116
155
|
try {
|
|
117
156
|
fs.rmSync(filePath, { force: true });
|
|
118
|
-
} catch {
|
|
119
|
-
//
|
|
120
|
-
//
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// FIX: Only ignore ENOENT (lock already gone). Other errors (EACCES,
|
|
159
|
+
// EPERM, EBUSY) indicate a real problem and should be surfaced.
|
|
160
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
161
|
+
if (code !== "ENOENT") throw error;
|
|
162
|
+
// Lock already gone — that's fine, we wanted to release it anyway.
|
|
121
163
|
}
|
|
122
164
|
}
|
|
123
165
|
// If the stored token does not match, our lock has been stolen
|
|
@@ -138,17 +180,12 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
|
|
|
138
180
|
if (Date.now() > deadline) {
|
|
139
181
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
140
182
|
}
|
|
141
|
-
// FIX: Use both staleness AND PID liveness to decide if we can steal
|
|
142
|
-
// a lock. Previously only staleness was checked, so a process whose
|
|
143
|
-
// PID was recently reused by another process could have its lock
|
|
144
|
-
// stolen even while still active. Now: fresh+alive = fail, else = clear.
|
|
145
183
|
const isStale = isLockStale(filePath, staleMs);
|
|
146
184
|
const isHolderAlive = isLockHolderAlive(filePath);
|
|
147
185
|
if (!isStale && isHolderAlive) {
|
|
148
|
-
// Lock is fresh AND holder is alive — fail fast
|
|
149
186
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
150
187
|
}
|
|
151
|
-
//
|
|
188
|
+
// Stale or dead holder — forcibly remove the lock.
|
|
152
189
|
try {
|
|
153
190
|
fs.rmSync(filePath, { force: true });
|
|
154
191
|
} catch { /* race — let loop retry */ }
|
|
@@ -176,16 +213,12 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
|
|
|
176
213
|
if (Date.now() > deadline) {
|
|
177
214
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
178
215
|
}
|
|
179
|
-
// FIX (Round 14, locks-async): Mirror the sync path's staleness AND
|
|
180
|
-
// PID liveness check. Previously the async path only checked
|
|
181
|
-
// staleness, so a recently-reused PID could have its lock stolen
|
|
182
|
-
// even while still running. Now: fresh + alive holder = fail.
|
|
183
216
|
const isStale = isLockStale(filePath, staleMs);
|
|
184
217
|
const isHolderAlive = isLockHolderAlive(filePath);
|
|
185
218
|
if (!isStale && isHolderAlive) {
|
|
186
219
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
187
220
|
}
|
|
188
|
-
//
|
|
221
|
+
// Stale or dead holder — forcibly remove the lock.
|
|
189
222
|
try {
|
|
190
223
|
fs.rmSync(filePath, { force: true });
|
|
191
224
|
} catch { /* race — let loop retry */ }
|
|
@@ -207,8 +240,38 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
207
240
|
// append, or even the lock acquisition itself) would race with the lock.
|
|
208
241
|
const lockFile = `${filePath}.lock`;
|
|
209
242
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
243
|
+
// FIX: Validate the parent directory is not a symlink BEFORE calling mkdirSync.
|
|
244
|
+
// Between mkdir and lock acquisition, an attacker could plant a symlink.
|
|
245
|
+
if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
|
|
210
246
|
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
211
|
-
|
|
247
|
+
// FIX: Validate that the target file still exists. If it was deleted and
|
|
248
|
+
// recreated since the last lock cycle, the old .lock file may be orphaned
|
|
249
|
+
// and should not block the new cycle. Clean it up if the target is missing.
|
|
250
|
+
try {
|
|
251
|
+
fs.statSync(filePath);
|
|
252
|
+
} catch {
|
|
253
|
+
// Target file doesn't exist — clean up any stale .lock file and proceed.
|
|
254
|
+
// The lock will be acquired fresh for the new file (if fn creates it).
|
|
255
|
+
try { fs.rmSync(lockFile, { force: true }); } catch { /* ignore */ }
|
|
256
|
+
}
|
|
257
|
+
// FIX (TOCTOU): Re-validate symlink safety before each lock acquisition
|
|
258
|
+
// attempt. Between our initial check and the acquisition (and between
|
|
259
|
+
// acquireLockWithRetry's internal retries), an attacker could plant a
|
|
260
|
+
// symlink. We must re-check on each iteration to catch TOCTOU races.
|
|
261
|
+
let token = "";
|
|
262
|
+
let attempt = 0;
|
|
263
|
+
const deadline = Date.now() + staleMs * 2;
|
|
264
|
+
while (Date.now() <= deadline) {
|
|
265
|
+
if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
|
|
266
|
+
try {
|
|
267
|
+
token = acquireLockWithRetry(lockFile, staleMs, "file");
|
|
268
|
+
break;
|
|
269
|
+
} catch {
|
|
270
|
+
sleepSync(Math.min(250, 25 * 2 ** attempt));
|
|
271
|
+
attempt++;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (token === "") throw new Error(`Run '${path.basename(lockFile)}' is locked by another operation.`);
|
|
212
275
|
try {
|
|
213
276
|
return fn();
|
|
214
277
|
} finally {
|
|
@@ -220,23 +283,42 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
220
283
|
export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, options: RunLockOptions = {}): T {
|
|
221
284
|
const filePath = lockPath(manifest);
|
|
222
285
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
286
|
+
const existingToken = runLockHeldByUs.get(filePath);
|
|
287
|
+
if (existingToken) {
|
|
288
|
+
// Re-entrant: already hold this lock, just run the callback.
|
|
289
|
+
return fn();
|
|
290
|
+
}
|
|
223
291
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
224
292
|
const token = acquireLockWithRetry(filePath, staleMs, "run");
|
|
293
|
+
runLockHeldByUs.set(filePath, token);
|
|
225
294
|
try {
|
|
226
295
|
return fn();
|
|
227
296
|
} finally {
|
|
297
|
+
runLockHeldByUs.delete(filePath);
|
|
228
298
|
releaseLock(filePath, token);
|
|
229
299
|
}
|
|
230
300
|
}
|
|
231
301
|
|
|
302
|
+
// Track re-entrant lock acquisitions per lock file path. When a lock is
|
|
303
|
+
// already held by this call stack (handleResume -> executeTeamRun ->
|
|
304
|
+
// executeTeamRunCore), we skip re-acquisition to avoid deadlock.
|
|
305
|
+
const runLockHeldByUs = new Map<string, string>(); // filePath -> token
|
|
306
|
+
|
|
232
307
|
export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
|
|
233
308
|
const filePath = lockPath(manifest);
|
|
234
309
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
310
|
+
const existingToken = runLockHeldByUs.get(filePath);
|
|
311
|
+
if (existingToken) {
|
|
312
|
+
// Re-entrant: already hold this lock, just run the callback.
|
|
313
|
+
return await fn();
|
|
314
|
+
}
|
|
235
315
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
236
316
|
const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
|
|
317
|
+
runLockHeldByUs.set(filePath, token);
|
|
237
318
|
try {
|
|
238
319
|
return await fn();
|
|
239
320
|
} finally {
|
|
321
|
+
runLockHeldByUs.delete(filePath);
|
|
240
322
|
releaseLock(filePath, token);
|
|
241
323
|
}
|
|
242
324
|
}
|
package/src/state/mailbox.ts
CHANGED
|
@@ -69,7 +69,24 @@ function mailboxDir(manifest: TeamRunManifest): string {
|
|
|
69
69
|
|
|
70
70
|
function safeMailboxDir(manifest: TeamRunManifest, create = false): string {
|
|
71
71
|
const dir = mailboxDir(manifest);
|
|
72
|
-
if (create)
|
|
72
|
+
if (create) {
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// Windows EPERM: can occur when dir uses long-name form (runneradmin)
|
|
77
|
+
// but filesystem parent only exists in short-name form (RUNNER~1).
|
|
78
|
+
// Retry with realpathSync-resolved form.
|
|
79
|
+
if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
|
|
80
|
+
try {
|
|
81
|
+
const realDir = fs.realpathSync(path.dirname(dir));
|
|
82
|
+
const correctedDir = path.join(realDir, path.basename(dir));
|
|
83
|
+
fs.mkdirSync(correctedDir, { recursive: true });
|
|
84
|
+
} catch { throw error; }
|
|
85
|
+
} else {
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
73
90
|
// SECURITY: When create=true, dir now exists and must be validated via
|
|
74
91
|
// resolveRealContainedPath. When create=false, missing dir must throw —
|
|
75
92
|
// never return an unvalidated bare path (bypasses containment checks).
|
|
@@ -78,7 +95,12 @@ function safeMailboxDir(manifest: TeamRunManifest, create = false): string {
|
|
|
78
95
|
return path.join(dir); // will throw in callers via resolveRealContainedPath on read
|
|
79
96
|
}
|
|
80
97
|
if (fs.lstatSync(dir).isSymbolicLink()) throw new Error(`Invalid mailbox directory: ${dir}`);
|
|
81
|
-
|
|
98
|
+
// Return dir as-is. The containment guarantee is provided by
|
|
99
|
+
// manifest.stateRoot which was validated by createRunPaths. Re-resolving
|
|
100
|
+
// through resolveRealContainedPath can change the path form on Windows
|
|
101
|
+
// (short-name vs long-name), causing subsequent operations to see a
|
|
102
|
+
// different filesystem entry.
|
|
103
|
+
return dir;
|
|
82
104
|
}
|
|
83
105
|
|
|
84
106
|
function safeTaskId(taskId: string): string {
|
|
@@ -91,7 +113,8 @@ function safeMailboxTasksRoot(manifest: TeamRunManifest, create = false): string
|
|
|
91
113
|
if (create) fs.mkdirSync(root, { recursive: true });
|
|
92
114
|
if (!fs.existsSync(root)) return root;
|
|
93
115
|
if (fs.lstatSync(root).isSymbolicLink()) throw new Error(`Invalid mailbox tasks directory: ${root}`);
|
|
94
|
-
|
|
116
|
+
// Return root as-is (see safeMailboxDir comment about path form consistency)
|
|
117
|
+
return root;
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
function taskMailboxDir(manifest: TeamRunManifest, taskId: string, create = false): string {
|
|
@@ -101,7 +124,9 @@ function taskMailboxDir(manifest: TeamRunManifest, taskId: string, create = fals
|
|
|
101
124
|
const relative = path.relative(tasksRoot, resolved);
|
|
102
125
|
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Invalid mailbox task id: ${taskId}`);
|
|
103
126
|
if (create) fs.mkdirSync(resolved, { recursive: true });
|
|
104
|
-
|
|
127
|
+
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) throw new Error(`Invalid mailbox task directory: ${resolved}`);
|
|
128
|
+
// Return resolved as-is (see safeMailboxDir comment about path form consistency)
|
|
129
|
+
return resolved;
|
|
105
130
|
}
|
|
106
131
|
|
|
107
132
|
function mailboxPath(manifest: TeamRunManifest, direction: MailboxDirection, taskId?: string, create = false): string {
|
|
@@ -115,7 +140,12 @@ function deliveryPath(manifest: TeamRunManifest, create = false): string {
|
|
|
115
140
|
function safeMailboxFile(filePath: string, parentDir: string): string {
|
|
116
141
|
if (!fs.existsSync(filePath)) return filePath;
|
|
117
142
|
if (fs.lstatSync(filePath).isSymbolicLink()) throw new Error(`Invalid mailbox file: ${filePath}`);
|
|
118
|
-
|
|
143
|
+
// Return filePath as-is instead of calling resolveRealContainedPath().
|
|
144
|
+
// The containment guarantee is already provided by safeMailboxDir/taskMailboxDir
|
|
145
|
+
// when the parent directory is created. Re-resolving can change the path
|
|
146
|
+
// form (e.g. short-name vs long-name on Windows), causing subsequent
|
|
147
|
+
// existsSync/appendFileSync to see a different file.
|
|
148
|
+
return filePath;
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
function mailboxFile(manifest: TeamRunManifest, direction: MailboxDirection, taskId?: string, create = false): string {
|
|
@@ -145,10 +175,18 @@ function ensureRunMailbox(manifest: TeamRunManifest): void {
|
|
|
145
175
|
safeMailboxDir(manifest, true);
|
|
146
176
|
for (const direction of ["inbox", "outbox"] as const) {
|
|
147
177
|
const filePath = mailboxFile(manifest, direction, undefined, true);
|
|
148
|
-
if (!fs.existsSync(filePath))
|
|
178
|
+
if (!fs.existsSync(filePath)) {
|
|
179
|
+
// Ensure parent dir exists (may have been lost due to race or
|
|
180
|
+
// Windows path normalization mismatch)
|
|
181
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
182
|
+
fs.writeFileSync(filePath, "", "utf-8");
|
|
183
|
+
}
|
|
149
184
|
}
|
|
150
185
|
const delivery = deliveryFile(manifest, true);
|
|
151
|
-
if (!fs.existsSync(delivery))
|
|
186
|
+
if (!fs.existsSync(delivery)) {
|
|
187
|
+
fs.mkdirSync(path.dirname(delivery), { recursive: true });
|
|
188
|
+
fs.writeFileSync(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf-8");
|
|
189
|
+
}
|
|
152
190
|
}
|
|
153
191
|
|
|
154
192
|
function ensureTaskMailbox(manifest: TeamRunManifest, taskId: string): void {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { mkdirSync, readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
12
14
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
13
15
|
|
|
14
16
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
@@ -151,7 +153,8 @@ export class ObservationStore {
|
|
|
151
153
|
*/
|
|
152
154
|
save(): void {
|
|
153
155
|
try {
|
|
154
|
-
|
|
156
|
+
// Use path.dirname for cross-platform support (handles both \ and /)
|
|
157
|
+
mkdirSync(path.dirname(this.storePath), { recursive: true });
|
|
155
158
|
writeFileSync(this.storePath, JSON.stringify({
|
|
156
159
|
observations: this.observations,
|
|
157
160
|
compressed: this.compressed,
|
package/src/state/run-graph.ts
CHANGED
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { projectCrewRoot } from "../utils/paths.ts";
|
|
4
|
+
import { assertSafePathId } from "../utils/safe-paths.ts";
|
|
3
5
|
import type { TeamRunManifest, TeamTaskState } from "./types.ts";
|
|
4
6
|
|
|
5
7
|
export interface RunGraphNode {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
id: string;
|
|
9
|
+
type: "run" | "task" | "agent" | "artifact" | "file";
|
|
10
|
+
name: string;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
export interface RunGraphEdge {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
source: string;
|
|
16
|
+
target: string;
|
|
17
|
+
type: "dependsOn" | "produces" | "runs" | "contains";
|
|
18
|
+
weight?: number;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface RunGraphLayer {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
name: string;
|
|
23
|
+
nodeIds: string[];
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface RunGraph {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
version: "1.0.0";
|
|
28
|
+
runId: string;
|
|
29
|
+
team: string;
|
|
30
|
+
workflow: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
completedAt?: string;
|
|
33
|
+
status: string;
|
|
34
|
+
nodes: RunGraphNode[];
|
|
35
|
+
edges: RunGraphEdge[];
|
|
36
|
+
layers: RunGraphLayer[];
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -39,142 +41,151 @@ export interface RunGraph {
|
|
|
39
41
|
* Consolidates state into a single graph JSON for dashboard/API use.
|
|
40
42
|
*/
|
|
41
43
|
export function buildRunGraph(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
manifest: TeamRunManifest,
|
|
45
|
+
tasks: TeamTaskState[],
|
|
44
46
|
): RunGraph {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
47
|
+
const nodes: RunGraphNode[] = [];
|
|
48
|
+
const edges: RunGraphEdge[] = [];
|
|
49
|
+
const nodeIds = new Set<string>();
|
|
50
|
+
|
|
51
|
+
// Add run node
|
|
52
|
+
const runId = manifest.runId;
|
|
53
|
+
nodes.push({
|
|
54
|
+
id: `run:${runId}`,
|
|
55
|
+
type: "run",
|
|
56
|
+
name: manifest.goal ?? runId,
|
|
57
|
+
metadata: {
|
|
58
|
+
team: manifest.team,
|
|
59
|
+
workflow: manifest.workflow,
|
|
60
|
+
status: manifest.status,
|
|
61
|
+
createdAt: manifest.createdAt,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
nodeIds.add(`run:${runId}`);
|
|
65
|
+
|
|
66
|
+
// Add task nodes
|
|
67
|
+
for (const task of tasks) {
|
|
68
|
+
const taskId = `task:${task.id}`;
|
|
69
|
+
if (nodeIds.has(taskId)) continue;
|
|
70
|
+
nodeIds.add(taskId);
|
|
71
|
+
|
|
72
|
+
nodes.push({
|
|
73
|
+
id: taskId,
|
|
74
|
+
type: "task",
|
|
75
|
+
name: task.role,
|
|
76
|
+
metadata: {
|
|
77
|
+
status: task.status,
|
|
78
|
+
startedAt: task.startedAt,
|
|
79
|
+
finishedAt: task.finishedAt,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Edge from run to task
|
|
84
|
+
edges.push({
|
|
85
|
+
source: `run:${runId}`,
|
|
86
|
+
target: taskId,
|
|
87
|
+
type: "contains",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Edges from dependencies
|
|
91
|
+
for (const dep of task.dependsOn ?? []) {
|
|
92
|
+
edges.push({
|
|
93
|
+
source: `task:${dep}`,
|
|
94
|
+
target: taskId,
|
|
95
|
+
type: "dependsOn",
|
|
96
|
+
weight: 1.0,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Group by layer (based on phase or role)
|
|
102
|
+
const layerMap = new Map<string, string[]>();
|
|
103
|
+
for (const task of tasks) {
|
|
104
|
+
const layerName = task.adaptive?.phase ?? task.role;
|
|
105
|
+
if (!layerMap.has(layerName)) layerMap.set(layerName, []);
|
|
106
|
+
layerMap.get(layerName)!.push(`task:${task.id}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const layers: RunGraphLayer[] = [...layerMap.entries()].map(
|
|
110
|
+
([name, nodeIdList]) => ({
|
|
111
|
+
name,
|
|
112
|
+
nodeIds: nodeIdList,
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
version: "1.0.0",
|
|
118
|
+
runId,
|
|
119
|
+
team: manifest.team ?? "unknown",
|
|
120
|
+
workflow: manifest.workflow ?? "unknown",
|
|
121
|
+
createdAt: manifest.createdAt,
|
|
122
|
+
completedAt: manifest.updatedAt,
|
|
123
|
+
status: manifest.status,
|
|
124
|
+
nodes,
|
|
125
|
+
edges,
|
|
126
|
+
layers,
|
|
127
|
+
};
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
/**
|
|
128
|
-
* Save run graph to disk
|
|
131
|
+
* Save run graph to disk.
|
|
132
|
+
* Uses projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
|
|
133
|
+
* projects (issue #29 follow-up). Graph files now live alongside the
|
|
134
|
+
* manifest at `<crewRoot>/graphs/<runId>.json`.
|
|
129
135
|
*/
|
|
130
136
|
export function saveRunGraph(graph: RunGraph, cwd: string): string {
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
assertSafePathId("runId", graph.runId);
|
|
138
|
+
const crewRoot = projectCrewRoot(cwd);
|
|
139
|
+
const graphsDir = path.join(crewRoot, "graphs");
|
|
133
140
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
if (!fs.existsSync(graphsDir)) {
|
|
142
|
+
fs.mkdirSync(graphsDir, { recursive: true });
|
|
143
|
+
}
|
|
137
144
|
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
const graphPath = path.join(graphsDir, `${graph.runId}.json`);
|
|
146
|
+
fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2), "utf-8");
|
|
140
147
|
|
|
141
|
-
|
|
148
|
+
return graphPath;
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
/**
|
|
145
152
|
* Load run graph from disk.
|
|
146
153
|
*/
|
|
147
154
|
export function loadRunGraph(cwd: string, runId: string): RunGraph | null {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
assertSafePathId("runId", runId);
|
|
156
|
+
const crewRoot = projectCrewRoot(cwd);
|
|
157
|
+
const graphPath = path.join(crewRoot, "graphs", `${runId}.json`);
|
|
158
|
+
if (!fs.existsSync(graphPath)) return null;
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(fs.readFileSync(graphPath, "utf-8")) as RunGraph;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
/**
|
|
159
168
|
* List all archived run graphs.
|
|
160
169
|
*/
|
|
161
170
|
export function listRunGraphs(cwd: string): string[] {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
const crewRoot = projectCrewRoot(cwd);
|
|
172
|
+
const graphsDir = path.join(crewRoot, "graphs");
|
|
173
|
+
if (!fs.existsSync(graphsDir)) return [];
|
|
174
|
+
|
|
175
|
+
return fs
|
|
176
|
+
.readdirSync(graphsDir)
|
|
177
|
+
.filter((f) => f.endsWith(".json"))
|
|
178
|
+
.map((f) => f.replace(/\.json$/, ""));
|
|
168
179
|
}
|
|
169
180
|
|
|
170
181
|
/**
|
|
171
182
|
* Build and save run graph from manifest + tasks.
|
|
172
183
|
*/
|
|
173
184
|
export function buildAndSaveRunGraph(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
manifest: TeamRunManifest,
|
|
186
|
+
tasks: TeamTaskState[],
|
|
187
|
+
cwd: string,
|
|
177
188
|
): string {
|
|
178
|
-
|
|
179
|
-
|
|
189
|
+
const graph = buildRunGraph(manifest, tasks);
|
|
190
|
+
return saveRunGraph(graph, cwd);
|
|
180
191
|
}
|