@stablekernel/pi-background-run 0.3.0 → 0.5.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 +108 -23
- package/extension/index.test.ts +1320 -50
- package/extension/index.ts +842 -230
- package/package.json +1 -1
- package/skill/run-bg/SKILL.md +32 -10
package/extension/index.test.ts
CHANGED
|
@@ -19,10 +19,12 @@ import {
|
|
|
19
19
|
readFileSync,
|
|
20
20
|
writeFileSync,
|
|
21
21
|
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
appendFileSync,
|
|
22
24
|
readdirSync,
|
|
23
25
|
} from "node:fs";
|
|
24
26
|
import { join } from "node:path";
|
|
25
|
-
import { tmpdir } from "node:os";
|
|
27
|
+
import { homedir, tmpdir } from "node:os";
|
|
26
28
|
import { pathToFileURL } from "node:url";
|
|
27
29
|
|
|
28
30
|
interface CapturedWake {
|
|
@@ -30,11 +32,21 @@ interface CapturedWake {
|
|
|
30
32
|
options?: Record<string, unknown>;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
function makeFakePi(
|
|
35
|
+
function makeFakePi(
|
|
36
|
+
opts: {
|
|
37
|
+
idle?: boolean;
|
|
38
|
+
priorEntries?: any[];
|
|
39
|
+
ctxFields?: Record<string, unknown>;
|
|
40
|
+
} = {},
|
|
41
|
+
): {
|
|
34
42
|
pi: any;
|
|
35
43
|
wakes: CapturedWake[];
|
|
36
44
|
entries: any[];
|
|
37
45
|
tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
|
|
46
|
+
commands: Map<
|
|
47
|
+
string,
|
|
48
|
+
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
49
|
+
>;
|
|
38
50
|
ctx: any;
|
|
39
51
|
handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
|
|
40
52
|
fireSessionStart: () => Promise<void>;
|
|
@@ -45,6 +57,10 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
45
57
|
string,
|
|
46
58
|
{ execute: (...args: any[]) => Promise<any> }
|
|
47
59
|
>();
|
|
60
|
+
const commands = new Map<
|
|
61
|
+
string,
|
|
62
|
+
{ description?: string; handler: (...args: any[]) => Promise<void> }
|
|
63
|
+
>();
|
|
48
64
|
const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
|
|
49
65
|
const idle = opts.idle ?? true;
|
|
50
66
|
const ctx = {
|
|
@@ -52,6 +68,7 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
52
68
|
hasUI: false,
|
|
53
69
|
ui: { notify() {}, setWidget() {}, setStatus() {} },
|
|
54
70
|
sessionManager: { getEntries: () => entries },
|
|
71
|
+
...(opts.ctxFields as Record<string, unknown> | undefined),
|
|
55
72
|
};
|
|
56
73
|
const pi = {
|
|
57
74
|
sendUserMessage(text: string, options?: Record<string, unknown>) {
|
|
@@ -64,6 +81,9 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
64
81
|
registerTool(def: any) {
|
|
65
82
|
tools.set(def.name, def);
|
|
66
83
|
},
|
|
84
|
+
registerCommand(name: string, def: any) {
|
|
85
|
+
commands.set(name, def);
|
|
86
|
+
},
|
|
67
87
|
on(event: string, handler: (...args: any[]) => Promise<any>) {
|
|
68
88
|
const list = handlers.get(event) ?? [];
|
|
69
89
|
list.push(handler);
|
|
@@ -75,7 +95,16 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
|
75
95
|
await h({ reason: "startup" }, ctx);
|
|
76
96
|
}
|
|
77
97
|
};
|
|
78
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
pi,
|
|
100
|
+
wakes,
|
|
101
|
+
entries,
|
|
102
|
+
tools,
|
|
103
|
+
commands,
|
|
104
|
+
ctx,
|
|
105
|
+
handlers,
|
|
106
|
+
fireSessionStart,
|
|
107
|
+
};
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
async function loadExtension(
|
|
@@ -268,11 +297,19 @@ test("bgtail: condenses output — strips ANSI, collapses repeats, caps long lin
|
|
|
268
297
|
const text = tail.content[0].text as string;
|
|
269
298
|
assert.ok(!text.includes("\u001b"), "ANSI escapes stripped");
|
|
270
299
|
assert.ok(text.includes("OK green"), "text after stripping survives");
|
|
271
|
-
assert.match(
|
|
300
|
+
assert.match(
|
|
301
|
+
text,
|
|
302
|
+
/wait {2}\[x5\]/,
|
|
303
|
+
"5 identical lines collapsed to one with count",
|
|
304
|
+
);
|
|
272
305
|
assert.ok(!text.includes("x".repeat(4000)), "5000-char line capped");
|
|
273
306
|
assert.match(text, /\u2026\[\+3\d{3} chars\]/, "truncation marker present");
|
|
274
307
|
assert.match(text, /\(\d+ ANSI escape/, "notes mention ANSI stripping");
|
|
275
|
-
assert.match(
|
|
308
|
+
assert.match(
|
|
309
|
+
text,
|
|
310
|
+
/1 repeated-line run collapsed/,
|
|
311
|
+
"notes mention run collapse",
|
|
312
|
+
);
|
|
276
313
|
assert.ok((tail.details as any).condensed === true);
|
|
277
314
|
} finally {
|
|
278
315
|
delete process.env.PI_BGRUN_DIR;
|
|
@@ -309,7 +346,10 @@ test("bgtail: raw=true skips condensing", async () => {
|
|
|
309
346
|
);
|
|
310
347
|
const text = tail.content[0].text as string;
|
|
311
348
|
assert.ok(text.includes("\u001b[31m"), "raw keeps ANSI escapes");
|
|
312
|
-
assert.ok(
|
|
349
|
+
assert.ok(
|
|
350
|
+
text.includes("wait\nwait\nwait"),
|
|
351
|
+
"raw keeps repeated lines uncollapsed",
|
|
352
|
+
);
|
|
313
353
|
assert.ok((tail.details as any).condensed === false);
|
|
314
354
|
} finally {
|
|
315
355
|
delete process.env.PI_BGRUN_DIR;
|
|
@@ -667,7 +707,7 @@ test("bgrun: name is optional — behavior unchanged without it", async () => {
|
|
|
667
707
|
);
|
|
668
708
|
const text = res.content[0].text as string;
|
|
669
709
|
// No 'name:' line in the response.
|
|
670
|
-
assert.ok(!/^
|
|
710
|
+
assert.ok(!/^ {2}name:/m.test(text), "no name line when name omitted");
|
|
671
711
|
const id = (text.match(/^started: ([^\n]+)/) || [])[1];
|
|
672
712
|
assert.ok(
|
|
673
713
|
id.startsWith("echo-unnamed-job-"),
|
|
@@ -702,7 +742,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
|
702
742
|
ctx,
|
|
703
743
|
);
|
|
704
744
|
assert.ok(
|
|
705
|
-
!/^
|
|
745
|
+
!/^ {2}name:/m.test(res1.content[0].text as string),
|
|
706
746
|
"blank name ignored",
|
|
707
747
|
);
|
|
708
748
|
|
|
@@ -716,7 +756,7 @@ test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
|
716
756
|
ctx,
|
|
717
757
|
);
|
|
718
758
|
const text2 = res2.content[0].text as string;
|
|
719
|
-
const nameLine = (text2.match(/^
|
|
759
|
+
const nameLine = (text2.match(/^ {2}name: (.+)$/m) || [])[1];
|
|
720
760
|
assert.equal(nameLine.length, 80, "name truncated to 80 chars");
|
|
721
761
|
|
|
722
762
|
await waitForWakes(wakes, 2);
|
|
@@ -1068,14 +1108,15 @@ test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
|
|
|
1068
1108
|
}
|
|
1069
1109
|
});
|
|
1070
1110
|
|
|
1071
|
-
test("bgclean: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
1111
|
+
test("bgclean all: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
1072
1112
|
// Regression: exit marker must win over pid liveness. Old code checked
|
|
1073
1113
|
// pid first, so any log whose id-pid happened to be a live process (e.g.
|
|
1074
1114
|
// pi's own pid from the old id bug, or pid reuse) was kept forever.
|
|
1075
1115
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1076
1116
|
process.env.PI_BGRUN_DIR = dir;
|
|
1077
1117
|
try {
|
|
1078
|
-
// Old finished log whose id-pid is THIS process (alive!) — must
|
|
1118
|
+
// Old finished foreign log whose id-pid is THIS process (alive!) — must
|
|
1119
|
+
// still be removed by an explicit global sweep.
|
|
1079
1120
|
const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
|
|
1080
1121
|
writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
|
|
1081
1122
|
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
@@ -1086,14 +1127,31 @@ test("bgclean: removes a FINISHED job's old log even when its id-pid is alive",
|
|
|
1086
1127
|
await loadExtension(pi);
|
|
1087
1128
|
const bgclean = tools.get("bgclean")!;
|
|
1088
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
|
+
|
|
1089
1144
|
const result = await bgclean.execute(
|
|
1090
1145
|
"call-stale",
|
|
1091
|
-
{ days: 7 },
|
|
1146
|
+
{ days: 7, all: true },
|
|
1092
1147
|
undefined,
|
|
1093
1148
|
undefined,
|
|
1094
1149
|
ctx,
|
|
1095
1150
|
);
|
|
1096
|
-
assert.match(
|
|
1151
|
+
assert.match(
|
|
1152
|
+
result.content[0].text as string,
|
|
1153
|
+
/removed 1 job log\(s\) \(all sessions\)/,
|
|
1154
|
+
);
|
|
1097
1155
|
assert.ok(
|
|
1098
1156
|
!existsSync(oldPath),
|
|
1099
1157
|
"finished job's log removed despite live id-pid",
|
|
@@ -1134,10 +1192,267 @@ test("session_start adoption: skips finished jobs even with a live id-pid", asyn
|
|
|
1134
1192
|
}
|
|
1135
1193
|
});
|
|
1136
1194
|
|
|
1137
|
-
test("
|
|
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 () => {
|
|
1138
1452
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1139
1453
|
process.env.PI_BGRUN_DIR = dir;
|
|
1140
1454
|
delete process.env.PI_BGRUN_FOREIGN_JOBS;
|
|
1455
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN; // default: on
|
|
1141
1456
|
try {
|
|
1142
1457
|
const fs = await import("node:fs");
|
|
1143
1458
|
const backdate = (path: string) => {
|
|
@@ -1145,92 +1460,150 @@ test("auto-clean: throttled via .last-clean marker; manual bgclean always runs",
|
|
|
1145
1460
|
fs.utimesSync(path, oldTime, oldTime);
|
|
1146
1461
|
};
|
|
1147
1462
|
|
|
1148
|
-
// Old log A + first session_start (no marker yet) → sweep
|
|
1463
|
+
// Old foreign log A + first session_start (no marker yet) → global sweep
|
|
1464
|
+
// runs, A removed.
|
|
1149
1465
|
const logA = join(dir, "old-a-1000000000-99999.log");
|
|
1150
1466
|
fs.writeFileSync(logA, "old a\n__BGRUN_EXIT__=0\n");
|
|
1151
1467
|
backdate(logA);
|
|
1152
1468
|
{
|
|
1153
|
-
const { pi,
|
|
1469
|
+
const { pi, fireSessionStart } = makeFakePi();
|
|
1154
1470
|
await loadExtension(pi);
|
|
1155
1471
|
await fireSessionStart();
|
|
1156
1472
|
}
|
|
1157
|
-
assert.ok(!fs.existsSync(logA), "first sweep removed old log A");
|
|
1473
|
+
assert.ok(!fs.existsSync(logA), "first global sweep removed old log A");
|
|
1158
1474
|
assert.ok(
|
|
1159
1475
|
fs.existsSync(join(dir, ".last-clean")),
|
|
1160
1476
|
"throttle marker written",
|
|
1161
1477
|
);
|
|
1162
1478
|
|
|
1163
|
-
// Old log B + second session_start while marker is fresh →
|
|
1479
|
+
// Old foreign log B + second session_start while marker is fresh →
|
|
1480
|
+
// throttled, B kept.
|
|
1164
1481
|
const logB = join(dir, "old-b-1000000000-99998.log");
|
|
1165
1482
|
fs.writeFileSync(logB, "old b\n__BGRUN_EXIT__=0\n");
|
|
1166
1483
|
backdate(logB);
|
|
1167
1484
|
{
|
|
1168
|
-
const { pi,
|
|
1485
|
+
const { pi, fireSessionStart } = makeFakePi();
|
|
1169
1486
|
await loadExtension(pi);
|
|
1170
1487
|
await fireSessionStart();
|
|
1171
1488
|
}
|
|
1172
|
-
assert.ok(
|
|
1489
|
+
assert.ok(
|
|
1490
|
+
fs.existsSync(logB),
|
|
1491
|
+
"second global sweep throttled — old log B kept",
|
|
1492
|
+
);
|
|
1173
1493
|
|
|
1174
|
-
// Manual bgclean ignores the throttle and removes B.
|
|
1494
|
+
// Manual `bgclean all` ignores the throttle and removes B.
|
|
1175
1495
|
const { pi: pi3, tools: tools3, ctx: ctx3 } = makeFakePi();
|
|
1176
1496
|
await loadExtension(pi3);
|
|
1177
1497
|
const bgclean = tools3.get("bgclean")!;
|
|
1178
1498
|
const result = await bgclean.execute(
|
|
1179
1499
|
"call-t1",
|
|
1180
|
-
{},
|
|
1500
|
+
{ all: true },
|
|
1181
1501
|
undefined,
|
|
1182
1502
|
undefined,
|
|
1183
1503
|
ctx3,
|
|
1184
1504
|
);
|
|
1185
|
-
assert.match(
|
|
1186
|
-
|
|
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");
|
|
1187
1510
|
} finally {
|
|
1188
1511
|
delete process.env.PI_BGRUN_DIR;
|
|
1512
|
+
delete process.env.PI_BGRUN_GLOBAL_AUTO_CLEAN;
|
|
1189
1513
|
rmSync(dir, { recursive: true, force: true });
|
|
1190
1514
|
}
|
|
1191
1515
|
});
|
|
1192
1516
|
|
|
1193
|
-
test("bgclean:
|
|
1517
|
+
test("bgclean: default scope is this session's logs; all: true sweeps everything", async () => {
|
|
1194
1518
|
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
1195
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";
|
|
1196
1523
|
try {
|
|
1197
|
-
const
|
|
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 });
|
|
1198
1575
|
await loadExtension(pi);
|
|
1199
|
-
|
|
1576
|
+
await fireSessionStart(); // reconstruct + session-scoped auto-sweep runs here too
|
|
1577
|
+
|
|
1200
1578
|
const bgclean = tools.get("bgclean")!;
|
|
1201
1579
|
|
|
1202
|
-
//
|
|
1203
|
-
await
|
|
1204
|
-
"call-
|
|
1205
|
-
{
|
|
1580
|
+
// Default: this session only.
|
|
1581
|
+
const scoped = await bgclean.execute(
|
|
1582
|
+
"call-c2",
|
|
1583
|
+
{ days: 7 },
|
|
1206
1584
|
undefined,
|
|
1207
1585
|
undefined,
|
|
1208
1586
|
ctx,
|
|
1209
1587
|
);
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
fs.utimesSync(oldPath, oldTime, oldTime);
|
|
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
|
+
);
|
|
1218
1595
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1596
|
+
// all: true sweeps the shared dir.
|
|
1597
|
+
const global = await bgclean.execute(
|
|
1598
|
+
"call-c3",
|
|
1599
|
+
{ days: 7, all: true },
|
|
1222
1600
|
undefined,
|
|
1223
1601
|
undefined,
|
|
1224
1602
|
ctx,
|
|
1225
1603
|
);
|
|
1226
|
-
|
|
1227
|
-
assert.
|
|
1228
|
-
assert.ok(
|
|
1229
|
-
// The recent log should still exist.
|
|
1230
|
-
const remaining = fs
|
|
1231
|
-
.readdirSync(dir)
|
|
1232
|
-
.filter((f: string) => f.endsWith(".log"));
|
|
1233
|
-
assert.equal(remaining.length, 1, "recent log kept");
|
|
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");
|
|
1234
1607
|
} finally {
|
|
1235
1608
|
delete process.env.PI_BGRUN_DIR;
|
|
1236
1609
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -1292,6 +1665,903 @@ test("bgclean: does not remove a running job's log", async () => {
|
|
|
1292
1665
|
} catch {
|
|
1293
1666
|
// already gone — fine
|
|
1294
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/);
|
|
1295
2565
|
} finally {
|
|
1296
2566
|
delete process.env.PI_BGRUN_DIR;
|
|
1297
2567
|
rmSync(dir, { recursive: true, force: true });
|