pi-better-background-tasks 0.2.20 → 0.4.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/README.md +46 -2
- package/package.json +1 -1
- package/src/failures.ts +101 -6
- package/src/index.ts +5 -0
- package/src/logs.ts +51 -7
- package/src/navigator-provider.ts +4 -3
- package/src/output.ts +783 -0
- package/src/process.ts +95 -8
- package/src/registry.ts +84 -7
- package/src/runtime.ts +228 -33
- package/src/sandbox.ts +6 -4
- package/src/shared-callback-batcher.ts +358 -29
- package/src/shared-failure-observations.ts +833 -23
- package/src/shared-log-utils.ts +1139 -2
- package/src/shared-navigator.ts +12 -10
- package/src/shared-sandbox-core.ts +660 -17
- package/src/tools.ts +201 -156
- package/src/types.ts +23 -0
package/src/process.ts
CHANGED
|
@@ -183,14 +183,15 @@ export function runCommandOnce(
|
|
|
183
183
|
validateCommandSpec(spec);
|
|
184
184
|
const startedAt = Date.now();
|
|
185
185
|
const child = spawnArgs(spec, true, ["ignore", "pipe", "pipe"]);
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
const cap = Math.max(1, Math.floor(maxBufferBytes));
|
|
187
|
+
const stdoutCapture = createCaptureBuffer();
|
|
188
|
+
const stderrCapture = createCaptureBuffer();
|
|
188
189
|
let timedOut = false;
|
|
189
|
-
child.stdout?.on("data", (chunk: Buffer) => {
|
|
190
|
-
|
|
190
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
191
|
+
captureChunk(stdoutCapture, chunk, cap);
|
|
191
192
|
});
|
|
192
|
-
child.stderr?.on("data", (chunk: Buffer) => {
|
|
193
|
-
|
|
193
|
+
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
194
|
+
captureChunk(stderrCapture, chunk, cap);
|
|
194
195
|
});
|
|
195
196
|
return new Promise((resolve, reject) => {
|
|
196
197
|
// The group outlives the leader as long as any member is alive, so the
|
|
@@ -239,19 +240,105 @@ export function runCommandOnce(
|
|
|
239
240
|
});
|
|
240
241
|
child.on("close", (exitCode, signal) => {
|
|
241
242
|
settle();
|
|
243
|
+
const stdout = finishCapture(stdoutCapture);
|
|
244
|
+
const stderr = finishCapture(stderrCapture);
|
|
245
|
+
const captureTruncated = stdout.discardedBytes > 0 || stderr.discardedBytes > 0;
|
|
242
246
|
resolve({
|
|
243
247
|
exitCode,
|
|
244
248
|
signal,
|
|
245
|
-
stdout,
|
|
246
|
-
stderr,
|
|
249
|
+
stdout: stdout.text,
|
|
250
|
+
stderr: stderr.text,
|
|
247
251
|
startedAt,
|
|
248
252
|
endedAt: Date.now(),
|
|
249
253
|
...(timedOut ? { timedOut: true } : {}),
|
|
254
|
+
...(stdout.discardedBytes > 0 ? { stdoutDiscardedBytes: stdout.discardedBytes } : {}),
|
|
255
|
+
...(stderr.discardedBytes > 0 ? { stderrDiscardedBytes: stderr.discardedBytes } : {}),
|
|
256
|
+
...(captureTruncated ? { captureTruncated: true } : {}),
|
|
250
257
|
});
|
|
251
258
|
});
|
|
252
259
|
});
|
|
253
260
|
}
|
|
254
261
|
|
|
262
|
+
interface CaptureBuffer {
|
|
263
|
+
chunks: Buffer[];
|
|
264
|
+
storedBytes: number;
|
|
265
|
+
discardedBytes: number;
|
|
266
|
+
truncated: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function createCaptureBuffer(): CaptureBuffer {
|
|
270
|
+
return { chunks: [], storedBytes: 0, discardedBytes: 0, truncated: false };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function asBuffer(chunk: Buffer | string): Buffer {
|
|
274
|
+
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Largest UTF-8 prefix of `buffer` that fits in `maxBytes`. */
|
|
278
|
+
export function utf8PrefixLength(buffer: Buffer, maxBytes: number): number {
|
|
279
|
+
if (maxBytes <= 0) return 0;
|
|
280
|
+
if (buffer.length <= maxBytes) return buffer.length;
|
|
281
|
+
let end = maxBytes;
|
|
282
|
+
while (end > 0 && (buffer[end]! & 0xc0) === 0x80) end -= 1;
|
|
283
|
+
if (end === 0 && (buffer[0]! & 0xc0) === 0x80) return 0;
|
|
284
|
+
const lead = buffer[end]!;
|
|
285
|
+
const need = lead <= 0x7f ? 1
|
|
286
|
+
: (lead & 0xe0) === 0xc0 ? 2
|
|
287
|
+
: (lead & 0xf0) === 0xe0 ? 3
|
|
288
|
+
: (lead & 0xf8) === 0xf0 ? 4
|
|
289
|
+
: 1;
|
|
290
|
+
return end + need <= maxBytes ? end + need : end;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function captureChunk(target: CaptureBuffer, chunk: Buffer | string, maxBytes: number): void {
|
|
294
|
+
const buffer = asBuffer(chunk);
|
|
295
|
+
if (target.truncated) {
|
|
296
|
+
target.discardedBytes += buffer.length;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const room = maxBytes - target.storedBytes;
|
|
300
|
+
if (buffer.length <= room) {
|
|
301
|
+
target.chunks.push(buffer);
|
|
302
|
+
target.storedBytes += buffer.length;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const take = utf8PrefixLength(buffer, room);
|
|
306
|
+
if (take > 0) {
|
|
307
|
+
target.chunks.push(buffer.subarray(0, take));
|
|
308
|
+
target.storedBytes += take;
|
|
309
|
+
}
|
|
310
|
+
target.discardedBytes += buffer.length - take;
|
|
311
|
+
target.truncated = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Length of `buffer` without a trailing UTF-8 sequence that the cap cut short.
|
|
316
|
+
* Chunk boundaries can split a code point; once capture overflows, the bytes
|
|
317
|
+
* that would have completed it were discarded, so the dangling lead bytes are
|
|
318
|
+
* discarded too instead of decoding to U+FFFD.
|
|
319
|
+
*/
|
|
320
|
+
export function completeUtf8Length(buffer: Buffer): number {
|
|
321
|
+
let start = buffer.length - 1;
|
|
322
|
+
while (start >= 0 && buffer.length - start < 4 && (buffer[start]! & 0xc0) === 0x80) start -= 1;
|
|
323
|
+
if (start < 0) return buffer.length;
|
|
324
|
+
const lead = buffer[start]!;
|
|
325
|
+
const need = lead <= 0x7f ? 1
|
|
326
|
+
: (lead & 0xe0) === 0xc0 ? 2
|
|
327
|
+
: (lead & 0xf0) === 0xe0 ? 3
|
|
328
|
+
: (lead & 0xf8) === 0xf0 ? 4
|
|
329
|
+
: 1;
|
|
330
|
+
return start + need > buffer.length ? start : buffer.length;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function finishCapture(target: CaptureBuffer): { text: string; discardedBytes: number } {
|
|
334
|
+
const stored = Buffer.concat(target.chunks, target.storedBytes);
|
|
335
|
+
const keep = target.truncated ? completeUtf8Length(stored) : stored.length;
|
|
336
|
+
return {
|
|
337
|
+
text: stored.subarray(0, keep).toString("utf8"),
|
|
338
|
+
discardedBytes: target.discardedBytes + (stored.length - keep),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
255
342
|
/**
|
|
256
343
|
* Stop a task's process tree.
|
|
257
344
|
*
|
package/src/registry.ts
CHANGED
|
@@ -92,15 +92,92 @@ export function onMetaChanged(listener: () => void): () => void {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export function readMeta(id: string): BackgroundTaskMeta | undefined {
|
|
95
|
+
return inspectMeta(id).meta;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface MetaInspection {
|
|
99
|
+
id: string;
|
|
100
|
+
meta?: BackgroundTaskMeta;
|
|
101
|
+
found: boolean;
|
|
102
|
+
readable: boolean;
|
|
103
|
+
error?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorText(error: unknown): string {
|
|
107
|
+
return error instanceof Error ? error.message : String(error);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Parsed JSON that lacks the identifying fields is corrupt, not a task. */
|
|
111
|
+
function metaShapeProblem(meta: unknown, id: string): string | undefined {
|
|
112
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) return "not an object";
|
|
113
|
+
const value = meta as Partial<BackgroundTaskMeta>;
|
|
114
|
+
if (value.id !== id) return `id ${JSON.stringify(value.id ?? null)} does not match ${JSON.stringify(id)}`;
|
|
115
|
+
if (typeof value.status !== "string" || !value.status) return "missing status";
|
|
116
|
+
if (typeof value.logPath !== "string" || !value.logPath) return "missing logPath";
|
|
117
|
+
if (typeof value.startedAt !== "number" || !Number.isFinite(value.startedAt)) return "missing startedAt";
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Distinguish missing, unreadable, and healthy metadata. Callers must not treat
|
|
123
|
+
* an unreadable file as proof that a task does not exist.
|
|
124
|
+
*/
|
|
125
|
+
export function inspectMeta(id: string): MetaInspection {
|
|
126
|
+
const path = metaPathFor(id);
|
|
95
127
|
try {
|
|
96
128
|
registryIo.metadataFileReads += 1;
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
const raw = readFileSync(path, "utf8");
|
|
130
|
+
try {
|
|
131
|
+
const meta = JSON.parse(raw) as BackgroundTaskMeta;
|
|
132
|
+
const invalid = metaShapeProblem(meta, id);
|
|
133
|
+
if (invalid) {
|
|
134
|
+
metaCache.delete(id);
|
|
135
|
+
return { id, found: true, readable: false, error: `invalid metadata: ${invalid}` };
|
|
136
|
+
}
|
|
137
|
+
metaCache.set(id, meta);
|
|
138
|
+
return { id, meta, found: true, readable: true };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
metaCache.delete(id);
|
|
141
|
+
return { id, found: true, readable: false, error: `invalid JSON: ${errorText(error)}` };
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
101
144
|
metaCache.delete(id);
|
|
102
|
-
|
|
145
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
146
|
+
if (code === "ENOENT") {
|
|
147
|
+
try {
|
|
148
|
+
statSync(taskDir(id));
|
|
149
|
+
return { id, found: true, readable: false, error: "metadata file is missing" };
|
|
150
|
+
} catch {
|
|
151
|
+
return { id, found: false, readable: false };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { id, found: true, readable: false, error: errorText(error) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface TaskIndex {
|
|
159
|
+
records: MetaInspection[];
|
|
160
|
+
indexError?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** List every task directory, including unreadable metadata. */
|
|
164
|
+
export function listTaskRecords(): TaskIndex {
|
|
165
|
+
let ids: string[];
|
|
166
|
+
try {
|
|
167
|
+
registryIo.fullDirectoryReads += 1;
|
|
168
|
+
ids = readdirSync(tasksDir());
|
|
169
|
+
} catch (error) {
|
|
170
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
171
|
+
if (code === "ENOENT") return { records: [] };
|
|
172
|
+
return { records: [], indexError: errorText(error) };
|
|
103
173
|
}
|
|
174
|
+
return {
|
|
175
|
+
records: ids.map((id) => inspectMeta(id)).sort((a, b) => {
|
|
176
|
+
const aTime = a.meta?.startedAt ?? 0;
|
|
177
|
+
const bTime = b.meta?.startedAt ?? 0;
|
|
178
|
+
return bTime - aTime;
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
104
181
|
}
|
|
105
182
|
|
|
106
183
|
export function removeMeta(meta: BackgroundTaskMeta): boolean {
|
|
@@ -164,11 +241,11 @@ function readOwnedMeta(id: string): BackgroundTaskMeta | undefined {
|
|
|
164
241
|
return metaCache.get(id) ?? readMeta(id);
|
|
165
242
|
}
|
|
166
243
|
|
|
167
|
-
function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
|
|
244
|
+
export function originOf(meta: BackgroundTaskMeta): BackgroundTaskCallbackOrigin {
|
|
168
245
|
return meta.callbackOrigin ?? { cwd: meta.cwd };
|
|
169
246
|
}
|
|
170
247
|
|
|
171
|
-
function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
|
|
248
|
+
export function belongsToOrigin(meta: BackgroundTaskMeta, origin: BackgroundTaskCallbackOrigin): boolean {
|
|
172
249
|
const candidate = originOf(meta);
|
|
173
250
|
if (candidate.cwd !== origin.cwd) return false;
|
|
174
251
|
if (candidate.sessionId || origin.sessionId) return candidate.sessionId === origin.sessionId;
|