opencode-harness-kit 0.4.3 → 0.4.4
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 +527 -18
- package/CONTRIBUTING.md +242 -0
- package/GLOBAL.md +34 -5
- package/MIGRATION.md +151 -0
- package/README.md +307 -37
- package/SECURITY.md +30 -0
- package/bin/baseline.mjs +15 -7
- package/bin/git-hooks/pre-commit +41 -9
- package/bin/oc-harness.mjs +1265 -82
- package/dist/config.d.ts +127 -0
- package/dist/denylist.d.ts +33 -0
- package/dist/effort.d.ts +64 -0
- package/dist/guard.d.ts +23 -0
- package/dist/health.d.ts +43 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +532 -149
- package/dist/plugin.d.ts +15 -0
- package/dist/profile.d.ts +399 -0
- package/dist/project.d.ts +8 -0
- package/dist/prune.d.ts +6 -0
- package/dist/router.d.ts +3 -0
- package/dist/rules.d.ts +109 -0
- package/dist/safe.d.ts +7 -0
- package/dist/telemetry-store.d.ts +12 -0
- package/dist/telemetry.d.ts +3 -0
- package/docs/EXIT-CODES.md +71 -0
- package/docs/KNOWN-LIMITS.md +45 -21
- package/docs/RESEARCH.md +9 -4
- package/docs/ROUTING.md +53 -8
- package/eval/RESULTS.md +443 -0
- package/eval/ab.mjs +743 -0
- package/eval/attribute.mjs +170 -0
- package/eval/flags.mjs +88 -0
- package/eval/measure-install.mjs +323 -0
- package/eval/measure-prune.mjs +339 -0
- package/eval/metrics.js +79 -0
- package/eval/premises.mjs +200 -0
- package/eval/prepare.mjs +233 -0
- package/eval/run.mjs +376 -0
- package/eval/tasks-self.json +81 -0
- package/eval/tasks-ts.json +50 -0
- package/eval/tasks.json +83 -0
- package/eval/thresholds.mjs +134 -0
- package/eval/verify.mjs +287 -0
- package/package.json +44 -17
- package/profiles/fleet-all.json +6 -1
- package/profiles/fleet-safe.json +7 -3
- package/profiles/schema.json +182 -51
- package/prompts/build.md +4 -2
- package/skills/space-build/SKILL.md +4 -2
- package/skills/space-finish/SKILL.md +6 -2
- package/skills/space-plan/reference/task-template.md +13 -5
- package/src/config.js +182 -32
- package/src/denylist.js +95 -0
- package/src/driver/run.mjs +81 -16
- package/src/effort.js +15 -4
- package/src/guard.ts +165 -13
- package/src/health.js +80 -0
- package/src/index.ts +15 -4
- package/src/lane-usage.js +72 -18
- package/src/plugin-type-check.ts +38 -0
- package/src/plugin.ts +39 -0
- package/src/profile.js +225 -26
- package/src/project.js +47 -0
- package/src/prune.ts +110 -31
- package/src/router.ts +32 -63
- package/src/rules.js +99 -7
- package/src/safe.ts +27 -0
- package/src/sqlite.js +144 -0
- package/src/tasks.js +112 -29
- package/src/telemetry-store.ts +62 -0
- package/src/telemetry.ts +60 -57
- package/src/trust.js +70 -9
package/src/router.ts
CHANGED
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
// retry it. The daily window resets at 00:00 UTC, so waiting inside a session
|
|
19
19
|
// is the wrong shape anyway.
|
|
20
20
|
|
|
21
|
-
import type { Plugin } from "
|
|
21
|
+
import type { Plugin } from "./plugin.js"
|
|
22
22
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
|
23
23
|
import { join } from "node:path"
|
|
24
|
+
import { safeStringify } from "./safe.js"
|
|
25
|
+
import { projectRoot } from "./project.js"
|
|
26
|
+
import { utilityModel } from "./config.js"
|
|
27
|
+
import { readHealth, writeHealth, unhealthyModels } from "./health.js"
|
|
24
28
|
import {
|
|
25
29
|
loadProfile,
|
|
26
30
|
pruneThreshold,
|
|
@@ -32,55 +36,6 @@ import {
|
|
|
32
36
|
resolveRoles,
|
|
33
37
|
} from "./profile.js"
|
|
34
38
|
|
|
35
|
-
interface Health {
|
|
36
|
-
unhealthy: Record<string, { at: string; reason: string }>
|
|
37
|
-
active?: string
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function healthPath(worktree: string): string {
|
|
41
|
-
return join(worktree, ".space", "health.json")
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function readHealth(worktree: string): Health {
|
|
45
|
-
try {
|
|
46
|
-
return JSON.parse(readFileSync(healthPath(worktree), "utf8"))
|
|
47
|
-
} catch {
|
|
48
|
-
return { unhealthy: {} }
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function writeHealth(worktree: string, h: Health) {
|
|
53
|
-
try {
|
|
54
|
-
mkdirSync(join(worktree, ".space"), { recursive: true })
|
|
55
|
-
writeFileSync(healthPath(worktree), JSON.stringify(h, null, 2))
|
|
56
|
-
} catch {
|
|
57
|
-
// Best effort.
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* The models a previous session found spent.
|
|
63
|
-
*
|
|
64
|
-
* Exported so the effort hook resolves a lane's variant against the model the
|
|
65
|
-
* lane actually ended up on. A lane that failed over from a model with a
|
|
66
|
-
* `none` effort level to one without levels has to lose its variant, or the
|
|
67
|
-
* config asks for an effort the new model does not publish.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} worktree
|
|
70
|
-
* @returns {Set<string>}
|
|
71
|
-
*/
|
|
72
|
-
export function unhealthyModels(worktree: string): Set<string> {
|
|
73
|
-
const health = readHealth(worktree)
|
|
74
|
-
// The health file is keyed by the qualified route, `opencode/<model>`. The
|
|
75
|
-
// lane chains list bare model ids, so the prefix is dropped here. Comparing
|
|
76
|
-
// the two directly would never match, and a spent route would look healthy.
|
|
77
|
-
const out = new Set<string>()
|
|
78
|
-
for (const key of Object.keys(health.unhealthy ?? {})) {
|
|
79
|
-
out.add(key.includes("/") ? key.slice(key.indexOf("/") + 1) : key)
|
|
80
|
-
}
|
|
81
|
-
return out
|
|
82
|
-
}
|
|
83
|
-
|
|
84
39
|
/** Patterns that mean "this route is spent", not "this request was bad". */
|
|
85
40
|
const SPENT = [
|
|
86
41
|
/FreeUsageLimitError/i,
|
|
@@ -103,17 +58,21 @@ const WARN_TEXT =
|
|
|
103
58
|
"new session. Run `oc-harness list` to see the lanes and their chains, or `oc-harness doctor` " +
|
|
104
59
|
"to check whether the profile still matches live metadata."
|
|
105
60
|
|
|
106
|
-
export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
61
|
+
export const RouterPlugin: Plugin = async ({ worktree, directory }) => {
|
|
62
|
+
// The project root, not the worktree: the worktree is the filesystem root in
|
|
63
|
+
// a project that is not a git repository, so the health file was written
|
|
64
|
+
// nowhere and no lane ever failed over.
|
|
65
|
+
const project = projectRoot(directory, worktree)
|
|
107
66
|
let failedSessions = 0
|
|
108
67
|
let warned = false
|
|
109
68
|
|
|
110
69
|
return {
|
|
111
70
|
// Runs once, before the session does any work.
|
|
112
|
-
config: async (config) => {
|
|
71
|
+
config: async (config: any) => {
|
|
113
72
|
try {
|
|
114
73
|
const cfg = config as Record<string, any>
|
|
115
|
-
const health = readHealth(
|
|
116
|
-
const unhealthy = unhealthyModels(
|
|
74
|
+
const health = readHealth(project)
|
|
75
|
+
const unhealthy = unhealthyModels(project)
|
|
117
76
|
|
|
118
77
|
// config.harness is stripped by schema validation, so the active
|
|
119
78
|
// profile comes from the config file on disk.
|
|
@@ -139,13 +98,15 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
|
139
98
|
if (health.unhealthy?.[baseKey]) {
|
|
140
99
|
const queue = [id, ...(profile.fallback ?? [])]
|
|
141
100
|
for (const candidate of queue) {
|
|
142
|
-
if (health.unhealthy?.[`opencode/${candidate}`]) continue
|
|
143
101
|
let fallback
|
|
144
102
|
try {
|
|
145
103
|
fallback = loadProfile(candidate)
|
|
146
104
|
} catch {
|
|
147
105
|
continue
|
|
148
106
|
}
|
|
107
|
+
// The health file is keyed by MODEL, and a fleet's profile id is
|
|
108
|
+
// not its model - so the check has to be against the model's own
|
|
109
|
+
// key. Checking the id was a check that could never fire.
|
|
149
110
|
const key = `opencode/${fallback.model}`
|
|
150
111
|
if (health.unhealthy?.[key]) continue
|
|
151
112
|
const fleet = isFleet(profile)
|
|
@@ -158,7 +119,7 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
|
158
119
|
threshold: fleet ? pruneThreshold(profile) : undefined,
|
|
159
120
|
})
|
|
160
121
|
health.active = candidate
|
|
161
|
-
writeHealth(
|
|
122
|
+
writeHealth(project, health)
|
|
162
123
|
break
|
|
163
124
|
}
|
|
164
125
|
}
|
|
@@ -189,12 +150,15 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
|
189
150
|
}
|
|
190
151
|
},
|
|
191
152
|
|
|
192
|
-
event: async ({ event }) => {
|
|
153
|
+
event: async ({ event }: any) => {
|
|
193
154
|
const e = event as any
|
|
194
155
|
const type = e?.type
|
|
195
156
|
if (!type) return
|
|
196
157
|
|
|
197
|
-
|
|
158
|
+
// The event is opencode's object, and it can hold a reference back to
|
|
159
|
+
// itself. A plain JSON.stringify on a cyclic object throws, and a hook
|
|
160
|
+
// that throws takes the event down with it.
|
|
161
|
+
const text = safeStringify(e)
|
|
198
162
|
const reason = classify(text)
|
|
199
163
|
if (!reason) return
|
|
200
164
|
|
|
@@ -203,14 +167,14 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
|
203
167
|
const key = modelID ? `opencode/${modelID}` : undefined
|
|
204
168
|
if (!key) return
|
|
205
169
|
|
|
206
|
-
const health = readHealth(
|
|
170
|
+
const health = readHealth(project)
|
|
207
171
|
if (health.unhealthy[key]) return // already recorded, do not spam
|
|
208
172
|
health.unhealthy[key] = { at: new Date().toISOString(), reason }
|
|
209
|
-
writeHealth(
|
|
173
|
+
writeHealth(project, health)
|
|
210
174
|
failedSessions++
|
|
211
175
|
},
|
|
212
176
|
|
|
213
|
-
"chat.message": async (input, output) => {
|
|
177
|
+
"chat.message": async (input: any, output: any) => {
|
|
214
178
|
// If this session has already hit a spent route, say so once, early,
|
|
215
179
|
// instead of letting the agent burn turns discovering it.
|
|
216
180
|
if (failedSessions === 0) return
|
|
@@ -226,7 +190,7 @@ export const RouterPlugin: Plugin = async ({ worktree }) => {
|
|
|
226
190
|
return
|
|
227
191
|
}
|
|
228
192
|
// Nothing to attach to: the health file and telemetry still record it.
|
|
229
|
-
writeHealth(
|
|
193
|
+
writeHealth(project, readHealth(project))
|
|
230
194
|
},
|
|
231
195
|
}
|
|
232
196
|
}
|
|
@@ -268,7 +232,12 @@ function applySessionModel(
|
|
|
268
232
|
try {
|
|
269
233
|
const model = `${profile.provider}/${profile.model}`
|
|
270
234
|
cfg.model = model
|
|
271
|
-
|
|
235
|
+
// The utility route belongs to the fleet, not to the model that happens
|
|
236
|
+
// to be the session's. Overwriting it with the fallback's session model
|
|
237
|
+
// threw away the cheap route exactly when the fleet failed over, and
|
|
238
|
+
// `verify` then called the difference between the file on disk and the
|
|
239
|
+
// running session a drift.
|
|
240
|
+
cfg.small_model = utilityModel(profile) ?? model
|
|
272
241
|
const variant = profile.effort?.default
|
|
273
242
|
if (profile.effort?.levels?.length && variant) cfg.variant = variant
|
|
274
243
|
else delete cfg.variant
|
package/src/rules.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { homedir, tmpdir } from "node:os"
|
|
5
5
|
import { resolve as resolvePath, sep } from "node:path"
|
|
6
|
+
import { deniedBy } from "./denylist.js"
|
|
6
7
|
|
|
7
8
|
// Two ideas here:
|
|
8
9
|
//
|
|
@@ -147,9 +148,22 @@ export function segments(command) {
|
|
|
147
148
|
if (command[i + 1] === "|") i++
|
|
148
149
|
continue
|
|
149
150
|
}
|
|
150
|
-
if (ch === "&"
|
|
151
|
+
if (ch === "&") {
|
|
152
|
+
// `&>` and `>&` are redirections, not separators: `cmd &> log` writes
|
|
153
|
+
// both streams to a file, and `cmd 2>&1` duplicates a descriptor. Both
|
|
154
|
+
// keep the `&` as part of the command.
|
|
155
|
+
if (command[i - 1] === ">" || command[i + 1] === ">") {
|
|
156
|
+
cur += ch
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
// `&&` runs the next command only if the first one succeeded, and a
|
|
160
|
+
// single `&` runs it regardless. Both are command boundaries.
|
|
161
|
+
if (command[i + 1] === "&") {
|
|
162
|
+
flush()
|
|
163
|
+
i++
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
151
166
|
flush()
|
|
152
|
-
i++
|
|
153
167
|
continue
|
|
154
168
|
}
|
|
155
169
|
cur += ch
|
|
@@ -210,7 +224,20 @@ export function tokenize(segment) {
|
|
|
210
224
|
}
|
|
211
225
|
|
|
212
226
|
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/
|
|
213
|
-
|
|
227
|
+
// The wrappers that put another command in front of the real one. `npx`
|
|
228
|
+
// runs a package's binary, so `npx npm publish` is `npm publish` with two
|
|
229
|
+
// words in front of it.
|
|
230
|
+
const GIT_WRAPPERS = new Set([
|
|
231
|
+
"env",
|
|
232
|
+
"sudo",
|
|
233
|
+
"doas",
|
|
234
|
+
"nohup",
|
|
235
|
+
"time",
|
|
236
|
+
"command",
|
|
237
|
+
"exec",
|
|
238
|
+
"xargs",
|
|
239
|
+
"npx",
|
|
240
|
+
])
|
|
214
241
|
// Wrappers whose flag consumes the next word, e.g. `sudo -u deploy git push`.
|
|
215
242
|
const WRAPPER_VALUE_FLAGS = new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"])
|
|
216
243
|
// git options that take a value, so the subcommand is the word after them.
|
|
@@ -322,13 +349,32 @@ const MAX_SHELL_DEPTH = 2
|
|
|
322
349
|
export function shellPayload(segment) {
|
|
323
350
|
const tokens = tokenize(segment)
|
|
324
351
|
if (tokens.length < 2) return null
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
352
|
+
// The wrappers come off FIRST. `sudo bash -c "rm -rf /"` hides a whole
|
|
353
|
+
// command line behind one argument, and the shell doing the hiding is the
|
|
354
|
+
// second word - so looking only at the first word missed every command
|
|
355
|
+
// with a wrapper in front of it, which is the shape a wrapper is used for.
|
|
356
|
+
const i = skipPrefixes(tokens)
|
|
357
|
+
if (i >= tokens.length) return null
|
|
358
|
+
if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null
|
|
359
|
+
if (!SHELL_BIN.test(tokens[i])) return null
|
|
360
|
+
const c = tokens.findIndex((t, n) => n > i && /^-[a-zA-Z]*c$/.test(t))
|
|
328
361
|
if (c === -1 || c === tokens.length - 1) return null
|
|
329
362
|
return tokens.slice(c + 1).join(" ") || null
|
|
330
363
|
}
|
|
331
364
|
|
|
365
|
+
/**
|
|
366
|
+
* Commands the guard deliberately does NOT block.
|
|
367
|
+
*
|
|
368
|
+
* `git push` is ask-gated through opencode's permission rules rather than
|
|
369
|
+
* guard-blocked, so it is the one command the agent reaches for that the
|
|
370
|
+
* guard has a policy about and lets through. It lives here, beside the rules,
|
|
371
|
+
* so there is ONE place that says what the guard's policy is - a keyword list
|
|
372
|
+
* in the telemetry was a second copy of it, and it drifted.
|
|
373
|
+
*
|
|
374
|
+
* @type {RegExp[]}
|
|
375
|
+
*/
|
|
376
|
+
export const ASK_GATED = [/^\s*git\s+push\b/]
|
|
377
|
+
|
|
332
378
|
/** @type {Rule[]} */
|
|
333
379
|
export const BASH_RULES = [
|
|
334
380
|
{
|
|
@@ -367,7 +413,18 @@ export const BASH_RULES = [
|
|
|
367
413
|
{
|
|
368
414
|
name: "history rewrite",
|
|
369
415
|
scope: "segment",
|
|
370
|
-
|
|
416
|
+
// The subcommand is resolved rather than read off the front of the text,
|
|
417
|
+
// because git takes options before it: `git -C /repo reset --hard` is the
|
|
418
|
+
// same command as `git reset --hard`, and a rule anchored at `^git` is
|
|
419
|
+
// defeated by every one of them. `gitSubcommand` already knows which
|
|
420
|
+
// options take a value and which ones are global.
|
|
421
|
+
check: (text) => {
|
|
422
|
+
const sub = gitSubcommand(text)
|
|
423
|
+
if (sub === "reset") return /(^|\s)--hard(\s|$)/.test(text)
|
|
424
|
+
if (sub === "clean") return /(^|\s)-[a-zA-Z]*f/.test(text)
|
|
425
|
+
if (sub === "rebase") return /(^|\s)--force/.test(text)
|
|
426
|
+
return false
|
|
427
|
+
},
|
|
371
428
|
why: "destroys work that only exists locally",
|
|
372
429
|
},
|
|
373
430
|
]
|
|
@@ -407,6 +464,41 @@ function matches(rule, text) {
|
|
|
407
464
|
return !rule.unless?.test(text)
|
|
408
465
|
}
|
|
409
466
|
|
|
467
|
+
/**
|
|
468
|
+
* The shared denylist, checked the way the rules are checked.
|
|
469
|
+
*
|
|
470
|
+
* The denylist used to be matched against the RAW command string, which is
|
|
471
|
+
* what lets `cd /tmp && npm publish` through: the pattern is `npm publish*`
|
|
472
|
+
* and the command does not start with it. It also let `sudo npm publish` and
|
|
473
|
+
* `sh -c "npm publish"` through, for the same reason.
|
|
474
|
+
*
|
|
475
|
+
* So the denylist is checked per SEGMENT, with the wrappers stripped, and
|
|
476
|
+
* the payload of a `bash -c` is checked too - exactly the way the rules are
|
|
477
|
+
* checked. One list, one way of reading it.
|
|
478
|
+
*
|
|
479
|
+
* @param {string} command
|
|
480
|
+
* @param {number} [depth]
|
|
481
|
+
* @returns {{ pattern: string, segment: string } | null}
|
|
482
|
+
*/
|
|
483
|
+
export function checkDenied(command, depth = 0) {
|
|
484
|
+
if (!command || !command.trim()) return null
|
|
485
|
+
const parts = segments(command)
|
|
486
|
+
const normalized = parts.map((seg) => stripPrefixes(seg))
|
|
487
|
+
for (let i = 0; i < parts.length; i++) {
|
|
488
|
+
const pattern = deniedBy(normalized[i])
|
|
489
|
+
if (pattern) return { pattern, segment: parts[i] }
|
|
490
|
+
}
|
|
491
|
+
if (depth < MAX_SHELL_DEPTH) {
|
|
492
|
+
for (const seg of parts) {
|
|
493
|
+
const payload = shellPayload(seg)
|
|
494
|
+
if (!payload) continue
|
|
495
|
+
const hit = checkDenied(payload, depth + 1)
|
|
496
|
+
if (hit) return hit
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return null
|
|
500
|
+
}
|
|
501
|
+
|
|
410
502
|
/**
|
|
411
503
|
* @param {string} command
|
|
412
504
|
* @param {Rule[]} [rules]
|
package/src/safe.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Stringify without throwing.
|
|
2
|
+
//
|
|
3
|
+
// An event object comes from opencode, and opencode passes the object it
|
|
4
|
+
// holds - which can hold anything, including a reference back to itself. A
|
|
5
|
+
// plain `JSON.stringify` on a cyclic object throws, and a hook that throws
|
|
6
|
+
// takes the event down with it.
|
|
7
|
+
//
|
|
8
|
+
// This is the one place the kit stringifies something it did not build, so it
|
|
9
|
+
// is the one place that has to be careful.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stringify a value, never throwing.
|
|
13
|
+
*
|
|
14
|
+
* @param {unknown} value
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
export function safeStringify(value: unknown): string {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.stringify(value) ?? String(value)
|
|
20
|
+
} catch {
|
|
21
|
+
try {
|
|
22
|
+
return String(value)
|
|
23
|
+
} catch {
|
|
24
|
+
return "[unstringifiable]"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/sqlite.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The one way this kit talks to the opencode database.
|
|
2
|
+
//
|
|
3
|
+
// It shells out to the `sqlite3` CLI rather than using a node driver, so
|
|
4
|
+
// there is no native module to build and no runtime dependency to install -
|
|
5
|
+
// the tool is either on the machine or it is not.
|
|
6
|
+
//
|
|
7
|
+
// When it is not, the failure used to be a raw `spawnSync sqlite3 ENOENT`
|
|
8
|
+
// stack trace. A missing tool is a thing a person can fix, and the error
|
|
9
|
+
// said what to install.
|
|
10
|
+
//
|
|
11
|
+
// Plain JS with JSDoc types, like the rest of src/: the CLI and the eval
|
|
12
|
+
// scripts have to run under plain node.
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the sqlite3 CLI is available.
|
|
18
|
+
* @returns {boolean}
|
|
19
|
+
*/
|
|
20
|
+
/** @type {boolean | null} */
|
|
21
|
+
let sqliteCli = null
|
|
22
|
+
export function hasSqlite() {
|
|
23
|
+
// Memoised. The measurement scripts issue one query per message, and a
|
|
24
|
+
// process spawn per query is thousands of throwaway processes.
|
|
25
|
+
if (sqliteCli !== null) return sqliteCli
|
|
26
|
+
try {
|
|
27
|
+
execFileSync("sqlite3", ["-version"], { stdio: "ignore" })
|
|
28
|
+
sqliteCli = true
|
|
29
|
+
} catch {
|
|
30
|
+
sqliteCli = false
|
|
31
|
+
}
|
|
32
|
+
return sqliteCli
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether python3 with its built-in sqlite3 module is available.
|
|
37
|
+
*
|
|
38
|
+
* The fallback exists because python3 is on more machines than the sqlite3
|
|
39
|
+
* CLI is, and a measurement harness that cannot run is a measurement nobody
|
|
40
|
+
* takes.
|
|
41
|
+
*
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
/** @type {boolean | null} */
|
|
45
|
+
let pythonSqlite = null
|
|
46
|
+
export function hasPythonSqlite() {
|
|
47
|
+
if (pythonSqlite !== null) return pythonSqlite
|
|
48
|
+
try {
|
|
49
|
+
execFileSync("python3", ["-c", "import sqlite3"], { stdio: "ignore" })
|
|
50
|
+
pythonSqlite = true
|
|
51
|
+
} catch {
|
|
52
|
+
pythonSqlite = false
|
|
53
|
+
}
|
|
54
|
+
return pythonSqlite
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether this machine can read a sqlite database at all.
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function canQuerySqlite() {
|
|
62
|
+
return hasSqlite() || hasPythonSqlite()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Explain that there is no way to read a sqlite database here, and what to
|
|
67
|
+
* install.
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function sqliteMissingMessage() {
|
|
71
|
+
return [
|
|
72
|
+
"this script needs a way to read a sqlite database, and this machine has neither.",
|
|
73
|
+
"",
|
|
74
|
+
" the sqlite3 command-line tool: brew install sqlite3 / apt install sqlite3",
|
|
75
|
+
" or python3 with its built-in sqlite3 module: apt install python3",
|
|
76
|
+
"",
|
|
77
|
+
"It is the sqlite3 CLI and not the sqlite3 library: the python3 and node",
|
|
78
|
+
"modules of that name do not put a `sqlite3` command on your PATH.",
|
|
79
|
+
].join("\n")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A database path as a URI.
|
|
84
|
+
*
|
|
85
|
+
* A path with a `?` or a `#` in it is parsed as a URI query or fragment
|
|
86
|
+
* rather than as a filename, so it is percent-encoded.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} db
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function encodePath(db) {
|
|
92
|
+
return db.split("/").map(encodeURIComponent).join("/")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run a query and return the rows.
|
|
97
|
+
*
|
|
98
|
+
* The sqlite3 CLI is the fast path. When it is not installed, python3's
|
|
99
|
+
* built-in sqlite3 module does the same job - and python3 is on more machines
|
|
100
|
+
* than the CLI is, so the measurement harness runs where it otherwise could
|
|
101
|
+
* not.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} db the database path
|
|
104
|
+
* @param {string} sql
|
|
105
|
+
* @returns {any[]}
|
|
106
|
+
*/
|
|
107
|
+
export function query(db, sql) {
|
|
108
|
+
if (hasSqlite()) {
|
|
109
|
+
// The path is percent-encoded, because it goes into a URI. A `?` in a
|
|
110
|
+
// path is a query separator, so a database called "we?ird.db" was read
|
|
111
|
+
// as a database called "we" with the query "ird.db?mode=ro" - and the
|
|
112
|
+
// error was "no such table" from a database that had the table.
|
|
113
|
+
const out = execFileSync("sqlite3", ["-json", `file:${encodePath(db)}?mode=ro`, sql], {
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
116
|
+
}).trim()
|
|
117
|
+
return out ? JSON.parse(out) : []
|
|
118
|
+
}
|
|
119
|
+
return queryWithPython(db, sql)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The same query, through python3's sqlite3 module.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} db the database path
|
|
126
|
+
* @param {string} sql
|
|
127
|
+
* @returns {any[]}
|
|
128
|
+
*/
|
|
129
|
+
function queryWithPython(db, sql) {
|
|
130
|
+
// The cursor is used once. The description came from a SECOND execute of
|
|
131
|
+
// the same statement, which re-ran the whole scan.
|
|
132
|
+
const script = [
|
|
133
|
+
"import json, sqlite3, sys",
|
|
134
|
+
"con = sqlite3.connect('file:' + sys.argv[1] + '?mode=ro', uri=True)",
|
|
135
|
+
"cur = con.execute(sys.argv[2])",
|
|
136
|
+
"cols = [d[0] for d in cur.description]",
|
|
137
|
+
"print(json.dumps([dict(zip(cols, r)) for r in cur.fetchall()]))",
|
|
138
|
+
].join("\n")
|
|
139
|
+
const out = execFileSync("python3", ["-c", script, encodePath(db), sql], {
|
|
140
|
+
encoding: "utf8",
|
|
141
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
142
|
+
}).trim()
|
|
143
|
+
return out ? JSON.parse(out) : []
|
|
144
|
+
}
|