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,347 @@
1
+ // Machine-wide admission control for heavy jobs (browser QA matrices,
2
+ // production builds): a crash-safe FIFO slot queue with no daemon and no
3
+ // distributed-lock pretensions. State is plain files on one machine.
4
+ //
5
+ // Layout, per resource under a caller-supplied base directory:
6
+ //
7
+ // <dir>/<resource>/tickets/<epoch_ms-13pad>-<pid>-<rand>.json waiters
8
+ // <dir>/<resource>/held/<same filename>.json holders
9
+ //
10
+ // Lexical filename order is enqueue order in BOTH directories. Admission is
11
+ // an atomic rename tickets/ → held/. A waiter may rename only when the live
12
+ // held count is under capacity AND its ticket is among the first
13
+ // (capacity − heldCount) live tickets. After renaming it re-lists held/: if
14
+ // live holders exceed capacity and its file is not among the capacity
15
+ // lexically-oldest, it renames itself back and rejoins the queue — the
16
+ // transient over-admission race self-corrects. Any participant prunes entries
17
+ // whose PID is dead or whose age exceeds the TTL. Release unlinks the held
18
+ // file; process death leaves a dead-PID file for the next participant to
19
+ // prune.
20
+ //
21
+ // Toolkit tier: this module must not import src/core (layering check).
22
+
23
+ import { randomUUID } from "node:crypto";
24
+ import {
25
+ mkdirSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ renameSync,
29
+ unlinkSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+ import { coordEnv } from "./env.ts";
35
+
36
+ export interface AdmissionConfig {
37
+ /** Base directory holding one subdirectory per resource. */
38
+ dir: string;
39
+ /** Resource name, e.g. "browser-qa" or "build". */
40
+ resource: string;
41
+ /** Maximum concurrent holders. */
42
+ capacity: number;
43
+ /** Poll interval while waiting (default 500ms). */
44
+ pollMs?: number;
45
+ /** Entries older than this are pruned regardless of PID (default 6h). */
46
+ ttlMs?: number;
47
+ }
48
+
49
+ export interface AdmissionEntry {
50
+ pid: number;
51
+ label: string;
52
+ created_at: string;
53
+ /** Set on holders once admitted. */
54
+ acquired_at?: string;
55
+ }
56
+
57
+ export interface AdmissionWaitInfo {
58
+ /** 1-based position in the ticket queue. */
59
+ position: number;
60
+ holders: AdmissionEntry[];
61
+ }
62
+
63
+ export interface AdmissionAcquireOptions {
64
+ /** Human-readable holder description shown in status listings. */
65
+ label: string;
66
+ /** Maximum wait before AdmissionTimeoutError (default 20 minutes). */
67
+ timeoutMs?: number;
68
+ /** Progress callback, invoked at most once per poll while waiting. */
69
+ onWait?: (info: AdmissionWaitInfo) => void;
70
+ }
71
+
72
+ export interface AdmissionHandle {
73
+ /** The held entry's filename (diagnostics). */
74
+ entry: string;
75
+ /** Milliseconds spent waiting in the queue. */
76
+ waitedMs: number;
77
+ /** Give the slot back. Safe to call more than once. */
78
+ release: () => void;
79
+ }
80
+
81
+ export class AdmissionTimeoutError extends Error {
82
+ readonly holders: AdmissionEntry[];
83
+ constructor(resource: string, timeoutMs: number, holders: AdmissionEntry[]) {
84
+ const labels = holders.map((holder) => `${holder.label} (pid ${holder.pid})`).join(", ");
85
+ const seconds =
86
+ timeoutMs < 10_000 ? (timeoutMs / 1000).toFixed(1) : String(Math.round(timeoutMs / 1000));
87
+ super(
88
+ `no ${resource} slot became free within ${seconds}s; ` +
89
+ `current holder(s): ${labels || "none (queue contention)"}`,
90
+ );
91
+ this.name = "AdmissionTimeoutError";
92
+ this.holders = holders;
93
+ }
94
+ }
95
+
96
+ const DEFAULT_POLL_MS = 500;
97
+ const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000;
98
+ const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000;
99
+ /** An unparseable entry younger than this is left alone (it may be mid-write
100
+ * by a non-atomic writer); older, it is torn garbage and gets pruned. */
101
+ const UNPARSEABLE_GRACE_MS = 60 * 1000;
102
+
103
+ /** Is a PID alive on this machine? EPERM counts as alive; only ESRCH counts
104
+ * as dead (fail toward respecting holders rather than stealing slots). */
105
+ export function pidAlive(pid: number): boolean {
106
+ try {
107
+ process.kill(pid, 0);
108
+ return true;
109
+ } catch (err: unknown) {
110
+ // EPERM = exists but not ours = alive. ESRCH (and anything else) = treat
111
+ // as dead only on ESRCH; unknown errors count as alive (fail toward
112
+ // respecting holders rather than stealing their slot).
113
+ const code = (err as NodeJS.ErrnoException).code;
114
+ if (code === "ESRCH") return false;
115
+ return true;
116
+ }
117
+ }
118
+
119
+ function ticketsDir(config: AdmissionConfig): string {
120
+ return join(config.dir, config.resource, "tickets");
121
+ }
122
+
123
+ function heldDir(config: AdmissionConfig): string {
124
+ return join(config.dir, config.resource, "held");
125
+ }
126
+
127
+ function ensureDirs(config: AdmissionConfig): void {
128
+ mkdirSync(ticketsDir(config), { recursive: true });
129
+ mkdirSync(heldDir(config), { recursive: true });
130
+ }
131
+
132
+ function writeEntry(dir: string, name: string, entry: AdmissionEntry): void {
133
+ const tmp = join(dir, `.${name}.${randomUUID().slice(0, 8)}.tmp`);
134
+ writeFileSync(tmp, `${JSON.stringify(entry, null, 2)}\n`);
135
+ renameSync(tmp, join(dir, name));
136
+ }
137
+
138
+ interface LiveEntry {
139
+ name: string;
140
+ entry: AdmissionEntry;
141
+ }
142
+
143
+ /** List live entries in lexical (= enqueue) order, pruning dead-PID, expired,
144
+ * and torn files as a side effect. Every participant prunes, so a crashed
145
+ * holder's slot frees as soon as anyone else looks. */
146
+ function listLive(dir: string, ttlMs: number): LiveEntry[] {
147
+ let names: string[];
148
+ try {
149
+ names = readdirSync(dir)
150
+ .filter((name) => name.endsWith(".json"))
151
+ .sort();
152
+ } catch {
153
+ return [];
154
+ }
155
+ const now = Date.now();
156
+ const live: LiveEntry[] = [];
157
+ for (const name of names) {
158
+ const path = join(dir, name);
159
+ let entry: AdmissionEntry | undefined;
160
+ try {
161
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
162
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
163
+ const record = parsed as Record<string, unknown>;
164
+ if (typeof record.pid === "number" && typeof record.created_at === "string") {
165
+ entry = {
166
+ pid: record.pid,
167
+ label: typeof record.label === "string" ? record.label : "",
168
+ created_at: record.created_at,
169
+ ...(typeof record.acquired_at === "string" ? { acquired_at: record.acquired_at } : {}),
170
+ };
171
+ }
172
+ }
173
+ } catch {
174
+ // fall through: unparseable
175
+ }
176
+ if (!entry) {
177
+ // Torn or foreign file: prune once it is clearly not mid-write. The
178
+ // enqueue timestamp is the filename prefix, so age is known without
179
+ // content.
180
+ const stamp = Number.parseInt(name.slice(0, 13), 10);
181
+ if (!Number.isFinite(stamp) || now - stamp > UNPARSEABLE_GRACE_MS) {
182
+ try {
183
+ unlinkSync(path);
184
+ } catch {
185
+ // a peer pruned it first
186
+ }
187
+ }
188
+ continue;
189
+ }
190
+ const age = now - Date.parse(entry.created_at);
191
+ if (!pidAlive(entry.pid) || Number.isNaN(age) || age > ttlMs) {
192
+ try {
193
+ unlinkSync(path);
194
+ } catch {
195
+ // a peer pruned it first
196
+ }
197
+ continue;
198
+ }
199
+ live.push({ name, entry });
200
+ }
201
+ return live;
202
+ }
203
+
204
+ function sleep(ms: number): Promise<void> {
205
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
206
+ }
207
+
208
+ /**
209
+ * Join the FIFO queue for one resource and resolve once a slot is held.
210
+ * Throws AdmissionTimeoutError (with the holders snapshot) when no slot
211
+ * frees within the timeout — the caller decides whether that is fatal.
212
+ */
213
+ export async function acquireAdmission(
214
+ config: AdmissionConfig,
215
+ options: AdmissionAcquireOptions,
216
+ ): Promise<AdmissionHandle> {
217
+ if (!Number.isInteger(config.capacity) || config.capacity < 1) {
218
+ throw new Error(`admission capacity must be a positive integer, got ${config.capacity}`);
219
+ }
220
+ ensureDirs(config);
221
+ const pollMs = config.pollMs ?? DEFAULT_POLL_MS;
222
+ const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
223
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
224
+ const tickets = ticketsDir(config);
225
+ const held = heldDir(config);
226
+
227
+ const myEntry: AdmissionEntry = {
228
+ pid: process.pid,
229
+ label: options.label,
230
+ created_at: new Date().toISOString(),
231
+ };
232
+ const myName = `${String(Date.now()).padStart(13, "0")}-${process.pid}-${randomUUID().slice(0, 8)}.json`;
233
+ writeEntry(tickets, myName, myEntry);
234
+
235
+ const startedWaiting = Date.now();
236
+ const deadline = startedWaiting + timeoutMs;
237
+ try {
238
+ while (true) {
239
+ const liveHeld = listLive(held, ttlMs);
240
+ const liveTickets = listLive(tickets, ttlMs);
241
+ const myIndex = liveTickets.findIndex((ticket) => ticket.name === myName);
242
+ if (myIndex === -1) {
243
+ // Pruned by a peer (should not happen while we are alive) or lost to
244
+ // a race; re-enqueue at the back rather than failing the caller.
245
+ writeEntry(tickets, myName, myEntry);
246
+ await sleep(pollMs);
247
+ continue;
248
+ }
249
+ const free = config.capacity - liveHeld.length;
250
+ if (free > 0 && myIndex < free) {
251
+ renameSync(join(tickets, myName), join(held, myName));
252
+ // Verify: the rename can transiently over-admit when a slot freed
253
+ // between two waiters' listings. Lexical order (= enqueue order)
254
+ // decides who stays; the loser rejoins the queue with its original
255
+ // priority because the filename keeps its enqueue timestamp.
256
+ const heldNow = listLive(held, ttlMs);
257
+ if (heldNow.length > config.capacity) {
258
+ const keepers = new Set(
259
+ heldNow.slice(0, config.capacity).map((holderEntry) => holderEntry.name),
260
+ );
261
+ if (!keepers.has(myName)) {
262
+ renameSync(join(held, myName), join(tickets, myName));
263
+ await sleep(pollMs);
264
+ continue;
265
+ }
266
+ }
267
+ writeEntry(held, myName, { ...myEntry, acquired_at: new Date().toISOString() });
268
+ let released = false;
269
+ return {
270
+ entry: myName,
271
+ waitedMs: Date.now() - startedWaiting,
272
+ release: () => {
273
+ if (released) return;
274
+ released = true;
275
+ try {
276
+ unlinkSync(join(held, myName));
277
+ } catch {
278
+ // already pruned
279
+ }
280
+ },
281
+ };
282
+ }
283
+ options.onWait?.({
284
+ position: myIndex + 1,
285
+ holders: liveHeld.map((holderEntry) => holderEntry.entry),
286
+ });
287
+ if (Date.now() >= deadline) {
288
+ throw new AdmissionTimeoutError(
289
+ config.resource,
290
+ timeoutMs,
291
+ liveHeld.map((holderEntry) => holderEntry.entry),
292
+ );
293
+ }
294
+ await sleep(pollMs);
295
+ }
296
+ } finally {
297
+ // On any non-admitted exit (timeout, caller abort via throw), leave no
298
+ // ticket behind. A successful acquire has already moved the file out.
299
+ try {
300
+ unlinkSync(join(tickets, myName));
301
+ } catch {
302
+ // admitted (file moved) or already pruned
303
+ }
304
+ }
305
+ }
306
+
307
+ export interface AdmissionStatus {
308
+ resource: string;
309
+ capacity?: number;
310
+ holders: AdmissionEntry[];
311
+ waiters: AdmissionEntry[];
312
+ }
313
+
314
+ /** Snapshot the queue for one resource, pruning dead entries on the way. */
315
+ export function admissionStatus(
316
+ config: Pick<AdmissionConfig, "dir" | "resource" | "ttlMs">,
317
+ ): AdmissionStatus {
318
+ const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
319
+ const full: AdmissionConfig = { ...config, capacity: 1 };
320
+ return {
321
+ resource: config.resource,
322
+ holders: listLive(heldDir(full), ttlMs).map((holderEntry) => holderEntry.entry),
323
+ waiters: listLive(ticketsDir(full), ttlMs).map((ticket) => ticket.entry),
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Base directory for admission queue state. tmpdir clears on reboot, which
329
+ * is correct — stale queue state must not outlive the boot. Override with
330
+ * HARNERY_ADMISSION_DIR. Host CLIs and harn commands must share this helper
331
+ * so they join the same machine-wide queues.
332
+ */
333
+ export function admissionBaseDir(): string {
334
+ return coordEnv("ADMISSION_DIR") ?? join(tmpdir(), "harnery-admission");
335
+ }
336
+
337
+ /** Resources present under an admission base directory. */
338
+ export function listAdmissionResources(dir: string): string[] {
339
+ try {
340
+ return readdirSync(dir, { withFileTypes: true })
341
+ .filter((entry) => entry.isDirectory())
342
+ .map((entry) => entry.name)
343
+ .sort();
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
@@ -1,11 +1,6 @@
1
1
  import { type SpawnSyncOptions, spawnSync } from "node:child_process";
2
2
  import { writeFileSync } from "node:fs";
3
- import {
4
- type CookieJar,
5
- type CookieStore,
6
- type Cookie as JarCookie,
7
- mergeCookies,
8
- } from "../cookies/index.ts";
3
+ import type { CookieJar, Cookie as JarCookie } from "../cookies/index.ts";
9
4
 
10
5
  /**
11
6
  * Thin wrapper over Vercel Labs' `agent-browser` Rust CLI.
@@ -216,10 +211,7 @@ export class AgentBrowser {
216
211
  const parsed = JSON.parse(result.stdout);
217
212
  const cookies: JarCookie[] = parsed.data?.cookies ?? parsed.cookies ?? [];
218
213
  if (!Array.isArray(cookies) || cookies.length === 0) return { saved: 0 };
219
- const jar = this.opts.jar;
220
- const store: CookieStore = jar.load();
221
- const merged = mergeCookies(store, cookies);
222
- jar.save(merged);
214
+ this.opts.jar.merge(cookies);
223
215
  return { saved: cookies.length };
224
216
  } catch {
225
217
  return { saved: 0 };
@@ -0,0 +1,98 @@
1
+ // Capture-fidelity probe for page review packs (pure: no browser, no src/core).
2
+ //
3
+ // A full-page screenshot of a very tall page can carry raster artifacts (a
4
+ // blank block, a callout clipped mid-sentence) that a viewport render of the
5
+ // same region does not show. While the capture browser is still open, the
6
+ // capture branch re-shoots every band by scroll-and-clip and pixel compares
7
+ // each with the crop the tiler took from the full-page image. When any band
8
+ // disagrees, the tiles are re-cut from those scrolled captures and the
9
+ // context records `capture_fidelity.source = "scrolled-bands"`.
10
+ //
11
+ // This module owns the parts that need no browser: the comparison, the
12
+ // decision, and the row stitch that joins viewport-height pieces into one
13
+ // band capture. `client.ts` owns the scroll-and-clip itself.
14
+
15
+ import { PNG } from "pngjs";
16
+ import type { PageReviewCaptureFidelity } from "./page-review-pack.js";
17
+ import { DEFAULT_REUSE_MISMATCH_RATIO, rectMismatch } from "./qa-reuse.js";
18
+
19
+ /** The band-diff reuse threshold doubles as the fidelity threshold: a probe
20
+ * that would count as "unchanged" for reuse counts as "agrees" here. */
21
+ export const CAPTURE_FIDELITY_MISMATCH_THRESHOLD = DEFAULT_REUSE_MISMATCH_RATIO;
22
+
23
+ export type FidelityProbe = PageReviewCaptureFidelity["probed"][number];
24
+
25
+ /**
26
+ * Pixel-compare a band cropped from the full-page screenshot with the same
27
+ * band captured by scrolling. `mismatch_ratio` is measured over the area the
28
+ * two images share (pixelmatch, anti-aliasing tolerant, the same math as
29
+ * band-diff reuse). `size_mismatch` is set when the two differ by more than a
30
+ * rounding pixel in either dimension; the caller treats that probe as
31
+ * inconclusive rather than as proof the full page is wrong, because a page
32
+ * wider than the viewport cannot be rendered whole by scrolling.
33
+ */
34
+ export function compareBand(
35
+ fullPageCrop: PNG,
36
+ scrolledCapture: PNG,
37
+ threshold: number = CAPTURE_FIDELITY_MISMATCH_THRESHOLD,
38
+ ): { mismatch_ratio: number; exceeds: boolean; size_mismatch: boolean } {
39
+ const width = Math.min(fullPageCrop.width, scrolledCapture.width);
40
+ const height = Math.min(fullPageCrop.height, scrolledCapture.height);
41
+ const size_mismatch =
42
+ Math.abs(fullPageCrop.width - scrolledCapture.width) > 1 ||
43
+ Math.abs(fullPageCrop.height - scrolledCapture.height) > 1;
44
+ if (width <= 0 || height <= 0) return { mismatch_ratio: 1, exceeds: true, size_mismatch: true };
45
+ const cmp = rectMismatch(fullPageCrop, scrolledCapture, { x: 0, y: 0, width, height });
46
+ return { mismatch_ratio: cmp.ratio, exceeds: cmp.ratio > threshold, size_mismatch };
47
+ }
48
+
49
+ /**
50
+ * Turn the probe results into the record the context carries. Any probe over
51
+ * the threshold means the full-page screenshot is not trustworthy for this
52
+ * context and the tiles must come from scrolled captures.
53
+ */
54
+ export function decideFidelity(
55
+ probes: readonly FidelityProbe[],
56
+ threshold: number = CAPTURE_FIDELITY_MISMATCH_THRESHOLD,
57
+ ): PageReviewCaptureFidelity {
58
+ const mismatched = probes
59
+ .filter((probe) => probe.mismatch_ratio > threshold)
60
+ .map((probe) => probe.tile_id);
61
+ return {
62
+ source: mismatched.length > 0 ? "scrolled-bands" : "full-page",
63
+ probed: probes.map((probe) => ({ ...probe })),
64
+ mismatched,
65
+ mismatch_threshold: threshold,
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Join PNG pieces top to bottom into one image. The output takes the first
71
+ * piece's width; a narrower piece leaves transparent pixels on its right, a
72
+ * wider one is cropped. Used to assemble a band taller than the viewport from
73
+ * viewport-height captures (see `Browser.captureRegionByScroll`).
74
+ */
75
+ export function stitchPngRows(pieces: readonly Buffer[]): Buffer {
76
+ const first = pieces[0];
77
+ if (!first) throw new Error("stitchPngRows: at least one piece is required");
78
+ if (pieces.length === 1) return first;
79
+ const decoded = pieces.map((piece) => PNG.sync.read(piece));
80
+ const width = (decoded[0] as PNG).width;
81
+ const height = decoded.reduce((sum, piece) => sum + piece.height, 0);
82
+ const out = new PNG({ width, height });
83
+ let row = 0;
84
+ for (const piece of decoded) {
85
+ const copyWidth = Math.min(width, piece.width);
86
+ for (let y = 0; y < piece.height; y++) {
87
+ const srcStart = y * piece.width * 4;
88
+ piece.data.copy(out.data, (row + y) * width * 4, srcStart, srcStart + copyWidth * 4);
89
+ }
90
+ row += piece.height;
91
+ }
92
+ return PNG.sync.write(out);
93
+ }
94
+
95
+ /** Width and height from a PNG header, without decoding the image. */
96
+ export function pngDimensions(buffer: Buffer): { width: number; height: number } {
97
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
98
+ }