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,407 @@
1
+ // Durable job records: a small on-disk representation of "a job that outlives
2
+ // the client that launched it". A supervisor process owns the record; a client
3
+ // only reads it. A bridge drop, a closed terminal, or a killed client
4
+ // therefore interrupts the *view* of the job, never the job itself.
5
+ //
6
+ // Layout, under a caller-supplied base directory:
7
+ //
8
+ // <base>/jobs/<job_id>/job.json immutable description (argv, cwd, resource)
9
+ // <base>/jobs/<job_id>/status.json mutable state, rewritten atomically
10
+ // <base>/jobs/<job_id>/job.log merged child stdout + stderr
11
+ //
12
+ // Classification is fail-closed, mirroring qa-status: liveness is proven by the
13
+ // PID, never assumed from the status document. A non-terminal state whose PID
14
+ // is dead classifies as "dead", never as "running".
15
+ //
16
+ // Toolkit tier: this module must not import src/core (layering check).
17
+
18
+ import { randomUUID } from "node:crypto";
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readdirSync,
23
+ readFileSync,
24
+ renameSync,
25
+ writeFileSync,
26
+ } from "node:fs";
27
+ import { join, resolve } from "node:path";
28
+
29
+ export const DURABLE_JOB_SCHEMA_VERSION = 1;
30
+
31
+ /** How often the supervisor rewrites status.json while a job runs. */
32
+ export const DURABLE_JOB_HEARTBEAT_MS = 15_000;
33
+
34
+ /** A running job whose heartbeat is older than this earns a staleness warning,
35
+ * but stays "running" while its PID is alive. */
36
+ export const DURABLE_JOB_HEARTBEAT_STALE_MS = 120_000;
37
+
38
+ export const DURABLE_JOB_DOCUMENT_FILENAME = "job.json";
39
+ export const DURABLE_JOB_STATUS_FILENAME = "status.json";
40
+ export const DURABLE_JOB_LOG_FILENAME = "job.log";
41
+
42
+ const JOBS_DIRNAME = "jobs";
43
+
44
+ /** States a supervisor writes. "completed" is the only terminal one. */
45
+ export type DurableJobState = "launching" | "queued" | "running" | "completed";
46
+
47
+ /** States a reader can observe. "dead" is derived, never written. */
48
+ export type DurableJobObservedState = DurableJobState | "dead";
49
+
50
+ const WRITTEN_STATES = new Set<string>(["launching", "queued", "running", "completed"]);
51
+
52
+ export interface DurableJobDocument {
53
+ schema_version: number;
54
+ job_id: string;
55
+ /** Admission resource the job queues on. */
56
+ resource: string;
57
+ /** Concurrent holders allowed on that resource. */
58
+ capacity: number;
59
+ /** Human-readable description shown in listings. */
60
+ label: string;
61
+ /** Argument vector of the wrapped command; argv[0] is the executable. */
62
+ argv: string[];
63
+ /** Directory the wrapped command runs in. */
64
+ cwd: string;
65
+ created_at: string;
66
+ }
67
+
68
+ export interface DurableJobStatus {
69
+ schema_version: number;
70
+ job_id: string;
71
+ /** PID of the supervisor, not of the wrapped command. */
72
+ pid: number;
73
+ state: DurableJobState;
74
+ started_at: string;
75
+ updated_at: string;
76
+ /** Present while the supervisor waits for an admission slot. */
77
+ queue?: { resource: string; waiting_since: string };
78
+ exit_code?: number | null;
79
+ signal?: string | null;
80
+ }
81
+
82
+ export interface DurableJobReport {
83
+ state: DurableJobObservedState;
84
+ /** The job reached "completed"; exit_code and signal are final. */
85
+ terminal: boolean;
86
+ job_id: string | null;
87
+ pid: number | null;
88
+ resource: string | null;
89
+ label: string | null;
90
+ argv: string[] | null;
91
+ exit_code: number | null;
92
+ signal: string | null;
93
+ created_at: string | null;
94
+ started_at: string | null;
95
+ updated_at: string | null;
96
+ /** now minus status.updated_at; null without a parseable heartbeat. */
97
+ heartbeat_age_ms: number | null;
98
+ queue?: { resource: string; waiting_since: string };
99
+ job_dir: string;
100
+ log_path: string;
101
+ warnings: string[];
102
+ }
103
+
104
+ export interface DurableJobDeps {
105
+ /** PID liveness probe (injectable for tests). Default: process.kill(pid, 0). */
106
+ pidAlive?: (pid: number) => boolean;
107
+ /** Evaluation instant in epoch milliseconds. Default: Date.now(). */
108
+ now?: () => number;
109
+ }
110
+
111
+ export type DurableJobClassification =
112
+ | { ok: true; report: DurableJobReport }
113
+ | { ok: false; error: string };
114
+
115
+ export interface DurableJobListEntry {
116
+ job_id: string;
117
+ job_dir: string;
118
+ created_at: string | null;
119
+ document: DurableJobDocument | null;
120
+ report: DurableJobReport | null;
121
+ /** Set when the directory exists but its status document is unusable. */
122
+ error?: string;
123
+ }
124
+
125
+ function isRecord(value: unknown): value is Record<string, unknown> {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value);
127
+ }
128
+
129
+ /** Default PID liveness probe. EPERM counts as alive; only ESRCH is dead. */
130
+ function defaultPidAlive(pid: number): boolean {
131
+ try {
132
+ process.kill(pid, 0);
133
+ return true;
134
+ } catch (err: unknown) {
135
+ return (err as NodeJS.ErrnoException).code !== "ESRCH";
136
+ }
137
+ }
138
+
139
+ /** Directory holding every job record under a base directory. */
140
+ export function jobsRoot(base: string): string {
141
+ return join(resolve(base), JOBS_DIRNAME);
142
+ }
143
+
144
+ /** Directory of one job record. Does not create it. */
145
+ export function jobDirFor(base: string, jobId: string): string {
146
+ return join(jobsRoot(base), jobId);
147
+ }
148
+
149
+ /** Create (idempotently) the directory for one job and return its path. */
150
+ export function createJobDir(base: string, jobId: string): string {
151
+ const dir = jobDirFor(base, jobId);
152
+ mkdirSync(dir, { recursive: true });
153
+ return dir;
154
+ }
155
+
156
+ export function jobLogPath(jobDir: string): string {
157
+ return join(resolve(jobDir), DURABLE_JOB_LOG_FILENAME);
158
+ }
159
+
160
+ function writeAtomic(path: string, body: string): void {
161
+ const tmp = `${path}.${randomUUID().slice(0, 8)}.tmp`;
162
+ writeFileSync(tmp, body);
163
+ renameSync(tmp, path);
164
+ }
165
+
166
+ /** Write the immutable job description. */
167
+ export function writeJobDocument(jobDir: string, document: DurableJobDocument): void {
168
+ writeAtomic(
169
+ join(resolve(jobDir), DURABLE_JOB_DOCUMENT_FILENAME),
170
+ `${JSON.stringify(document, null, 2)}\n`,
171
+ );
172
+ }
173
+
174
+ /** Replace the status document atomically, so a reader never sees a torn file. */
175
+ export function writeJobStatus(jobDir: string, status: DurableJobStatus): void {
176
+ writeAtomic(
177
+ join(resolve(jobDir), DURABLE_JOB_STATUS_FILENAME),
178
+ `${JSON.stringify(status, null, 2)}\n`,
179
+ );
180
+ }
181
+
182
+ function readJson(path: string): unknown {
183
+ return JSON.parse(readFileSync(path, "utf8"));
184
+ }
185
+
186
+ /** Read the job description, or null when it is missing or unusable. */
187
+ export function readJobDocument(jobDir: string): DurableJobDocument | null {
188
+ const path = join(resolve(jobDir), DURABLE_JOB_DOCUMENT_FILENAME);
189
+ let parsed: unknown;
190
+ try {
191
+ parsed = readJson(path);
192
+ } catch {
193
+ return null;
194
+ }
195
+ if (!isRecord(parsed)) return null;
196
+ if (typeof parsed.job_id !== "string") return null;
197
+ return {
198
+ schema_version:
199
+ typeof parsed.schema_version === "number"
200
+ ? parsed.schema_version
201
+ : DURABLE_JOB_SCHEMA_VERSION,
202
+ job_id: parsed.job_id,
203
+ resource: typeof parsed.resource === "string" ? parsed.resource : "",
204
+ capacity: typeof parsed.capacity === "number" ? parsed.capacity : 1,
205
+ label: typeof parsed.label === "string" ? parsed.label : "",
206
+ argv: Array.isArray(parsed.argv)
207
+ ? parsed.argv.filter((a): a is string => typeof a === "string")
208
+ : [],
209
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : "",
210
+ created_at: typeof parsed.created_at === "string" ? parsed.created_at : "",
211
+ };
212
+ }
213
+
214
+ /** Read the status document, or null when it is missing or unusable. */
215
+ export function readJobStatus(jobDir: string): DurableJobStatus | null {
216
+ const path = join(resolve(jobDir), DURABLE_JOB_STATUS_FILENAME);
217
+ let parsed: unknown;
218
+ try {
219
+ parsed = readJson(path);
220
+ } catch {
221
+ return null;
222
+ }
223
+ if (!isRecord(parsed)) return null;
224
+ if (typeof parsed.job_id !== "string") return null;
225
+ if (typeof parsed.state !== "string" || !WRITTEN_STATES.has(parsed.state)) return null;
226
+ const queue = parsed.queue;
227
+ return {
228
+ schema_version:
229
+ typeof parsed.schema_version === "number"
230
+ ? parsed.schema_version
231
+ : DURABLE_JOB_SCHEMA_VERSION,
232
+ job_id: parsed.job_id,
233
+ pid: typeof parsed.pid === "number" ? parsed.pid : 0,
234
+ state: parsed.state as DurableJobState,
235
+ started_at: typeof parsed.started_at === "string" ? parsed.started_at : "",
236
+ updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : "",
237
+ ...(isRecord(queue) &&
238
+ typeof queue.resource === "string" &&
239
+ typeof queue.waiting_since === "string"
240
+ ? { queue: { resource: queue.resource, waiting_since: queue.waiting_since } }
241
+ : {}),
242
+ ...(typeof parsed.exit_code === "number" || parsed.exit_code === null
243
+ ? { exit_code: parsed.exit_code as number | null }
244
+ : {}),
245
+ ...(typeof parsed.signal === "string" || parsed.signal === null
246
+ ? { signal: parsed.signal as string | null }
247
+ : {}),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Classify one job directory, fail-closed. "completed" is terminal and carries
253
+ * the wrapped command's exit code. Any other written state is trusted only
254
+ * while the supervisor PID is alive; otherwise the job is "dead" — the record
255
+ * outlived the process that owned it.
256
+ */
257
+ export function classifyJob(jobDir: string, deps: DurableJobDeps = {}): DurableJobClassification {
258
+ const isAlive = deps.pidAlive ?? defaultPidAlive;
259
+ const nowMs = deps.now ? deps.now() : Date.now();
260
+ const abs = resolve(jobDir);
261
+ if (!existsSync(abs)) return { ok: false, error: `no such job directory: ${abs}` };
262
+
263
+ const statusPath = join(abs, DURABLE_JOB_STATUS_FILENAME);
264
+ if (!existsSync(statusPath)) {
265
+ return {
266
+ ok: false,
267
+ error: `${abs} carries no ${DURABLE_JOB_STATUS_FILENAME}: not a job directory`,
268
+ };
269
+ }
270
+ const status = readJobStatus(abs);
271
+ if (!status) {
272
+ return { ok: false, error: `${statusPath} is not a readable job status document` };
273
+ }
274
+ const document = readJobDocument(abs);
275
+
276
+ let heartbeatAgeMs: number | null = null;
277
+ const updatedMs = Date.parse(status.updated_at);
278
+ if (!Number.isNaN(updatedMs)) heartbeatAgeMs = Math.max(0, nowMs - updatedMs);
279
+
280
+ const base = {
281
+ job_id: status.job_id,
282
+ pid: status.pid > 0 ? status.pid : null,
283
+ resource: document?.resource ?? null,
284
+ label: document?.label ?? null,
285
+ argv: document?.argv ?? null,
286
+ created_at: document?.created_at ?? null,
287
+ started_at: status.started_at || null,
288
+ updated_at: status.updated_at || null,
289
+ heartbeat_age_ms: heartbeatAgeMs,
290
+ job_dir: abs,
291
+ log_path: jobLogPath(abs),
292
+ };
293
+
294
+ if (status.state === "completed") {
295
+ return {
296
+ ok: true,
297
+ report: {
298
+ ...base,
299
+ state: "completed",
300
+ terminal: true,
301
+ exit_code: status.exit_code ?? null,
302
+ signal: status.signal ?? null,
303
+ warnings: [],
304
+ },
305
+ };
306
+ }
307
+
308
+ if (!(status.pid > 0 && isAlive(status.pid))) {
309
+ return {
310
+ ok: true,
311
+ report: {
312
+ ...base,
313
+ state: "dead",
314
+ terminal: false,
315
+ exit_code: null,
316
+ signal: null,
317
+ ...(status.queue ? { queue: status.queue } : {}),
318
+ warnings: [],
319
+ },
320
+ };
321
+ }
322
+
323
+ const warnings: string[] = [];
324
+ if (
325
+ status.state === "running" &&
326
+ heartbeatAgeMs !== null &&
327
+ heartbeatAgeMs > DURABLE_JOB_HEARTBEAT_STALE_MS
328
+ ) {
329
+ warnings.push(
330
+ `heartbeat stale: last update ${Math.round(heartbeatAgeMs / 1000)}s ago ` +
331
+ `(threshold ${DURABLE_JOB_HEARTBEAT_STALE_MS / 1000}s), but pid ${status.pid} is alive`,
332
+ );
333
+ }
334
+ return {
335
+ ok: true,
336
+ report: {
337
+ ...base,
338
+ state: status.state,
339
+ terminal: false,
340
+ exit_code: null,
341
+ signal: null,
342
+ ...(status.queue ? { queue: status.queue } : {}),
343
+ warnings,
344
+ },
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Every job record under a base directory, newest first. Ordering keys on the
350
+ * description's created_at, falling back to the status document's started_at,
351
+ * then to the directory name, so a record missing its description still lists.
352
+ */
353
+ export function listJobs(base: string, deps: DurableJobDeps = {}): DurableJobListEntry[] {
354
+ const root = jobsRoot(base);
355
+ let names: string[];
356
+ try {
357
+ names = readdirSync(root, { withFileTypes: true })
358
+ .filter((entry) => entry.isDirectory())
359
+ .map((entry) => entry.name);
360
+ } catch {
361
+ return [];
362
+ }
363
+ const entries: { entry: DurableJobListEntry; sortKey: number }[] = [];
364
+ for (const name of names) {
365
+ const dir = join(root, name);
366
+ const document = readJobDocument(dir);
367
+ const classified = classifyJob(dir, deps);
368
+ const createdAt =
369
+ document?.created_at || (classified.ok ? classified.report.started_at : null) || null;
370
+ const parsed = createdAt !== null ? Date.parse(createdAt) : Number.NaN;
371
+ entries.push({
372
+ entry: {
373
+ job_id: document?.job_id ?? name,
374
+ job_dir: dir,
375
+ created_at: createdAt,
376
+ document,
377
+ report: classified.ok ? classified.report : null,
378
+ ...(classified.ok ? {} : { error: classified.error }),
379
+ },
380
+ sortKey: Number.isNaN(parsed) ? 0 : parsed,
381
+ });
382
+ }
383
+ entries.sort((a, b) => {
384
+ if (b.sortKey !== a.sortKey) return b.sortKey - a.sortKey;
385
+ return b.entry.job_id.localeCompare(a.entry.job_id);
386
+ });
387
+ return entries.map((item) => item.entry);
388
+ }
389
+
390
+ /** Describe a job's outcome as a process exit code: the wrapped command's own
391
+ * code, 1 when it died on a signal, 4 when the record is dead. */
392
+ export function jobExitCode(report: DurableJobReport): number {
393
+ if (report.state === "dead") return 4;
394
+ if (!report.terminal) return 5;
395
+ if (report.signal !== null) return 1;
396
+ return report.exit_code ?? 1;
397
+ }
398
+
399
+ /** Format a duration the way the status commands do. */
400
+ export function formatJobAge(ms: number): string {
401
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
402
+ if (totalSeconds < 60) return `${totalSeconds}s`;
403
+ const minutes = Math.floor(totalSeconds / 60);
404
+ if (minutes < 60) return `${minutes}m ${totalSeconds % 60}s`;
405
+ const hours = Math.floor(minutes / 60);
406
+ return `${hours}h ${minutes % 60}m`;
407
+ }
@@ -1,4 +1,9 @@
1
- import type { Cookie, CookieJar } from "../cookies/index.ts";
1
+ import {
2
+ applyExtraCookies,
3
+ type Cookie,
4
+ type CookieJar,
5
+ type ExtraCookies,
6
+ } from "../cookies/index.ts";
2
7
 
3
8
  /**
4
9
  * Simple cookie-aware HTTP client.
@@ -39,6 +44,12 @@ export interface FetchOptions<TResponseType extends FetchResponseType = "text">
39
44
  * only land when the key isn't already set.
40
45
  */
41
46
  extraHeaders?: (url: string) => Record<string, string>;
47
+ /**
48
+ * Optional callback that returns extra cookies to persist into `jar`
49
+ * before the Cookie header is built. Ignored when no jar is passed
50
+ * (`--no-cookies`). Must stay synchronous.
51
+ */
52
+ extraCookies?: ExtraCookies;
42
53
  /** Response body representation. Default `text`; use `bytes` for lossless file output. */
43
54
  responseType?: TResponseType;
44
55
  }
@@ -72,6 +83,7 @@ export async function fetchWithJar<TResponseType extends FetchResponseType = "te
72
83
  const headers: Record<string, string> = { ...(opts.headers ?? {}) };
73
84
 
74
85
  if (opts.jar) {
86
+ applyExtraCookies(url, opts.jar, opts.extraCookies);
75
87
  const cookieHeader = opts.jar.header(url);
76
88
  if (cookieHeader && !headers.Cookie && !headers.cookie) {
77
89
  headers.Cookie = cookieHeader;
@@ -282,10 +282,13 @@ auto-route into contribute.
282
282
  ## Create (\`create <objective>\`)
283
283
 
284
284
  The web UI is the member + steward picker; don't create from the CLI (that skips
285
- the steward choice). Emit the link with the objective URL-encoded:
285
+ the steward choice). Read the dashboard base URL from the active Harnery web
286
+ status or start output, which resolves the project's configured \`web.port\`.
287
+ Append the URL-encoded objective to this path; never assume a port or reuse a
288
+ dashboard URL from another project:
286
289
 
287
290
  \`\`\`
288
- http://localhost:4276/councils/new?objective=<encoded>
291
+ <configured-dashboard-base-url>/councils/new?objective=<encoded>
289
292
  \`\`\`
290
293
 
291
294
  If the dev server isn't up, start it with \`${b} web up\`.