pi-async 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +135 -1
- package/README.md +73 -19
- package/docs/CONTRACT.md +242 -53
- package/extensions/index.ts +211 -59
- package/package.json +4 -2
- package/src/core/duration.ts +8 -4
- package/src/core/env.ts +111 -0
- package/src/core/notify.ts +63 -15
- package/src/core/registry.ts +465 -41
- package/src/core/spawn.ts +46 -11
- package/src/core/task.ts +13 -0
- package/src/guard/settle-guard.ts +152 -43
- package/src/tools/index.ts +185 -30
- package/src/tui/panel.ts +308 -74
- package/src/tui/rail.ts +76 -45
- package/src/tui/status.ts +86 -30
package/src/core/registry.ts
CHANGED
|
@@ -1,22 +1,65 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
4
|
import { killTree, spawnDetached, streamPaths } from "./spawn.ts";
|
|
5
5
|
import { stripLogControls, truncateUtf8Bytes } from "./output.ts";
|
|
6
|
-
import { envNumber } from "./env.ts";
|
|
7
|
-
import { capName, deriveName, isTaskStatus, isTerminal, type FailureKind, type KillKind, type RunOptions, type SyncResult, type TaskMode, type TaskSnapshot, type WatchSpec, type WaitOutcome } from "./task.ts";
|
|
6
|
+
import { assertSafeEnvKeys, envNumber } from "./env.ts";
|
|
7
|
+
import { capName, deriveName, isTaskStatus, isTerminal, type FailureKind, type KillKind, type RunOptions, type SyncResult, type TaskMode, type TaskSnapshot, type TaskStatus, type WatchSpec, type WaitOutcome } from "./task.ts";
|
|
8
8
|
import { evalConditions, validateWatchSpec, WatchLoop, type WatchOutput } from "./watch.ts";
|
|
9
9
|
|
|
10
10
|
export interface RegistryEvents {
|
|
11
11
|
onFinalize(snapshot: TaskSnapshot): void;
|
|
12
12
|
onStateChange(snapshot: TaskSnapshot): void;
|
|
13
13
|
}
|
|
14
|
-
export interface RegistryConfig { dataDir: string; maxTasks?: number; maxOutputBytes?: number; retentionMs?: number; retentionCount?: number; }
|
|
14
|
+
export interface RegistryConfig { dataDir: string; cwd?: string; maxTasks?: number; maxOutputBytes?: number; retentionMs?: number; retentionCount?: number; }
|
|
15
|
+
export interface ListOptions { includeHistory?: boolean; status?: TaskStatus | "active"; name?: string; limit?: number; }
|
|
16
|
+
export type LogStream = "merged" | "stdout" | "stderr";
|
|
17
|
+
export interface LogSlice { text: string; truncated: boolean; fullPath: string; missing?: boolean; }
|
|
18
|
+
|
|
19
|
+
/** Registry fan-out event (REV-14/TUI refresh): `state` for every mutation, `finalize` once per terminal transition. */
|
|
20
|
+
export type RegistryEvent =
|
|
21
|
+
| { type: "state"; snapshot: TaskSnapshot }
|
|
22
|
+
| { type: "finalize"; snapshot: TaskSnapshot };
|
|
23
|
+
|
|
15
24
|
interface LiveTask { outputFile: string; exit?: Promise<{ code: number }>; timer?: ReturnType<typeof setTimeout>; delayTimer?: ReturnType<typeof setTimeout>; watcher?: ReturnType<typeof setInterval>; killKind?: KillKind; processExited?: boolean; }
|
|
16
25
|
interface Waiter { ids: Set<string>; resolve: () => void; }
|
|
17
26
|
const DEFAULT_MAX_OUTPUT = 20 * 1024 * 1024;
|
|
18
27
|
const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
28
|
const DEFAULT_RETENTION_COUNT = 100;
|
|
29
|
+
const MAX_LIST_LIMIT = 50;
|
|
30
|
+
const DEFAULT_RESEND_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
const DEFAULT_RESEND_MAX_ATTEMPTS = 2;
|
|
32
|
+
/** Default read window for `logs`; callers (tools/TUI) own the policy above this. */
|
|
33
|
+
const DEFAULT_LOG_BYTES = 50 * 1024;
|
|
34
|
+
/** Defensive ceiling for a single `logs` read: an accidental huge window is clamped, not honoured. */
|
|
35
|
+
const MAX_LOG_WINDOW = 8 * 1024 * 1024;
|
|
36
|
+
/** Artifacts owned by one task; migrated wholesale between data dirs. */
|
|
37
|
+
const ARTIFACT_SUFFIXES = [".json", ".output", ".stdout", ".stderr"] as const;
|
|
38
|
+
|
|
39
|
+
/** Read `length` bytes at `start`, tolerating short reads from a partially written file. */
|
|
40
|
+
function readExactly(handle: number, start: number, length: number): Buffer {
|
|
41
|
+
const buffer = Buffer.alloc(length);
|
|
42
|
+
let read = 0;
|
|
43
|
+
while (read < length) {
|
|
44
|
+
const got = readSync(handle, buffer, read, length - read, start + read);
|
|
45
|
+
if (got <= 0) break;
|
|
46
|
+
read += got;
|
|
47
|
+
}
|
|
48
|
+
return read === length ? buffer : buffer.subarray(0, read);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** UTF-8-safe decode of an already bounded window; mirrors `truncateUtf8Bytes` without the whole file. */
|
|
52
|
+
function truncateUtf8Window(buffer: Buffer, maxBytes: number, tail: boolean, truncated: boolean): string {
|
|
53
|
+
if (!truncated) return buffer.toString("utf8");
|
|
54
|
+
if (tail) {
|
|
55
|
+
let start = 0;
|
|
56
|
+
while (start < buffer.length && (buffer[start]! & 0xc0) === 0x80) start++;
|
|
57
|
+
return buffer.subarray(start).toString("utf8");
|
|
58
|
+
}
|
|
59
|
+
let end = Math.min(maxBytes, buffer.length);
|
|
60
|
+
while (end > 0 && (buffer[end]! & 0xc0) === 0x80) end--;
|
|
61
|
+
return buffer.subarray(0, end).toString("utf8");
|
|
62
|
+
}
|
|
20
63
|
|
|
21
64
|
export class TaskRegistry {
|
|
22
65
|
private readonly tasks = new Map<string, TaskSnapshot>();
|
|
@@ -24,33 +67,86 @@ export class TaskRegistry {
|
|
|
24
67
|
private readonly waiters = new Set<Waiter>();
|
|
25
68
|
private readonly notifyClaims = new Set<string>();
|
|
26
69
|
private readonly watchLoops = new Map<string, WatchLoop>();
|
|
70
|
+
private readonly listeners = new Set<(event: RegistryEvent) => void>();
|
|
27
71
|
private events: RegistryEvents = { onFinalize: () => undefined, onStateChange: () => undefined };
|
|
28
72
|
private readonly maxTasks: number;
|
|
29
73
|
private readonly maxOutputBytes: number;
|
|
30
74
|
private readonly retentionMs: number;
|
|
31
75
|
private readonly retentionCount: number;
|
|
76
|
+
private config: RegistryConfig;
|
|
32
77
|
private closed = false;
|
|
78
|
+
/** Set by `suspend()`: this generation is retired and must not settle adopted work (REV-17). */
|
|
79
|
+
private suspended = false;
|
|
33
80
|
private reloading = false;
|
|
34
81
|
private shutdownPromise?: Promise<void>;
|
|
82
|
+
private configured = false;
|
|
83
|
+
private dataDirReady = false;
|
|
35
84
|
|
|
36
|
-
private readonly config: RegistryConfig;
|
|
37
85
|
constructor(config: RegistryConfig) {
|
|
38
|
-
|
|
86
|
+
// NOTE (D-04): the constructor no longer creates the data dir — the factory must stay
|
|
87
|
+
// side-effect free. `persist()` and `configure()` create it on demand.
|
|
88
|
+
this.config = { ...config };
|
|
39
89
|
this.maxTasks = config.maxTasks ?? envNumber("PI_ASYNC_MAX_TASKS", 32, { integer: true, min: 1 });
|
|
40
90
|
this.maxOutputBytes = config.maxOutputBytes ?? envNumber("PI_ASYNC_MAX_OUTPUT_BYTES", DEFAULT_MAX_OUTPUT, { integer: true, min: 0 });
|
|
41
91
|
this.retentionMs = config.retentionMs ?? envNumber("PI_ASYNC_RETENTION_MS", DEFAULT_RETENTION_MS, { integer: true, min: 0 });
|
|
42
92
|
this.retentionCount = config.retentionCount ?? envNumber("PI_ASYNC_RETENTION_COUNT", DEFAULT_RETENTION_COUNT, { integer: true, min: 0 });
|
|
43
|
-
mkdirSync(config.dataDir, { recursive: true });
|
|
44
93
|
}
|
|
45
94
|
|
|
46
95
|
setEvents(events: RegistryEvents): void { this.events = events; }
|
|
47
96
|
|
|
97
|
+
/** Subscribe to registry state/finalize events; returns an unsubscribe function. */
|
|
98
|
+
subscribe(listener: (event: RegistryEvent) => void): () => void {
|
|
99
|
+
this.listeners.add(listener);
|
|
100
|
+
return () => { this.listeners.delete(listener); };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Session-scoped configuration (REV-13/REV-17). Idempotent.
|
|
105
|
+
*
|
|
106
|
+
* First call switches this registry to the session data dir + cwd and loads persisted
|
|
107
|
+
* tasks. Later calls only react to changes; a data-dir change migrates task artifacts
|
|
108
|
+
* instead of dropping them. Migration failures keep the old dir and never throw (D-05):
|
|
109
|
+
* this runs inside a `session_start` handler where a throw becomes extension noise.
|
|
110
|
+
*/
|
|
111
|
+
configure(opts: { dataDir?: string; cwd?: string }): void {
|
|
112
|
+
const first = !this.configured;
|
|
113
|
+
this.configured = true;
|
|
114
|
+
if (opts.cwd !== undefined && opts.cwd.length > 0) this.config = { ...this.config, cwd: opts.cwd };
|
|
115
|
+
const nextDataDir = opts.dataDir;
|
|
116
|
+
const changed = nextDataDir !== undefined && nextDataDir !== this.config.dataDir;
|
|
117
|
+
if (changed) {
|
|
118
|
+
if (this.tasks.size > 0) {
|
|
119
|
+
if (!this.migrateDataDir(nextDataDir)) {
|
|
120
|
+
this.ensureDataDirQuietly();
|
|
121
|
+
if (first) {
|
|
122
|
+
void this.reloadFromDisk();
|
|
123
|
+
this.reapOrphans();
|
|
124
|
+
}
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
this.switchDataDir(nextDataDir);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// D-04: the session data dir is created here or on first persist — never in the constructor.
|
|
132
|
+
this.ensureDataDirQuietly();
|
|
133
|
+
if (first || changed) {
|
|
134
|
+
// reloadFromDisk() has no await points, so it is fully applied when it returns; reaping
|
|
135
|
+
// keeps this generation from adopting snapshots whose processes are already gone (#44).
|
|
136
|
+
void this.reloadFromDisk();
|
|
137
|
+
this.reapOrphans();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
48
141
|
submit(opts: RunOptions): TaskSnapshot {
|
|
49
142
|
if (opts.mode && opts.mode !== "background") throw new Error("submit only accepts background mode");
|
|
50
143
|
return this.createAndStart(opts, "background");
|
|
51
144
|
}
|
|
52
145
|
|
|
53
146
|
scheduleDelayed(opts: RunOptions, delayMs: number): TaskSnapshot {
|
|
147
|
+
// Fail fast: delayed tasks spawn later, so dangerous env overrides must be rejected at submit
|
|
148
|
+
// time exactly like background/sync (spawnDetached would otherwise only fail at due time).
|
|
149
|
+
assertSafeEnvKeys(opts.env, "safe");
|
|
54
150
|
this.assertAdmission(opts);
|
|
55
151
|
if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error("delayMs must be a non-negative finite number");
|
|
56
152
|
this.validateRunWatch(opts, "delayed");
|
|
@@ -60,6 +156,7 @@ export class TaskRegistry {
|
|
|
60
156
|
const task: TaskSnapshot = {
|
|
61
157
|
taskId, name: opts.name ? capName(opts.name) : deriveName(opts.command), command: opts.command, mode: "delayed", status: "pending",
|
|
62
158
|
createdAt, dueAt: createdAt + delayMs, taskTimeoutMs: opts.taskTimeoutMs, notified: false, outputConsumed: false,
|
|
159
|
+
cwd: opts.cwd ?? this.config.cwd ?? process.cwd(),
|
|
63
160
|
...(opts.watch ? { watch: opts.watch } : {}),
|
|
64
161
|
metadata: { notifyOnCompletion: opts.notifyOnCompletion ?? true, triggerOnCompletion: opts.triggerOnCompletion ?? true, outputFile, ...(opts.env ? { delayedEnv: opts.env } : {}) },
|
|
65
162
|
};
|
|
@@ -97,7 +194,24 @@ export class TaskRegistry {
|
|
|
97
194
|
return this.syncResult(task, promoted);
|
|
98
195
|
}
|
|
99
196
|
|
|
100
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Resolve a user-supplied task reference. Ids win; otherwise an exact (case-insensitive)
|
|
199
|
+
* name is required, and duplicate names are an error listing candidate ids (REV-15).
|
|
200
|
+
*/
|
|
201
|
+
resolveTaskId(idOrName: string): TaskSnapshot {
|
|
202
|
+
const direct = this.tasks.get(idOrName);
|
|
203
|
+
if (direct) return direct;
|
|
204
|
+
const needle = idOrName.trim().toLowerCase();
|
|
205
|
+
const matches = needle.length === 0 ? [] : [...this.tasks.values()].filter((task) => task.name.toLowerCase() === needle);
|
|
206
|
+
const [match] = matches;
|
|
207
|
+
if (match && matches.length === 1) return match;
|
|
208
|
+
if (matches.length > 1) {
|
|
209
|
+
throw new Error(`Ambiguous task name "${idOrName}": matches ${matches.map((task) => task.taskId).join(", ")}`);
|
|
210
|
+
}
|
|
211
|
+
throw new Error(`Unknown task or name: ${idOrName}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
attachWatch(taskId: string, spec: WatchSpec, deadline?: number): void {
|
|
101
215
|
const validation = validateWatchSpec(spec);
|
|
102
216
|
if (!validation.ok) throw new Error(`Invalid watch spec: ${validation.error}`);
|
|
103
217
|
const task = this.tasks.get(taskId);
|
|
@@ -105,8 +219,17 @@ export class TaskRegistry {
|
|
|
105
219
|
if (isTerminal(task.status)) throw new Error(`Cannot attach watch to terminal task ${taskId}`);
|
|
106
220
|
const existing = this.watchLoops.get(taskId);
|
|
107
221
|
existing?.stop();
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
// Persist the absolute watch deadline so a reload can restore the remaining budget (D-03).
|
|
224
|
+
// Pending delayed tasks get their deadline when the task actually starts (startTask).
|
|
225
|
+
if (deadline !== undefined) task.watchDeadline = deadline;
|
|
226
|
+
else if (task.status === "running") task.watchDeadline = now + spec.timeoutMs;
|
|
227
|
+
else delete task.watchDeadline;
|
|
228
|
+
// The loop only understands relative timeouts; feed it the remaining budget without
|
|
229
|
+
// rewriting the user-visible spec (`task.watch.timeoutMs` stays the original value).
|
|
230
|
+
const loopSpec: WatchSpec = task.watchDeadline === undefined ? spec : { ...spec, timeoutMs: Math.max(1, task.watchDeadline - now) };
|
|
108
231
|
const loop = new WatchLoop({
|
|
109
|
-
taskId, spec,
|
|
232
|
+
taskId, spec: loopSpec,
|
|
110
233
|
readOutput: () => this.readWatchOutput(taskId),
|
|
111
234
|
getExitCode: () => this.tasks.get(taskId)?.exitCode,
|
|
112
235
|
onSuccess: (detail) => this.markWatchSuccess(taskId, detail),
|
|
@@ -129,10 +252,13 @@ export class TaskRegistry {
|
|
|
129
252
|
},
|
|
130
253
|
});
|
|
131
254
|
this.watchLoops.set(taskId, loop);
|
|
255
|
+
this.persist(task);
|
|
132
256
|
if (task.status === "running") loop.start();
|
|
133
257
|
}
|
|
134
258
|
private assertAdmission(opts: RunOptions): void {
|
|
135
259
|
if (this.closed) throw new Error("Async task admission is closed");
|
|
260
|
+
// A retired generation must not start work: the next generation owns the session data dir.
|
|
261
|
+
if (this.suspended) throw new Error("Async task admission is closed (registry suspended)");
|
|
136
262
|
const active = this.activeCount();
|
|
137
263
|
if (active.running + active.pending >= this.maxTasks) throw new Error(`Maximum active async tasks reached (${this.maxTasks})`);
|
|
138
264
|
if (!opts.command.trim()) throw new Error("command must not be empty");
|
|
@@ -174,9 +300,19 @@ export class TaskRegistry {
|
|
|
174
300
|
if (live.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
|
|
175
301
|
task.status = "running";
|
|
176
302
|
task.startedAt = Date.now();
|
|
177
|
-
|
|
303
|
+
// REV-13: always run in the resolved session cwd, never the host process cwd.
|
|
304
|
+
task.cwd = opts.cwd ?? this.config.cwd ?? process.cwd();
|
|
305
|
+
if (task.watch) task.watchDeadline = Date.now() + task.watch.timeoutMs;
|
|
306
|
+
const handle = spawnDetached(task.command, {
|
|
307
|
+
outputFile: live.outputFile,
|
|
308
|
+
env: { ...opts.env, PI_ASYNC_INTERNAL_MAX_OUTPUT_BYTES: String(this.maxOutputBytes) },
|
|
309
|
+
cwd: task.cwd,
|
|
310
|
+
});
|
|
178
311
|
task.pid = handle.pid;
|
|
179
312
|
live.exit = handle.exit.then(({ code }) => {
|
|
313
|
+
// A retired generation no longer owns this process: the next generation adopted it via
|
|
314
|
+
// reloadFromDisk and its snapshot wins (no finalize, no persist, no emit).
|
|
315
|
+
if (this.suspended) return { code };
|
|
180
316
|
live.processExited = true;
|
|
181
317
|
if (live.timer) { clearTimeout(live.timer); live.timer = undefined; }
|
|
182
318
|
task.exitCode = code;
|
|
@@ -199,21 +335,35 @@ export class TaskRegistry {
|
|
|
199
335
|
}
|
|
200
336
|
return { code };
|
|
201
337
|
}).catch(() => {
|
|
338
|
+
if (this.suspended) return { code: 1 };
|
|
202
339
|
live.processExited = true;
|
|
203
340
|
if (!isTerminal(task.status)) this.finalize(task, "failed", 1);
|
|
204
341
|
return { code: 1 };
|
|
205
342
|
});
|
|
343
|
+
if (opts.taskTimeoutMs !== undefined && opts.taskTimeoutMs > 0) {
|
|
344
|
+
// Absolute deadline so a reload can re-arm only the remaining time (D-03).
|
|
345
|
+
task.timeoutAt = task.timeoutAt ?? Date.now() + opts.taskTimeoutMs;
|
|
346
|
+
live.timer = this.armTaskTimeout(task, Math.max(1, task.timeoutAt - Date.now()));
|
|
347
|
+
}
|
|
206
348
|
this.persist(task);
|
|
207
|
-
|
|
349
|
+
this.emitState(task);
|
|
350
|
+
const watch = this.watchLoops.get(task.taskId);
|
|
351
|
+
if (watch) watch.start();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Arm (or re-arm) the task-timeout kill for a running task. */
|
|
355
|
+
private armTaskTimeout(task: TaskSnapshot, remainingMs: number, opts: { unref?: boolean } = {}): ReturnType<typeof setTimeout> {
|
|
356
|
+
const live = this.live.get(task.taskId);
|
|
357
|
+
const timer = setTimeout(() => {
|
|
208
358
|
if (isTerminal(task.status)) {
|
|
209
|
-
if (!live.processExited && task.pid) void killTree(task.pid).catch((error: unknown) => console.error(`pi-async: timeout kill failed for completed task ${task.taskId}`, error));
|
|
359
|
+
if (live && !live.processExited && task.pid) void killTree(task.pid).catch((error: unknown) => console.error(`pi-async: timeout kill failed for completed task ${task.taskId}`, error));
|
|
210
360
|
return;
|
|
211
361
|
}
|
|
212
362
|
void this.kill(task.taskId, "timeout").catch((error: unknown) => console.error(`pi-async: timeout kill failed for task ${task.taskId}`, error));
|
|
213
|
-
},
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
363
|
+
}, remainingMs);
|
|
364
|
+
// A timeout restored from disk must not be the only thing keeping the host process alive.
|
|
365
|
+
if (opts.unref) timer.unref?.();
|
|
366
|
+
return timer;
|
|
217
367
|
}
|
|
218
368
|
|
|
219
369
|
private scheduleDelayedStart(task: TaskSnapshot, delayMs: number): void {
|
|
@@ -229,22 +379,27 @@ export class TaskRegistry {
|
|
|
229
379
|
triggerOnCompletion: task.metadata.triggerOnCompletion !== false,
|
|
230
380
|
...(rawEnv && typeof rawEnv === "object" ? { env: rawEnv as Record<string, string> } : {}),
|
|
231
381
|
...(task.watch ? { watch: task.watch } : {}),
|
|
382
|
+
...(task.cwd !== undefined ? { cwd: task.cwd } : {}),
|
|
232
383
|
};
|
|
233
384
|
try { this.startTask(task, opts); }
|
|
234
|
-
catch
|
|
385
|
+
catch (error) {
|
|
386
|
+
console.error(`pi-async: delayed task ${task.taskId} failed to start`, error);
|
|
387
|
+
task.metadata = { ...task.metadata, startError: error instanceof Error ? error.message : String(error) };
|
|
388
|
+
this.finalize(task, "failed", 1);
|
|
389
|
+
}
|
|
235
390
|
}, delayMs);
|
|
236
391
|
}
|
|
237
392
|
|
|
238
393
|
private readWatchOutput(taskId: string): WatchOutput {
|
|
239
394
|
const task = this.tasks.get(taskId);
|
|
240
395
|
if (!task) throw new Error(`Unknown task id: ${taskId}`);
|
|
241
|
-
const outputFile = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
|
|
396
|
+
const outputFile = this.assertInsideDataDir(String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`)));
|
|
242
397
|
// Missing primary output must throw so WatchLoop blind_checks can fire (#37/#68 + CONTRACT).
|
|
243
398
|
// stripLogControls: ANSI + bare C0 for model/watch paths (#18); streams stay distinct (#4).
|
|
244
399
|
const output = stripLogControls(readFileSync(outputFile, "utf8"));
|
|
245
|
-
const {
|
|
400
|
+
const { stdout, stderr } = streamPaths(outputFile);
|
|
246
401
|
const readSide = (file: string): string => existsSync(file) ? stripLogControls(readFileSync(file, "utf8")) : "";
|
|
247
|
-
return { output, stdout: readSide(
|
|
402
|
+
return { output, stdout: readSide(stdout), stderr: readSide(stderr) };
|
|
248
403
|
}
|
|
249
404
|
|
|
250
405
|
private syncResult(task: TaskSnapshot, promoted: boolean): SyncResult {
|
|
@@ -262,7 +417,32 @@ export class TaskRegistry {
|
|
|
262
417
|
|
|
263
418
|
markWatchSuccess(taskId: string, detail: string): void { const task = this.tasks.get(taskId); if (!task || isTerminal(task.status)) return; task.metadata.watchSuccess = detail; this.finalize(task, "completed", task.exitCode); }
|
|
264
419
|
get(taskId: string): TaskSnapshot | undefined { return this.tasks.get(taskId); }
|
|
265
|
-
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* List snapshots. `includeHistory`/`status`/`name` filter; `limit` clamps to 1–50 when given
|
|
423
|
+
* (the tool layer supplies the default so internal prune/reload callers stay unbounded).
|
|
424
|
+
* History-wide queries sort newest-first (D-08).
|
|
425
|
+
*/
|
|
426
|
+
list(opts: ListOptions = {}): TaskSnapshot[] {
|
|
427
|
+
let items = [...this.tasks.values()];
|
|
428
|
+
if (opts.includeHistory === false) items = items.filter((task) => !isTerminal(task.status));
|
|
429
|
+
if (opts.status !== undefined) {
|
|
430
|
+
const status = opts.status;
|
|
431
|
+
items = status === "active" ? items.filter((task) => !isTerminal(task.status)) : items.filter((task) => task.status === status);
|
|
432
|
+
}
|
|
433
|
+
if (opts.name !== undefined) {
|
|
434
|
+
const needle = opts.name.trim().toLowerCase();
|
|
435
|
+
items = items.filter((task) => task.name.toLowerCase().includes(needle));
|
|
436
|
+
}
|
|
437
|
+
if (opts.includeHistory === true || opts.status !== undefined) {
|
|
438
|
+
items = [...items].sort((a, b) => (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt));
|
|
439
|
+
}
|
|
440
|
+
if (opts.limit !== undefined && Number.isFinite(opts.limit)) {
|
|
441
|
+
const limit = Math.min(MAX_LIST_LIMIT, Math.max(1, Math.trunc(opts.limit)));
|
|
442
|
+
items = items.slice(0, limit);
|
|
443
|
+
}
|
|
444
|
+
return items;
|
|
445
|
+
}
|
|
266
446
|
|
|
267
447
|
wait(taskIds: string[] | string, timeoutMs: number, signal?: AbortSignal): Promise<WaitOutcome> {
|
|
268
448
|
// A bare string is one id; String is iterable so `[...new Set("uuid")]` would split into characters (#40).
|
|
@@ -300,16 +480,44 @@ export class TaskRegistry {
|
|
|
300
480
|
});
|
|
301
481
|
}
|
|
302
482
|
|
|
303
|
-
|
|
483
|
+
/**
|
|
484
|
+
* Read bounded task output. `stream` selects merged/stdout/stderr; a missing file returns
|
|
485
|
+
* `missing: true` instead of throwing, while a path outside the data dir throws (D-11).
|
|
486
|
+
*
|
|
487
|
+
* Only the requested window is read (open/fstat/read, tail by default), so a multi-GB output
|
|
488
|
+
* file costs one bounded read instead of a full-file load. The size policy belongs to the
|
|
489
|
+
* caller (`maxBytes` defaults to 50 KiB; tools cap `async_logs`, the TUI may ask for a larger
|
|
490
|
+
* window), while `MAX_LOG_WINDOW` keeps a mistaken huge request from allocating unboundedly.
|
|
491
|
+
*/
|
|
492
|
+
logs(taskId: string, opts: { maxBytes?: number; tail?: boolean; stream?: LogStream } = {}): LogSlice {
|
|
304
493
|
const task = this.tasks.get(taskId);
|
|
305
494
|
if (!task) throw new Error(`Unknown task id: ${taskId}`);
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
const
|
|
312
|
-
|
|
495
|
+
const mergedPath = String(task.metadata.outputFile ?? this.live.get(taskId)?.outputFile ?? join(this.config.dataDir, `${taskId}.output`));
|
|
496
|
+
const stream = opts.stream ?? "merged";
|
|
497
|
+
const { stdout, stderr } = streamPaths(mergedPath);
|
|
498
|
+
const requestPath = stream === "stdout" ? stdout : stream === "stderr" ? stderr : mergedPath;
|
|
499
|
+
const fullPath = this.assertInsideDataDir(requestPath);
|
|
500
|
+
const requestedBytes = opts.maxBytes ?? DEFAULT_LOG_BYTES;
|
|
501
|
+
const boundedBytes = Number.isFinite(requestedBytes) ? Math.max(0, Math.trunc(requestedBytes)) : DEFAULT_LOG_BYTES;
|
|
502
|
+
const maxBytes = Math.min(boundedBytes, MAX_LOG_WINDOW);
|
|
503
|
+
const tail = opts.tail !== false;
|
|
504
|
+
let handle: number | undefined;
|
|
505
|
+
try {
|
|
506
|
+
handle = openSync(fullPath, "r");
|
|
507
|
+
const { size } = fstatSync(handle);
|
|
508
|
+
const truncated = size > maxBytes;
|
|
509
|
+
if (maxBytes === 0) return { text: "", truncated, fullPath };
|
|
510
|
+
// Non-tail reads take one extra byte so the UTF-8 cut can inspect the excluded byte.
|
|
511
|
+
const start = tail ? Math.max(0, size - maxBytes) : 0;
|
|
512
|
+
const length = Math.min(size, tail ? maxBytes : maxBytes + (truncated ? 1 : 0));
|
|
513
|
+
const buffer = readExactly(handle, start, length);
|
|
514
|
+
return { text: stripLogControls(truncateUtf8Window(buffer, maxBytes, tail, truncated)), truncated, fullPath };
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { text: "", truncated: false, fullPath, missing: true };
|
|
517
|
+
throw error;
|
|
518
|
+
} finally {
|
|
519
|
+
if (handle !== undefined) closeSync(handle);
|
|
520
|
+
}
|
|
313
521
|
}
|
|
314
522
|
|
|
315
523
|
async kill(taskId: string, kind: KillKind): Promise<void> {
|
|
@@ -350,14 +558,120 @@ export class TaskRegistry {
|
|
|
350
558
|
return true;
|
|
351
559
|
}
|
|
352
560
|
consume(taskId: string): void { const task = this.tasks.get(taskId); if (!task) return; task.outputConsumed = true; this.persist(task); }
|
|
561
|
+
|
|
562
|
+
/** In-memory send claim only (D-01): `notified` is owned by `markNotified`. */
|
|
353
563
|
claimNotification(taskId: string): boolean {
|
|
354
564
|
const task = this.tasks.get(taskId);
|
|
355
565
|
if (!task || task.notified || task.outputConsumed || this.notifyClaims.has(taskId)) return false;
|
|
356
|
-
this.notifyClaims.add(taskId);
|
|
566
|
+
this.notifyClaims.add(taskId);
|
|
567
|
+
return true;
|
|
357
568
|
}
|
|
358
|
-
|
|
569
|
+
/** Record a delivery attempt before calling the host (no retries: attempts stay honest). */
|
|
570
|
+
noteNotifyAttempt(taskId: string): void {
|
|
571
|
+
const task = this.tasks.get(taskId);
|
|
572
|
+
if (!task) return;
|
|
573
|
+
task.notifyAttempts = (task.notifyAttempts ?? 0) + 1;
|
|
574
|
+
this.persist(task);
|
|
575
|
+
}
|
|
576
|
+
/** Host accepted the message; the claim is kept for the rest of the generation. */
|
|
577
|
+
markNotified(taskId: string): void {
|
|
578
|
+
const task = this.tasks.get(taskId);
|
|
579
|
+
if (!task) return;
|
|
580
|
+
task.notified = true;
|
|
581
|
+
this.persist(task);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Release the in-memory claim after a failed delivery. `notified` is untouched: it is only
|
|
585
|
+
* ever set by `markNotified`, so a failed send leaves it false and `resendUnnotified` can retry.
|
|
586
|
+
*/
|
|
587
|
+
releaseNotification(taskId: string): void {
|
|
588
|
+
this.notifyClaims.delete(taskId);
|
|
589
|
+
}
|
|
590
|
+
|
|
359
591
|
activeCount(): { running: number; pending: number } { let running = 0; let pending = 0; for (const task of this.tasks.values()) { if (task.status === "running") running++; else if (task.status === "pending") pending++; } return { running, pending }; }
|
|
360
592
|
hasActiveWork(): boolean { const count = this.activeCount(); return count.running + count.pending > 0; }
|
|
593
|
+
|
|
594
|
+
/** Liveness probe for `async_kill` (D-07): no pid (or an exited child) → false. */
|
|
595
|
+
isProcessAlive(taskId: string): boolean {
|
|
596
|
+
const task = this.tasks.get(taskId);
|
|
597
|
+
if (!task || task.pid === undefined) return false;
|
|
598
|
+
const live = this.live.get(taskId);
|
|
599
|
+
if (live?.processExited) return false;
|
|
600
|
+
return this.pidAlive(task.pid);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Stop every timer/watcher and persist, but never kill (REV-17 `/reload`):
|
|
605
|
+
* the next generation adopts the surviving processes via `reloadFromDisk`.
|
|
606
|
+
*/
|
|
607
|
+
async suspend(): Promise<void> {
|
|
608
|
+
// Retire first, so a child exit landing during teardown is already ignored.
|
|
609
|
+
this.suspended = true;
|
|
610
|
+
for (const task of this.tasks.values()) {
|
|
611
|
+
const live = this.live.get(task.taskId);
|
|
612
|
+
if (live?.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
|
|
613
|
+
if (live?.timer) { clearTimeout(live.timer); live.timer = undefined; }
|
|
614
|
+
if (live?.watcher) { clearInterval(live.watcher); live.watcher = undefined; }
|
|
615
|
+
this.watchLoops.get(task.taskId)?.stop();
|
|
616
|
+
this.watchLoops.delete(task.taskId);
|
|
617
|
+
try { this.persist(task); } catch (error) { console.error(`pi-async: suspend could not persist task ${task.taskId}`, error); }
|
|
618
|
+
}
|
|
619
|
+
// Pending waiters belong to the retired generation; never wake them with stale results.
|
|
620
|
+
this.waiters.clear();
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Re-send completion notifications the host never accepted (REV-14 `session_start` backfill).
|
|
625
|
+
* Only the existing `onFinalize` hook is replayed (D-02) — the registry owns no notifier.
|
|
626
|
+
*/
|
|
627
|
+
async resendUnnotified(opts: { maxAgeMs?: number; maxAttempts?: number } = {}): Promise<number> {
|
|
628
|
+
const maxAgeMs = opts.maxAgeMs ?? DEFAULT_RESEND_MAX_AGE_MS;
|
|
629
|
+
const maxAttempts = opts.maxAttempts ?? DEFAULT_RESEND_MAX_ATTEMPTS;
|
|
630
|
+
const now = Date.now();
|
|
631
|
+
let resent = 0;
|
|
632
|
+
for (const task of [...this.tasks.values()]) {
|
|
633
|
+
if (!isTerminal(task.status)) continue;
|
|
634
|
+
if (task.notified === true || task.outputConsumed === true) continue;
|
|
635
|
+
if (task.metadata.notifyOnCompletion === false) continue;
|
|
636
|
+
if ((task.notifyAttempts ?? 0) >= maxAttempts) continue;
|
|
637
|
+
if (now - (task.finishedAt ?? task.createdAt) > maxAgeMs) continue;
|
|
638
|
+
try {
|
|
639
|
+
this.events.onFinalize(task);
|
|
640
|
+
this.emit({ type: "finalize", snapshot: task });
|
|
641
|
+
resent++;
|
|
642
|
+
} catch (error) {
|
|
643
|
+
console.error(`pi-async: resend of completion notification failed for ${task.taskId}`, error);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return resent;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Mark orphaned non-terminal tasks `failed/lost` (REV-16 startup hygiene):
|
|
651
|
+
* a running task whose process group is gone is dead; pending delayed tasks are kept.
|
|
652
|
+
*/
|
|
653
|
+
reapOrphans(now: number = Date.now()): number {
|
|
654
|
+
let reaped = 0;
|
|
655
|
+
for (const task of [...this.tasks.values()]) {
|
|
656
|
+
if (isTerminal(task.status)) continue;
|
|
657
|
+
if (task.status === "pending") {
|
|
658
|
+
// Pending delayed tasks hold no OS resources — their due timer is the source of truth…
|
|
659
|
+
// …unless the due time itself fell out of the retention window (PLAN §2.4).
|
|
660
|
+
if (task.dueAt === undefined || now - task.dueAt <= this.retentionMs) continue;
|
|
661
|
+
task.failureKind = "lost";
|
|
662
|
+
this.finalize(task, "failed");
|
|
663
|
+
reaped++;
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
const pid = task.pid;
|
|
667
|
+
if (pid !== undefined && this.pidAlive(pid)) continue;
|
|
668
|
+
task.failureKind = "lost";
|
|
669
|
+
this.finalize(task, "failed");
|
|
670
|
+
reaped++;
|
|
671
|
+
}
|
|
672
|
+
return reaped;
|
|
673
|
+
}
|
|
674
|
+
|
|
361
675
|
shutdown(): Promise<void> {
|
|
362
676
|
if (this.shutdownPromise) return this.shutdownPromise;
|
|
363
677
|
this.closed = true;
|
|
@@ -390,10 +704,13 @@ export class TaskRegistry {
|
|
|
390
704
|
})();
|
|
391
705
|
return this.shutdownPromise;
|
|
392
706
|
}
|
|
707
|
+
|
|
393
708
|
async reloadFromDisk(): Promise<void> {
|
|
394
709
|
this.reloading = true;
|
|
395
710
|
try {
|
|
396
|
-
|
|
711
|
+
let files: string[] = [];
|
|
712
|
+
try { files = readdirSync(this.config.dataDir); } catch { /* data dir not created yet */ }
|
|
713
|
+
for (const file of files.filter((name) => name.endsWith(".json"))) {
|
|
397
714
|
try {
|
|
398
715
|
const snapshot = JSON.parse(readFileSync(join(this.config.dataDir, file), "utf8")) as TaskSnapshot;
|
|
399
716
|
// #34: skip unknown status values from disk.
|
|
@@ -425,9 +742,18 @@ export class TaskRegistry {
|
|
|
425
742
|
}
|
|
426
743
|
}, 100);
|
|
427
744
|
live.watcher.unref?.();
|
|
428
|
-
|
|
745
|
+
// D-03: re-arm only the remaining task-timeout budget instead of a fresh full window.
|
|
746
|
+
if (snapshot.status === "running" && snapshot.taskTimeoutMs !== undefined && snapshot.taskTimeoutMs > 0) {
|
|
747
|
+
const anchor = snapshot.timeoutAt ?? (snapshot.startedAt ?? snapshot.createdAt) + snapshot.taskTimeoutMs;
|
|
748
|
+
live.timer = this.armTaskTimeout(snapshot, Math.max(1, anchor - Date.now()), { unref: true });
|
|
749
|
+
}
|
|
750
|
+
// D-03: restore the watch window from the persisted absolute deadline.
|
|
751
|
+
if (snapshot.status === "running" && snapshot.watch) this.attachWatch(snapshot.taskId, snapshot.watch, snapshot.watchDeadline);
|
|
429
752
|
} else if (snapshot.status === "running") {
|
|
430
|
-
|
|
753
|
+
// Orphaned on disk: settle it through the same path as `reapOrphans()` so the
|
|
754
|
+
// snapshot also records `processAlive = false` (D-07) and emits one finalize.
|
|
755
|
+
snapshot.failureKind = "lost";
|
|
756
|
+
this.finalize(snapshot, "failed");
|
|
431
757
|
}
|
|
432
758
|
} catch { /* Ignore incomplete/corrupt metadata files; atomic writer leaves prior version intact. */ }
|
|
433
759
|
}
|
|
@@ -443,7 +769,7 @@ export class TaskRegistry {
|
|
|
443
769
|
* Policy (per dataDir): keep the union of (1) the newest `retentionCount`
|
|
444
770
|
* terminal tasks by finishedAt/createdAt and (2) any terminal task still
|
|
445
771
|
* younger than `retentionMs`. Everything else terminal is deleted
|
|
446
|
-
* (.json + .output via metadata.outputFile) and removed from memory.
|
|
772
|
+
* (.json + .output/.stdout/.stderr via metadata.outputFile) and removed from memory.
|
|
447
773
|
* Non-terminal tasks are never touched. Missing files are ignored.
|
|
448
774
|
*/
|
|
449
775
|
prune(now = Date.now()): { pruned: number } {
|
|
@@ -465,8 +791,12 @@ export class TaskRegistry {
|
|
|
465
791
|
|
|
466
792
|
private removeArtifacts(task: TaskSnapshot): void {
|
|
467
793
|
const outputFile = String(task.metadata.outputFile ?? join(this.config.dataDir, `${task.taskId}.output`));
|
|
468
|
-
|
|
469
|
-
|
|
794
|
+
const { stdout, stderr } = streamPaths(outputFile);
|
|
795
|
+
// D-11: never delete outside the data dir (skip silently so prune cannot crash).
|
|
796
|
+
for (const file of [outputFile, stdout, stderr, join(this.config.dataDir, `${task.taskId}.json`)]) {
|
|
797
|
+
if (!this.isInsideDataDir(file)) continue;
|
|
798
|
+
try { rmSync(file, { force: true }); } catch { /* force already ignores ENOENT; swallow other rare races */ }
|
|
799
|
+
}
|
|
470
800
|
const live = this.live.get(task.taskId);
|
|
471
801
|
if (live?.timer) clearTimeout(live.timer);
|
|
472
802
|
if (live?.delayTimer) clearTimeout(live.delayTimer);
|
|
@@ -492,6 +822,8 @@ export class TaskRegistry {
|
|
|
492
822
|
private finalize(task: TaskSnapshot, status: TaskSnapshot["status"], code?: number, killKind?: KillKind): void {
|
|
493
823
|
if (isTerminal(task.status)) return;
|
|
494
824
|
task.status = status; task.exitCode = code; task.killKind = killKind; task.finishedAt = Date.now();
|
|
825
|
+
// D-07: a terminal snapshot never claims a live process.
|
|
826
|
+
task.processAlive = false;
|
|
495
827
|
const live = this.live.get(task.taskId);
|
|
496
828
|
if (live?.delayTimer) { clearTimeout(live.delayTimer); live.delayTimer = undefined; }
|
|
497
829
|
const watch = this.watchLoops.get(task.taskId); watch?.stop(); this.watchLoops.delete(task.taskId);
|
|
@@ -503,12 +835,37 @@ export class TaskRegistry {
|
|
|
503
835
|
if ([...this.waiters].some((waiter) => waiter.ids.has(task.taskId))) {
|
|
504
836
|
task.outputConsumed = true;
|
|
505
837
|
}
|
|
506
|
-
this.
|
|
838
|
+
this.emitState(task);
|
|
839
|
+
this.finalizeEvent(task);
|
|
507
840
|
for (const waiter of [...this.waiters]) if (waiter.ids.has(task.taskId)) waiter.resolve();
|
|
508
841
|
if (!this.reloading) this.prune();
|
|
509
842
|
}
|
|
510
|
-
private
|
|
843
|
+
private finalizeEvent(task: TaskSnapshot): void {
|
|
844
|
+
this.events.onFinalize(task);
|
|
845
|
+
this.emit({ type: "finalize", snapshot: task });
|
|
846
|
+
}
|
|
847
|
+
private emitState(task: TaskSnapshot): void {
|
|
848
|
+
this.persist(task);
|
|
849
|
+
this.events.onStateChange(task);
|
|
850
|
+
this.emit({ type: "state", snapshot: task });
|
|
851
|
+
}
|
|
852
|
+
private emit(event: RegistryEvent): void {
|
|
853
|
+
for (const listener of [...this.listeners]) {
|
|
854
|
+
try { listener(event); } catch (error) { console.error("pi-async: registry event listener failed", error); }
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
private ensureDataDir(): void {
|
|
859
|
+
if (this.dataDirReady) return;
|
|
860
|
+
mkdirSync(this.config.dataDir, { recursive: true });
|
|
861
|
+
this.dataDirReady = true;
|
|
862
|
+
}
|
|
863
|
+
/** `configure()` is contractually non-throwing (D-05); a bad data dir surfaces on persist. */
|
|
864
|
+
private ensureDataDirQuietly(): void {
|
|
865
|
+
try { this.ensureDataDir(); } catch (error) { console.error(`pi-async: cannot create data dir ${this.config.dataDir}`, error); }
|
|
866
|
+
}
|
|
511
867
|
private persist(task: TaskSnapshot): void {
|
|
868
|
+
this.ensureDataDir();
|
|
512
869
|
const file = join(this.config.dataDir, `${task.taskId}.json`); const temp = `${file}.${process.pid}.tmp`;
|
|
513
870
|
writeFileSync(temp, JSON.stringify(task), { mode: 0o600 }); renameSync(temp, file);
|
|
514
871
|
}
|
|
@@ -518,6 +875,74 @@ export class TaskRegistry {
|
|
|
518
875
|
return persisted.metadata.capped === true;
|
|
519
876
|
} catch { return task.metadata.capped === true; }
|
|
520
877
|
}
|
|
878
|
+
|
|
879
|
+
private switchDataDir(nextDataDir: string): void {
|
|
880
|
+
this.config = { ...this.config, dataDir: nextDataDir };
|
|
881
|
+
this.dataDirReady = false;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Move every task artifact from the current data dir to `nextDataDir` (copy + rename),
|
|
886
|
+
* then repoint snapshots. On failure nothing is lost: the old dir stays authoritative
|
|
887
|
+
* and the error is reported, never thrown (D-05).
|
|
888
|
+
*/
|
|
889
|
+
private migrateDataDir(nextDataDir: string): boolean {
|
|
890
|
+
const oldDir = this.config.dataDir;
|
|
891
|
+
let names: string[] = [];
|
|
892
|
+
try {
|
|
893
|
+
names = readdirSync(oldDir).filter((name) => ARTIFACT_SUFFIXES.some((suffix) => name.endsWith(suffix)));
|
|
894
|
+
} catch { /* Old dir missing: nothing to migrate. */ }
|
|
895
|
+
const copied: string[] = [];
|
|
896
|
+
try {
|
|
897
|
+
mkdirSync(nextDataDir, { recursive: true });
|
|
898
|
+
for (const name of names) {
|
|
899
|
+
const target = join(nextDataDir, name);
|
|
900
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
901
|
+
copyFileSync(join(oldDir, name), temp);
|
|
902
|
+
renameSync(temp, target);
|
|
903
|
+
copied.push(target);
|
|
904
|
+
}
|
|
905
|
+
} catch (error) {
|
|
906
|
+
for (const file of copied) { try { rmSync(file, { force: true }); } catch { /* best-effort rollback */ } }
|
|
907
|
+
console.error(`pi-async: data dir migration failed; keeping ${oldDir}`, error);
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
this.switchDataDir(nextDataDir);
|
|
911
|
+
this.ensureDataDir();
|
|
912
|
+
for (const task of this.tasks.values()) {
|
|
913
|
+
const outputFile = join(nextDataDir, `${task.taskId}.output`);
|
|
914
|
+
task.metadata = { ...task.metadata, outputFile };
|
|
915
|
+
const live = this.live.get(task.taskId);
|
|
916
|
+
if (live) live.outputFile = outputFile;
|
|
917
|
+
try { this.persist(task); } catch (error) { console.error(`pi-async: could not rewrite task ${task.taskId} after data dir migration`, error); }
|
|
918
|
+
}
|
|
919
|
+
for (const name of names) { try { rmSync(join(oldDir, name), { force: true }); } catch { /* best-effort cleanup */ } }
|
|
920
|
+
return true;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Absolute data dir (realpath when it exists) for boundary checks. */
|
|
924
|
+
private resolvedDataDir(): string {
|
|
925
|
+
try { return realpathSync(this.config.dataDir); } catch { return resolve(this.config.dataDir); }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private isInsideDataDir(target: string): boolean {
|
|
929
|
+
const root = this.resolvedDataDir();
|
|
930
|
+
let resolvedTarget: string;
|
|
931
|
+
try { resolvedTarget = realpathSync(target); } catch { resolvedTarget = resolve(target); }
|
|
932
|
+
return resolvedTarget === root || resolvedTarget.startsWith(root + sep);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** D-11: reject reads of `metadata.outputFile` outside the data dir. */
|
|
936
|
+
private assertInsideDataDir(target: string): string {
|
|
937
|
+
const root = this.resolvedDataDir();
|
|
938
|
+
let resolvedTarget: string;
|
|
939
|
+
try { resolvedTarget = realpathSync(target); } catch { resolvedTarget = resolve(target); }
|
|
940
|
+
if (resolvedTarget !== root && !resolvedTarget.startsWith(root + sep)) {
|
|
941
|
+
throw new Error(`Refusing to read task output outside the async data directory: ${target}`);
|
|
942
|
+
}
|
|
943
|
+
return resolvedTarget;
|
|
944
|
+
}
|
|
945
|
+
|
|
521
946
|
private pidAlive(pid: number): boolean {
|
|
522
947
|
// Prefer /proc so a zombie-only group is not treated as alive (#6 / reload lost detection).
|
|
523
948
|
try {
|
|
@@ -540,4 +965,3 @@ export class TaskRegistry {
|
|
|
540
965
|
}
|
|
541
966
|
}
|
|
542
967
|
}
|
|
543
|
-
|