@andares/pi-toolkits 0.11.2 → 0.12.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 +76 -1
- package/README.zh-CN.md +45 -1
- package/package.json +1 -1
- package/src/features/compact/compact.test.ts +60 -5
- package/src/features/compact/index.ts +9 -8
- package/src/features/favorites/constants.ts +2 -1
- package/src/features/favorites/favorites.test.ts +36 -0
- package/src/features/favorites/patch-cycle.ts +3 -3
- package/src/features/favorites/patch-selector.ts +1 -1
- package/src/features/quit-guard/constants.ts +60 -0
- package/src/features/quit-guard/guard.ts +214 -0
- package/src/features/quit-guard/index.ts +292 -0
- package/src/features/quit-guard/quit-guard.test.ts +736 -0
- package/src/features/quit-guard/report.ts +212 -0
- package/src/features/quit-guard/store.ts +144 -0
- package/src/features/review/index.ts +11 -10
- package/src/features/review/prompt.ts +20 -15
- package/src/features/review/review.test.ts +9 -9
- package/src/index.ts +3 -0
- package/src/lib/approval-log.ts +8 -1
- package/src/lib/model-picker.test.ts +6 -4
- package/src/lib/model-picker.ts +7 -5
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* quit-guard feature — watchdog state machine, settings and report tests.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is deterministic: the guard takes an injected clock, timer
|
|
5
|
+
* scheduler and one-turn deferral, and the store/report tests use temp
|
|
6
|
+
* directories (never the real agent dir).
|
|
7
|
+
*
|
|
8
|
+
* Coverage map:
|
|
9
|
+
* - arming on both teardown markers, idempotence, re-arm after disarm;
|
|
10
|
+
* - disarming on both restart markers (TUI mode switch, Ctrl+Z resume) and
|
|
11
|
+
* on an explicit `resume()`, including the "expired timer loses to
|
|
12
|
+
* SIGCONT" race;
|
|
13
|
+
* - `enabled: false` is inert in both directions;
|
|
14
|
+
* - Buffer chunks are understood;
|
|
15
|
+
* - timeout normalization/clamping and settings round-trips;
|
|
16
|
+
* - the hang report shape, its write path, its failure paths, redaction and
|
|
17
|
+
* file modes;
|
|
18
|
+
* - the `/quit-guard` command surface (status/on/off/timeout/usage) and the
|
|
19
|
+
* one-wrapper-per-process invariant.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
statSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
} from "node:fs";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
32
|
+
import type {
|
|
33
|
+
ExtensionAPI,
|
|
34
|
+
ExtensionCommandContext,
|
|
35
|
+
} from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import {
|
|
37
|
+
DEFAULT_TIMEOUT_MS,
|
|
38
|
+
MAX_TIMEOUT_MS,
|
|
39
|
+
MIN_TIMEOUT_MS,
|
|
40
|
+
RESTART_MARKERS,
|
|
41
|
+
TEARDOWN_MARKERS,
|
|
42
|
+
} from "./constants.js";
|
|
43
|
+
import { QuitGuard, findMarker, type GuardFireInfo } from "./guard.js";
|
|
44
|
+
import { registerQuitGuard } from "./index.js";
|
|
45
|
+
import {
|
|
46
|
+
collectHangReport,
|
|
47
|
+
formatHangReportSummary,
|
|
48
|
+
redactArgv,
|
|
49
|
+
writeHangReport,
|
|
50
|
+
} from "./report.js";
|
|
51
|
+
import {
|
|
52
|
+
DEFAULT_CONFIG,
|
|
53
|
+
QuitGuardStore,
|
|
54
|
+
normalizeConfig,
|
|
55
|
+
normalizeTimeoutMs,
|
|
56
|
+
} from "./store.js";
|
|
57
|
+
|
|
58
|
+
/** Minimal controllable scheduler: nothing runs until `fire()`. */
|
|
59
|
+
function fakeTimers() {
|
|
60
|
+
const callbacks = new Map<number, () => void>();
|
|
61
|
+
let nextId = 0;
|
|
62
|
+
return {
|
|
63
|
+
schedule: (run: () => void): number => {
|
|
64
|
+
nextId += 1;
|
|
65
|
+
const id = nextId;
|
|
66
|
+
callbacks.set(id, run);
|
|
67
|
+
return id;
|
|
68
|
+
},
|
|
69
|
+
cancel: (handle: unknown): void => {
|
|
70
|
+
callbacks.delete(handle as number);
|
|
71
|
+
},
|
|
72
|
+
/** Run every currently registered timer once. */
|
|
73
|
+
fire(): void {
|
|
74
|
+
const pending = [...callbacks.values()];
|
|
75
|
+
callbacks.clear();
|
|
76
|
+
for (const run of pending) run();
|
|
77
|
+
},
|
|
78
|
+
pending(): number {
|
|
79
|
+
return callbacks.size;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Collects one-turn deferrals so a resume can be simulated in between. */
|
|
85
|
+
function fakeDefer() {
|
|
86
|
+
const queue: Array<() => void> = [];
|
|
87
|
+
return {
|
|
88
|
+
defer: (run: () => void): void => {
|
|
89
|
+
queue.push(run);
|
|
90
|
+
},
|
|
91
|
+
flush(): void {
|
|
92
|
+
const pending = queue.splice(0);
|
|
93
|
+
for (const run of pending) run();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeGuard(options: {
|
|
99
|
+
timeoutMs?: number;
|
|
100
|
+
now?: () => number;
|
|
101
|
+
fired?: GuardFireInfo[];
|
|
102
|
+
isTuiActive?: () => boolean;
|
|
103
|
+
events?: string[];
|
|
104
|
+
}) {
|
|
105
|
+
const timers = fakeTimers();
|
|
106
|
+
const deferred = fakeDefer();
|
|
107
|
+
let now = 0;
|
|
108
|
+
const fired = options.fired ?? [];
|
|
109
|
+
const guard = new QuitGuard(options.timeoutMs ?? 1_000, {
|
|
110
|
+
now: options.now ?? (() => now),
|
|
111
|
+
schedule: timers.schedule,
|
|
112
|
+
cancel: timers.cancel,
|
|
113
|
+
defer: deferred.defer,
|
|
114
|
+
onFire: (info) => fired.push(info),
|
|
115
|
+
isTuiActive: options.isTuiActive,
|
|
116
|
+
onEvent: options.events
|
|
117
|
+
? (event, detail) => options.events?.push(`${event}:${detail}`)
|
|
118
|
+
: undefined,
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
guard,
|
|
122
|
+
timers,
|
|
123
|
+
deferred,
|
|
124
|
+
fired,
|
|
125
|
+
advance: (ms: number) => {
|
|
126
|
+
now += ms;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("terminal markers", () => {
|
|
132
|
+
it("uses the exact sequences pi's TUI writes", () => {
|
|
133
|
+
expect(TEARDOWN_MARKERS).toEqual(["\x1b[?2004l", "\x1b[?1049l"]);
|
|
134
|
+
expect(RESTART_MARKERS).toEqual(["\x1b[?2004h", "\x1b[?1049h"]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("findMarker", () => {
|
|
139
|
+
it("finds a marker in a string chunk", () => {
|
|
140
|
+
expect(findMarker("hello\x1b[?2004l", TEARDOWN_MARKERS)).toBe(
|
|
141
|
+
"\x1b[?2004l",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("finds a marker in a Buffer chunk", () => {
|
|
146
|
+
expect(findMarker(Buffer.from("x\x1b[?1049ly"), TEARDOWN_MARKERS)).toBe(
|
|
147
|
+
"\x1b[?1049l",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns undefined for unrelated and empty chunks", () => {
|
|
152
|
+
expect(findMarker("rendering…", TEARDOWN_MARKERS)).toBeUndefined();
|
|
153
|
+
expect(findMarker("", TEARDOWN_MARKERS)).toBeUndefined();
|
|
154
|
+
expect(findMarker(new Uint8Array(0), TEARDOWN_MARKERS)).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("QuitGuard", () => {
|
|
159
|
+
it("arms on the bracketed-paste teardown and fires with the elapsed time", () => {
|
|
160
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({});
|
|
161
|
+
guard.observeOutput("\x1b[?2004l");
|
|
162
|
+
expect(guard.isArmed()).toBe(true);
|
|
163
|
+
advance(1_500);
|
|
164
|
+
timers.fire();
|
|
165
|
+
deferred.flush();
|
|
166
|
+
expect(fired).toEqual([
|
|
167
|
+
{ startedAt: 0, elapsedMs: 1_500, marker: "\x1b[?2004l" },
|
|
168
|
+
]);
|
|
169
|
+
expect(guard.isArmed()).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("arms on the alternate-screen teardown as well", () => {
|
|
173
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({});
|
|
174
|
+
guard.observeOutput("\x1b[?1049l");
|
|
175
|
+
advance(2_000);
|
|
176
|
+
timers.fire();
|
|
177
|
+
deferred.flush();
|
|
178
|
+
expect(fired).toHaveLength(1);
|
|
179
|
+
expect(fired[0].marker).toBe("\x1b[?1049l");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("arms only once when a teardown writes both markers", () => {
|
|
183
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({});
|
|
184
|
+
guard.observeOutput("\x1b[?2004l");
|
|
185
|
+
guard.observeOutput("\x1b[?1049l");
|
|
186
|
+
expect(timers.pending()).toBe(1);
|
|
187
|
+
advance(5_000);
|
|
188
|
+
timers.fire();
|
|
189
|
+
deferred.flush();
|
|
190
|
+
expect(fired).toHaveLength(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("disarms when the TUI restarts (mode switch) before the deadline", () => {
|
|
194
|
+
const { guard, timers, fired } = makeGuard({});
|
|
195
|
+
guard.observeOutput("\x1b[?2004l");
|
|
196
|
+
guard.observeOutput("\x1b[?2004h");
|
|
197
|
+
expect(guard.isArmed()).toBe(false);
|
|
198
|
+
expect(timers.pending()).toBe(0);
|
|
199
|
+
expect(fired).toHaveLength(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("disarms on the alternate-screen restart marker too", () => {
|
|
203
|
+
const { guard, timers, fired } = makeGuard({});
|
|
204
|
+
guard.observeOutput("\x1b[?1049l");
|
|
205
|
+
guard.observeOutput("\x1b[?1049h");
|
|
206
|
+
expect(guard.isArmed()).toBe(false);
|
|
207
|
+
expect(timers.pending()).toBe(0);
|
|
208
|
+
expect(fired).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("loses an already-expired timer to a resume (SIGCONT wins the race)", () => {
|
|
212
|
+
const { guard, timers, deferred, fired } = makeGuard({});
|
|
213
|
+
guard.observeOutput("\x1b[?2004l");
|
|
214
|
+
timers.fire(); // timer expired while the process was suspended
|
|
215
|
+
guard.resume(); // SIGCONT handler ran first
|
|
216
|
+
deferred.flush();
|
|
217
|
+
expect(fired).toHaveLength(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("re-arms after a disarm and reports the newest arm", () => {
|
|
221
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({});
|
|
222
|
+
guard.observeOutput("\x1b[?2004l");
|
|
223
|
+
guard.observeOutput("\x1b[?2004h"); // mode switch: disarm
|
|
224
|
+
guard.observeOutput("\x1b[?1049l"); // real quit: arm again
|
|
225
|
+
advance(2_500);
|
|
226
|
+
// Firing here runs the current (second) arm's timer, not the cancelled one.
|
|
227
|
+
timers.fire();
|
|
228
|
+
deferred.flush();
|
|
229
|
+
expect(fired).toHaveLength(1);
|
|
230
|
+
expect(fired[0].elapsedMs).toBe(2_500);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("does nothing when disabled", () => {
|
|
234
|
+
const { guard, timers, fired } = makeGuard({});
|
|
235
|
+
guard.setEnabled(false);
|
|
236
|
+
guard.observeOutput("\x1b[?2004l");
|
|
237
|
+
expect(guard.isArmed()).toBe(false);
|
|
238
|
+
expect(timers.pending()).toBe(0);
|
|
239
|
+
expect(fired).toHaveLength(0);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("disarms an armed guard when it is disabled", () => {
|
|
243
|
+
const { guard, timers, fired } = makeGuard({});
|
|
244
|
+
guard.observeOutput("\x1b[?2004l");
|
|
245
|
+
guard.setEnabled(false);
|
|
246
|
+
expect(guard.isArmed()).toBe(false);
|
|
247
|
+
expect(timers.pending()).toBe(0);
|
|
248
|
+
expect(fired).toHaveLength(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("understands Buffer chunks", () => {
|
|
252
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({});
|
|
253
|
+
guard.observeOutput(Buffer.from("\x1b[?2004l"));
|
|
254
|
+
advance(1_000);
|
|
255
|
+
timers.fire();
|
|
256
|
+
deferred.flush();
|
|
257
|
+
expect(fired).toHaveLength(1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("ignores ordinary render output", () => {
|
|
261
|
+
const { guard, timers, fired } = makeGuard({});
|
|
262
|
+
guard.observeOutput("the quick brown fox");
|
|
263
|
+
guard.observeOutput("x\x1b[?25ly");
|
|
264
|
+
expect(guard.isArmed()).toBe(false);
|
|
265
|
+
expect(timers.pending()).toBe(0);
|
|
266
|
+
expect(fired).toHaveLength(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("never fires while a TUI is still active (stray marker in rendered content)", () => {
|
|
270
|
+
const events: string[] = [];
|
|
271
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({
|
|
272
|
+
isTuiActive: () => true,
|
|
273
|
+
events,
|
|
274
|
+
});
|
|
275
|
+
guard.observeOutput("\x1b[?2004l");
|
|
276
|
+
advance(5_000);
|
|
277
|
+
timers.fire();
|
|
278
|
+
deferred.flush();
|
|
279
|
+
expect(fired).toHaveLength(0);
|
|
280
|
+
expect(guard.isArmed()).toBe(false);
|
|
281
|
+
expect(events).toContain("disarm:tui-still-active");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("still fires when the liveness probe itself throws", () => {
|
|
285
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({
|
|
286
|
+
isTuiActive: () => {
|
|
287
|
+
throw new Error("stdin is gone");
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
guard.observeOutput("\x1b[?2004l");
|
|
291
|
+
advance(1_200);
|
|
292
|
+
timers.fire();
|
|
293
|
+
deferred.flush();
|
|
294
|
+
expect(fired).toHaveLength(1);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("fires when the TUI has left raw mode", () => {
|
|
298
|
+
const { guard, timers, deferred, fired, advance } = makeGuard({
|
|
299
|
+
isTuiActive: () => false,
|
|
300
|
+
});
|
|
301
|
+
guard.observeOutput("\x1b[?2004l");
|
|
302
|
+
advance(1_200);
|
|
303
|
+
timers.fire();
|
|
304
|
+
deferred.flush();
|
|
305
|
+
expect(fired).toHaveLength(1);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("reports arm/disarm/fire transitions to the debug tap", () => {
|
|
309
|
+
const events: string[] = [];
|
|
310
|
+
const timers = fakeTimers();
|
|
311
|
+
const deferred = fakeDefer();
|
|
312
|
+
let now = 0;
|
|
313
|
+
const guard = new QuitGuard(1_000, {
|
|
314
|
+
now: () => now,
|
|
315
|
+
schedule: timers.schedule,
|
|
316
|
+
cancel: timers.cancel,
|
|
317
|
+
defer: deferred.defer,
|
|
318
|
+
onFire: () => {},
|
|
319
|
+
onEvent: (event, detail) => events.push(`${event}:${detail}`),
|
|
320
|
+
});
|
|
321
|
+
guard.observeOutput("\x1b[?2004l");
|
|
322
|
+
guard.observeOutput("\x1b[?2004h");
|
|
323
|
+
guard.observeOutput("\x1b[?1049l");
|
|
324
|
+
now = 1_000;
|
|
325
|
+
timers.fire();
|
|
326
|
+
deferred.flush();
|
|
327
|
+
expect(events).toEqual([
|
|
328
|
+
"arm:\x1b[?2004l timeout=1000ms",
|
|
329
|
+
"disarm:\x1b[?2004l",
|
|
330
|
+
"arm:\x1b[?1049l timeout=1000ms",
|
|
331
|
+
"fire:elapsed=1000ms",
|
|
332
|
+
]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("keeps the configured timeout", () => {
|
|
336
|
+
const { guard } = makeGuard({ timeoutMs: 1_000 });
|
|
337
|
+
expect(guard.getTimeoutMs()).toBe(1_000);
|
|
338
|
+
guard.setTimeoutMs(7_000);
|
|
339
|
+
expect(guard.getTimeoutMs()).toBe(7_000);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("normalizeTimeoutMs", () => {
|
|
344
|
+
it("clamps below the floor", () => {
|
|
345
|
+
expect(normalizeTimeoutMs(500)).toBe(MIN_TIMEOUT_MS);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("clamps above the ceiling", () => {
|
|
349
|
+
expect(normalizeTimeoutMs(99_999)).toBe(MAX_TIMEOUT_MS);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("rounds numeric input", () => {
|
|
353
|
+
expect(normalizeTimeoutMs(4_321.6)).toBe(4_322);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("falls back for non-numbers", () => {
|
|
357
|
+
expect(normalizeTimeoutMs("abc")).toBe(DEFAULT_TIMEOUT_MS);
|
|
358
|
+
expect(normalizeTimeoutMs(Number.NaN)).toBe(DEFAULT_TIMEOUT_MS);
|
|
359
|
+
expect(normalizeTimeoutMs(undefined)).toBe(DEFAULT_TIMEOUT_MS);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("normalizeConfig", () => {
|
|
364
|
+
it("returns defaults for a non-object", () => {
|
|
365
|
+
expect(normalizeConfig(null)).toEqual(DEFAULT_CONFIG);
|
|
366
|
+
expect(normalizeConfig("nope")).toEqual(DEFAULT_CONFIG);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("keeps known fields and defaults the rest", () => {
|
|
370
|
+
expect(normalizeConfig({ enabled: false })).toEqual({
|
|
371
|
+
...DEFAULT_CONFIG,
|
|
372
|
+
enabled: false,
|
|
373
|
+
});
|
|
374
|
+
expect(normalizeConfig({ timeoutMs: 3_000, report: false })).toEqual({
|
|
375
|
+
enabled: true,
|
|
376
|
+
timeoutMs: 3_000,
|
|
377
|
+
report: false,
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("clamps a stored timeout instead of trusting it", () => {
|
|
382
|
+
expect(normalizeConfig({ timeoutMs: 1 }).timeoutMs).toBe(MIN_TIMEOUT_MS);
|
|
383
|
+
expect(normalizeConfig({ timeoutMs: 10 ** 9 }).timeoutMs).toBe(
|
|
384
|
+
MAX_TIMEOUT_MS,
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("QuitGuardStore", () => {
|
|
390
|
+
function tempFile(): { dir: string; file: string } {
|
|
391
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-quit-guard-"));
|
|
392
|
+
return { dir, file: join(dir, "pi-toolkits-quit-guard.json") };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
it("defaults when the file does not exist", () => {
|
|
396
|
+
const { dir, file } = tempFile();
|
|
397
|
+
try {
|
|
398
|
+
const store = new QuitGuardStore(file);
|
|
399
|
+
expect(store.get()).toEqual(DEFAULT_CONFIG);
|
|
400
|
+
} finally {
|
|
401
|
+
rmSync(dir, { recursive: true, force: true });
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("falls back to defaults and warns on a corrupt file", () => {
|
|
406
|
+
const { dir, file } = tempFile();
|
|
407
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
408
|
+
try {
|
|
409
|
+
writeFileSync(file, "{ not json", "utf8");
|
|
410
|
+
const store = new QuitGuardStore(file);
|
|
411
|
+
expect(store.get()).toEqual(DEFAULT_CONFIG);
|
|
412
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
413
|
+
} finally {
|
|
414
|
+
warn.mockRestore();
|
|
415
|
+
rmSync(dir, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("persists an update atomically and reads it back", () => {
|
|
420
|
+
const { dir, file } = tempFile();
|
|
421
|
+
try {
|
|
422
|
+
const store = new QuitGuardStore(file);
|
|
423
|
+
expect(store.update({ enabled: false, timeoutMs: 3_000 }).enabled).toBe(
|
|
424
|
+
false,
|
|
425
|
+
);
|
|
426
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
427
|
+
expect(parsed).toEqual({
|
|
428
|
+
enabled: false,
|
|
429
|
+
timeoutMs: 3_000,
|
|
430
|
+
report: true,
|
|
431
|
+
});
|
|
432
|
+
expect(new QuitGuardStore(file).get()).toEqual({
|
|
433
|
+
enabled: false,
|
|
434
|
+
timeoutMs: 3_000,
|
|
435
|
+
report: true,
|
|
436
|
+
});
|
|
437
|
+
} finally {
|
|
438
|
+
rmSync(dir, { recursive: true, force: true });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("normalizes a timeout given to the command", () => {
|
|
443
|
+
const { dir, file } = tempFile();
|
|
444
|
+
try {
|
|
445
|
+
const store = new QuitGuardStore(file);
|
|
446
|
+
expect(store.update({ timeoutMs: 100 }).timeoutMs).toBe(MIN_TIMEOUT_MS);
|
|
447
|
+
} finally {
|
|
448
|
+
rmSync(dir, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe("hang report", () => {
|
|
454
|
+
it("collects timing, process metadata and live handles", () => {
|
|
455
|
+
const report = collectHangReport(
|
|
456
|
+
{ timeoutMs: 10_000, elapsedMs: 12_345, marker: "\x1b[?1049l" },
|
|
457
|
+
{
|
|
458
|
+
now: () => new Date("2026-11-05T06:00:00.000Z"),
|
|
459
|
+
pid: 4242,
|
|
460
|
+
cwd: "/tmp/project",
|
|
461
|
+
argv: ["pi", "--no-session"],
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
expect(report.version).toBe(1);
|
|
465
|
+
expect(report.kind).toBe("quit-guard");
|
|
466
|
+
expect(report.timestamp).toBe("2026-11-05T06:00:00.000Z");
|
|
467
|
+
expect(report.pid).toBe(4242);
|
|
468
|
+
expect(report.timeoutMs).toBe(10_000);
|
|
469
|
+
expect(report.elapsedMs).toBe(12_345);
|
|
470
|
+
expect(report.marker).toBe("\x1b[?1049l");
|
|
471
|
+
expect(report.cwd).toBe("/tmp/project");
|
|
472
|
+
expect(report.argv).toEqual(["pi", "--no-session"]);
|
|
473
|
+
expect(Array.isArray(report.activeHandles)).toBe(true);
|
|
474
|
+
expect(Array.isArray(report.activeRequests)).toBe(true);
|
|
475
|
+
expect(Array.isArray(report.libuv)).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("writes a JSON artifact and returns its path", () => {
|
|
479
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-quit-guard-report-"));
|
|
480
|
+
try {
|
|
481
|
+
const report = collectHangReport(
|
|
482
|
+
{ timeoutMs: 10_000, elapsedMs: 11_000, marker: "\x1b[?2004l" },
|
|
483
|
+
{ now: () => new Date("2026-11-05T06:00:00.000Z"), pid: 7 },
|
|
484
|
+
);
|
|
485
|
+
const file = writeHangReport(dir, report);
|
|
486
|
+
expect(file).toBeDefined();
|
|
487
|
+
expect(file ?? "").toContain(
|
|
488
|
+
"quit-hang-2026-11-05T06-00-00-000Z-7.json",
|
|
489
|
+
);
|
|
490
|
+
expect(JSON.parse(readFileSync(file ?? "", "utf8"))).toMatchObject({
|
|
491
|
+
kind: "quit-guard",
|
|
492
|
+
pid: 7,
|
|
493
|
+
elapsedMs: 11_000,
|
|
494
|
+
});
|
|
495
|
+
} finally {
|
|
496
|
+
rmSync(dir, { recursive: true, force: true });
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("returns undefined when the report cannot be written", () => {
|
|
501
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-quit-guard-report-"));
|
|
502
|
+
// A file where a directory is expected: mkdir fails, write must degrade.
|
|
503
|
+
const notADirectory = join(dir, "occupied");
|
|
504
|
+
writeFileSync(notADirectory, "", "utf8");
|
|
505
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
506
|
+
try {
|
|
507
|
+
const report = collectHangReport(
|
|
508
|
+
{ timeoutMs: 10_000, elapsedMs: 10_000, marker: "\x1b[?2004l" },
|
|
509
|
+
{ pid: 8 },
|
|
510
|
+
);
|
|
511
|
+
expect(writeHangReport(notADirectory, report)).toBeUndefined();
|
|
512
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
513
|
+
} finally {
|
|
514
|
+
warn.mockRestore();
|
|
515
|
+
rmSync(dir, { recursive: true, force: true });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it("survives a working directory that no longer exists", () => {
|
|
520
|
+
// `process.cwd()` throws ENOENT once the directory is removed; the report
|
|
521
|
+
// is written on the exit path, so that must not abort it.
|
|
522
|
+
const original = process.cwd();
|
|
523
|
+
const doomed = mkdtempSync(join(tmpdir(), "pi-quit-guard-cwd-"));
|
|
524
|
+
try {
|
|
525
|
+
process.chdir(doomed);
|
|
526
|
+
rmSync(doomed, { recursive: true, force: true });
|
|
527
|
+
const report = collectHangReport(
|
|
528
|
+
{ timeoutMs: 10_000, elapsedMs: 10_000, marker: "\x1b[?2004l" },
|
|
529
|
+
{ pid: 11 },
|
|
530
|
+
);
|
|
531
|
+
expect(report.cwd).toContain("working directory unavailable");
|
|
532
|
+
expect(report.pid).toBe(11);
|
|
533
|
+
expect(formatHangReportSummary(report)).toContain("cwd unavailable");
|
|
534
|
+
} finally {
|
|
535
|
+
process.chdir(original);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("redacts credentials in the recorded arguments", () => {
|
|
540
|
+
expect(
|
|
541
|
+
redactArgv([
|
|
542
|
+
"pi",
|
|
543
|
+
"--api-key",
|
|
544
|
+
"opaque-token-value",
|
|
545
|
+
"--token=another-secret",
|
|
546
|
+
"--trace",
|
|
547
|
+
"sk-abcdefghijklmnopqrstuv",
|
|
548
|
+
]),
|
|
549
|
+
).toEqual([
|
|
550
|
+
"pi",
|
|
551
|
+
"--api-key",
|
|
552
|
+
"[redacted]",
|
|
553
|
+
"--token=[redacted]",
|
|
554
|
+
"--trace",
|
|
555
|
+
"[redacted]",
|
|
556
|
+
]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("leaves an ordinary argument list intact", () => {
|
|
560
|
+
expect(redactArgv(["pi", "--no-session", "./src/index.ts"])).toEqual([
|
|
561
|
+
"pi",
|
|
562
|
+
"--no-session",
|
|
563
|
+
"./src/index.ts",
|
|
564
|
+
]);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it.skipIf(process.platform === "win32")(
|
|
568
|
+
"writes the report owner-only (0600 in an 0700 directory)",
|
|
569
|
+
() => {
|
|
570
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-quit-guard-mode-"));
|
|
571
|
+
const reports = join(dir, "quit-guard");
|
|
572
|
+
try {
|
|
573
|
+
const report = collectHangReport(
|
|
574
|
+
{ timeoutMs: 10_000, elapsedMs: 10_000, marker: "\x1b[?2004l" },
|
|
575
|
+
{ pid: 12 },
|
|
576
|
+
);
|
|
577
|
+
const file = writeHangReport(reports, report);
|
|
578
|
+
expect(file).toBeDefined();
|
|
579
|
+
expect(statSync(reports).mode & 0o777).toBe(0o700);
|
|
580
|
+
expect(statSync(file ?? "").mode & 0o777).toBe(0o600);
|
|
581
|
+
} finally {
|
|
582
|
+
rmSync(dir, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
it("summarizes what is left when no handle is reported", () => {
|
|
588
|
+
const report = collectHangReport(
|
|
589
|
+
{ timeoutMs: 2_000, elapsedMs: 2_000, marker: "\x1b[?2004l" },
|
|
590
|
+
{ pid: 13 },
|
|
591
|
+
);
|
|
592
|
+
expect(formatHangReportSummary({ ...report, activeHandles: [] })).toContain(
|
|
593
|
+
"handles: none",
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("summarizes the timeout, the time spent and the live handles", () => {
|
|
598
|
+
const report = collectHangReport(
|
|
599
|
+
{ timeoutMs: 10_000, elapsedMs: 13_000, marker: "\x1b[?2004l" },
|
|
600
|
+
{ pid: 9 },
|
|
601
|
+
);
|
|
602
|
+
const summary = formatHangReportSummary({
|
|
603
|
+
...report,
|
|
604
|
+
activeHandles: ["Socket", "Timer"],
|
|
605
|
+
});
|
|
606
|
+
expect(summary).toContain("10000ms");
|
|
607
|
+
expect(summary).toContain("13000ms");
|
|
608
|
+
expect(summary).toContain("Socket, Timer");
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
/** The shared state is keyed on globalThis and would leak between tests. */
|
|
613
|
+
const SHARED_KEY = Symbol.for("@andares/pi-toolkits/quit-guard/state");
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* `registerQuitGuard` installs a process-wide stdout wrapper; keep the real
|
|
617
|
+
* one so every test leaves the reporter's stream exactly as it found it.
|
|
618
|
+
*/
|
|
619
|
+
const REAL_STDOUT_WRITE = process.stdout.write;
|
|
620
|
+
|
|
621
|
+
function clearSharedState(): void {
|
|
622
|
+
delete (globalThis as unknown as Record<symbol, unknown>)[SHARED_KEY];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function fakePi(): { pi: ExtensionAPI; command: () => CommandHandler } {
|
|
626
|
+
let registered: CommandHandler | undefined;
|
|
627
|
+
// SAFETY: only `registerCommand` is called by the feature; the rest of the
|
|
628
|
+
// host surface is irrelevant here.
|
|
629
|
+
const pi = {
|
|
630
|
+
registerCommand: (
|
|
631
|
+
_name: string,
|
|
632
|
+
options: { handler: CommandHandler },
|
|
633
|
+
): void => {
|
|
634
|
+
registered = options.handler;
|
|
635
|
+
},
|
|
636
|
+
} as unknown as ExtensionAPI;
|
|
637
|
+
return {
|
|
638
|
+
pi,
|
|
639
|
+
command: () => {
|
|
640
|
+
if (!registered) throw new Error("command was not registered");
|
|
641
|
+
return registered;
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
type CommandHandler = (
|
|
647
|
+
args: string,
|
|
648
|
+
ctx: ExtensionCommandContext,
|
|
649
|
+
) => Promise<void>;
|
|
650
|
+
|
|
651
|
+
function fakeCtx(): { ctx: ExtensionCommandContext; notes: string[] } {
|
|
652
|
+
const notes: string[] = [];
|
|
653
|
+
// SAFETY: the command only touches `ui.notify`; every other ctx member is
|
|
654
|
+
// unused by this feature and therefore absent.
|
|
655
|
+
const ctx = {
|
|
656
|
+
ui: { notify: (message: string) => notes.push(message) },
|
|
657
|
+
} as unknown as ExtensionCommandContext;
|
|
658
|
+
return { ctx, notes };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function readSettings(file: string): unknown {
|
|
662
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
describe("/quit-guard command", () => {
|
|
666
|
+
const dirs: string[] = [];
|
|
667
|
+
|
|
668
|
+
function setup(): { dir: string; settings: string } {
|
|
669
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-quit-guard-cmd-"));
|
|
670
|
+
dirs.push(dir);
|
|
671
|
+
return { dir, settings: join(dir, "pi-toolkits-quit-guard.json") };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
afterEach(() => {
|
|
675
|
+
process.stdout.write = REAL_STDOUT_WRITE;
|
|
676
|
+
clearSharedState();
|
|
677
|
+
for (const dir of dirs.splice(0)) {
|
|
678
|
+
rmSync(dir, { recursive: true, force: true });
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("reports the current state when called without arguments", async () => {
|
|
683
|
+
const { dir, settings } = setup();
|
|
684
|
+
const { pi, command } = fakePi();
|
|
685
|
+
const { ctx, notes } = fakeCtx();
|
|
686
|
+
registerQuitGuard(pi, { agentDir: dir });
|
|
687
|
+
await command()("", ctx);
|
|
688
|
+
expect(notes).toHaveLength(1);
|
|
689
|
+
expect(notes[0]).toContain("quit guard: on");
|
|
690
|
+
expect(notes[0]).toContain(`timeout ${DEFAULT_TIMEOUT_MS}ms`);
|
|
691
|
+
expect(notes[0]).toContain("last hang: none");
|
|
692
|
+
// A status query must not create the settings file.
|
|
693
|
+
expect(existsSync(settings)).toBe(false);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("persists an off switch and turns the guard inert", async () => {
|
|
697
|
+
const { dir, settings } = setup();
|
|
698
|
+
const { pi, command } = fakePi();
|
|
699
|
+
const { ctx, notes } = fakeCtx();
|
|
700
|
+
registerQuitGuard(pi, { agentDir: dir });
|
|
701
|
+
await command()("off", ctx);
|
|
702
|
+
expect(notes[0]).toContain("quit guard OFF");
|
|
703
|
+
expect(readSettings(settings)).toMatchObject({ enabled: false });
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("clamps a requested timeout into the supported range", async () => {
|
|
707
|
+
const { dir, settings } = setup();
|
|
708
|
+
const { pi, command } = fakePi();
|
|
709
|
+
const { ctx, notes } = fakeCtx();
|
|
710
|
+
registerQuitGuard(pi, { agentDir: dir });
|
|
711
|
+
await command()("100", ctx);
|
|
712
|
+
expect(notes[0]).toContain(`timeout ${MIN_TIMEOUT_MS}ms`);
|
|
713
|
+
expect(readSettings(settings)).toMatchObject({
|
|
714
|
+
enabled: true,
|
|
715
|
+
timeoutMs: MIN_TIMEOUT_MS,
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("rejects an unknown argument with the usage line", async () => {
|
|
720
|
+
const { dir } = setup();
|
|
721
|
+
const { pi, command } = fakePi();
|
|
722
|
+
const { ctx, notes } = fakeCtx();
|
|
723
|
+
registerQuitGuard(pi, { agentDir: dir });
|
|
724
|
+
await command()("sometimes", ctx);
|
|
725
|
+
expect(notes[0]).toContain("Usage: /quit-guard");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("keeps one stdout wrapper per process across reload re-registration", () => {
|
|
729
|
+
const { dir } = setup();
|
|
730
|
+
registerQuitGuard(fakePi().pi, { agentDir: dir });
|
|
731
|
+
const afterFirst = process.stdout.write;
|
|
732
|
+
expect(afterFirst).not.toBe(REAL_STDOUT_WRITE);
|
|
733
|
+
registerQuitGuard(fakePi().pi, { agentDir: dir });
|
|
734
|
+
expect(process.stdout.write).toBe(afterFirst);
|
|
735
|
+
});
|
|
736
|
+
});
|