omakit 0.5.1 → 0.6.2

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.
Files changed (94) hide show
  1. package/README.md +38 -45
  2. package/blocks/history.json +68 -0
  3. package/blocks/run/NOTICE +12 -0
  4. package/blocks/run/Run.qml +242 -0
  5. package/blocks/run/run-supervisor.py +522 -0
  6. package/blocks/store/NOTICE +12 -0
  7. package/blocks/store/Store.qml +157 -0
  8. package/blocks/store/store-helper.py +431 -0
  9. package/package.json +12 -5
  10. package/skills/omarchy-plugin-audit/SKILL.md +11 -5
  11. package/skills/omarchy-plugin-build/SKILL.md +164 -0
  12. package/skills/omarchy-plugin-check/SKILL.md +6 -3
  13. package/skills/omarchy-plugin-submit/SKILL.md +4 -1
  14. package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
  15. package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
  16. package/tests/fixtures/weigh/clean/Widget.qml +19 -0
  17. package/tests/fixtures/weigh/clean/manifest.json +9 -0
  18. package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
  19. package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
  20. package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
  21. package/tests/fixtures/weigh/poller/Service.qml +50 -0
  22. package/tests/fixtures/weigh/poller/manifest.json +9 -0
  23. package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
  24. package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
  25. package/tests/lab/run/harness/scenarios/controls.sh +6 -0
  26. package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
  27. package/tests/lab/run/harness/scenarios/forge.sh +11 -0
  28. package/tests/lab/run/harness/scenarios/holder.sh +5 -0
  29. package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
  30. package/tests/lab/run/harness/scenarios/stall.sh +5 -0
  31. package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
  32. package/tests/lab/run/harness/scenarios/tree.sh +7 -0
  33. package/tests/lab/run/harness/shell.qml +84 -0
  34. package/tests/lab/run/report.py +217 -0
  35. package/tests/lab/run/suite.sh +106 -0
  36. package/tests/lab/store/harness/shell.qml +73 -0
  37. package/tests/lab/store/report.py +133 -0
  38. package/tests/lab/store/suite.sh +109 -0
  39. package/tests/parity/corpus.mjs +8 -3
  40. package/tests/parity/run.mjs +4 -4
  41. package/tools/audit/audit.mjs +17 -6
  42. package/tools/audit/git.mjs +3 -3
  43. package/tools/audit/report.mjs +31 -5
  44. package/tools/blocks/add.mjs +138 -0
  45. package/tools/blocks/commit.json +5 -0
  46. package/tools/blocks/record-commit.mjs +77 -0
  47. package/tools/blocks/registry.mjs +191 -0
  48. package/tools/blocks/stamp.mjs +61 -0
  49. package/tools/inspect/contract.mjs +36 -5
  50. package/tools/inspect/helpers.mjs +217 -0
  51. package/tools/inspect/inspect.mjs +68 -3
  52. package/tools/inspect/patterns.mjs +18 -3
  53. package/tools/inspect/processes.mjs +38 -5
  54. package/tools/inspect/report.mjs +18 -2
  55. package/tools/inspect/writes.mjs +22 -4
  56. package/tools/lab/guest.mjs +155 -0
  57. package/tools/lab/harness.sh +119 -0
  58. package/tools/lab/host.mjs +177 -0
  59. package/tools/lab/inspect.mjs +240 -0
  60. package/tools/lab/omarchy.gpg +13 -0
  61. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  62. package/tools/lab/paths.mjs +173 -0
  63. package/tools/lab/pin.json +42 -0
  64. package/tools/lab/pin.mjs +64 -0
  65. package/tools/lab/prune.mjs +68 -0
  66. package/tools/lab/qemu.mjs +153 -0
  67. package/tools/lab/qmp-cli.mjs +21 -0
  68. package/tools/lab/report.mjs +183 -0
  69. package/tools/lab/run.mjs +344 -0
  70. package/tools/lab/setup.mjs +430 -0
  71. package/tools/lab/suites/run.sh +35 -0
  72. package/tools/lab/suites/store.sh +41 -0
  73. package/tools/lab/suites/weigh.sh +196 -0
  74. package/tools/lab/suites.mjs +142 -0
  75. package/tools/lab/verify.mjs +134 -0
  76. package/tools/marketplace/README.md +38 -1
  77. package/tools/marketplace/banner.mjs +23 -2
  78. package/tools/marketplace/cli.mjs +449 -146
  79. package/tools/marketplace/completion-check.mjs +27 -1
  80. package/tools/marketplace/completion.mjs +32 -4
  81. package/tools/marketplace/doctor.mjs +47 -9
  82. package/tools/marketplace/github.mjs +52 -6
  83. package/tools/marketplace/local-transport.mjs +1 -1
  84. package/tools/marketplace/options.mjs +16 -5
  85. package/tools/marketplace/outcome.mjs +244 -0
  86. package/tools/marketplace/pin.mjs +178 -33
  87. package/tools/marketplace/setup.mjs +16 -15
  88. package/tools/marketplace/tree.mjs +1 -1
  89. package/tools/marketplace/upgrade.mjs +5 -5
  90. package/tools/marketplace/usage.mjs +116 -72
  91. package/tools/subject/resolve.mjs +19 -6
  92. package/tools/weigh/audit.mjs +47 -16
  93. package/tools/weigh/config.mjs +105 -24
  94. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,344 @@
