@molecule/api-ai-tools 1.5.15 → 1.5.17
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 +91 -4
- package/dist/tools.d.ts +13 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +226 -37
- package/dist/utilities.d.ts +48 -3
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +148 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
|
|
|
3
3
|
Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
|
|
4
4
|
Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
|
|
5
5
|
To change this document, edit the module-level JSDoc in src/index.ts.
|
|
6
|
-
Generated: 2026-10-
|
|
6
|
+
Generated: 2026-10-16T09:30:45.054Z
|
|
7
7
|
-->
|
|
8
8
|
|
|
9
9
|
# @molecule/api-ai-tools
|
|
@@ -488,6 +488,24 @@ function buildAgentPrompt(ctx: PromptContext): string
|
|
|
488
488
|
|
|
489
489
|
**Returns:** Fully assembled system prompt text for the coding agent.
|
|
490
490
|
|
|
491
|
+
#### `moleculeDistReadNote(path)`
|
|
492
|
+
|
|
493
|
+
The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT
|
|
494
|
+
redirect below fires only when the read FAILS, so a model that knows the hoisted
|
|
495
|
+
layout reads `node_modules/@molecule/<pkg>/dist/*.js` happily and never learns the
|
|
496
|
+
documented API is one call away (X0 flash-m70: 24 such reads in 57 s, no note on
|
|
497
|
+
any of them). Carried as `distNote` rather than merged into `note` so a host that
|
|
498
|
+
has read_molecule_doc (molecule-dev's cadence note, which also names the functions)
|
|
499
|
+
can supersede it without string-matching.
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
function moleculeDistReadNote(path: string): string | null
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
- `path` — The resolved path that was read.
|
|
506
|
+
|
|
507
|
+
**Returns:** The note, or null when the path is not inside a package's `dist`.
|
|
508
|
+
|
|
491
509
|
#### `buildTools(backend, config)`
|
|
492
510
|
|
|
493
511
|
Build a complete set of AI agent tools bound to an execution backend.
|
|
@@ -572,6 +590,22 @@ function redactSecretsInCode(s: string): string
|
|
|
572
590
|
|
|
573
591
|
**Returns:** A redacted copy that preserves ordinary code verbatim.
|
|
574
592
|
|
|
593
|
+
#### `printsOnlySource(command)`
|
|
594
|
+
|
|
595
|
+
Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
|
|
596
|
+
`head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
|
|
597
|
+
read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
|
|
598
|
+
came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
|
|
599
|
+
flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
function printsOnlySource(command: string): boolean
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
- `command` — The shell command.
|
|
606
|
+
|
|
607
|
+
**Returns:** True when every segment prints source files only.
|
|
608
|
+
|
|
575
609
|
#### `isEnvFilePath(path)`
|
|
576
610
|
|
|
577
611
|
Whether a path is an env file, for which {@link redactSecrets}' full env-dump
|
|
@@ -646,9 +680,16 @@ killed (X1 run flash-m15 lost 20 of its 60 minutes this way, 2026-10-15):
|
|
|
646
680
|
- a trailing `| tail -N`, which holds everything back until the command ends.
|
|
647
681
|
It is dropped: `wait_for_task` already returns the end of the log.
|
|
648
682
|
- `playwright test` with no `--reporter`, which runs the config's reporter —
|
|
649
|
-
often `html`, which prints nothing per test. `--reporter=
|
|
683
|
+
often `html`, which prints nothing per test. `--reporter=list` is added
|
|
650
684
|
when the playwright run is the command's last step and nothing is piped or
|
|
651
|
-
redirected from it
|
|
685
|
+
redirected from it: it writes each result as it finishes, and it is the
|
|
686
|
+
shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
|
|
687
|
+
fail-fast note and the Tests bar both parse `list` lines). A run that names
|
|
688
|
+
`--reporter=line` is switched to `list` for the same reason — `line` writes
|
|
689
|
+
`[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
|
|
690
|
+
8.3-minute walk of one suite could never trip the fail-fast note.
|
|
691
|
+
|
|
692
|
+
Only the command's last step is touched, and only its reporter.
|
|
652
693
|
|
|
653
694
|
```typescript
|
|
654
695
|
function streamingBackgroundCommand(command: string): { command: string; changes: string[] }
|
|
@@ -827,6 +868,25 @@ function failureBlocks(s: string): string[]
|
|
|
827
868
|
|
|
828
869
|
**Returns:** Each block, in order (empty when there are none).
|
|
829
870
|
|
|
871
|
+
#### `runningOutputSliceCommand(log, own)`
|
|
872
|
+
|
|
873
|
+
The shell command whose stdout is the interesting part of a log that is still being written:
|
|
874
|
+
the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
|
|
875
|
+
|
|
876
|
+
A Playwright `list` run prints one line per test as it finishes and its error blocks only at
|
|
877
|
+
the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
|
|
878
|
+
the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
|
|
879
|
+
them, which cost the run the grep it had to make anyway.
|
|
880
|
+
|
|
881
|
+
```typescript
|
|
882
|
+
function runningOutputSliceCommand(log: string, own?: string): string
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
- `log` — The task log path.
|
|
886
|
+
- `own` — A file the command redirects its own output to, if any.
|
|
887
|
+
|
|
888
|
+
**Returns:** The shell command, safe to hand to the execution backend as-is.
|
|
889
|
+
|
|
830
890
|
#### `whitespaceTolerantReplace(content, oldString, newString)`
|
|
831
891
|
|
|
832
892
|
Attempt a whitespace-tolerant replacement when an exact `old_string` match
|
|
@@ -985,7 +1045,9 @@ const MAX_READ_RETURN_CHARS: number
|
|
|
985
1045
|
|
|
986
1046
|
#### `SMALL_FILE_WHOLE_CHARS`
|
|
987
1047
|
|
|
988
|
-
A file this small is returned whole
|
|
1048
|
+
A file this small is returned whole on a second window of it (paging it costs a call each).
|
|
1049
|
+
The same bound as {@link MAX_READ_RETURN_CHARS}: the windowed-read note promises that a
|
|
1050
|
+
file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
|
|
989
1051
|
|
|
990
1052
|
```typescript
|
|
991
1053
|
const SMALL_FILE_WHOLE_CHARS: number
|
|
@@ -1031,6 +1093,31 @@ const DEFAULT_SEARCH_EXCLUDED_DIRS: readonly [
|
|
|
1031
1093
|
]
|
|
1032
1094
|
```
|
|
1033
1095
|
|
|
1096
|
+
#### `RUNNING_FAILURE_LINES`
|
|
1097
|
+
|
|
1098
|
+
Failure lines shown from a log that is still being written.
|
|
1099
|
+
|
|
1100
|
+
```typescript
|
|
1101
|
+
const RUNNING_FAILURE_LINES: 40
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
#### `RUNNING_TAIL_BYTES`
|
|
1105
|
+
|
|
1106
|
+
Bytes of a running log's end appended after its failure lines.
|
|
1107
|
+
|
|
1108
|
+
```typescript
|
|
1109
|
+
const RUNNING_TAIL_BYTES: 1000
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
#### `RUNNING_FAILURE_LINE_RE`
|
|
1113
|
+
|
|
1114
|
+
The lines worth showing from a log that is still being written: a failed test's mark or
|
|
1115
|
+
numbered block header, and a runner's own error lines. Matched with `grep -E`.
|
|
1116
|
+
|
|
1117
|
+
```typescript
|
|
1118
|
+
const RUNNING_FAILURE_LINE_RE: '^[[:space:]]*(✘|✗|×|✖|✕|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)'
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1034
1121
|
#### `PARSE_CHECKABLE`
|
|
1035
1122
|
|
|
1036
1123
|
Files a parse check can vouch for cheaply right after a write.
|
package/dist/tools.d.ts
CHANGED
|
@@ -8,6 +8,19 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { AITool } from '@molecule/api-ai';
|
|
10
10
|
import type { ExecutionBackend, ToolBuildConfig } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT
|
|
13
|
+
* redirect below fires only when the read FAILS, so a model that knows the hoisted
|
|
14
|
+
* layout reads `node_modules/@molecule/<pkg>/dist/*.js` happily and never learns the
|
|
15
|
+
* documented API is one call away (X0 flash-m70: 24 such reads in 57 s, no note on
|
|
16
|
+
* any of them). Carried as `distNote` rather than merged into `note` so a host that
|
|
17
|
+
* has read_molecule_doc (molecule-dev's cadence note, which also names the functions)
|
|
18
|
+
* can supersede it without string-matching.
|
|
19
|
+
*
|
|
20
|
+
* @param path - The resolved path that was read.
|
|
21
|
+
* @returns The note, or null when the path is not inside a package's `dist`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function moleculeDistReadNote(path: string): string | null;
|
|
11
24
|
/**
|
|
12
25
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
13
26
|
*
|
package/dist/tools.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AA0H9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA6CnE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAutDxF"}
|
package/dist/tools.js
CHANGED
|
@@ -25,12 +25,118 @@ const waitedByTask = new Map();
|
|
|
25
25
|
const logBytesByTask = new Map();
|
|
26
26
|
/** The file a background command writes its own output to, when it redirects it. */
|
|
27
27
|
const outputFileByTask = new Map();
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Task ids whose own-output file has already been looked for this process.
|
|
30
|
+
* {@link outputFileByTask} is empty for every task after a restart — it is
|
|
31
|
+
* process memory and the task is not — so without this the recovery below
|
|
32
|
+
* would re-read the task's script on every single wait.
|
|
33
|
+
*/
|
|
34
|
+
const outputFileProbed = new Set();
|
|
35
|
+
/** A millisecond span the way a model reads it: seconds under 90, else minutes. */
|
|
36
|
+
function durationText(ms) {
|
|
37
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
38
|
+
const mins = Math.floor(s / 60);
|
|
39
|
+
return s < 90 ? `${s} s` : s % 60 ? `${mins} min ${s % 60} s` : `${mins} min`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* When a background command started, read from its own id: the handle is minted
|
|
43
|
+
* as `mol-bg-${Date.now().toString(36)}-…`, so the start stamp is in the id and
|
|
44
|
+
* no new state has to be kept to have it.
|
|
45
|
+
*
|
|
46
|
+
* @param taskId - The handle a run_in_background command returned.
|
|
47
|
+
* @returns Epoch ms, or null when the id carries no usable stamp (a hand-written
|
|
48
|
+
* or synthetic id) — a wrong number in a note is worse than none.
|
|
49
|
+
*/
|
|
50
|
+
function taskStartedAt(taskId) {
|
|
51
|
+
const stamp = Number.parseInt(taskId.split('-')[2] ?? '', 36);
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
return Number.isFinite(stamp) && stamp > now - 24 * 3600_000 && stamp < now + 60_000
|
|
54
|
+
? stamp
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The progress a run reports in its own log: the last `[done/total]` counter a
|
|
59
|
+
* Playwright reporter writes (`[86/139] [chromium] › e2e/settings.spec.ts:…`).
|
|
60
|
+
*
|
|
61
|
+
* @param log - The log slice this wait read.
|
|
62
|
+
* @returns The counter, or null when the log carries none.
|
|
63
|
+
*/
|
|
64
|
+
function logProgress(log) {
|
|
65
|
+
const last = [...log.matchAll(/\[(\d+)\/(\d+)\]/g)].at(-1);
|
|
66
|
+
const done = Number(last?.[1]);
|
|
67
|
+
const total = Number(last?.[2]);
|
|
68
|
+
return last && total > 0 && done >= 0 && done <= total ? { done, total } : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* What a wait knows about the command BESIDES "it has not exited": how long it
|
|
72
|
+
* has been running, whether its output is still growing, how far its own log
|
|
73
|
+
* says it has got, and how much longer that implies.
|
|
74
|
+
*
|
|
75
|
+
* X1 flash-m62 (2026-10-16) spent three 290 s waits on one 8.3-minute suite and
|
|
76
|
+
* every answer read only "Still running after 280s": the run could not tell a
|
|
77
|
+
* suite with 30 s left from one with 6 minutes left, so the wait cadence — not
|
|
78
|
+
* the suite — set the pace, and the run learned nothing for its 14 minutes of
|
|
79
|
+
* waiting. The elapsed time comes from the task id, the progress from the log
|
|
80
|
+
* this wait already read, and the time left is the rate so far, said as such.
|
|
81
|
+
*
|
|
82
|
+
* @param o - The wait's facts: `taskId`, the `slice` of the log it read,
|
|
83
|
+
* `stillWriting` (the log grew during this wait), `bytes` (the log's size, null
|
|
84
|
+
* when unreadable), and `ceilingMs` (this call's ceiling, for "one more wait").
|
|
85
|
+
* @returns A sentence to append to a running note, or '' when nothing is known.
|
|
86
|
+
*/
|
|
87
|
+
function waitProgressNote(o) {
|
|
88
|
+
const startedAt = taskStartedAt(o.taskId);
|
|
89
|
+
const elapsedMs = startedAt === null ? null : Math.max(0, Date.now() - startedAt);
|
|
90
|
+
const parts = [];
|
|
91
|
+
if (elapsedMs !== null)
|
|
92
|
+
parts.push(`it has been running ${durationText(elapsedMs)}`);
|
|
93
|
+
if (o.stillWriting)
|
|
94
|
+
parts.push(`its output is still growing (${o.bytes} bytes) — slow, not stuck`);
|
|
95
|
+
else if (o.bytes !== null)
|
|
96
|
+
parts.push(`its log has not grown during this wait (${o.bytes} bytes)`);
|
|
97
|
+
const counter = logProgress(o.slice);
|
|
98
|
+
if (counter) {
|
|
99
|
+
parts.push(`its log is at ${counter.done} of ${counter.total}`);
|
|
100
|
+
if (elapsedMs !== null && counter.done > 0 && counter.done < counter.total) {
|
|
101
|
+
const leftMs = Math.round((elapsedMs * (counter.total - counter.done)) / counter.done);
|
|
102
|
+
parts.push(leftMs <= o.ceilingMs
|
|
103
|
+
? `about ${durationText(leftMs)} left at that rate — one more wait covers it`
|
|
104
|
+
: `about ${durationText(leftMs)} left at that rate (${Math.ceil(leftMs / o.ceilingMs)} more waits)`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!parts.length)
|
|
108
|
+
return '';
|
|
109
|
+
const sentence = parts.join('; ');
|
|
110
|
+
return ` ${sentence[0].toUpperCase()}${sentence.slice(1)}.`;
|
|
111
|
+
}
|
|
112
|
+
import { checkBlockedCommand, declaredBudgetSeconds, DEFAULT_SEARCH_EXCLUDED_DIRS, directoryReadHint, fusedLine, isEnvFilePath, isOutsideRoot, isValidGlob, MAX_BATCH_READ_BYTES, MAX_BATCH_READ_FILES, MAX_FIND_RESULTS, MAX_OUTPUT_SIZE, MAX_READ_RETURN_CHARS, MAX_READ_SIZE, MAX_SEARCH_CONTEXT_LINES, MAX_SEARCH_RESULTS, MAX_WRITE_SIZE, outputIsWithheldUntilExit, outputRedirectTarget, parseCheckCommand, parseCheckProblem, pathArgError, printsOnlySource, redactSecrets, redactSecretsInCode, resolvePath, RUNNING_FAILURE_LINES, runningOutputSliceCommand, shellQuote, sliceLines, SMALL_FILE_WHOLE_CHARS, streamingBackgroundCommand, stripControlChars, truncateMiddle, whitespaceTolerantReplace, } from './utilities.js';
|
|
29
113
|
/**
|
|
30
114
|
* How long exec_command waits for a command it backgrounded only because the
|
|
31
115
|
* command asked for more than the tool's ceiling, before handing back a task id.
|
|
32
116
|
*/
|
|
33
117
|
const OVER_CEILING_GRACE_MS = 45_000;
|
|
118
|
+
/**
|
|
119
|
+
* The steer attached to a SUCCESSFUL read of a package's built output. The ENOENT
|
|
120
|
+
* redirect below fires only when the read FAILS, so a model that knows the hoisted
|
|
121
|
+
* layout reads `node_modules/@molecule/<pkg>/dist/*.js` happily and never learns the
|
|
122
|
+
* documented API is one call away (X0 flash-m70: 24 such reads in 57 s, no note on
|
|
123
|
+
* any of them). Carried as `distNote` rather than merged into `note` so a host that
|
|
124
|
+
* has read_molecule_doc (molecule-dev's cadence note, which also names the functions)
|
|
125
|
+
* can supersede it without string-matching.
|
|
126
|
+
*
|
|
127
|
+
* @param path - The resolved path that was read.
|
|
128
|
+
* @returns The note, or null when the path is not inside a package's `dist`.
|
|
129
|
+
*/
|
|
130
|
+
export function moleculeDistReadNote(path) {
|
|
131
|
+
const pkg = /node_modules\/@molecule\/([a-z0-9-]+)\/dist\//.exec(path)?.[1];
|
|
132
|
+
if (!pkg)
|
|
133
|
+
return null;
|
|
134
|
+
return (`This is @molecule/${pkg}'s compiled dist, not its API: read_molecule_doc("@molecule/${pkg}") ` +
|
|
135
|
+
'returns its README.md — the functions, options and bond wiring — in one call.' +
|
|
136
|
+
(/\.d\.ts$/.test(path)
|
|
137
|
+
? ' The declarations above are generated from it.'
|
|
138
|
+
: ' For one signature, read the matching .d.ts file.'));
|
|
139
|
+
}
|
|
34
140
|
/**
|
|
35
141
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
36
142
|
*
|
|
@@ -135,6 +241,8 @@ export function buildTools(backend, config) {
|
|
|
135
241
|
}
|
|
136
242
|
/** Paths read with a window in this tool set, to tell paging from one targeted read. */
|
|
137
243
|
const windowedReads = new Set();
|
|
244
|
+
/** The text each path was last handed back WHOLE as, so that correction fires once per version. */
|
|
245
|
+
const wholeReturned = new Map();
|
|
138
246
|
/**
|
|
139
247
|
* Verify that `path` does not symlink outside the project root when guards are enabled.
|
|
140
248
|
*
|
|
@@ -365,6 +473,10 @@ export function buildTools(backend, config) {
|
|
|
365
473
|
const symlinkErr = await checkSymlink(path);
|
|
366
474
|
if (symlinkErr)
|
|
367
475
|
return { error: symlinkErr };
|
|
476
|
+
// Carried on every successful read of a package's built output, so no dist read
|
|
477
|
+
// goes out unsteered (see moleculeDistReadNote).
|
|
478
|
+
const distNote = moleculeDistReadNote(path);
|
|
479
|
+
const noted = (r) => distNote ? { ...r, distNote } : r;
|
|
368
480
|
try {
|
|
369
481
|
const content = await backend.readFile(path);
|
|
370
482
|
// A read that produced no usable text is verified against the file's real
|
|
@@ -376,12 +488,12 @@ export function buildTools(backend, config) {
|
|
|
376
488
|
const verdict = await classifyEmptyRead(path, 'read_file');
|
|
377
489
|
if ('error' in verdict)
|
|
378
490
|
return verdict;
|
|
379
|
-
return {
|
|
491
|
+
return noted({
|
|
380
492
|
path,
|
|
381
493
|
content: sanitizeFileContent(verdict.content, path),
|
|
382
494
|
...(verdict.content === '' ? { empty: true } : {}),
|
|
383
495
|
...(verdict.note ? { note: verdict.note } : {}),
|
|
384
|
-
};
|
|
496
|
+
});
|
|
385
497
|
}
|
|
386
498
|
if (content.length > MAX_READ_SIZE)
|
|
387
499
|
return {
|
|
@@ -395,21 +507,32 @@ export function buildTools(backend, config) {
|
|
|
395
507
|
: window;
|
|
396
508
|
// A SECOND window of the same small file: hand back all of it (one window is a targeted read). X1 flash-m50 paged files that fit in one
|
|
397
509
|
// call 13 times in a row, each window answered with "read it again with no offset/limit".
|
|
510
|
+
// The bound is the one that note states (SMALL_FILE_WHOLE_CHARS = MAX_READ_RETURN_CHARS): 24 KB left
|
|
511
|
+
// RealWorld's 24,935-byte openapi.yml paged nine times under "fits in ONE call" (flash-m67).
|
|
398
512
|
const small = sliceLines(content, window);
|
|
399
513
|
const pagedBefore = small?.truncated ? windowedReads.has(path) : false;
|
|
400
514
|
if (small?.truncated)
|
|
401
515
|
windowedReads.add(path);
|
|
402
|
-
|
|
403
|
-
|
|
516
|
+
// Once per version of the file: after the whole text went back, a later window is a
|
|
517
|
+
// targeted read and gets its window (X1 flash-m67: 25 firings, 8 byte-identical repeats).
|
|
518
|
+
const sentWhole = wholeReturned.get(path) === content;
|
|
519
|
+
if (small?.truncated &&
|
|
520
|
+
pagedBefore &&
|
|
521
|
+
!sentWhole &&
|
|
522
|
+
content.length <= SMALL_FILE_WHOLE_CHARS) {
|
|
523
|
+
wholeReturned.set(path, content);
|
|
524
|
+
return noted({
|
|
404
525
|
path,
|
|
405
526
|
content: sanitizeFileContent(content, path),
|
|
527
|
+
offset: 1,
|
|
528
|
+
lines: small.totalLines,
|
|
406
529
|
totalLines: small.totalLines,
|
|
407
530
|
note: `The whole file is returned (${Math.round(content.length / 1024)} KB, ${small.totalLines} lines): a file this small fits in one call, so it is not paged.`,
|
|
408
|
-
};
|
|
531
|
+
});
|
|
409
532
|
}
|
|
410
533
|
const windowed = sliceLines(content, effectiveWindow);
|
|
411
534
|
if (windowed) {
|
|
412
|
-
return {
|
|
535
|
+
return noted({
|
|
413
536
|
path,
|
|
414
537
|
content: sanitizeFileContent(windowed.text, path),
|
|
415
538
|
offset: windowed.offset,
|
|
@@ -422,18 +545,21 @@ export function buildTools(backend, config) {
|
|
|
422
545
|
// exact next window so the executor does not have to compute it.
|
|
423
546
|
...(windowed.truncated
|
|
424
547
|
? {
|
|
425
|
-
note:
|
|
426
|
-
? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
548
|
+
note: sentWhole
|
|
549
|
+
? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ${windowed.totalLines}; ` +
|
|
550
|
+
'the whole file, unchanged, was returned earlier in this conversation.'
|
|
551
|
+
: content.length <= MAX_READ_RETURN_CHARS
|
|
552
|
+
? `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
|
|
553
|
+
`${windowed.totalLines}. The whole file is ${Math.round(content.length / 1024)} KB ` +
|
|
554
|
+
`and fits in ONE call — read it again with no offset/limit instead of paging.`
|
|
555
|
+
: `Lines ${windowed.offset}-${windowed.offset + windowed.lines - 1} of ` +
|
|
556
|
+
`${windowed.totalLines}. Next window: offset ${windowed.offset + windowed.lines}, ` +
|
|
557
|
+
`limit ${windowed.lines}.`,
|
|
432
558
|
}
|
|
433
559
|
: {}),
|
|
434
|
-
};
|
|
560
|
+
});
|
|
435
561
|
}
|
|
436
|
-
return { path, content: sanitizeFileContent(content, path) };
|
|
562
|
+
return noted({ path, content: sanitizeFileContent(content, path) });
|
|
437
563
|
}
|
|
438
564
|
catch (e) {
|
|
439
565
|
// Backends (e.g. the docker provider's `cat`) already prefix "Failed to read <path>: ";
|
|
@@ -921,7 +1047,15 @@ export function buildTools(backend, config) {
|
|
|
921
1047
|
...(fusedNote
|
|
922
1048
|
? { syntaxError: fusedNote }
|
|
923
1049
|
: {
|
|
924
|
-
|
|
1050
|
+
// The evidence for this note is `located`, not `dropped`: a drop joins
|
|
1051
|
+
// two lines only when something other than a newline (or EOF) follows
|
|
1052
|
+
// the replaced text. A weaker model writes one line's change as two
|
|
1053
|
+
// replacements — the line including its trailing newline, then the
|
|
1054
|
+
// line's own text — and the newline it consumed ended that line with a
|
|
1055
|
+
// BLANK line after it, so nothing joined while the model was told it
|
|
1056
|
+
// had: it spent a new round trip putting the blank line back
|
|
1057
|
+
// (flash-m62). Say nothing when nothing was joined.
|
|
1058
|
+
...(dropped.length > 0 && located.length > 0
|
|
925
1059
|
? {
|
|
926
1060
|
newlineNote: `${dropped.length === 1 ? 'A replacement' : `${dropped.length} replacements`} removed the line break after the replaced text (old_string ended with a newline, new_string does not), so the next line now continues on the same line. Check the excerpt; add the newline back if that was not intended.`,
|
|
927
1061
|
}
|
|
@@ -966,7 +1100,9 @@ export function buildTools(backend, config) {
|
|
|
966
1100
|
// search found nothing (X1 run sol-m07). A pattern that is not a valid
|
|
967
1101
|
// extended regex (`foo(` — an unbalanced paren) makes grep exit 2; it is
|
|
968
1102
|
// then searched as the plain text it was meant as.
|
|
969
|
-
|
|
1103
|
+
// -H: a search scoped to one file printed `line:content` with no file, and every match
|
|
1104
|
+
// came back `{file: "", line: 0}` (X1 flash-m62).
|
|
1105
|
+
const grepArgs = `-rnH ${globArg} ${contextArg} ${grepExcludeArgs(path)} --max-count=${MAX_SEARCH_RESULTS} -- ${shellQuote(pattern)} ${shellQuote(path)}`;
|
|
970
1106
|
const result = await backend.run(`{ grep -E ${grepArgs}; s=$?; if [ "$s" -eq 2 ]; then grep -F ${grepArgs}; fi; } 2>/dev/null || true`, { timeout: 10000 });
|
|
971
1107
|
// grep emits `<file>:<line>:<content>`, so redaction runs PER MATCH on the
|
|
972
1108
|
// content alone: the file prefix would otherwise hide an env assignment from
|
|
@@ -1268,7 +1404,12 @@ export function buildTools(backend, config) {
|
|
|
1268
1404
|
// truncateMiddle (not truncate): a failing build/test/migration puts its
|
|
1269
1405
|
// error at the TAIL, so keep the head AND the tail — head-only truncation
|
|
1270
1406
|
// strands the executor with passing progress and no failure reason.
|
|
1271
|
-
|
|
1407
|
+
// A command that only prints source files returns file content: the code-safe grade,
|
|
1408
|
+
// as read_file uses (utilities.ts printsOnlySource).
|
|
1409
|
+
const grade = printsOnlySource(command)
|
|
1410
|
+
? (s) => sanitizeFileContent(s, 'printed.ts')
|
|
1411
|
+
: sanitizeOutput;
|
|
1412
|
+
const stdout = grade(truncateMiddle(result.stdout, MAX_OUTPUT_SIZE));
|
|
1272
1413
|
const stderr = sanitizeOutput(truncateMiddle(result.stderr, MAX_OUTPUT_SIZE));
|
|
1273
1414
|
if (budgetSeconds && result.exitCode === 124) {
|
|
1274
1415
|
return {
|
|
@@ -1351,6 +1492,33 @@ export function buildTools(backend, config) {
|
|
|
1351
1492
|
// server-side costs no round trip and no context.
|
|
1352
1493
|
async wait_for_task(input) {
|
|
1353
1494
|
const taskId = typeof input.taskId === 'string' ? input.taskId.trim() : '';
|
|
1495
|
+
// Where a task writes its own output is process memory; the task is not.
|
|
1496
|
+
// An API process swapped mid-run (a release, a patch, a crash) leaves the
|
|
1497
|
+
// command running in the sandbox with nothing left that remembers the file
|
|
1498
|
+
// it redirects to — and then `wait_for_task` returns the task's near-empty
|
|
1499
|
+
// log as the command's whole result, which is the X1 flash-m48 loss all
|
|
1500
|
+
// over again (X1 flash-m63's +43:20 restart: the public walk's handle
|
|
1501
|
+
// outlived the process, its redirect target did not). The script the task
|
|
1502
|
+
// was started from is still on disk and carries the command, so read the
|
|
1503
|
+
// target back from there. The id is format-checked before any /tmp path is
|
|
1504
|
+
// built from it, exactly as the reads below are.
|
|
1505
|
+
if (!outputFileByTask.has(taskId) &&
|
|
1506
|
+
!outputFileProbed.has(taskId) &&
|
|
1507
|
+
/^mol-bg-[a-z0-9]+-[a-z0-9]+$/.test(taskId)) {
|
|
1508
|
+
outputFileProbed.add(taskId);
|
|
1509
|
+
try {
|
|
1510
|
+
const target = outputRedirectTarget(await backend.readFile(`/tmp/${taskId}.sh`));
|
|
1511
|
+
// A relative target was written against the command's own cwd, which
|
|
1512
|
+
// the handle does not carry; the project root is where a background
|
|
1513
|
+
// command runs unless the caller passed one.
|
|
1514
|
+
if (target)
|
|
1515
|
+
outputFileByTask.set(taskId, target.startsWith('/') ? target : `${root}/${target}`);
|
|
1516
|
+
}
|
|
1517
|
+
catch (_error) {
|
|
1518
|
+
// No script under that id: this process did not start the task, so
|
|
1519
|
+
// there is nothing to recover. The log stays the whole result.
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1354
1522
|
// A command that has been waited on for stuckAfterMs() in total without
|
|
1355
1523
|
// exiting is not going to: on one run the executor waited five times,
|
|
1356
1524
|
// 120 s each, on an e2e suite that could never start (no browser page
|
|
@@ -1432,16 +1600,44 @@ export function buildTools(backend, config) {
|
|
|
1432
1600
|
const lastSize = logBytesByTask.get(taskId);
|
|
1433
1601
|
if (grownTo !== null)
|
|
1434
1602
|
logBytesByTask.set(taskId, grownTo);
|
|
1603
|
+
// Evidence of liveness, read the same way by every answer below: a log
|
|
1604
|
+
// that grew during THIS wait means the command is working. On the first
|
|
1605
|
+
// wait any output at all counts (there is no earlier size to compare).
|
|
1435
1606
|
const stillWriting = grownTo !== null && (lastSize === undefined ? grownTo > 0 : grownTo > lastSize);
|
|
1607
|
+
// The log's own slice, read ONCE for whichever answer follows: it is what
|
|
1608
|
+
// the progress sentence is built from, and a wait that showed nothing was
|
|
1609
|
+
// waited blind (X1 flash-m50: 4m42s across two waits on an 11-minute run).
|
|
1610
|
+
// The slice leads with the failure lines, not the last bytes — a suite's
|
|
1611
|
+
// tail is its progress lines (X1 flash-m61: the last 3 000 bytes of a
|
|
1612
|
+
// 139-test walk were the skip list).
|
|
1613
|
+
const own = outputFileByTask.get(taskId);
|
|
1614
|
+
let soFar = '';
|
|
1615
|
+
try {
|
|
1616
|
+
const r = await backend.run(runningOutputSliceCommand(log, own), { timeout: 5_000 });
|
|
1617
|
+
soFar = r.stdout ?? '';
|
|
1618
|
+
}
|
|
1619
|
+
catch (_error) {
|
|
1620
|
+
// No slice: the notes still say how to peek, and the task id still
|
|
1621
|
+
// carries how long the command has been running.
|
|
1622
|
+
}
|
|
1623
|
+
const progress = waitProgressNote({
|
|
1624
|
+
taskId,
|
|
1625
|
+
slice: soFar,
|
|
1626
|
+
stillWriting,
|
|
1627
|
+
bytes: grownTo,
|
|
1628
|
+
ceilingMs: ceiling,
|
|
1629
|
+
});
|
|
1630
|
+
const sliceField = soFar.trim() ? { outputSoFar: sanitizeOutput(soFar) } : {};
|
|
1436
1631
|
if (stillWriting && waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
1437
1632
|
waitedByTask.set(taskId, 0);
|
|
1438
1633
|
return {
|
|
1439
1634
|
taskId,
|
|
1440
1635
|
status: 'running',
|
|
1441
1636
|
waitedMs,
|
|
1637
|
+
...sliceField,
|
|
1442
1638
|
note: `Still running after ${Math.round((waitedBefore + waitedMs) / 1000)}s of waiting, and its ` +
|
|
1443
|
-
`output is still growing (${grownTo} bytes) — slow, not stuck
|
|
1444
|
-
`keep waiting; peek with exec_command: tail ${log}.`,
|
|
1639
|
+
`output is still growing (${grownTo} bytes) — slow, not stuck.${progress} Call wait_for_task ` +
|
|
1640
|
+
`again to keep waiting; peek with exec_command: tail ${log}.`,
|
|
1445
1641
|
};
|
|
1446
1642
|
}
|
|
1447
1643
|
if (waitedBefore + waitedMs >= stuckAfterMs()) {
|
|
@@ -1455,26 +1651,19 @@ export function buildTools(backend, config) {
|
|
|
1455
1651
|
`kill $(cat /tmp/${taskId}.pid) 2>/dev/null; pkill -f ${taskId}.sh) and move on; do not wait on it again.`,
|
|
1456
1652
|
};
|
|
1457
1653
|
}
|
|
1458
|
-
// The output so far rides the result: a wait that shows nothing was waited blind
|
|
1459
|
-
// (X1 flash-m50: 4m42s across two waits on an 11-minute run).
|
|
1460
|
-
const own = outputFileByTask.get(taskId);
|
|
1461
|
-
let soFar;
|
|
1462
|
-
try {
|
|
1463
|
-
const r = await backend.run(`cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null | tail -c 3000`, { timeout: 5_000 });
|
|
1464
|
-
soFar = r.stdout ?? '';
|
|
1465
|
-
}
|
|
1466
|
-
catch (_error) {
|
|
1467
|
-
// No tail: the note still says how to peek.
|
|
1468
|
-
soFar = '';
|
|
1469
|
-
}
|
|
1470
1654
|
return {
|
|
1471
1655
|
taskId,
|
|
1472
1656
|
status: 'running',
|
|
1473
1657
|
waitedMs,
|
|
1474
|
-
...
|
|
1475
|
-
note: `Still running after ${Math.round(waitedMs / 1000)}s
|
|
1476
|
-
`keep waiting — its result carries the log output
|
|
1477
|
-
|
|
1658
|
+
...sliceField,
|
|
1659
|
+
note: `Still running after ${Math.round(waitedMs / 1000)}s.${progress} Call wait_for_task again to ` +
|
|
1660
|
+
`keep waiting — its result carries the log output. ` +
|
|
1661
|
+
(soFar.trim()
|
|
1662
|
+
? `outputSoFar is the failure lines so far (up to ${RUNNING_FAILURE_LINES}) and then the end of ` +
|
|
1663
|
+
`the log, not the whole run — \`grep -n '✘' ${log} | head -40\` lists every failure, ` +
|
|
1664
|
+
`\`grep -n -A 20 '<test name>' ${log}\` reads one failure's error. `
|
|
1665
|
+
: `Nothing has been printed yet — \`grep -n '✘' ${log} | head -40\` lists failures as they appear. `) +
|
|
1666
|
+
`Meanwhile do work that leaves the code under test alone (read the failures so far, plan the fixes): editing app/src or api/src now changes the code the run is testing, and its result no longer counts.`,
|
|
1478
1667
|
};
|
|
1479
1668
|
}
|
|
1480
1669
|
let output;
|
package/dist/utilities.d.ts
CHANGED
|
@@ -55,6 +55,17 @@ export declare function redactSecrets(s: string): string;
|
|
|
55
55
|
* @returns A redacted copy that preserves ordinary code verbatim.
|
|
56
56
|
*/
|
|
57
57
|
export declare function redactSecretsInCode(s: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
|
|
60
|
+
* `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
|
|
61
|
+
* read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
|
|
62
|
+
* came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
|
|
63
|
+
* flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
|
|
64
|
+
*
|
|
65
|
+
* @param command - The shell command.
|
|
66
|
+
* @returns True when every segment prints source files only.
|
|
67
|
+
*/
|
|
68
|
+
export declare function printsOnlySource(command: string): boolean;
|
|
58
69
|
/**
|
|
59
70
|
* Whether a path is an env file, for which {@link redactSecrets}' full env-dump
|
|
60
71
|
* treatment is appropriate rather than {@link redactSecretsInCode}. Matches
|
|
@@ -108,9 +119,16 @@ export declare function outputRedirectTarget(command: string): string | null;
|
|
|
108
119
|
* - a trailing `| tail -N`, which holds everything back until the command ends.
|
|
109
120
|
* It is dropped: `wait_for_task` already returns the end of the log.
|
|
110
121
|
* - `playwright test` with no `--reporter`, which runs the config's reporter —
|
|
111
|
-
* often `html`, which prints nothing per test. `--reporter=
|
|
122
|
+
* often `html`, which prints nothing per test. `--reporter=list` is added
|
|
112
123
|
* when the playwright run is the command's last step and nothing is piped or
|
|
113
|
-
* redirected from it
|
|
124
|
+
* redirected from it: it writes each result as it finishes, and it is the
|
|
125
|
+
* shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
|
|
126
|
+
* fail-fast note and the Tests bar both parse `list` lines). A run that names
|
|
127
|
+
* `--reporter=line` is switched to `list` for the same reason — `line` writes
|
|
128
|
+
* `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
|
|
129
|
+
* 8.3-minute walk of one suite could never trip the fail-fast note.
|
|
130
|
+
*
|
|
131
|
+
* Only the command's last step is touched, and only its reporter.
|
|
114
132
|
*
|
|
115
133
|
* @param command - The command as the executor sent it.
|
|
116
134
|
* @returns The command to run and one sentence per change made (empty when none).
|
|
@@ -249,7 +267,11 @@ export declare const MAX_BATCH_READ_BYTES: number;
|
|
|
249
267
|
* needs.
|
|
250
268
|
*/
|
|
251
269
|
export declare const MAX_READ_RETURN_CHARS: number;
|
|
252
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* A file this small is returned whole on a second window of it (paging it costs a call each).
|
|
272
|
+
* The same bound as {@link MAX_READ_RETURN_CHARS}: the windowed-read note promises that a
|
|
273
|
+
* file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
|
|
274
|
+
*/
|
|
253
275
|
export declare const SMALL_FILE_WHOLE_CHARS: number;
|
|
254
276
|
/** Max search results. */
|
|
255
277
|
export declare const MAX_SEARCH_RESULTS = 50;
|
|
@@ -296,6 +318,29 @@ export declare function truncateMiddle(s: string, maxLength: number): string;
|
|
|
296
318
|
* @returns Each block, in order (empty when there are none).
|
|
297
319
|
*/
|
|
298
320
|
export declare function failureBlocks(s: string): string[];
|
|
321
|
+
/** Failure lines shown from a log that is still being written. */
|
|
322
|
+
export declare const RUNNING_FAILURE_LINES = 40;
|
|
323
|
+
/** Bytes of a running log's end appended after its failure lines. */
|
|
324
|
+
export declare const RUNNING_TAIL_BYTES = 1000;
|
|
325
|
+
/**
|
|
326
|
+
* The lines worth showing from a log that is still being written: a failed test's mark or
|
|
327
|
+
* numbered block header, and a runner's own error lines. Matched with `grep -E`.
|
|
328
|
+
*/
|
|
329
|
+
export declare const RUNNING_FAILURE_LINE_RE = "^[[:space:]]*(\u2718|\u2717|\u00D7|\u2716|\u2715|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)";
|
|
330
|
+
/**
|
|
331
|
+
* The shell command whose stdout is the interesting part of a log that is still being written:
|
|
332
|
+
* the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
|
|
333
|
+
*
|
|
334
|
+
* A Playwright `list` run prints one line per test as it finishes and its error blocks only at
|
|
335
|
+
* the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
|
|
336
|
+
* the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
|
|
337
|
+
* them, which cost the run the grep it had to make anyway.
|
|
338
|
+
*
|
|
339
|
+
* @param log - The task log path.
|
|
340
|
+
* @param own - A file the command redirects its own output to, if any.
|
|
341
|
+
* @returns The shell command, safe to hand to the execution backend as-is.
|
|
342
|
+
*/
|
|
343
|
+
export declare function runningOutputSliceCommand(log: string, own?: string): string;
|
|
299
344
|
/**
|
|
300
345
|
* Attempt a whitespace-tolerant replacement when an exact `old_string` match
|
|
301
346
|
* failed. Finds a contiguous run of lines in `content` whose per-line
|
package/dist/utilities.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../src/utilities.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAS5C;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,GAAG,MAAM,KAAG,MAEE,CAAA;
|
|
1
|
+
{"version":3,"file":"utilities.d.ts","sourceRoot":"","sources":["../src/utilities.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAS5C;AAED;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,GAAG,MAAM,KAAG,MAEE,CAAA;AAuGhD;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK/C;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAErD;AAKD;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAsCzD;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,GAAG,MAAM,CAUpF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAElE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAWnE;AAQD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG;IAC3D,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,EAAE,CAAA;CAClB,CA0CA;AA4BD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAuBlE;AAID;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAQrE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAOxE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAQpD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAIvE;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI9E;AAID,yCAAyC;AACzC,eAAO,MAAM,aAAa,QAAkB,CAAA;AAC5C,8CAA8C;AAC9C,eAAO,MAAM,cAAc,QAAmB,CAAA;AAC9C,kDAAkD;AAClD,eAAO,MAAM,eAAe,QAAa,CAAA;AACzC;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,KAAK,CAAA;AAE1C;;;;;;;;;;;;GAYG;AACH,wBAAgB,UAAU,CACxB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CA6BhG;AAED,0BAA0B;AAC1B;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC;;;GAGG;AACH,eAAO,MAAM,oBAAoB,QAAa,CAAA;AAE9C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB,QAAY,CAAA;AAE9C;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,QAAwB,CAAA;AAE3D,0BAA0B;AAC1B,eAAO,MAAM,kBAAkB,KAAK,CAAA;AACpC,wBAAwB;AACxB,eAAO,MAAM,gBAAgB,MAAM,CAAA;AAEnC;;;;;;;GAOG;AACH,eAAO,MAAM,4BAA4B,mHAW/B,CAAA;AAEV;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAOnE;AAwED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAiBjD;AAED,kEAAkE;AAClE,eAAO,MAAM,qBAAqB,KAAK,CAAA;AAEvC,qEAAqE;AACrE,eAAO,MAAM,kBAAkB,OAAO,CAAA;AAEtC;;;GAGG;AACH,eAAO,MAAM,uBAAuB,0KAC4G,CAAA;AAEhJ;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAM3E;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CAsCf;AAED,qEAAqE;AACrE,eAAO,MAAM,eAAe,QAA0C,CAAA;AAEtE;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgB7D;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE;IACxC,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;CACjB,GAAG,MAAM,GAAG,IAAI,CAKhB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CACvB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAChB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAgBvC"}
|
package/dist/utilities.js
CHANGED
|
@@ -98,9 +98,15 @@ const NON_SECRET_VALUES = new Set([
|
|
|
98
98
|
* @param quote - The quote character the value uses (`"` or `'`).
|
|
99
99
|
* @returns A String.replace replacer preserving allowlisted values.
|
|
100
100
|
*/
|
|
101
|
-
const jsonValueReplacer = (quote) => (match, prefix) => {
|
|
101
|
+
const jsonValueReplacer = (quote) => (match, prefix, offset, whole) => {
|
|
102
102
|
const value = match.slice(prefix.length);
|
|
103
103
|
const inner = value.slice(1, -1);
|
|
104
|
+
// A TypeScript type literal is never a credential: `type AuthState = 'authenticated' | …`
|
|
105
|
+
// (X1 flash-m58). A union member is followed by `|`; a declaration starts `type`.
|
|
106
|
+
if (/^\s*\|/.test(whole.slice(offset + match.length)))
|
|
107
|
+
return match;
|
|
108
|
+
if (/\btype\s+$/.test(whole.slice(Math.max(0, offset - 16), offset)))
|
|
109
|
+
return match;
|
|
104
110
|
// An empty value hides nothing, and a suite fixture's `"password": ""` is the fact a
|
|
105
111
|
// failing assertion turns on (X1 flash-m52).
|
|
106
112
|
// A version (`"jsonwebtoken": "9.0.2"`, a package name that holds a keyword) is not a
|
|
@@ -153,6 +159,61 @@ export function redactSecrets(s) {
|
|
|
153
159
|
export function redactSecretsInCode(s) {
|
|
154
160
|
return s.replace(SECRET_ENV_ASSIGNMENT, '$1=[REDACTED]');
|
|
155
161
|
}
|
|
162
|
+
/** Source-file extensions a printing command's output is read as code for. */
|
|
163
|
+
const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?|css|scss|html|vue|svelte|md|sql|hurl)$/;
|
|
164
|
+
/**
|
|
165
|
+
* Whether a shell command only PRINTS source files (`cat a.tsx`, `sed -n '1,40p' x.ts`,
|
|
166
|
+
* `head -50 b.hurl`): its output is file content and gets the code-safe redaction, like
|
|
167
|
+
* read_file. Through the env-dump grade a printed `type AuthState = 'authenticated' | …`
|
|
168
|
+
* came back as `'[REDACTED]'`, and the executor wrote the mask into a new file (X1
|
|
169
|
+
* flash-m58). Any segment that is not such a print, or any env file named, keeps the full grade.
|
|
170
|
+
*
|
|
171
|
+
* @param command - The shell command.
|
|
172
|
+
* @returns True when every segment prints source files only.
|
|
173
|
+
*/
|
|
174
|
+
export function printsOnlySource(command) {
|
|
175
|
+
if (/\.env\b/.test(command))
|
|
176
|
+
return false;
|
|
177
|
+
const segments = command
|
|
178
|
+
.split(/\s*(?:&&|;|\n)\s*/)
|
|
179
|
+
.map((s) => s.trim())
|
|
180
|
+
.filter((s) => s && !/^cd\s/.test(s));
|
|
181
|
+
if (segments.length === 0)
|
|
182
|
+
return false;
|
|
183
|
+
return segments.every((seg) => {
|
|
184
|
+
// Words, quotes kept whole: a grep pattern like "sign\\|verify" is one word, not a pipe.
|
|
185
|
+
const words = seg.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g) ?? [];
|
|
186
|
+
const stages = [[]];
|
|
187
|
+
for (const w of words) {
|
|
188
|
+
if (w === '|')
|
|
189
|
+
stages.push([]);
|
|
190
|
+
else if (!/^\d?>&\d$|^2>\/dev\/null$/.test(w))
|
|
191
|
+
stages[stages.length - 1].push(w);
|
|
192
|
+
}
|
|
193
|
+
const [first, ...rest] = stages;
|
|
194
|
+
const head = first[0];
|
|
195
|
+
// A print of named source files: cat/head/tail/nl/sed -n, or a non-recursive grep/rg
|
|
196
|
+
// (X1 flash-m61 read a fixture's password masked through `grep -n … auth.hurl`).
|
|
197
|
+
let args = first.slice(1);
|
|
198
|
+
if (head === 'sed' && args[0] !== '-n')
|
|
199
|
+
return false;
|
|
200
|
+
if (head === 'grep' || head === 'rg') {
|
|
201
|
+
if (args.some((a) => /^-[a-zA-Z]*[rR]/.test(a) || a === '--recursive'))
|
|
202
|
+
return false;
|
|
203
|
+
const flags = args.filter((a) => a.startsWith('-'));
|
|
204
|
+
const rest2 = args.filter((a) => !a.startsWith('-'));
|
|
205
|
+
args = flags.includes('-e') ? rest2 : rest2.slice(1);
|
|
206
|
+
}
|
|
207
|
+
else if (!/^(?:cat|head|tail|nl|sed)$/.test(head ?? ''))
|
|
208
|
+
return false;
|
|
209
|
+
const files = args.filter((t) => !t.startsWith('-') && !/^'[^']*'$|^"[^"]*"$|^\d+$|^\d+,\d+p$/.test(t));
|
|
210
|
+
if (files.length === 0 || !files.every((f) => SOURCE_FILE_RE.test(f)))
|
|
211
|
+
return false;
|
|
212
|
+
// After it, only text filters that read no file of their own.
|
|
213
|
+
return rest.every((stage) => /^(?:head|tail|sort|uniq|wc|cut|nl|grep)$/.test(stage[0] ?? '') &&
|
|
214
|
+
!stage.slice(1).some((a) => !a.startsWith('-') && /\.[a-z]+$/i.test(a) && !/^['"]/.test(a)));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
156
217
|
/**
|
|
157
218
|
* Whether a path is an env file, for which {@link redactSecrets}' full env-dump
|
|
158
219
|
* treatment is appropriate rather than {@link redactSecretsInCode}. Matches
|
|
@@ -226,6 +287,8 @@ export function outputRedirectTarget(command) {
|
|
|
226
287
|
}
|
|
227
288
|
/** A trailing `| tail -N` / `| tail -n N` (stderr redirects before it stay). */
|
|
228
289
|
const TRAILING_TAIL = /\s*\|\s*tail(?:\s+-n)?\s+[-+]?\d+\s*$/;
|
|
290
|
+
/** `--reporter=line` / `--reporter line`, the one reporter the platform cannot read back. */
|
|
291
|
+
const LINE_REPORTER_FLAG = /--reporter([= ])line\b/;
|
|
229
292
|
/**
|
|
230
293
|
* Rewrite a command about to run in the background so its log grows while it
|
|
231
294
|
* runs. Two shapes left the log empty until exit, and an empty log is what
|
|
@@ -235,9 +298,16 @@ const TRAILING_TAIL = /\s*\|\s*tail(?:\s+-n)?\s+[-+]?\d+\s*$/;
|
|
|
235
298
|
* - a trailing `| tail -N`, which holds everything back until the command ends.
|
|
236
299
|
* It is dropped: `wait_for_task` already returns the end of the log.
|
|
237
300
|
* - `playwright test` with no `--reporter`, which runs the config's reporter —
|
|
238
|
-
* often `html`, which prints nothing per test. `--reporter=
|
|
301
|
+
* often `html`, which prints nothing per test. `--reporter=list` is added
|
|
239
302
|
* when the playwright run is the command's last step and nothing is piped or
|
|
240
|
-
* redirected from it
|
|
303
|
+
* redirected from it: it writes each result as it finishes, and it is the
|
|
304
|
+
* shape the platform reads a background suite back in (`suite-fail-fast.ts`'s
|
|
305
|
+
* fail-fast note and the Tests bar both parse `list` lines). A run that names
|
|
306
|
+
* `--reporter=line` is switched to `list` for the same reason — `line` writes
|
|
307
|
+
* `[n/N] <title>` with no ✓/✘ mark and no duration, so on X1 flash-m62 an
|
|
308
|
+
* 8.3-minute walk of one suite could never trip the fail-fast note.
|
|
309
|
+
*
|
|
310
|
+
* Only the command's last step is touched, and only its reporter.
|
|
241
311
|
*
|
|
242
312
|
* @param command - The command as the executor sent it.
|
|
243
313
|
* @returns The command to run and one sentence per change made (empty when none).
|
|
@@ -259,12 +329,20 @@ export function streamingBackgroundCommand(command) {
|
|
|
259
329
|
changes.push(`Dropped the trailing \`${head[0].trim()}\`: Playwright prints its pass/fail count last, and it would be cut.`);
|
|
260
330
|
}
|
|
261
331
|
const lastStep = out.split(/&&|\|\||;/).at(-1) ?? '';
|
|
262
|
-
|
|
263
|
-
!/--reporter\b/.test(lastStep) &&
|
|
332
|
+
const finalPlaywrightRun = /\bplaywright\s+test\b/.test(lastStep) &&
|
|
264
333
|
// `2>&1` only joins stderr to the log; any other pipe or redirect sends the output elsewhere.
|
|
265
|
-
!/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''))
|
|
266
|
-
|
|
267
|
-
|
|
334
|
+
!/[|>]/.test(lastStep.replace(/\d?>&\d/g, ''));
|
|
335
|
+
if (finalPlaywrightRun && LINE_REPORTER_FLAG.test(lastStep)) {
|
|
336
|
+
// Only the last step is rewritten, and `lastStep` is a suffix of `out`.
|
|
337
|
+
out =
|
|
338
|
+
out.slice(0, out.length - lastStep.length) +
|
|
339
|
+
lastStep.replace(LINE_REPORTER_FLAG, '--reporter$1list');
|
|
340
|
+
changes.push('Switched `--reporter=line` to `--reporter=list`: it also writes each test result as it ' +
|
|
341
|
+
'finishes, and it is the shape the platform reads back from a background run.');
|
|
342
|
+
}
|
|
343
|
+
else if (finalPlaywrightRun && !/--reporter\b/.test(lastStep)) {
|
|
344
|
+
out = `${out.trimEnd()} --reporter=list`;
|
|
345
|
+
changes.push('Added `--reporter=list` so each test result is written to the log as it finishes ' +
|
|
268
346
|
'(the config reporter may print nothing until the end).');
|
|
269
347
|
}
|
|
270
348
|
return { command: out, changes };
|
|
@@ -283,7 +361,8 @@ export function streamingBackgroundCommand(command) {
|
|
|
283
361
|
* a keyless build still succeeds, and blocking those sent it looking for
|
|
284
362
|
* workarounds (X0 rehearsal 14).
|
|
285
363
|
*/
|
|
286
|
-
|
|
364
|
+
// `(?<!\\)`: a backslash-escaped `\|` is a literal in the shell (grep "a\|env"), not a pipe (X1 flash-m58).
|
|
365
|
+
const BLOCKED_COMMANDS = /(?<!\\)(?:^|[;&|`]\s*|(?:sh|bash|zsh|dash)\s+-c\s+['"]?\s*)(?:\/usr\/bin\/)?(?:\benv(?:\s+(?:-0|--null|--))?\s*(?:$|[;&|>)`'"])|\bprintenv\b|\bexport\s*$|\bset\s*$|\bdeclare\s+-x|cat\s+\/etc\/environment|cat\s+\/root\/\.bashrc|cat\s+\/proc\/\d+\/environ|cat\s+\/proc\/self\/environ|strings\s+\/proc|xargs[^;&|\n]*\/proc\/[^;&|\n]*environ|less\s+\/proc|head\s+\/proc|tail\s+\/proc|xxd\s+\/proc|od\s+\/proc|base64\s+\/proc|dd\s[^\n]*\/proc|sed\s[^\n]*\/proc\/[^\n]*environ|awk\s[^\n]*\/proc\/[^\n]*environ|cp\s[^\n]*\/proc\/[^\n]*environ|(?:cat|less|head|tail|xxd|od|base64|strings|dd|cp|sed|awk)\s[^\n;&|]*\/etc\/mol(?:\/|\b))/i;
|
|
287
366
|
/** Block shell redirects from /proc environ. */
|
|
288
367
|
const BLOCKED_PROC_REDIRECT = /(?:<\s*\/proc\/(?:\d+|self)\/environ)/i;
|
|
289
368
|
/** Block shell redirects from the molecule platform secrets dir (`/etc/mol`). */
|
|
@@ -498,8 +577,12 @@ export const MAX_BATCH_READ_BYTES = 200 * 1024;
|
|
|
498
577
|
* needs.
|
|
499
578
|
*/
|
|
500
579
|
export const MAX_READ_RETURN_CHARS = 80 * 1024;
|
|
501
|
-
/**
|
|
502
|
-
|
|
580
|
+
/**
|
|
581
|
+
* A file this small is returned whole on a second window of it (paging it costs a call each).
|
|
582
|
+
* The same bound as {@link MAX_READ_RETURN_CHARS}: the windowed-read note promises that a
|
|
583
|
+
* file under it "fits in ONE call", and a smaller bound here broke that promise (X1 flash-m67).
|
|
584
|
+
*/
|
|
585
|
+
export const SMALL_FILE_WHOLE_CHARS = MAX_READ_RETURN_CHARS;
|
|
503
586
|
/** Max search results. */
|
|
504
587
|
export const MAX_SEARCH_RESULTS = 50;
|
|
505
588
|
/** Max find results. */
|
|
@@ -554,6 +637,33 @@ export function truncate(s, maxLength) {
|
|
|
554
637
|
export function truncateMiddle(s, maxLength) {
|
|
555
638
|
if (s.length <= maxLength)
|
|
556
639
|
return s;
|
|
640
|
+
// Room for the tally line it may append, so the result stays near the cap.
|
|
641
|
+
return withRunTally(s, truncateMiddleBody(s, Math.max(Math.floor(maxLength / 2), maxLength - 200)));
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* A test run's tally lines (` 3 failed`, ` 71 passed (4.6m)`) the cut text lost, put back at
|
|
645
|
+
* its end: Playwright lists every failed test after its "N failed" line, so a long list pushed
|
|
646
|
+
* the line itself into the cut middle and the run read as having no failures (X1 flash-m60).
|
|
647
|
+
*
|
|
648
|
+
* @param original - The whole output.
|
|
649
|
+
* @param cut - The truncated output.
|
|
650
|
+
* @returns The cut output, with any lost tally lines appended.
|
|
651
|
+
*/
|
|
652
|
+
function withRunTally(original, cut) {
|
|
653
|
+
const tally = [
|
|
654
|
+
...original.matchAll(/^\s*\d+ (?:failed|passed|flaky|skipped|did not run|interrupted)\b[^\n]{0,40}$/gm),
|
|
655
|
+
].map((m) => m[0].trim());
|
|
656
|
+
const lost = tally.filter((line) => !cut.includes(line));
|
|
657
|
+
return lost.length ? `${cut}\n[run tally] ${lost.join(' · ')}` : cut;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* {@link truncateMiddle} without the tally guard.
|
|
661
|
+
*
|
|
662
|
+
* @param s - The text.
|
|
663
|
+
* @param maxLength - The budget.
|
|
664
|
+
* @returns The cut text.
|
|
665
|
+
*/
|
|
666
|
+
function truncateMiddleBody(s, maxLength) {
|
|
557
667
|
// Reserve room for the elision notice so head + notice + tail stays WITHIN
|
|
558
668
|
// maxLength — the cap is a real token budget the caller relies on (a head-only
|
|
559
669
|
// truncate that overshoots by its suffix length breaks that contract). 140 =
|
|
@@ -624,6 +734,33 @@ export function failureBlocks(s) {
|
|
|
624
734
|
blocks.push(current.join('\n').trimEnd());
|
|
625
735
|
return blocks;
|
|
626
736
|
}
|
|
737
|
+
/** Failure lines shown from a log that is still being written. */
|
|
738
|
+
export const RUNNING_FAILURE_LINES = 40;
|
|
739
|
+
/** Bytes of a running log's end appended after its failure lines. */
|
|
740
|
+
export const RUNNING_TAIL_BYTES = 1000;
|
|
741
|
+
/**
|
|
742
|
+
* The lines worth showing from a log that is still being written: a failed test's mark or
|
|
743
|
+
* numbered block header, and a runner's own error lines. Matched with `grep -E`.
|
|
744
|
+
*/
|
|
745
|
+
export const RUNNING_FAILURE_LINE_RE = '^[[:space:]]*(✘|✗|×|✖|✕|[0-9]+\\)|[0-9]+ (passed|failed|flaky|skipped|did not run)|FAIL|FAILED|ERROR|Error:|error:|Traceback|AssertionError)';
|
|
746
|
+
/**
|
|
747
|
+
* The shell command whose stdout is the interesting part of a log that is still being written:
|
|
748
|
+
* the failure lines so far (capped at {@link RUNNING_FAILURE_LINES}), then the end of the log.
|
|
749
|
+
*
|
|
750
|
+
* A Playwright `list` run prints one line per test as it finishes and its error blocks only at
|
|
751
|
+
* the end, so the LAST bytes of a running log are its least informative part — in X1 flash-m61
|
|
752
|
+
* the last 3 000 bytes of a 139-test walk were the skip list, and all 61 failures were above
|
|
753
|
+
* them, which cost the run the grep it had to make anyway.
|
|
754
|
+
*
|
|
755
|
+
* @param log - The task log path.
|
|
756
|
+
* @param own - A file the command redirects its own output to, if any.
|
|
757
|
+
* @returns The shell command, safe to hand to the execution backend as-is.
|
|
758
|
+
*/
|
|
759
|
+
export function runningOutputSliceCommand(log, own) {
|
|
760
|
+
const read = `cat ${log}${own ? ` ${shellQuote(own)}` : ''} 2>/dev/null`;
|
|
761
|
+
return (`{ ${read} | grep -aE ${shellQuote(RUNNING_FAILURE_LINE_RE)} | head -${RUNNING_FAILURE_LINES} | cut -c1-300; ` +
|
|
762
|
+
`echo; ${read} | tail -c ${RUNNING_TAIL_BYTES}; }`);
|
|
763
|
+
}
|
|
627
764
|
/**
|
|
628
765
|
* Attempt a whitespace-tolerant replacement when an exact `old_string` match
|
|
629
766
|
* failed. Finds a contiguous run of lines in `content` whose per-line
|