opencode-harness-kit 0.5.4 → 0.5.6
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/CHANGELOG.md +207 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +514 -73
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +53 -11
- package/eval/measure-prune.mjs +228 -54
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +45 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +440 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/telemetry.ts +69 -9
- package/src/trust.js +182 -54
package/src/config.js
CHANGED
|
@@ -158,6 +158,11 @@ export function utilityModel(p) {
|
|
|
158
158
|
* `instructions` array used to replace it wholesale, which unloaded the
|
|
159
159
|
* whole behavioural layer while everything still looked correct.
|
|
160
160
|
*
|
|
161
|
+
* A SINGLE string is a list of one. The parameter has always been typed
|
|
162
|
+
* `string[] | string | undefined`, but a string was dropped on the floor:
|
|
163
|
+
* the file that came back named only the kit's GLOBAL.md, so the user's own
|
|
164
|
+
* instructions were unloaded without a word.
|
|
165
|
+
*
|
|
161
166
|
* The user's own files keep their order and come first; the kit's is
|
|
162
167
|
* appended. Duplicates are dropped, so re-running `use` does not grow the
|
|
163
168
|
* list.
|
|
@@ -167,7 +172,12 @@ export function utilityModel(p) {
|
|
|
167
172
|
* @returns {string[]}
|
|
168
173
|
*/
|
|
169
174
|
export function mergeInstructions(base, kitPath) {
|
|
170
|
-
const
|
|
175
|
+
const given = Array.isArray(base)
|
|
176
|
+
? base
|
|
177
|
+
: typeof base === "string"
|
|
178
|
+
? [base]
|
|
179
|
+
: []
|
|
180
|
+
const list = given.filter((x) => typeof x === "string")
|
|
171
181
|
/** @type {string[]} */
|
|
172
182
|
const out = []
|
|
173
183
|
for (const entry of [...list, kitPath]) {
|
|
@@ -235,6 +245,75 @@ export const BASH_ALLOWLIST = [
|
|
|
235
245
|
*/
|
|
236
246
|
export { BASH_DENYLIST } from "./denylist.js"
|
|
237
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The user's own bash rules, as a map the kit can merge into its own.
|
|
250
|
+
*
|
|
251
|
+
* The value has to be one of the three verbs opencode's schema defines. A
|
|
252
|
+
* rule the kit cannot express cannot work anyway, and writing it out
|
|
253
|
+
* unchanged would make the whole config invalid - which is a worse outcome
|
|
254
|
+
* than a rule the kit drops.
|
|
255
|
+
*
|
|
256
|
+
* A rule the kit drops is NOT dropped silently: the pattern and the verb it
|
|
257
|
+
* carried are returned in `dropped`, and the header of the generated file
|
|
258
|
+
* names them. The rule was the user's, and a file that comes back looking
|
|
259
|
+
* exactly as it did before is how a lost rule stays lost.
|
|
260
|
+
*
|
|
261
|
+
* `permission.bash` may also be a SINGLE verb, and a single verb is the
|
|
262
|
+
* blanket rule - every command that no other rule names. It used to be
|
|
263
|
+
* dropped on the floor with the rest of the non-object values, so a user
|
|
264
|
+
* who had `bash: "allow"` lost the blanket allow and got the kit's map
|
|
265
|
+
* instead.
|
|
266
|
+
*
|
|
267
|
+
* @param {any} base the config as it is on disk
|
|
268
|
+
* @returns {{ rules: Record<string, string>, dropped: string[] }}
|
|
269
|
+
*/
|
|
270
|
+
function userBashRules(base) {
|
|
271
|
+
const own = base?.permission?.bash
|
|
272
|
+
/** @type {Record<string, string>} */
|
|
273
|
+
const rules = {}
|
|
274
|
+
/** @type {string[]} */
|
|
275
|
+
const dropped = []
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Keep a rule the kit can write, or name the one it cannot.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} pattern
|
|
281
|
+
* @param {any} verb
|
|
282
|
+
*/
|
|
283
|
+
const keep = (pattern, verb) => {
|
|
284
|
+
if (verb === "allow" || verb === "ask" || verb === "deny") {
|
|
285
|
+
rules[pattern] = verb
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
const said = typeof verb === "string" ? JSON.stringify(verb) : String(verb)
|
|
289
|
+
dropped.push(`permission.bash "${pattern}" -> ${said}`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (own === undefined || own === null) return { rules, dropped }
|
|
293
|
+
|
|
294
|
+
if (typeof own === "string") {
|
|
295
|
+
keep("*", own)
|
|
296
|
+
return { rules, dropped }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Anything else that is not a map of rules cannot be merged, and it is
|
|
300
|
+
// named so the loss is not silent either.
|
|
301
|
+
if (typeof own !== "object" || Array.isArray(own)) {
|
|
302
|
+
dropped.push(
|
|
303
|
+
`permission.bash is ${Array.isArray(own) ? "a list" : String(own)}, which is not a rule the kit can write`,
|
|
304
|
+
)
|
|
305
|
+
return { rules, dropped }
|
|
306
|
+
}
|
|
307
|
+
for (const [pattern, verb] of Object.entries(own)) {
|
|
308
|
+
if (typeof pattern !== "string" || !pattern) {
|
|
309
|
+
dropped.push(`permission.bash has a rule with no pattern, which was dropped`)
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
keep(pattern, verb)
|
|
313
|
+
}
|
|
314
|
+
return { rules, dropped }
|
|
315
|
+
}
|
|
316
|
+
|
|
238
317
|
/**
|
|
239
318
|
* @param {Profile} p
|
|
240
319
|
* @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string> }} [opts]
|
|
@@ -294,9 +373,34 @@ export function generateConfig(p, opts = {}) {
|
|
|
294
373
|
agent[role] = existing
|
|
295
374
|
}
|
|
296
375
|
|
|
376
|
+
// The user's own bash rules are merged in, not replaced.
|
|
377
|
+
//
|
|
378
|
+
// The map used to be built from the kit's two lists alone, so every rule
|
|
379
|
+
// the user had written was deleted on the next `oc-harness use` - and the
|
|
380
|
+
// file that came back looked exactly as it had before, so nothing said
|
|
381
|
+
// it. The rules that let somebody run their own tools without a prompt
|
|
382
|
+
// were the ones that vanished.
|
|
383
|
+
//
|
|
384
|
+
// The order is the precedence ladder, and it is the kit's denylist that
|
|
385
|
+
// closes it:
|
|
386
|
+
//
|
|
387
|
+
// kit allowlist -> the user's rules -> kit denylist
|
|
388
|
+
//
|
|
389
|
+
// The denylist is last because a deny a user rule could re-allow would
|
|
390
|
+
// not be a deny, and the denylist is the mechanical half of the safety
|
|
391
|
+
// policy. The user's rules sit after the allowlist so a rule of their own
|
|
392
|
+
// is not shadowed by one of the kit's.
|
|
393
|
+
//
|
|
394
|
+
// The kit's own catch-all stays the FIRST key, so a user rule for `*`
|
|
395
|
+
// replaces its value and not its position: the catch-all remains the
|
|
396
|
+
// fallback for the commands no other rule mentions, and the allowlist
|
|
397
|
+
// still decides the ones it names. opencode takes the last matching rule,
|
|
398
|
+
// so this order is the whole rule.
|
|
399
|
+
const own = userBashRules(base)
|
|
297
400
|
const bashRules = {
|
|
298
401
|
"*": "ask",
|
|
299
402
|
...Object.fromEntries(BASH_ALLOWLIST.map((rule) => [rule, "allow"])),
|
|
403
|
+
...own.rules,
|
|
300
404
|
...Object.fromEntries(BASH_DENYLIST.map((rule) => [rule, "deny"])),
|
|
301
405
|
}
|
|
302
406
|
|
|
@@ -423,13 +527,19 @@ export function generateConfig(p, opts = {}) {
|
|
|
423
527
|
},
|
|
424
528
|
}
|
|
425
529
|
|
|
426
|
-
return render(out, p, {
|
|
530
|
+
return render(out, p, {
|
|
531
|
+
comments: opts.comments ?? true,
|
|
532
|
+
instructionsPath,
|
|
533
|
+
unhealthy,
|
|
534
|
+
dropped: own.dropped,
|
|
535
|
+
})
|
|
427
536
|
}
|
|
428
537
|
|
|
429
538
|
/**
|
|
430
539
|
* @param {Record<string, any>} cfg
|
|
431
540
|
* @param {Profile} p
|
|
432
|
-
* @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string
|
|
541
|
+
* @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string>,
|
|
542
|
+
* dropped?: string[] }} opts
|
|
433
543
|
* @returns {string}
|
|
434
544
|
*/
|
|
435
545
|
function render(cfg, p, opts) {
|
|
@@ -481,6 +591,19 @@ function render(cfg, p, opts) {
|
|
|
481
591
|
`${c}ceiling ${p.maxRetention} - a lane may only resolve to a model at or below this`,
|
|
482
592
|
]
|
|
483
593
|
: []),
|
|
594
|
+
// The rules the kit could not carry over. They are named HERE, in the
|
|
595
|
+
// file the user reads, because a rule that is dropped without a word is
|
|
596
|
+
// a rule that is lost for good: the file comes back looking exactly as
|
|
597
|
+
// it did before, and the prompt it used to allow is back.
|
|
598
|
+
...(opts.dropped?.length
|
|
599
|
+
? [
|
|
600
|
+
`${c}`,
|
|
601
|
+
`${c}Dropped from your own permission.bash rules, and NOT in the file below.`,
|
|
602
|
+
`${c}The verb has to be one of allow, ask or deny - a rule the kit cannot`,
|
|
603
|
+
`${c}write would fail the schema for the whole file:`,
|
|
604
|
+
...opts.dropped.map((d) => `${c} ${d}`),
|
|
605
|
+
]
|
|
606
|
+
: []),
|
|
484
607
|
`${c}`,
|
|
485
608
|
`${c}Derived numbers, do not hand-edit:`,
|
|
486
609
|
`${c} compaction.reserved ${reservedTokens(p, { unhealthy }).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the narrowest input cap in play, held back for reasoning growth)`,
|
package/src/denylist.js
CHANGED
|
@@ -19,10 +19,49 @@ export const BASH_DENYLIST = [
|
|
|
19
19
|
// so opencode prompts for human approval before publishing.
|
|
20
20
|
"rm -rf *",
|
|
21
21
|
"rm -fr *",
|
|
22
|
+
// The short forms of the same flags, one at a time.
|
|
23
|
+
//
|
|
24
|
+
// The guard's rule is a TOKEN scanner: it blocks an rm whose short flag
|
|
25
|
+
// cluster contains r or f in any case, and an rm whose long flag is
|
|
26
|
+
// --recursive or --force. So `rm -f file` and `rm -r dir` are blocked
|
|
27
|
+
// there - and the permission map has to deny what the guard blocks, or
|
|
28
|
+
// the two layers contradict each other: the map would ask about a
|
|
29
|
+
// command the guard refuses.
|
|
30
|
+
//
|
|
31
|
+
// `-R` is named too because the guard's rule is case-insensitive and
|
|
32
|
+
// GNU rm reads `-R` as `--recursive`.
|
|
33
|
+
//
|
|
34
|
+
// The two layers are not the same matcher. The guard reads the tokens
|
|
35
|
+
// and can see a destructive flag in any position; this list is a flat
|
|
36
|
+
// glob over the command, so it can only name the spellings.
|
|
37
|
+
//
|
|
38
|
+
// A cluster a glob cannot express (`-rfv`) is caught by the guard and
|
|
39
|
+
// asked about by the config - the config asks about everything it does
|
|
40
|
+
// not name, and asking is the safe direction.
|
|
41
|
+
//
|
|
42
|
+
// A PERMUTED delete (`rm /tmp/x -rf`) is caught by the GUARD, which reads
|
|
43
|
+
// the tokens and does not care which order they come in. The glob cannot
|
|
44
|
+
// name it - a glob here could only name one spelling of it, and the
|
|
45
|
+
// spelling would still be the one it missed. So the config asks about it,
|
|
46
|
+
// and a human decides, and the code-level block is the guard's.
|
|
47
|
+
"rm -f *",
|
|
48
|
+
"rm -r *",
|
|
49
|
+
"rm -R *",
|
|
50
|
+
// The long GNU names of the same two flags. A delete that is spelled
|
|
51
|
+
// `--recursive --force` is the delete the short forms name.
|
|
52
|
+
"rm --recursive *",
|
|
53
|
+
"rm --force *",
|
|
22
54
|
"curl * | sh",
|
|
23
55
|
"curl * | bash",
|
|
56
|
+
// The same pipe into a shell that is named by its path. The shell is the
|
|
57
|
+
// same shell either way, and the config can only match what it can see.
|
|
58
|
+
"curl * | */sh",
|
|
59
|
+
"curl * | */bash",
|
|
24
60
|
"chmod 777 *",
|
|
25
61
|
"chmod 777*",
|
|
62
|
+
// The same mode written with a leading zero.
|
|
63
|
+
"chmod 0777 *",
|
|
64
|
+
"chmod 0777*",
|
|
26
65
|
"gh pr create*",
|
|
27
66
|
"gh pr merge*",
|
|
28
67
|
"gh pr close*",
|
|
@@ -52,6 +91,10 @@ export const BASH_DENYLIST = [
|
|
|
52
91
|
*
|
|
53
92
|
* `*` matches any run of characters, including none.
|
|
54
93
|
*
|
|
94
|
+
* Every other metacharacter is literal. `?` is a regex quantifier, so an
|
|
95
|
+
* unescaped one made the character before it optional and the pattern
|
|
96
|
+
* matched a command it never named.
|
|
97
|
+
*
|
|
55
98
|
* @param {string} command
|
|
56
99
|
* @param {string} pattern
|
|
57
100
|
* @returns {boolean}
|
|
@@ -59,7 +102,7 @@ export const BASH_DENYLIST = [
|
|
|
59
102
|
export function matchesPattern(command, pattern) {
|
|
60
103
|
const re = new RegExp(
|
|
61
104
|
"^" +
|
|
62
|
-
pattern.replace(/[.+^${}()|[\]
|
|
105
|
+
pattern.replace(/[.+^${}()|[\]\\?]/g, "\\$&").replace(/\*/g, "[\\s\\S]*") +
|
|
63
106
|
"$",
|
|
64
107
|
)
|
|
65
108
|
return re.test(command)
|
|
@@ -78,7 +121,7 @@ export function matchesPattern(command, pattern) {
|
|
|
78
121
|
* same list and one of them reads it more carefully; that is the price of
|
|
79
122
|
* having the list in two places.
|
|
80
123
|
*
|
|
81
|
-
* The
|
|
124
|
+
* The PIPE patterns are for the config's side only. `segments()` splits
|
|
82
125
|
* on `|`, so a segment never contains " | sh" and the guard catches a piped
|
|
83
126
|
* script through its own rule instead.
|
|
84
127
|
*
|
package/src/guard.ts
CHANGED
|
@@ -31,12 +31,50 @@ export const MARKER = "[opencode-harness-kit] blocked"
|
|
|
31
31
|
*/
|
|
32
32
|
const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Whether a hunk header follows, before another file's headers do.
|
|
36
|
+
*
|
|
37
|
+
* A `---`/`+++` pair is the header pair of a file only when a hunk header
|
|
38
|
+
* (`@@`) comes after it. That is the difference between the headers of a
|
|
39
|
+
* diff and two body lines that happen to look like one, and it is the same
|
|
40
|
+
* thing a reader of the diff does.
|
|
41
|
+
*
|
|
42
|
+
* @param {string[]} lines
|
|
43
|
+
* @param {number} from the line to start looking at
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function hunkFollows(lines: string[], from: number): boolean {
|
|
47
|
+
for (let i = from; i < lines.length; i++) {
|
|
48
|
+
if (/^@@/.test(lines[i])) return true
|
|
49
|
+
// Another pair of headers starts here, so this pair was not followed by
|
|
50
|
+
// a hunk of its own.
|
|
51
|
+
if (/^(?:---|\+\+\+)\s/.test(lines[i])) return false
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/**
|
|
35
57
|
* The files a patch touches.
|
|
36
58
|
*
|
|
37
59
|
* `apply_patch` sends a patch, not a path, so the paths have to be read out
|
|
38
60
|
* of it. Two formats are in use: opencode's own `*** Update File: path`
|
|
39
|
-
* markers, and a plain unified diff with `+++ b/path`
|
|
61
|
+
* markers, and a plain unified diff with `--- a/path` and `+++ b/path`
|
|
62
|
+
* headers.
|
|
63
|
+
*
|
|
64
|
+
* A unified diff names the file TWICE: `--- a/path` is where the change
|
|
65
|
+
* comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
|
|
66
|
+
* and `+++ /dev/null`, so the path that has to be scope-checked is the one
|
|
67
|
+
* in the `---` header and nowhere else. Reading only the `+++` header meant
|
|
68
|
+
* a patch that deleted a file outside the project was checked against
|
|
69
|
+
* nothing at all - and a patch that deletes a file is exactly the shape
|
|
70
|
+
* that reaches outside the project.
|
|
71
|
+
*
|
|
72
|
+
* The headers of a diff come BEFORE its first hunk, and a body line can
|
|
73
|
+
* look like a header: a removed line whose content is "-- foo" renders as
|
|
74
|
+
* "--- foo", and an added line "++ bar" renders as "+++ bar". Reading
|
|
75
|
+
* those as the headers of a file collected two words as paths, and a path
|
|
76
|
+
* that is not a path is a write the guard refuses for a reason nobody can
|
|
77
|
+
* find.
|
|
40
78
|
*
|
|
41
79
|
* @param {string} text
|
|
42
80
|
* @returns {string[]}
|
|
@@ -44,16 +82,49 @@ const WRITE_TOOLS = new Set(["write", "edit", "apply_patch", "patch"])
|
|
|
44
82
|
export function pathsInPatch(text: string): string[] {
|
|
45
83
|
const out: string[] = []
|
|
46
84
|
const push = (p: string) => {
|
|
47
|
-
|
|
85
|
+
// A unified diff header can end in a timestamp: `--- a/file\t2026-01-01`.
|
|
86
|
+
const v = p.split("\t")[0].trim()
|
|
48
87
|
if (!v) return
|
|
49
88
|
if (v === "/dev/null") return
|
|
50
89
|
if (!out.includes(v)) out.push(v)
|
|
51
90
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
const lines = text.split("\n")
|
|
92
|
+
// Whether a hunk header has been seen. After one, a `+++` line is added
|
|
93
|
+
// content and not the header of another file.
|
|
94
|
+
let seenHunk = false
|
|
95
|
+
for (let i = 0; i < lines.length; i++) {
|
|
96
|
+
const line = lines[i]
|
|
97
|
+
const marker = /^\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+)$/.exec(line)
|
|
98
|
+
if (marker) {
|
|
99
|
+
push(marker[1])
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (/^@@/.test(line)) {
|
|
103
|
+
seenHunk = true
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
// The `---` and `+++` headers of a unified diff are one pair: the
|
|
107
|
+
// first names where the change comes FROM, the second where it goes
|
|
108
|
+
// TO. They are read together so a body line that looks like a header
|
|
109
|
+
// is not mistaken for one.
|
|
110
|
+
const minus = /^---\s+(?:a\/)?(.+)$/.exec(line)
|
|
111
|
+
const next = i + 1 < lines.length ? /^\+\+\+\s+(?:b\/)?(.+)$/.exec(lines[i + 1]) : null
|
|
112
|
+
if (minus && next) {
|
|
113
|
+
// A pair is a header pair only when a hunk header follows it.
|
|
114
|
+
if (hunkFollows(lines, i + 2)) {
|
|
115
|
+
push(minus[1])
|
|
116
|
+
push(next[1])
|
|
117
|
+
}
|
|
118
|
+
// The `+++` line is consumed either way: it is the other half of the
|
|
119
|
+
// pair, and not a header of its own.
|
|
120
|
+
i++
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
// A diff that carries only the `+++` header still names its file. It
|
|
124
|
+
// only names one while no hunk has been seen - after a hunk, a `+++`
|
|
125
|
+
// line is added content.
|
|
126
|
+
const plus = /^\+\+\+\s+(?:b\/)?(.+)$/.exec(line)
|
|
127
|
+
if (plus && !seenHunk) push(plus[1])
|
|
57
128
|
}
|
|
58
129
|
return out
|
|
59
130
|
}
|
package/src/health.js
CHANGED
|
@@ -15,12 +15,96 @@
|
|
|
15
15
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
16
|
import { join } from "node:path"
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} SpentEntry
|
|
20
|
+
* @property {string} at when the route was recorded as spent
|
|
21
|
+
* @property {string} reason the pattern that matched
|
|
22
|
+
*/
|
|
23
|
+
|
|
18
24
|
/**
|
|
19
25
|
* @typedef {object} Health
|
|
20
|
-
* @property {Record<string,
|
|
26
|
+
* @property {Record<string, SpentEntry>} unhealthy
|
|
21
27
|
* @property {string} [active]
|
|
22
28
|
*/
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How long a recorded spent route is honoured for, by default.
|
|
32
|
+
*
|
|
33
|
+
* The provider's free window resets at 00:00 UTC, so a route recorded as
|
|
34
|
+
* spent is expected to come back within a day. Without an expiry the file is
|
|
35
|
+
* a one-way ratchet: a fleet that hit one rate limit never uses that model
|
|
36
|
+
* again, its lanes drain onto the last rung of their chains, and the only
|
|
37
|
+
* way back is a human editing a JSON file by hand.
|
|
38
|
+
*
|
|
39
|
+
* 24h is the default because the provider's window is a day. The window
|
|
40
|
+
* belongs to the provider rather than to the kit, so it is configurable:
|
|
41
|
+
* HARNESS_SPENT_WINDOW_HOURS overrides it.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The window, in milliseconds, a recorded spent route is honoured for.
|
|
47
|
+
*
|
|
48
|
+
* Read at call time rather than at import time, so a caller that changes it
|
|
49
|
+
* does not have to reload the module to see the effect.
|
|
50
|
+
*
|
|
51
|
+
* @returns {number}
|
|
52
|
+
*/
|
|
53
|
+
export function spentWindowMs() {
|
|
54
|
+
const raw = process.env.HARNESS_SPENT_WINDOW_HOURS
|
|
55
|
+
if (!raw) return DEFAULT_WINDOW_MS
|
|
56
|
+
const hours = Number(raw)
|
|
57
|
+
// A window of zero would expire every route the moment it was written, and
|
|
58
|
+
// a negative one would expire routes that were never spent. Neither is a
|
|
59
|
+
// setting anybody meant, so the default stands.
|
|
60
|
+
if (!Number.isFinite(hours) || hours <= 0) return DEFAULT_WINDOW_MS
|
|
61
|
+
return hours * 60 * 60 * 1000
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a recorded route is still spent.
|
|
66
|
+
*
|
|
67
|
+
* An entry with no usable timestamp is KEPT. The router always stamps one,
|
|
68
|
+
* so an entry without one was written by a hand, and honouring a standing
|
|
69
|
+
* statement is safer than putting a route that may be dead back into a
|
|
70
|
+
* fleet. An entry that cannot be shown to be stale is not dropped.
|
|
71
|
+
*
|
|
72
|
+
* @param {unknown} entry
|
|
73
|
+
* @param {number} [now]
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function isSpent(entry, now = Date.now()) {
|
|
77
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false
|
|
78
|
+
const at = /** @type {any} */ (entry).at
|
|
79
|
+
if (typeof at !== "string") return true
|
|
80
|
+
const when = Date.parse(at)
|
|
81
|
+
if (Number.isNaN(when)) return true
|
|
82
|
+
return now - when <= spentWindowMs()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The spent routes that are still spent.
|
|
87
|
+
*
|
|
88
|
+
* Every reader of the file goes through here, so the router and the effort
|
|
89
|
+
* hook cannot disagree about which routes are down. A reader that looked at
|
|
90
|
+
* the raw file would see a route that came back an hour ago as spent, and
|
|
91
|
+
* would fail the fleet over onto a model that works.
|
|
92
|
+
*
|
|
93
|
+
* @param {Health} health
|
|
94
|
+
* @param {number} [now]
|
|
95
|
+
* @returns {Record<string, SpentEntry>}
|
|
96
|
+
*/
|
|
97
|
+
export function liveUnhealthy(health, now = Date.now()) {
|
|
98
|
+
/** @type {Record<string, SpentEntry>} */
|
|
99
|
+
const out = {}
|
|
100
|
+
const source = health?.unhealthy
|
|
101
|
+
if (!source || typeof source !== "object" || Array.isArray(source)) return out
|
|
102
|
+
for (const [key, entry] of Object.entries(source)) {
|
|
103
|
+
if (isSpent(entry, now)) out[key] = entry
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
24
108
|
/**
|
|
25
109
|
* @param {string} dir the project root
|
|
26
110
|
* @returns {string}
|
|
@@ -30,31 +114,69 @@ export function healthPath(dir) {
|
|
|
30
114
|
}
|
|
31
115
|
|
|
32
116
|
/**
|
|
117
|
+
* The health file, or an empty one.
|
|
118
|
+
*
|
|
119
|
+
* A file that is not an object - null, an array, a number, a string - is not
|
|
120
|
+
* a health file, and reading it as one made `unhealthyModels` throw on a
|
|
121
|
+
* null. That call sits inside a bare catch in src/index.ts, so the throw
|
|
122
|
+
* silently skipped the effort plan AND the write of `.space/active` that the
|
|
123
|
+
* pruner depends on: a corrupt file switched the pruner off without a word.
|
|
124
|
+
*
|
|
33
125
|
* @param {string} dir the project root
|
|
34
126
|
* @returns {Health}
|
|
35
127
|
*/
|
|
36
128
|
export function readHealth(dir) {
|
|
129
|
+
/** @type {any} */
|
|
130
|
+
let parsed
|
|
37
131
|
try {
|
|
38
|
-
|
|
132
|
+
parsed = JSON.parse(readFileSync(healthPath(dir), "utf8"))
|
|
39
133
|
} catch {
|
|
40
134
|
return { unhealthy: {} }
|
|
41
135
|
}
|
|
136
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
137
|
+
return { unhealthy: {} }
|
|
138
|
+
}
|
|
139
|
+
const unhealthy = parsed.unhealthy
|
|
140
|
+
if (!unhealthy || typeof unhealthy !== "object" || Array.isArray(unhealthy)) {
|
|
141
|
+
// The other keys are kept: `active` names the profile the fleet is
|
|
142
|
+
// running, and dropping it because the routes are malformed would lose
|
|
143
|
+
// the pruner's target as well.
|
|
144
|
+
return { ...parsed, unhealthy: {} }
|
|
145
|
+
}
|
|
146
|
+
return parsed
|
|
42
147
|
}
|
|
43
148
|
|
|
44
149
|
/**
|
|
150
|
+
* Write the health file, without the routes whose window has passed.
|
|
151
|
+
*
|
|
152
|
+
* The expiry is applied on the way out as well as on the way in, so a file
|
|
153
|
+
* that is read and written again - which the router does on every event -
|
|
154
|
+
* does not carry its dead routes forever.
|
|
155
|
+
*
|
|
45
156
|
* @param {string} dir the project root
|
|
46
157
|
* @param {Health} health
|
|
47
158
|
*/
|
|
48
159
|
export function writeHealth(dir, health) {
|
|
49
160
|
try {
|
|
50
161
|
if (!existsSync(join(dir, ".space"))) mkdirSync(join(dir, ".space"), { recursive: true })
|
|
51
|
-
|
|
162
|
+
const pruned = { ...(health ?? {}), unhealthy: liveUnhealthy(health) }
|
|
163
|
+
writeFileSync(healthPath(dir), JSON.stringify(pruned, null, 2))
|
|
52
164
|
} catch {
|
|
53
165
|
// Best effort. A health file that cannot be written means no failover,
|
|
54
166
|
// and failing to write it must not break the session.
|
|
55
167
|
}
|
|
56
168
|
}
|
|
57
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The routes on disk that are still spent.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} dir the project root
|
|
174
|
+
* @returns {Record<string, SpentEntry>}
|
|
175
|
+
*/
|
|
176
|
+
export function unhealthyRoutes(dir) {
|
|
177
|
+
return liveUnhealthy(readHealth(dir))
|
|
178
|
+
}
|
|
179
|
+
|
|
58
180
|
/**
|
|
59
181
|
* The models a previous session found spent.
|
|
60
182
|
*
|
|
@@ -67,13 +189,16 @@ export function writeHealth(dir, health) {
|
|
|
67
189
|
* lane chains list bare model ids, so the prefix is dropped here. Comparing
|
|
68
190
|
* the two directly would never match, and a spent route would look healthy.
|
|
69
191
|
*
|
|
192
|
+
* A route whose window has passed is not spent any more, and is not returned
|
|
193
|
+
* here: the provider's quota comes back at 00:00 UTC, and a lane that stays
|
|
194
|
+
* on its fallback forever is a lane that never comes back either.
|
|
195
|
+
*
|
|
70
196
|
* @param {string} dir the project root
|
|
71
197
|
* @returns {Set<string>}
|
|
72
198
|
*/
|
|
73
199
|
export function unhealthyModels(dir) {
|
|
74
|
-
const health = readHealth(dir)
|
|
75
200
|
const out = new Set()
|
|
76
|
-
for (const key of Object.keys(
|
|
201
|
+
for (const key of Object.keys(unhealthyRoutes(dir))) {
|
|
77
202
|
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
|
|
78
203
|
}
|
|
79
204
|
return out
|
package/src/lane-usage.js
CHANGED
|
@@ -34,10 +34,11 @@ import { dirname, join, resolve } from "node:path"
|
|
|
34
34
|
* The telemetry files for the project containing `startDir`.
|
|
35
35
|
*
|
|
36
36
|
* ALL of them, oldest first. The plugin rotates the file when it passes 4 MB
|
|
37
|
-
* and
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* and keeps THREE generations - `telemetry.jsonl`, `telemetry.jsonl.1` and
|
|
38
|
+
* `telemetry.jsonl.2` - and the reader used to look at `telemetry.jsonl`
|
|
39
|
+
* alone, so a rotation silently threw away the history and every lane
|
|
40
|
+
* reported "never used". The number that decides whether the fleet stays at
|
|
41
|
+
* ten lanes was disappearing on a schedule.
|
|
41
42
|
*
|
|
42
43
|
* @param {string} startDir
|
|
43
44
|
* @returns {string[]} the paths, or an empty array when the project has none
|
|
@@ -62,13 +63,19 @@ export function findTelemetryFiles(startDir) {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* The telemetry file for the project containing `startDir`.
|
|
66
|
+
* The CURRENT telemetry file for the project containing `startDir`.
|
|
67
|
+
*
|
|
68
|
+
* The current one, not the oldest one. The files are enumerated oldest
|
|
69
|
+
* first, so the current file is the LAST of them - and taking the first
|
|
70
|
+
* handed back the file that had been rotated away twice, which is the
|
|
71
|
+
* history rather than the present.
|
|
66
72
|
*
|
|
67
73
|
* @param {string} startDir
|
|
68
74
|
* @returns {string | null} the path, or null when the project has no telemetry
|
|
69
75
|
*/
|
|
70
76
|
export function findTelemetryFile(startDir) {
|
|
71
|
-
|
|
77
|
+
const files = findTelemetryFiles(startDir)
|
|
78
|
+
return files.length ? files[files.length - 1] : null
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/**
|