harnery 0.37.0 → 0.38.0

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 (184) hide show
  1. package/dist/commander.d.ts +9 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +7 -2
  4. package/dist/commands/admission.d.ts +21 -0
  5. package/dist/commands/admission.d.ts.map +1 -0
  6. package/dist/commands/admission.js +565 -0
  7. package/dist/commands/agents.d.ts +7 -0
  8. package/dist/commands/agents.d.ts.map +1 -1
  9. package/dist/commands/agents.js +61 -1
  10. package/dist/commands/artifacts.d.ts.map +1 -1
  11. package/dist/commands/artifacts.js +48 -2
  12. package/dist/commands/browse-ai.d.ts +2 -2
  13. package/dist/commands/browse-ai.d.ts.map +1 -1
  14. package/dist/commands/browse-ai.js +6 -4
  15. package/dist/commands/browse.d.ts.map +1 -1
  16. package/dist/commands/browse.js +349 -21
  17. package/dist/commands/fetch.js +1 -0
  18. package/dist/commands/qa-record.d.ts +144 -0
  19. package/dist/commands/qa-record.d.ts.map +1 -0
  20. package/dist/commands/qa-record.js +0 -0
  21. package/dist/commands/qa-run.d.ts +7 -3
  22. package/dist/commands/qa-run.d.ts.map +1 -1
  23. package/dist/commands/qa-run.js +268 -13
  24. package/dist/commands/qa-status.d.ts +71 -0
  25. package/dist/commands/qa-status.d.ts.map +1 -0
  26. package/dist/commands/qa-status.js +490 -0
  27. package/dist/commands/qa-verify.d.ts +40 -0
  28. package/dist/commands/qa-verify.d.ts.map +1 -0
  29. package/dist/commands/qa-verify.js +180 -0
  30. package/dist/commands/review-pack.d.ts +4 -0
  31. package/dist/commands/review-pack.d.ts.map +1 -0
  32. package/dist/commands/review-pack.js +1001 -0
  33. package/dist/core/agents/qa-signal.d.ts +111 -0
  34. package/dist/core/agents/qa-signal.d.ts.map +1 -0
  35. package/dist/core/agents/qa-signal.js +231 -0
  36. package/dist/core/agents/session-name-display.d.ts +20 -5
  37. package/dist/core/agents/session-name-display.d.ts.map +1 -1
  38. package/dist/core/agents/session-name-display.js +67 -7
  39. package/dist/core/agents/state/heartbeat-reader.d.ts +7 -0
  40. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  41. package/dist/core/agents/state/heartbeat-writer.d.ts +11 -0
  42. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  43. package/dist/core/agents/state/heartbeat-writer.js +17 -0
  44. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  45. package/dist/core/agents/state/live-coordination-view.js +1 -0
  46. package/dist/core/agents/state/live-coordination-writer.js +5 -0
  47. package/dist/core/artifacts/constants.d.ts +1 -1
  48. package/dist/core/artifacts/constants.js +1 -1
  49. package/dist/core/artifacts/index.d.ts +57 -6
  50. package/dist/core/artifacts/index.d.ts.map +1 -1
  51. package/dist/core/artifacts/index.js +265 -10
  52. package/dist/core/config.d.ts +8 -0
  53. package/dist/core/config.d.ts.map +1 -1
  54. package/dist/core/config.js +16 -0
  55. package/dist/core/diagnostics/bundle.d.ts +16 -0
  56. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  57. package/dist/core/diagnostics/bundle.js +101 -7
  58. package/dist/core/events/v3/bootstrap.d.ts.map +1 -1
  59. package/dist/core/events/v3/bootstrap.js +10 -0
  60. package/dist/core/events/v3/coordination-view.d.ts +3 -0
  61. package/dist/core/events/v3/coordination-view.d.ts.map +1 -1
  62. package/dist/core/events/v3/coordination-view.js +69 -8
  63. package/dist/core/events/v3/producers/intake.d.ts.map +1 -1
  64. package/dist/core/events/v3/producers/intake.js +9 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts +23 -0
  66. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  67. package/dist/core/events/v3/producers/recorder.js +264 -18
  68. package/dist/core/hooks/cli.js +120 -27
  69. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  70. package/dist/core/hooks/resolve/transcript.js +10 -3
  71. package/dist/core/hooks/session-name-presence.d.ts.map +1 -1
  72. package/dist/core/hooks/session-name-presence.js +4 -1
  73. package/dist/core/qa-artifacts.d.ts +20 -0
  74. package/dist/core/qa-artifacts.d.ts.map +1 -0
  75. package/dist/core/qa-artifacts.js +110 -0
  76. package/dist/core/resources/contract.d.ts +6 -0
  77. package/dist/core/resources/contract.d.ts.map +1 -1
  78. package/dist/core/resources/sampler.d.ts +7 -0
  79. package/dist/core/resources/sampler.d.ts.map +1 -1
  80. package/dist/core/resources/sampler.js +106 -5
  81. package/dist/lib/admission.d.ts +71 -0
  82. package/dist/lib/admission.d.ts.map +1 -0
  83. package/dist/lib/admission.js +264 -0
  84. package/dist/lib/agent-browser/client.d.ts +1 -1
  85. package/dist/lib/agent-browser/client.d.ts.map +1 -1
  86. package/dist/lib/agent-browser/client.js +1 -5
  87. package/dist/lib/browser/capture-fidelity.d.ts +39 -0
  88. package/dist/lib/browser/capture-fidelity.d.ts.map +1 -0
  89. package/dist/lib/browser/capture-fidelity.js +84 -0
  90. package/dist/lib/browser/client.d.ts +41 -1
  91. package/dist/lib/browser/client.d.ts.map +1 -1
  92. package/dist/lib/browser/client.js +167 -9
  93. package/dist/lib/browser/critique.d.ts +38 -1
  94. package/dist/lib/browser/critique.d.ts.map +1 -1
  95. package/dist/lib/browser/critique.js +34 -6
  96. package/dist/lib/browser/index.d.ts +4 -2
  97. package/dist/lib/browser/index.d.ts.map +1 -1
  98. package/dist/lib/browser/index.js +2 -0
  99. package/dist/lib/browser/page-review-judge.d.ts +64 -0
  100. package/dist/lib/browser/page-review-judge.d.ts.map +1 -0
  101. package/dist/lib/browser/page-review-judge.js +270 -0
  102. package/dist/lib/browser/page-review-pack.d.ts +613 -0
  103. package/dist/lib/browser/page-review-pack.d.ts.map +1 -0
  104. package/dist/lib/browser/page-review-pack.js +1751 -0
  105. package/dist/lib/browser/qa-run-contracts.d.ts +214 -10
  106. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  107. package/dist/lib/browser/qa-run-contracts.js +136 -1
  108. package/dist/lib/browser/qa-run.d.ts +100 -10
  109. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  110. package/dist/lib/browser/qa-run.js +768 -169
  111. package/dist/lib/browser/request-diagnostics.d.ts +13 -0
  112. package/dist/lib/browser/request-diagnostics.d.ts.map +1 -0
  113. package/dist/lib/browser/request-diagnostics.js +18 -0
  114. package/dist/lib/browser/tiling.d.ts +19 -0
  115. package/dist/lib/browser/tiling.d.ts.map +1 -1
  116. package/dist/lib/browser/tiling.js +28 -0
  117. package/dist/lib/cookies/client.d.ts +9 -0
  118. package/dist/lib/cookies/client.d.ts.map +1 -1
  119. package/dist/lib/cookies/client.js +197 -44
  120. package/dist/lib/cookies/extra.d.ts +18 -0
  121. package/dist/lib/cookies/extra.d.ts.map +1 -0
  122. package/dist/lib/cookies/extra.js +14 -0
  123. package/dist/lib/cookies/index.d.ts +2 -1
  124. package/dist/lib/cookies/index.d.ts.map +1 -1
  125. package/dist/lib/cookies/index.js +2 -1
  126. package/dist/lib/durable-job.d.ts +124 -0
  127. package/dist/lib/durable-job.d.ts.map +1 -0
  128. package/dist/lib/durable-job.js +296 -0
  129. package/dist/lib/http/client.d.ts +7 -1
  130. package/dist/lib/http/client.d.ts.map +1 -1
  131. package/dist/lib/http/client.js +2 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +5 -2
  134. package/package.json +8 -2
  135. package/src/commander.ts +50 -2
  136. package/src/commands/admission.ts +699 -0
  137. package/src/commands/agents.ts +87 -1
  138. package/src/commands/artifacts.ts +97 -21
  139. package/src/commands/browse-ai.ts +10 -5
  140. package/src/commands/browse.ts +481 -20
  141. package/src/commands/fetch.ts +1 -0
  142. package/src/commands/qa-record.ts +682 -0
  143. package/src/commands/qa-run.ts +335 -16
  144. package/src/commands/qa-status.ts +608 -0
  145. package/src/commands/qa-verify.ts +238 -0
  146. package/src/commands/review-pack.ts +1281 -0
  147. package/src/core/agents/qa-signal.ts +261 -0
  148. package/src/core/agents/session-name-display.ts +78 -7
  149. package/src/core/agents/state/heartbeat-reader.ts +7 -0
  150. package/src/core/agents/state/heartbeat-writer.ts +23 -0
  151. package/src/core/agents/state/live-coordination-view.ts +1 -0
  152. package/src/core/agents/state/live-coordination-writer.ts +5 -0
  153. package/src/core/artifacts/constants.ts +1 -1
  154. package/src/core/artifacts/index.ts +370 -21
  155. package/src/core/config.ts +23 -0
  156. package/src/core/diagnostics/bundle.ts +119 -11
  157. package/src/core/events/v3/bootstrap.ts +10 -0
  158. package/src/core/events/v3/coordination-view.ts +100 -11
  159. package/src/core/events/v3/producers/intake.ts +9 -2
  160. package/src/core/events/v3/producers/recorder.ts +312 -18
  161. package/src/core/hooks/cli.ts +140 -32
  162. package/src/core/hooks/resolve/transcript.ts +10 -3
  163. package/src/core/hooks/session-name-presence.ts +6 -1
  164. package/src/core/qa-artifacts.ts +126 -0
  165. package/src/core/resources/contract.ts +7 -0
  166. package/src/core/resources/sampler.ts +138 -6
  167. package/src/lib/admission.ts +347 -0
  168. package/src/lib/agent-browser/client.ts +2 -10
  169. package/src/lib/browser/capture-fidelity.ts +98 -0
  170. package/src/lib/browser/client.ts +206 -10
  171. package/src/lib/browser/critique.ts +62 -7
  172. package/src/lib/browser/index.ts +36 -0
  173. package/src/lib/browser/page-review-judge.ts +360 -0
  174. package/src/lib/browser/page-review-pack.ts +2384 -0
  175. package/src/lib/browser/qa-run-contracts.ts +366 -3
  176. package/src/lib/browser/qa-run.ts +868 -190
  177. package/src/lib/browser/request-diagnostics.ts +27 -0
  178. package/src/lib/browser/tiling.ts +32 -0
  179. package/src/lib/cookies/client.ts +228 -42
  180. package/src/lib/cookies/extra.ts +28 -0
  181. package/src/lib/cookies/index.ts +2 -0
  182. package/src/lib/durable-job.ts +407 -0
  183. package/src/lib/http/client.ts +13 -1
  184. package/src/lib/instructions/templates.ts +5 -2
