@stablekernel/pi-background-run 0.4.0 → 0.6.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 +480 -41
- package/extension/digestPresets.ts +316 -0
- package/extension/index.ts +2560 -360
- package/package.json +10 -3
- package/skill/digest-config/SKILL.md +117 -0
- package/skill/run-bg/SKILL.md +98 -22
- package/extension/index.test.ts +0 -1772
package/extension/index.test.ts
DELETED
|
@@ -1,1772 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-bgrun — Phase 0 spike smoke tests.
|
|
3
|
-
*
|
|
4
|
-
* These don't require a real pi runtime. We extract the core logic by importing
|
|
5
|
-
* the module's internals via a test harness that fakes the ExtensionAPI:
|
|
6
|
-
* - fakePi.sendUserMessage captures wake messages
|
|
7
|
-
* - fakeCtx.isIdle() simulates the agent's idle state (true by default —
|
|
8
|
-
* bgrun returns immediately so by the time the child exits the agent has
|
|
9
|
-
* finished its turn)
|
|
10
|
-
* - we drive a real child_process.spawn through the bgrun tool's execute()
|
|
11
|
-
* - assert exit handling, log marker, bgtail, bgstatus
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { test } from "node:test";
|
|
15
|
-
import assert from "node:assert/strict";
|
|
16
|
-
import {
|
|
17
|
-
mkdtempSync,
|
|
18
|
-
rmSync,
|
|
19
|
-
readFileSync,
|
|
20
|
-
writeFileSync,
|
|
21
|
-
existsSync,
|
|
22
|
-
readdirSync,
|
|
23
|
-
} from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import { tmpdir } from "node:os";
|
|
26
|
-
import { pathToFileURL } from "node:url";
|
|
27
|
-
|
|
28
|
-
interface CapturedWake {
|
|
29
|
-
text: string;
|
|
30
|
-
options?: Record<string, unknown>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
34
|
-
pi: any;
|
|
35
|
-
wakes: CapturedWake[];
|
|
36
|
-
entries: any[];
|
|
37
|
-
tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
|
|
38
|
-
commands: Map<
|
|
39
|
-
string,
|
|
40
|
-
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
41
|
-
>;
|
|
42
|
-
ctx: any;
|
|
43
|
-
handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
|
|
44
|
-
fireSessionStart: () => Promise<void>;
|
|
45
|
-
} {
|
|
46
|
-
const wakes: CapturedWake[] = [];
|
|
47
|
-
const entries: any[] = opts.priorEntries ? [...opts.priorEntries] : [];
|
|
48
|
-
const tools = new Map<
|
|
49
|
-
string,
|
|
50
|
-
{ execute: (...args: any[]) => Promise<any> }
|
|
51
|
-
>();
|
|
52
|
-
const commands = new Map<
|
|
53
|
-
string,
|
|
54
|
-
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
55
|
-
>();
|
|
56
|
-
const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
|
|
57
|
-
const idle = opts.idle ?? true;
|
|
58
|
-
const ctx = {
|
|
59
|
-
isIdle: () => idle,
|
|
60
|
-
hasUI: false,
|
|
61
|
-
ui: { notify() {}, setWidget() {}, setStatus() {} },
|
|
62
|
-
sessionManager: { getEntries: () => entries },
|
|
63
|
-
};
|
|
64
|
-
const pi = {
|
|
65
|
-
sendUserMessage(text: string, options?: Record<string, unknown>) {
|
|
66
|
-
wakes.push({ text, options });
|
|
67
|
-
},
|
|
68
|
-
appendEntry(customType: string, data?: unknown) {
|
|
69
|
-
entries.push({ type: "custom", customType, data });
|
|
70
|
-
},
|
|
71
|
-
registerEntryRenderer() {},
|
|
72
|
-
registerTool(def: any) {
|
|
73
|
-
tools.set(def.name, def);
|
|
74
|
-
},
|
|
75
|
-
registerCommand(name: string, def: any) {
|
|
76
|
-
commands.set(name, def);
|
|
77
|
-
},
|
|
78
|
-
on(event: string, handler: (...args: any[]) => Promise<any>) {
|
|
79
|
-
const list = handlers.get(event) ?? [];
|
|
80
|
-
list.push(handler);
|
|
81
|
-
handlers.set(event, list);
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
const fireSessionStart = async () => {
|
|
85
|
-
for (const h of handlers.get("session_start") ?? []) {
|
|
86
|
-
await h({ reason: "startup" }, ctx);
|
|
87
|
-
}
|
|
88
|
-
};
|
|
89
|
-
return {
|
|
90
|
-
pi,
|
|
91
|
-
wakes,
|
|
92
|
-
entries,
|
|
93
|
-
tools,
|
|
94
|
-
commands,
|
|
95
|
-
ctx,
|
|
96
|
-
handlers,
|
|
97
|
-
fireSessionStart,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function loadExtension(
|
|
102
|
-
fakePi: any,
|
|
103
|
-
): Promise<Map<string, { execute: (...args: any[]) => Promise<any> }>> {
|
|
104
|
-
const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
|
|
105
|
-
const mod = await import(url);
|
|
106
|
-
mod.default(fakePi);
|
|
107
|
-
return fakePi.tools as Map<
|
|
108
|
-
string,
|
|
109
|
-
{ execute: (...args: any[]) => Promise<any> }
|
|
110
|
-
>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function waitForWakes(
|
|
114
|
-
wakes: CapturedWake[],
|
|
115
|
-
count: number,
|
|
116
|
-
timeoutMs = 5000,
|
|
117
|
-
): Promise<void> {
|
|
118
|
-
return new Promise((resolve, reject) => {
|
|
119
|
-
const start = Date.now();
|
|
120
|
-
const tick = () => {
|
|
121
|
-
if (wakes.length >= count) return resolve();
|
|
122
|
-
if (Date.now() - start > timeoutMs)
|
|
123
|
-
return reject(
|
|
124
|
-
new Error(
|
|
125
|
-
`timed out waiting for ${count} wakes, got ${wakes.length}`,
|
|
126
|
-
),
|
|
127
|
-
);
|
|
128
|
-
setTimeout(tick, 50);
|
|
129
|
-
};
|
|
130
|
-
tick();
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
test("bgrun: successful command writes log + exit marker and wakes with ✅", async () => {
|
|
135
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
136
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
137
|
-
try {
|
|
138
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
139
|
-
await loadExtension(pi);
|
|
140
|
-
const bgrun = tools.get("bgrun")!;
|
|
141
|
-
|
|
142
|
-
const res = await bgrun.execute(
|
|
143
|
-
"call-1",
|
|
144
|
-
{ command: "echo hello world" },
|
|
145
|
-
undefined,
|
|
146
|
-
undefined,
|
|
147
|
-
ctx,
|
|
148
|
-
);
|
|
149
|
-
const started = res.content[0].text as string;
|
|
150
|
-
assert.match(started, /^started: /);
|
|
151
|
-
const id = (started.match(/^started: ([^\n]+)/) || [])[1];
|
|
152
|
-
assert.ok(id, "got a job id");
|
|
153
|
-
|
|
154
|
-
await waitForWakes(wakes, 1);
|
|
155
|
-
// When idle, sendUserMessage is called with no options.
|
|
156
|
-
assert.equal(wakes[0].options, undefined);
|
|
157
|
-
const wake = wakes[0].text;
|
|
158
|
-
assert.match(wake, /✅/);
|
|
159
|
-
assert.match(wake, /exit 0/);
|
|
160
|
-
assert.match(wake, /hello world/);
|
|
161
|
-
assert.match(wake, new RegExp(id));
|
|
162
|
-
|
|
163
|
-
const logPath = join(dir, `${id}.log`);
|
|
164
|
-
assert.ok(existsSync(logPath), "log file exists");
|
|
165
|
-
const log = readFileSync(logPath, "utf8");
|
|
166
|
-
assert.match(log, /hello world/);
|
|
167
|
-
assert.match(log, /__BGRUN_EXIT__=0/);
|
|
168
|
-
} finally {
|
|
169
|
-
delete process.env.PI_BGRUN_DIR;
|
|
170
|
-
rmSync(dir, { recursive: true, force: true });
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("bgrun: failing command wakes with ❌ and the non-zero exit code", async () => {
|
|
175
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
176
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
177
|
-
try {
|
|
178
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
179
|
-
await loadExtension(pi);
|
|
180
|
-
const bgrun = tools.get("bgrun")!;
|
|
181
|
-
|
|
182
|
-
await bgrun.execute(
|
|
183
|
-
"call-2",
|
|
184
|
-
{ command: "echo failing now; exit 7" },
|
|
185
|
-
undefined,
|
|
186
|
-
undefined,
|
|
187
|
-
ctx,
|
|
188
|
-
);
|
|
189
|
-
await waitForWakes(wakes, 1);
|
|
190
|
-
const wake = wakes[0].text;
|
|
191
|
-
assert.match(wake, /❌/);
|
|
192
|
-
assert.match(wake, /exit 7/);
|
|
193
|
-
} finally {
|
|
194
|
-
delete process.env.PI_BGRUN_DIR;
|
|
195
|
-
rmSync(dir, { recursive: true, force: true });
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("bgrun: when agent is busy, wake is queued as followUp", async () => {
|
|
200
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
201
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
202
|
-
try {
|
|
203
|
-
const { pi, wakes, tools, ctx } = makeFakePi({ idle: false });
|
|
204
|
-
await loadExtension(pi);
|
|
205
|
-
const bgrun = tools.get("bgrun")!;
|
|
206
|
-
|
|
207
|
-
await bgrun.execute(
|
|
208
|
-
"call-busy",
|
|
209
|
-
{ command: "echo while-busy" },
|
|
210
|
-
undefined,
|
|
211
|
-
undefined,
|
|
212
|
-
ctx,
|
|
213
|
-
);
|
|
214
|
-
await waitForWakes(wakes, 1);
|
|
215
|
-
assert.equal(wakes[0].options?.deliverAs, "followUp");
|
|
216
|
-
} finally {
|
|
217
|
-
delete process.env.PI_BGRUN_DIR;
|
|
218
|
-
rmSync(dir, { recursive: true, force: true });
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("bgtail: returns last N lines, strips the exit marker", async () => {
|
|
223
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
224
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
225
|
-
try {
|
|
226
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
227
|
-
await loadExtension(pi);
|
|
228
|
-
const bgrun = tools.get("bgrun")!;
|
|
229
|
-
const bgtail = tools.get("bgtail")!;
|
|
230
|
-
|
|
231
|
-
const res = await bgrun.execute(
|
|
232
|
-
"call-3",
|
|
233
|
-
{ command: "printf 'line1\\nline2\\nline3\\n'" },
|
|
234
|
-
undefined,
|
|
235
|
-
undefined,
|
|
236
|
-
ctx,
|
|
237
|
-
);
|
|
238
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
239
|
-
await waitForWakes(wakes, 1);
|
|
240
|
-
|
|
241
|
-
const tail = await bgtail.execute(
|
|
242
|
-
"call-3",
|
|
243
|
-
{ id, lines: 2 },
|
|
244
|
-
undefined,
|
|
245
|
-
undefined,
|
|
246
|
-
ctx,
|
|
247
|
-
);
|
|
248
|
-
const text = tail.content[0].text as string;
|
|
249
|
-
assert.ok(!text.includes("__BGRUN_EXIT__"), "marker stripped");
|
|
250
|
-
assert.match(text, /line2\nline3$|^line3$/);
|
|
251
|
-
} finally {
|
|
252
|
-
delete process.env.PI_BGRUN_DIR;
|
|
253
|
-
rmSync(dir, { recursive: true, force: true });
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lines", async () => {
|
|
258
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
259
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
260
|
-
try {
|
|
261
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
262
|
-
await loadExtension(pi);
|
|
263
|
-
const bgrun = tools.get("bgrun")!;
|
|
264
|
-
const bgtail = tools.get("bgtail")!;
|
|
265
|
-
|
|
266
|
-
// 1 ANSI-colored line, 5 identical spinner lines, 1 huge line
|
|
267
|
-
const esc = "\u001b"; // literal ESC byte, safe to pass through a shell arg
|
|
268
|
-
const payload =
|
|
269
|
-
`printf "${esc}[32mOK green${esc}[0m\nwait\nwait\nwait\nwait\nwait\nline3\n"; ` +
|
|
270
|
-
"echo \"$(printf 'x%.0s' $(seq 1 5000))\"";
|
|
271
|
-
const res = await bgrun.execute(
|
|
272
|
-
"call-c1",
|
|
273
|
-
{ command: payload },
|
|
274
|
-
undefined,
|
|
275
|
-
undefined,
|
|
276
|
-
ctx,
|
|
277
|
-
);
|
|
278
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
279
|
-
await waitForWakes(wakes, 1);
|
|
280
|
-
|
|
281
|
-
const tail = await bgtail.execute(
|
|
282
|
-
"call-c1",
|
|
283
|
-
{ id, lines: 40 },
|
|
284
|
-
undefined,
|
|
285
|
-
undefined,
|
|
286
|
-
ctx,
|
|
287
|
-
);
|
|
288
|
-
const text = tail.content[0].text as string;
|
|
289
|
-
assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
|
|
290
|
-
assert.ok(text.includes("OK green"), "text after stripping survives");
|
|
291
|
-
assert.match(
|
|
292
|
-
text,
|
|
293
|
-
/wait {2}\[x5\]/,
|
|
294
|
-
"5 identical lines collapsed to one with count",
|
|
295
|
-
);
|
|
296
|
-
assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
|
|
297
|
-
assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
|
|
298
|
-
assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
|
|
299
|
-
assert.match(
|
|
300
|
-
text,
|
|
301
|
-
/1 repeated-line run collapsed/,
|
|
302
|
-
"notes mention run collapse",
|
|
303
|
-
);
|
|
304
|
-
assert.ok((tail.details as any).condensed === true);
|
|
305
|
-
} finally {
|
|
306
|
-
delete process.env.PI_BGRUN_DIR;
|
|
307
|
-
rmSync(dir, { recursive: true, force: true });
|
|
308
|
-
}
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
test("bgtail: raw=true skips condensing", async () => {
|
|
312
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
313
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
314
|
-
try {
|
|
315
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
316
|
-
await loadExtension(pi);
|
|
317
|
-
const bgrun = tools.get("bgrun")!;
|
|
318
|
-
const bgtail = tools.get("bgtail")!;
|
|
319
|
-
|
|
320
|
-
const esc = "\u001b";
|
|
321
|
-
const res = await bgrun.execute(
|
|
322
|
-
"call-c2",
|
|
323
|
-
{ command: `printf "${esc}[31mraw-red${esc}[0m\nwait\nwait\nwait\n"` },
|
|
324
|
-
undefined,
|
|
325
|
-
undefined,
|
|
326
|
-
ctx,
|
|
327
|
-
);
|
|
328
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
329
|
-
await waitForWakes(wakes, 1);
|
|
330
|
-
|
|
331
|
-
const tail = await bgtail.execute(
|
|
332
|
-
"call-c2",
|
|
333
|
-
{ id, raw: true },
|
|
334
|
-
undefined,
|
|
335
|
-
undefined,
|
|
336
|
-
ctx,
|
|
337
|
-
);
|
|
338
|
-
const text = tail.content[0].text as string;
|
|
339
|
-
assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
|
|
340
|
-
assert.ok(
|
|
341
|
-
text.includes("wait\nwait\nwait"),
|
|
342
|
-
"raw keeps repeated lines uncollapsed",
|
|
343
|
-
);
|
|
344
|
-
assert.ok((tail.details as any).condensed === false);
|
|
345
|
-
} finally {
|
|
346
|
-
delete process.env.PI_BGRUN_DIR;
|
|
347
|
-
rmSync(dir, { recursive: true, force: true });
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
test("bgtail: total cap kicks in on large output with guidance note", async () => {
|
|
352
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
353
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
354
|
-
try {
|
|
355
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
356
|
-
await loadExtension(pi);
|
|
357
|
-
const bgrun = tools.get("bgrun")!;
|
|
358
|
-
const bgtail = tools.get("bgtail")!;
|
|
359
|
-
|
|
360
|
-
// ~200 distinct lines x ~500 chars = ~100KB, well past the 8KB total cap
|
|
361
|
-
const cmd =
|
|
362
|
-
"for i in $(seq 1 200); do echo \"line-$i $(printf 'y%.0s' $(seq 1 500))\"; done";
|
|
363
|
-
const res = await bgrun.execute(
|
|
364
|
-
"call-c3",
|
|
365
|
-
{ command: cmd },
|
|
366
|
-
undefined,
|
|
367
|
-
undefined,
|
|
368
|
-
ctx,
|
|
369
|
-
);
|
|
370
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
371
|
-
await waitForWakes(wakes, 1);
|
|
372
|
-
|
|
373
|
-
const tail = await bgtail.execute(
|
|
374
|
-
"call-c3",
|
|
375
|
-
{ id, lines: 200 },
|
|
376
|
-
undefined,
|
|
377
|
-
undefined,
|
|
378
|
-
ctx,
|
|
379
|
-
);
|
|
380
|
-
const text = tail.content[0].text as string;
|
|
381
|
-
assert.ok(text.length < 10_000, "result capped well below raw size");
|
|
382
|
-
assert.match(
|
|
383
|
-
text,
|
|
384
|
-
/output capped at 8000 chars — 200 raw lines total/,
|
|
385
|
-
"cap note names the raw line count and suggests escalation paths",
|
|
386
|
-
);
|
|
387
|
-
assert.ok((tail.details as any).condenserNotes, "notes in details too");
|
|
388
|
-
} finally {
|
|
389
|
-
delete process.env.PI_BGRUN_DIR;
|
|
390
|
-
rmSync(dir, { recursive: true, force: true });
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
test("bgstatus: shows running then done with exit code", async () => {
|
|
395
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
396
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
397
|
-
try {
|
|
398
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
399
|
-
await loadExtension(pi);
|
|
400
|
-
const bgrun = tools.get("bgrun")!;
|
|
401
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
402
|
-
|
|
403
|
-
const res = await bgrun.execute(
|
|
404
|
-
"call-4",
|
|
405
|
-
{ command: "sleep 0.2; echo done" },
|
|
406
|
-
undefined,
|
|
407
|
-
undefined,
|
|
408
|
-
ctx,
|
|
409
|
-
);
|
|
410
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
411
|
-
|
|
412
|
-
// While running, status should say running.
|
|
413
|
-
const running = await bgstatus.execute(
|
|
414
|
-
"call-4",
|
|
415
|
-
{ id },
|
|
416
|
-
undefined,
|
|
417
|
-
undefined,
|
|
418
|
-
ctx,
|
|
419
|
-
);
|
|
420
|
-
assert.match(running.content[0].text as string, /running/);
|
|
421
|
-
|
|
422
|
-
await waitForWakes(wakes, 1);
|
|
423
|
-
const done = await bgstatus.execute(
|
|
424
|
-
"call-4",
|
|
425
|
-
{ id },
|
|
426
|
-
undefined,
|
|
427
|
-
undefined,
|
|
428
|
-
ctx,
|
|
429
|
-
);
|
|
430
|
-
assert.match(done.content[0].text as string, /done/);
|
|
431
|
-
assert.match(done.content[0].text as string, /exit=0/);
|
|
432
|
-
} finally {
|
|
433
|
-
delete process.env.PI_BGRUN_DIR;
|
|
434
|
-
rmSync(dir, { recursive: true, force: true });
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
test("bgstatus: list-all after 'restart' hides finished logs by default, notes them instead", async () => {
|
|
439
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
440
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
441
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
442
|
-
try {
|
|
443
|
-
const { pi, tools, ctx } = makeFakePi();
|
|
444
|
-
await loadExtension(pi);
|
|
445
|
-
const bgrun = tools.get("bgrun")!;
|
|
446
|
-
const res = await bgrun.execute(
|
|
447
|
-
"call-5",
|
|
448
|
-
{ command: "echo persisted" },
|
|
449
|
-
undefined,
|
|
450
|
-
undefined,
|
|
451
|
-
ctx,
|
|
452
|
-
);
|
|
453
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
454
|
-
|
|
455
|
-
// Wait for completion by polling the log marker.
|
|
456
|
-
const logPath = join(dir, `${id}.log`);
|
|
457
|
-
await new Promise<void>((resolve, reject) => {
|
|
458
|
-
const start = Date.now();
|
|
459
|
-
const tick = () => {
|
|
460
|
-
try {
|
|
461
|
-
if (readFileSync(logPath, "utf8").includes("__BGRUN_EXIT__=0"))
|
|
462
|
-
return resolve();
|
|
463
|
-
} catch {}
|
|
464
|
-
if (Date.now() - start > 5000)
|
|
465
|
-
return reject(new Error("log marker never appeared"));
|
|
466
|
-
setTimeout(tick, 50);
|
|
467
|
-
};
|
|
468
|
-
tick();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
// Fresh instance — no in-memory records. Default listing must NOT spam the
|
|
472
|
-
// finished job; it gets a one-line count note instead.
|
|
473
|
-
const { pi: pi2, tools: tools2 } = makeFakePi();
|
|
474
|
-
await loadExtension(pi2);
|
|
475
|
-
const bgstatus2 = tools2.get("bgstatus")!;
|
|
476
|
-
const list = await bgstatus2.execute(
|
|
477
|
-
"call-5",
|
|
478
|
-
{},
|
|
479
|
-
undefined,
|
|
480
|
-
undefined,
|
|
481
|
-
ctx,
|
|
482
|
-
);
|
|
483
|
-
const text = list.content[0].text as string;
|
|
484
|
-
assert.ok(!new RegExp(id).test(text), "finished job hidden by default");
|
|
485
|
-
assert.match(text, /\(1 more job log\(s\) on disk/);
|
|
486
|
-
|
|
487
|
-
// includeDone reveals it with the exit code recovered from the log.
|
|
488
|
-
const full = await bgstatus2.execute(
|
|
489
|
-
"call-5b",
|
|
490
|
-
{ includeDone: true },
|
|
491
|
-
undefined,
|
|
492
|
-
undefined,
|
|
493
|
-
ctx,
|
|
494
|
-
);
|
|
495
|
-
const fullText = full.content[0].text as string;
|
|
496
|
-
assert.match(fullText, new RegExp(id));
|
|
497
|
-
assert.match(fullText, /exit=0/);
|
|
498
|
-
assert.match(fullText, /\(from log\)/);
|
|
499
|
-
} finally {
|
|
500
|
-
delete process.env.PI_BGRUN_DIR;
|
|
501
|
-
rmSync(dir, { recursive: true, force: true });
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
test("bgstatus: showCompletedJobs config (env) lists finished jobs by default", async () => {
|
|
506
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
507
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
508
|
-
process.env.PI_BGRUN_SHOW_COMPLETED = "1";
|
|
509
|
-
try {
|
|
510
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
511
|
-
await loadExtension(pi);
|
|
512
|
-
const bgrun = tools.get("bgrun")!;
|
|
513
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
514
|
-
|
|
515
|
-
const res = await bgrun.execute(
|
|
516
|
-
"call-sd1",
|
|
517
|
-
{ command: "echo shown-done", name: "done-job" },
|
|
518
|
-
undefined,
|
|
519
|
-
undefined,
|
|
520
|
-
ctx,
|
|
521
|
-
);
|
|
522
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
523
|
-
await waitForWakes(wakes, 1);
|
|
524
|
-
|
|
525
|
-
const list = await bgstatus.execute(
|
|
526
|
-
"call-sd2",
|
|
527
|
-
{},
|
|
528
|
-
undefined,
|
|
529
|
-
undefined,
|
|
530
|
-
ctx,
|
|
531
|
-
);
|
|
532
|
-
assert.match(
|
|
533
|
-
list.content[0].text as string,
|
|
534
|
-
new RegExp(`${id} — done-job: done exit=0`),
|
|
535
|
-
);
|
|
536
|
-
} finally {
|
|
537
|
-
delete process.env.PI_BGRUN_DIR;
|
|
538
|
-
delete process.env.PI_BGRUN_SHOW_COMPLETED;
|
|
539
|
-
rmSync(dir, { recursive: true, force: true });
|
|
540
|
-
}
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
test("bgrun: rejects empty command", async () => {
|
|
544
|
-
const { pi, tools, ctx } = makeFakePi();
|
|
545
|
-
await loadExtension(pi);
|
|
546
|
-
const bgrun = tools.get("bgrun")!;
|
|
547
|
-
await assert.rejects(
|
|
548
|
-
() => bgrun.execute("call-6", { command: "" }, undefined, undefined, ctx),
|
|
549
|
-
/command is required/,
|
|
550
|
-
);
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
// ── Phase 1 tests ────────────────────────────────────────────────────────────
|
|
554
|
-
|
|
555
|
-
test("bgrun: appends bgrun-job entries (running then done)", async () => {
|
|
556
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
557
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
558
|
-
try {
|
|
559
|
-
const { pi, wakes, entries, tools, ctx } = makeFakePi();
|
|
560
|
-
await loadExtension(pi);
|
|
561
|
-
const bgrun = tools.get("bgrun")!;
|
|
562
|
-
|
|
563
|
-
await bgrun.execute(
|
|
564
|
-
"call-e1",
|
|
565
|
-
{ command: "echo entry-test" },
|
|
566
|
-
undefined,
|
|
567
|
-
undefined,
|
|
568
|
-
ctx,
|
|
569
|
-
);
|
|
570
|
-
// One running entry appended at start.
|
|
571
|
-
const runningEntries = entries.filter((e) => e.data?.state === "running");
|
|
572
|
-
assert.equal(runningEntries.length, 1, "running entry appended at start");
|
|
573
|
-
assert.equal(runningEntries[0].data.cmd, "echo entry-test");
|
|
574
|
-
|
|
575
|
-
await waitForWakes(wakes, 1);
|
|
576
|
-
// One done entry appended on exit.
|
|
577
|
-
const doneEntries = entries.filter((e) => e.data?.state === "done");
|
|
578
|
-
assert.equal(doneEntries.length, 1, "done entry appended on exit");
|
|
579
|
-
assert.equal(doneEntries[0].data.exitCode, 0);
|
|
580
|
-
} finally {
|
|
581
|
-
delete process.env.PI_BGRUN_DIR;
|
|
582
|
-
rmSync(dir, { recursive: true, force: true });
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
test("session_start: reconstructs in-memory Map from bgrun-job entries", async () => {
|
|
587
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
588
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
589
|
-
try {
|
|
590
|
-
// First instance: run a job, capture its entries.
|
|
591
|
-
const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
|
|
592
|
-
await loadExtension(pi1);
|
|
593
|
-
const bgrun1 = tools1.get("bgrun")!;
|
|
594
|
-
const res = await bgrun1.execute(
|
|
595
|
-
"call-r1",
|
|
596
|
-
{ command: "echo reconstruct-me" },
|
|
597
|
-
undefined,
|
|
598
|
-
undefined,
|
|
599
|
-
ctx1,
|
|
600
|
-
);
|
|
601
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
602
|
-
await waitForWakes(wakes, 1);
|
|
603
|
-
|
|
604
|
-
// Second instance: simulate a restart. Load fresh, passing the prior entries,
|
|
605
|
-
// then fire session_start to trigger reconstruction.
|
|
606
|
-
const {
|
|
607
|
-
pi: pi2,
|
|
608
|
-
tools: tools2,
|
|
609
|
-
ctx: ctx2,
|
|
610
|
-
fireSessionStart,
|
|
611
|
-
} = makeFakePi({ priorEntries: entries });
|
|
612
|
-
await loadExtension(pi2);
|
|
613
|
-
await fireSessionStart();
|
|
614
|
-
|
|
615
|
-
// Now bgstatus should find the job in the in-memory Map (not just dir scan).
|
|
616
|
-
const bgstatus2 = tools2.get("bgstatus")!;
|
|
617
|
-
const status = await bgstatus2.execute(
|
|
618
|
-
"call-r2",
|
|
619
|
-
{ id },
|
|
620
|
-
undefined,
|
|
621
|
-
undefined,
|
|
622
|
-
ctx2,
|
|
623
|
-
);
|
|
624
|
-
const text = status.content[0].text as string;
|
|
625
|
-
assert.match(text, /done.*exit=0/);
|
|
626
|
-
// Verify it came from the in-memory Map (not "from log" marker).
|
|
627
|
-
assert.ok(
|
|
628
|
-
!text.includes("from log"),
|
|
629
|
-
"reconstructed from entries, not dir scan",
|
|
630
|
-
);
|
|
631
|
-
assert.ok(
|
|
632
|
-
!text.includes("recovered from log"),
|
|
633
|
-
"reconstructed from entries, not log recovery",
|
|
634
|
-
);
|
|
635
|
-
} finally {
|
|
636
|
-
delete process.env.PI_BGRUN_DIR;
|
|
637
|
-
rmSync(dir, { recursive: true, force: true });
|
|
638
|
-
}
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
// ── name (human-readable label) tests ───────────────────────────────────────
|
|
642
|
-
|
|
643
|
-
test("bgrun: name flows into job id, response, entry, wake, and status", async () => {
|
|
644
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
645
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
646
|
-
try {
|
|
647
|
-
const { pi, wakes, entries, tools, ctx } = makeFakePi();
|
|
648
|
-
await loadExtension(pi);
|
|
649
|
-
const bgrun = tools.get("bgrun")!;
|
|
650
|
-
|
|
651
|
-
const res = await bgrun.execute(
|
|
652
|
-
"call-n1",
|
|
653
|
-
{ command: "echo named job", name: "unit-tests" },
|
|
654
|
-
undefined,
|
|
655
|
-
undefined,
|
|
656
|
-
ctx,
|
|
657
|
-
);
|
|
658
|
-
const text = res.content[0].text as string;
|
|
659
|
-
const id = (text.match(/^started: ([^\n]+)/) || [])[1];
|
|
660
|
-
// Slug derives from the name, not the command.
|
|
661
|
-
assert.ok(
|
|
662
|
-
id.startsWith("unit-tests-"),
|
|
663
|
-
`id should start with 'unit-tests-': ${id}`,
|
|
664
|
-
);
|
|
665
|
-
// Response includes the name.
|
|
666
|
-
assert.match(text, /name: unit-tests/);
|
|
667
|
-
// Details include the name.
|
|
668
|
-
assert.equal((res.details as any).name, "unit-tests");
|
|
669
|
-
|
|
670
|
-
await waitForWakes(wakes, 1);
|
|
671
|
-
const wake = wakes[0].text;
|
|
672
|
-
// Wake includes the name.
|
|
673
|
-
assert.match(wake, /"unit-tests"/);
|
|
674
|
-
|
|
675
|
-
// Persisted entries carry the name.
|
|
676
|
-
const withName = entries.filter((e) => e.data?.name === "unit-tests");
|
|
677
|
-
assert.equal(withName.length, 2, "running + done entries carry name");
|
|
678
|
-
} finally {
|
|
679
|
-
delete process.env.PI_BGRUN_DIR;
|
|
680
|
-
rmSync(dir, { recursive: true, force: true });
|
|
681
|
-
}
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
test("bgrun: name is optional — behavior unchanged without it", async () => {
|
|
685
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
686
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
687
|
-
try {
|
|
688
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
689
|
-
await loadExtension(pi);
|
|
690
|
-
const bgrun = tools.get("bgrun")!;
|
|
691
|
-
|
|
692
|
-
const res = await bgrun.execute(
|
|
693
|
-
"call-n2",
|
|
694
|
-
{ command: "echo unnamed job" },
|
|
695
|
-
undefined,
|
|
696
|
-
undefined,
|
|
697
|
-
ctx,
|
|
698
|
-
);
|
|
699
|
-
const text = res.content[0].text as string;
|
|
700
|
-
// No 'name:' line in the response.
|
|
701
|
-
assert.ok(!/^ {2}name:/m.test(text), "no name line when name omitted");
|
|
702
|
-
const id = (text.match(/^started: ([^\n]+)/) || [])[1];
|
|
703
|
-
assert.ok(
|
|
704
|
-
id.startsWith("echo-unnamed-job-"),
|
|
705
|
-
`slug falls back to command: ${id}`,
|
|
706
|
-
);
|
|
707
|
-
|
|
708
|
-
await waitForWakes(wakes, 1);
|
|
709
|
-
assert.ok(
|
|
710
|
-
!wakes[0].text.includes('"'),
|
|
711
|
-
"wake has no name quote when unnamed",
|
|
712
|
-
);
|
|
713
|
-
} finally {
|
|
714
|
-
delete process.env.PI_BGRUN_DIR;
|
|
715
|
-
rmSync(dir, { recursive: true, force: true });
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
720
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
721
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
722
|
-
try {
|
|
723
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
724
|
-
await loadExtension(pi);
|
|
725
|
-
const bgrun = tools.get("bgrun")!;
|
|
726
|
-
|
|
727
|
-
// Blank name treated as absent.
|
|
728
|
-
const res1 = await bgrun.execute(
|
|
729
|
-
"call-n3",
|
|
730
|
-
{ command: "echo blank", name: " " },
|
|
731
|
-
undefined,
|
|
732
|
-
undefined,
|
|
733
|
-
ctx,
|
|
734
|
-
);
|
|
735
|
-
assert.ok(
|
|
736
|
-
!/^ {2}name:/m.test(res1.content[0].text as string),
|
|
737
|
-
"blank name ignored",
|
|
738
|
-
);
|
|
739
|
-
|
|
740
|
-
// Over-long name truncated to 80 chars.
|
|
741
|
-
const longName = "x".repeat(200);
|
|
742
|
-
const res2 = await bgrun.execute(
|
|
743
|
-
"call-n4",
|
|
744
|
-
{ command: "echo long", name: longName },
|
|
745
|
-
undefined,
|
|
746
|
-
undefined,
|
|
747
|
-
ctx,
|
|
748
|
-
);
|
|
749
|
-
const text2 = res2.content[0].text as string;
|
|
750
|
-
const nameLine = (text2.match(/^ {2}name: (.+)$/m) || [])[1];
|
|
751
|
-
assert.equal(nameLine.length, 80, "name truncated to 80 chars");
|
|
752
|
-
|
|
753
|
-
await waitForWakes(wakes, 2);
|
|
754
|
-
} finally {
|
|
755
|
-
delete process.env.PI_BGRUN_DIR;
|
|
756
|
-
rmSync(dir, { recursive: true, force: true });
|
|
757
|
-
}
|
|
758
|
-
});
|
|
759
|
-
|
|
760
|
-
test("bgrun: name survives session_start reconstruction", async () => {
|
|
761
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
762
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
763
|
-
try {
|
|
764
|
-
// First instance: run a named job, capture entries.
|
|
765
|
-
const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
|
|
766
|
-
await loadExtension(pi1);
|
|
767
|
-
const bgrun1 = tools1.get("bgrun")!;
|
|
768
|
-
const res = await bgrun1.execute(
|
|
769
|
-
"call-n5",
|
|
770
|
-
{ command: "echo named-restart", name: "rebuild" },
|
|
771
|
-
undefined,
|
|
772
|
-
undefined,
|
|
773
|
-
ctx1,
|
|
774
|
-
);
|
|
775
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
776
|
-
await waitForWakes(wakes, 1);
|
|
777
|
-
|
|
778
|
-
// Second instance: reconstruct from entries, name should be restored.
|
|
779
|
-
const {
|
|
780
|
-
pi: pi2,
|
|
781
|
-
tools: tools2,
|
|
782
|
-
ctx: ctx2,
|
|
783
|
-
fireSessionStart,
|
|
784
|
-
} = makeFakePi({ priorEntries: entries });
|
|
785
|
-
await loadExtension(pi2);
|
|
786
|
-
await fireSessionStart();
|
|
787
|
-
|
|
788
|
-
const bgstatus2 = tools2.get("bgstatus")!;
|
|
789
|
-
const status = await bgstatus2.execute(
|
|
790
|
-
"call-n6",
|
|
791
|
-
{ id },
|
|
792
|
-
undefined,
|
|
793
|
-
undefined,
|
|
794
|
-
ctx2,
|
|
795
|
-
);
|
|
796
|
-
const text = status.content[0].text as string;
|
|
797
|
-
assert.match(text, /name: rebuild/);
|
|
798
|
-
assert.ok(
|
|
799
|
-
!text.includes("recovered from log"),
|
|
800
|
-
"reconstructed from entries, not log",
|
|
801
|
-
);
|
|
802
|
-
} finally {
|
|
803
|
-
delete process.env.PI_BGRUN_DIR;
|
|
804
|
-
rmSync(dir, { recursive: true, force: true });
|
|
805
|
-
}
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
test("bgstatus: list shows name after job id", async () => {
|
|
809
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
810
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
811
|
-
try {
|
|
812
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
813
|
-
await loadExtension(pi);
|
|
814
|
-
const bgrun = tools.get("bgrun")!;
|
|
815
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
816
|
-
|
|
817
|
-
await bgrun.execute(
|
|
818
|
-
"call-n7",
|
|
819
|
-
{ command: "sleep 0.1; echo listed", name: "nightly" },
|
|
820
|
-
undefined,
|
|
821
|
-
undefined,
|
|
822
|
-
ctx,
|
|
823
|
-
);
|
|
824
|
-
await waitForWakes(wakes, 1);
|
|
825
|
-
|
|
826
|
-
const list = await bgstatus.execute(
|
|
827
|
-
"call-n8",
|
|
828
|
-
{ includeDone: true },
|
|
829
|
-
undefined,
|
|
830
|
-
undefined,
|
|
831
|
-
ctx,
|
|
832
|
-
);
|
|
833
|
-
assert.match(list.content[0].text as string, /— nightly: done exit=0/);
|
|
834
|
-
// Without includeDone, finished jobs are hidden by default.
|
|
835
|
-
const runningOnly = await bgstatus.execute(
|
|
836
|
-
"call-n8b",
|
|
837
|
-
{},
|
|
838
|
-
undefined,
|
|
839
|
-
undefined,
|
|
840
|
-
ctx,
|
|
841
|
-
);
|
|
842
|
-
assert.ok(
|
|
843
|
-
!/— nightly: done/.test(runningOnly.content[0].text as string),
|
|
844
|
-
"done job hidden without includeDone",
|
|
845
|
-
);
|
|
846
|
-
} finally {
|
|
847
|
-
delete process.env.PI_BGRUN_DIR;
|
|
848
|
-
rmSync(dir, { recursive: true, force: true });
|
|
849
|
-
}
|
|
850
|
-
});
|
|
851
|
-
|
|
852
|
-
test("session_start: foreign jobs are NOT adopted by default (opt-in only)", async () => {
|
|
853
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
854
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
855
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
856
|
-
try {
|
|
857
|
-
// A running foreign job (no exit marker, live pid — this test process).
|
|
858
|
-
const foreignId = `other-session-job-${Date.now()}-${process.pid}`;
|
|
859
|
-
writeFileSync(join(dir, `${foreignId}.log`), "someone else's job\n");
|
|
860
|
-
|
|
861
|
-
const { pi, tools, ctx, fireSessionStart } = makeFakePi();
|
|
862
|
-
ctx.hasUI = true;
|
|
863
|
-
const widgetCalls: (string[] | undefined)[] = [];
|
|
864
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
865
|
-
widgetCalls.push(lines);
|
|
866
|
-
|
|
867
|
-
await loadExtension(pi);
|
|
868
|
-
await fireSessionStart();
|
|
869
|
-
|
|
870
|
-
// Widget never shows the foreign job.
|
|
871
|
-
const shown = widgetCalls.find((l) => Array.isArray(l));
|
|
872
|
-
assert.equal(
|
|
873
|
-
shown,
|
|
874
|
-
undefined,
|
|
875
|
-
"no widget content for foreign jobs by default",
|
|
876
|
-
);
|
|
877
|
-
|
|
878
|
-
// List-all gives a count note, not the job itself.
|
|
879
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
880
|
-
const list = await bgstatus.execute(
|
|
881
|
-
"call-f1",
|
|
882
|
-
{},
|
|
883
|
-
undefined,
|
|
884
|
-
undefined,
|
|
885
|
-
ctx,
|
|
886
|
-
);
|
|
887
|
-
const text = list.content[0].text as string;
|
|
888
|
-
assert.ok(!text.includes(foreignId), "foreign job not listed by default");
|
|
889
|
-
assert.match(text, /\(1 more job log\(s\) on disk/);
|
|
890
|
-
|
|
891
|
-
// Single-id lookup still works — that's the explicit escape hatch.
|
|
892
|
-
const one = await bgstatus.execute(
|
|
893
|
-
"call-f2",
|
|
894
|
-
{ id: foreignId },
|
|
895
|
-
undefined,
|
|
896
|
-
undefined,
|
|
897
|
-
ctx,
|
|
898
|
-
);
|
|
899
|
-
assert.match(one.content[0].text as string, /: running/);
|
|
900
|
-
} finally {
|
|
901
|
-
delete process.env.PI_BGRUN_DIR;
|
|
902
|
-
rmSync(dir, { recursive: true, force: true });
|
|
903
|
-
}
|
|
904
|
-
});
|
|
905
|
-
|
|
906
|
-
test("session_start: adopts running jobs from the jobs dir (other session's job) into the widget", async () => {
|
|
907
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
908
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
909
|
-
process.env.PI_BGRUN_FOREIGN_JOBS = "1";
|
|
910
|
-
try {
|
|
911
|
-
// A log with no exit marker whose pid is alive (this test process's own pid).
|
|
912
|
-
const adoptedId = `kafka-bootstrap-${Date.now()}-${process.pid}`;
|
|
913
|
-
writeFileSync(join(dir, `${adoptedId}.log`), "job still going\n");
|
|
914
|
-
|
|
915
|
-
const { pi, tools, ctx, fireSessionStart } = makeFakePi();
|
|
916
|
-
ctx.hasUI = true;
|
|
917
|
-
const widgetCalls: (string[] | undefined)[] = [];
|
|
918
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
919
|
-
widgetCalls.push(lines);
|
|
920
|
-
|
|
921
|
-
await loadExtension(pi);
|
|
922
|
-
await fireSessionStart();
|
|
923
|
-
|
|
924
|
-
// Widget should now show the adopted job.
|
|
925
|
-
const shown = widgetCalls.find((l) => Array.isArray(l)) ?? [];
|
|
926
|
-
const flat = (shown as string[]).join("\n");
|
|
927
|
-
assert.match(flat, /bgrun: 1 running/);
|
|
928
|
-
assert.match(flat, new RegExp(adoptedId.slice(0, 20)));
|
|
929
|
-
assert.match(flat, /\(adopted\)/);
|
|
930
|
-
assert.match(flat, /since \d{2}:\d{2}:\d{2}/);
|
|
931
|
-
|
|
932
|
-
// bgstatus single-id should also see it as running (in-memory now).
|
|
933
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
934
|
-
const res = await bgstatus.execute(
|
|
935
|
-
"call-a1",
|
|
936
|
-
{ id: adoptedId },
|
|
937
|
-
undefined,
|
|
938
|
-
undefined,
|
|
939
|
-
ctx,
|
|
940
|
-
);
|
|
941
|
-
assert.match(res.content[0].text as string, /: running/);
|
|
942
|
-
} finally {
|
|
943
|
-
delete process.env.PI_BGRUN_DIR;
|
|
944
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
945
|
-
rmSync(dir, { recursive: true, force: true });
|
|
946
|
-
}
|
|
947
|
-
});
|
|
948
|
-
|
|
949
|
-
test("adopted job leaves the widget once its log shows the exit marker (revalidation)", async () => {
|
|
950
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
951
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
952
|
-
process.env.PI_BGRUN_FOREIGN_JOBS = "1";
|
|
953
|
-
try {
|
|
954
|
-
// Foreign running job (live pid = this process, no marker).
|
|
955
|
-
const adoptedId = `foreign-finish-${Date.now()}-${process.pid}`;
|
|
956
|
-
const logPath = join(dir, `${adoptedId}.log`);
|
|
957
|
-
writeFileSync(logPath, "job still going\n");
|
|
958
|
-
|
|
959
|
-
const { pi, tools, ctx, fireSessionStart } = makeFakePi();
|
|
960
|
-
ctx.hasUI = true;
|
|
961
|
-
const widgetCalls: (string[] | undefined)[] = [];
|
|
962
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
963
|
-
widgetCalls.push(lines);
|
|
964
|
-
|
|
965
|
-
await loadExtension(pi);
|
|
966
|
-
await fireSessionStart();
|
|
967
|
-
assert.ok(
|
|
968
|
-
widgetCalls.some((l) => Array.isArray(l)),
|
|
969
|
-
"widget shown after adoption",
|
|
970
|
-
);
|
|
971
|
-
|
|
972
|
-
// The foreign job finishes: marker appears in the log.
|
|
973
|
-
writeFileSync(logPath, "job still going\n__BGRUN_EXIT__=0\n");
|
|
974
|
-
|
|
975
|
-
// Any bgstatus call revalidates adopted jobs and refreshes the widget.
|
|
976
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
977
|
-
const list = await bgstatus.execute(
|
|
978
|
-
"call-a2",
|
|
979
|
-
{},
|
|
980
|
-
undefined,
|
|
981
|
-
undefined,
|
|
982
|
-
ctx,
|
|
983
|
-
);
|
|
984
|
-
const text = list.content[0].text as string;
|
|
985
|
-
assert.ok(
|
|
986
|
-
!/: running/.test(text),
|
|
987
|
-
"adopted job no longer listed as running",
|
|
988
|
-
);
|
|
989
|
-
assert.match(
|
|
990
|
-
text,
|
|
991
|
-
/\(1 more job log\(s\) on disk/,
|
|
992
|
-
"finished adopted job folded into the disk note",
|
|
993
|
-
);
|
|
994
|
-
assert.ok(
|
|
995
|
-
widgetCalls.some((l) => l === undefined),
|
|
996
|
-
"widget cleared after adopted job finished",
|
|
997
|
-
);
|
|
998
|
-
} finally {
|
|
999
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1000
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1001
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1002
|
-
}
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
test("session_start: does NOT adopt finished or dead-pid jobs", async () => {
|
|
1006
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1007
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1008
|
-
process.env.PI_BGRUN_FOREIGN_JOBS = "1";
|
|
1009
|
-
try {
|
|
1010
|
-
// Finished (exit marker present).
|
|
1011
|
-
writeFileSync(
|
|
1012
|
-
join(dir, `done-job-${Date.now()}-${process.pid}.log`),
|
|
1013
|
-
"out\n__BGRUN_EXIT__=0\n",
|
|
1014
|
-
);
|
|
1015
|
-
// No marker but pid is certainly dead (pid 1 is launchd — alive, so use a likely-dead high pid).
|
|
1016
|
-
// Use pid 1-style trick instead: a dead pid we spawn and reap.
|
|
1017
|
-
const { spawnSync } = await import("node:child_process");
|
|
1018
|
-
const dead = spawnSync("sh", ["-c", "exit 0"]);
|
|
1019
|
-
assert.equal(dead.status, 0);
|
|
1020
|
-
// Write log with a pid that no longer exists: use the reaped child's pid if captured, else 999999.
|
|
1021
|
-
const deadPid = dead.pid ?? 999999;
|
|
1022
|
-
writeFileSync(
|
|
1023
|
-
join(dir, `dead-job-${Date.now()}-${deadPid}.log`),
|
|
1024
|
-
"partial\n",
|
|
1025
|
-
);
|
|
1026
|
-
|
|
1027
|
-
const { pi, ctx, fireSessionStart } = makeFakePi();
|
|
1028
|
-
ctx.hasUI = true;
|
|
1029
|
-
let widgetShown = false;
|
|
1030
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
|
|
1031
|
-
if (lines) widgetShown = true;
|
|
1032
|
-
};
|
|
1033
|
-
|
|
1034
|
-
await loadExtension(pi);
|
|
1035
|
-
await fireSessionStart();
|
|
1036
|
-
assert.equal(widgetShown, false, "no widget for finished/dead jobs");
|
|
1037
|
-
} finally {
|
|
1038
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1039
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1040
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1041
|
-
}
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
test("session_start: with foreign adoption OFF, finished foreign logs are not adopted", async () => {
|
|
1045
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1046
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1047
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1048
|
-
try {
|
|
1049
|
-
writeFileSync(
|
|
1050
|
-
join(dir, `done-job-${Date.now()}-${process.pid}.log`),
|
|
1051
|
-
"out\n__BGRUN_EXIT__=0\n",
|
|
1052
|
-
);
|
|
1053
|
-
const { pi, ctx, fireSessionStart } = makeFakePi();
|
|
1054
|
-
ctx.hasUI = true;
|
|
1055
|
-
let widgetShown = false;
|
|
1056
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
|
|
1057
|
-
if (lines) widgetShown = true;
|
|
1058
|
-
};
|
|
1059
|
-
await loadExtension(pi);
|
|
1060
|
-
await fireSessionStart();
|
|
1061
|
-
assert.equal(widgetShown, false, "no adoption when disabled");
|
|
1062
|
-
} finally {
|
|
1063
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1064
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1065
|
-
}
|
|
1066
|
-
});
|
|
1067
|
-
|
|
1068
|
-
test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
|
|
1069
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1070
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1071
|
-
try {
|
|
1072
|
-
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
1073
|
-
await loadExtension(pi);
|
|
1074
|
-
const bgrun = tools.get("bgrun")!;
|
|
1075
|
-
|
|
1076
|
-
const res = await bgrun.execute(
|
|
1077
|
-
"call-pid",
|
|
1078
|
-
{ command: "echo pidcheck" },
|
|
1079
|
-
undefined,
|
|
1080
|
-
undefined,
|
|
1081
|
-
ctx,
|
|
1082
|
-
);
|
|
1083
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
1084
|
-
const idPid = Number(id.split("-").pop());
|
|
1085
|
-
assert.ok(idPid > 0, `id ends with child pid: ${id}`);
|
|
1086
|
-
assert.notEqual(idPid, process.pid, "id must NOT carry pi's own pid");
|
|
1087
|
-
// Log file named after the id, no .tmp- leftovers.
|
|
1088
|
-
assert.ok(existsSync(join(dir, `${id}.log`)), "log at final id-named path");
|
|
1089
|
-
assert.equal(
|
|
1090
|
-
readdirSync(dir).filter((f) => f.startsWith(".tmp-")).length,
|
|
1091
|
-
0,
|
|
1092
|
-
"no temp log leftovers",
|
|
1093
|
-
);
|
|
1094
|
-
|
|
1095
|
-
await waitForWakes(wakes, 1);
|
|
1096
|
-
} finally {
|
|
1097
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1098
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1099
|
-
}
|
|
1100
|
-
});
|
|
1101
|
-
|
|
1102
|
-
test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
1103
|
-
// Regression: exit marker must win over pid liveness. Old code checked
|
|
1104
|
-
// pid first, so any log whose id-pid happened to be a live process (e.g.
|
|
1105
|
-
// pi's own pid from the old id bug, or pid reuse) was kept forever.
|
|
1106
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1107
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1108
|
-
try {
|
|
1109
|
-
// Old finished foreign log whose id-pid is THIS process (alive!) — must
|
|
1110
|
-
// still be removed by an explicit global sweep.
|
|
1111
|
-
const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
|
|
1112
|
-
writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
|
|
1113
|
-
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1114
|
-
const fs = await import("node:fs");
|
|
1115
|
-
fs.utimesSync(oldPath, oldTime, oldTime);
|
|
1116
|
-
|
|
1117
|
-
const { pi, tools, ctx } = makeFakePi();
|
|
1118
|
-
await loadExtension(pi);
|
|
1119
|
-
const bgclean = tools.get("bgclean")!;
|
|
1120
|
-
|
|
1121
|
-
// Default scope: this session only — the foreign log is untouched.
|
|
1122
|
-
const scoped = await bgclean.execute(
|
|
1123
|
-
"call-stale-scoped",
|
|
1124
|
-
{ days: 7 },
|
|
1125
|
-
undefined,
|
|
1126
|
-
undefined,
|
|
1127
|
-
ctx,
|
|
1128
|
-
);
|
|
1129
|
-
assert.match(scoped.content[0].text as string, /removed 0/);
|
|
1130
|
-
assert.ok(
|
|
1131
|
-
existsSync(oldPath),
|
|
1132
|
-
"foreign log untouched by session-scoped bgclean",
|
|
1133
|
-
);
|
|
1134
|
-
|
|
1135
|
-
const result = await bgclean.execute(
|
|
1136
|
-
"call-stale",
|
|
1137
|
-
{ days: 7, all: true },
|
|
1138
|
-
undefined,
|
|
1139
|
-
undefined,
|
|
1140
|
-
ctx,
|
|
1141
|
-
);
|
|
1142
|
-
assert.match(
|
|
1143
|
-
result.content[0].text as string,
|
|
1144
|
-
/removed 1 job log\(s\) \(all sessions\)/,
|
|
1145
|
-
);
|
|
1146
|
-
assert.ok(
|
|
1147
|
-
!existsSync(oldPath),
|
|
1148
|
-
"finished job's log removed despite live id-pid",
|
|
1149
|
-
);
|
|
1150
|
-
} finally {
|
|
1151
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1152
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1153
|
-
}
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
|
-
test("session_start adoption: skips finished jobs even with a live id-pid", async () => {
|
|
1157
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1158
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1159
|
-
process.env.PI_BGRUN_FOREIGN_JOBS = "1";
|
|
1160
|
-
try {
|
|
1161
|
-
// Finished job (exit marker) whose id-pid is this process (alive).
|
|
1162
|
-
writeFileSync(
|
|
1163
|
-
join(dir, `done-job-${Date.now()}-${process.pid}.log`),
|
|
1164
|
-
"out\n__BGRUN_EXIT__=0\n",
|
|
1165
|
-
);
|
|
1166
|
-
const { pi, ctx, fireSessionStart } = makeFakePi();
|
|
1167
|
-
ctx.hasUI = true;
|
|
1168
|
-
let widgetShown = false;
|
|
1169
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
|
|
1170
|
-
if (lines) widgetShown = true;
|
|
1171
|
-
};
|
|
1172
|
-
await loadExtension(pi);
|
|
1173
|
-
await fireSessionStart();
|
|
1174
|
-
assert.equal(
|
|
1175
|
-
widgetShown,
|
|
1176
|
-
false,
|
|
1177
|
-
"finished job not adopted even though id-pid is alive",
|
|
1178
|
-
);
|
|
1179
|
-
} finally {
|
|
1180
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1181
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1182
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1183
|
-
}
|
|
1184
|
-
});
|
|
1185
|
-
|
|
1186
|
-
test("session_start: reconstructed 'running' job that finished while pi was down is cleared, not zombified", async () => {
|
|
1187
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1188
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1189
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1190
|
-
try {
|
|
1191
|
-
// A job from 5 days ago whose transcript entry never got a done entry
|
|
1192
|
-
// (pi wasn't running when it exited), whose log is long gone and whose
|
|
1193
|
-
// pid is definitely dead.
|
|
1194
|
-
const zombieId = `cd-old-project-make-test-${Date.now()}-99999999`;
|
|
1195
|
-
const logPath = join(dir, `${zombieId}.log`); // never created
|
|
1196
|
-
const priorEntries = [
|
|
1197
|
-
{
|
|
1198
|
-
type: "custom",
|
|
1199
|
-
customType: "bgrun-job",
|
|
1200
|
-
data: {
|
|
1201
|
-
id: zombieId,
|
|
1202
|
-
pid: 99999999,
|
|
1203
|
-
cmd: "cd /old/project && make test",
|
|
1204
|
-
name: undefined,
|
|
1205
|
-
started: Date.now() - 5 * 24 * 60 * 60 * 1000,
|
|
1206
|
-
logPath,
|
|
1207
|
-
state: "running",
|
|
1208
|
-
},
|
|
1209
|
-
},
|
|
1210
|
-
];
|
|
1211
|
-
const { pi, entries, tools, ctx, fireSessionStart } = makeFakePi({
|
|
1212
|
-
priorEntries,
|
|
1213
|
-
});
|
|
1214
|
-
ctx.hasUI = true;
|
|
1215
|
-
const widgetCalls: (string[] | undefined)[] = [];
|
|
1216
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
1217
|
-
widgetCalls.push(lines);
|
|
1218
|
-
|
|
1219
|
-
await loadExtension(pi);
|
|
1220
|
-
await fireSessionStart();
|
|
1221
|
-
|
|
1222
|
-
// Revalidation runs before the widget ever renders — the zombie is
|
|
1223
|
-
// cleared immediately instead of showing as "running" forever.
|
|
1224
|
-
assert.ok(
|
|
1225
|
-
!widgetCalls.some((l) => Array.isArray(l)),
|
|
1226
|
-
"reconstructed zombie never shown in the widget",
|
|
1227
|
-
);
|
|
1228
|
-
|
|
1229
|
-
// A done entry is appended so future resumes reconstruct it as done.
|
|
1230
|
-
const doneEntry = entries.find(
|
|
1231
|
-
(e) =>
|
|
1232
|
-
e.customType === "bgrun-job" &&
|
|
1233
|
-
e.data?.id === zombieId &&
|
|
1234
|
-
e.data?.state === "done",
|
|
1235
|
-
);
|
|
1236
|
-
assert.ok(doneEntry, "done entry appended for the recovered job");
|
|
1237
|
-
|
|
1238
|
-
// Single-id lookup reports done, not running.
|
|
1239
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
1240
|
-
const res = await bgstatus.execute(
|
|
1241
|
-
"call-z1",
|
|
1242
|
-
{ id: zombieId },
|
|
1243
|
-
undefined,
|
|
1244
|
-
undefined,
|
|
1245
|
-
ctx,
|
|
1246
|
-
);
|
|
1247
|
-
assert.match(res.content[0].text as string, /: done/);
|
|
1248
|
-
} finally {
|
|
1249
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1250
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1251
|
-
}
|
|
1252
|
-
});
|
|
1253
|
-
|
|
1254
|
-
test("session_start: done entries with missing exitCode (signal kills) reconstruct as done", async () => {
|
|
1255
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1256
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1257
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1258
|
-
try {
|
|
1259
|
-
// Jobs killed by a signal persist state:"done" with exitCode: undefined —
|
|
1260
|
-
// reconstruction must honor the state field, not just the exit code.
|
|
1261
|
-
const killedId = `nightly-watch-${Date.now()}-${process.pid}`;
|
|
1262
|
-
const logPath = join(dir, `${killedId}.log`);
|
|
1263
|
-
writeFileSync(logPath, "partial output\n"); // no marker — killed before it
|
|
1264
|
-
const priorEntries = [
|
|
1265
|
-
{
|
|
1266
|
-
type: "custom",
|
|
1267
|
-
customType: "bgrun-job",
|
|
1268
|
-
data: {
|
|
1269
|
-
id: killedId,
|
|
1270
|
-
pid: process.pid, // alive — liveness alone must not resurrect it as running
|
|
1271
|
-
cmd: "npm run watch",
|
|
1272
|
-
name: "nightly-watch",
|
|
1273
|
-
started: Date.now() - 60_000,
|
|
1274
|
-
logPath,
|
|
1275
|
-
state: "done",
|
|
1276
|
-
exitCode: undefined,
|
|
1277
|
-
exitedAt: Date.now() - 30_000,
|
|
1278
|
-
},
|
|
1279
|
-
},
|
|
1280
|
-
];
|
|
1281
|
-
const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1282
|
-
ctx.hasUI = true;
|
|
1283
|
-
const widgetCalls: (string[] | undefined)[] = [];
|
|
1284
|
-
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
|
|
1285
|
-
widgetCalls.push(lines);
|
|
1286
|
-
|
|
1287
|
-
await loadExtension(pi);
|
|
1288
|
-
await fireSessionStart();
|
|
1289
|
-
|
|
1290
|
-
assert.ok(
|
|
1291
|
-
!widgetCalls.some((l) => Array.isArray(l)),
|
|
1292
|
-
"signal-killed job with a done entry is not resurrected as running",
|
|
1293
|
-
);
|
|
1294
|
-
const bgstatus = tools.get("bgstatus")!;
|
|
1295
|
-
const res = await bgstatus.execute(
|
|
1296
|
-
"call-z2",
|
|
1297
|
-
{ id: killedId },
|
|
1298
|
-
undefined,
|
|
1299
|
-
undefined,
|
|
1300
|
-
ctx,
|
|
1301
|
-
);
|
|
1302
|
-
assert.match(res.content[0].text as string, /: done/);
|
|
1303
|
-
} finally {
|
|
1304
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1305
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1306
|
-
}
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1309
|
-
test("auto-clean: session boundaries sweep this session's old logs AND week-old foreign orphans by default", async () => {
|
|
1310
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1311
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1312
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1313
|
-
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1314
|
-
try {
|
|
1315
|
-
const fs = await import("node:fs");
|
|
1316
|
-
const backdate = (path: string) => {
|
|
1317
|
-
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1318
|
-
fs.utimesSync(path, oldTime, oldTime);
|
|
1319
|
-
};
|
|
1320
|
-
|
|
1321
|
-
// This session's old done job (from the transcript) with a backdated log.
|
|
1322
|
-
const mineId = `my-old-job-${Date.now()}-99999999`;
|
|
1323
|
-
const myLog = join(dir, `${mineId}.log`);
|
|
1324
|
-
fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
|
|
1325
|
-
backdate(myLog);
|
|
1326
|
-
|
|
1327
|
-
// A foreign session's week-old FINISHED log — an orphan; swept by default.
|
|
1328
|
-
const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
|
|
1329
|
-
fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1330
|
-
backdate(orphanLog);
|
|
1331
|
-
|
|
1332
|
-
// A foreign session's RECENT finished log — within retention, kept.
|
|
1333
|
-
const recentForeignLog = join(
|
|
1334
|
-
dir,
|
|
1335
|
-
`foreign-recent-${Math.floor(Date.now() / 1000)}-99997.log`,
|
|
1336
|
-
);
|
|
1337
|
-
fs.writeFileSync(recentForeignLog, "recent foreign\n__BGRUN_EXIT__=0\n");
|
|
1338
|
-
|
|
1339
|
-
// A foreign session's week-old RUNNING log (no marker, live pid) — running
|
|
1340
|
-
// jobs are pid-protected even when old.
|
|
1341
|
-
const runningForeignLog = join(
|
|
1342
|
-
dir,
|
|
1343
|
-
`foreign-running-${Math.floor(Date.now() / 1000)}-${process.pid}.log`,
|
|
1344
|
-
);
|
|
1345
|
-
fs.writeFileSync(runningForeignLog, "still going\n");
|
|
1346
|
-
backdate(runningForeignLog);
|
|
1347
|
-
|
|
1348
|
-
const priorEntries = [
|
|
1349
|
-
{
|
|
1350
|
-
type: "custom",
|
|
1351
|
-
customType: "bgrun-job",
|
|
1352
|
-
data: {
|
|
1353
|
-
id: mineId,
|
|
1354
|
-
pid: 99999999,
|
|
1355
|
-
cmd: "echo mine",
|
|
1356
|
-
name: undefined,
|
|
1357
|
-
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1358
|
-
logPath: myLog,
|
|
1359
|
-
state: "done",
|
|
1360
|
-
exitCode: 0,
|
|
1361
|
-
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1362
|
-
},
|
|
1363
|
-
},
|
|
1364
|
-
];
|
|
1365
|
-
const { pi, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1366
|
-
await loadExtension(pi);
|
|
1367
|
-
await fireSessionStart();
|
|
1368
|
-
|
|
1369
|
-
assert.ok(!fs.existsSync(myLog), "this session's old log swept");
|
|
1370
|
-
assert.ok(
|
|
1371
|
-
!fs.existsSync(orphanLog),
|
|
1372
|
-
"week-old finished foreign orphan swept by default",
|
|
1373
|
-
);
|
|
1374
|
-
assert.ok(
|
|
1375
|
-
fs.existsSync(recentForeignLog),
|
|
1376
|
-
"recent foreign log kept (within retention)",
|
|
1377
|
-
);
|
|
1378
|
-
assert.ok(
|
|
1379
|
-
fs.existsSync(runningForeignLog),
|
|
1380
|
-
"old but RUNNING foreign log kept (pid-protected)",
|
|
1381
|
-
);
|
|
1382
|
-
} finally {
|
|
1383
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1384
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1385
|
-
}
|
|
1386
|
-
});
|
|
1387
|
-
|
|
1388
|
-
test("auto-clean: globalAutoClean=false opts out — foreign orphans untouched, own old logs still swept", async () => {
|
|
1389
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1390
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1391
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1392
|
-
process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
|
|
1393
|
-
try {
|
|
1394
|
-
const fs = await import("node:fs");
|
|
1395
|
-
const backdate = (path: string) => {
|
|
1396
|
-
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1397
|
-
fs.utimesSync(path, oldTime, oldTime);
|
|
1398
|
-
};
|
|
1399
|
-
|
|
1400
|
-
const mineId = `my-old-job-${Date.now()}-99999999`;
|
|
1401
|
-
const myLog = join(dir, `${mineId}.log`);
|
|
1402
|
-
fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
|
|
1403
|
-
backdate(myLog);
|
|
1404
|
-
|
|
1405
|
-
const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
|
|
1406
|
-
fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1407
|
-
backdate(orphanLog);
|
|
1408
|
-
|
|
1409
|
-
const priorEntries = [
|
|
1410
|
-
{
|
|
1411
|
-
type: "custom",
|
|
1412
|
-
customType: "bgrun-job",
|
|
1413
|
-
data: {
|
|
1414
|
-
id: mineId,
|
|
1415
|
-
pid: 99999999,
|
|
1416
|
-
cmd: "echo mine",
|
|
1417
|
-
name: undefined,
|
|
1418
|
-
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1419
|
-
logPath: myLog,
|
|
1420
|
-
state: "done",
|
|
1421
|
-
exitCode: 0,
|
|
1422
|
-
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1423
|
-
},
|
|
1424
|
-
},
|
|
1425
|
-
];
|
|
1426
|
-
const { pi, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1427
|
-
await loadExtension(pi);
|
|
1428
|
-
await fireSessionStart();
|
|
1429
|
-
|
|
1430
|
-
assert.ok(!fs.existsSync(myLog), "this session's old log still swept");
|
|
1431
|
-
assert.ok(
|
|
1432
|
-
fs.existsSync(orphanLog),
|
|
1433
|
-
"foreign orphan untouched when globalAutoClean is off",
|
|
1434
|
-
);
|
|
1435
|
-
} finally {
|
|
1436
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1437
|
-
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1438
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1439
|
-
}
|
|
1440
|
-
});
|
|
1441
|
-
|
|
1442
|
-
test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgclean all always runs", async () => {
|
|
1443
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1444
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1445
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1446
|
-
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on
|
|
1447
|
-
try {
|
|
1448
|
-
const fs = await import("node:fs");
|
|
1449
|
-
const backdate = (path: string) => {
|
|
1450
|
-
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1451
|
-
fs.utimesSync(path, oldTime, oldTime);
|
|
1452
|
-
};
|
|
1453
|
-
|
|
1454
|
-
// Old foreign log A + first session_start (no marker yet) → global sweep
|
|
1455
|
-
// runs, A removed.
|
|
1456
|
-
const logA = join(dir, "old-a-1000000000-99999.log");
|
|
1457
|
-
fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
|
|
1458
|
-
backdate(logA);
|
|
1459
|
-
{
|
|
1460
|
-
const { pi, fireSessionStart } = makeFakePi();
|
|
1461
|
-
await loadExtension(pi);
|
|
1462
|
-
await fireSessionStart();
|
|
1463
|
-
}
|
|
1464
|
-
assert.ok(!fs.existsSync(logA), "first global sweep removed old log A");
|
|
1465
|
-
assert.ok(
|
|
1466
|
-
fs.existsSync(join(dir, ".last-clean")),
|
|
1467
|
-
"throttle marker written",
|
|
1468
|
-
);
|
|
1469
|
-
|
|
1470
|
-
// Old foreign log B + second session_start while marker is fresh →
|
|
1471
|
-
// throttled, B kept.
|
|
1472
|
-
const logB = join(dir, "old-b-1000000000-99998.log");
|
|
1473
|
-
fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
|
|
1474
|
-
backdate(logB);
|
|
1475
|
-
{
|
|
1476
|
-
const { pi, fireSessionStart } = makeFakePi();
|
|
1477
|
-
await loadExtension(pi);
|
|
1478
|
-
await fireSessionStart();
|
|
1479
|
-
}
|
|
1480
|
-
assert.ok(
|
|
1481
|
-
fs.existsSync(logB),
|
|
1482
|
-
"second global sweep throttled — old log B kept",
|
|
1483
|
-
);
|
|
1484
|
-
|
|
1485
|
-
// Manual `bgclean all` ignores the throttle and removes B.
|
|
1486
|
-
const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
|
|
1487
|
-
await loadExtension(pi3);
|
|
1488
|
-
const bgclean = tools3.get("bgclean")!;
|
|
1489
|
-
const result = await bgclean.execute(
|
|
1490
|
-
"call-t1",
|
|
1491
|
-
{ all: true },
|
|
1492
|
-
undefined,
|
|
1493
|
-
undefined,
|
|
1494
|
-
ctx3,
|
|
1495
|
-
);
|
|
1496
|
-
assert.match(
|
|
1497
|
-
result.content[0].text as string,
|
|
1498
|
-
/removed 1 job log\(s\) \(all sessions\)/,
|
|
1499
|
-
);
|
|
1500
|
-
assert.ok(!fs.existsSync(logB), "manual bgclean all removed log B");
|
|
1501
|
-
} finally {
|
|
1502
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1503
|
-
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1504
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1505
|
-
}
|
|
1506
|
-
});
|
|
1507
|
-
|
|
1508
|
-
test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => {
|
|
1509
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1510
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1511
|
-
// Isolate bgclean's scoping from the global orphan auto-sweep (default on)
|
|
1512
|
-
// so the foreign log survives session_start for bgclean to (not) act on.
|
|
1513
|
-
process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
|
|
1514
|
-
try {
|
|
1515
|
-
const fs = await import("node:fs");
|
|
1516
|
-
const backdate = (path: string) => {
|
|
1517
|
-
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1518
|
-
fs.utimesSync(path, oldTime, oldTime);
|
|
1519
|
-
};
|
|
1520
|
-
|
|
1521
|
-
const mkEntry = (
|
|
1522
|
-
id: string,
|
|
1523
|
-
logPath: string,
|
|
1524
|
-
extra: Record<string, unknown> = {},
|
|
1525
|
-
) => ({
|
|
1526
|
-
type: "custom",
|
|
1527
|
-
customType: "bgrun-job",
|
|
1528
|
-
data: {
|
|
1529
|
-
id,
|
|
1530
|
-
pid: 99999999,
|
|
1531
|
-
cmd: `echo ${id}`,
|
|
1532
|
-
name: undefined,
|
|
1533
|
-
started: Date.now() - 60_000,
|
|
1534
|
-
logPath,
|
|
1535
|
-
state: "done",
|
|
1536
|
-
exitCode: 0,
|
|
1537
|
-
exitedAt: Date.now() - 30_000,
|
|
1538
|
-
...extra,
|
|
1539
|
-
},
|
|
1540
|
-
});
|
|
1541
|
-
|
|
1542
|
-
// This session's recent done job (fresh log — kept).
|
|
1543
|
-
const recentId = `recent-job-${Date.now()}-99999998`;
|
|
1544
|
-
const recentLog = join(dir, `${recentId}.log`);
|
|
1545
|
-
fs.writeFileSync(recentLog, "recent\n__BGRUN_EXIT__=0\n");
|
|
1546
|
-
|
|
1547
|
-
// This session's old done job (backdated log — removed by default scope).
|
|
1548
|
-
const oldId = `old-session-job-${Date.now()}-99999997`;
|
|
1549
|
-
const oldLog = join(dir, `${oldId}.log`);
|
|
1550
|
-
fs.writeFileSync(oldLog, "old session job\n__BGRUN_EXIT__=0\n");
|
|
1551
|
-
backdate(oldLog);
|
|
1552
|
-
|
|
1553
|
-
// A foreign session's old log — untouched by default, removed with all.
|
|
1554
|
-
const foreignLog = join(dir, "foreign-old-job-1000000000-99996.log");
|
|
1555
|
-
fs.writeFileSync(foreignLog, "foreign\n__BGRUN_EXIT__=0\n");
|
|
1556
|
-
backdate(foreignLog);
|
|
1557
|
-
|
|
1558
|
-
const priorEntries = [
|
|
1559
|
-
mkEntry(recentId, recentLog),
|
|
1560
|
-
mkEntry(oldId, oldLog, {
|
|
1561
|
-
started: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1562
|
-
exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
|
|
1563
|
-
}),
|
|
1564
|
-
];
|
|
1565
|
-
const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
|
|
1566
|
-
await loadExtension(pi);
|
|
1567
|
-
await fireSessionStart(); // reconstruct + session-scoped auto-sweep runs here too
|
|
1568
|
-
|
|
1569
|
-
const bgclean = tools.get("bgclean")!;
|
|
1570
|
-
|
|
1571
|
-
// Default: this session only.
|
|
1572
|
-
const scoped = await bgclean.execute(
|
|
1573
|
-
"call-c2",
|
|
1574
|
-
{ days: 7 },
|
|
1575
|
-
undefined,
|
|
1576
|
-
undefined,
|
|
1577
|
-
ctx,
|
|
1578
|
-
);
|
|
1579
|
-
assert.match(scoped.content[0].text as string, /\(this session\)/);
|
|
1580
|
-
assert.ok(!fs.existsSync(oldLog), "this session's old log removed");
|
|
1581
|
-
assert.ok(fs.existsSync(recentLog), "this session's recent log kept");
|
|
1582
|
-
assert.ok(
|
|
1583
|
-
fs.existsSync(foreignLog),
|
|
1584
|
-
"foreign log untouched by session-scoped bgclean",
|
|
1585
|
-
);
|
|
1586
|
-
|
|
1587
|
-
// all: true sweeps the shared dir.
|
|
1588
|
-
const global = await bgclean.execute(
|
|
1589
|
-
"call-c3",
|
|
1590
|
-
{ days: 7, all: true },
|
|
1591
|
-
undefined,
|
|
1592
|
-
undefined,
|
|
1593
|
-
ctx,
|
|
1594
|
-
);
|
|
1595
|
-
assert.match(global.content[0].text as string, /\(all sessions\)/);
|
|
1596
|
-
assert.ok(!fs.existsSync(foreignLog), "foreign log removed by bgclean all");
|
|
1597
|
-
assert.ok(fs.existsSync(recentLog), "recent log still kept");
|
|
1598
|
-
} finally {
|
|
1599
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1600
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1601
|
-
}
|
|
1602
|
-
});
|
|
1603
|
-
|
|
1604
|
-
test("bgclean: rejects negative days", async () => {
|
|
1605
|
-
const { pi, tools, ctx } = makeFakePi();
|
|
1606
|
-
await loadExtension(pi);
|
|
1607
|
-
const bgclean = tools.get("bgclean")!;
|
|
1608
|
-
await assert.rejects(
|
|
1609
|
-
() => bgclean.execute("call-c3", { days: -1 }, undefined, undefined, ctx),
|
|
1610
|
-
/non-negative/,
|
|
1611
|
-
);
|
|
1612
|
-
});
|
|
1613
|
-
|
|
1614
|
-
test("bgclean: does not remove a running job's log", async () => {
|
|
1615
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1616
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1617
|
-
try {
|
|
1618
|
-
const { pi, tools, ctx } = makeFakePi();
|
|
1619
|
-
await loadExtension(pi);
|
|
1620
|
-
const bgrun = tools.get("bgrun")!;
|
|
1621
|
-
const bgclean = tools.get("bgclean")!;
|
|
1622
|
-
|
|
1623
|
-
// Start a long-running job (10s) so it's still running when we clean.
|
|
1624
|
-
const res = await bgrun.execute(
|
|
1625
|
-
"call-c4",
|
|
1626
|
-
{ command: "sleep 10" },
|
|
1627
|
-
undefined,
|
|
1628
|
-
undefined,
|
|
1629
|
-
ctx,
|
|
1630
|
-
);
|
|
1631
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
1632
|
-
const logPath = join(dir, `${id}.log`);
|
|
1633
|
-
|
|
1634
|
-
// Backdate the log's mtime to make it look old — but the job is still running
|
|
1635
|
-
// (pid is in the in-memory Map), so bgclean should skip it.
|
|
1636
|
-
const fs = await import("node:fs");
|
|
1637
|
-
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
1638
|
-
// Wait a moment for the log file to exist, then backdate.
|
|
1639
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1640
|
-
fs.utimesSync(logPath, oldTime, oldTime);
|
|
1641
|
-
|
|
1642
|
-
const result = await bgclean.execute(
|
|
1643
|
-
"call-c5",
|
|
1644
|
-
{ days: 7 },
|
|
1645
|
-
undefined,
|
|
1646
|
-
undefined,
|
|
1647
|
-
ctx,
|
|
1648
|
-
);
|
|
1649
|
-
const text = result.content[0].text as string;
|
|
1650
|
-
assert.match(text, /skipped 1 running/);
|
|
1651
|
-
assert.ok(fs.existsSync(logPath), "running job's log not removed");
|
|
1652
|
-
|
|
1653
|
-
// Kill the orphaned sleep so it doesn't linger (best-effort: it may have exited already).
|
|
1654
|
-
try {
|
|
1655
|
-
process.kill((res.details as any).pid);
|
|
1656
|
-
} catch {
|
|
1657
|
-
// already gone — fine
|
|
1658
|
-
}
|
|
1659
|
-
} finally {
|
|
1660
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1661
|
-
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1662
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1663
|
-
}
|
|
1664
|
-
});
|
|
1665
|
-
|
|
1666
|
-
test("slash commands: /bgstatus, /bgtail, /bgclean registered and share the tool logic", async () => {
|
|
1667
|
-
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1668
|
-
process.env.PI_BGRUN_DIR = dir;
|
|
1669
|
-
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1670
|
-
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1671
|
-
try {
|
|
1672
|
-
const { pi, wakes, tools, commands, ctx } = makeFakePi();
|
|
1673
|
-
ctx.hasUI = true;
|
|
1674
|
-
const notes: { text: string; kind: string }[] = [];
|
|
1675
|
-
ctx.ui.notify = (text: string, kind: string) => notes.push({ text, kind });
|
|
1676
|
-
|
|
1677
|
-
await loadExtension(pi);
|
|
1678
|
-
|
|
1679
|
-
// All three human-facing commands are registered (/bgrun is agent-only).
|
|
1680
|
-
assert.ok(commands.has("bgstatus"), "/bgstatus registered");
|
|
1681
|
-
assert.ok(commands.has("bgtail"), "/bgtail registered");
|
|
1682
|
-
assert.ok(commands.has("bgclean"), "/bgclean registered");
|
|
1683
|
-
assert.ok(!commands.has("bgrun"), "/bgrun deliberately not a command");
|
|
1684
|
-
|
|
1685
|
-
// Run a real job to completion so there's something to inspect.
|
|
1686
|
-
const bgrun = tools.get("bgrun")!;
|
|
1687
|
-
const res = await bgrun.execute(
|
|
1688
|
-
"call-cmd1",
|
|
1689
|
-
{ command: "echo cmd-mirror", name: "mirror-job" },
|
|
1690
|
-
undefined,
|
|
1691
|
-
undefined,
|
|
1692
|
-
ctx,
|
|
1693
|
-
);
|
|
1694
|
-
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
1695
|
-
await waitForWakes(wakes, 1);
|
|
1696
|
-
|
|
1697
|
-
// /bgstatus <id> → single-job status via notify.
|
|
1698
|
-
await commands.get("bgstatus")!.handler(id, ctx);
|
|
1699
|
-
assert.ok(
|
|
1700
|
-
notes.some((n) => n.text.includes(id) && /: done/.test(n.text)),
|
|
1701
|
-
"/bgstatus <id> notifies job status",
|
|
1702
|
-
);
|
|
1703
|
-
|
|
1704
|
-
// /bgstatus done → listing includes the finished job.
|
|
1705
|
-
await commands.get("bgstatus")!.handler("done", ctx);
|
|
1706
|
-
assert.ok(
|
|
1707
|
-
notes.some((n) => /mirror-job: done exit=0/.test(n.text)),
|
|
1708
|
-
"/bgstatus done lists finished jobs",
|
|
1709
|
-
);
|
|
1710
|
-
|
|
1711
|
-
// /bgtail <id> <lines> → condensed tail via notify.
|
|
1712
|
-
await commands.get("bgtail")!.handler(`${id} 5`, ctx);
|
|
1713
|
-
assert.ok(
|
|
1714
|
-
notes.some((n) => n.text.includes("cmd-mirror")),
|
|
1715
|
-
"/bgtail notifies the log tail",
|
|
1716
|
-
);
|
|
1717
|
-
|
|
1718
|
-
// /bgtail with no args → usage error.
|
|
1719
|
-
await commands.get("bgtail")!.handler("", ctx);
|
|
1720
|
-
assert.ok(
|
|
1721
|
-
notes.some((n) => n.kind === "error" && /Usage: \/bgtail/.test(n.text)),
|
|
1722
|
-
"/bgtail without id shows usage",
|
|
1723
|
-
);
|
|
1724
|
-
|
|
1725
|
-
// /bgclean (no args) → session-scoped summary via notify.
|
|
1726
|
-
await commands.get("bgclean")!.handler("", ctx);
|
|
1727
|
-
assert.ok(
|
|
1728
|
-
notes.some((n) => /removed 0 job log\(s\) \(this session\)/.test(n.text)),
|
|
1729
|
-
"/bgclean notifies the session-scoped summary",
|
|
1730
|
-
);
|
|
1731
|
-
|
|
1732
|
-
// /bgclean 7 all → global scope.
|
|
1733
|
-
await commands.get("bgclean")!.handler("7 all", ctx);
|
|
1734
|
-
assert.ok(
|
|
1735
|
-
notes.some((n) => /\(all sessions\)/.test(n.text)),
|
|
1736
|
-
"/bgclean all notifies the global summary",
|
|
1737
|
-
);
|
|
1738
|
-
} finally {
|
|
1739
|
-
delete process.env.PI_BGRUN_DIR;
|
|
1740
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1741
|
-
}
|
|
1742
|
-
});
|
|
1743
|
-
|
|
1744
|
-
test("formatSince: same-day shows time only; older days include the date", async () => {
|
|
1745
|
-
const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
|
|
1746
|
-
const mod: any = await import(url);
|
|
1747
|
-
assert.equal(typeof mod.formatSince, "function");
|
|
1748
|
-
|
|
1749
|
-
const now = new Date("2026-09-09T10:00:00").getTime();
|
|
1750
|
-
const sameDay = new Date("2026-09-09T06:30:12").getTime();
|
|
1751
|
-
const prevDay = new Date("2026-09-04T15:05:40").getTime();
|
|
1752
|
-
const prevMonth = new Date("2026-08-12T23:59:59").getTime();
|
|
1753
|
-
const prevYear = new Date("2025-12-30T08:00:00").getTime();
|
|
1754
|
-
|
|
1755
|
-
// Same calendar day → time only (unchanged display).
|
|
1756
|
-
assert.equal(mod.formatSince(sameDay, now), "06:30:12");
|
|
1757
|
-
|
|
1758
|
-
// Different day, same year → date + time.
|
|
1759
|
-
const prevDayStr = mod.formatSince(prevDay, now);
|
|
1760
|
-
assert.match(prevDayStr, /Sep 4/);
|
|
1761
|
-
assert.match(prevDayStr, /15:05:40/);
|
|
1762
|
-
|
|
1763
|
-
const prevMonthStr = mod.formatSince(prevMonth, now);
|
|
1764
|
-
assert.match(prevMonthStr, /Aug 12/);
|
|
1765
|
-
assert.match(prevMonthStr, /23:59:59/);
|
|
1766
|
-
|
|
1767
|
-
// Different year → date includes the year.
|
|
1768
|
-
const prevYearStr = mod.formatSince(prevYear, now);
|
|
1769
|
-
assert.match(prevYearStr, /2025/);
|
|
1770
|
-
assert.match(prevYearStr, /Dec 30/);
|
|
1771
|
-
assert.match(prevYearStr, /08:00:00/);
|
|
1772
|
-
});
|