@pify/shell-background 0.4.2 → 0.5.1
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 +7 -2
- package/extensions/shell-background.ts +5 -5
- package/package.json +1 -1
- package/src/config.ts +8 -0
- package/src/format.ts +41 -10
- package/src/redundancy.ts +127 -0
- package/src/sanitize.ts +10 -4
- package/src/shrink.ts +131 -0
- package/src/tail.ts +3 -1
- package/src/testreport.ts +158 -0
package/README.md
CHANGED
|
@@ -54,17 +54,22 @@ Put these in `.pi/shell-background.json` (project) or `<agentDir>/shell-backgrou
|
|
|
54
54
|
{
|
|
55
55
|
"autoBackgroundMs": 30000,
|
|
56
56
|
"tailBytes": 65536,
|
|
57
|
+
"tailLines": 200,
|
|
57
58
|
"maxBackground": 8
|
|
58
59
|
}
|
|
59
60
|
```
|
|
60
61
|
|
|
61
|
-
`autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result
|
|
62
|
+
`autoBackgroundMs` is how long a foreground command may run before it auto-backgrounds; set it to `0` to disable auto-background (explicit `background: true` still works). `PIFY_SHELL_BG_MS` overrides it for one run or in CI. `tailBytes` bounds how much of a job's log a status result reads; `tailLines` bounds how many lines of it the model is shown (0 disables the line shrink). `maxBackground` bounds how many jobs may be alive at once: over it, a `background: true` request is refused with a message naming the limit (the command can still run in the foreground), and a command that crosses the auto-background threshold simply stays in the foreground instead of moving — a command is never refused, only the decision to background it. Bad values fall back to the defaults with a warning rather than taking the tool down.
|
|
62
63
|
|
|
63
64
|
Since pi 0.99 the bash tool also returns a machine-readable result — `structuredContent` — which codemode scripts and tools calling `ctx.executeTool("bash", …)` receive instead of the text. This package keeps pi's shape so such a script reads either tool unchanged: `output` (the whole log, up to 1 MiB, head and tail around an omission marker), `truncated`, `full_output_path`, `exit_code`, `wall_time_seconds` — plus `status` and `job_id`, because a command here can still be running when the tool returns; then `exit_code` is absent and `shell_status {id}` collects it, with the same structured result.
|
|
64
65
|
|
|
65
66
|
A backgrounded command runs with no one to answer it, so its environment says so: `GIT_TERMINAL_PROMPT=0` and `GIT_EDITOR`/`EDITOR`/`VISUAL=true` are forced (a `git commit` without `-m` fails with "empty message" instead of waiting on an editor that never comes, and an https `git fetch` fails instead of prompting for a password), and pagers default to `cat` unless you set them. `CI` and `TERM` are left alone on purpose — dev servers and watchers read them.
|
|
66
67
|
|
|
67
|
-
The tail a result carries is cleaned the way pi's own bash cleans what the model sees — ANSI escapes
|
|
68
|
+
The tail a result carries is cleaned the way pi's own bash cleans what the model sees — ANSI escapes and control characters stripped — with two improvements borrowed from caveman's log compressors. A carriage-return redraw is **resolved to its last frame** rather than having the `\r` deleted: pi's own cleaning keeps every frame's text, so a 2000-frame progress bar arrives as one line holding all 2000 frames; here it arrives as `1999/2000`. And a long result is **shrunk by lines, not bytes**: the first and last lines are kept, every line that names trouble (`error`, `FAILED`, `Traceback`, `at …`, `File "…"`, `path:12:5:`, `FooError`, `caused by`, `exit status 1`) is kept wherever it sits, and the rest collapses into markers that count what they replaced — `[… 412 lines omitted (INFO ×380, DEBUG ×29) …]` — computed from exactly those lines. A byte window kept the *end* of a build log, where the summary is; a line window with that regex keeps the failing step in the middle too. Runs under three lines are shown as they are (a marker in place of one line only reads as a hole), the markers are recognised on a later poll so a shrunk result re-serialises identically (the provider prefix cache depends on it), and one note names what was left out and where the whole log is. The line shrink is guarded against truncating a *distinct* document: before a run of lines is collapsed, one representative of every kind of line a surviving line does not already resemble is kept (caveman's redundancy guard, vocabulary-containment with digit masking so lines differing only in a timestamp count as one). So a 50k-line repetitive build log collapses hard, while a 50k-line document where every line is distinct is kept and the shrink reports no win — the honest outcome rather than a silent deletion.
|
|
69
|
+
|
|
70
|
+
A whole JSON test report in the tail — `pytest --json-report`, `jest --json` — is rendered as counts plus every failure with its stack trimmed to the first three and last two frames (`TEST SUMMARY: 240 passed, 3 failed` then each `FAILED: …`), instead of the raw document. Detection is on the exact top-level keys, so ordinary JSON is untouched; a truncated report is not valid JSON and falls through to the line view; the full report stays on disk. (JUnit XML lands in a file rather than on stdout and is left alone.)
|
|
71
|
+
|
|
72
|
+
A log that cannot be read says `(log unavailable: …)` rather than `(no output)`, which would send the model polling for output that will never come. The log file on disk keeps every byte.
|
|
68
73
|
|
|
69
74
|
## Coexistence with @pify/pretty
|
|
70
75
|
|
|
@@ -208,7 +208,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
208
208
|
|
|
209
209
|
function finished(job: Job): ToolResult {
|
|
210
210
|
return {
|
|
211
|
-
content: [{ type: "text", text: formatResult(job, settings.tailBytes) }],
|
|
211
|
+
content: [{ type: "text", text: formatResult(job, settings.tailBytes, settings.tailLines) }],
|
|
212
212
|
details: { id: job.id, status: job.status, exitCode: job.exitCode, signal: job.signal, background: false },
|
|
213
213
|
structuredContent: structured(job),
|
|
214
214
|
isError: job.status === "failed",
|
|
@@ -221,7 +221,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
221
221
|
pi.sendMessage(
|
|
222
222
|
{
|
|
223
223
|
customType: DELIVERY_TYPE,
|
|
224
|
-
content: deliveryMessage(job.id, formatResult(job, settings.tailBytes)),
|
|
224
|
+
content: deliveryMessage(job.id, formatResult(job, settings.tailBytes, settings.tailLines)),
|
|
225
225
|
display: true,
|
|
226
226
|
details: { id: job.id, status: job.status, exitCode: job.exitCode },
|
|
227
227
|
},
|
|
@@ -432,7 +432,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
432
432
|
{
|
|
433
433
|
type: "text",
|
|
434
434
|
text:
|
|
435
|
-
formatResult(job, settings.tailBytes) +
|
|
435
|
+
formatResult(job, settings.tailBytes, settings.tailLines) +
|
|
436
436
|
`\n\n[kill signal sent; process had not exited after 3s — shell_status ${job.id} to confirm]`,
|
|
437
437
|
},
|
|
438
438
|
],
|
|
@@ -446,7 +446,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
446
446
|
? `\n\n[killed: exceeded the ${params.timeout}s timeout]`
|
|
447
447
|
: `\n\n[killed: the turn was aborted]`;
|
|
448
448
|
return {
|
|
449
|
-
content: [{ type: "text", text: formatResult(job, settings.tailBytes) + note }],
|
|
449
|
+
content: [{ type: "text", text: formatResult(job, settings.tailBytes, settings.tailLines) + note }],
|
|
450
450
|
details: { id: job.id, status: job.status },
|
|
451
451
|
structuredContent: structured(job),
|
|
452
452
|
isError: true,
|
|
@@ -559,7 +559,7 @@ export default function shellBackground(pi: ExtensionAPI) {
|
|
|
559
559
|
registry.persist(job);
|
|
560
560
|
}
|
|
561
561
|
return {
|
|
562
|
-
content: [{ type: "text", text: formatResult(job, settings.tailBytes) }],
|
|
562
|
+
content: [{ type: "text", text: formatResult(job, settings.tailBytes, settings.tailLines) }],
|
|
563
563
|
details: { id: job.id, status: job.status, exitCode: job.exitCode },
|
|
564
564
|
structuredContent: structured(job),
|
|
565
565
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/shell-background",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Long-running bash goes async: background: true launches detached, and any command still running after 30s auto-backgrounds and delivers its result when it finishes",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/config.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface ShellBgSettings {
|
|
|
14
14
|
autoBackgroundMs: number;
|
|
15
15
|
/** Bytes of the log tail shown in a status/collect result. */
|
|
16
16
|
tailBytes: number;
|
|
17
|
+
/**
|
|
18
|
+
* Lines a result may carry before it is shrunk: first and last lines kept,
|
|
19
|
+
* every error/warning line kept wherever it sits, the rest collapsed into
|
|
20
|
+
* counted markers. 0 disables shrinking (the byte tail is still the cap).
|
|
21
|
+
*/
|
|
22
|
+
tailLines: number;
|
|
17
23
|
/**
|
|
18
24
|
* How many jobs may be alive at once before a request to background one
|
|
19
25
|
* more is refused and an auto-background transition is skipped. Every live
|
|
@@ -28,6 +34,7 @@ export interface ShellBgSettings {
|
|
|
28
34
|
export const DEFAULT_SETTINGS: ShellBgSettings = {
|
|
29
35
|
autoBackgroundMs: 30_000,
|
|
30
36
|
tailBytes: 64 * 1024,
|
|
37
|
+
tailLines: 200,
|
|
31
38
|
maxBackground: 8,
|
|
32
39
|
};
|
|
33
40
|
|
|
@@ -36,6 +43,7 @@ const LIMITS: Record<keyof ShellBgSettings, { min: number; max: number }> = {
|
|
|
36
43
|
// does not make every command look long-running.
|
|
37
44
|
autoBackgroundMs: { min: 0, max: 3_600_000 },
|
|
38
45
|
tailBytes: { min: 1024, max: 4 * 1024 * 1024 },
|
|
46
|
+
tailLines: { min: 0, max: 5000 },
|
|
39
47
|
maxBackground: { min: 1, max: 64 },
|
|
40
48
|
};
|
|
41
49
|
|
package/src/format.ts
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
* job and a tail, produce the string — the extension supplies both.
|
|
4
4
|
*/
|
|
5
5
|
import type { Job } from "./types.ts";
|
|
6
|
-
import { readTail
|
|
6
|
+
import { readTail } from "./tail.ts";
|
|
7
7
|
import { sanitizeOutput } from "./sanitize.ts";
|
|
8
|
+
import { DEFAULT_SHRINK, shrinkLines } from "./shrink.ts";
|
|
9
|
+
import { parseTestReport, renderTestReport } from "./testreport.ts";
|
|
10
|
+
|
|
11
|
+
function kb(bytes: number): string {
|
|
12
|
+
return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))}KB` : `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
function secs(ms: number): string {
|
|
10
16
|
if (ms < 1000) return `${ms}ms`;
|
|
@@ -34,22 +40,47 @@ export function header(job: Job): string {
|
|
|
34
40
|
/**
|
|
35
41
|
* The full result of a finished (or polled) job: the header, then the tail of
|
|
36
42
|
* its output, with an honest note when output was truncated and where the whole
|
|
37
|
-
* log lives.
|
|
43
|
+
* log lives. The tail is read as bytes (`tailBytes`), cleaned, and then shrunk
|
|
44
|
+
* by lines (`tailLines`): first and last lines kept, every error/warning line
|
|
45
|
+
* kept wherever it sits, the rest collapsed into counted markers — so a long
|
|
46
|
+
* build's failing step survives in the middle instead of only its summary at
|
|
47
|
+
* the end. The log on disk keeps every byte, and the note says where it is.
|
|
38
48
|
*/
|
|
39
|
-
export function formatResult(job: Job, tailBytes: number): string {
|
|
49
|
+
export function formatResult(job: Job, tailBytes: number, tailLines: number = DEFAULT_SHRINK.maxLines): string {
|
|
40
50
|
const tail = readTail(job.logPath, tailBytes);
|
|
41
|
-
// Cleaned the way pi's own bash cleans its output for the model; the log on
|
|
42
|
-
// disk (which the truncation note points at) keeps every byte.
|
|
43
|
-
const text = sanitizeOutput(tail.text);
|
|
44
51
|
const lines = [header(job)];
|
|
52
|
+
if (tail.missing) {
|
|
53
|
+
// A log that cannot be read is not a command that printed nothing: say so,
|
|
54
|
+
// or the model keeps polling for output that will never come.
|
|
55
|
+
lines.push(`(log unavailable: ${job.logPath} could not be read)`);
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
// Cleaned the way pi's own bash cleans its output for the model; the log on
|
|
59
|
+
// disk (which the note points at) keeps every byte.
|
|
60
|
+
const text = sanitizeOutput(tail.text).replace(/\n+$/, "");
|
|
45
61
|
if (text.trim() === "") {
|
|
46
62
|
lines.push(job.status === "running" ? "(no output yet)" : "(no output)");
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
// A whole JSON test report (pytest --json-report, jest --json) read from the
|
|
66
|
+
// tail becomes counts + every failure. Only when the tail holds the whole
|
|
67
|
+
// document — a truncated one is not valid JSON and falls through to the
|
|
68
|
+
// line view. The full report stays on disk.
|
|
69
|
+
if (!tail.truncated) {
|
|
70
|
+
const report = parseTestReport(text);
|
|
71
|
+
if (report) {
|
|
72
|
+
lines.push(renderTestReport(report));
|
|
73
|
+
return lines.join("\n");
|
|
50
74
|
}
|
|
51
|
-
lines.push(text.replace(/\n+$/, ""));
|
|
52
75
|
}
|
|
76
|
+
const shrunk = shrinkLines(text, { maxLines: tailLines, tail: Math.max(DEFAULT_SHRINK.tail, Math.floor(tailLines / 2)) });
|
|
77
|
+
const notes: string[] = [];
|
|
78
|
+
if (shrunk.omitted > 0) {
|
|
79
|
+
notes.push(`${shrunk.omitted} of ${shrunk.total} lines omitted, none with an error or warning (those are kept wherever they sit)`);
|
|
80
|
+
}
|
|
81
|
+
if (tail.truncated) notes.push(`this is the last ${kb(Buffer.byteLength(tail.text, "utf8"))} of a ${kb(tail.bytes)} log`);
|
|
82
|
+
if (notes.length > 0) lines.push(`… ${notes.join("; ")} — full log: ${job.logPath}`);
|
|
83
|
+
lines.push(shrunk.text);
|
|
53
84
|
return lines.join("\n");
|
|
54
85
|
}
|
|
55
86
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guard that separates *eliding repetition* from *truncating a document*.
|
|
3
|
+
* A faithful port of caveman's keepNonRedundant (JuliusBrussee/caveman,
|
|
4
|
+
* engine/compressors/redundancy.go).
|
|
5
|
+
*
|
|
6
|
+
* The line shrink (shrink.ts) keeps the head, the tail, and every line that
|
|
7
|
+
* names trouble, and collapses the rest. On a build log that is the right
|
|
8
|
+
* call: the dropped lines are repeated chunk/step noise, each represented by
|
|
9
|
+
* a surviving one. On a 50k-line document where every line is distinct — a
|
|
10
|
+
* bibliography, a generated source file — the same transform would silently
|
|
11
|
+
* delete most of the content. This gate runs before the collapse: a line
|
|
12
|
+
* about to be dropped is KEPT when no surviving line already resembles it, so
|
|
13
|
+
* a distinct document retains a representative of every kind of line and only
|
|
14
|
+
* true repetition is elided. Deterministic (prefix cache), bounded (the work
|
|
15
|
+
* is the same on a 200-line and a 20,000-line payload), pure.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const CONTAINMENT = 0.9; // a dropped line's vocabulary a survivor must carry to represent it
|
|
19
|
+
const MAX_CLASSES = 64; // kept lines sampled to seed the represented classes
|
|
20
|
+
const MAX_PROMOTIONS = 128; // first-of-kind lines kept before the payload is declared a document
|
|
21
|
+
const MAX_UNIT_TOKENS = 128; // vocabulary built per line, so one long line cannot make this quadratic
|
|
22
|
+
const MIN_WORD_TOKENS = 1; // letter-bearing tokens a line needs before its digits are masked
|
|
23
|
+
|
|
24
|
+
interface Profile {
|
|
25
|
+
vocab: Set<string>;
|
|
26
|
+
masked: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A line reduced to its word/number tokens, every run of digits optionally
|
|
31
|
+
* collapsed to `#`. Masking lets two log lines that differ only in a timestamp
|
|
32
|
+
* recognise each other; a line with no words keeps its digits, because there
|
|
33
|
+
* the numbers ARE the content (`['1973.', 251]` must not match `['1974.', 260]`).
|
|
34
|
+
*/
|
|
35
|
+
function tokenize(unit: string, maskDigits: boolean): { vocab: Set<string>; words: number } {
|
|
36
|
+
const vocab = new Set<string>();
|
|
37
|
+
let words = 0;
|
|
38
|
+
let token = "";
|
|
39
|
+
let hasLetter = false;
|
|
40
|
+
let inDigits = false;
|
|
41
|
+
const flush = () => {
|
|
42
|
+
if (token.length > 0 && !vocab.has(token)) {
|
|
43
|
+
vocab.add(token);
|
|
44
|
+
if (hasLetter) words++;
|
|
45
|
+
}
|
|
46
|
+
token = "";
|
|
47
|
+
hasLetter = false;
|
|
48
|
+
inDigits = false;
|
|
49
|
+
};
|
|
50
|
+
for (const ch of unit.toLowerCase()) {
|
|
51
|
+
if (ch >= "0" && ch <= "9") {
|
|
52
|
+
if (maskDigits) {
|
|
53
|
+
if (!inDigits) {
|
|
54
|
+
token += "#";
|
|
55
|
+
inDigits = true;
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
token += ch;
|
|
59
|
+
inDigits = false;
|
|
60
|
+
}
|
|
61
|
+
} else if (/[a-z_]/.test(ch) || (ch.toLowerCase() !== ch.toUpperCase())) {
|
|
62
|
+
token += ch;
|
|
63
|
+
hasLetter = true;
|
|
64
|
+
inDigits = false;
|
|
65
|
+
} else {
|
|
66
|
+
flush();
|
|
67
|
+
}
|
|
68
|
+
if (vocab.size >= MAX_UNIT_TOKENS) return { vocab, words };
|
|
69
|
+
}
|
|
70
|
+
flush();
|
|
71
|
+
return { vocab, words };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function unitVocabulary(unit: string): Profile {
|
|
75
|
+
const masked = tokenize(unit, true);
|
|
76
|
+
if (masked.words >= MIN_WORD_TOKENS) return { vocab: masked.vocab, masked: true };
|
|
77
|
+
return { vocab: tokenize(unit, false).vocab, masked: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function representedBy(dropped: Profile, classes: Profile[]): boolean {
|
|
81
|
+
if (dropped.vocab.size === 0) return true; // punctuation-only line carries nothing to lose
|
|
82
|
+
const need = Math.ceil(CONTAINMENT * dropped.vocab.size - 1e-9);
|
|
83
|
+
for (const cls of classes) {
|
|
84
|
+
if (cls.masked !== dropped.masked) continue;
|
|
85
|
+
let hit = 0;
|
|
86
|
+
for (const token of dropped.vocab) {
|
|
87
|
+
if (cls.vocab.has(token)) {
|
|
88
|
+
hit++;
|
|
89
|
+
if (hit >= need) return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Promote back to kept any not-yet-kept line that no surviving line
|
|
98
|
+
* represents. Mutates `keep` in place, in document order.
|
|
99
|
+
*/
|
|
100
|
+
export function keepNonRedundant(units: readonly string[], keep: boolean[]): void {
|
|
101
|
+
if (units.length !== keep.length) return;
|
|
102
|
+
const kept: number[] = [];
|
|
103
|
+
for (let i = 0; i < keep.length; i++) if (keep[i]) kept.push(i);
|
|
104
|
+
|
|
105
|
+
const classes: Profile[] = [];
|
|
106
|
+
const step = kept.length > MAX_CLASSES ? Math.floor(kept.length / MAX_CLASSES) : 1;
|
|
107
|
+
for (let n = 0; n < kept.length && classes.length < MAX_CLASSES; n += step) {
|
|
108
|
+
classes.push(unitVocabulary(units[kept[n]!]!));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let promotions = 0;
|
|
112
|
+
for (let i = 0; i < units.length; i++) {
|
|
113
|
+
if (keep[i]) continue;
|
|
114
|
+
if (promotions >= MAX_PROMOTIONS) {
|
|
115
|
+
// More distinct kinds of line than any repetitive stream has: a document.
|
|
116
|
+
// Keep the rest; the caller's "did this actually shrink?" check then
|
|
117
|
+
// passes the original through. Prefer the honest zero.
|
|
118
|
+
keep[i] = true;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const vocab = unitVocabulary(units[i]!);
|
|
122
|
+
if (representedBy(vocab, classes)) continue;
|
|
123
|
+
keep[i] = true;
|
|
124
|
+
promotions++;
|
|
125
|
+
classes.push(vocab);
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/sanitize.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* (bash-executor.ts: `sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "")`)
|
|
2
|
+
* The cleaning pi's own bash tool applies to what the model sees
|
|
3
|
+
* (bash-executor.ts: `sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "")`),
|
|
4
|
+
* with one improvement: a carriage return is resolved, not deleted. pi drops
|
|
5
|
+
* the `\r` and keeps every progress frame's text, so a 2000-frame webpack bar
|
|
6
|
+
* arrives as one line holding all 2000 frames; here only the last frame of
|
|
7
|
+
* each line survives, which is what the terminal showed (see shrink.ts).
|
|
4
8
|
*
|
|
5
9
|
* This package re-registers `bash`, and a job's log is captured raw on disk on
|
|
6
10
|
* purpose (the detached spawn writes straight to a file; no in-process pipes).
|
|
@@ -12,6 +16,8 @@
|
|
|
12
16
|
* vendored here verbatim.
|
|
13
17
|
*/
|
|
14
18
|
|
|
19
|
+
import { collapseCarriageReturns } from "./shrink.ts";
|
|
20
|
+
|
|
15
21
|
// Valid string terminator sequences are BEL, ESC\, and 0x9c
|
|
16
22
|
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
|
|
17
23
|
// OSC sequences only: ESC ] ... ST (non-greedy until the first ST)
|
|
@@ -44,7 +50,7 @@ export function sanitizeBinaryOutput(text: string): string {
|
|
|
44
50
|
.join("");
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
/**
|
|
53
|
+
/** What pi's bash gives the model — no ANSI, no control noise — with `\r` redraws collapsed to their last frame. */
|
|
48
54
|
export function sanitizeOutput(text: string): string {
|
|
49
|
-
return sanitizeBinaryOutput(stripAnsi(text))
|
|
55
|
+
return sanitizeBinaryOutput(collapseCarriageReturns(stripAnsi(text)));
|
|
50
56
|
}
|
package/src/shrink.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A long log, shrunk the way a person skims it: the first lines, the last
|
|
3
|
+
* lines, and every line that looks like trouble wherever it sits — the rest
|
|
4
|
+
* collapsed into counted markers. Adapted from caveman's log/terminal
|
|
5
|
+
* compressors (JuliusBrussee/caveman, engine/compressors/{log,terminal}.go):
|
|
6
|
+
* a byte window keeps the END of a build log, which is exactly where the
|
|
7
|
+
* summary is and not where the failing step was; a line window with an
|
|
8
|
+
* importance regex keeps both.
|
|
9
|
+
*
|
|
10
|
+
* Rules that matter, kept from the original:
|
|
11
|
+
* - an important line is never elided, no matter where it sits;
|
|
12
|
+
* - a run of fewer than 3 dropped lines is emitted verbatim — a marker that
|
|
13
|
+
* replaces one line costs about what the line cost and reads as a hole;
|
|
14
|
+
* - a marker states facts computed from exactly the lines it replaces (their
|
|
15
|
+
* log levels, counted), never a guess;
|
|
16
|
+
* - markers are recognised on re-entry and kept, so shrinking a shrunk text
|
|
17
|
+
* is byte-identical — a result that re-serialises differently on a later
|
|
18
|
+
* poll busts the provider's prefix cache.
|
|
19
|
+
* Pure; zero dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Words that mark trouble, case-insensitive (`error:`, `FAILED`, `Traceback`). */
|
|
23
|
+
const IMPORTANT_WORDS =
|
|
24
|
+
/\b(?:ERROR|FATAL|PANIC|EXCEPTION|TRACEBACK|FAIL|FAILED|FAILURE|WARN|WARNING|CRIT|CRITICAL|ALERT|EMERG|EMERGENCY)\b|^\s+at\s|^\s+File "|:\d+:\d+:|^\s*--->|caused by|\bexit (?:status|code) [1-9]/i;
|
|
25
|
+
/** camelCase error types a bare ERROR never matches: AssertionError, ValueError, NullPointerException. Case-sensitive on purpose (not "terror"). */
|
|
26
|
+
const CAMEL_ERROR = /\b[A-Za-z]+(?:Error|Exception)\b/;
|
|
27
|
+
/** A log level token, for the marker's counts. */
|
|
28
|
+
const LEVEL = /\b(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|FATAL)\b/i;
|
|
29
|
+
/** A marker this module wrote earlier; kept as-is on re-entry. */
|
|
30
|
+
export const MARKER_RE = /^\[… \d+ lines omitted/;
|
|
31
|
+
|
|
32
|
+
export function isImportantLine(line: string): boolean {
|
|
33
|
+
return IMPORTANT_WORDS.test(line) || CAMEL_ERROR.test(line) || MARKER_RE.test(line);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A bare carriage return rewinds the cursor: `10%\r20%\r100% done` only ever
|
|
38
|
+
* displayed `100% done`, and the earlier frames are visual history. Deleting
|
|
39
|
+
* the `\r` instead (what pi's own bash does) concatenates every frame into
|
|
40
|
+
* one enormous line. Keep the last non-empty segment of each line. A CRLF
|
|
41
|
+
* line ending leaves an empty final segment, so the content before it wins.
|
|
42
|
+
*/
|
|
43
|
+
export function collapseCarriageReturns(text: string): string {
|
|
44
|
+
if (!text.includes("\r")) return text;
|
|
45
|
+
return text
|
|
46
|
+
.split("\n")
|
|
47
|
+
.map((line) => {
|
|
48
|
+
if (!line.includes("\r")) return line;
|
|
49
|
+
let chosen = "";
|
|
50
|
+
for (const segment of line.split("\r")) if (segment.length > 0) chosen = segment;
|
|
51
|
+
return chosen;
|
|
52
|
+
})
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ShrinkOptions {
|
|
57
|
+
/** Lines kept from the start. */
|
|
58
|
+
head?: number;
|
|
59
|
+
/** Lines kept from the end. */
|
|
60
|
+
tail?: number;
|
|
61
|
+
/** Texts of at most this many lines are returned untouched; 0 disables shrinking. */
|
|
62
|
+
maxLines?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Shrunk {
|
|
66
|
+
text: string;
|
|
67
|
+
/** Lines replaced by markers. */
|
|
68
|
+
omitted: number;
|
|
69
|
+
/** Lines in the input. */
|
|
70
|
+
total: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
import { keepNonRedundant } from "./redundancy.ts";
|
|
74
|
+
|
|
75
|
+
export const DEFAULT_SHRINK: Required<ShrinkOptions> = { head: 3, tail: 100, maxLines: 200 };
|
|
76
|
+
|
|
77
|
+
function levelSummary(lines: string[]): string {
|
|
78
|
+
const counts = new Map<string, number>();
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
const m = LEVEL.exec(line);
|
|
81
|
+
if (!m) continue;
|
|
82
|
+
const key = m[1]!.toUpperCase();
|
|
83
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
84
|
+
}
|
|
85
|
+
if (counts.size === 0) return "";
|
|
86
|
+
// Every level seen, never a partial list; most frequent first, ties by name.
|
|
87
|
+
return [...counts.entries()]
|
|
88
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
89
|
+
.map(([level, n]) => `${level} ×${n}`)
|
|
90
|
+
.join(", ");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function marker(run: number, dropped: string[]): string {
|
|
94
|
+
const summary = levelSummary(dropped);
|
|
95
|
+
return `[… ${run} lines omitted${summary ? ` (${summary})` : ""} …]`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function shrinkLines(text: string, opts: ShrinkOptions = {}): Shrunk {
|
|
99
|
+
const head = Math.max(0, opts.head ?? DEFAULT_SHRINK.head);
|
|
100
|
+
const tail = Math.max(0, opts.tail ?? DEFAULT_SHRINK.tail);
|
|
101
|
+
const maxLines = opts.maxLines ?? DEFAULT_SHRINK.maxLines;
|
|
102
|
+
const lines = text.split("\n");
|
|
103
|
+
const total = lines.length;
|
|
104
|
+
if (maxLines <= 0 || total <= maxLines) return { text, omitted: 0, total };
|
|
105
|
+
const keep = lines.map((line, i) => i < head || i >= total - tail || isImportantLine(line));
|
|
106
|
+
// Before collapsing, keep one representative of every DISTINCT line a run is
|
|
107
|
+
// about to drop, so a document of unique lines is not truncated — only true
|
|
108
|
+
// repetition is elided (caveman's redundancy guard).
|
|
109
|
+
keepNonRedundant(lines, keep);
|
|
110
|
+
const out: string[] = [];
|
|
111
|
+
let omitted = 0;
|
|
112
|
+
let i = 0;
|
|
113
|
+
while (i < total) {
|
|
114
|
+
if (keep[i]) {
|
|
115
|
+
out.push(lines[i]!);
|
|
116
|
+
i++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
let j = i;
|
|
120
|
+
while (j < total && !keep[j]) j++;
|
|
121
|
+
const run = j - i;
|
|
122
|
+
if (run < 3) {
|
|
123
|
+
for (let k = i; k < j; k++) out.push(lines[k]!);
|
|
124
|
+
} else {
|
|
125
|
+
out.push(marker(run, lines.slice(i, j)));
|
|
126
|
+
omitted += run;
|
|
127
|
+
}
|
|
128
|
+
i = j;
|
|
129
|
+
}
|
|
130
|
+
return { text: out.join("\n"), omitted, total };
|
|
131
|
+
}
|
package/src/tail.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface Tail {
|
|
|
20
20
|
truncated: boolean;
|
|
21
21
|
/** Total size of the file in bytes. */
|
|
22
22
|
bytes: number;
|
|
23
|
+
/** True when the log could not be opened at all — distinct from a command that printed nothing. */
|
|
24
|
+
missing?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export const DEFAULT_TAIL_BYTES = 64 * 1024;
|
|
@@ -29,7 +31,7 @@ export function readTail(path: string, maxBytes: number = DEFAULT_TAIL_BYTES): T
|
|
|
29
31
|
try {
|
|
30
32
|
fd = openSync(path, "r");
|
|
31
33
|
} catch {
|
|
32
|
-
return { text: "", truncated: false, bytes: 0 };
|
|
34
|
+
return { text: "", truncated: false, bytes: 0, missing: true };
|
|
33
35
|
}
|
|
34
36
|
try {
|
|
35
37
|
const size = fstatSync(fd).size;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A machine-readable test report, rendered as counts plus every failure.
|
|
3
|
+
* Ported from caveman's test-report compressor (JuliusBrussee/caveman,
|
|
4
|
+
* engine/compressors/testreport.go), narrowed to the two JSON shapes a bash
|
|
5
|
+
* command actually prints to stdout: pytest-json-report and Jest `--json`.
|
|
6
|
+
* (JUnit XML lands in a file, not on stdout, and parsing it well needs an XML
|
|
7
|
+
* dependency this package does not take.)
|
|
8
|
+
*
|
|
9
|
+
* No failure is ever dropped: the summary counts every outcome, and each
|
|
10
|
+
* failure keeps its name, message and a stack trimmed to its first three and
|
|
11
|
+
* last two frames. Detection gates on the exact top-level keys, so ordinary
|
|
12
|
+
* JSON that merely mentions them is left to the normal tail. Pure; the full
|
|
13
|
+
* log stays on disk for recovery.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface TestReport {
|
|
17
|
+
passed: number;
|
|
18
|
+
failed: number;
|
|
19
|
+
skipped: number;
|
|
20
|
+
errors: number;
|
|
21
|
+
duration?: number;
|
|
22
|
+
other: Record<string, number>;
|
|
23
|
+
failures: Array<{ name: string; message: string; type: "failure" | "error"; stack: string[] }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Keep a stack's first 3 and last 2 real frames, only when that drops at least 3. */
|
|
27
|
+
export function trimStack(frames: string[]): string[] {
|
|
28
|
+
const clean = frames
|
|
29
|
+
.map((f) => f.trim())
|
|
30
|
+
.filter((f) => f !== "" && (/^at /.test(f) || /\.(py|java|js|ts|rb|go):/.test(f)));
|
|
31
|
+
if (clean.length < 8) return clean;
|
|
32
|
+
return [...clean.slice(0, 3), `... (${clean.length - 5} frames omitted)`, ...clean.slice(-2)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function num(v: unknown): number {
|
|
36
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parsePytest(doc: Record<string, unknown>): TestReport | null {
|
|
40
|
+
if (!Array.isArray(doc.tests)) return null;
|
|
41
|
+
const r: TestReport = { passed: 0, failed: 0, skipped: 0, errors: 0, duration: num(doc.duration) || undefined, other: {}, failures: [] };
|
|
42
|
+
for (const t of doc.tests) {
|
|
43
|
+
if (!t || typeof t !== "object") continue;
|
|
44
|
+
const test = t as Record<string, unknown>;
|
|
45
|
+
const outcome = typeof test.outcome === "string" ? test.outcome : "";
|
|
46
|
+
const stageRepr = (name: string): string => {
|
|
47
|
+
const stage = test[name];
|
|
48
|
+
const lr = stage && typeof stage === "object" ? (stage as Record<string, unknown>).longrepr : undefined;
|
|
49
|
+
return typeof lr === "string" ? lr : "";
|
|
50
|
+
};
|
|
51
|
+
const longrepr = stageRepr("call") || stageRepr("setup") || stageRepr("teardown");
|
|
52
|
+
const nodeid = typeof test.nodeid === "string" ? test.nodeid : "(unknown)";
|
|
53
|
+
if (outcome === "passed") r.passed++;
|
|
54
|
+
else if (outcome === "skipped") r.skipped++;
|
|
55
|
+
else if (outcome === "failed" || outcome === "error") {
|
|
56
|
+
const isError = outcome === "error";
|
|
57
|
+
isError ? r.errors++ : r.failed++;
|
|
58
|
+
let message = "";
|
|
59
|
+
const stack: string[] = [];
|
|
60
|
+
for (const raw of longrepr.split("\n")) {
|
|
61
|
+
const line = raw.trim();
|
|
62
|
+
if (!line) continue;
|
|
63
|
+
if (!isError && line.startsWith("E ")) {
|
|
64
|
+
if (!message) message = line.slice(2).trim();
|
|
65
|
+
} else if (line.includes(".py:") || line.startsWith("at ")) stack.push(line);
|
|
66
|
+
else if (isError && !message && !line.includes(".py:")) message = line;
|
|
67
|
+
}
|
|
68
|
+
r.failures.push({ name: nodeid, message, type: isError ? "error" : "failure", stack: trimStack(stack) });
|
|
69
|
+
} else if (outcome) r.other[outcome] = (r.other[outcome] ?? 0) + 1;
|
|
70
|
+
}
|
|
71
|
+
return r;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJest(doc: Record<string, unknown>): TestReport | null {
|
|
75
|
+
if (!Array.isArray(doc.testResults)) return null;
|
|
76
|
+
const r: TestReport = {
|
|
77
|
+
passed: num(doc.numPassedTests),
|
|
78
|
+
failed: num(doc.numFailedTests),
|
|
79
|
+
skipped: num(doc.numPendingTests),
|
|
80
|
+
errors: 0,
|
|
81
|
+
other: {},
|
|
82
|
+
failures: [],
|
|
83
|
+
};
|
|
84
|
+
for (const suite of doc.testResults) {
|
|
85
|
+
const results = suite && typeof suite === "object" ? (suite as Record<string, unknown>).assertionResults : undefined;
|
|
86
|
+
if (!Array.isArray(results)) continue;
|
|
87
|
+
for (const a of results) {
|
|
88
|
+
if (!a || typeof a !== "object") continue;
|
|
89
|
+
const test = a as Record<string, unknown>;
|
|
90
|
+
if (test.status !== "failed") continue;
|
|
91
|
+
const ancestors = Array.isArray(test.ancestorTitles) ? (test.ancestorTitles as unknown[]).map(String) : [];
|
|
92
|
+
const title = typeof test.title === "string" ? test.title : "";
|
|
93
|
+
const name = (typeof test.fullName === "string" && test.fullName) || [...ancestors, title].filter(Boolean).join(" › ") || "(unnamed)";
|
|
94
|
+
let message = "";
|
|
95
|
+
const stack: string[] = [];
|
|
96
|
+
const msgs = Array.isArray(test.failureMessages) ? test.failureMessages : [];
|
|
97
|
+
if (msgs.length > 0 && typeof msgs[0] === "string") {
|
|
98
|
+
for (const raw of (msgs[0] as string).split("\n")) {
|
|
99
|
+
const line = raw.trim();
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
if (line.startsWith("at ") || line.includes(".test.") || line.includes(".spec.")) stack.push(line);
|
|
102
|
+
else if (!message) message = line;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
r.failures.push({ name, message, type: "failure", stack: trimStack(stack) });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Parse a JSON test report, or null when the text is not one. Detection is on exact top-level keys. */
|
|
112
|
+
export function parseTestReport(text: string): TestReport | null {
|
|
113
|
+
const trimmed = text.trim();
|
|
114
|
+
if (!trimmed.startsWith("{")) return null;
|
|
115
|
+
// Byte pre-check so ordinary JSON is not parsed twice.
|
|
116
|
+
if (!trimmed.includes('"exitcode"') && !trimmed.includes('"numFailedTests"')) return null;
|
|
117
|
+
let doc: unknown;
|
|
118
|
+
try {
|
|
119
|
+
doc = JSON.parse(trimmed);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
|
|
124
|
+
const top = doc as Record<string, unknown>;
|
|
125
|
+
const report = "exitcode" in top && Array.isArray(top.tests) ? parsePytest(top)
|
|
126
|
+
: "numFailedTests" in top && Array.isArray(top.testResults) ? parseJest(top)
|
|
127
|
+
: null;
|
|
128
|
+
if (!report) return null;
|
|
129
|
+
const total = report.passed + report.failed + report.skipped + report.errors + Object.values(report.other).reduce((a, b) => a + b, 0);
|
|
130
|
+
return total === 0 ? null : report;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function renderTestReport(r: TestReport): string {
|
|
134
|
+
const parts: string[] = [];
|
|
135
|
+
if (r.passed > 0) parts.push(`${r.passed} passed`);
|
|
136
|
+
if (r.failed > 0) parts.push(`${r.failed} failed`);
|
|
137
|
+
if (r.errors > 0) parts.push(`${r.errors} errors`);
|
|
138
|
+
if (r.skipped > 0) parts.push(`${r.skipped} skipped`);
|
|
139
|
+
for (const name of Object.keys(r.other).sort()) parts.push(`${r.other[name]} ${name}`);
|
|
140
|
+
let out = `TEST SUMMARY: ${parts.join(", ")}`;
|
|
141
|
+
if (r.duration && r.duration > 0) out += ` (${r.duration.toFixed(1)}s)`;
|
|
142
|
+
for (const f of r.failures) {
|
|
143
|
+
out += `\n\n${f.type === "error" ? "ERROR" : "FAILED"}: ${f.name}`;
|
|
144
|
+
if (f.message) for (const line of f.message.trim().split("\n")) out += `\n ${line}`;
|
|
145
|
+
for (const frame of f.stack) out += `\n ${frame}`;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `N failed, M passed`-style summary, or null — for a one-line collapsed row. */
|
|
151
|
+
export function testCountLine(r: TestReport): string {
|
|
152
|
+
const parts: string[] = [];
|
|
153
|
+
if (r.failed > 0) parts.push(`${r.failed} failed`);
|
|
154
|
+
if (r.errors > 0) parts.push(`${r.errors} errors`);
|
|
155
|
+
if (r.passed > 0) parts.push(`${r.passed} passed`);
|
|
156
|
+
if (r.skipped > 0) parts.push(`${r.skipped} skipped`);
|
|
157
|
+
return parts.join(", ");
|
|
158
|
+
}
|