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,699 @@
1
+ // `admission`: inspect the machine-wide admission queues, or run an arbitrary
2
+ // command while holding a slot on a named resource. The queue mechanics live
3
+ // in src/lib/admission.ts and the durable job record in src/lib/durable-job.ts;
4
+ // this command owns flag parsing, human/JSON rendering, and exit-code
5
+ // propagation for the wrapped child process.
6
+ //
7
+ // `run --detach` splits the work in two: the client mints a job record and
8
+ // launches a detached supervisor, then returns. The supervisor holds the
9
+ // admission slot, runs the command, and writes the terminal record. Losing the
10
+ // client after that point interrupts nothing.
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { randomUUID } from "node:crypto";
14
+ import { closeSync, openSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { type Command, Option } from "commander";
17
+ import type { EmitContext } from "../commander.ts";
18
+ import { resolveBinName } from "../core/config.ts";
19
+ import {
20
+ type AdmissionEntry,
21
+ type AdmissionStatus,
22
+ AdmissionTimeoutError,
23
+ acquireAdmission,
24
+ admissionBaseDir,
25
+ admissionStatus,
26
+ listAdmissionResources,
27
+ } from "../lib/admission.ts";
28
+ import {
29
+ classifyJob,
30
+ createJobDir,
31
+ DURABLE_JOB_HEARTBEAT_MS,
32
+ DURABLE_JOB_LOG_FILENAME,
33
+ DURABLE_JOB_SCHEMA_VERSION,
34
+ type DurableJobDocument,
35
+ type DurableJobReport,
36
+ type DurableJobStatus,
37
+ formatJobAge,
38
+ jobExitCode,
39
+ listJobs,
40
+ readJobDocument,
41
+ writeJobDocument,
42
+ writeJobStatus,
43
+ } from "../lib/durable-job.ts";
44
+ import { coordEnv } from "../lib/env.ts";
45
+
46
+ interface AdmissionStatusOpts {
47
+ resource?: string;
48
+ json?: boolean;
49
+ }
50
+
51
+ interface AdmissionRunOpts {
52
+ resource: string;
53
+ capacity?: string;
54
+ timeout?: string;
55
+ label?: string;
56
+ detach?: boolean;
57
+ json?: boolean;
58
+ }
59
+
60
+ interface AdmissionSuperviseOpts {
61
+ jobDir: string;
62
+ timeout?: string;
63
+ }
64
+
65
+ interface AdmissionWaitOpts {
66
+ timeout?: string;
67
+ json?: boolean;
68
+ }
69
+
70
+ interface AdmissionJobsOpts {
71
+ json?: boolean;
72
+ limit?: string;
73
+ }
74
+
75
+ const LABEL_MAX_CHARS = 80;
76
+ const WAIT_POLL_MS = 2_000;
77
+ const DEFAULT_WAIT_TIMEOUT_MINUTES = 120;
78
+ const DEFAULT_JOBS_LIMIT = 20;
79
+
80
+ /**
81
+ * Root for durable detached job records. A sibling of the admission queue root
82
+ * rather than a child of it, so job directories are never mistaken for
83
+ * admission resources by anything listing that directory.
84
+ */
85
+ export function jobsBaseDir(): string {
86
+ return coordEnv("JOBS_DIR") ?? `${admissionBaseDir()}-jobs`;
87
+ }
88
+
89
+ /** Parse an integer flag with a bounded range; undefined means invalid. */
90
+ function parseBoundedInt(
91
+ raw: string | undefined,
92
+ fallback: number,
93
+ min: number,
94
+ max: number,
95
+ ): number | undefined {
96
+ if (raw === undefined) return fallback;
97
+ const value = Number(raw);
98
+ if (!Number.isInteger(value) || value < min || value > max) return undefined;
99
+ return value;
100
+ }
101
+
102
+ function describeEntry(entry: AdmissionEntry): string {
103
+ const since = entry.acquired_at ?? entry.created_at;
104
+ return `${entry.label || "(no label)"} (pid ${entry.pid}) since ${since}`;
105
+ }
106
+
107
+ function logResourceStatus(emit: EmitContext, status: AdmissionStatus): void {
108
+ emit.log(
109
+ `${status.resource}: ${status.holders.length} holder(s), ${status.waiters.length} waiter(s)`,
110
+ "info",
111
+ );
112
+ for (const holder of status.holders) emit.log(` holding: ${describeEntry(holder)}`, "info");
113
+ for (const waiter of status.waiters) emit.log(` waiting: ${describeEntry(waiter)}`, "info");
114
+ }
115
+
116
+ function describeError(err: unknown): string {
117
+ return err instanceof Error ? err.message : String(err);
118
+ }
119
+
120
+ function sleep(ms: number): Promise<void> {
121
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
122
+ }
123
+
124
+ function nowIso(): string {
125
+ return new Date().toISOString();
126
+ }
127
+
128
+ /** One-line job summary shared by `jobs`, `wait`, and `status`. */
129
+ function describeJobReport(report: DurableJobReport, nowMs: number): string {
130
+ const label = report.label || "(no label)";
131
+ const resource = report.resource ?? "(no resource)";
132
+ const bits: string[] = [`${report.state}`, `resource ${resource}`];
133
+ if (report.terminal) {
134
+ bits.push(
135
+ report.signal !== null
136
+ ? `killed by ${report.signal}`
137
+ : `exit ${report.exit_code ?? "unknown"}`,
138
+ );
139
+ } else if (report.state === "dead") {
140
+ bits.push(`pid ${report.pid ?? "unknown"} not running`);
141
+ } else {
142
+ bits.push(`pid ${report.pid ?? "unknown"}`);
143
+ }
144
+ if (report.heartbeat_age_ms !== null) {
145
+ bits.push(`heartbeat ${formatJobAge(report.heartbeat_age_ms)} ago`);
146
+ }
147
+ const startedMs = report.started_at !== null ? Date.parse(report.started_at) : Number.NaN;
148
+ if (!Number.isNaN(startedMs)) bits.push(`age ${formatJobAge(nowMs - startedMs)}`);
149
+ return `job ${report.job_id ?? "(no id)"}: ${bits.join(", ")} — ${label}`;
150
+ }
151
+
152
+ export function registerAdmissionCommand(program: Command, emit: EmitContext): void {
153
+ const admission = program
154
+ .command("admission")
155
+ .description(
156
+ "Machine-wide admission control for heavy jobs: inspect the per-resource " +
157
+ "slot queues, run a command while holding a slot, or launch it as a " +
158
+ "durable detached job that survives losing its client.",
159
+ )
160
+ .enablePositionalOptions();
161
+
162
+ // ------------------------------------------------------------------ status
163
+ admission
164
+ .command("status")
165
+ .description(
166
+ "Show holders and waiters per admission resource, plus any detached jobs " +
167
+ "still in flight. Queues prune dead-PID, expired, and torn entries as a " +
168
+ "side effect of being listed.",
169
+ )
170
+ .option("--resource <name>", "Show one resource instead of all of them.")
171
+ .option("--json", "Emit { resources: [...], jobs: [...] } as JSON.")
172
+ .addHelpText("after", "\nExit codes: 0 always (reporting only) · 1 usage error.")
173
+ .action((opts: AdmissionStatusOpts) => {
174
+ const dir = admissionBaseDir();
175
+ const resources = opts.resource !== undefined ? [opts.resource] : listAdmissionResources(dir);
176
+ const statuses = resources.map((resource) => admissionStatus({ dir, resource }));
177
+ const nowMs = Date.now();
178
+ const liveJobs = listJobs(jobsBaseDir())
179
+ .map((entry) => entry.report)
180
+ .filter((report): report is DurableJobReport => report !== null && !report.terminal)
181
+ .filter(
182
+ (report) =>
183
+ report.state !== "dead" &&
184
+ (opts.resource === undefined || report.resource === opts.resource),
185
+ );
186
+ if (opts.json) {
187
+ emit.data({
188
+ resources: statuses.map((status) => ({
189
+ resource: status.resource,
190
+ holders: status.holders,
191
+ waiters: status.waiters,
192
+ })),
193
+ jobs: liveJobs,
194
+ });
195
+ return;
196
+ }
197
+ const active = statuses.filter(
198
+ (status) => status.holders.length > 0 || status.waiters.length > 0,
199
+ );
200
+ if (active.length === 0 && liveJobs.length === 0) {
201
+ emit.log(
202
+ opts.resource !== undefined
203
+ ? `no admission activity on ${opts.resource}`
204
+ : "no admission activity",
205
+ "info",
206
+ );
207
+ return;
208
+ }
209
+ for (const status of active) logResourceStatus(emit, status);
210
+ if (liveJobs.length > 0) {
211
+ emit.log(`detached jobs in flight: ${liveJobs.length}`, "info");
212
+ for (const report of liveJobs) emit.log(` ${describeJobReport(report, nowMs)}`, "info");
213
+ }
214
+ });
215
+
216
+ // --------------------------------------------------------------------- run
217
+ admission
218
+ .command("run")
219
+ .description(
220
+ "Acquire one slot on an admission resource, run <command...> with inherited " +
221
+ "stdio (no shell — the first token is the executable), then release the " +
222
+ "slot and propagate the child's exit code. Everything after -- reaches " +
223
+ "the child untouched. With --detach the command instead becomes a durable " +
224
+ "job supervised by a detached process.",
225
+ )
226
+ .passThroughOptions()
227
+ .requiredOption("--resource <name>", 'Admission resource to queue on, e.g. "browser-qa".')
228
+ .option("--capacity <n>", "Concurrent holders this machine should allow (1-32; default 1).")
229
+ .option("--timeout <minutes>", "Maximum admission wait before giving up (1-1440; default 60).")
230
+ .option(
231
+ "--label <text>",
232
+ "Holder description shown in status listings (default: the command itself).",
233
+ )
234
+ .option(
235
+ "--detach",
236
+ "Launch the command as a durable job: a detached supervisor holds the slot, " +
237
+ "runs the command, and records the outcome on disk. Prints the job id and " +
238
+ "job directory and returns immediately; losing the client no longer kills " +
239
+ "the job. Reconnect with admission wait <job-dir>.",
240
+ )
241
+ .option("--json", "With --detach, print the job envelope as JSON.")
242
+ .argument("<command...>", "Command to run while holding the slot.")
243
+ .addHelpText(
244
+ "after",
245
+ "\nExit codes (foreground): the child's exit code · 4 admission timeout · " +
246
+ "1 usage error, spawn failure, or child killed by a signal." +
247
+ "\nExit codes (--detach): 0 once the job is launched · 1 usage error or " +
248
+ "launch failure. The job's own outcome comes from admission wait.",
249
+ )
250
+ .action(async (commandArgs: string[], opts: AdmissionRunOpts) => {
251
+ const capacity = parseBoundedInt(opts.capacity, 1, 1, 32);
252
+ if (capacity === undefined) {
253
+ emit.error({
254
+ code: "admission_usage",
255
+ message: "--capacity must be an integer between 1 and 32",
256
+ });
257
+ process.exitCode = 1;
258
+ return;
259
+ }
260
+ const timeoutMinutes = parseBoundedInt(opts.timeout, 60, 1, 1440);
261
+ if (timeoutMinutes === undefined) {
262
+ emit.error({
263
+ code: "admission_usage",
264
+ message: "--timeout must be an integer number of minutes between 1 and 1440",
265
+ });
266
+ process.exitCode = 1;
267
+ return;
268
+ }
269
+ const joined = commandArgs.join(" ");
270
+ const label =
271
+ opts.label ??
272
+ (joined.length > LABEL_MAX_CHARS ? `${joined.slice(0, LABEL_MAX_CHARS - 3)}...` : joined);
273
+
274
+ if (opts.detach) {
275
+ launchDetachedJob(emit, {
276
+ commandArgs,
277
+ resource: opts.resource,
278
+ capacity,
279
+ label,
280
+ timeoutMinutes,
281
+ json: opts.json === true,
282
+ });
283
+ return;
284
+ }
285
+
286
+ const dir = admissionBaseDir();
287
+ let lastWaitMessage = "";
288
+ let handle: Awaited<ReturnType<typeof acquireAdmission>>;
289
+ try {
290
+ handle = await acquireAdmission(
291
+ { dir, resource: opts.resource, capacity },
292
+ {
293
+ label,
294
+ timeoutMs: timeoutMinutes * 60_000,
295
+ onWait: (info) => {
296
+ const holders = info.holders.map((holder) => holder.label).join(", ") || "none";
297
+ const message =
298
+ `queued for a ${opts.resource} slot: position ${info.position}, ` +
299
+ `capacity ${capacity}, holder(s): ${holders}`;
300
+ if (message !== lastWaitMessage) {
301
+ lastWaitMessage = message;
302
+ emit.log(message, "info");
303
+ }
304
+ },
305
+ },
306
+ );
307
+ } catch (err: unknown) {
308
+ if (err instanceof AdmissionTimeoutError) {
309
+ emit.error({
310
+ code: "admission_timeout",
311
+ message: err.message,
312
+ hint: `${resolveBinName()} admission status --resource ${opts.resource} lists current holders`,
313
+ });
314
+ process.exitCode = 4;
315
+ return;
316
+ }
317
+ throw err;
318
+ }
319
+
320
+ const [executable, ...childArgs] = commandArgs;
321
+ try {
322
+ const outcome = await new Promise<{
323
+ code: number | null;
324
+ signal: NodeJS.Signals | null;
325
+ }>((resolvePromise, rejectPromise) => {
326
+ const child = spawn(executable as string, childArgs, {
327
+ stdio: "inherit",
328
+ shell: false,
329
+ });
330
+ child.on("error", rejectPromise);
331
+ child.on("exit", (code, signal) => resolvePromise({ code, signal }));
332
+ });
333
+ if (outcome.signal !== null) {
334
+ emit.error({
335
+ code: "admission_child_signal",
336
+ message: `${executable} was terminated by signal ${outcome.signal}`,
337
+ });
338
+ process.exitCode = 1;
339
+ return;
340
+ }
341
+ process.exitCode = outcome.code ?? 1;
342
+ } catch (err: unknown) {
343
+ emit.error({
344
+ code: "admission_spawn_error",
345
+ message: `cannot run ${executable}: ${describeError(err)}`,
346
+ });
347
+ process.exitCode = 1;
348
+ } finally {
349
+ handle.release();
350
+ }
351
+ });
352
+
353
+ // --------------------------------------------------------------- supervise
354
+ admission
355
+ .command("supervise", { hidden: true })
356
+ .description("Internal: run one durable job record to completion (detach child plumbing).")
357
+ .addOption(
358
+ new Option("--job-dir <dir>", "Job directory to supervise.").makeOptionMandatory().hideHelp(),
359
+ )
360
+ .addOption(new Option("--timeout <minutes>", "Maximum admission wait.").hideHelp())
361
+ .action(async (opts: AdmissionSuperviseOpts) => {
362
+ await superviseJob(emit, opts);
363
+ });
364
+
365
+ // -------------------------------------------------------------------- wait
366
+ admission
367
+ .command("wait <job-dir>")
368
+ .description(
369
+ "Reconnect to a detached job and block until it settles. Polls the job " +
370
+ "record every 2s: the record is authoritative, so a client that died and " +
371
+ "came back sees exactly the same outcome.",
372
+ )
373
+ .option(
374
+ "--timeout <minutes>",
375
+ `Maximum time to wait for the job to settle (1-1440; default ${DEFAULT_WAIT_TIMEOUT_MINUTES}).`,
376
+ )
377
+ .option("--json", "Print the job report as JSON.")
378
+ .addHelpText(
379
+ "after",
380
+ "\nExit codes: the job's own exit code once completed · 1 usage error or an " +
381
+ "unreadable job record · 4 the job is dead (non-terminal state, dead " +
382
+ "supervisor PID) · 5 the wait timed out while the job was still running.",
383
+ )
384
+ .action(async (jobDir: string, opts: AdmissionWaitOpts) => {
385
+ const timeoutMinutes = parseBoundedInt(opts.timeout, DEFAULT_WAIT_TIMEOUT_MINUTES, 1, 1440);
386
+ if (timeoutMinutes === undefined) {
387
+ emit.error({
388
+ code: "admission_usage",
389
+ message: "--timeout must be an integer number of minutes between 1 and 1440",
390
+ });
391
+ process.exitCode = 1;
392
+ return;
393
+ }
394
+ const deadline = Date.now() + timeoutMinutes * 60_000;
395
+ let lastState = "";
396
+ while (true) {
397
+ const outcome = classifyJob(jobDir);
398
+ if (!outcome.ok) {
399
+ emit.error({ code: "admission_job_unreadable", message: outcome.error });
400
+ process.exitCode = 1;
401
+ return;
402
+ }
403
+ const report = outcome.report;
404
+ const settled = report.terminal || report.state === "dead";
405
+ if (settled) {
406
+ if (opts.json) emit.data(report);
407
+ emit.log(describeJobReport(report, Date.now()), report.terminal ? "info" : "warn");
408
+ if (!report.terminal) emit.log(`log: ${report.log_path}`, "warn");
409
+ for (const warning of report.warnings) emit.log(`warning: ${warning}`, "warn");
410
+ const exit = jobExitCode(report);
411
+ if (exit !== 0) process.exitCode = exit;
412
+ return;
413
+ }
414
+ // Progress prints on state transitions only; a job that runs for an
415
+ // hour should not scroll a line every two seconds.
416
+ if (report.state !== lastState) {
417
+ lastState = report.state;
418
+ emit.log(describeJobReport(report, Date.now()), "info");
419
+ }
420
+ if (Date.now() >= deadline) {
421
+ if (opts.json) emit.data(report);
422
+ emit.log(`wait timed out after ${timeoutMinutes}m; job is still ${report.state}`, "warn");
423
+ process.exitCode = 5;
424
+ return;
425
+ }
426
+ await sleep(WAIT_POLL_MS);
427
+ }
428
+ });
429
+
430
+ // -------------------------------------------------------------------- jobs
431
+ admission
432
+ .command("jobs")
433
+ .description(
434
+ "List recent detached jobs, newest first: id, resource, state, label, age, " +
435
+ "and exit code. A job whose supervisor PID is gone before it completed " +
436
+ "lists as dead.",
437
+ )
438
+ .option("--json", "Emit { dir, jobs: [...] } as JSON.")
439
+ .option("--limit <n>", `Maximum jobs to list (1-500; default ${DEFAULT_JOBS_LIMIT}).`)
440
+ .addHelpText("after", "\nExit codes: 0 always (reporting only) · 1 usage error.")
441
+ .action((opts: AdmissionJobsOpts) => {
442
+ const limit = parseBoundedInt(opts.limit, DEFAULT_JOBS_LIMIT, 1, 500);
443
+ if (limit === undefined) {
444
+ emit.error({
445
+ code: "admission_usage",
446
+ message: "--limit must be an integer between 1 and 500",
447
+ });
448
+ process.exitCode = 1;
449
+ return;
450
+ }
451
+ const base = jobsBaseDir();
452
+ const entries = listJobs(base).slice(0, limit);
453
+ if (opts.json) {
454
+ emit.data({ dir: base, jobs: entries });
455
+ return;
456
+ }
457
+ if (entries.length === 0) {
458
+ emit.log(`no detached jobs under ${base}`, "info");
459
+ return;
460
+ }
461
+ const nowMs = Date.now();
462
+ for (const entry of entries) {
463
+ if (entry.report === null) {
464
+ emit.log(`job ${entry.job_id}: unreadable (${entry.error ?? "no status"})`, "warn");
465
+ continue;
466
+ }
467
+ emit.log(describeJobReport(entry.report, nowMs), entry.report.terminal ? "info" : "warn");
468
+ }
469
+ });
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Detach: client half
474
+ // ---------------------------------------------------------------------------
475
+
476
+ interface LaunchOptions {
477
+ commandArgs: string[];
478
+ resource: string;
479
+ capacity: number;
480
+ label: string;
481
+ timeoutMinutes: number;
482
+ json: boolean;
483
+ }
484
+
485
+ function launchDetachedJob(emit: EmitContext, options: LaunchOptions): void {
486
+ const cliScript = process.argv[1];
487
+ if (!cliScript) {
488
+ emit.error({
489
+ code: "admission_no_cli_script",
490
+ message: "cannot resolve the host CLI script path to launch a detached supervisor",
491
+ });
492
+ process.exitCode = 1;
493
+ return;
494
+ }
495
+ const jobId = randomUUID();
496
+ const base = jobsBaseDir();
497
+ let jobDir: string;
498
+ let logFd: number;
499
+ try {
500
+ jobDir = createJobDir(base, jobId);
501
+ const document: DurableJobDocument = {
502
+ schema_version: DURABLE_JOB_SCHEMA_VERSION,
503
+ job_id: jobId,
504
+ resource: options.resource,
505
+ capacity: options.capacity,
506
+ label: options.label,
507
+ argv: options.commandArgs,
508
+ cwd: process.cwd(),
509
+ created_at: nowIso(),
510
+ };
511
+ writeJobDocument(jobDir, document);
512
+ logFd = openSync(join(jobDir, DURABLE_JOB_LOG_FILENAME), "a");
513
+ } catch (err: unknown) {
514
+ emit.error({
515
+ code: "admission_job_setup_failed",
516
+ message: `cannot create a job record under ${base}: ${describeError(err)}`,
517
+ });
518
+ process.exitCode = 1;
519
+ return;
520
+ }
521
+
522
+ // The supervisor is this same CLI, detached from the terminal with its
523
+ // output on disk: the client may die at any moment after this point.
524
+ let child: ReturnType<typeof spawn>;
525
+ try {
526
+ child = spawn(
527
+ process.execPath,
528
+ [
529
+ cliScript,
530
+ "admission",
531
+ "supervise",
532
+ "--job-dir",
533
+ jobDir,
534
+ "--timeout",
535
+ String(options.timeoutMinutes),
536
+ ],
537
+ { detached: true, stdio: ["ignore", logFd, logFd] },
538
+ );
539
+ child.unref();
540
+ } catch (err: unknown) {
541
+ closeSync(logFd);
542
+ emit.error({
543
+ code: "admission_supervisor_spawn_failed",
544
+ message: `cannot launch the job supervisor: ${describeError(err)}`,
545
+ });
546
+ process.exitCode = 1;
547
+ return;
548
+ }
549
+ closeSync(logFd);
550
+
551
+ const startedAt = nowIso();
552
+ const launching: DurableJobStatus = {
553
+ schema_version: DURABLE_JOB_SCHEMA_VERSION,
554
+ job_id: jobId,
555
+ pid: child.pid ?? 0,
556
+ state: "launching",
557
+ started_at: startedAt,
558
+ updated_at: startedAt,
559
+ };
560
+ writeJobStatus(jobDir, launching);
561
+
562
+ const logPath = join(jobDir, DURABLE_JOB_LOG_FILENAME);
563
+ const binName = resolveBinName();
564
+ emit.log(`detached job ${jobId} (supervisor pid ${child.pid ?? "unknown"})`, "info");
565
+ emit.log(`job dir: ${jobDir}`, "info");
566
+ emit.log(`log: ${logPath}`, "info");
567
+ emit.log(`reconnect: ${binName} admission wait ${jobDir}`, "info");
568
+ if (options.json) {
569
+ emit.data({
570
+ detached: true,
571
+ job_id: jobId,
572
+ pid: child.pid ?? null,
573
+ job_dir: jobDir,
574
+ log: logPath,
575
+ resource: options.resource,
576
+ });
577
+ }
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Detach: supervisor half
582
+ // ---------------------------------------------------------------------------
583
+
584
+ /**
585
+ * Own one job record end to end: queue for the slot, run the command with its
586
+ * output on disk, then write the terminal record. Exported for tests, which
587
+ * drive it in-process against a temporary job directory.
588
+ */
589
+ export async function superviseJob(emit: EmitContext, opts: AdmissionSuperviseOpts): Promise<void> {
590
+ const jobDir = opts.jobDir;
591
+ const document = readJobDocument(jobDir);
592
+ if (!document || document.argv.length === 0) {
593
+ emit.error({
594
+ code: "admission_job_document_unreadable",
595
+ message: `${jobDir} carries no usable job document`,
596
+ });
597
+ process.exitCode = 1;
598
+ return;
599
+ }
600
+ const timeoutMinutes = parseBoundedInt(opts.timeout, 60, 1, 1440) ?? 60;
601
+ const startedAt = nowIso();
602
+ const status: DurableJobStatus = {
603
+ schema_version: DURABLE_JOB_SCHEMA_VERSION,
604
+ job_id: document.job_id,
605
+ pid: process.pid,
606
+ state: "queued",
607
+ started_at: startedAt,
608
+ updated_at: startedAt,
609
+ queue: { resource: document.resource, waiting_since: startedAt },
610
+ };
611
+ writeJobStatus(jobDir, status);
612
+
613
+ const finish = (state: { exitCode: number | null; signal: string | null }): void => {
614
+ writeJobStatus(jobDir, {
615
+ schema_version: DURABLE_JOB_SCHEMA_VERSION,
616
+ job_id: document.job_id,
617
+ pid: process.pid,
618
+ state: "completed",
619
+ started_at: startedAt,
620
+ updated_at: nowIso(),
621
+ exit_code: state.exitCode,
622
+ signal: state.signal,
623
+ });
624
+ };
625
+
626
+ let handle: Awaited<ReturnType<typeof acquireAdmission>>;
627
+ try {
628
+ handle = await acquireAdmission(
629
+ {
630
+ dir: admissionBaseDir(),
631
+ resource: document.resource,
632
+ capacity: document.capacity,
633
+ },
634
+ {
635
+ label: document.label,
636
+ timeoutMs: timeoutMinutes * 60_000,
637
+ onWait: () => {
638
+ writeJobStatus(jobDir, { ...status, updated_at: nowIso() });
639
+ },
640
+ },
641
+ );
642
+ } catch (err: unknown) {
643
+ if (err instanceof AdmissionTimeoutError) {
644
+ emit.error({ code: "admission_timeout", message: err.message });
645
+ finish({ exitCode: 4, signal: null });
646
+ process.exitCode = 4;
647
+ return;
648
+ }
649
+ finish({ exitCode: 1, signal: null });
650
+ throw err;
651
+ }
652
+
653
+ const runningAt = nowIso();
654
+ const running: DurableJobStatus = {
655
+ schema_version: DURABLE_JOB_SCHEMA_VERSION,
656
+ job_id: document.job_id,
657
+ pid: process.pid,
658
+ state: "running",
659
+ started_at: startedAt,
660
+ updated_at: runningAt,
661
+ };
662
+ writeJobStatus(jobDir, running);
663
+ // The heartbeat is what lets a reader tell a wedged supervisor from a busy
664
+ // one; a dead supervisor stops writing and the record classifies as dead.
665
+ const heartbeat = setInterval(() => {
666
+ writeJobStatus(jobDir, { ...running, updated_at: nowIso() });
667
+ }, DURABLE_JOB_HEARTBEAT_MS);
668
+ heartbeat.unref?.();
669
+
670
+ const [executable, ...childArgs] = document.argv;
671
+ let logFd: number | undefined;
672
+ try {
673
+ logFd = openSync(join(jobDir, DURABLE_JOB_LOG_FILENAME), "a");
674
+ const outcome = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
675
+ (resolvePromise, rejectPromise) => {
676
+ const child = spawn(executable as string, childArgs, {
677
+ stdio: ["ignore", logFd as number, logFd as number],
678
+ shell: false,
679
+ ...(document.cwd ? { cwd: document.cwd } : {}),
680
+ });
681
+ child.on("error", rejectPromise);
682
+ child.on("exit", (code, signal) => resolvePromise({ code, signal }));
683
+ },
684
+ );
685
+ finish({ exitCode: outcome.code, signal: outcome.signal });
686
+ process.exitCode = outcome.signal !== null ? 1 : (outcome.code ?? 1);
687
+ } catch (err: unknown) {
688
+ emit.error({
689
+ code: "admission_spawn_error",
690
+ message: `cannot run ${executable}: ${describeError(err)}`,
691
+ });
692
+ finish({ exitCode: 1, signal: null });
693
+ process.exitCode = 1;
694
+ } finally {
695
+ clearInterval(heartbeat);
696
+ if (logFd !== undefined) closeSync(logFd);
697
+ handle.release();
698
+ }
699
+ }