@@ -0,0 +1,490 @@
1
+ // `qa-status [path]`: report where a qa-run stands from its on-disk state:
2
+ // live (launching, queued, running), dead (non-terminal status document with
3
+ // a dead PID), or terminal (result document written; the state is the run's
4
+ // verdict). Classification is fail-closed: liveness is proven by the PID,
5
+ // never assumed from the status document, and the result document is
6
+ // authoritative over the status document once it exists. `--wait` reconnects
7
+ // to a detached run and exits with its verdict; `--queue` shows the
8
+ // machine-wide admission queue instead of any one run.
9
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { latestManagedQaRun, resolveQaRepoRoot } from "../core/qa-artifacts.js";
12
+ import { admissionBaseDir, admissionStatus, pidAlive as defaultPidAlive, listAdmissionResources, } from "../lib/admission.js";
13
+ import { QA_RUN_LATEST_FILENAME, QA_RUN_RESULT_FILENAME, QA_RUN_STATUS_FILENAME, } from "../lib/browser/qa-run.js";
14
+ import { QA_RUN_STAGES, } from "../lib/browser/qa-run-contracts.js";
15
+ import { QA_ADMISSION_RESOURCE } from "./qa-run.js";
16
+ /** A running state whose heartbeat is older than this earns a staleness
17
+ * warning, but stays "running" while the PID is alive. */
18
+ export const QA_STATUS_HEARTBEAT_STALE_MS = 120_000;
19
+ /** Log file `qa-run --detach` leaves in the run directory. */
20
+ const RUNNER_LOG_FILENAME = "runner.log";
21
+ const POLL_MS = 2_000;
22
+ const DEFAULT_WAIT_TIMEOUT_MINUTES = 60;
23
+ const STATUS_DOC_STATES = new Set(["launching", "queued", "running", "completed"]);
24
+ function describeError(err) {
25
+ return err instanceof Error ? err.message : String(err);
26
+ }
27
+ function isRecord(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ function readJsonFile(path) {
31
+ let raw;
32
+ try {
33
+ raw = readFileSync(path, "utf8");
34
+ }
35
+ catch (err) {
36
+ return { ok: false, error: `cannot read ${path}: ${describeError(err)}` };
37
+ }
38
+ try {
39
+ return { ok: true, value: JSON.parse(raw) };
40
+ }
41
+ catch (err) {
42
+ return { ok: false, error: `${path} is not valid JSON: ${describeError(err)}` };
43
+ }
44
+ }
45
+ function hasRunFiles(dir) {
46
+ return (existsSync(join(dir, QA_RUN_STATUS_FILENAME)) || existsSync(join(dir, QA_RUN_RESULT_FILENAME)));
47
+ }
48
+ /** Best-effort started_at for one run directory: the status document's
49
+ * `started_at`, falling back to the result document's `run.started_at`. */
50
+ function startedAtOf(dir) {
51
+ const statusPath = join(dir, QA_RUN_STATUS_FILENAME);
52
+ if (existsSync(statusPath)) {
53
+ const read = readJsonFile(statusPath);
54
+ if (read.ok && isRecord(read.value) && typeof read.value.started_at === "string") {
55
+ return read.value.started_at;
56
+ }
57
+ }
58
+ const resultPath = join(dir, QA_RUN_RESULT_FILENAME);
59
+ if (existsSync(resultPath)) {
60
+ const read = readJsonFile(resultPath);
61
+ if (read.ok && isRecord(read.value) && isRecord(read.value.run)) {
62
+ const startedAt = read.value.run.started_at;
63
+ if (typeof startedAt === "string")
64
+ return startedAt;
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+ /**
70
+ * Resolve `[path]` to a run directory. A file is a result document (its
71
+ * directory is the run directory); a directory carrying a status or result
72
+ * document is the run directory itself; any other directory is treated as a
73
+ * run PARENT: its `run-*` children are scanned and the one with the newest
74
+ * started_at wins, falling back to the `latest.json` pointer when no child
75
+ * carries a status or result. Without a path, the newest managed QA artifact
76
+ * under the repository's `.harnery/artifacts/` store is used.
77
+ */
78
+ export function resolveRunDir(inputPath, repoRoot) {
79
+ if (inputPath === undefined) {
80
+ const latest = latestManagedQaRun(repoRoot ?? resolveQaRepoRoot());
81
+ if (!latest) {
82
+ return {
83
+ ok: false,
84
+ error: "no managed qa-run or qa-record output exists under .harnery/artifacts",
85
+ };
86
+ }
87
+ return { ok: true, runDir: latest };
88
+ }
89
+ const abs = resolve(inputPath);
90
+ let stats;
91
+ try {
92
+ stats = statSync(abs);
93
+ }
94
+ catch (err) {
95
+ return { ok: false, error: `cannot access ${abs}: ${describeError(err)}` };
96
+ }
97
+ if (stats.isFile())
98
+ return { ok: true, runDir: dirname(abs) };
99
+ if (!stats.isDirectory()) {
100
+ return { ok: false, error: `${abs} is neither a file nor a directory` };
101
+ }
102
+ if (hasRunFiles(abs))
103
+ return { ok: true, runDir: abs };
104
+ // Run parent: newest run-*/ child by started_at.
105
+ let childNames = [];
106
+ try {
107
+ childNames = readdirSync(abs, { withFileTypes: true })
108
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
109
+ .map((entry) => entry.name)
110
+ .sort();
111
+ }
112
+ catch {
113
+ childNames = [];
114
+ }
115
+ let best;
116
+ for (const name of childNames) {
117
+ const child = join(abs, name);
118
+ if (!hasRunFiles(child))
119
+ continue;
120
+ const startedAt = startedAtOf(child);
121
+ const startedAtMs = startedAt !== undefined ? Date.parse(startedAt) : Number.NaN;
122
+ if (Number.isNaN(startedAtMs))
123
+ continue;
124
+ if (!best || startedAtMs > best.startedAtMs)
125
+ best = { dir: child, startedAtMs };
126
+ }
127
+ if (best)
128
+ return { ok: true, runDir: best.dir };
129
+ const pointerPath = join(abs, QA_RUN_LATEST_FILENAME);
130
+ if (existsSync(pointerPath)) {
131
+ const pointer = readJsonFile(pointerPath);
132
+ if (!pointer.ok)
133
+ return pointer;
134
+ if (isRecord(pointer.value)) {
135
+ const record = pointer.value;
136
+ let rel;
137
+ if (typeof record.dir === "string" && record.dir.length > 0)
138
+ rel = record.dir;
139
+ else if (typeof record.result === "string" && record.result.length > 0) {
140
+ rel = dirname(record.result);
141
+ }
142
+ if (rel !== undefined)
143
+ return { ok: true, runDir: resolve(abs, rel) };
144
+ }
145
+ return { ok: false, error: `${pointerPath} carries no run directory to follow` };
146
+ }
147
+ return {
148
+ ok: false,
149
+ error: `${abs} carries no ${QA_RUN_STATUS_FILENAME} or ${QA_RUN_RESULT_FILENAME}, no run-*/ ` +
150
+ `child with either, and no ${QA_RUN_LATEST_FILENAME} pointer`,
151
+ };
152
+ }
153
+ function exitForVerdict(verdict) {
154
+ if (verdict === "passed")
155
+ return 0;
156
+ if (verdict === "failed")
157
+ return 2;
158
+ return 4;
159
+ }
160
+ /**
161
+ * Classify one run directory, fail-closed. The result document is terminal
162
+ * and authoritative when it exists; otherwise the status document plus a PID
163
+ * liveness probe decides between a live state and "dead". Neither file is a
164
+ * usage error, not a state.
165
+ */
166
+ export function classifyRun(runDir, deps = {}) {
167
+ const isAlive = deps.pidAlive ?? defaultPidAlive;
168
+ const nowMs = deps.now ? deps.now() : Date.now();
169
+ const abs = resolve(runDir);
170
+ const resultPath = join(abs, QA_RUN_RESULT_FILENAME);
171
+ const statusPath = join(abs, QA_RUN_STATUS_FILENAME);
172
+ const logPath = join(abs, RUNNER_LOG_FILENAME);
173
+ const logExists = existsSync(logPath);
174
+ const resultExists = existsSync(resultPath);
175
+ // The status document supplies pid/stage/heartbeat even for terminal runs,
176
+ // so read it best-effort first; a broken status only matters when it is
177
+ // the sole evidence.
178
+ let status;
179
+ if (existsSync(statusPath)) {
180
+ const read = readJsonFile(statusPath);
181
+ if (read.ok && isRecord(read.value))
182
+ status = read.value;
183
+ else if (!resultExists) {
184
+ return { ok: false, error: read.ok ? `${statusPath} is not a status object` : read.error };
185
+ }
186
+ }
187
+ let heartbeatAgeMs = null;
188
+ if (status && typeof status.updated_at === "string") {
189
+ const updatedMs = Date.parse(status.updated_at);
190
+ if (!Number.isNaN(updatedMs))
191
+ heartbeatAgeMs = Math.max(0, nowMs - updatedMs);
192
+ }
193
+ if (resultExists) {
194
+ const read = readJsonFile(resultPath);
195
+ if (!read.ok)
196
+ return read;
197
+ if (!isRecord(read.value)) {
198
+ return { ok: false, error: `${resultPath} is not a result object` };
199
+ }
200
+ const result = read.value;
201
+ const verdict = result.verdict;
202
+ if (verdict !== "passed" && verdict !== "failed" && verdict !== "incomplete") {
203
+ return { ok: false, error: `${resultPath} carries no recognizable verdict` };
204
+ }
205
+ const run = isRecord(result.run) ? result.run : {};
206
+ const statusRunId = typeof status?.run_id === "string" ? status.run_id : null;
207
+ return {
208
+ ok: true,
209
+ report: {
210
+ state: verdict,
211
+ terminal: true,
212
+ exit: exitForVerdict(verdict),
213
+ run_id: typeof run.run_id === "string" ? run.run_id : statusRunId,
214
+ pid: typeof status?.pid === "number" ? status.pid : null,
215
+ stage: null,
216
+ started_at: typeof run.started_at === "string" ? run.started_at : null,
217
+ updated_at: typeof status?.updated_at === "string" ? status.updated_at : null,
218
+ completed_at: typeof run.completed_at === "string" ? run.completed_at : null,
219
+ heartbeat_age_ms: heartbeatAgeMs,
220
+ verdict,
221
+ result_path: resultPath,
222
+ ...(logExists ? { log_path: logPath } : {}),
223
+ ...(isRecord(result.wall_time_ms)
224
+ ? { wall_time_ms: result.wall_time_ms }
225
+ : {}),
226
+ warnings: [],
227
+ },
228
+ };
229
+ }
230
+ if (!status) {
231
+ return {
232
+ ok: false,
233
+ error: `${abs} carries neither ${QA_RUN_RESULT_FILENAME} nor ${QA_RUN_STATUS_FILENAME}: ` +
234
+ "not a qa-run directory",
235
+ };
236
+ }
237
+ const docState = typeof status.state === "string" ? status.state : "";
238
+ if (!STATUS_DOC_STATES.has(docState)) {
239
+ return {
240
+ ok: false,
241
+ error: `${statusPath} carries unrecognized state ${JSON.stringify(status.state)}`,
242
+ };
243
+ }
244
+ const pid = typeof status.pid === "number" ? status.pid : 0;
245
+ const runId = typeof status.run_id === "string" ? status.run_id : null;
246
+ const stage = typeof status.stage === "string" && QA_RUN_STAGES.includes(status.stage)
247
+ ? status.stage
248
+ : null;
249
+ const startedAt = typeof status.started_at === "string" ? status.started_at : null;
250
+ const updatedAt = typeof status.updated_at === "string" ? status.updated_at : null;
251
+ const alive = pid > 0 && isAlive(pid);
252
+ if (!alive) {
253
+ return {
254
+ ok: true,
255
+ report: {
256
+ state: "dead",
257
+ terminal: false,
258
+ exit: 4,
259
+ run_id: runId,
260
+ pid: pid > 0 ? pid : null,
261
+ stage,
262
+ started_at: startedAt,
263
+ updated_at: updatedAt,
264
+ completed_at: null,
265
+ heartbeat_age_ms: heartbeatAgeMs,
266
+ log_path: logPath,
267
+ warnings: [],
268
+ },
269
+ };
270
+ }
271
+ const warnings = [];
272
+ let state;
273
+ if (docState === "completed") {
274
+ // The status document claims completion but no result document exists.
275
+ // The PID is alive, so the runner may still be writing; stay "running"
276
+ // and say so rather than inventing a verdict.
277
+ state = "running";
278
+ warnings.push(`${QA_RUN_STATUS_FILENAME} says completed but no ${QA_RUN_RESULT_FILENAME} exists yet; ` +
279
+ "treating as running while the PID is alive");
280
+ }
281
+ else {
282
+ state = docState;
283
+ }
284
+ if (state === "running" &&
285
+ heartbeatAgeMs !== null &&
286
+ heartbeatAgeMs > QA_STATUS_HEARTBEAT_STALE_MS) {
287
+ warnings.push(`heartbeat stale: last update ${Math.round(heartbeatAgeMs / 1000)}s ago ` +
288
+ `(threshold ${QA_STATUS_HEARTBEAT_STALE_MS / 1000}s), but pid ${pid} is alive`);
289
+ }
290
+ const queueValue = status.queue;
291
+ const queue = isRecord(queueValue) &&
292
+ typeof queueValue.resource === "string" &&
293
+ typeof queueValue.waiting_since === "string"
294
+ ? { resource: queueValue.resource, waiting_since: queueValue.waiting_since }
295
+ : undefined;
296
+ return {
297
+ ok: true,
298
+ report: {
299
+ state,
300
+ terminal: false,
301
+ exit: 5,
302
+ run_id: runId,
303
+ pid,
304
+ stage,
305
+ started_at: startedAt,
306
+ updated_at: updatedAt,
307
+ completed_at: null,
308
+ heartbeat_age_ms: heartbeatAgeMs,
309
+ ...(queue ? { queue } : {}),
310
+ ...(logExists ? { log_path: logPath } : {}),
311
+ warnings,
312
+ },
313
+ };
314
+ }
315
+ // ---------------------------------------------------------------------------
316
+ // Rendering
317
+ // ---------------------------------------------------------------------------
318
+ function formatAge(ms) {
319
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
320
+ if (totalSeconds < 60)
321
+ return `${totalSeconds}s`;
322
+ const minutes = Math.floor(totalSeconds / 60);
323
+ if (minutes < 60)
324
+ return `${minutes}m ${totalSeconds % 60}s`;
325
+ const hours = Math.floor(minutes / 60);
326
+ return `${hours}h ${minutes % 60}m`;
327
+ }
328
+ function renderReport(emit, report) {
329
+ const runId = report.run_id ?? "(no run id)";
330
+ if (report.terminal) {
331
+ const completed = report.completed_at ? `, completed ${report.completed_at}` : "";
332
+ emit.log(`run ${runId}: verdict ${report.verdict}${completed} (${report.result_path})`, report.state === "passed" ? "info" : "warn");
333
+ if (report.wall_time_ms) {
334
+ const total = report.wall_time_ms.total;
335
+ const queueMs = report.wall_time_ms.queue;
336
+ const totalPart = total !== undefined ? `total ${total}ms` : "total unknown";
337
+ const queuePart = queueMs !== undefined ? `, queue wait ${queueMs}ms` : "";
338
+ emit.log(`wall time: ${totalPart}${queuePart}`, "info");
339
+ }
340
+ }
341
+ else if (report.state === "dead") {
342
+ const bits = [
343
+ `pid ${report.pid ?? "unknown"} not running`,
344
+ ...(report.stage !== null ? [`last stage ${report.stage}`] : []),
345
+ ...(report.heartbeat_age_ms !== null
346
+ ? [`last heartbeat ${formatAge(report.heartbeat_age_ms)} ago`]
347
+ : []),
348
+ ];
349
+ emit.log(`run ${runId}: dead, ${bits.join(", ")}`, "warn");
350
+ if (report.log_path !== undefined)
351
+ emit.log(`log: ${report.log_path}`, "warn");
352
+ }
353
+ else {
354
+ const bits = [
355
+ ...(report.stage !== null ? [`stage ${report.stage}`] : []),
356
+ `pid ${report.pid ?? "unknown"}`,
357
+ ...(report.heartbeat_age_ms !== null
358
+ ? [`heartbeat ${formatAge(report.heartbeat_age_ms)} ago`]
359
+ : []),
360
+ ];
361
+ emit.log(`run ${runId}: ${report.state}, ${bits.join(", ")}`, "info");
362
+ if (report.queue) {
363
+ emit.log(`queued for ${report.queue.resource} since ${report.queue.waiting_since}`, "info");
364
+ }
365
+ }
366
+ for (const warning of report.warnings)
367
+ emit.log(`warning: ${warning}`, "warn");
368
+ }
369
+ function describeAdmissionEntry(entry, nowMs, verb) {
370
+ const stamp = verb === "acquired" ? (entry.acquired_at ?? entry.created_at) : entry.created_at;
371
+ const ageMs = nowMs - Date.parse(stamp);
372
+ const age = Number.isNaN(ageMs) ? `${verb} at unknown time` : `${verb} ${formatAge(ageMs)} ago`;
373
+ return `${entry.label || "(no label)"} (pid ${entry.pid}), ${age}`;
374
+ }
375
+ function runQueueAction(emit, json) {
376
+ const dir = admissionBaseDir();
377
+ const nowMs = Date.now();
378
+ const names = new Set(listAdmissionResources(dir));
379
+ names.add(QA_ADMISSION_RESOURCE);
380
+ // browser-qa first, everything else alphabetical.
381
+ const ordered = [...names].sort((a, b) => {
382
+ if (a === QA_ADMISSION_RESOURCE)
383
+ return -1;
384
+ if (b === QA_ADMISSION_RESOURCE)
385
+ return 1;
386
+ return a.localeCompare(b);
387
+ });
388
+ const resources = ordered.map((resource) => admissionStatus({ dir, resource }));
389
+ if (json) {
390
+ emit.data({ dir, resources });
391
+ return;
392
+ }
393
+ for (const entry of resources) {
394
+ const holders = entry.holders.length;
395
+ const waiters = entry.waiters.length;
396
+ emit.log(`${entry.resource}: ${holders} holder${holders === 1 ? "" : "s"}, ${waiters} waiting`, "info");
397
+ for (const holder of entry.holders) {
398
+ emit.log(` holding: ${describeAdmissionEntry(holder, nowMs, "acquired")}`, "info");
399
+ }
400
+ for (const waiter of entry.waiters) {
401
+ emit.log(` waiting: ${describeAdmissionEntry(waiter, nowMs, "queued")}`, "info");
402
+ }
403
+ }
404
+ }
405
+ function sleep(ms) {
406
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
407
+ }
408
+ export function registerQaStatusCommand(program, emit, context) {
409
+ program
410
+ .command("qa-status [path]")
411
+ .description("Report where a qa-run stands from its on-disk state: live (launching, queued, " +
412
+ "running), dead (non-terminal status with a dead PID), or terminal (result " +
413
+ "document written; the state is the verdict). Fail-closed: liveness is proven " +
414
+ "by the PID, never assumed from the status document.")
415
+ .option("--json", "Print the status envelope as JSON.")
416
+ .option("--wait", "Poll every 2s until the run reaches a terminal verdict or is dead.")
417
+ .option("--wait-timeout <minutes>", `Maximum --wait duration in minutes (default ${DEFAULT_WAIT_TIMEOUT_MINUTES}).`)
418
+ .option("--queue", "Ignore [path] and print the machine-wide admission queue instead: holders and " +
419
+ "waiters per resource, dead entries pruned on the way.")
420
+ .addHelpText("after", `\n[path] may be a ${QA_RUN_RESULT_FILENAME} file, a run directory (containing ` +
421
+ `${QA_RUN_STATUS_FILENAME} or ${QA_RUN_RESULT_FILENAME}), or a run parent directory ` +
422
+ `(newest run-*/ child by started_at, falling back to the ${QA_RUN_LATEST_FILENAME} ` +
423
+ `pointer). Without [path], the newest managed run under .harnery/artifacts is used.` +
424
+ "\n\nExit codes: 0 completed passed · 1 usage or unreadable state · " +
425
+ "2 completed failed · 4 completed incomplete or dead · " +
426
+ "5 still in progress (or --wait timeout).")
427
+ .action(async (path, opts) => {
428
+ if (opts.queue) {
429
+ runQueueAction(emit, opts.json === true);
430
+ return;
431
+ }
432
+ let waitTimeoutMinutes = DEFAULT_WAIT_TIMEOUT_MINUTES;
433
+ if (opts.waitTimeout !== undefined) {
434
+ const minutes = Number.parseInt(opts.waitTimeout, 10);
435
+ if (!Number.isInteger(minutes) || minutes < 1) {
436
+ emit.error({
437
+ code: "qa_status_invalid_wait_timeout",
438
+ message: "--wait-timeout must be a positive integer number of minutes",
439
+ });
440
+ process.exitCode = 1;
441
+ return;
442
+ }
443
+ waitTimeoutMinutes = minutes;
444
+ }
445
+ const resolved = resolveRunDir(path, resolveQaRepoRoot(context));
446
+ if (!resolved.ok) {
447
+ emit.error({ code: "qa_status_unresolvable_path", message: resolved.error });
448
+ process.exitCode = 1;
449
+ return;
450
+ }
451
+ const runDir = resolved.runDir;
452
+ const deadline = Date.now() + waitTimeoutMinutes * 60_000;
453
+ let lastProgressLine = "";
454
+ while (true) {
455
+ const outcome = classifyRun(runDir);
456
+ if (!outcome.ok) {
457
+ emit.error({ code: "qa_status_unreadable", message: outcome.error });
458
+ process.exitCode = 1;
459
+ return;
460
+ }
461
+ const report = outcome.report;
462
+ const settled = report.terminal || report.state === "dead";
463
+ if (settled || !opts.wait) {
464
+ if (opts.json)
465
+ emit.data(report);
466
+ renderReport(emit, report);
467
+ if (!settled && !opts.wait) {
468
+ emit.log("still in progress; rerun with --wait to block until the run settles", "info");
469
+ }
470
+ if (report.exit !== 0)
471
+ process.exitCode = report.exit;
472
+ return;
473
+ }
474
+ const progressLine = `run ${report.run_id ?? "(no run id)"}: ${report.state}` +
475
+ `${report.stage !== null ? `, stage ${report.stage}` : ""}`;
476
+ if (progressLine !== lastProgressLine) {
477
+ lastProgressLine = progressLine;
478
+ emit.log(progressLine, "info");
479
+ }
480
+ if (Date.now() >= deadline) {
481
+ if (opts.json)
482
+ emit.data(report);
483
+ emit.log(`wait timed out after ${waitTimeoutMinutes}m; run is still ${report.state}`, "warn");
484
+ process.exitCode = 5;
485
+ return;
486
+ }
487
+ await sleep(POLL_MS);
488
+ }
489
+ });
490
+ }
@@ -0,0 +1,40 @@
1
+ import type { Command } from "commander";
2
+ import type { EmitContext } from "../commander.js";
3
+ import { type QaEvidenceAssessment } from "../lib/browser/qa-run-contracts.js";
4
+ export interface QaVerifyOptions {
5
+ /** Exact run ID the evidence must carry (`--run-id`). */
6
+ runId?: string;
7
+ /** Revision the evidence must have tested (`--revision`). */
8
+ revision?: string;
9
+ /** Job file to reconstruct and digest-match (`--job`). */
10
+ job?: string;
11
+ /** Maximum age of completed_at, in minutes (`--max-age`). */
12
+ maxAge?: string;
13
+ /** Emit the assessment as JSON (`--json`). */
14
+ json?: boolean;
15
+ }
16
+ export interface QaVerifyDeps {
17
+ /** Evaluation instant for --max-age (ISO-8601). Default: current time. */
18
+ now?: string;
19
+ }
20
+ export interface QaVerifyOutcome {
21
+ /** 0 evidence fresh · 3 stale or unverifiable · 1 usage/unreadable/invalid job. */
22
+ exit: 0 | 1 | 3;
23
+ /** Present whenever the document was resolved and assessed. */
24
+ assessment?: QaEvidenceAssessment;
25
+ /** Absolute path of the result document that was (or would be) assessed. */
26
+ resultPath?: string;
27
+ /** Human-readable failure for exit-1 outcomes. */
28
+ error?: string;
29
+ /** Individual job-validation errors when --job failed validation. */
30
+ jobErrors?: string[];
31
+ }
32
+ /**
33
+ * Pure core of qa-verify: resolve `<path>` to a result document, collect the
34
+ * expectations the flags express, and assess. The commander action is a thin
35
+ * wrapper over this so the resolution and expectation paths test without a
36
+ * CLI harness.
37
+ */
38
+ export declare function resolveAndAssess(inputPath: string, opts?: QaVerifyOptions, deps?: QaVerifyDeps): QaVerifyOutcome;
39
+ export declare function registerQaVerifyCommand(program: Command, emit: EmitContext): void;
40
+ //# sourceMappingURL=qa-verify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"qa-verify.d.ts","sourceRoot":"","sources":["../../src/commands/qa-verify.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,OAAO,EAGL,KAAK,oBAAoB,EAG1B,MAAM,oCAAoC,CAAC;AAE5C,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0DAA0D;IAC1D,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,mFAAmF;IACnF,IAAI,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAChB,+DAA+D;IAC/D,UAAU,CAAC,EAAE,oBAAoB,CAAC;IAClC,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;CACtB;AAiED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE,eAAoB,EAC1B,IAAI,GAAE,YAAiB,GACtB,eAAe,CAwDjB;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAsDjF"}