1
+ // `omakit lab prove <suite>`: one suite in a disposable guest, its document
2
+ // written with the identity of the guest it ran on.
3
+ //
4
+ // The lifecycle, in order, and what each step guarantees:
5
+ //
6
+ // 1. Preflight, reading only: the base is ready and is the pin's, the
7
+ // host can run a guest, the suite's files are there, the disk has
8
+ // room for one overlay. A missing thing is named with its cost and
9
+ // the one command; nothing is fetched, nothing is created.
10
+ // 2. The lock: one directory, made atomically, holding the run id, this
11
+ // pid and the QMP socket. A lock whose QEMU still answers on its
12
+ // socket is held, whatever PID namespace it is in; one whose QEMU is
13
+ // gone and whose holder is gone is stale and is taken over.
14
+ // 3. Staging, private to the run: a qcow2 overlay backed by the base
15
+ // (the base opened read-only through the backing chain, and made
16
+ // 0444 at promotion), a copy of the base's firmware variables (the
17
+ // template is never opened writable: docs/history/2026-09-18-lab-
18
+ // inventory.md P15), the QMP socket, the serial log.
19
+ // 4. QEMU as a child of this process: no daemon, no host directory, no
20
+ // host socket, SSH forwarded on 127.0.0.1 and nothing else.
21
+ // 5. The session the way a person gets one: SSH answers, the password
22
+ // is typed at the greeter until a Hyprland owned by the guest user
23
+ // exists, the startup notifications are dismissed.
24
+ // 6. The identity, read from inside before anything else: the installed
25
+ // omarchy package, the kernel, whether the session runs from the
26
+ // package or a linked checkout. Skew is printed, never collapsed (P8).
27
+ // 7. The suite, through tools/lab/harness.sh, its output streamed to the
28
+ // run's host.log; its document read back and held to the suite's
29
+ // assertion; provenance added to the document (P18).
30
+ // 8. The guest powered off, QEMU ended (QMP, then the signal), the
31
+ // overlay measured and removed, the base and the template checked
32
+ // unchanged, the lock released, on the normal path, on failure, and
33
+ // on SIGINT and SIGTERM.
34
+ //
35
+ // Nothing here touches the host's own session: no hyprctl, no
36
+ // omarchy-shell, no shell.json on the host, and tests/unit/lab.test.mjs
37
+ // proves it over this file, the harness and the suites.
38
+
39
+ import { spawn, spawnSync } from "node:child_process"
40
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, writeFileSync } from "node:fs"
41
+ import { createServer } from "node:net"
42
+ import { join, resolve } from "node:path"
43
+ import { bytesBoth, labPin } from "./pin.mjs"
44
+ import { allocatedBytes, inLab, labDir, labLayout, readJson, removeFromLab, stampNow, writeJson } from "./paths.mjs"
45
+ import { freeBytesAt, guestCpus, probeRunHost } from "./host.mjs"
46
+ import { BASE_FILES, inspectBase, inspectLock } from "./inspect.mjs"
47
+ import { press, qemuArgs, qmpExecute, startQemu, typeText } from "./qemu.mjs"
48
+ import { clearStartupNotifications, establishSession, guestIdentity, SESSION_PREAMBLE, sleep, sshGuest, waitForSsh } from "./guest.mjs"
49
+ import { SUITES, suitePreflight } from "./suites.mjs"
50
+
51
+ export class LabError extends Error {
52
+ constructor(code, message, { remedy = null, missing = [] } = {}) {
53
+ super(message)
54
+ this.name = "LabError"
55
+ this.code = code
56
+ // An interrupt's one action is the lab's, not weigh's (whose
57
+ // `interrupted` remedy in cli.mjs speaks of shell.json).
58
+ this.remedy = remedy || (code === "interrupted" ? "the guest was ended and its overlay removed; run it again when you are ready" : null)
59
+ this.missing = missing
60
+ }
61
+ }
62
+
63
+ /** The first free port on the loopback interface from 2222 up; the toolchain's fixed port is where two runs collided (P7). */
64
+ export async function freePort(start = 2222, { attempts = 50 } = {}) {
65
+ for (let port = start; port < start + attempts; port += 1) {
66
+ const free = await new Promise((resolve) => {
67
+ const server = createServer()
68
+ server.once("error", () => resolve(false))
69
+ server.listen({ host: "127.0.0.1", port }, () => server.close(() => resolve(true)))
70
+ })
71
+ if (free) return port
72
+ }
73
+ throw new LabError("no-port", `no free port between ${start} and ${start + attempts - 1} on 127.0.0.1`)
74
+ }
75
+
76
+ /**
77
+ * Take the lab lock or refuse. `holder.json` inside it names the run; a
78
+ * QEMU answering on the recorded socket, or a live holder pid, means
79
+ * held. A stale lock is reported and taken.
80
+ */
81
+ export async function acquireLock(layout, record) {
82
+ let stale = null
83
+ for (let attempt = 0; attempt < 2; attempt += 1) {
84
+ try {
85
+ mkdirSync(inLab(layout.cache, "lab.lock"), { recursive: false, mode: 0o700 })
86
+ writeJson(layout.cache, "lab.lock/holder.json", record)
87
+ return { taken: true, stale }
88
+ } catch (error) {
89
+ if (error.code === "ENOENT") {
90
+ labDir(layout.cache)
91
+ continue
92
+ }
93
+ if (error.code !== "EEXIST") throw error
94
+ const held = await inspectLock(layout)
95
+ if (held.alive) {
96
+ throw new LabError("lab-busy", `the lab is in use by run ${held.record?.runId || "unknown"} (pid ${held.record?.pid || "?"}${held.qemuAnswers ? ", its QEMU answers on QMP" : ""}); one run at a time`, { remedy: "wait for it, or `omakit lab inspect` to see it" })
97
+ }
98
+ stale = held.record
99
+ removeFromLab(layout.cache, "lab.lock")
100
+ }
101
+ }
102
+ throw new LabError("lab-busy", "the lock could not be taken")
103
+ }
104
+
105
+ export function releaseLock(layout, runId) {
106
+ const holder = readJson(join(layout.lock, "holder.json"))
107
+ if (holder && holder.runId !== runId) return false
108
+ removeFromLab(layout.cache, "lab.lock")
109
+ return true
110
+ }
111
+
112
+ function identityOf(file) {
113
+ const st = statSync(file)
114
+ return { bytes: st.size, mtimeMs: st.mtimeMs, ino: st.ino }
115
+ }
116
+
117
+ function sameIdentity(a, b) {
118
+ return a.bytes === b.bytes && a.mtimeMs === b.mtimeMs && a.ino === b.ino
119
+ }
120
+
121
+ /**
122
+ * Boot a guest from a base directory, log in, read its identity, hand it
123
+ * to `body`, and end it on every path. The staging directory is the
124
+ * caller's (under the lab cache) and is removed here; the overlay's
125
+ * allocated bytes are measured before removal, which is the per-run disk
126
+ * cost M14 records.
127
+ *
128
+ * @param {{ baseDir: string, stagingName: string, logFile: string, layout: object, pin: object, onPhase?: Function, signal?: AbortSignal }} options
129
+ * @param {(context: { guest: object, paths: object, identity: object, session: object }) => Promise<any>} body
130
+ */
131
+ export async function withGuest({ baseDir, stagingName, logFile, layout, pin, onPhase = () => {}, signal }, body) {
132
+ const staging = labDir(layout.cache, "staging", stagingName)
133
+ const paths = { overlay: join(staging, "run.qcow2"), vars: join(staging, "vars.fd"), qmp: join(staging, "qmp.sock"), serial: join(staging, "serial.log"), pid: join(staging, "qemu.pid") }
134
+ const baseDisk = join(baseDir, BASE_FILES.disk)
135
+ const baseVars = join(baseDir, BASE_FILES.vars)
136
+ const before = { disk: identityOf(baseDisk), vars: identityOf(baseVars) }
137
+ const guest = { key: join(baseDir, BASE_FILES.key), port: null, user: pin.guest.user }
138
+ const out = { identity: null, session: null, overlayBytes: null, baseUnchanged: null, result: undefined, sshPort: null, cpus: guestCpus() }
139
+ let qemu = null
140
+ let stopped = false
141
+ const stopGuest = async () => {
142
+ if (stopped) return
143
+ stopped = true
144
+ if (!qemu || qemu.exitCode !== null) return
145
+ onPhase("powering the guest off")
146
+ let off = { status: 1 }
147
+ if (guest.port) off = sshGuest(guest, "sudo -S systemctl poweroff", { input: `${pin.guest.password}\n`, timeoutMs: 15000 })
148
+ if (off.status !== 0) await qmpExecute(paths.qmp, "system_powerdown").catch(() => {})
149
+ for (let waited = 0; waited < 20 && qemu.exitCode === null; waited += 1) await sleep(1000)
150
+ if (qemu.exitCode === null) {
151
+ await qmpExecute(paths.qmp, "quit").catch(() => {})
152
+ await sleep(1000)
153
+ }
154
+ if (qemu.exitCode === null) {
155
+ qemu.kill("SIGTERM")
156
+ await sleep(1000)
157
+ }
158
+ if (qemu.exitCode === null) qemu.kill("SIGKILL")
159
+ }
160
+ const end = async () => {
161
+ await stopGuest()
162
+ if (existsSync(paths.overlay)) out.overlayBytes = allocatedBytes(paths.overlay)
163
+ removeFromLab(layout.cache, `staging/${stagingName}`)
164
+ const after = { disk: identityOf(baseDisk), vars: identityOf(baseVars) }
165
+ out.baseUnchanged = sameIdentity(before.disk, after.disk) && sameIdentity(before.vars, after.vars)
166
+ }
167
+ try {
168
+ onPhase("creating the overlay and the run's firmware variables")
169
+ const created = spawnSync("qemu-img", ["create", "-f", "qcow2", "-b", baseDisk, "-F", "qcow2", paths.overlay], { timeout: 60_000, encoding: "utf8" })
170
+ if (created.status !== 0) throw new LabError("overlay-failed", `qemu-img could not create the overlay: ${(created.stderr || "").trim()}`)
171
+ chmodSync(paths.overlay, 0o600)
172
+ // The base's variables hold the boot entries the install wrote, so a
173
+ // guest boots from them; the run gets its own copy and the template
174
+ // stays as it was (P15).
175
+ writeFileSync(inLab(layout.cache, `staging/${stagingName}/vars.fd`), readFileSync(baseVars), { mode: 0o600 })
176
+ guest.port = await freePort()
177
+ out.sshPort = guest.port
178
+ const args = qemuArgs({ overlay: paths.overlay, vars: paths.vars, memoryMiB: pin.guest.memoryMiB, cpus: out.cpus, sshPort: guest.port, qmpSocket: paths.qmp, serialLog: paths.serial, pidFile: paths.pid })
179
+ const logFd = openSync(logFile, "a", 0o600)
180
+ onPhase(`booting the guest (${pin.guest.memoryMiB} MiB, ${out.cpus} vCPUs, SSH on 127.0.0.1:${guest.port})`)
181
+ qemu = startQemu(args, { log: logFd })
182
+ closeSync(logFd)
183
+ await new Promise((resolvePromise, reject) => {
184
+ qemu.once("spawn", resolvePromise)
185
+ qemu.once("error", (error) => reject(new LabError("qemu-failed", `QEMU did not start: ${error.message}`)))
186
+ })
187
+ // Alive means our child has not exited: it is ours, so its exit code is
188
+ // the whole answer. Measured on the first verification boot: a QMP
189
+ // probe in the first second, before QEMU had created its socket, read
190
+ // as "the guest exited" and the base was never promoted.
191
+ const alive = async () => qemu.exitCode === null
192
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted before the guest booted")
193
+ const sshSeconds = await waitForSsh(guest, { timeoutSeconds: 600, alive, onPhase })
194
+ // Five seconds for the greeter to draw after sshd answers, as the toolchain waited; a keystroke typed before it is lost.
195
+ await sleep(5000)
196
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted while the guest booted")
197
+ const session = await establishSession({ guest, socket: paths.qmp, password: pin.guest.password, typeText, press, onPhase })
198
+ const notifications = await clearStartupNotifications(guest, { onPhase })
199
+ out.session = { sshSeconds, loginRounds: session.rounds, loginSeconds: session.seconds, notificationsCleared: notifications.cleared, notificationsReason: notifications.reason }
200
+ onPhase("reading the guest's identity")
201
+ out.identity = guestIdentity(guest)
202
+ if (signal?.aborted) throw new LabError("interrupted", "interrupted before the suite started")
203
+ out.result = await body({ guest, paths, identity: out.identity, session: out.session })
204
+ } finally {
205
+ await end()
206
+ }
207
+ return out
208
+ }
209
+
210
+ /**
211
+ * Everything a run needs before a guest boots, or a LabError naming what
212
+ * is missing. Reads only.
213
+ */
214
+ export function preflightRun({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {} }) {
215
+ const suite = SUITES[suiteName]
216
+ if (!suite) throw new LabError("usage", `no suite named ${JSON.stringify(suiteName)}; the suites are ${Object.keys(SUITES).join(", ")}`, { remedy: `omakit lab prove <${Object.keys(SUITES).join("|")}>` })
217
+ const layout = labLayout(env)
218
+ const missing = []
219
+ const base = inspectBase(layout, pin)
220
+ if (base.state !== "ready") missing.push({ what: `a ready base (${base.state})`, cost: base.reason, command: "omakit lab setup" })
221
+ for (const line of probeRunHost({ pin, env })) if (line.state !== "ok") missing.push({ what: line.name, cost: line.reason, command: line.remedy })
222
+ missing.push(...suitePreflight(suite, { repoRoot, layout }))
223
+ const free = freeBytesAt(layout.cache)
224
+ if (free.bytes < pin.measured.overlayAfterRunBytes) missing.push({ what: "disk for one overlay", cost: `${bytesBoth(free.bytes)} free at ${free.path}; one run's overlay measured ${bytesBoth(pin.measured.overlayAfterRunBytes)} (M14)`, command: "omakit lab prune" })
225
+ if (missing.length) throw new LabError("lab-not-ready", `\`omakit lab prove ${suiteName}\` cannot start: ${missing.length} thing${missing.length === 1 ? " is" : "s are"} missing`, { missing, remedy: missing[0].command })
226
+ return { suite, layout, base, free }
227
+ }
228
+
229
+ /**
230
+ * Run one suite. `onPhase` gets the progress line, `onLine` every line
231
+ * the harness prints, `signal` aborts (SIGINT and SIGTERM in cli.mjs).
232
+ * Returns the run record; the suite's document is in the run directory,
233
+ * with its provenance.
234
+ */
235
+ export async function runSuite({ suiteName, env = process.env, pin = labPin(), repoRoot, options = {}, onPhase = () => {}, onLine = () => {}, signal }) {
236
+ const { suite, layout, base } = preflightRun({ suiteName, env, pin, repoRoot, options })
237
+ const runId = `${stampNow()}-${suite.name}`
238
+ const runDir = labDir(layout.state, "runs", runId)
239
+ const startedAt = new Date()
240
+ const record = {
241
+ schema: 1,
242
+ runId,
243
+ suite: suite.name,
244
+ startedAt: startedAt.toISOString(),
245
+ pin: { release: pin.release.name, isoSha256: pin.release.sha256, expectedGuestVersion: pin.release.expectedGuestVersion },
246
+ base: { dir: base.dir, createdAt: base.manifest.createdAt, allocatedBytes: base.allocatedBytes, diskSha256: base.manifest.disk.sha256, varsSha256: base.manifest.vars.sha256, origin: base.manifest.build?.origin || null },
247
+ guest: null,
248
+ skew: null,
249
+ testedSource: null,
250
+ host: { kernel: spawnSync("uname", ["-r"], { timeout: 60_000, encoding: "utf8" }).stdout?.trim() || null, qemu: spawnSync("qemu-system-x86_64", ["--version"], { timeout: 60_000, encoding: "utf8" }).stdout?.split("\n")[0] || null, cpus: guestCpus(), memoryMiB: pin.guest.memoryMiB, sshPort: null },
251
+ session: null,
252
+ suiteStatus: null,
253
+ document: null,
254
+ assertion: null,
255
+ overlayBytes: null,
256
+ baseUnchanged: null,
257
+ durationMs: null,
258
+ ok: false,
259
+ runDir,
260
+ }
261
+ const save = () => writeJson(layout.state, `runs/${runId}/run.json`, record)
262
+ await acquireLock(layout, { runId, pid: process.pid, qmpSocket: join(layout.staging, `run-${runId}`, "qmp.sock"), startedAt: record.startedAt })
263
+ save()
264
+ let booted
265
+ try {
266
+ booted = await withGuest({ baseDir: base.dir, stagingName: `run-${runId}`, logFile: join(runDir, "qemu.log"), layout, pin, onPhase, signal }, async ({ guest, paths, identity }) => {
267
+ record.guest = identity
268
+ record.host.sshPort = guest.port
269
+ record.skew = identity.linked || identity.version !== pin.release.expectedGuestVersion
270
+ record.testedSource = identity.testedSource
271
+ save()
272
+ onLine({ state: "info", text: `guest omarchy ${identity.version || "unknown"} on ${identity.kernel || "?"}; tested source ${identity.testedSource}; skew ${record.skew}`, record })
273
+ onPhase(`running ${suite.title}`)
274
+ const status = await runHarness({ suite, runId, runDir, layout, guest, paths, repoRoot, pin, suiteArgs: suite.args(options, layout), timeoutSeconds: suite.timeoutSeconds, onLine, signal })
275
+ record.suiteStatus = status
276
+ // An interrupt during the suite is an interrupt, exit 130, the same as
277
+ // one before it: the guest is ended and the staging removed on the way out.
278
+ if (status === "interrupted") throw new LabError("interrupted", "interrupted during the suite")
279
+ const documentPath = join(runDir, suite.document)
280
+ const document = readJson(documentPath)
281
+ record.document = existsSync(documentPath) ? documentPath : null
282
+ record.assertion = document ? suite.assert(document) : { ok: false, reason: `the suite wrote no ${suite.document}` }
283
+ record.ok = status === 0 && record.assertion.ok
284
+ if (document) {
285
+ // Provenance into the document itself, in the shape the evidence
286
+ // files already carried by hand (`where`), plus the identity a
287
+ // reader can check: run id, guest version, pin, skew.
288
+ document.where = `the ${record.skew ? "linked" : "stock"} Omarchy ${identity.version || "?"} guest of omakit lab, run ${runId}`
289
+ document.lab = { runId, suite: suite.name, guest: identity, skew: record.skew, testedSource: identity.testedSource, pin: record.pin, base: { createdAt: record.base.createdAt, diskSha256: record.base.diskSha256, origin: record.base.origin }, host: record.host, startedAt: record.startedAt }
290
+ writeJson(layout.state, `runs/${runId}/${suite.document}`, document)
291
+ }
292
+ })
293
+ } catch (error) {
294
+ record.error = error?.message || String(error)
295
+ record.errorCode = error?.code || null
296
+ if (signal?.aborted) record.suiteStatus = "interrupted"
297
+ throw error
298
+ } finally {
299
+ if (booted) {
300
+ record.session = booted.session
301
+ record.host.sshPort = booted.sshPort
302
+ record.overlayBytes = booted.overlayBytes
303
+ record.baseUnchanged = booted.baseUnchanged
304
+ }
305
+ releaseLock(layout, runId)
306
+ record.durationMs = Date.now() - startedAt.getTime()
307
+ save()
308
+ }
309
+ return record
310
+ }
311
+
312
+ /** The harness as a child, its lines streamed, its exit status the suite's. `null` for a signal, `"interrupted"` for an abort. */
313
+ function runHarness({ suite, runId, runDir, layout, guest, paths, repoRoot, pin, suiteArgs, timeoutSeconds, onLine, signal }) {
314
+ return new Promise((resolvePromise) => {
315
+ const hostLog = openSync(inLab(layout.state, `runs/${runId}/host.log`), "a", 0o600)
316
+ const child = spawn("bash", [join(resolve(repoRoot), "tools/lab/harness.sh"), suite.host, runDir, guest.key, String(guest.port), paths.qmp, resolve(repoRoot), SESSION_PREAMBLE, pin.guest.user, pin.guest.password, ...suiteArgs], { stdio: ["ignore", "pipe", "pipe"] })
317
+ let buffer = ""
318
+ const feed = (chunk, stream) => {
319
+ writeFileSync(hostLog, chunk)
320
+ buffer += chunk.toString("utf8")
321
+ let index
322
+ while ((index = buffer.indexOf("\n")) >= 0) {
323
+ const line = buffer.slice(0, index)
324
+ buffer = buffer.slice(index + 1)
325
+ onLine({ state: /^not ok/.test(line) ? "fail" : /^ok - /.test(line) ? "pass" : /^==> /.test(line) ? "info" : "prose", text: line, stream })
326
+ }
327
+ }
328
+ child.stdout.on("data", (chunk) => feed(chunk, "stdout"))
329
+ child.stderr.on("data", (chunk) => feed(chunk, "stderr"))
330
+ const timer = setTimeout(() => {
331
+ onLine({ state: "fail", text: `the suite ran past ${timeoutSeconds} s and was stopped` })
332
+ child.kill("SIGKILL")
333
+ }, timeoutSeconds * 1000)
334
+ const abort = () => child.kill("SIGTERM")
335
+ signal?.addEventListener("abort", abort, { once: true })
336
+ child.on("close", (code, sig) => {
337
+ clearTimeout(timer)
338
+ signal?.removeEventListener("abort", abort)
339
+ if (buffer) feed("\n", "stdout")
340
+ closeSync(hostLog)
341
+ resolvePromise(signal?.aborted ? "interrupted" : sig ? null : code)
342
+ })
343
+ })
344
+ }