@stablekernel/pi-background-run 0.5.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.
@@ -1,2569 +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
- mkdirSync,
23
- appendFileSync,
24
- readdirSync,
25
- } from "node:fs";
26
- import { join } from "node:path";
27
- import { homedir, tmpdir } from "node:os";
28
- import { pathToFileURL } from "node:url";
29
-
30
- interface CapturedWake {
31
- text: string;
32
- options?: Record<string, unknown>;
33
- }
34
-
35
- function makeFakePi(
36
- opts: {
37
- idle?: boolean;
38
- priorEntries?: any[];
39
- ctxFields?: Record<string, unknown>;
40
- } = {},
41
- ): {
42
- pi: any;
43
- wakes: CapturedWake[];
44
- entries: any[];
45
- tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
46
- commands: Map<
47
- string,
48
- { description?: string; handler: (...args: any[]) => Promise<void> }
49
- >;
50
- ctx: any;
51
- handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
52
- fireSessionStart: () => Promise<void>;
53
- } {
54
- const wakes: CapturedWake[] = [];
55
- const entries: any[] = opts.priorEntries ? [...opts.priorEntries] : [];
56
- const tools = new Map<
57
- string,
58
- { execute: (...args: any[]) => Promise<any> }
59
- >();
60
- const commands = new Map<
61
- string,
62
- { description?: string; handler: (...args: any[]) => Promise<void> }
63
- >();
64
- const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
65
- const idle = opts.idle ?? true;
66
- const ctx = {
67
- isIdle: () => idle,
68
- hasUI: false,
69
- ui: { notify() {}, setWidget() {}, setStatus() {} },
70
- sessionManager: { getEntries: () => entries },
71
- ...(opts.ctxFields as Record<string, unknown> | undefined),
72
- };
73
- const pi = {
74
- sendUserMessage(text: string, options?: Record<string, unknown>) {
75
- wakes.push({ text, options });
76
- },
77
- appendEntry(customType: string, data?: unknown) {
78
- entries.push({ type: "custom", customType, data });
79
- },
80
- registerEntryRenderer() {},
81
- registerTool(def: any) {
82
- tools.set(def.name, def);
83
- },
84
- registerCommand(name: string, def: any) {
85
- commands.set(name, def);
86
- },
87
- on(event: string, handler: (...args: any[]) => Promise<any>) {
88
- const list = handlers.get(event) ?? [];
89
- list.push(handler);
90
- handlers.set(event, list);
91
- },
92
- };
93
- const fireSessionStart = async () => {
94
- for (const h of handlers.get("session_start") ?? []) {
95
- await h({ reason: "startup" }, ctx);
96
- }
97
- };
98
- return {
99
- pi,
100
- wakes,
101
- entries,
102
- tools,
103
- commands,
104
- ctx,
105
- handlers,
106
- fireSessionStart,
107
- };
108
- }
109
-
110
- async function loadExtension(
111
- fakePi: any,
112
- ): Promise<Map<string, { execute: (...args: any[]) => Promise<any> }>> {
113
- const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
114
- const mod = await import(url);
115
- mod.default(fakePi);
116
- return fakePi.tools as Map<
117
- string,
118
- { execute: (...args: any[]) => Promise<any> }
119
- >;
120
- }
121
-
122
- function waitForWakes(
123
- wakes: CapturedWake[],
124
- count: number,
125
- timeoutMs = 5000,
126
- ): Promise<void> {
127
- return new Promise((resolve, reject) => {
128
- const start = Date.now();
129
- const tick = () => {
130
- if (wakes.length >= count) return resolve();
131
- if (Date.now() - start > timeoutMs)
132
- return reject(
133
- new Error(
134
- `timed out waiting for ${count} wakes, got ${wakes.length}`,
135
- ),
136
- );
137
- setTimeout(tick, 50);
138
- };
139
- tick();
140
- });
141
- }
142
-
143
- test("bgrun: successful command writes log + exit marker and wakes with ✅", async () => {
144
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
145
- process.env.PI_BGRUN_DIR = dir;
146
- try {
147
- const { pi, wakes, tools, ctx } = makeFakePi();
148
- await loadExtension(pi);
149
- const bgrun = tools.get("bgrun")!;
150
-
151
- const res = await bgrun.execute(
152
- "call-1",
153
- { command: "echo hello world" },
154
- undefined,
155
- undefined,
156
- ctx,
157
- );
158
- const started = res.content[0].text as string;
159
- assert.match(started, /^started: /);
160
- const id = (started.match(/^started: ([^\n]+)/) || [])[1];
161
- assert.ok(id, "got a job id");
162
-
163
- await waitForWakes(wakes, 1);
164
- // When idle, sendUserMessage is called with no options.
165
- assert.equal(wakes[0].options, undefined);
166
- const wake = wakes[0].text;
167
- assert.match(wake, /✅/);
168
- assert.match(wake, /exit 0/);
169
- assert.match(wake, /hello world/);
170
- assert.match(wake, new RegExp(id));
171
-
172
- const logPath = join(dir, `${id}.log`);
173
- assert.ok(existsSync(logPath), "log file exists");
174
- const log = readFileSync(logPath, "utf8");
175
- assert.match(log, /hello world/);
176
- assert.match(log, /__BGRUN_EXIT__=0/);
177
- } finally {
178
- delete process.env.PI_BGRUN_DIR;
179
- rmSync(dir, { recursive: true, force: true });
180
- }
181
- });
182
-
183
- test("bgrun: failing command wakes with ❌ and the non-zero exit code", async () => {
184
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
185
- process.env.PI_BGRUN_DIR = dir;
186
- try {
187
- const { pi, wakes, tools, ctx } = makeFakePi();
188
- await loadExtension(pi);
189
- const bgrun = tools.get("bgrun")!;
190
-
191
- await bgrun.execute(
192
- "call-2",
193
- { command: "echo failing now; exit 7" },
194
- undefined,
195
- undefined,
196
- ctx,
197
- );
198
- await waitForWakes(wakes, 1);
199
- const wake = wakes[0].text;
200
- assert.match(wake, /❌/);
201
- assert.match(wake, /exit 7/);
202
- } finally {
203
- delete process.env.PI_BGRUN_DIR;
204
- rmSync(dir, { recursive: true, force: true });
205
- }
206
- });
207
-
208
- test("bgrun: when agent is busy, wake is queued as followUp", async () => {
209
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
210
- process.env.PI_BGRUN_DIR = dir;
211
- try {
212
- const { pi, wakes, tools, ctx } = makeFakePi({ idle: false });
213
- await loadExtension(pi);
214
- const bgrun = tools.get("bgrun")!;
215
-
216
- await bgrun.execute(
217
- "call-busy",
218
- { command: "echo while-busy" },
219
- undefined,
220
- undefined,
221
- ctx,
222
- );
223
- await waitForWakes(wakes, 1);
224
- assert.equal(wakes[0].options?.deliverAs, "followUp");
225
- } finally {
226
- delete process.env.PI_BGRUN_DIR;
227
- rmSync(dir, { recursive: true, force: true });
228
- }
229
- });
230
-
231
- test("bgtail: returns last N lines, strips the exit marker", async () => {
232
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
233
- process.env.PI_BGRUN_DIR = dir;
234
- try {
235
- const { pi, wakes, tools, ctx } = makeFakePi();
236
- await loadExtension(pi);
237
- const bgrun = tools.get("bgrun")!;
238
- const bgtail = tools.get("bgtail")!;
239
-
240
- const res = await bgrun.execute(
241
- "call-3",
242
- { command: "printf 'line1\\nline2\\nline3\\n'" },
243
- undefined,
244
- undefined,
245
- ctx,
246
- );
247
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
248
- await waitForWakes(wakes, 1);
249
-
250
- const tail = await bgtail.execute(
251
- "call-3",
252
- { id, lines: 2 },
253
- undefined,
254
- undefined,
255
- ctx,
256
- );
257
- const text = tail.content[0].text as string;
258
- assert.ok(!text.includes("__BGRUN_EXIT__"), "marker stripped");
259
- assert.match(text, /line2\nline3$|^line3$/);
260
- } finally {
261
- delete process.env.PI_BGRUN_DIR;
262
- rmSync(dir, { recursive: true, force: true });
263
- }
264
- });
265
-
266
- test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lines", async () => {
267
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
268
- process.env.PI_BGRUN_DIR = dir;
269
- try {
270
- const { pi, wakes, tools, ctx } = makeFakePi();
271
- await loadExtension(pi);
272
- const bgrun = tools.get("bgrun")!;
273
- const bgtail = tools.get("bgtail")!;
274
-
275
- // 1 ANSI-colored line, 5 identical spinner lines, 1 huge line
276
- const esc = "\u001b"; // literal ESC byte, safe to pass through a shell arg
277
- const payload =
278
- `printf "${esc}[32mOK green${esc}[0m\nwait\nwait\nwait\nwait\nwait\nline3\n"; ` +
279
- "echo \"$(printf 'x%.0s' $(seq 1 5000))\"";
280
- const res = await bgrun.execute(
281
- "call-c1",
282
- { command: payload },
283
- undefined,
284
- undefined,
285
- ctx,
286
- );
287
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
288
- await waitForWakes(wakes, 1);
289
-
290
- const tail = await bgtail.execute(
291
- "call-c1",
292
- { id, lines: 40 },
293
- undefined,
294
- undefined,
295
- ctx,
296
- );
297
- const text = tail.content[0].text as string;
298
- assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
299
- assert.ok(text.includes("OK green"), "text after stripping survives");
300
- assert.match(
301
- text,
302
- /wait {2}\[x5\]/,
303
- "5 identical lines collapsed to one with count",
304
- );
305
- assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
306
- assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
307
- assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
308
- assert.match(
309
- text,
310
- /1 repeated-line run collapsed/,
311
- "notes mention run collapse",
312
- );
313
- assert.ok((tail.details as any).condensed === true);
314
- } finally {
315
- delete process.env.PI_BGRUN_DIR;
316
- rmSync(dir, { recursive: true, force: true });
317
- }
318
- });
319
-
320
- test("bgtail: raw=true skips condensing", async () => {
321
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
322
- process.env.PI_BGRUN_DIR = dir;
323
- try {
324
- const { pi, wakes, tools, ctx } = makeFakePi();
325
- await loadExtension(pi);
326
- const bgrun = tools.get("bgrun")!;
327
- const bgtail = tools.get("bgtail")!;
328
-
329
- const esc = "\u001b";
330
- const res = await bgrun.execute(
331
- "call-c2",
332
- { command: `printf "${esc}[31mraw-red${esc}[0m\nwait\nwait\nwait\n"` },
333
- undefined,
334
- undefined,
335
- ctx,
336
- );
337
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
338
- await waitForWakes(wakes, 1);
339
-
340
- const tail = await bgtail.execute(
341
- "call-c2",
342
- { id, raw: true },
343
- undefined,
344
- undefined,
345
- ctx,
346
- );
347
- const text = tail.content[0].text as string;
348
- assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
349
- assert.ok(
350
- text.includes("wait\nwait\nwait"),
351
- "raw keeps repeated lines uncollapsed",
352
- );
353
- assert.ok((tail.details as any).condensed === false);
354
- } finally {
355
- delete process.env.PI_BGRUN_DIR;
356
- rmSync(dir, { recursive: true, force: true });
357
- }
358
- });
359
-
360
- test("bgtail: total cap kicks in on large output with guidance note", async () => {
361
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
362
- process.env.PI_BGRUN_DIR = dir;
363
- try {
364
- const { pi, wakes, tools, ctx } = makeFakePi();
365
- await loadExtension(pi);
366
- const bgrun = tools.get("bgrun")!;
367
- const bgtail = tools.get("bgtail")!;
368
-
369
- // ~200 distinct lines x ~500 chars = ~100KB, well past the 8KB total cap
370
- const cmd =
371
- "for i in $(seq 1 200); do echo \"line-$i $(printf 'y%.0s' $(seq 1 500))\"; done";
372
- const res = await bgrun.execute(
373
- "call-c3",
374
- { command: cmd },
375
- undefined,
376
- undefined,
377
- ctx,
378
- );
379
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
380
- await waitForWakes(wakes, 1);
381
-
382
- const tail = await bgtail.execute(
383
- "call-c3",
384
- { id, lines: 200 },
385
- undefined,
386
- undefined,
387
- ctx,
388
- );
389
- const text = tail.content[0].text as string;
390
- assert.ok(text.length < 10_000, "result capped well below raw size");
391
- assert.match(
392
- text,
393
- /output capped at 8000 chars — 200 raw lines total/,
394
- "cap note names the raw line count and suggests escalation paths",
395
- );
396
- assert.ok((tail.details as any).condenserNotes, "notes in details too");
397
- } finally {
398
- delete process.env.PI_BGRUN_DIR;
399
- rmSync(dir, { recursive: true, force: true });
400
- }
401
- });
402
-
403
- test("bgstatus: shows running then done with exit code", async () => {
404
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
405
- process.env.PI_BGRUN_DIR = dir;
406
- try {
407
- const { pi, wakes, tools, ctx } = makeFakePi();
408
- await loadExtension(pi);
409
- const bgrun = tools.get("bgrun")!;
410
- const bgstatus = tools.get("bgstatus")!;
411
-
412
- const res = await bgrun.execute(
413
- "call-4",
414
- { command: "sleep 0.2; echo done" },
415
- undefined,
416
- undefined,
417
- ctx,
418
- );
419
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
420
-
421
- // While running, status should say running.
422
- const running = await bgstatus.execute(
423
- "call-4",
424
- { id },
425
- undefined,
426
- undefined,
427
- ctx,
428
- );
429
- assert.match(running.content[0].text as string, /running/);
430
-
431
- await waitForWakes(wakes, 1);
432
- const done = await bgstatus.execute(
433
- "call-4",
434
- { id },
435
- undefined,
436
- undefined,
437
- ctx,
438
- );
439
- assert.match(done.content[0].text as string, /done/);
440
- assert.match(done.content[0].text as string, /exit=0/);
441
- } finally {
442
- delete process.env.PI_BGRUN_DIR;
443
- rmSync(dir, { recursive: true, force: true });
444
- }
445
- });
446
-
447
- test("bgstatus: list-all after 'restart' hides finished logs by default, notes them instead", async () => {
448
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
449
- process.env.PI_BGRUN_DIR = dir;
450
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
451
- try {
452
- const { pi, tools, ctx } = makeFakePi();
453
- await loadExtension(pi);
454
- const bgrun = tools.get("bgrun")!;
455
- const res = await bgrun.execute(
456
- "call-5",
457
- { command: "echo persisted" },
458
- undefined,
459
- undefined,
460
- ctx,
461
- );
462
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
463
-
464
- // Wait for completion by polling the log marker.
465
- const logPath = join(dir, `${id}.log`);
466
- await new Promise<void>((resolve, reject) => {
467
- const start = Date.now();
468
- const tick = () => {
469
- try {
470
- if (readFileSync(logPath, "utf8").includes("__BGRUN_EXIT__=0"))
471
- return resolve();
472
- } catch {}
473
- if (Date.now() - start > 5000)
474
- return reject(new Error("log marker never appeared"));
475
- setTimeout(tick, 50);
476
- };
477
- tick();
478
- });
479
-
480
- // Fresh instance — no in-memory records. Default listing must NOT spam the
481
- // finished job; it gets a one-line count note instead.
482
- const { pi: pi2, tools: tools2 } = makeFakePi();
483
- await loadExtension(pi2);
484
- const bgstatus2 = tools2.get("bgstatus")!;
485
- const list = await bgstatus2.execute(
486
- "call-5",
487
- {},
488
- undefined,
489
- undefined,
490
- ctx,
491
- );
492
- const text = list.content[0].text as string;
493
- assert.ok(!new RegExp(id).test(text), "finished job hidden by default");
494
- assert.match(text, /\(1 more job log\(s\) on disk/);
495
-
496
- // includeDone reveals it with the exit code recovered from the log.
497
- const full = await bgstatus2.execute(
498
- "call-5b",
499
- { includeDone: true },
500
- undefined,
501
- undefined,
502
- ctx,
503
- );
504
- const fullText = full.content[0].text as string;
505
- assert.match(fullText, new RegExp(id));
506
- assert.match(fullText, /exit=0/);
507
- assert.match(fullText, /\(from log\)/);
508
- } finally {
509
- delete process.env.PI_BGRUN_DIR;
510
- rmSync(dir, { recursive: true, force: true });
511
- }
512
- });
513
-
514
- test("bgstatus: showCompletedJobs config (env) lists finished jobs by default", async () => {
515
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
516
- process.env.PI_BGRUN_DIR = dir;
517
- process.env.PI_BGRUN_SHOW_COMPLETED = "1";
518
- try {
519
- const { pi, wakes, tools, ctx } = makeFakePi();
520
- await loadExtension(pi);
521
- const bgrun = tools.get("bgrun")!;
522
- const bgstatus = tools.get("bgstatus")!;
523
-
524
- const res = await bgrun.execute(
525
- "call-sd1",
526
- { command: "echo shown-done", name: "done-job" },
527
- undefined,
528
- undefined,
529
- ctx,
530
- );
531
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
532
- await waitForWakes(wakes, 1);
533
-
534
- const list = await bgstatus.execute(
535
- "call-sd2",
536
- {},
537
- undefined,
538
- undefined,
539
- ctx,
540
- );
541
- assert.match(
542
- list.content[0].text as string,
543
- new RegExp(`${id} — done-job: done exit=0`),
544
- );
545
- } finally {
546
- delete process.env.PI_BGRUN_DIR;
547
- delete process.env.PI_BGRUN_SHOW_COMPLETED;
548
- rmSync(dir, { recursive: true, force: true });
549
- }
550
- });
551
-
552
- test("bgrun: rejects empty command", async () => {
553
- const { pi, tools, ctx } = makeFakePi();
554
- await loadExtension(pi);
555
- const bgrun = tools.get("bgrun")!;
556
- await assert.rejects(
557
- () => bgrun.execute("call-6", { command: "" }, undefined, undefined, ctx),
558
- /command is required/,
559
- );
560
- });
561
-
562
- // ── Phase 1 tests ────────────────────────────────────────────────────────────
563
-
564
- test("bgrun: appends bgrun-job entries (running then done)", async () => {
565
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
566
- process.env.PI_BGRUN_DIR = dir;
567
- try {
568
- const { pi, wakes, entries, tools, ctx } = makeFakePi();
569
- await loadExtension(pi);
570
- const bgrun = tools.get("bgrun")!;
571
-
572
- await bgrun.execute(
573
- "call-e1",
574
- { command: "echo entry-test" },
575
- undefined,
576
- undefined,
577
- ctx,
578
- );
579
- // One running entry appended at start.
580
- const runningEntries = entries.filter((e) => e.data?.state === "running");
581
- assert.equal(runningEntries.length, 1, "running entry appended at start");
582
- assert.equal(runningEntries[0].data.cmd, "echo entry-test");
583
-
584
- await waitForWakes(wakes, 1);
585
- // One done entry appended on exit.
586
- const doneEntries = entries.filter((e) => e.data?.state === "done");
587
- assert.equal(doneEntries.length, 1, "done entry appended on exit");
588
- assert.equal(doneEntries[0].data.exitCode, 0);
589
- } finally {
590
- delete process.env.PI_BGRUN_DIR;
591
- rmSync(dir, { recursive: true, force: true });
592
- }
593
- });
594
-
595
- test("session_start: reconstructs in-memory Map from bgrun-job entries", async () => {
596
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
597
- process.env.PI_BGRUN_DIR = dir;
598
- try {
599
- // First instance: run a job, capture its entries.
600
- const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
601
- await loadExtension(pi1);
602
- const bgrun1 = tools1.get("bgrun")!;
603
- const res = await bgrun1.execute(
604
- "call-r1",
605
- { command: "echo reconstruct-me" },
606
- undefined,
607
- undefined,
608
- ctx1,
609
- );
610
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
611
- await waitForWakes(wakes, 1);
612
-
613
- // Second instance: simulate a restart. Load fresh, passing the prior entries,
614
- // then fire session_start to trigger reconstruction.
615
- const {
616
- pi: pi2,
617
- tools: tools2,
618
- ctx: ctx2,
619
- fireSessionStart,
620
- } = makeFakePi({ priorEntries: entries });
621
- await loadExtension(pi2);
622
- await fireSessionStart();
623
-
624
- // Now bgstatus should find the job in the in-memory Map (not just dir scan).
625
- const bgstatus2 = tools2.get("bgstatus")!;
626
- const status = await bgstatus2.execute(
627
- "call-r2",
628
- { id },
629
- undefined,
630
- undefined,
631
- ctx2,
632
- );
633
- const text = status.content[0].text as string;
634
- assert.match(text, /done.*exit=0/);
635
- // Verify it came from the in-memory Map (not "from log" marker).
636
- assert.ok(
637
- !text.includes("from log"),
638
- "reconstructed from entries, not dir scan",
639
- );
640
- assert.ok(
641
- !text.includes("recovered from log"),
642
- "reconstructed from entries, not log recovery",
643
- );
644
- } finally {
645
- delete process.env.PI_BGRUN_DIR;
646
- rmSync(dir, { recursive: true, force: true });
647
- }
648
- });
649
-
650
- // ── name (human-readable label) tests ───────────────────────────────────────
651
-
652
- test("bgrun: name flows into job id, response, entry, wake, and status", async () => {
653
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
654
- process.env.PI_BGRUN_DIR = dir;
655
- try {
656
- const { pi, wakes, entries, tools, ctx } = makeFakePi();
657
- await loadExtension(pi);
658
- const bgrun = tools.get("bgrun")!;
659
-
660
- const res = await bgrun.execute(
661
- "call-n1",
662
- { command: "echo named job", name: "unit-tests" },
663
- undefined,
664
- undefined,
665
- ctx,
666
- );
667
- const text = res.content[0].text as string;
668
- const id = (text.match(/^started: ([^\n]+)/) || [])[1];
669
- // Slug derives from the name, not the command.
670
- assert.ok(
671
- id.startsWith("unit-tests-"),
672
- `id should start with 'unit-tests-': ${id}`,
673
- );
674
- // Response includes the name.
675
- assert.match(text, /name: unit-tests/);
676
- // Details include the name.
677
- assert.equal((res.details as any).name, "unit-tests");
678
-
679
- await waitForWakes(wakes, 1);
680
- const wake = wakes[0].text;
681
- // Wake includes the name.
682
- assert.match(wake, /"unit-tests"/);
683
-
684
- // Persisted entries carry the name.
685
- const withName = entries.filter((e) => e.data?.name === "unit-tests");
686
- assert.equal(withName.length, 2, "running + done entries carry name");
687
- } finally {
688
- delete process.env.PI_BGRUN_DIR;
689
- rmSync(dir, { recursive: true, force: true });
690
- }
691
- });
692
-
693
- test("bgrun: name is optional — behavior unchanged without it", async () => {
694
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
695
- process.env.PI_BGRUN_DIR = dir;
696
- try {
697
- const { pi, wakes, tools, ctx } = makeFakePi();
698
- await loadExtension(pi);
699
- const bgrun = tools.get("bgrun")!;
700
-
701
- const res = await bgrun.execute(
702
- "call-n2",
703
- { command: "echo unnamed job" },
704
- undefined,
705
- undefined,
706
- ctx,
707
- );
708
- const text = res.content[0].text as string;
709
- // No 'name:' line in the response.
710
- assert.ok(!/^ {2}name:/m.test(text), "no name line when name omitted");
711
- const id = (text.match(/^started: ([^\n]+)/) || [])[1];
712
- assert.ok(
713
- id.startsWith("echo-unnamed-job-"),
714
- `slug falls back to command: ${id}`,
715
- );
716
-
717
- await waitForWakes(wakes, 1);
718
- assert.ok(
719
- !wakes[0].text.includes('"'),
720
- "wake has no name quote when unnamed",
721
- );
722
- } finally {
723
- delete process.env.PI_BGRUN_DIR;
724
- rmSync(dir, { recursive: true, force: true });
725
- }
726
- });
727
-
728
- test("bgrun: blank name is ignored, over-long name is truncated", async () => {
729
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
730
- process.env.PI_BGRUN_DIR = dir;
731
- try {
732
- const { pi, wakes, tools, ctx } = makeFakePi();
733
- await loadExtension(pi);
734
- const bgrun = tools.get("bgrun")!;
735
-
736
- // Blank name treated as absent.
737
- const res1 = await bgrun.execute(
738
- "call-n3",
739
- { command: "echo blank", name: " " },
740
- undefined,
741
- undefined,
742
- ctx,
743
- );
744
- assert.ok(
745
- !/^ {2}name:/m.test(res1.content[0].text as string),
746
- "blank name ignored",
747
- );
748
-
749
- // Over-long name truncated to 80 chars.
750
- const longName = "x".repeat(200);
751
- const res2 = await bgrun.execute(
752
- "call-n4",
753
- { command: "echo long", name: longName },
754
- undefined,
755
- undefined,
756
- ctx,
757
- );
758
- const text2 = res2.content[0].text as string;
759
- const nameLine = (text2.match(/^ {2}name: (.+)$/m) || [])[1];
760
- assert.equal(nameLine.length, 80, "name truncated to 80 chars");
761
-
762
- await waitForWakes(wakes, 2);
763
- } finally {
764
- delete process.env.PI_BGRUN_DIR;
765
- rmSync(dir, { recursive: true, force: true });
766
- }
767
- });
768
-
769
- test("bgrun: name survives session_start reconstruction", async () => {
770
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
771
- process.env.PI_BGRUN_DIR = dir;
772
- try {
773
- // First instance: run a named job, capture entries.
774
- const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
775
- await loadExtension(pi1);
776
- const bgrun1 = tools1.get("bgrun")!;
777
- const res = await bgrun1.execute(
778
- "call-n5",
779
- { command: "echo named-restart", name: "rebuild" },
780
- undefined,
781
- undefined,
782
- ctx1,
783
- );
784
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
785
- await waitForWakes(wakes, 1);
786
-
787
- // Second instance: reconstruct from entries, name should be restored.
788
- const {
789
- pi: pi2,
790
- tools: tools2,
791
- ctx: ctx2,
792
- fireSessionStart,
793
- } = makeFakePi({ priorEntries: entries });
794
- await loadExtension(pi2);
795
- await fireSessionStart();
796
-
797
- const bgstatus2 = tools2.get("bgstatus")!;
798
- const status = await bgstatus2.execute(
799
- "call-n6",
800
- { id },
801
- undefined,
802
- undefined,
803
- ctx2,
804
- );
805
- const text = status.content[0].text as string;
806
- assert.match(text, /name: rebuild/);
807
- assert.ok(
808
- !text.includes("recovered from log"),
809
- "reconstructed from entries, not log",
810
- );
811
- } finally {
812
- delete process.env.PI_BGRUN_DIR;
813
- rmSync(dir, { recursive: true, force: true });
814
- }
815
- });
816
-
817
- test("bgstatus: list shows name after job id", async () => {
818
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
819
- process.env.PI_BGRUN_DIR = dir;
820
- try {
821
- const { pi, wakes, tools, ctx } = makeFakePi();
822
- await loadExtension(pi);
823
- const bgrun = tools.get("bgrun")!;
824
- const bgstatus = tools.get("bgstatus")!;
825
-
826
- await bgrun.execute(
827
- "call-n7",
828
- { command: "sleep 0.1; echo listed", name: "nightly" },
829
- undefined,
830
- undefined,
831
- ctx,
832
- );
833
- await waitForWakes(wakes, 1);
834
-
835
- const list = await bgstatus.execute(
836
- "call-n8",
837
- { includeDone: true },
838
- undefined,
839
- undefined,
840
- ctx,
841
- );
842
- assert.match(list.content[0].text as string, /— nightly: done exit=0/);
843
- // Without includeDone, finished jobs are hidden by default.
844
- const runningOnly = await bgstatus.execute(
845
- "call-n8b",
846
- {},
847
- undefined,
848
- undefined,
849
- ctx,
850
- );
851
- assert.ok(
852
- !/— nightly: done/.test(runningOnly.content[0].text as string),
853
- "done job hidden without includeDone",
854
- );
855
- } finally {
856
- delete process.env.PI_BGRUN_DIR;
857
- rmSync(dir, { recursive: true, force: true });
858
- }
859
- });
860
-
861
- test("session_start: foreign jobs are NOT adopted by default (opt-in only)", async () => {
862
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
863
- process.env.PI_BGRUN_DIR = dir;
864
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
865
- try {
866
- // A running foreign job (no exit marker, live pid — this test process).
867
- const foreignId = `other-session-job-${Date.now()}-${process.pid}`;
868
- writeFileSync(join(dir, `${foreignId}.log`), "someone else's job\n");
869
-
870
- const { pi, tools, ctx, fireSessionStart } = makeFakePi();
871
- ctx.hasUI = true;
872
- const widgetCalls: (string[] | undefined)[] = [];
873
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
874
- widgetCalls.push(lines);
875
-
876
- await loadExtension(pi);
877
- await fireSessionStart();
878
-
879
- // Widget never shows the foreign job.
880
- const shown = widgetCalls.find((l) => Array.isArray(l));
881
- assert.equal(
882
- shown,
883
- undefined,
884
- "no widget content for foreign jobs by default",
885
- );
886
-
887
- // List-all gives a count note, not the job itself.
888
- const bgstatus = tools.get("bgstatus")!;
889
- const list = await bgstatus.execute(
890
- "call-f1",
891
- {},
892
- undefined,
893
- undefined,
894
- ctx,
895
- );
896
- const text = list.content[0].text as string;
897
- assert.ok(!text.includes(foreignId), "foreign job not listed by default");
898
- assert.match(text, /\(1 more job log\(s\) on disk/);
899
-
900
- // Single-id lookup still works — that's the explicit escape hatch.
901
- const one = await bgstatus.execute(
902
- "call-f2",
903
- { id: foreignId },
904
- undefined,
905
- undefined,
906
- ctx,
907
- );
908
- assert.match(one.content[0].text as string, /: running/);
909
- } finally {
910
- delete process.env.PI_BGRUN_DIR;
911
- rmSync(dir, { recursive: true, force: true });
912
- }
913
- });
914
-
915
- test("session_start: adopts running jobs from the jobs dir (other session's job) into the widget", async () => {
916
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
917
- process.env.PI_BGRUN_DIR = dir;
918
- process.env.PI_BGRUN_FOREIGN_JOBS = "1";
919
- try {
920
- // A log with no exit marker whose pid is alive (this test process's own pid).
921
- const adoptedId = `kafka-bootstrap-${Date.now()}-${process.pid}`;
922
- writeFileSync(join(dir, `${adoptedId}.log`), "job still going\n");
923
-
924
- const { pi, tools, ctx, fireSessionStart } = makeFakePi();
925
- ctx.hasUI = true;
926
- const widgetCalls: (string[] | undefined)[] = [];
927
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
928
- widgetCalls.push(lines);
929
-
930
- await loadExtension(pi);
931
- await fireSessionStart();
932
-
933
- // Widget should now show the adopted job.
934
- const shown = widgetCalls.find((l) => Array.isArray(l)) ?? [];
935
- const flat = (shown as string[]).join("\n");
936
- assert.match(flat, /bgrun: 1 running/);
937
- assert.match(flat, new RegExp(adoptedId.slice(0, 20)));
938
- assert.match(flat, /\(adopted\)/);
939
- assert.match(flat, /since \d{2}:\d{2}:\d{2}/);
940
-
941
- // bgstatus single-id should also see it as running (in-memory now).
942
- const bgstatus = tools.get("bgstatus")!;
943
- const res = await bgstatus.execute(
944
- "call-a1",
945
- { id: adoptedId },
946
- undefined,
947
- undefined,
948
- ctx,
949
- );
950
- assert.match(res.content[0].text as string, /: running/);
951
- } finally {
952
- delete process.env.PI_BGRUN_DIR;
953
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
954
- rmSync(dir, { recursive: true, force: true });
955
- }
956
- });
957
-
958
- test("adopted job leaves the widget once its log shows the exit marker (revalidation)", async () => {
959
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
960
- process.env.PI_BGRUN_DIR = dir;
961
- process.env.PI_BGRUN_FOREIGN_JOBS = "1";
962
- try {
963
- // Foreign running job (live pid = this process, no marker).
964
- const adoptedId = `foreign-finish-${Date.now()}-${process.pid}`;
965
- const logPath = join(dir, `${adoptedId}.log`);
966
- writeFileSync(logPath, "job still going\n");
967
-
968
- const { pi, tools, ctx, fireSessionStart } = makeFakePi();
969
- ctx.hasUI = true;
970
- const widgetCalls: (string[] | undefined)[] = [];
971
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
972
- widgetCalls.push(lines);
973
-
974
- await loadExtension(pi);
975
- await fireSessionStart();
976
- assert.ok(
977
- widgetCalls.some((l) => Array.isArray(l)),
978
- "widget shown after adoption",
979
- );
980
-
981
- // The foreign job finishes: marker appears in the log.
982
- writeFileSync(logPath, "job still going\n__BGRUN_EXIT__=0\n");
983
-
984
- // Any bgstatus call revalidates adopted jobs and refreshes the widget.
985
- const bgstatus = tools.get("bgstatus")!;
986
- const list = await bgstatus.execute(
987
- "call-a2",
988
- {},
989
- undefined,
990
- undefined,
991
- ctx,
992
- );
993
- const text = list.content[0].text as string;
994
- assert.ok(
995
- !/: running/.test(text),
996
- "adopted job no longer listed as running",
997
- );
998
- assert.match(
999
- text,
1000
- /\(1 more job log\(s\) on disk/,
1001
- "finished adopted job folded into the disk note",
1002
- );
1003
- assert.ok(
1004
- widgetCalls.some((l) => l === undefined),
1005
- "widget cleared after adopted job finished",
1006
- );
1007
- } finally {
1008
- delete process.env.PI_BGRUN_DIR;
1009
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1010
- rmSync(dir, { recursive: true, force: true });
1011
- }
1012
- });
1013
-
1014
- test("session_start: does NOT adopt finished or dead-pid jobs", async () => {
1015
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1016
- process.env.PI_BGRUN_DIR = dir;
1017
- process.env.PI_BGRUN_FOREIGN_JOBS = "1";
1018
- try {
1019
- // Finished (exit marker present).
1020
- writeFileSync(
1021
- join(dir, `done-job-${Date.now()}-${process.pid}.log`),
1022
- "out\n__BGRUN_EXIT__=0\n",
1023
- );
1024
- // No marker but pid is certainly dead (pid 1 is launchd — alive, so use a likely-dead high pid).
1025
- // Use pid 1-style trick instead: a dead pid we spawn and reap.
1026
- const { spawnSync } = await import("node:child_process");
1027
- const dead = spawnSync("sh", ["-c", "exit 0"]);
1028
- assert.equal(dead.status, 0);
1029
- // Write log with a pid that no longer exists: use the reaped child's pid if captured, else 999999.
1030
- const deadPid = dead.pid ?? 999999;
1031
- writeFileSync(
1032
- join(dir, `dead-job-${Date.now()}-${deadPid}.log`),
1033
- "partial\n",
1034
- );
1035
-
1036
- const { pi, ctx, fireSessionStart } = makeFakePi();
1037
- ctx.hasUI = true;
1038
- let widgetShown = false;
1039
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
1040
- if (lines) widgetShown = true;
1041
- };
1042
-
1043
- await loadExtension(pi);
1044
- await fireSessionStart();
1045
- assert.equal(widgetShown, false, "no widget for finished/dead jobs");
1046
- } finally {
1047
- delete process.env.PI_BGRUN_DIR;
1048
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1049
- rmSync(dir, { recursive: true, force: true });
1050
- }
1051
- });
1052
-
1053
- test("session_start: with foreign adoption OFF, finished foreign logs are not adopted", async () => {
1054
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1055
- process.env.PI_BGRUN_DIR = dir;
1056
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1057
- try {
1058
- writeFileSync(
1059
- join(dir, `done-job-${Date.now()}-${process.pid}.log`),
1060
- "out\n__BGRUN_EXIT__=0\n",
1061
- );
1062
- const { pi, ctx, fireSessionStart } = makeFakePi();
1063
- ctx.hasUI = true;
1064
- let widgetShown = false;
1065
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
1066
- if (lines) widgetShown = true;
1067
- };
1068
- await loadExtension(pi);
1069
- await fireSessionStart();
1070
- assert.equal(widgetShown, false, "no adoption when disabled");
1071
- } finally {
1072
- delete process.env.PI_BGRUN_DIR;
1073
- rmSync(dir, { recursive: true, force: true });
1074
- }
1075
- });
1076
-
1077
- test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
1078
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1079
- process.env.PI_BGRUN_DIR = dir;
1080
- try {
1081
- const { pi, wakes, tools, ctx } = makeFakePi();
1082
- await loadExtension(pi);
1083
- const bgrun = tools.get("bgrun")!;
1084
-
1085
- const res = await bgrun.execute(
1086
- "call-pid",
1087
- { command: "echo pidcheck" },
1088
- undefined,
1089
- undefined,
1090
- ctx,
1091
- );
1092
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
1093
- const idPid = Number(id.split("-").pop());
1094
- assert.ok(idPid > 0, `id ends with child pid: ${id}`);
1095
- assert.notEqual(idPid, process.pid, "id must NOT carry pi's own pid");
1096
- // Log file named after the id, no .tmp- leftovers.
1097
- assert.ok(existsSync(join(dir, `${id}.log`)), "log at final id-named path");
1098
- assert.equal(
1099
- readdirSync(dir).filter((f) => f.startsWith(".tmp-")).length,
1100
- 0,
1101
- "no temp log leftovers",
1102
- );
1103
-
1104
- await waitForWakes(wakes, 1);
1105
- } finally {
1106
- delete process.env.PI_BGRUN_DIR;
1107
- rmSync(dir, { recursive: true, force: true });
1108
- }
1109
- });
1110
-
1111
- test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => {
1112
- // Regression: exit marker must win over pid liveness. Old code checked
1113
- // pid first, so any log whose id-pid happened to be a live process (e.g.
1114
- // pi's own pid from the old id bug, or pid reuse) was kept forever.
1115
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1116
- process.env.PI_BGRUN_DIR = dir;
1117
- try {
1118
- // Old finished foreign log whose id-pid is THIS process (alive!) — must
1119
- // still be removed by an explicit global sweep.
1120
- const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
1121
- writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
1122
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1123
- const fs = await import("node:fs");
1124
- fs.utimesSync(oldPath, oldTime, oldTime);
1125
-
1126
- const { pi, tools, ctx } = makeFakePi();
1127
- await loadExtension(pi);
1128
- const bgclean = tools.get("bgclean")!;
1129
-
1130
- // Default scope: this session only — the foreign log is untouched.
1131
- const scoped = await bgclean.execute(
1132
- "call-stale-scoped",
1133
- { days: 7 },
1134
- undefined,
1135
- undefined,
1136
- ctx,
1137
- );
1138
- assert.match(scoped.content[0].text as string, /removed 0/);
1139
- assert.ok(
1140
- existsSync(oldPath),
1141
- "foreign log untouched by session-scoped bgclean",
1142
- );
1143
-
1144
- const result = await bgclean.execute(
1145
- "call-stale",
1146
- { days: 7, all: true },
1147
- undefined,
1148
- undefined,
1149
- ctx,
1150
- );
1151
- assert.match(
1152
- result.content[0].text as string,
1153
- /removed 1 job log\(s\) \(all sessions\)/,
1154
- );
1155
- assert.ok(
1156
- !existsSync(oldPath),
1157
- "finished job's log removed despite live id-pid",
1158
- );
1159
- } finally {
1160
- delete process.env.PI_BGRUN_DIR;
1161
- rmSync(dir, { recursive: true, force: true });
1162
- }
1163
- });
1164
-
1165
- test("session_start adoption: skips finished jobs even with a live id-pid", async () => {
1166
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1167
- process.env.PI_BGRUN_DIR = dir;
1168
- process.env.PI_BGRUN_FOREIGN_JOBS = "1";
1169
- try {
1170
- // Finished job (exit marker) whose id-pid is this process (alive).
1171
- writeFileSync(
1172
- join(dir, `done-job-${Date.now()}-${process.pid}.log`),
1173
- "out\n__BGRUN_EXIT__=0\n",
1174
- );
1175
- const { pi, ctx, fireSessionStart } = makeFakePi();
1176
- ctx.hasUI = true;
1177
- let widgetShown = false;
1178
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
1179
- if (lines) widgetShown = true;
1180
- };
1181
- await loadExtension(pi);
1182
- await fireSessionStart();
1183
- assert.equal(
1184
- widgetShown,
1185
- false,
1186
- "finished job not adopted even though id-pid is alive",
1187
- );
1188
- } finally {
1189
- delete process.env.PI_BGRUN_DIR;
1190
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1191
- rmSync(dir, { recursive: true, force: true });
1192
- }
1193
- });
1194
-
1195
- test("session_start: reconstructed 'running' job that finished while pi was down is cleared, not zombified", async () => {
1196
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1197
- process.env.PI_BGRUN_DIR = dir;
1198
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1199
- try {
1200
- // A job from 5 days ago whose transcript entry never got a done entry
1201
- // (pi wasn't running when it exited), whose log is long gone and whose
1202
- // pid is definitely dead.
1203
- const zombieId = `cd-old-project-make-test-${Date.now()}-99999999`;
1204
- const logPath = join(dir, `${zombieId}.log`); // never created
1205
- const priorEntries = [
1206
- {
1207
- type: "custom",
1208
- customType: "bgrun-job",
1209
- data: {
1210
- id: zombieId,
1211
- pid: 99999999,
1212
- cmd: "cd /old/project && make test",
1213
- name: undefined,
1214
- started: Date.now() - 5 * 24 * 60 * 60 * 1000,
1215
- logPath,
1216
- state: "running",
1217
- },
1218
- },
1219
- ];
1220
- const { pi, entries, tools, ctx, fireSessionStart } = makeFakePi({
1221
- priorEntries,
1222
- });
1223
- ctx.hasUI = true;
1224
- const widgetCalls: (string[] | undefined)[] = [];
1225
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
1226
- widgetCalls.push(lines);
1227
-
1228
- await loadExtension(pi);
1229
- await fireSessionStart();
1230
-
1231
- // Revalidation runs before the widget ever renders — the zombie is
1232
- // cleared immediately instead of showing as "running" forever.
1233
- assert.ok(
1234
- !widgetCalls.some((l) => Array.isArray(l)),
1235
- "reconstructed zombie never shown in the widget",
1236
- );
1237
-
1238
- // A done entry is appended so future resumes reconstruct it as done.
1239
- const doneEntry = entries.find(
1240
- (e) =>
1241
- e.customType === "bgrun-job" &&
1242
- e.data?.id === zombieId &&
1243
- e.data?.state === "done",
1244
- );
1245
- assert.ok(doneEntry, "done entry appended for the recovered job");
1246
-
1247
- // Single-id lookup reports done, not running.
1248
- const bgstatus = tools.get("bgstatus")!;
1249
- const res = await bgstatus.execute(
1250
- "call-z1",
1251
- { id: zombieId },
1252
- undefined,
1253
- undefined,
1254
- ctx,
1255
- );
1256
- assert.match(res.content[0].text as string, /: done/);
1257
- } finally {
1258
- delete process.env.PI_BGRUN_DIR;
1259
- rmSync(dir, { recursive: true, force: true });
1260
- }
1261
- });
1262
-
1263
- test("session_start: done entries with missing exitCode (signal kills) reconstruct as done", async () => {
1264
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1265
- process.env.PI_BGRUN_DIR = dir;
1266
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1267
- try {
1268
- // Jobs killed by a signal persist state:"done" with exitCode: undefined —
1269
- // reconstruction must honor the state field, not just the exit code.
1270
- const killedId = `nightly-watch-${Date.now()}-${process.pid}`;
1271
- const logPath = join(dir, `${killedId}.log`);
1272
- writeFileSync(logPath, "partial output\n"); // no marker — killed before it
1273
- const priorEntries = [
1274
- {
1275
- type: "custom",
1276
- customType: "bgrun-job",
1277
- data: {
1278
- id: killedId,
1279
- pid: process.pid, // alive — liveness alone must not resurrect it as running
1280
- cmd: "npm run watch",
1281
- name: "nightly-watch",
1282
- started: Date.now() - 60_000,
1283
- logPath,
1284
- state: "done",
1285
- exitCode: undefined,
1286
- exitedAt: Date.now() - 30_000,
1287
- },
1288
- },
1289
- ];
1290
- const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
1291
- ctx.hasUI = true;
1292
- const widgetCalls: (string[] | undefined)[] = [];
1293
- ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) =>
1294
- widgetCalls.push(lines);
1295
-
1296
- await loadExtension(pi);
1297
- await fireSessionStart();
1298
-
1299
- assert.ok(
1300
- !widgetCalls.some((l) => Array.isArray(l)),
1301
- "signal-killed job with a done entry is not resurrected as running",
1302
- );
1303
- const bgstatus = tools.get("bgstatus")!;
1304
- const res = await bgstatus.execute(
1305
- "call-z2",
1306
- { id: killedId },
1307
- undefined,
1308
- undefined,
1309
- ctx,
1310
- );
1311
- assert.match(res.content[0].text as string, /: done/);
1312
- } finally {
1313
- delete process.env.PI_BGRUN_DIR;
1314
- rmSync(dir, { recursive: true, force: true });
1315
- }
1316
- });
1317
-
1318
- test("auto-clean: session boundaries sweep this session's old logs AND week-old foreign orphans by default", async () => {
1319
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1320
- process.env.PI_BGRUN_DIR = dir;
1321
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1322
- delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1323
- try {
1324
- const fs = await import("node:fs");
1325
- const backdate = (path: string) => {
1326
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1327
- fs.utimesSync(path, oldTime, oldTime);
1328
- };
1329
-
1330
- // This session's old done job (from the transcript) with a backdated log.
1331
- const mineId = `my-old-job-${Date.now()}-99999999`;
1332
- const myLog = join(dir, `${mineId}.log`);
1333
- fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
1334
- backdate(myLog);
1335
-
1336
- // A foreign session's week-old FINISHED log — an orphan; swept by default.
1337
- const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
1338
- fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
1339
- backdate(orphanLog);
1340
-
1341
- // A foreign session's RECENT finished log — within retention, kept.
1342
- const recentForeignLog = join(
1343
- dir,
1344
- `foreign-recent-${Math.floor(Date.now() / 1000)}-99997.log`,
1345
- );
1346
- fs.writeFileSync(recentForeignLog, "recent foreign\n__BGRUN_EXIT__=0\n");
1347
-
1348
- // A foreign session's week-old RUNNING log (no marker, live pid) — running
1349
- // jobs are pid-protected even when old.
1350
- const runningForeignLog = join(
1351
- dir,
1352
- `foreign-running-${Math.floor(Date.now() / 1000)}-${process.pid}.log`,
1353
- );
1354
- fs.writeFileSync(runningForeignLog, "still going\n");
1355
- backdate(runningForeignLog);
1356
-
1357
- const priorEntries = [
1358
- {
1359
- type: "custom",
1360
- customType: "bgrun-job",
1361
- data: {
1362
- id: mineId,
1363
- pid: 99999999,
1364
- cmd: "echo mine",
1365
- name: undefined,
1366
- started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1367
- logPath: myLog,
1368
- state: "done",
1369
- exitCode: 0,
1370
- exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1371
- },
1372
- },
1373
- ];
1374
- const { pi, fireSessionStart } = makeFakePi({ priorEntries });
1375
- await loadExtension(pi);
1376
- await fireSessionStart();
1377
-
1378
- assert.ok(!fs.existsSync(myLog), "this session's old log swept");
1379
- assert.ok(
1380
- !fs.existsSync(orphanLog),
1381
- "week-old finished foreign orphan swept by default",
1382
- );
1383
- assert.ok(
1384
- fs.existsSync(recentForeignLog),
1385
- "recent foreign log kept (within retention)",
1386
- );
1387
- assert.ok(
1388
- fs.existsSync(runningForeignLog),
1389
- "old but RUNNING foreign log kept (pid-protected)",
1390
- );
1391
- } finally {
1392
- delete process.env.PI_BGRUN_DIR;
1393
- rmSync(dir, { recursive: true, force: true });
1394
- }
1395
- });
1396
-
1397
- test("auto-clean: globalAutoClean=false opts out — foreign orphans untouched, own old logs still swept", async () => {
1398
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1399
- process.env.PI_BGRUN_DIR = dir;
1400
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1401
- process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
1402
- try {
1403
- const fs = await import("node:fs");
1404
- const backdate = (path: string) => {
1405
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1406
- fs.utimesSync(path, oldTime, oldTime);
1407
- };
1408
-
1409
- const mineId = `my-old-job-${Date.now()}-99999999`;
1410
- const myLog = join(dir, `${mineId}.log`);
1411
- fs.writeFileSync(myLog, "mine\n__BGRUN_EXIT__=0\n");
1412
- backdate(myLog);
1413
-
1414
- const orphanLog = join(dir, "foreign-old-job-1000000000-99998.log");
1415
- fs.writeFileSync(orphanLog, "foreign\n__BGRUN_EXIT__=0\n");
1416
- backdate(orphanLog);
1417
-
1418
- const priorEntries = [
1419
- {
1420
- type: "custom",
1421
- customType: "bgrun-job",
1422
- data: {
1423
- id: mineId,
1424
- pid: 99999999,
1425
- cmd: "echo mine",
1426
- name: undefined,
1427
- started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1428
- logPath: myLog,
1429
- state: "done",
1430
- exitCode: 0,
1431
- exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1432
- },
1433
- },
1434
- ];
1435
- const { pi, fireSessionStart } = makeFakePi({ priorEntries });
1436
- await loadExtension(pi);
1437
- await fireSessionStart();
1438
-
1439
- assert.ok(!fs.existsSync(myLog), "this session's old log still swept");
1440
- assert.ok(
1441
- fs.existsSync(orphanLog),
1442
- "foreign orphan untouched when globalAutoClean is off",
1443
- );
1444
- } finally {
1445
- delete process.env.PI_BGRUN_DIR;
1446
- delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1447
- rmSync(dir, { recursive: true, force: true });
1448
- }
1449
- });
1450
-
1451
- test("auto-clean: global orphan sweep is throttled via .last-clean; manual bgclean all always runs", async () => {
1452
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1453
- process.env.PI_BGRUN_DIR = dir;
1454
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1455
- delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on
1456
- try {
1457
- const fs = await import("node:fs");
1458
- const backdate = (path: string) => {
1459
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1460
- fs.utimesSync(path, oldTime, oldTime);
1461
- };
1462
-
1463
- // Old foreign log A + first session_start (no marker yet) → global sweep
1464
- // runs, A removed.
1465
- const logA = join(dir, "old-a-1000000000-99999.log");
1466
- fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
1467
- backdate(logA);
1468
- {
1469
- const { pi, fireSessionStart } = makeFakePi();
1470
- await loadExtension(pi);
1471
- await fireSessionStart();
1472
- }
1473
- assert.ok(!fs.existsSync(logA), "first global sweep removed old log A");
1474
- assert.ok(
1475
- fs.existsSync(join(dir, ".last-clean")),
1476
- "throttle marker written",
1477
- );
1478
-
1479
- // Old foreign log B + second session_start while marker is fresh →
1480
- // throttled, B kept.
1481
- const logB = join(dir, "old-b-1000000000-99998.log");
1482
- fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
1483
- backdate(logB);
1484
- {
1485
- const { pi, fireSessionStart } = makeFakePi();
1486
- await loadExtension(pi);
1487
- await fireSessionStart();
1488
- }
1489
- assert.ok(
1490
- fs.existsSync(logB),
1491
- "second global sweep throttled — old log B kept",
1492
- );
1493
-
1494
- // Manual `bgclean all` ignores the throttle and removes B.
1495
- const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
1496
- await loadExtension(pi3);
1497
- const bgclean = tools3.get("bgclean")!;
1498
- const result = await bgclean.execute(
1499
- "call-t1",
1500
- { all: true },
1501
- undefined,
1502
- undefined,
1503
- ctx3,
1504
- );
1505
- assert.match(
1506
- result.content[0].text as string,
1507
- /removed 1 job log\(s\) \(all sessions\)/,
1508
- );
1509
- assert.ok(!fs.existsSync(logB), "manual bgclean all removed log B");
1510
- } finally {
1511
- delete process.env.PI_BGRUN_DIR;
1512
- delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1513
- rmSync(dir, { recursive: true, force: true });
1514
- }
1515
- });
1516
-
1517
- test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => {
1518
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1519
- process.env.PI_BGRUN_DIR = dir;
1520
- // Isolate bgclean's scoping from the global orphan auto-sweep (default on)
1521
- // so the foreign log survives session_start for bgclean to (not) act on.
1522
- process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN = "0";
1523
- try {
1524
- const fs = await import("node:fs");
1525
- const backdate = (path: string) => {
1526
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1527
- fs.utimesSync(path, oldTime, oldTime);
1528
- };
1529
-
1530
- const mkEntry = (
1531
- id: string,
1532
- logPath: string,
1533
- extra: Record<string, unknown> = {},
1534
- ) => ({
1535
- type: "custom",
1536
- customType: "bgrun-job",
1537
- data: {
1538
- id,
1539
- pid: 99999999,
1540
- cmd: `echo ${id}`,
1541
- name: undefined,
1542
- started: Date.now() - 60_000,
1543
- logPath,
1544
- state: "done",
1545
- exitCode: 0,
1546
- exitedAt: Date.now() - 30_000,
1547
- ...extra,
1548
- },
1549
- });
1550
-
1551
- // This session's recent done job (fresh log — kept).
1552
- const recentId = `recent-job-${Date.now()}-99999998`;
1553
- const recentLog = join(dir, `${recentId}.log`);
1554
- fs.writeFileSync(recentLog, "recent\n__BGRUN_EXIT__=0\n");
1555
-
1556
- // This session's old done job (backdated log — removed by default scope).
1557
- const oldId = `old-session-job-${Date.now()}-99999997`;
1558
- const oldLog = join(dir, `${oldId}.log`);
1559
- fs.writeFileSync(oldLog, "old session job\n__BGRUN_EXIT__=0\n");
1560
- backdate(oldLog);
1561
-
1562
- // A foreign session's old log — untouched by default, removed with all.
1563
- const foreignLog = join(dir, "foreign-old-job-1000000000-99996.log");
1564
- fs.writeFileSync(foreignLog, "foreign\n__BGRUN_EXIT__=0\n");
1565
- backdate(foreignLog);
1566
-
1567
- const priorEntries = [
1568
- mkEntry(recentId, recentLog),
1569
- mkEntry(oldId, oldLog, {
1570
- started: Date.now() - 30 * 24 * 60 * 60 * 1000,
1571
- exitedAt: Date.now() - 30 * 24 * 60 * 60 * 1000,
1572
- }),
1573
- ];
1574
- const { pi, tools, ctx, fireSessionStart } = makeFakePi({ priorEntries });
1575
- await loadExtension(pi);
1576
- await fireSessionStart(); // reconstruct + session-scoped auto-sweep runs here too
1577
-
1578
- const bgclean = tools.get("bgclean")!;
1579
-
1580
- // Default: this session only.
1581
- const scoped = await bgclean.execute(
1582
- "call-c2",
1583
- { days: 7 },
1584
- undefined,
1585
- undefined,
1586
- ctx,
1587
- );
1588
- assert.match(scoped.content[0].text as string, /\(this session\)/);
1589
- assert.ok(!fs.existsSync(oldLog), "this session's old log removed");
1590
- assert.ok(fs.existsSync(recentLog), "this session's recent log kept");
1591
- assert.ok(
1592
- fs.existsSync(foreignLog),
1593
- "foreign log untouched by session-scoped bgclean",
1594
- );
1595
-
1596
- // all: true sweeps the shared dir.
1597
- const global = await bgclean.execute(
1598
- "call-c3",
1599
- { days: 7, all: true },
1600
- undefined,
1601
- undefined,
1602
- ctx,
1603
- );
1604
- assert.match(global.content[0].text as string, /\(all sessions\)/);
1605
- assert.ok(!fs.existsSync(foreignLog), "foreign log removed by bgclean all");
1606
- assert.ok(fs.existsSync(recentLog), "recent log still kept");
1607
- } finally {
1608
- delete process.env.PI_BGRUN_DIR;
1609
- rmSync(dir, { recursive: true, force: true });
1610
- }
1611
- });
1612
-
1613
- test("bgclean: rejects negative days", async () => {
1614
- const { pi, tools, ctx } = makeFakePi();
1615
- await loadExtension(pi);
1616
- const bgclean = tools.get("bgclean")!;
1617
- await assert.rejects(
1618
- () => bgclean.execute("call-c3", { days: -1 }, undefined, undefined, ctx),
1619
- /non-negative/,
1620
- );
1621
- });
1622
-
1623
- test("bgclean: does not remove a running job's log", async () => {
1624
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1625
- process.env.PI_BGRUN_DIR = dir;
1626
- try {
1627
- const { pi, tools, ctx } = makeFakePi();
1628
- await loadExtension(pi);
1629
- const bgrun = tools.get("bgrun")!;
1630
- const bgclean = tools.get("bgclean")!;
1631
-
1632
- // Start a long-running job (10s) so it's still running when we clean.
1633
- const res = await bgrun.execute(
1634
- "call-c4",
1635
- { command: "sleep 10" },
1636
- undefined,
1637
- undefined,
1638
- ctx,
1639
- );
1640
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
1641
- const logPath = join(dir, `${id}.log`);
1642
-
1643
- // Backdate the log's mtime to make it look old — but the job is still running
1644
- // (pid is in the in-memory Map), so bgclean should skip it.
1645
- const fs = await import("node:fs");
1646
- const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
1647
- // Wait a moment for the log file to exist, then backdate.
1648
- await new Promise((r) => setTimeout(r, 100));
1649
- fs.utimesSync(logPath, oldTime, oldTime);
1650
-
1651
- const result = await bgclean.execute(
1652
- "call-c5",
1653
- { days: 7 },
1654
- undefined,
1655
- undefined,
1656
- ctx,
1657
- );
1658
- const text = result.content[0].text as string;
1659
- assert.match(text, /skipped 1 running/);
1660
- assert.ok(fs.existsSync(logPath), "running job's log not removed");
1661
-
1662
- // Kill the orphaned sleep so it doesn't linger (best-effort: it may have exited already).
1663
- try {
1664
- process.kill((res.details as any).pid);
1665
- } catch {
1666
- // already gone — fine
1667
- }
1668
- } finally {
1669
- delete process.env.PI_BGRUN_DIR;
1670
- delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1671
- rmSync(dir, { recursive: true, force: true });
1672
- }
1673
- });
1674
-
1675
- test("slash commands: /bgstatus, /bgtail, /bgclean registered and share the tool logic", async () => {
1676
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1677
- process.env.PI_BGRUN_DIR = dir;
1678
- delete process.env.PI_BGRUN_FOREIGN_JOBS;
1679
- delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
1680
- try {
1681
- const { pi, wakes, tools, commands, ctx } = makeFakePi();
1682
- ctx.hasUI = true;
1683
- const notes: { text: string; kind: string }[] = [];
1684
- ctx.ui.notify = (text: string, kind: string) => notes.push({ text, kind });
1685
-
1686
- await loadExtension(pi);
1687
-
1688
- // All three human-facing commands are registered (/bgrun is agent-only).
1689
- assert.ok(commands.has("bgstatus"), "/bgstatus registered");
1690
- assert.ok(commands.has("bgtail"), "/bgtail registered");
1691
- assert.ok(commands.has("bgclean"), "/bgclean registered");
1692
- assert.ok(!commands.has("bgrun"), "/bgrun deliberately not a command");
1693
-
1694
- // Run a real job to completion so there's something to inspect.
1695
- const bgrun = tools.get("bgrun")!;
1696
- const res = await bgrun.execute(
1697
- "call-cmd1",
1698
- { command: "echo cmd-mirror", name: "mirror-job" },
1699
- undefined,
1700
- undefined,
1701
- ctx,
1702
- );
1703
- const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
1704
- await waitForWakes(wakes, 1);
1705
-
1706
- // /bgstatus <id> → single-job status via notify.
1707
- await commands.get("bgstatus")!.handler(id, ctx);
1708
- assert.ok(
1709
- notes.some((n) => n.text.includes(id) && /: done/.test(n.text)),
1710
- "/bgstatus <id> notifies job status",
1711
- );
1712
-
1713
- // /bgstatus done → listing includes the finished job.
1714
- await commands.get("bgstatus")!.handler("done", ctx);
1715
- assert.ok(
1716
- notes.some((n) => /mirror-job: done exit=0/.test(n.text)),
1717
- "/bgstatus done lists finished jobs",
1718
- );
1719
-
1720
- // /bgtail <id> <lines> → condensed tail via notify.
1721
- await commands.get("bgtail")!.handler(`${id} 5`, ctx);
1722
- assert.ok(
1723
- notes.some((n) => n.text.includes("cmd-mirror")),
1724
- "/bgtail notifies the log tail",
1725
- );
1726
-
1727
- // /bgtail with no args → usage error.
1728
- await commands.get("bgtail")!.handler("", ctx);
1729
- assert.ok(
1730
- notes.some((n) => n.kind === "error" && /Usage: \/bgtail/.test(n.text)),
1731
- "/bgtail without id shows usage",
1732
- );
1733
-
1734
- // /bgclean (no args) → session-scoped summary via notify.
1735
- await commands.get("bgclean")!.handler("", ctx);
1736
- assert.ok(
1737
- notes.some((n) => /removed 0 job log\(s\) \(this session\)/.test(n.text)),
1738
- "/bgclean notifies the session-scoped summary",
1739
- );
1740
-
1741
- // /bgclean 7 all → global scope.
1742
- await commands.get("bgclean")!.handler("7 all", ctx);
1743
- assert.ok(
1744
- notes.some((n) => /\(all sessions\)/.test(n.text)),
1745
- "/bgclean all notifies the global summary",
1746
- );
1747
- } finally {
1748
- delete process.env.PI_BGRUN_DIR;
1749
- rmSync(dir, { recursive: true, force: true });
1750
- }
1751
- });
1752
-
1753
- test("formatSince: same-day shows time only; older days include the date", async () => {
1754
- const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
1755
- const mod: any = await import(url);
1756
- assert.equal(typeof mod.formatSince, "function");
1757
-
1758
- const now = new Date("2026-09-09T10:00:00").getTime();
1759
- const sameDay = new Date("2026-09-09T06:30:12").getTime();
1760
- const prevDay = new Date("2026-09-04T15:05:40").getTime();
1761
- const prevMonth = new Date("2026-08-12T23:59:59").getTime();
1762
- const prevYear = new Date("2025-12-30T08:00:00").getTime();
1763
-
1764
- // Same calendar day → time only (unchanged display).
1765
- assert.equal(mod.formatSince(sameDay, now), "06:30:12");
1766
-
1767
- // Different day, same year → date + time.
1768
- const prevDayStr = mod.formatSince(prevDay, now);
1769
- assert.match(prevDayStr, /Sep 4/);
1770
- assert.match(prevDayStr, /15:05:40/);
1771
-
1772
- const prevMonthStr = mod.formatSince(prevMonth, now);
1773
- assert.match(prevMonthStr, /Aug 12/);
1774
- assert.match(prevMonthStr, /23:59:59/);
1775
-
1776
- // Different year → date includes the year.
1777
- const prevYearStr = mod.formatSince(prevYear, now);
1778
- assert.match(prevYearStr, /2025/);
1779
- assert.match(prevYearStr, /Dec 30/);
1780
- assert.match(prevYearStr, /08:00:00/);
1781
- });
1782
-
1783
- // ── Project-local jobs dir ──────────────────────────────────────────────────
1784
-
1785
- test("resolveJobsDirPath: relative resolves against a project root; absolute and no-root fall back", async () => {
1786
- const mod = await import(
1787
- pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1788
- );
1789
- const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1790
- const scratch = mkdtempSync(join(tmpdir(), "pi-bgrun-scratch-"));
1791
- try {
1792
- mkdirSync(join(proj, ".git"), { recursive: true });
1793
-
1794
- // absolute → used as-is, never flagged project-local (older configs keep
1795
- // working unchanged — the migration guarantee)
1796
- const absPath = join(proj, "abs-jobs");
1797
- const abs = mod.resolveJobsDirPath(absPath, { cwd: proj });
1798
- assert.equal(abs.dir, absPath);
1799
- assert.equal(abs.projectLocal, false);
1800
-
1801
- // relative + project root → resolved against the root, flagged project-local
1802
- const rel = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: proj });
1803
- assert.equal(rel.dir, join(proj, ".pi-bgrun", "jobs"));
1804
- assert.equal(rel.projectLocal, true);
1805
-
1806
- // unset → global default
1807
- const none = mod.resolveJobsDirPath(undefined, { cwd: proj });
1808
- assert.equal(none.dir, join(homedir(), ".pi-bgrun", "jobs"));
1809
- assert.equal(none.projectLocal, false);
1810
-
1811
- // relative + cwd that is not a project → global fallback, never cwd-relative
1812
- const fb = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: scratch });
1813
- assert.equal(fb.dir, join(homedir(), ".pi-bgrun", "jobs"));
1814
- assert.equal(fb.projectLocal, false);
1815
- } finally {
1816
- rmSync(proj, { recursive: true, force: true });
1817
- rmSync(scratch, { recursive: true, force: true });
1818
- }
1819
- });
1820
-
1821
- test("ensureGitExcluded: appends the jobs dir pattern to .git/info/exclude once per dir", async () => {
1822
- const mod = await import(
1823
- pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1824
- );
1825
- const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-"));
1826
- try {
1827
- mkdirSync(join(repo, ".git", "info"), { recursive: true });
1828
- mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs"));
1829
- mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs"));
1830
- // a second, different jobs dir under the same repo adds its own pattern
1831
- mod.ensureGitExcluded(join(repo, ".pi-bgrun", "other"));
1832
- const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8");
1833
- assert.match(exclude, /# pi-bgrun job logs/);
1834
- assert.equal(
1835
- exclude.split("\n").filter((l) => l.trim() === ".pi-bgrun/jobs/").length,
1836
- 1,
1837
- "pattern appears exactly once",
1838
- );
1839
- assert.ok(exclude.split("\n").includes(".pi-bgrun/other/"));
1840
- } finally {
1841
- rmSync(repo, { recursive: true, force: true });
1842
- }
1843
- });
1844
-
1845
- test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git dir", async () => {
1846
- const mod = await import(
1847
- pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1848
- );
1849
- const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-"));
1850
- const gd = mkdtempSync(join(tmpdir(), "pi-bgrun-gitdir-"));
1851
- try {
1852
- writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`);
1853
- mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs"));
1854
- const exclude = readFileSync(join(gd, "info", "exclude"), "utf8");
1855
- assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1856
- // nothing was created inside the worktree's own .git (it's a file)
1857
- assert.ok(!existsSync(join(wt, ".git", "info")));
1858
- } finally {
1859
- rmSync(wt, { recursive: true, force: true });
1860
- rmSync(gd, { recursive: true, force: true });
1861
- }
1862
- });
1863
-
1864
- test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => {
1865
- const mod = await import(
1866
- pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1867
- );
1868
- const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-"));
1869
- const gd = join(tmpdir(), "pi-bgrun git dir with spaces");
1870
- mkdirSync(gd, { recursive: true });
1871
- try {
1872
- writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`);
1873
- assert.equal(mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")), true);
1874
- const exclude = readFileSync(join(gd, "info", "exclude"), "utf8");
1875
- assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1876
- } finally {
1877
- rmSync(wt, { recursive: true, force: true });
1878
- rmSync(gd, { recursive: true, force: true });
1879
- }
1880
- });
1881
-
1882
- test("ensureGitExcluded: retries after a transient failure — memoizes only on success", async () => {
1883
- const mod = await import(
1884
- pathToFileURL(join(process.cwd(), "extension/index.ts")).href
1885
- );
1886
- const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-"));
1887
- try {
1888
- mkdirSync(join(repo, ".git", "info"), { recursive: true });
1889
- // Block the exclude path with a directory → the append fails (EISDIR)
1890
- mkdirSync(join(repo, ".git", "info", "exclude"));
1891
- const jobsDir = join(repo, ".pi-bgrun", "jobs");
1892
- assert.equal(mod.ensureGitExcluded(jobsDir), false);
1893
-
1894
- // Unblock: the next call must retry (failure was not memoized) and succeed
1895
- rmSync(join(repo, ".git", "info", "exclude"), { recursive: true });
1896
- assert.equal(mod.ensureGitExcluded(jobsDir), true);
1897
- const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8");
1898
- assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m);
1899
- } finally {
1900
- rmSync(repo, { recursive: true, force: true });
1901
- }
1902
- });
1903
-
1904
- test("bgrun: relative jobsDir in project config → project-local log + auto git-exclude", async () => {
1905
- const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1906
- delete process.env.PI_BGRUN_DIR;
1907
- try {
1908
- mkdirSync(join(proj, ".git"), { recursive: true });
1909
- mkdirSync(join(proj, ".pi"), { recursive: true });
1910
- writeFileSync(
1911
- join(proj, ".pi", "pi-bgrun.json"),
1912
- JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
1913
- );
1914
- const { pi, wakes, tools, ctx } = makeFakePi({
1915
- ctxFields: { cwd: proj, isProjectTrusted: () => true },
1916
- });
1917
- await loadExtension(pi);
1918
- const bgrun = tools.get("bgrun")!;
1919
-
1920
- const res = await bgrun.execute(
1921
- "call-1",
1922
- { command: "echo project-local" },
1923
- undefined,
1924
- undefined,
1925
- ctx,
1926
- );
1927
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
1928
- [])[1];
1929
- assert.ok(id, "got a job id");
1930
-
1931
- await waitForWakes(wakes, 1);
1932
-
1933
- const logPath = join(proj, ".pi-bgrun", "jobs", `${id}.log`);
1934
- assert.ok(existsSync(logPath), "log written inside the project");
1935
- assert.match(readFileSync(logPath, "utf8"), /project-local/);
1936
-
1937
- const exclude = join(proj, ".git", "info", "exclude");
1938
- assert.ok(existsSync(exclude), "exclude file created");
1939
- assert.match(readFileSync(exclude, "utf8"), /^\.pi-bgrun\/jobs\/$/m);
1940
- } finally {
1941
- delete process.env.PI_BGRUN_DIR;
1942
- rmSync(proj, { recursive: true, force: true });
1943
- }
1944
- });
1945
-
1946
- test("bgtail: prefers the session record's logPath when the jobsDir config changes", async () => {
1947
- const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
1948
- delete process.env.PI_BGRUN_DIR;
1949
- try {
1950
- mkdirSync(join(proj, ".git"), { recursive: true });
1951
- mkdirSync(join(proj, ".pi"), { recursive: true });
1952
- writeFileSync(
1953
- join(proj, ".pi", "pi-bgrun.json"),
1954
- JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
1955
- );
1956
- const { pi, wakes, tools, ctx } = makeFakePi({
1957
- ctxFields: { cwd: proj, isProjectTrusted: () => true },
1958
- });
1959
- await loadExtension(pi);
1960
- const bgrun = tools.get("bgrun")!;
1961
- const bgtail = tools.get("bgtail")!;
1962
-
1963
- const res = await bgrun.execute(
1964
- "call-1",
1965
- { command: "echo migrated-log" },
1966
- undefined,
1967
- undefined,
1968
- ctx,
1969
- );
1970
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
1971
- [])[1];
1972
- assert.ok(id, "got a job id");
1973
- await waitForWakes(wakes, 1);
1974
-
1975
- // A ctx with no project config/trust now resolves the jobs dir to the
1976
- // GLOBAL default — only the session record's logPath can still find the
1977
- // log (the mid-upgrade config-change scenario).
1978
- const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined };
1979
- const tail = await bgtail.execute(
1980
- "call-2",
1981
- { id, lines: 10 },
1982
- undefined,
1983
- undefined,
1984
- plainCtx,
1985
- );
1986
- assert.equal(tail.details.notFound, false);
1987
- assert.match(tail.content[0].text as string, /migrated-log/);
1988
- } finally {
1989
- delete process.env.PI_BGRUN_DIR;
1990
- rmSync(proj, { recursive: true, force: true });
1991
- }
1992
- });
1993
-
1994
- // ── bggrep ──────────────────────────────────────────────────────────────────
1995
-
1996
- test("bggrep: line-numbered matches; explicit pattern wins; default pattern; no-match case", async () => {
1997
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
1998
- process.env.PI_BGRUN_DIR = dir;
1999
- try {
2000
- const { pi, wakes, tools, ctx } = makeFakePi();
2001
- await loadExtension(pi);
2002
- const bgrun = tools.get("bgrun")!;
2003
- const bggrep = tools.get("bggrep")!;
2004
-
2005
- const res = await bgrun.execute(
2006
- "c1",
2007
- { command: "printf 'alpha\\nerror: boom BANANA\\nomega\\n'" },
2008
- undefined,
2009
- undefined,
2010
- ctx,
2011
- );
2012
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2013
- [])[1];
2014
- assert.ok(id, "got a job id");
2015
- await waitForWakes(wakes, 1);
2016
-
2017
- // explicit pattern → only matching lines, with line numbers
2018
- const g = await bggrep.execute(
2019
- "c2",
2020
- { id, pattern: "BANANA" },
2021
- undefined,
2022
- undefined,
2023
- ctx,
2024
- );
2025
- assert.equal(g.details.matches, 1);
2026
- assert.equal(g.details.notFound, false);
2027
- assert.match(g.content[0].text as string, /L2: error: boom BANANA/);
2028
- assert.doesNotMatch(g.content[0].text as string, /alpha|omega/);
2029
-
2030
- // default pattern (no pattern passed) catches the failure signature
2031
- const g2 = await bggrep.execute("c3", { id }, undefined, undefined, ctx);
2032
- assert.equal(g2.details.matches, 1);
2033
- assert.match(g2.content[0].text as string, /1 match for \//);
2034
- assert.equal(
2035
- g2.details.pattern,
2036
- "--- FAIL:|^FAIL\\b|^panic:|fatal error:|AssertionError|Error:|error:|make: \\*\\*\\*.*Error|✗|✖",
2037
- );
2038
-
2039
- // a log with no failure signatures → clean no-match (not an error)
2040
- const res2 = await bgrun.execute(
2041
- "c4",
2042
- { command: "echo all clear, nothing to see" },
2043
- undefined,
2044
- undefined,
2045
- ctx,
2046
- );
2047
- const id2 = ((res2.content[0].text as string).match(/^started: ([^\n]+)/) ||
2048
- [])[1];
2049
- await waitForWakes(wakes, 2);
2050
- const g3 = await bggrep.execute(
2051
- "c5",
2052
- { id: id2 },
2053
- undefined,
2054
- undefined,
2055
- ctx,
2056
- );
2057
- assert.equal(g3.details.matches, 0);
2058
- assert.equal(g3.isError, undefined);
2059
- assert.match(g3.content[0].text as string, /— none/);
2060
- } finally {
2061
- delete process.env.PI_BGRUN_DIR;
2062
- rmSync(dir, { recursive: true, force: true });
2063
- }
2064
- });
2065
-
2066
- test("bggrep: context lines with gap markers between distant matches", async () => {
2067
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2068
- process.env.PI_BGRUN_DIR = dir;
2069
- try {
2070
- const { pi, wakes, tools, ctx } = makeFakePi();
2071
- await loadExtension(pi);
2072
- const bgrun = tools.get("bgrun")!;
2073
- const bggrep = tools.get("bggrep")!;
2074
-
2075
- const res = await bgrun.execute(
2076
- "c1",
2077
- {
2078
- command:
2079
- "printf 'l1\\nMATCH one\\nl3\\nl4\\nl5\\nl6\\nl7\\nMATCH two\\nl9\\n'",
2080
- },
2081
- undefined,
2082
- undefined,
2083
- ctx,
2084
- );
2085
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2086
- [])[1];
2087
- assert.ok(id, "got a job id");
2088
- await waitForWakes(wakes, 1);
2089
-
2090
- const g = await bggrep.execute(
2091
- "c2",
2092
- { id, pattern: "MATCH", context: 1 },
2093
- undefined,
2094
- undefined,
2095
- ctx,
2096
- );
2097
- assert.equal(g.details.matches, 2);
2098
- const text = g.content[0].text as string;
2099
- assert.match(text, /L2: MATCH one/);
2100
- assert.match(text, /L1: l1/); // context before
2101
- assert.match(text, /L8: MATCH two/);
2102
- assert.match(text, /L9: l9/); // context after
2103
- assert.match(text, /…\[3 lines skipped\]…/); // l4-l6 between the windows
2104
- } finally {
2105
- delete process.env.PI_BGRUN_DIR;
2106
- rmSync(dir, { recursive: true, force: true });
2107
- }
2108
- });
2109
-
2110
- test("bggrep: invalid pattern errors clearly", async () => {
2111
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2112
- process.env.PI_BGRUN_DIR = dir;
2113
- try {
2114
- const { pi, wakes, tools, ctx } = makeFakePi();
2115
- await loadExtension(pi);
2116
- const bgrun = tools.get("bgrun")!;
2117
- const bggrep = tools.get("bggrep")!;
2118
- const res = await bgrun.execute(
2119
- "c1",
2120
- { command: "echo hi" },
2121
- undefined,
2122
- undefined,
2123
- ctx,
2124
- );
2125
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2126
- [])[1];
2127
- await waitForWakes(wakes, 1);
2128
- await assert.rejects(
2129
- bggrep.execute(
2130
- "c2",
2131
- { id, pattern: "([unclosed" },
2132
- undefined,
2133
- undefined,
2134
- ctx,
2135
- ),
2136
- /bggrep: invalid pattern/,
2137
- );
2138
- } finally {
2139
- delete process.env.PI_BGRUN_DIR;
2140
- rmSync(dir, { recursive: true, force: true });
2141
- }
2142
- });
2143
-
2144
- test("bggrep: caps at 50 matches with a not-shown note", async () => {
2145
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2146
- process.env.PI_BGRUN_DIR = dir;
2147
- try {
2148
- const { pi, wakes, tools, ctx } = makeFakePi();
2149
- await loadExtension(pi);
2150
- const bgrun = tools.get("bgrun")!;
2151
- const bggrep = tools.get("bggrep")!;
2152
- const res = await bgrun.execute(
2153
- "c1",
2154
- { command: 'for i in $(seq 1 60); do echo "boom $i"; done' },
2155
- undefined,
2156
- undefined,
2157
- ctx,
2158
- );
2159
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2160
- [])[1];
2161
- await waitForWakes(wakes, 1);
2162
- const g = await bggrep.execute(
2163
- "c2",
2164
- { id, pattern: "boom" },
2165
- undefined,
2166
- undefined,
2167
- ctx,
2168
- );
2169
- assert.equal(g.details.matches, 60);
2170
- assert.equal(g.details.capped, true);
2171
- assert.match(
2172
- g.content[0].text as string,
2173
- /showing first 50; 10 more not shown/,
2174
- );
2175
- assert.match(g.content[0].text as string, /L50: boom 50/);
2176
- assert.doesNotMatch(g.content[0].text as string, /L51: boom 51/);
2177
- } finally {
2178
- delete process.env.PI_BGRUN_DIR;
2179
- rmSync(dir, { recursive: true, force: true });
2180
- }
2181
- });
2182
-
2183
- test("bggrep: prefers the session record's logPath when the jobsDir config changes", async () => {
2184
- const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-"));
2185
- delete process.env.PI_BGRUN_DIR;
2186
- try {
2187
- mkdirSync(join(proj, ".git"), { recursive: true });
2188
- mkdirSync(join(proj, ".pi"), { recursive: true });
2189
- writeFileSync(
2190
- join(proj, ".pi", "pi-bgrun.json"),
2191
- JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }),
2192
- );
2193
- const { pi, wakes, tools, ctx } = makeFakePi({
2194
- ctxFields: { cwd: proj, isProjectTrusted: () => true },
2195
- });
2196
- await loadExtension(pi);
2197
- const bgrun = tools.get("bgrun")!;
2198
- const bggrep = tools.get("bggrep")!;
2199
- const res = await bgrun.execute(
2200
- "c1",
2201
- { command: "echo pattern-target-line" },
2202
- undefined,
2203
- undefined,
2204
- ctx,
2205
- );
2206
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2207
- [])[1];
2208
- await waitForWakes(wakes, 1);
2209
-
2210
- const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined };
2211
- const g = await bggrep.execute(
2212
- "c2",
2213
- { id, pattern: "pattern-target" },
2214
- undefined,
2215
- undefined,
2216
- plainCtx,
2217
- );
2218
- assert.equal(g.details.notFound, false);
2219
- assert.equal(g.details.matches, 1);
2220
- } finally {
2221
- delete process.env.PI_BGRUN_DIR;
2222
- rmSync(proj, { recursive: true, force: true });
2223
- }
2224
- });
2225
-
2226
- // ── bgtail delta tailing ────────────────────────────────────────────────────
2227
-
2228
- test("bgtail: delta tailing — first read full tail, then only new lines, then none", async () => {
2229
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2230
- process.env.PI_BGRUN_DIR = dir;
2231
- try {
2232
- const { pi, wakes, tools, ctx } = makeFakePi();
2233
- await loadExtension(pi);
2234
- const bgrun = tools.get("bgrun")!;
2235
- const bgtail = tools.get("bgtail")!;
2236
- const res = await bgrun.execute(
2237
- "c1",
2238
- { command: "echo first line" },
2239
- undefined,
2240
- undefined,
2241
- ctx,
2242
- );
2243
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2244
- [])[1];
2245
- assert.ok(id, "got a job id");
2246
- await waitForWakes(wakes, 1);
2247
- const logPath = join(dir, `${id}.log`);
2248
-
2249
- // First read: full tail, no delta header
2250
- const t1 = await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2251
- assert.match(t1.content[0].text as string, /first line/);
2252
- assert.equal(t1.details.newLines, undefined);
2253
- assert.doesNotMatch(
2254
- t1.content[0].text as string,
2255
- /new lines since last read/,
2256
- );
2257
-
2258
- // Log grows: only the new lines come back, with a +N header
2259
- appendFileSync(logPath, "appended-A\nappended-B\n");
2260
- const t2 = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2261
- const text2 = t2.content[0].text as string;
2262
- assert.match(text2, /\+2 new lines since last read/);
2263
- assert.match(text2, /appended-A/);
2264
- assert.match(text2, /appended-B/);
2265
- assert.doesNotMatch(text2, /first line/);
2266
- assert.equal(t2.details.newLines, 2);
2267
-
2268
- // Nothing new: a tiny no-new-lines response (cheap polling)
2269
- const t3 = await bgtail.execute("c4", { id }, undefined, undefined, ctx);
2270
- assert.match(t3.content[0].text as string, /no new lines since last read/);
2271
- assert.equal(t3.details.linesShown, 0);
2272
- } finally {
2273
- delete process.env.PI_BGRUN_DIR;
2274
- rmSync(dir, { recursive: true, force: true });
2275
- }
2276
- });
2277
-
2278
- test("bgtail: raw:true keeps the verbatim window but still advances the bookmark", async () => {
2279
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2280
- process.env.PI_BGRUN_DIR = dir;
2281
- try {
2282
- const { pi, wakes, tools, ctx } = makeFakePi();
2283
- await loadExtension(pi);
2284
- const bgrun = tools.get("bgrun")!;
2285
- const bgtail = tools.get("bgtail")!;
2286
- const res = await bgrun.execute(
2287
- "c1",
2288
- { command: "echo baseline" },
2289
- undefined,
2290
- undefined,
2291
- ctx,
2292
- );
2293
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2294
- [])[1];
2295
- await waitForWakes(wakes, 1);
2296
- const logPath = join(dir, `${id}.log`);
2297
-
2298
- appendFileSync(logPath, "post-raw line\n");
2299
- const r = await bgtail.execute(
2300
- "c2",
2301
- { id, lines: 3, raw: true },
2302
- undefined,
2303
- undefined,
2304
- ctx,
2305
- );
2306
- assert.match(r.content[0].text as string, /post-raw line/);
2307
- assert.equal(r.details.condensed, false);
2308
-
2309
- // The raw read advanced the bookmark → the next condensed read is empty
2310
- const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2311
- assert.match(t.content[0].text as string, /no new lines since last read/);
2312
- } finally {
2313
- delete process.env.PI_BGRUN_DIR;
2314
- rmSync(dir, { recursive: true, force: true });
2315
- }
2316
- });
2317
-
2318
- test("bgtail: a shrunken log resets to a full tail with a note", async () => {
2319
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2320
- process.env.PI_BGRUN_DIR = dir;
2321
- try {
2322
- const { pi, wakes, tools, ctx } = makeFakePi();
2323
- await loadExtension(pi);
2324
- const bgrun = tools.get("bgrun")!;
2325
- const bgtail = tools.get("bgtail")!;
2326
- const res = await bgrun.execute(
2327
- "c1",
2328
- { command: "echo long original content line" },
2329
- undefined,
2330
- undefined,
2331
- ctx,
2332
- );
2333
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2334
- [])[1];
2335
- await waitForWakes(wakes, 1);
2336
- const logPath = join(dir, `${id}.log`);
2337
-
2338
- // First read sets the bookmark; then the log is replaced by a shorter one
2339
- await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2340
- writeFileSync(logPath, "tiny replacement\n");
2341
- const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2342
- const text = t.content[0].text as string;
2343
- assert.match(text, /log shrank since last read — showing full tail/);
2344
- assert.match(text, /tiny replacement/);
2345
- } finally {
2346
- delete process.env.PI_BGRUN_DIR;
2347
- rmSync(dir, { recursive: true, force: true });
2348
- }
2349
- });
2350
-
2351
- test("bgtail: a replaced log with the same line count resets to a full tail", async () => {
2352
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2353
- process.env.PI_BGRUN_DIR = dir;
2354
- try {
2355
- const { pi, wakes, tools, ctx } = makeFakePi();
2356
- await loadExtension(pi);
2357
- const bgrun = tools.get("bgrun")!;
2358
- const bgtail = tools.get("bgtail")!;
2359
- const res = await bgrun.execute(
2360
- "c1",
2361
- { command: "printf 'aaaa\\nbbbb\\ncccc\\n'" },
2362
- undefined,
2363
- undefined,
2364
- ctx,
2365
- );
2366
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2367
- [])[1];
2368
- await waitForWakes(wakes, 1);
2369
- const logPath = join(dir, `${id}.log`);
2370
-
2371
- // First read sets the bookmark (3 content lines, first line "aaaa")
2372
- await bgtail.execute("c2", { id }, undefined, undefined, ctx);
2373
- // Replacement: SAME line count, LARGER byte size (so the shrink checks
2374
- // cannot fire), different first line — only the first-line detector
2375
- // (append-only logs never mutate line 0) can catch this.
2376
- writeFileSync(
2377
- logPath,
2378
- "xxxxxxxxxxxxxxxxxx\nyyyyyyyyyyyyyyyyyy\nzzzzzzzzzzzzzzzzzz\n",
2379
- );
2380
- const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2381
- const text = t.content[0].text as string;
2382
- assert.match(text, /log was replaced since last read — showing full tail/);
2383
- assert.match(text, /xxxxxxxxxxxxxxxxxx/);
2384
- } finally {
2385
- delete process.env.PI_BGRUN_DIR;
2386
- rmSync(dir, { recursive: true, force: true });
2387
- }
2388
- });
2389
-
2390
- test("bggrep and bgtail normalize CRLF logs", async () => {
2391
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2392
- process.env.PI_BGRUN_DIR = dir;
2393
- try {
2394
- const { pi, wakes, tools, ctx } = makeFakePi();
2395
- await loadExtension(pi);
2396
- const bgrun = tools.get("bgrun")!;
2397
- const bgtail = tools.get("bgtail")!;
2398
- const bggrep = tools.get("bggrep")!;
2399
- const res = await bgrun.execute(
2400
- "c1",
2401
- { command: "echo something" },
2402
- undefined,
2403
- undefined,
2404
- ctx,
2405
- );
2406
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2407
- [])[1];
2408
- await waitForWakes(wakes, 1);
2409
- const logPath = join(dir, `${id}.log`);
2410
-
2411
- writeFileSync(logPath, "alpha\r\nerror: boom\r\nomega\r\n");
2412
- // A $-anchored pattern must match despite the CRLF source
2413
- const g = await bggrep.execute(
2414
- "c2",
2415
- { id, pattern: "boom$" },
2416
- undefined,
2417
- undefined,
2418
- ctx,
2419
- );
2420
- assert.match(g.content[0].text as string, /L2: error: boom/);
2421
- // And no stray \r leaks into either tool's output
2422
- assert.ok(!(g.content[0].text as string).includes("\r"));
2423
- const t = await bgtail.execute("c3", { id }, undefined, undefined, ctx);
2424
- assert.ok(!(t.content[0].text as string).includes("\r"));
2425
- assert.match(t.content[0].text as string, /error: boom/);
2426
- } finally {
2427
- delete process.env.PI_BGRUN_DIR;
2428
- rmSync(dir, { recursive: true, force: true });
2429
- }
2430
- });
2431
-
2432
- test("bggrep: empty log reports zero lines, and a missing log is notFound", async () => {
2433
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2434
- process.env.PI_BGRUN_DIR = dir;
2435
- try {
2436
- const { pi, wakes, tools, ctx } = makeFakePi();
2437
- await loadExtension(pi);
2438
- const bgrun = tools.get("bgrun")!;
2439
- const bggrep = tools.get("bggrep")!;
2440
- const res = await bgrun.execute(
2441
- "c1",
2442
- { command: "echo x" },
2443
- undefined,
2444
- undefined,
2445
- ctx,
2446
- );
2447
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2448
- [])[1];
2449
- await waitForWakes(wakes, 1);
2450
-
2451
- writeFileSync(join(dir, `${id}.log`), "");
2452
- const g = await bggrep.execute(
2453
- "c2",
2454
- { id, pattern: "Error:" },
2455
- undefined,
2456
- undefined,
2457
- ctx,
2458
- );
2459
- assert.match(
2460
- g.content[0].text as string,
2461
- /0 matches for \/Error:\/ in 0 lines — none/,
2462
- );
2463
-
2464
- const missing = await bggrep.execute(
2465
- "c3",
2466
- { id: "no-such-job-123", pattern: "x" },
2467
- undefined,
2468
- undefined,
2469
- ctx,
2470
- );
2471
- assert.equal(missing.isError, true);
2472
- assert.equal(missing.details.notFound, true);
2473
- } finally {
2474
- delete process.env.PI_BGRUN_DIR;
2475
- rmSync(dir, { recursive: true, force: true });
2476
- }
2477
- });
2478
-
2479
- test("bggrep: context windows combine with the 50-match cap", async () => {
2480
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2481
- process.env.PI_BGRUN_DIR = dir;
2482
- try {
2483
- const { pi, wakes, tools, ctx } = makeFakePi();
2484
- await loadExtension(pi);
2485
- const bgrun = tools.get("bgrun")!;
2486
- const bggrep = tools.get("bggrep")!;
2487
- const res = await bgrun.execute(
2488
- "c1",
2489
- { command: "echo x" },
2490
- undefined,
2491
- undefined,
2492
- ctx,
2493
- );
2494
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2495
- [])[1];
2496
- await waitForWakes(wakes, 1);
2497
-
2498
- // 240 lines, a hit every 4th line → 60 matches (cap 50); with context: 1
2499
- // each window is [i-1, i+1] and consecutive windows leave a 1-line gap.
2500
- const lines: string[] = [];
2501
- for (let i = 1; i <= 240; i++) {
2502
- lines.push(i % 4 === 0 ? `hit ${i}` : `filler ${i}`);
2503
- }
2504
- writeFileSync(join(dir, `${id}.log`), lines.join("\n") + "\n");
2505
- const r = await bggrep.execute(
2506
- "c2",
2507
- { id, pattern: "^hit", context: 1 },
2508
- undefined,
2509
- undefined,
2510
- ctx,
2511
- );
2512
- const text = r.content[0].text as string;
2513
- assert.equal(r.details.matches, 60);
2514
- assert.equal(r.details.capped, true);
2515
- assert.match(text, /showing first 50; 10 more not shown/);
2516
- assert.match(text, /L4: hit 4/);
2517
- assert.match(text, /…\[1 line skipped\]…/);
2518
- } finally {
2519
- delete process.env.PI_BGRUN_DIR;
2520
- rmSync(dir, { recursive: true, force: true });
2521
- }
2522
- });
2523
-
2524
- test("bgtail and bggrep clamp nonsensical numeric params", async () => {
2525
- const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
2526
- process.env.PI_BGRUN_DIR = dir;
2527
- try {
2528
- const { pi, wakes, tools, ctx } = makeFakePi();
2529
- await loadExtension(pi);
2530
- const bgrun = tools.get("bgrun")!;
2531
- const bgtail = tools.get("bgtail")!;
2532
- const bggrep = tools.get("bggrep")!;
2533
- const res = await bgrun.execute(
2534
- "c1",
2535
- { command: "printf 'one\\ntwo\\nthree\\nfour\\nfive\\n'" },
2536
- undefined,
2537
- undefined,
2538
- ctx,
2539
- );
2540
- const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) ||
2541
- [])[1];
2542
- await waitForWakes(wakes, 1);
2543
-
2544
- // lines: 0 must not mean "everything" (slice(-0) pitfall) — clamps to 1
2545
- const t = await bgtail.execute(
2546
- "c2",
2547
- { id, lines: 0 },
2548
- undefined,
2549
- undefined,
2550
- ctx,
2551
- );
2552
- assert.equal(t.details.linesShown, 1);
2553
- assert.match(t.content[0].text as string, /five/);
2554
- assert.ok(!(t.content[0].text as string).includes("four"));
2555
-
2556
- // negative context must not drop the match lines themselves — clamps to 0
2557
- const g = await bggrep.execute(
2558
- "c3",
2559
- { id, pattern: "^three", context: -1 },
2560
- undefined,
2561
- undefined,
2562
- ctx,
2563
- );
2564
- assert.match(g.content[0].text as string, /L3: three/);
2565
- } finally {
2566
- delete process.env.PI_BGRUN_DIR;
2567
- rmSync(dir, { recursive: true, force: true });
2568
- }
2569
- });