@molecule/api-ai-tools 1.6.4 → 1.6.5
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 +210 -3
- package/dist/command-position.d.ts +1 -1
- package/dist/command-position.d.ts.map +1 -1
- package/dist/command-position.js +55 -21
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/suite-overlap.d.ts +132 -0
- package/dist/suite-overlap.d.ts.map +1 -0
- package/dist/suite-overlap.js +208 -0
- package/dist/tools.d.ts +25 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +215 -14
- package/dist/utilities.d.ts +38 -2
- package/dist/utilities.d.ts.map +1 -1
- package/dist/utilities.js +76 -12
- package/package.json +1 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE rule that decides whether a suite run may start while another is already walking, and
|
|
3
|
+
* the command readings both sides of it need.
|
|
4
|
+
*
|
|
5
|
+
* Two sites ask "may a second run start now?" about the same pair of runs — the mid-run note that
|
|
6
|
+
* tells the executor what to do next while the suite it is waiting on is still going
|
|
7
|
+
* ({@link ./utilities.js} `repeatingFailureNote`), and the hold that refuses the run when it
|
|
8
|
+
* arrives (molecule-dev's `suite-overlap-hold.ts`) — and until xr-molecule-flash-m89 they answered
|
|
9
|
+
* it separately. That run's +19:59 `wait_for_task` handed over a repeating cause with "the test
|
|
10
|
+
* alone reproduces the first one … start on them now, while the run keeps walking"; 23 s later the
|
|
11
|
+
* executor sent exactly that re-run, and the overlap hold refused it — "A playwright suite is
|
|
12
|
+
* already running against this app and database … Wait for it." Two orders, one run, and the round
|
|
13
|
+
* trip between them was the whole cost.
|
|
14
|
+
*
|
|
15
|
+
* So the rule lives here ({@link suiteRunInTheWay}) and both of them call it: a run may start only
|
|
16
|
+
* when nothing in flight is already measuring the same app and database. What "the same app and
|
|
17
|
+
* database" MEANS lives here too ({@link suiteCommandTarget}) — the note and the hold have to key
|
|
18
|
+
* a command the same way before they can agree on whether two of them collide, and the re-run a
|
|
19
|
+
* note hands over is built from the same reading of the running command
|
|
20
|
+
* ({@link scopedRerunCommand}).
|
|
21
|
+
*
|
|
22
|
+
* The same treatment for the other pair that answered one question twice: whether a run walks the
|
|
23
|
+
* WHOLE suite or a part of it. The rewriter that decides whether a background Playwright run gets
|
|
24
|
+
* `--retries=0` ({@link ./utilities.js} `streamingBackgroundCommand`) and the platform's own notes
|
|
25
|
+
* (molecule-dev's `suite-cli-override.ts`) asked it separately — a regex there, a predicate here —
|
|
26
|
+
* until xr-molecule-flash-m89 showed the two answers were never the same question
|
|
27
|
+
* ({@link isDiagnosisRun}).
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
import { commandSegments } from './command-position.js';
|
|
32
|
+
import { commandRunsNoTests } from './no-test-run.js';
|
|
33
|
+
/**
|
|
34
|
+
* A command with its heredoc bodies removed: the runs, kills and hosts it names in a file it
|
|
35
|
+
* WRITES are not what the command itself does.
|
|
36
|
+
*
|
|
37
|
+
* The header is kept (`cat > run.sh <<'EOF' …EOF` leaves `cat > run.sh`), which is what the
|
|
38
|
+
* readers here want: everything before the body is still readable as the command's own words.
|
|
39
|
+
*
|
|
40
|
+
* @param command - The shell command.
|
|
41
|
+
* @returns The command without its heredoc bodies.
|
|
42
|
+
*/
|
|
43
|
+
export function withoutHeredocs(command) {
|
|
44
|
+
return command.replace(/<<-?\s*(['"]?)(\w+)\1[\s\S]*?\n\s*\2\b/g, ' ');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The server a suite command points at, as a comparable key: the host it names (`HOST=`,
|
|
48
|
+
* `BASE_URL=`, `PLAYWRIGHT_BASE_URL=`, `--variable host=`, or a bare URL), or `local` for a run
|
|
49
|
+
* that names none — a suite run in the sandbox goes to the sandbox's own app unless the command
|
|
50
|
+
* says otherwise. Every localhost spelling collapses to `local` (one sandbox, one app, one
|
|
51
|
+
* database).
|
|
52
|
+
*
|
|
53
|
+
* @param command - The suite command.
|
|
54
|
+
* @returns The target key.
|
|
55
|
+
*/
|
|
56
|
+
export function suiteCommandTarget(command) {
|
|
57
|
+
const text = withoutHeredocs(command);
|
|
58
|
+
const named = /(?:\b(?:HOST|BASE_URL|PLAYWRIGHT_BASE_URL|E2E_BASE_URL)|--variable\s+host)\s*=\s*['"]?([^\s'"|;&]+)/i.exec(text)?.[1] ?? /https?:\/\/[^\s'"|;&]+/i.exec(text)?.[0];
|
|
59
|
+
if (!named)
|
|
60
|
+
return 'local';
|
|
61
|
+
const value = named.trim();
|
|
62
|
+
let host;
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`);
|
|
65
|
+
host = url.hostname.toLowerCase();
|
|
66
|
+
if (!/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1|\[::1\])$/.test(host))
|
|
67
|
+
return url.port ? `${host}:${url.port}` : host;
|
|
68
|
+
}
|
|
69
|
+
catch (_error) {
|
|
70
|
+
// Not a URL at all (a shell variable the command does not expand): compare
|
|
71
|
+
// the token itself, so the same spelling still reads as the same target.
|
|
72
|
+
return value.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
return 'local';
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The whole-suite command made scoped to the tests it should re-run instead: the same
|
|
78
|
+
* invocation — same directory, same settings — with the named `file:line`s in place of the whole
|
|
79
|
+
* suite, `--last-failed` dropped (it re-runs the tests that never ran too, X1 flash-m60) and
|
|
80
|
+
* `--retries=0` set (a re-run of a known failure does not need to pay for retries).
|
|
81
|
+
*
|
|
82
|
+
* @param command - The whole-suite run's command.
|
|
83
|
+
* @param locations - The failing `file:line`s to re-run, as the run's own log printed them — or,
|
|
84
|
+
* for a run that started outside the suite, the absolute form Playwright matches each file
|
|
85
|
+
* argument against.
|
|
86
|
+
* @returns The scoped command, or the command unchanged when it runs no Playwright suite.
|
|
87
|
+
*/
|
|
88
|
+
export function scopedRerunCommand(command, locations) {
|
|
89
|
+
const at = command.search(/\bplaywright\s+test\b/);
|
|
90
|
+
if (at === -1)
|
|
91
|
+
return command;
|
|
92
|
+
const head = command.slice(0, at);
|
|
93
|
+
const rest = command.slice(at);
|
|
94
|
+
// Only the first statement runs the suite; the rest (`| tail -50`, `&& echo done`)
|
|
95
|
+
// is left exactly as it was.
|
|
96
|
+
const end = rest.search(/&&|\|\||;|\|/);
|
|
97
|
+
const statement = end === -1 ? rest : rest.slice(0, end);
|
|
98
|
+
const tail = end === -1 ? '' : rest.slice(end);
|
|
99
|
+
const scoped = statement
|
|
100
|
+
.replace(/\s*--last-failed\b/g, '')
|
|
101
|
+
.replace(/\s*--retries(?:[= ]\d+)?/g, '')
|
|
102
|
+
.replace(/^(playwright\s+test)\b/, `$1 ${locations.join(' ')} --retries=0`);
|
|
103
|
+
return `${head}${scoped}${tail}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The run in flight that makes a second run a hazard, or null when nothing is in the way.
|
|
107
|
+
*
|
|
108
|
+
* This is the ONE rule the mid-run note and the suite-overlap hold both answer with: a suite run
|
|
109
|
+
* may start only when this returns null, because two suites against one app and database share its
|
|
110
|
+
* rows — the failures one causes the other cannot be told from real ones. A run that invokes no
|
|
111
|
+
* suite is never in the way, and neither is one the candidate command stops first.
|
|
112
|
+
*
|
|
113
|
+
* @param candidate - The run about to start, by the app and database it would measure
|
|
114
|
+
* ({@link suiteCommandTarget}).
|
|
115
|
+
* @param inFlight - The suite runs already going, in the order the caller found them.
|
|
116
|
+
* @returns The first run in the way, or null.
|
|
117
|
+
*/
|
|
118
|
+
export function suiteRunInTheWay(candidate, inFlight) {
|
|
119
|
+
return (inFlight.find((run) => run.runsSuite && run.target === candidate.target && !run.stoppedByCommand) ?? null);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The Playwright invocation of a command, as its own command-position text (`playwright test …`),
|
|
123
|
+
* or null when the command starts no Playwright run.
|
|
124
|
+
*
|
|
125
|
+
* The runner's own word has to END where the sub-command starts (`(?=\s|$)`, not a `\b`): a
|
|
126
|
+
* segment can be a fragment beginning at a `playwright test` printed INSIDE a string
|
|
127
|
+
* (`echo "then npx playwright test" && npx playwright test`), and a match that ends against the
|
|
128
|
+
* closing quote is that echo. A version-pinned `playwright@1.44.0 test` is the same run.
|
|
129
|
+
*/
|
|
130
|
+
const PLAYWRIGHT_RUN = /^(?:@playwright\/test|playwright)(?:@\S+)?\s+test(?=\s|$)/;
|
|
131
|
+
/**
|
|
132
|
+
* What narrows a Playwright run to a part of the suite: a named file or line, a title filter, or
|
|
133
|
+
* the tests the run before it printed (`--last-failed`, `--only-changed`).
|
|
134
|
+
*/
|
|
135
|
+
const NARROWED_RUN = /\.(?:spec|test)\.[cm]?[jt]sx?\b|\.[cm]?[jt]sx?:\d+|(?:^|\s)(?:-g|--grep(?:-invert)?|--last-failed|--only-changed)\b/;
|
|
136
|
+
/**
|
|
137
|
+
* The Playwright invocation's text when the command starts one, or null.
|
|
138
|
+
*
|
|
139
|
+
* @param command - The command as the executor sent it.
|
|
140
|
+
* @returns The invocation's command-position text, or null.
|
|
141
|
+
*/
|
|
142
|
+
function playwrightRun(command) {
|
|
143
|
+
return (commandSegments(withoutHeredocs(command)).find((segment) => PLAYWRIGHT_RUN.test(segment.text))
|
|
144
|
+
?.text ?? null);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Whether a `--max-failures` BOUNDS the walk: the flag is given a count above zero.
|
|
148
|
+
*
|
|
149
|
+
* Playwright's own spelling of "no cap" is zero — `maxFailures: 0` "disables the feature" — and
|
|
150
|
+
* that is the flag the platform's `testRunNote` asks for on the uncapped whole-suite run ("run the
|
|
151
|
+
* whole suite once with `--max-failures=0`"), which is why this is not a plain flag test.
|
|
152
|
+
*
|
|
153
|
+
* @param command - The command as the executor sent it.
|
|
154
|
+
* @returns True when a failure cap would stop the walk.
|
|
155
|
+
*/
|
|
156
|
+
function boundsFailures(command) {
|
|
157
|
+
const flag = /(?:^|[\s;&|(])--max-failures(?![-\w])/.exec(command);
|
|
158
|
+
if (!flag)
|
|
159
|
+
return false;
|
|
160
|
+
const value = /(?:^|[\s;&|(])--max-failures(?:=|\s+)(['"]?)(\d[\d_]*)/.exec(command)?.[2];
|
|
161
|
+
// A `--max-failures` with no count at all is not the uncapped spelling of anything: it is read
|
|
162
|
+
// as a cap, the way Playwright's own default reading of the flag is a count of failures.
|
|
163
|
+
if (value === undefined)
|
|
164
|
+
return true;
|
|
165
|
+
return Number(value.replace(/_/g, '')) !== 0;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether a command walks a PART of a suite instead of all of it: a cap that stops it at a failure
|
|
169
|
+
* count, the failures the last run printed (`--last-failed`, `--only-changed`), a title filter, or
|
|
170
|
+
* named files/lines.
|
|
171
|
+
*
|
|
172
|
+
* THE one reading of that question, in one place. Two sites act on the answer and must never
|
|
173
|
+
* disagree:
|
|
174
|
+
*
|
|
175
|
+
* - the command rewriter that adds `--retries=0` to a background Playwright run
|
|
176
|
+
* ({@link ./utilities.js} `streamingBackgroundCommand`) — a diagnosis walks each failure once,
|
|
177
|
+
* while the run the suite is judged by keeps the suite's own retries; and
|
|
178
|
+
* - the platform's own notes (molecule-dev's `suite-cli-override.ts`), which must not tell the
|
|
179
|
+
* executor to drop the `--retries=0` the platform itself has just added — the contradiction X1
|
|
180
|
+
* flash-m74 and m75 filed, and m76 filed a third time.
|
|
181
|
+
*
|
|
182
|
+
* Until xr-molecule-flash-m89 they were a regex and a predicate that only LOOKED alike, and the
|
|
183
|
+
* gap was exactly the run the platform had asked for: its `testRunNote` says "run the whole suite
|
|
184
|
+
* once with `--max-failures=0`", and the rewriter read that cap flag as a capped diagnosis and
|
|
185
|
+
* stripped the suite's retries from the uncapped proof run that follows a deploy — the one run the
|
|
186
|
+
* graded run is compared against (+25:03, +31:39, +36:40 and +49:57, the last two against the
|
|
187
|
+
* deployed URL, where a network blip is likeliest and the retries it dropped are the ones that
|
|
188
|
+
* absorb it).
|
|
189
|
+
*
|
|
190
|
+
* `--max-failures=0` is Playwright's own "no cap" ({@link boundsFailures}), so it is not a
|
|
191
|
+
* diagnosis. A command that starts no Playwright run is not one either (false): the question is
|
|
192
|
+
* about a run already read as a Playwright suite.
|
|
193
|
+
*
|
|
194
|
+
* @param command - The command as the executor sent it.
|
|
195
|
+
* @returns True when the run is a diagnosis or a re-run, not the whole suite.
|
|
196
|
+
*/
|
|
197
|
+
export function isDiagnosisRun(command) {
|
|
198
|
+
if (boundsFailures(command))
|
|
199
|
+
return true;
|
|
200
|
+
const run = playwrightRun(command);
|
|
201
|
+
if (run === null)
|
|
202
|
+
return false;
|
|
203
|
+
// A run that only collects and prints (`playwright test --list`, `--version`) walks no part of
|
|
204
|
+
// the suite either, and is not the whole-suite run the retries belong on.
|
|
205
|
+
if (commandRunsNoTests(run))
|
|
206
|
+
return true;
|
|
207
|
+
return NARROWED_RUN.test(run);
|
|
208
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -39,6 +39,31 @@ import type { ExecutionBackend, ToolBuildConfig } from './types.js';
|
|
|
39
39
|
* @returns The note, or null when the path is not inside a package's `dist`.
|
|
40
40
|
*/
|
|
41
41
|
export declare function moleculeDistReadNote(path: string): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* The refusal for a write whose target is inside an installed package, or null
|
|
44
|
+
* when the path is not under a `node_modules` directory.
|
|
45
|
+
*
|
|
46
|
+
* `node_modules` is not the project's code: nothing written there survives the
|
|
47
|
+
* next install, no deploy carries it, and a sandbox whose dependency tree is
|
|
48
|
+
* shared (the pre-installed superset) breaks every other run through it. X0
|
|
49
|
+
* xr-molecule-flash-m91 (+04:43) called `write_file` on
|
|
50
|
+
* `node_modules/@molecule/api-resource-comment/dist/numbers.d.ts` while reading
|
|
51
|
+
* the package's declarations and clobbered its runtime module a call later
|
|
52
|
+
* (`module.exports = {}` over the ESM handler its own code imports) — the write
|
|
53
|
+
* answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
|
|
54
|
+
* putting the file back. Reading these files stays allowed (that is how the
|
|
55
|
+
* declarations above are read at all).
|
|
56
|
+
*
|
|
57
|
+
* The restore route is named because the model that has ALREADY clobbered a file
|
|
58
|
+
* (this refusal is too late for it) is the one who needs it, and because the
|
|
59
|
+
* obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
|
|
60
|
+
* sandbox. `npm pack` + a `tar` extraction over the package directory is what
|
|
61
|
+
* m91 used, and what both guards leave alone.
|
|
62
|
+
*
|
|
63
|
+
* @param path - An absolute path a tool is about to write.
|
|
64
|
+
* @returns The one-line refusal naming the package, or null when nothing is installed there.
|
|
65
|
+
*/
|
|
66
|
+
export declare function installedPackageWriteRefusal(path: string): string | null;
|
|
42
67
|
/**
|
|
43
68
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
44
69
|
*
|
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;AAiF9C;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAahG;AAwHD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAoEnE;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUhE;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsBxE;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAgjExF"}
|
package/dist/tools.js
CHANGED
|
@@ -25,6 +25,16 @@ 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
|
+
/**
|
|
29
|
+
* How each background task was started: the command as it was actually run (after
|
|
30
|
+
* {@link streamingBackgroundCommand} rewrote it) and the directory it ran in.
|
|
31
|
+
*
|
|
32
|
+
* A mid-run note has to hand over the re-run of a test that just failed, and a re-run is composed
|
|
33
|
+
* from the command that is still walking ({@link rerunOfRunningSuite}) — a whole-suite re-run is
|
|
34
|
+
* what the executor must not be handed for now, and a re-run narrowed for the wrong directory
|
|
35
|
+
* resolves the failing test's `file:line` against the wrong suite.
|
|
36
|
+
*/
|
|
37
|
+
const runByTask = new Map();
|
|
28
38
|
/**
|
|
29
39
|
* Task ids whose own-output file has already been looked for this process.
|
|
30
40
|
* {@link outputFileByTask} is empty for every task after a restart — it is
|
|
@@ -234,6 +244,52 @@ export function moleculeDistReadNote(path) {
|
|
|
234
244
|
? ' The declarations above are generated from it.'
|
|
235
245
|
: ' For one signature, read the matching .d.ts file.'));
|
|
236
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* The refusal for a write whose target is inside an installed package, or null
|
|
249
|
+
* when the path is not under a `node_modules` directory.
|
|
250
|
+
*
|
|
251
|
+
* `node_modules` is not the project's code: nothing written there survives the
|
|
252
|
+
* next install, no deploy carries it, and a sandbox whose dependency tree is
|
|
253
|
+
* shared (the pre-installed superset) breaks every other run through it. X0
|
|
254
|
+
* xr-molecule-flash-m91 (+04:43) called `write_file` on
|
|
255
|
+
* `node_modules/@molecule/api-resource-comment/dist/numbers.d.ts` while reading
|
|
256
|
+
* the package's declarations and clobbered its runtime module a call later
|
|
257
|
+
* (`module.exports = {}` over the ESM handler its own code imports) — the write
|
|
258
|
+
* answered `ok: true`, and 18 s went to noticing, `npm pack`-ing the package and
|
|
259
|
+
* putting the file back. Reading these files stays allowed (that is how the
|
|
260
|
+
* declarations above are read at all).
|
|
261
|
+
*
|
|
262
|
+
* The restore route is named because the model that has ALREADY clobbered a file
|
|
263
|
+
* (this refusal is too late for it) is the one who needs it, and because the
|
|
264
|
+
* obvious repair — `npm install <pkg>` — is itself refused in the pre-installed
|
|
265
|
+
* sandbox. `npm pack` + a `tar` extraction over the package directory is what
|
|
266
|
+
* m91 used, and what both guards leave alone.
|
|
267
|
+
*
|
|
268
|
+
* @param path - An absolute path a tool is about to write.
|
|
269
|
+
* @returns The one-line refusal naming the package, or null when nothing is installed there.
|
|
270
|
+
*/
|
|
271
|
+
export function installedPackageWriteRefusal(path) {
|
|
272
|
+
// A path may sit in a NESTED node_modules (`app/node_modules/...`) as well as the
|
|
273
|
+
// hoisted root one: both are installed packages.
|
|
274
|
+
const at = /(?:^|\/)node_modules(?:\/|$)/.exec(path);
|
|
275
|
+
if (!at)
|
|
276
|
+
return null;
|
|
277
|
+
const named = /(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)(?:\/|$)/.exec(path);
|
|
278
|
+
const name = named?.[1] ?? null;
|
|
279
|
+
const scoped = at[0].startsWith('/') ? path.slice(0, at.index) : '';
|
|
280
|
+
const dir = name ? `${scoped}/node_modules/${name}` : null;
|
|
281
|
+
const what = name ? `the installed package ${name}` : 'an installed package';
|
|
282
|
+
const change = 'Installed packages are read-only: the next install replaces that file and a deploy never ' +
|
|
283
|
+
'carries it. Change its behaviour from your app instead (its options, your own handler, a ' +
|
|
284
|
+
'bond), and where the package itself is wrong, report it rather than patching it here. ' +
|
|
285
|
+
'Reading these files is fine.';
|
|
286
|
+
const restore = name && dir
|
|
287
|
+
? ` If you have already overwritten one there, restore the package before anything else: ` +
|
|
288
|
+
`\`npm pack ${name}@<version>\` (the version is in ${dir}/package.json), then ` +
|
|
289
|
+
`\`tar -xzf <the tarball npm pack printed> --strip-components=1 -C ${dir}\` — never a hand-edit.`
|
|
290
|
+
: '';
|
|
291
|
+
return `Not written: ${path} is inside ${what} (node_modules). ${change}${restore}`;
|
|
292
|
+
}
|
|
237
293
|
/**
|
|
238
294
|
* Build a complete set of AI agent tools bound to an execution backend.
|
|
239
295
|
*
|
|
@@ -336,6 +392,22 @@ export function buildTools(backend, config) {
|
|
|
336
392
|
}
|
|
337
393
|
return pathGuards ? resolvePath(path, root) : path;
|
|
338
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Resolve the path a WRITING tool was given, refusing a target inside an
|
|
397
|
+
* installed package ({@link installedPackageWriteRefusal}). Reads are not sent
|
|
398
|
+
* through this: reading `node_modules` is how a package's declarations are
|
|
399
|
+
* read at all, and only the write is unrecoverable.
|
|
400
|
+
*
|
|
401
|
+
* @param path - Relative or absolute path requested by the tool input.
|
|
402
|
+
* @returns The normalized path, when writing it is allowed.
|
|
403
|
+
*/
|
|
404
|
+
function resolveWrite(path) {
|
|
405
|
+
const resolved = resolve(path);
|
|
406
|
+
const refusal = installedPackageWriteRefusal(resolved);
|
|
407
|
+
if (refusal)
|
|
408
|
+
throw new ToolInputError(refusal);
|
|
409
|
+
return resolved;
|
|
410
|
+
}
|
|
339
411
|
/** Paths read with a window in this tool set, to tell paging from one targeted read. */
|
|
340
412
|
const windowedReads = new Set();
|
|
341
413
|
/** The text each path was last handed back WHOLE as, so that correction fires once per version. */
|
|
@@ -887,7 +959,7 @@ export function buildTools(backend, config) {
|
|
|
887
959
|
const argErr = pathArgError(input.path, 'write_file');
|
|
888
960
|
if (argErr)
|
|
889
961
|
return { error: argErr };
|
|
890
|
-
const path =
|
|
962
|
+
const path = resolveWrite(input.path);
|
|
891
963
|
const content = input.content;
|
|
892
964
|
if (content.length > MAX_WRITE_SIZE)
|
|
893
965
|
return {
|
|
@@ -936,7 +1008,7 @@ export function buildTools(backend, config) {
|
|
|
936
1008
|
const argErr = pathArgError(input.path, 'edit_file');
|
|
937
1009
|
if (argErr)
|
|
938
1010
|
return { error: argErr };
|
|
939
|
-
const path =
|
|
1011
|
+
const path = resolveWrite(input.path);
|
|
940
1012
|
// Support both formats: { replacements: [...] } (batch) and { old_string, new_string } (single, backwards-compatible)
|
|
941
1013
|
let replacements = input.replacements;
|
|
942
1014
|
// Accept the single format when BOTH are strings — `typeof` (not truthy) so
|
|
@@ -973,13 +1045,35 @@ export function buildTools(backend, config) {
|
|
|
973
1045
|
}
|
|
974
1046
|
const oldContent = content;
|
|
975
1047
|
let alreadyApplied = 0;
|
|
1048
|
+
// Replacements refused for changing the line break at old_string's boundary and
|
|
1049
|
+
// nothing else — reported by position, never applied (see below).
|
|
1050
|
+
const skipped = [];
|
|
976
1051
|
// Where each applied replacement landed, in the coordinates of the
|
|
977
1052
|
// latest content — rendered into the result so the model sees the edit.
|
|
978
1053
|
let spans = [];
|
|
979
|
-
|
|
1054
|
+
// A replacement whose ONLY effect is the line break at old_string's boundary:
|
|
1055
|
+
// `new_string` is `old_string` minus its trailing newline (applying it runs the
|
|
1056
|
+
// line after it onto the last line) or `old_string` plus one (applying it moves
|
|
1057
|
+
// the rest of that line down). A weaker model sends the first as an "anchor" — it
|
|
1058
|
+
// repeats a line it means to keep, minus its `\n` — and the join is silent when
|
|
1059
|
+
// it still parses: flash-m28 merged two lines three times in one run, flash-m89
|
|
1060
|
+
// twice in two consecutive calls (comments.ts, then articles.ts), flash-m55,
|
|
1061
|
+
// m62 and m89 all paid a repair round trip for it. A real edit changes some other
|
|
1062
|
+
// character, so this costs nothing.
|
|
1063
|
+
const boundaryOnly = (oldText, newText) => {
|
|
1064
|
+
const oldBody = oldText.replace(/\r?\n$/, '');
|
|
1065
|
+
// `newText !== ''` keeps a deletion out of this: `old_string: "\n"` → `""` is a
|
|
1066
|
+
// real request to remove a blank line, and the notes below already cover it.
|
|
1067
|
+
if (newText !== '' && oldBody !== oldText && newText === oldBody)
|
|
1068
|
+
return 'removed';
|
|
1069
|
+
if (!oldText.endsWith('\n') && newText === `${oldText}\n`)
|
|
1070
|
+
return 'added';
|
|
1071
|
+
return null;
|
|
1072
|
+
};
|
|
1073
|
+
for (const [index, { old_string: oldString, new_string: newString, replace_all: replaceAll,
|
|
980
1074
|
// Models sometimes send the camelCase spelling inside an item, where
|
|
981
1075
|
// the input normalizer (top-level keys only) does not reach.
|
|
982
|
-
replaceAll: replaceAllCamel, } of replacements) {
|
|
1076
|
+
replaceAll: replaceAllCamel, },] of replacements.entries()) {
|
|
983
1077
|
// Validate each replacement is well-formed BEFORE touching content. A
|
|
984
1078
|
// weak model sometimes emits a batch element missing a field or with a
|
|
985
1079
|
// non-string value; left unchecked that either crashes (`undefined`
|
|
@@ -1081,15 +1175,74 @@ export function buildTools(backend, config) {
|
|
|
1081
1175
|
.map((l) => l.trim())
|
|
1082
1176
|
.filter((l) => l.length >= 8)),
|
|
1083
1177
|
].sort((a, b) => b.length - a.length);
|
|
1178
|
+
const oldLines = oldString.split('\n');
|
|
1179
|
+
// Whitespace is the one difference the model cannot see in its own
|
|
1180
|
+
// text, so make it visible when that is the whole difference.
|
|
1181
|
+
const visible = (l) => l.replace(/ /g, '·').replace(/\t/g, '⇥');
|
|
1182
|
+
// Report where old_string and the file STOP agreeing, not where the
|
|
1183
|
+
// match starts. A snippet centred on the anchor is the part the model
|
|
1184
|
+
// already has right, so it re-sends the same old_string and fails the
|
|
1185
|
+
// same way (X0 m89: two failed edits, a whole-file re-read and a
|
|
1186
|
+
// rewrite, 9 s, for a divergence 35 lines below the anchor).
|
|
1187
|
+
const showDivergence = (base, k) => {
|
|
1188
|
+
const expected = oldLines[k];
|
|
1189
|
+
const actual = fileLines[base + k] ?? '(end of file)';
|
|
1190
|
+
const whitespaceOnly = expected.replace(/\s/g, '') === actual.replace(/\s/g, '');
|
|
1191
|
+
let col = 0;
|
|
1192
|
+
while (col < expected.length && col < actual.length && expected[col] === actual[col])
|
|
1193
|
+
col++;
|
|
1194
|
+
const howFar = whitespaceOnly
|
|
1195
|
+
? ' (whitespace only — · is a space, ⇥ a tab)'
|
|
1196
|
+
: col > 0
|
|
1197
|
+
? ` (first character difference at column ${col + 1})`
|
|
1198
|
+
: '';
|
|
1199
|
+
const start = Math.max(0, base + k - 2);
|
|
1200
|
+
const snippet = fileLines
|
|
1201
|
+
.slice(start, base + k + 6)
|
|
1202
|
+
.map((l, i) => `${start + i + 1}: ${l}`)
|
|
1203
|
+
.join('\n');
|
|
1204
|
+
return {
|
|
1205
|
+
error: `old_string not found in ${path} — it matches the file through line ${base + k}, ` +
|
|
1206
|
+
`then diverges at your line ${k + 1} of ${oldLines.length}${howFar}.\n` +
|
|
1207
|
+
`Your old_string line ${k + 1}: ${whitespaceOnly ? visible(expected) : expected}\n` +
|
|
1208
|
+
`The file's line ${base + k + 1}: ${whitespaceOnly ? visible(actual) : actual}\n` +
|
|
1209
|
+
`The file's ACTUAL content near your target (copy the exact text from here):\n${snippet}`,
|
|
1210
|
+
};
|
|
1211
|
+
};
|
|
1212
|
+
// The longest run of old_string lines that IS in the file decides which
|
|
1213
|
+
// alignment to report, so a later distinctive line can beat an earlier
|
|
1214
|
+
// one. k === 0 means nothing beyond the anchor itself lines up; only
|
|
1215
|
+
// then fall back to the old anchor snippet.
|
|
1216
|
+
let best = null;
|
|
1217
|
+
let firstUniqueHit = null;
|
|
1084
1218
|
for (const anchor of distinctiveLines) {
|
|
1085
1219
|
const hits = fileLines.reduce((acc, l, i) => {
|
|
1086
1220
|
if (l.includes(anchor))
|
|
1087
1221
|
acc.push(i);
|
|
1088
1222
|
return acc;
|
|
1089
1223
|
}, []);
|
|
1090
|
-
if (hits.length
|
|
1091
|
-
|
|
1224
|
+
if (hits.length !== 1)
|
|
1225
|
+
continue;
|
|
1226
|
+
if (firstUniqueHit === null)
|
|
1227
|
+
firstUniqueHit = hits[0];
|
|
1228
|
+
const base = hits[0] - oldLines.findIndex((l) => l.trim() === anchor);
|
|
1229
|
+
if (base < 0)
|
|
1230
|
+
continue;
|
|
1231
|
+
// Exact comparison: a line that differs only in trailing whitespace
|
|
1232
|
+
// is itself a reason the exact match failed, and it is invisible in
|
|
1233
|
+
// the model's own text — report it as the divergence (the message
|
|
1234
|
+
// renders it with · for space).
|
|
1235
|
+
let k = 0;
|
|
1236
|
+
while (k < oldLines.length && fileLines[base + k] === oldLines[k]) {
|
|
1237
|
+
k++;
|
|
1238
|
+
}
|
|
1239
|
+
if (k > 0 && k < oldLines.length && (best === null || k > best.k))
|
|
1240
|
+
best = { base, k };
|
|
1092
1241
|
}
|
|
1242
|
+
if (best)
|
|
1243
|
+
return showDivergence(best.base, best.k);
|
|
1244
|
+
if (firstUniqueHit !== null)
|
|
1245
|
+
return showSnippet(firstUniqueHit);
|
|
1093
1246
|
// Fallback: the first non-blank line, even if not unique (better than a
|
|
1094
1247
|
// bare "re-read" when the model just needs to see nearby real content).
|
|
1095
1248
|
const firstLine = oldString
|
|
@@ -1149,10 +1302,36 @@ export function buildTools(backend, config) {
|
|
|
1149
1302
|
// a self-inflicted syntax error written with ok:true that the executor
|
|
1150
1303
|
// then can't locate. count === 1 here, so indexOf is the unique site.
|
|
1151
1304
|
const at = content.indexOf(oldString);
|
|
1305
|
+
// Refuse a replacement whose only effect is the line break at the boundary,
|
|
1306
|
+
// INSTEAD of applying it (flash-m89 +08:32 left `function pageLimit(req): number
|
|
1307
|
+
// { const raw = …` on one line and +07:25 left a comment handler unparseable,
|
|
1308
|
+
// twice in two calls, and the API was down between the pair). The file's own
|
|
1309
|
+
// next character is the evidence that decides it: text on the same line means
|
|
1310
|
+
// the break is moving across lines (a join, or a split), which the shape of the
|
|
1311
|
+
// replacement says the model did not ask for. At a line boundary — a blank line
|
|
1312
|
+
// after it, or the end of the file — nothing crosses lines and the edit stands.
|
|
1313
|
+
const boundary = boundaryOnly(oldString, newString);
|
|
1314
|
+
const after = content[at + oldString.length];
|
|
1315
|
+
if (boundary !== null && after !== undefined && after !== '\n') {
|
|
1316
|
+
skipped.push({ position: index + 1, shape: boundary });
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1152
1319
|
const spliced = content.slice(0, at) + newString + content.slice(at + oldString.length);
|
|
1153
1320
|
spans = addChangeSpan(spans, content, spliced);
|
|
1154
1321
|
content = spliced;
|
|
1155
1322
|
}
|
|
1323
|
+
// One line per refused replacement: what it would have done, and the two ways to
|
|
1324
|
+
// say it on purpose. The rest of the batch is applied — the second half of the pair
|
|
1325
|
+
// above (`comments.ts`'s third replacement, `articles.ts`'s own text edit) anchors
|
|
1326
|
+
// on text a joined file no longer has, so applying the join takes the batch down
|
|
1327
|
+
// with it.
|
|
1328
|
+
const skippedNote = skipped.length === 0
|
|
1329
|
+
? null
|
|
1330
|
+
: skipped
|
|
1331
|
+
.map(({ position, shape }) => shape === 'removed'
|
|
1332
|
+
? `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string minus the trailing newline, so the only thing it changes is the line break between that text and the line after it — applying it would run the two lines together. To join two lines on purpose, include the next line in old_string.`
|
|
1333
|
+
: `replacement ${position} was skipped, nothing was changed for it: its new_string is its old_string plus a trailing newline, so the only thing it changes is the line break after that text — applying it would move the rest of that line down. To split a line on purpose, include the text that follows it in old_string.`)
|
|
1334
|
+
.join('\n');
|
|
1156
1335
|
const alreadyNote = alreadyApplied > 0
|
|
1157
1336
|
? `${alreadyApplied} of these edits ${alreadyApplied === 1 ? 'was' : 'were'} already in the file (its new_string is there and its old_string is not) — nothing to do for ${alreadyApplied === 1 ? 'it' : 'them'}.`
|
|
1158
1337
|
: null;
|
|
@@ -1164,7 +1343,16 @@ export function buildTools(backend, config) {
|
|
|
1164
1343
|
(identical > 0
|
|
1165
1344
|
? `Nothing changed: ${identical === 1 ? 'the replacement’s new_string is' : `${identical} replacements’ new_string are`} identical to old_string, so the file is exactly as it was. Write the text you want in new_string.`
|
|
1166
1345
|
: 'Nothing changed: the file is exactly as it was.');
|
|
1167
|
-
|
|
1346
|
+
// A refused boundary-newline replacement is why nothing changed: "the file is
|
|
1347
|
+
// exactly as it was" would read as "your edit landed".
|
|
1348
|
+
return {
|
|
1349
|
+
path,
|
|
1350
|
+
ok: true,
|
|
1351
|
+
replacementsApplied: 0,
|
|
1352
|
+
alreadyApplied,
|
|
1353
|
+
note: skippedNote ?? note,
|
|
1354
|
+
...(skippedNote ? { skippedNote } : {}),
|
|
1355
|
+
};
|
|
1168
1356
|
}
|
|
1169
1357
|
if (onFileDiff)
|
|
1170
1358
|
onFileDiff({ path, oldContent, newContent: content });
|
|
@@ -1190,10 +1378,13 @@ export function buildTools(backend, config) {
|
|
|
1190
1378
|
const excerpt = renderEditExcerpt(finalContent, spans);
|
|
1191
1379
|
// A replacement whose old text ended in a newline and whose new text does not
|
|
1192
1380
|
// joins the line after it onto its last line. Applied as written, but said:
|
|
1193
|
-
// flash-m28 saw two lines merged only one round trip later.
|
|
1381
|
+
// flash-m28 saw two lines merged only one round trip later. The refused ones
|
|
1382
|
+
// above were never applied, so what the file holds is not their work.
|
|
1383
|
+
const refused = new Set(skipped.map((s) => s.position));
|
|
1194
1384
|
const dropped = replacements
|
|
1195
1385
|
.map((r, i) => ({ ...r, position: i + 1 }))
|
|
1196
|
-
.filter((r) =>
|
|
1386
|
+
.filter((r) => !refused.has(r.position) &&
|
|
1387
|
+
typeof r.old_string === 'string' &&
|
|
1197
1388
|
typeof r.new_string === 'string' &&
|
|
1198
1389
|
r.old_string.endsWith('\n') &&
|
|
1199
1390
|
r.new_string !== '' &&
|
|
@@ -1232,7 +1423,7 @@ export function buildTools(backend, config) {
|
|
|
1232
1423
|
return {
|
|
1233
1424
|
path,
|
|
1234
1425
|
ok: true,
|
|
1235
|
-
replacementsApplied: replacements.length - alreadyApplied,
|
|
1426
|
+
replacementsApplied: replacements.length - alreadyApplied - skipped.length,
|
|
1236
1427
|
...(fusedNote
|
|
1237
1428
|
? { syntaxError: fusedNote }
|
|
1238
1429
|
: {
|
|
@@ -1251,6 +1442,7 @@ export function buildTools(backend, config) {
|
|
|
1251
1442
|
: {}),
|
|
1252
1443
|
...syntax,
|
|
1253
1444
|
}),
|
|
1445
|
+
...(skippedNote ? { skippedNote } : {}),
|
|
1254
1446
|
...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
|
|
1255
1447
|
totalLines: countLines(finalContent),
|
|
1256
1448
|
...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
|
|
@@ -1369,7 +1561,7 @@ export function buildTools(backend, config) {
|
|
|
1369
1561
|
}
|
|
1370
1562
|
},
|
|
1371
1563
|
async create_directory(input) {
|
|
1372
|
-
const path =
|
|
1564
|
+
const path = resolveWrite(input.path);
|
|
1373
1565
|
const parentPath = path.substring(0, path.lastIndexOf('/')) || root;
|
|
1374
1566
|
const symlinkErr = await checkSymlink(parentPath);
|
|
1375
1567
|
if (symlinkErr)
|
|
@@ -1385,8 +1577,10 @@ export function buildTools(backend, config) {
|
|
|
1385
1577
|
}
|
|
1386
1578
|
},
|
|
1387
1579
|
async rename_file(input) {
|
|
1388
|
-
|
|
1389
|
-
|
|
1580
|
+
// Both ends: moving a package's file OUT is as much a change to the installed
|
|
1581
|
+
// package as moving one in.
|
|
1582
|
+
const oldPath = resolveWrite(input.old_path);
|
|
1583
|
+
const newPath = resolveWrite(input.new_path);
|
|
1390
1584
|
const oldSymlinkErr = await checkSymlink(oldPath);
|
|
1391
1585
|
if (oldSymlinkErr)
|
|
1392
1586
|
return { error: oldSymlinkErr };
|
|
@@ -1408,7 +1602,7 @@ export function buildTools(backend, config) {
|
|
|
1408
1602
|
}
|
|
1409
1603
|
},
|
|
1410
1604
|
async delete_file(input) {
|
|
1411
|
-
const path =
|
|
1605
|
+
const path = resolveWrite(input.path);
|
|
1412
1606
|
const symlinkErr = await checkSymlink(path);
|
|
1413
1607
|
if (symlinkErr)
|
|
1414
1608
|
return { error: symlinkErr };
|
|
@@ -1508,6 +1702,7 @@ export function buildTools(backend, config) {
|
|
|
1508
1702
|
: null;
|
|
1509
1703
|
if (ownFile)
|
|
1510
1704
|
outputFileByTask.set(id, ownFile);
|
|
1705
|
+
runByTask.set(id, { command: streaming.command, cwd });
|
|
1511
1706
|
await backend.writeFile(script, `${streaming.command}\n`);
|
|
1512
1707
|
await backend.run(`nohup sh -c ${shellQuote(prioritized.launch)} ` +
|
|
1513
1708
|
`> /dev/null 2>&1 & echo $! > /tmp/${id}.pid`, { cwd, timeout: 15_000 });
|
|
@@ -1848,6 +2043,11 @@ export function buildTools(backend, config) {
|
|
|
1848
2043
|
// Only a cause that is failing the whole run gets a stop command — and only then is the
|
|
1849
2044
|
// pid walk paid for.
|
|
1850
2045
|
const universal = universalCause(causeLog, found);
|
|
2046
|
+
// How this run was started, so the note can hand over the re-run to send once it ends
|
|
2047
|
+
// instead of one for now — the platform refuses a second run against the same app and
|
|
2048
|
+
// database while this one walks (xr-molecule-flash-m89: the +19:59 note named a re-run
|
|
2049
|
+
// and its overlap hold refused that exact run 23 s later).
|
|
2050
|
+
const started = runByTask.get(taskId);
|
|
1851
2051
|
repeatingCauseNoted.add(taskId);
|
|
1852
2052
|
const earlyMs = Date.now() - startedAt;
|
|
1853
2053
|
waitedByTask.set(taskId, waitedBefore + earlyMs);
|
|
@@ -1860,6 +2060,7 @@ export function buildTools(backend, config) {
|
|
|
1860
2060
|
log,
|
|
1861
2061
|
stop: universal ? await taskStopCommand(backend, taskId) : '',
|
|
1862
2062
|
universal,
|
|
2063
|
+
...(started ? { run: started } : {}),
|
|
1863
2064
|
}),
|
|
1864
2065
|
};
|
|
1865
2066
|
}
|