pi-background-run 0.1.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/LICENSE +21 -0
- package/README.md +81 -0
- package/extension/index.test.ts +659 -0
- package/extension/index.ts +644 -0
- package/package.json +57 -0
- package/skill/run-bg/SKILL.md +85 -0
|
@@ -0,0 +1,659 @@
|
|
|
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 { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
interface CapturedWake {
|
|
22
|
+
text: string;
|
|
23
|
+
options?: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): {
|
|
27
|
+
pi: any;
|
|
28
|
+
wakes: CapturedWake[];
|
|
29
|
+
entries: any[];
|
|
30
|
+
tools: Map<string, { execute: (...args: any[]) => Promise<any> }>;
|
|
31
|
+
ctx: any;
|
|
32
|
+
handlers: Map<string, ((...args: any[]) => Promise<any>)[]>;
|
|
33
|
+
fireSessionStart: () => Promise<void>;
|
|
34
|
+
} {
|
|
35
|
+
const wakes: CapturedWake[] = [];
|
|
36
|
+
const entries: any[] = opts.priorEntries ? [...opts.priorEntries] : [];
|
|
37
|
+
const tools = new Map<string, { execute: (...args: any[]) => Promise<any> }>();
|
|
38
|
+
const handlers = new Map<string, ((...args: any[]) => Promise<any>)[]>();
|
|
39
|
+
const idle = opts.idle ?? true;
|
|
40
|
+
const ctx = {
|
|
41
|
+
isIdle: () => idle,
|
|
42
|
+
hasUI: false,
|
|
43
|
+
ui: { notify() {}, setWidget() {}, setStatus() {} },
|
|
44
|
+
sessionManager: { getEntries: () => entries },
|
|
45
|
+
};
|
|
46
|
+
const pi = {
|
|
47
|
+
sendUserMessage(text: string, options?: Record<string, unknown>) {
|
|
48
|
+
wakes.push({ text, options });
|
|
49
|
+
},
|
|
50
|
+
appendEntry(customType: string, data?: unknown) {
|
|
51
|
+
entries.push({ type: "custom", customType, data });
|
|
52
|
+
},
|
|
53
|
+
registerEntryRenderer() {},
|
|
54
|
+
registerTool(def: any) {
|
|
55
|
+
tools.set(def.name, def);
|
|
56
|
+
},
|
|
57
|
+
on(event: string, handler: (...args: any[]) => Promise<any>) {
|
|
58
|
+
const list = handlers.get(event) ?? [];
|
|
59
|
+
list.push(handler);
|
|
60
|
+
handlers.set(event, list);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const fireSessionStart = async () => {
|
|
64
|
+
for (const h of handlers.get("session_start") ?? []) {
|
|
65
|
+
await h({ reason: "startup" }, ctx);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
return { pi, wakes, entries, tools, ctx, handlers, fireSessionStart };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function loadExtension(fakePi: any): Promise<Map<string, { execute: (...args: any[]) => Promise<any> }>> {
|
|
72
|
+
const url = pathToFileURL(join(process.cwd(), "extension/index.ts")).href;
|
|
73
|
+
const mod = await import(url);
|
|
74
|
+
mod.default(fakePi);
|
|
75
|
+
return fakePi.tools as Map<string, { execute: (...args: any[]) => Promise<any> }>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function waitForWakes(wakes: CapturedWake[], count: number, timeoutMs = 5000): Promise<void> {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
const tick = () => {
|
|
82
|
+
if (wakes.length >= count) return resolve();
|
|
83
|
+
if (Date.now() - start > timeoutMs) return reject(new Error(`timed out waiting for ${count} wakes, got ${wakes.length}`));
|
|
84
|
+
setTimeout(tick, 50);
|
|
85
|
+
};
|
|
86
|
+
tick();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("bgrun: successful command writes log + exit marker and wakes with ✅", async () => {
|
|
91
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
92
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
93
|
+
try {
|
|
94
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
95
|
+
await loadExtension(pi);
|
|
96
|
+
const bgrun = tools.get("bgrun")!;
|
|
97
|
+
|
|
98
|
+
const res = await bgrun.execute("call-1", { command: "echo hello world" }, undefined, undefined, ctx);
|
|
99
|
+
const started = res.content[0].text as string;
|
|
100
|
+
assert.match(started, /^started: /);
|
|
101
|
+
const id = (started.match(/^started: ([^\n]+)/) || [])[1];
|
|
102
|
+
assert.ok(id, "got a job id");
|
|
103
|
+
|
|
104
|
+
await waitForWakes(wakes, 1);
|
|
105
|
+
// When idle, sendUserMessage is called with no options.
|
|
106
|
+
assert.equal(wakes[0].options, undefined);
|
|
107
|
+
const wake = wakes[0].text;
|
|
108
|
+
assert.match(wake, /✅/);
|
|
109
|
+
assert.match(wake, /exit 0/);
|
|
110
|
+
assert.match(wake, /hello world/);
|
|
111
|
+
assert.match(wake, new RegExp(id));
|
|
112
|
+
|
|
113
|
+
const logPath = join(dir, `${id}.log`);
|
|
114
|
+
assert.ok(existsSync(logPath), "log file exists");
|
|
115
|
+
const log = readFileSync(logPath, "utf8");
|
|
116
|
+
assert.match(log, /hello world/);
|
|
117
|
+
assert.match(log, /__BGRUN_EXIT__=0/);
|
|
118
|
+
} finally {
|
|
119
|
+
delete process.env.PI_BGRUN_DIR;
|
|
120
|
+
rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("bgrun: failing command wakes with ❌ and the non-zero exit code", async () => {
|
|
125
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
126
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
127
|
+
try {
|
|
128
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
129
|
+
await loadExtension(pi);
|
|
130
|
+
const bgrun = tools.get("bgrun")!;
|
|
131
|
+
|
|
132
|
+
await bgrun.execute("call-2", { command: "echo failing now; exit 7" }, undefined, undefined, ctx);
|
|
133
|
+
await waitForWakes(wakes, 1);
|
|
134
|
+
const wake = wakes[0].text;
|
|
135
|
+
assert.match(wake, /❌/);
|
|
136
|
+
assert.match(wake, /exit 7/);
|
|
137
|
+
} finally {
|
|
138
|
+
delete process.env.PI_BGRUN_DIR;
|
|
139
|
+
rmSync(dir, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("bgrun: when agent is busy, wake is queued as followUp", 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({ idle: false });
|
|
148
|
+
await loadExtension(pi);
|
|
149
|
+
const bgrun = tools.get("bgrun")!;
|
|
150
|
+
|
|
151
|
+
await bgrun.execute("call-busy", { command: "echo while-busy" }, undefined, undefined, ctx);
|
|
152
|
+
await waitForWakes(wakes, 1);
|
|
153
|
+
assert.equal(wakes[0].options?.deliverAs, "followUp");
|
|
154
|
+
} finally {
|
|
155
|
+
delete process.env.PI_BGRUN_DIR;
|
|
156
|
+
rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("bgtail: returns last N lines, strips the exit marker", async () => {
|
|
161
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
162
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
163
|
+
try {
|
|
164
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
165
|
+
await loadExtension(pi);
|
|
166
|
+
const bgrun = tools.get("bgrun")!;
|
|
167
|
+
const bgtail = tools.get("bgtail")!;
|
|
168
|
+
|
|
169
|
+
const res = await bgrun.execute("call-3", { command: "printf 'line1\\nline2\\nline3\\n'" }, undefined, undefined, ctx);
|
|
170
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
171
|
+
await waitForWakes(wakes, 1);
|
|
172
|
+
|
|
173
|
+
const tail = await bgtail.execute("call-3", { id, lines: 2 }, undefined, undefined, ctx);
|
|
174
|
+
const text = tail.content[0].text as string;
|
|
175
|
+
assert.ok(!text.includes("__BGRUN_EXIT__"), "marker stripped");
|
|
176
|
+
assert.match(text, /line2\nline3$|^line3$/);
|
|
177
|
+
} finally {
|
|
178
|
+
delete process.env.PI_BGRUN_DIR;
|
|
179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("bgstatus: shows running then done with 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
|
+
const bgstatus = tools.get("bgstatus")!;
|
|
191
|
+
|
|
192
|
+
const res = await bgrun.execute("call-4", { command: "sleep 0.2; echo done" }, undefined, undefined, ctx);
|
|
193
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
194
|
+
|
|
195
|
+
// While running, status should say running.
|
|
196
|
+
const running = await bgstatus.execute("call-4", { id }, undefined, undefined, ctx);
|
|
197
|
+
assert.match(running.content[0].text as string, /running/);
|
|
198
|
+
|
|
199
|
+
await waitForWakes(wakes, 1);
|
|
200
|
+
const done = await bgstatus.execute("call-4", { id }, undefined, undefined, ctx);
|
|
201
|
+
assert.match(done.content[0].text as string, /done/);
|
|
202
|
+
assert.match(done.content[0].text as string, /exit=0/);
|
|
203
|
+
} finally {
|
|
204
|
+
delete process.env.PI_BGRUN_DIR;
|
|
205
|
+
rmSync(dir, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("bgstatus: list-all scans the jobs dir after 'restart' (no in-memory records)", async () => {
|
|
210
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
211
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
212
|
+
try {
|
|
213
|
+
const { pi, tools, ctx } = makeFakePi();
|
|
214
|
+
await loadExtension(pi);
|
|
215
|
+
const bgrun = tools.get("bgrun")!;
|
|
216
|
+
const res = await bgrun.execute("call-5", { command: "echo persisted" }, undefined, undefined, ctx);
|
|
217
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
218
|
+
|
|
219
|
+
// Wait for completion by polling the log marker.
|
|
220
|
+
const logPath = join(dir, `${id}.log`);
|
|
221
|
+
await new Promise<void>((resolve, reject) => {
|
|
222
|
+
const start = Date.now();
|
|
223
|
+
const tick = () => {
|
|
224
|
+
try {
|
|
225
|
+
if (readFileSync(logPath, "utf8").includes("__BGRUN_EXIT__=0")) return resolve();
|
|
226
|
+
} catch {}
|
|
227
|
+
if (Date.now() - start > 5000) return reject(new Error("log marker never appeared"));
|
|
228
|
+
setTimeout(tick, 50);
|
|
229
|
+
};
|
|
230
|
+
tick();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Fresh instance — no in-memory records. Directory scan should still find it.
|
|
234
|
+
const { pi: pi2, tools: tools2 } = makeFakePi();
|
|
235
|
+
await loadExtension(pi2);
|
|
236
|
+
const bgstatus2 = tools2.get("bgstatus")!;
|
|
237
|
+
const list = await bgstatus2.execute("call-5", {}, undefined, undefined, ctx);
|
|
238
|
+
assert.match(list.content[0].text as string, new RegExp(id));
|
|
239
|
+
assert.match(list.content[0].text as string, /exit=0/);
|
|
240
|
+
} finally {
|
|
241
|
+
delete process.env.PI_BGRUN_DIR;
|
|
242
|
+
rmSync(dir, { recursive: true, force: true });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("bgrun: rejects empty command", async () => {
|
|
247
|
+
const { pi, tools, ctx } = makeFakePi();
|
|
248
|
+
await loadExtension(pi);
|
|
249
|
+
const bgrun = tools.get("bgrun")!;
|
|
250
|
+
await assert.rejects(
|
|
251
|
+
() => bgrun.execute("call-6", { command: "" }, undefined, undefined, ctx),
|
|
252
|
+
/command is required/,
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── Phase 1 tests ────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
test("bgrun: appends bgrun-job entries (running then done)", async () => {
|
|
259
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
260
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
261
|
+
try {
|
|
262
|
+
const { pi, wakes, entries, tools, ctx } = makeFakePi();
|
|
263
|
+
await loadExtension(pi);
|
|
264
|
+
const bgrun = tools.get("bgrun")!;
|
|
265
|
+
|
|
266
|
+
await bgrun.execute("call-e1", { command: "echo entry-test" }, undefined, undefined, ctx);
|
|
267
|
+
// One running entry appended at start.
|
|
268
|
+
const runningEntries = entries.filter((e) => e.data?.state === "running");
|
|
269
|
+
assert.equal(runningEntries.length, 1, "running entry appended at start");
|
|
270
|
+
assert.equal(runningEntries[0].data.cmd, "echo entry-test");
|
|
271
|
+
|
|
272
|
+
await waitForWakes(wakes, 1);
|
|
273
|
+
// One done entry appended on exit.
|
|
274
|
+
const doneEntries = entries.filter((e) => e.data?.state === "done");
|
|
275
|
+
assert.equal(doneEntries.length, 1, "done entry appended on exit");
|
|
276
|
+
assert.equal(doneEntries[0].data.exitCode, 0);
|
|
277
|
+
} finally {
|
|
278
|
+
delete process.env.PI_BGRUN_DIR;
|
|
279
|
+
rmSync(dir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("session_start: reconstructs in-memory Map from bgrun-job entries", async () => {
|
|
284
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
285
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
286
|
+
try {
|
|
287
|
+
// First instance: run a job, capture its entries.
|
|
288
|
+
const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
|
|
289
|
+
await loadExtension(pi1);
|
|
290
|
+
const bgrun1 = tools1.get("bgrun")!;
|
|
291
|
+
const res = await bgrun1.execute("call-r1", { command: "echo reconstruct-me" }, undefined, undefined, ctx1);
|
|
292
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
293
|
+
await waitForWakes(wakes, 1);
|
|
294
|
+
|
|
295
|
+
// Second instance: simulate a restart. Load fresh, passing the prior entries,
|
|
296
|
+
// then fire session_start to trigger reconstruction.
|
|
297
|
+
const { pi: pi2, tools: tools2, ctx: ctx2, fireSessionStart } = makeFakePi({ priorEntries: entries });
|
|
298
|
+
await loadExtension(pi2);
|
|
299
|
+
await fireSessionStart();
|
|
300
|
+
|
|
301
|
+
// Now bgstatus should find the job in the in-memory Map (not just dir scan).
|
|
302
|
+
const bgstatus2 = tools2.get("bgstatus")!;
|
|
303
|
+
const status = await bgstatus2.execute("call-r2", { id }, undefined, undefined, ctx2);
|
|
304
|
+
const text = status.content[0].text as string;
|
|
305
|
+
assert.match(text, /done.*exit=0/);
|
|
306
|
+
// Verify it came from the in-memory Map (not "from log" marker).
|
|
307
|
+
assert.ok(!text.includes("from log"), "reconstructed from entries, not dir scan");
|
|
308
|
+
assert.ok(!text.includes("recovered from log"), "reconstructed from entries, not log recovery");
|
|
309
|
+
} finally {
|
|
310
|
+
delete process.env.PI_BGRUN_DIR;
|
|
311
|
+
rmSync(dir, { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// ── name (human-readable label) tests ───────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
test("bgrun: name flows into job id, response, entry, wake, and status", async () => {
|
|
318
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
319
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
320
|
+
try {
|
|
321
|
+
const { pi, wakes, entries, tools, ctx } = makeFakePi();
|
|
322
|
+
await loadExtension(pi);
|
|
323
|
+
const bgrun = tools.get("bgrun")!;
|
|
324
|
+
|
|
325
|
+
const res = await bgrun.execute("call-n1", { command: "echo named job", name: "unit-tests" }, undefined, undefined, ctx);
|
|
326
|
+
const text = res.content[0].text as string;
|
|
327
|
+
const id = (text.match(/^started: ([^\n]+)/) || [])[1];
|
|
328
|
+
// Slug derives from the name, not the command.
|
|
329
|
+
assert.ok(id.startsWith("unit-tests-"), `id should start with 'unit-tests-': ${id}`);
|
|
330
|
+
// Response includes the name.
|
|
331
|
+
assert.match(text, /name: unit-tests/);
|
|
332
|
+
// Details include the name.
|
|
333
|
+
assert.equal((res.details as any).name, "unit-tests");
|
|
334
|
+
|
|
335
|
+
await waitForWakes(wakes, 1);
|
|
336
|
+
const wake = wakes[0].text;
|
|
337
|
+
// Wake includes the name.
|
|
338
|
+
assert.match(wake, /"unit-tests"/);
|
|
339
|
+
|
|
340
|
+
// Persisted entries carry the name.
|
|
341
|
+
const withName = entries.filter((e) => e.data?.name === "unit-tests");
|
|
342
|
+
assert.equal(withName.length, 2, "running + done entries carry name");
|
|
343
|
+
} finally {
|
|
344
|
+
delete process.env.PI_BGRUN_DIR;
|
|
345
|
+
rmSync(dir, { recursive: true, force: true });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("bgrun: name is optional — behavior unchanged without it", async () => {
|
|
350
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
351
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
352
|
+
try {
|
|
353
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
354
|
+
await loadExtension(pi);
|
|
355
|
+
const bgrun = tools.get("bgrun")!;
|
|
356
|
+
|
|
357
|
+
const res = await bgrun.execute("call-n2", { command: "echo unnamed job" }, undefined, undefined, ctx);
|
|
358
|
+
const text = res.content[0].text as string;
|
|
359
|
+
// No 'name:' line in the response.
|
|
360
|
+
assert.ok(!/^ name:/m.test(text), "no name line when name omitted");
|
|
361
|
+
const id = (text.match(/^started: ([^\n]+)/) || [])[1];
|
|
362
|
+
assert.ok(id.startsWith("echo-unnamed-job-"), `slug falls back to command: ${id}`);
|
|
363
|
+
|
|
364
|
+
await waitForWakes(wakes, 1);
|
|
365
|
+
assert.ok(!wakes[0].text.includes('"'), "wake has no name quote when unnamed");
|
|
366
|
+
} finally {
|
|
367
|
+
delete process.env.PI_BGRUN_DIR;
|
|
368
|
+
rmSync(dir, { recursive: true, force: true });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("bgrun: blank name is ignored, over-long name is truncated", async () => {
|
|
373
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
374
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
375
|
+
try {
|
|
376
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
377
|
+
await loadExtension(pi);
|
|
378
|
+
const bgrun = tools.get("bgrun")!;
|
|
379
|
+
|
|
380
|
+
// Blank name treated as absent.
|
|
381
|
+
const res1 = await bgrun.execute("call-n3", { command: "echo blank", name: " " }, undefined, undefined, ctx);
|
|
382
|
+
assert.ok(!/^ name:/m.test(res1.content[0].text as string), "blank name ignored");
|
|
383
|
+
|
|
384
|
+
// Over-long name truncated to 80 chars.
|
|
385
|
+
const longName = "x".repeat(200);
|
|
386
|
+
const res2 = await bgrun.execute("call-n4", { command: "echo long", name: longName }, undefined, undefined, ctx);
|
|
387
|
+
const text2 = res2.content[0].text as string;
|
|
388
|
+
const nameLine = (text2.match(/^ name: (.+)$/m) || [])[1];
|
|
389
|
+
assert.equal(nameLine.length, 80, "name truncated to 80 chars");
|
|
390
|
+
|
|
391
|
+
await waitForWakes(wakes, 2);
|
|
392
|
+
} finally {
|
|
393
|
+
delete process.env.PI_BGRUN_DIR;
|
|
394
|
+
rmSync(dir, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("bgrun: name survives session_start reconstruction", async () => {
|
|
399
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
400
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
401
|
+
try {
|
|
402
|
+
// First instance: run a named job, capture entries.
|
|
403
|
+
const { pi: pi1, wakes, entries, tools: tools1, ctx: ctx1 } = makeFakePi();
|
|
404
|
+
await loadExtension(pi1);
|
|
405
|
+
const bgrun1 = tools1.get("bgrun")!;
|
|
406
|
+
const res = await bgrun1.execute("call-n5", { command: "echo named-restart", name: "rebuild" }, undefined, undefined, ctx1);
|
|
407
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
408
|
+
await waitForWakes(wakes, 1);
|
|
409
|
+
|
|
410
|
+
// Second instance: reconstruct from entries, name should be restored.
|
|
411
|
+
const { pi: pi2, tools: tools2, ctx: ctx2, fireSessionStart } = makeFakePi({ priorEntries: entries });
|
|
412
|
+
await loadExtension(pi2);
|
|
413
|
+
await fireSessionStart();
|
|
414
|
+
|
|
415
|
+
const bgstatus2 = tools2.get("bgstatus")!;
|
|
416
|
+
const status = await bgstatus2.execute("call-n6", { id }, undefined, undefined, ctx2);
|
|
417
|
+
const text = status.content[0].text as string;
|
|
418
|
+
assert.match(text, /name: rebuild/);
|
|
419
|
+
assert.ok(!text.includes("recovered from log"), "reconstructed from entries, not log");
|
|
420
|
+
} finally {
|
|
421
|
+
delete process.env.PI_BGRUN_DIR;
|
|
422
|
+
rmSync(dir, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("bgstatus: list shows name after job id", async () => {
|
|
427
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
428
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
429
|
+
try {
|
|
430
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
431
|
+
await loadExtension(pi);
|
|
432
|
+
const bgrun = tools.get("bgrun")!;
|
|
433
|
+
const bgstatus = tools.get("bgstatus")!;
|
|
434
|
+
|
|
435
|
+
await bgrun.execute("call-n7", { command: "sleep 0.1; echo listed", name: "nightly" }, undefined, undefined, ctx);
|
|
436
|
+
await waitForWakes(wakes, 1);
|
|
437
|
+
|
|
438
|
+
const list = await bgstatus.execute("call-n8", {}, undefined, undefined, ctx);
|
|
439
|
+
assert.match(list.content[0].text as string, /— nightly: done exit=0/);
|
|
440
|
+
} finally {
|
|
441
|
+
delete process.env.PI_BGRUN_DIR;
|
|
442
|
+
rmSync(dir, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("session_start: adopts running jobs from the jobs dir (other session's job) into the widget", async () => {
|
|
447
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
448
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
449
|
+
try {
|
|
450
|
+
// A log with no exit marker whose pid is alive (this test process's own pid).
|
|
451
|
+
const adoptedId = `kafka-bootstrap-${Date.now()}-${process.pid}`;
|
|
452
|
+
writeFileSync(join(dir, `${adoptedId}.log`), "job still going\n");
|
|
453
|
+
|
|
454
|
+
const { pi, tools, ctx, fireSessionStart } = makeFakePi();
|
|
455
|
+
ctx.hasUI = true;
|
|
456
|
+
const widgetCalls: (string[] | undefined)[] = [];
|
|
457
|
+
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => widgetCalls.push(lines);
|
|
458
|
+
|
|
459
|
+
await loadExtension(pi);
|
|
460
|
+
await fireSessionStart();
|
|
461
|
+
|
|
462
|
+
// Widget should now show the adopted job.
|
|
463
|
+
const shown = widgetCalls.find((l) => Array.isArray(l)) ?? [];
|
|
464
|
+
const flat = (shown as string[]).join("\n");
|
|
465
|
+
assert.match(flat, /bgrun: 1 running/);
|
|
466
|
+
assert.match(flat, new RegExp(adoptedId.slice(0, 20)));
|
|
467
|
+
assert.match(flat, /\(adopted\)/);
|
|
468
|
+
assert.match(flat, /since \d{2}:\d{2}:\d{2}/);
|
|
469
|
+
|
|
470
|
+
// bgstatus single-id should also see it as running (in-memory now).
|
|
471
|
+
const bgstatus = tools.get("bgstatus")!;
|
|
472
|
+
const res = await bgstatus.execute("call-a1", { id: adoptedId }, undefined, undefined, ctx);
|
|
473
|
+
assert.match(res.content[0].text as string, /: running/);
|
|
474
|
+
} finally {
|
|
475
|
+
delete process.env.PI_BGRUN_DIR;
|
|
476
|
+
rmSync(dir, { recursive: true, force: true });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("session_start: does NOT adopt finished or dead-pid jobs", async () => {
|
|
481
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
482
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
483
|
+
try {
|
|
484
|
+
// Finished (exit marker present).
|
|
485
|
+
writeFileSync(join(dir, `done-job-${Date.now()}-${process.pid}.log`), "out\n__BGRUN_EXIT__=0\n");
|
|
486
|
+
// No marker but pid is certainly dead (pid 1 is launchd — alive, so use a likely-dead high pid).
|
|
487
|
+
// Use pid 1-style trick instead: a dead pid we spawn and reap.
|
|
488
|
+
const { spawnSync } = await import("node:child_process");
|
|
489
|
+
const dead = spawnSync("sh", ["-c", "exit 0"]);
|
|
490
|
+
assert.equal(dead.status, 0);
|
|
491
|
+
// Write log with a pid that no longer exists: use the reaped child's pid if captured, else 999999.
|
|
492
|
+
const deadPid = dead.pid ?? 999999;
|
|
493
|
+
writeFileSync(join(dir, `dead-job-${Date.now()}-${deadPid}.log`), "partial\n");
|
|
494
|
+
|
|
495
|
+
const { pi, ctx, fireSessionStart } = makeFakePi();
|
|
496
|
+
ctx.hasUI = true;
|
|
497
|
+
let widgetShown = false;
|
|
498
|
+
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
|
|
499
|
+
if (lines) widgetShown = true;
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
await loadExtension(pi);
|
|
503
|
+
await fireSessionStart();
|
|
504
|
+
assert.equal(widgetShown, false, "no widget for finished/dead jobs");
|
|
505
|
+
} finally {
|
|
506
|
+
delete process.env.PI_BGRUN_DIR;
|
|
507
|
+
rmSync(dir, { recursive: true, force: true });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test("bgrun: job id encodes the CHILD's pid, not pi's own pid", async () => {
|
|
512
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
513
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
514
|
+
try {
|
|
515
|
+
const { pi, wakes, tools, ctx } = makeFakePi();
|
|
516
|
+
await loadExtension(pi);
|
|
517
|
+
const bgrun = tools.get("bgrun")!;
|
|
518
|
+
|
|
519
|
+
const res = await bgrun.execute("call-pid", { command: "echo pidcheck" }, undefined, undefined, ctx);
|
|
520
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
521
|
+
const idPid = Number(id.split("-").pop());
|
|
522
|
+
assert.ok(idPid > 0, `id ends with child pid: ${id}`);
|
|
523
|
+
assert.notEqual(idPid, process.pid, "id must NOT carry pi's own pid");
|
|
524
|
+
// Log file named after the id, no .tmp- leftovers.
|
|
525
|
+
assert.ok(existsSync(join(dir, `${id}.log`)), "log at final id-named path");
|
|
526
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith(".tmp-")).length, 0, "no temp log leftovers");
|
|
527
|
+
|
|
528
|
+
await waitForWakes(wakes, 1);
|
|
529
|
+
} finally {
|
|
530
|
+
delete process.env.PI_BGRUN_DIR;
|
|
531
|
+
rmSync(dir, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("bgclean: removes a FINISHED job's old log even when its id-pid is alive", async () => {
|
|
536
|
+
// Regression: exit marker must win over pid liveness. Old code checked
|
|
537
|
+
// pid first, so any log whose id-pid happened to be a live process (e.g.
|
|
538
|
+
// pi's own pid from the old id bug, or pid reuse) was kept forever.
|
|
539
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
540
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
541
|
+
try {
|
|
542
|
+
// Old finished log whose id-pid is THIS process (alive!) — must still be removed.
|
|
543
|
+
const oldPath = join(dir, `stale-job-1000000000-${process.pid}.log`);
|
|
544
|
+
writeFileSync(oldPath, "stale\n__BGRUN_EXIT__=2\n");
|
|
545
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
546
|
+
const fs = await import("node:fs");
|
|
547
|
+
fs.utimesSync(oldPath, oldTime, oldTime);
|
|
548
|
+
|
|
549
|
+
const { pi, tools, ctx } = makeFakePi();
|
|
550
|
+
await loadExtension(pi);
|
|
551
|
+
const bgclean = tools.get("bgclean")!;
|
|
552
|
+
|
|
553
|
+
const result = await bgclean.execute("call-stale", { days: 7 }, undefined, undefined, ctx);
|
|
554
|
+
assert.match(result.content[0].text as string, /removed 1/);
|
|
555
|
+
assert.ok(!existsSync(oldPath), "finished job's log removed despite live id-pid");
|
|
556
|
+
} finally {
|
|
557
|
+
delete process.env.PI_BGRUN_DIR;
|
|
558
|
+
rmSync(dir, { recursive: true, force: true });
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("session_start adoption: skips finished jobs even with a live id-pid", async () => {
|
|
563
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
564
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
565
|
+
try {
|
|
566
|
+
// Finished job (exit marker) whose id-pid is this process (alive).
|
|
567
|
+
writeFileSync(join(dir, `done-job-${Date.now()}-${process.pid}.log`), "out\n__BGRUN_EXIT__=0\n");
|
|
568
|
+
const { pi, ctx, fireSessionStart } = makeFakePi();
|
|
569
|
+
ctx.hasUI = true;
|
|
570
|
+
let widgetShown = false;
|
|
571
|
+
ctx.ui.setWidget = (_ns: string, lines: string[] | undefined) => {
|
|
572
|
+
if (lines) widgetShown = true;
|
|
573
|
+
};
|
|
574
|
+
await loadExtension(pi);
|
|
575
|
+
await fireSessionStart();
|
|
576
|
+
assert.equal(widgetShown, false, "finished job not adopted even though id-pid is alive");
|
|
577
|
+
} finally {
|
|
578
|
+
delete process.env.PI_BGRUN_DIR;
|
|
579
|
+
rmSync(dir, { recursive: true, force: true });
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("bgclean: removes old logs, keeps recent ones", async () => {
|
|
584
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
585
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
586
|
+
try {
|
|
587
|
+
const { pi, tools, ctx } = makeFakePi();
|
|
588
|
+
await loadExtension(pi);
|
|
589
|
+
const bgrun = tools.get("bgrun")!;
|
|
590
|
+
const bgclean = tools.get("bgclean")!;
|
|
591
|
+
|
|
592
|
+
// Run a real job (recent log — should be kept).
|
|
593
|
+
await bgrun.execute("call-c1", { command: "echo recent" }, undefined, undefined, ctx);
|
|
594
|
+
await new Promise((r) => setTimeout(r, 200)); // let it finish
|
|
595
|
+
|
|
596
|
+
// Write an old log file (backdated mtime).
|
|
597
|
+
const oldPath = join(dir, "old-job-1000000000-99999.log");
|
|
598
|
+
const fs = await import("node:fs");
|
|
599
|
+
fs.writeFileSync(oldPath, "old output\n__BGRUN_EXIT__=0\n");
|
|
600
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
|
|
601
|
+
fs.utimesSync(oldPath, oldTime, oldTime);
|
|
602
|
+
|
|
603
|
+
const result = await bgclean.execute("call-c2", { days: 7 }, undefined, undefined, ctx);
|
|
604
|
+
const text = result.content[0].text as string;
|
|
605
|
+
assert.match(text, /removed 1/);
|
|
606
|
+
assert.ok(!fs.existsSync(oldPath), "old log removed");
|
|
607
|
+
// The recent log should still exist.
|
|
608
|
+
const remaining = fs.readdirSync(dir).filter((f: string) => f.endsWith(".log"));
|
|
609
|
+
assert.equal(remaining.length, 1, "recent log kept");
|
|
610
|
+
} finally {
|
|
611
|
+
delete process.env.PI_BGRUN_DIR;
|
|
612
|
+
rmSync(dir, { recursive: true, force: true });
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("bgclean: rejects negative days", async () => {
|
|
617
|
+
const { pi, tools, ctx } = makeFakePi();
|
|
618
|
+
await loadExtension(pi);
|
|
619
|
+
const bgclean = tools.get("bgclean")!;
|
|
620
|
+
await assert.rejects(
|
|
621
|
+
() => bgclean.execute("call-c3", { days: -1 }, undefined, undefined, ctx),
|
|
622
|
+
/non-negative/,
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("bgclean: does not remove a running job's log", async () => {
|
|
627
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-bgrun-test-"));
|
|
628
|
+
process.env.PI_BGRUN_DIR = dir;
|
|
629
|
+
try {
|
|
630
|
+
const { pi, tools, ctx } = makeFakePi();
|
|
631
|
+
await loadExtension(pi);
|
|
632
|
+
const bgrun = tools.get("bgrun")!;
|
|
633
|
+
const bgclean = tools.get("bgclean")!;
|
|
634
|
+
|
|
635
|
+
// Start a long-running job (10s) so it's still running when we clean.
|
|
636
|
+
const res = await bgrun.execute("call-c4", { command: "sleep 10" }, undefined, undefined, ctx);
|
|
637
|
+
const id = (res.content[0].text as string).match(/^started: ([^\n]+)/)![1];
|
|
638
|
+
const logPath = join(dir, `${id}.log`);
|
|
639
|
+
|
|
640
|
+
// Backdate the log's mtime to make it look old — but the job is still running
|
|
641
|
+
// (pid is in the in-memory Map), so bgclean should skip it.
|
|
642
|
+
const fs = await import("node:fs");
|
|
643
|
+
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
644
|
+
// Wait a moment for the log file to exist, then backdate.
|
|
645
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
646
|
+
fs.utimesSync(logPath, oldTime, oldTime);
|
|
647
|
+
|
|
648
|
+
const result = await bgclean.execute("call-c5", { days: 7 }, undefined, undefined, ctx);
|
|
649
|
+
const text = result.content[0].text as string;
|
|
650
|
+
assert.match(text, /skipped 1 running/);
|
|
651
|
+
assert.ok(fs.existsSync(logPath), "running job's log not removed");
|
|
652
|
+
|
|
653
|
+
// Kill the orphaned sleep so it doesn't linger.
|
|
654
|
+
try { process.kill((res.details as any).pid); } catch {}
|
|
655
|
+
} finally {
|
|
656
|
+
delete process.env.PI_BGRUN_DIR;
|
|
657
|
+
rmSync(dir, { recursive: true, force: true });
|
|
658
|
+
}
|
|
659
|
+
});
|