@a2f0/agent-tool 0.1.40 → 0.1.42

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.40",
3
+ "version": "0.1.42",
4
4
  "packageManager": "bun@1.4.4",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -41,6 +41,63 @@ export function parseGroup(source: string): { label: string; steps: Step[] } {
41
41
  }
42
42
  return plan;
43
43
  }
44
+ /** One step of a run-group report: only a finished step has a receipt, and a failed one lacks it only when its runner could not start. */
45
+ export type GroupStep =
46
+ | { label: string; status: "pending" | "running"; receipt: null }
47
+ | { label: string; status: "passed" | "failed"; receipt: RunReceipt | null };
48
+
49
+ /**
50
+ * A run-group report as saved and printed. Field order is the report's JSON
51
+ * order. `endedAt` is set exactly when the group is no longer running; `after`
52
+ * stays null when the final source state could not be read.
53
+ */
54
+ export type GroupRecord = {
55
+ schemaVersion: 1; id: string; label: string; startedAt: string; endedAt: string | null; durationMs: number;
56
+ status: "running" | "passed" | "failed"; before: SourceState; after: SourceState | null; steps: GroupStep[]; error: string | null;
57
+ };
58
+
59
+ export type GroupEvent =
60
+ | { type: "stepStarted"; index: number }
61
+ | { type: "stepFinished"; index: number; receipt: RunReceipt }
62
+ | { type: "failed"; error: string }
63
+ | { type: "finished"; passed: boolean; endedAt: string; durationMs: number }
64
+ | { type: "settled"; after: SourceState }
65
+ | { type: "settleFailed"; error: string };
66
+
67
+ /**
68
+ * The only writer of a run-group report. Events outside their state are
69
+ * ignored: a step starts only from pending, finishes only while running, and
70
+ * nothing but the final source read changes a finished group. A failure while
71
+ * a step runs (its runner could not start) finishes that step as failed with
72
+ * no receipt, so a finished report never shows a step still running.
73
+ */
74
+ export function groupTransition(record: GroupRecord, event: GroupEvent): GroupRecord {
75
+ const step = (index: number, next: GroupStep) => ({ ...record, steps: record.steps.map((item, at) => at === index ? next : item) });
76
+ switch (event.type) {
77
+ case "stepStarted": {
78
+ const current = record.steps[event.index];
79
+ return record.status === "running" && current?.status === "pending" ? step(event.index, { label: current.label, status: "running", receipt: null }) : record;
80
+ }
81
+ case "stepFinished": {
82
+ const current = record.steps[event.index];
83
+ return record.status === "running" && current?.status === "running" ? step(event.index, { label: current.label, status: event.receipt.status === "passed" ? "passed" : "failed", receipt: event.receipt }) : record;
84
+ }
85
+ case "failed":
86
+ if (record.status !== "running") return record;
87
+ return { ...record, steps: record.steps.map(item => item.status === "running" ? { label: item.label, status: "failed", receipt: null } : item), error: event.error };
88
+ case "finished":
89
+ return record.status === "running" ? { ...record, status: event.passed ? "passed" : "failed", endedAt: event.endedAt, durationMs: event.durationMs } : record;
90
+ case "settled":
91
+ return record.status !== "running" && record.after === null ? { ...record, after: event.after } : record;
92
+ case "settleFailed":
93
+ return record.status !== "running" && record.after === null ? { ...record, status: "failed", error: record.error ?? event.error } : record;
94
+ default:
95
+ return assertNever(event);
96
+ }
97
+ }
98
+
99
+ function assertNever(value: never): never { throw new Error(`Unhandled run-group event: ${JSON.stringify(value)}`); }
100
+
44
101
  export async function runGroup(args: string[]): Promise<number> {
45
102
  assert(args.length === 1, "Usage: run-group <plan.json>");
46
103
  const plan = parseGroup(readFileSync(args[0]!, "utf8"));
@@ -49,21 +106,21 @@ export async function runGroup(args: string[]): Promise<number> {
49
106
  const id = `${new Date().toISOString().replaceAll(":", "-")}-${randomUUID()}`;
50
107
  const report = path.join(directory, `${id}.json`);
51
108
  const started = performance.now();
52
- const record = { schemaVersion: 1, id, label: plan.label, startedAt: new Date().toISOString(), endedAt: null as string | null, durationMs: 0, status: "running", before, after: null as SourceState | null, steps: plan.steps.map(step => ({ label: step.label, status: "pending", receipt: null as RunReceipt | null })), error: null as string | null };
109
+ let record: GroupRecord = { schemaVersion: 1, id, label: plan.label, startedAt: new Date().toISOString(), endedAt: null, durationMs: 0, status: "running", before, after: null, steps: plan.steps.map(step => ({ label: step.label, status: "pending", receipt: null })), error: null };
110
+ const send = (event: GroupEvent) => { record = groupTransition(record, event); };
53
111
  save(report, record);
54
112
  let code = 0;
55
113
  try {
56
114
  for (const [index, step] of plan.steps.entries()) {
57
- record.steps[index]!.status = "running"; save(report, record);
58
- code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { record.steps[index]!.receipt = receipt; record.steps[index]!.status = receipt.status; save(report, record); }, { quiet: true, reraiseChildSignal: false });
115
+ send({ type: "stepStarted", index }); save(report, record);
116
+ code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { send({ type: "stepFinished", index, receipt }); save(report, record); }, { quiet: true, reraiseChildSignal: false });
59
117
  const after = sourceState();
60
118
  assert(JSON.stringify(after) === JSON.stringify(before), "Source state changed during validation; results do not establish the current checkout.");
61
119
  if (code !== 0) break;
62
120
  }
63
- } catch (error) { code = 1; record.error = (error as Error).message; }
64
- record.status = code === 0 ? "passed" : "failed";
65
- record.endedAt = new Date().toISOString(); record.durationMs = Math.round(performance.now() - started);
66
- try { record.after = sourceState(); } catch (error) { code = 1; record.status = "failed"; record.error ??= (error as Error).message; }
121
+ } catch (error) { code = 1; send({ type: "failed", error: (error as Error).message }); }
122
+ send({ type: "finished", passed: code === 0, endedAt: new Date().toISOString(), durationMs: Math.round(performance.now() - started) });
123
+ try { send({ type: "settled", after: sourceState() }); } catch (error) { code = 1; send({ type: "settleFailed", error: (error as Error).message }); }
67
124
  save(report, record);
68
125
  output({ ...record, report }); return code;
69
126
  }
@@ -6,12 +6,19 @@ import { assert, flag, json, oid, option, output, poll, repository, timeoutOptio
6
6
 
7
7
  function packageName(name: unknown): asserts name is string { assert(typeof name === "string" && /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(name), "Invalid npm package name."); }
8
8
  export interface ReleaseRun { databaseId: number; status: string; conclusion: string; headSha: string; url: string }
9
- export async function verifyTarball(metadata: { name: string; version: string; dist: { tarball: string; integrity: string } }): Promise<{ tarball: string; integrity: string }> {
9
+ /**
10
+ * Downloads and checks the published tarball. Resolves null only when the
11
+ * registry answers 404 or 410: npm can list a version minutes before its
12
+ * tarball is served, so absence is a propagation delay. Any other failure,
13
+ * including a tarball that downloads but does not match, throws.
14
+ */
15
+ export async function verifyTarball(metadata: { name: string; version: string; dist: { tarball: string; integrity: string } }): Promise<{ tarball: string; integrity: string } | null> {
10
16
  const url = new URL(metadata.dist.tarball);
11
17
  assert(url.protocol === "https:" && url.hostname === "registry.npmjs.org", "Expected an HTTPS npm registry tarball.");
12
18
  const integrity = /^sha512-([A-Za-z0-9+/]+={0,2})$/.exec(metadata.dist.integrity);
13
19
  assert(integrity, "Package must have SHA-512 tarball integrity.");
14
20
  const response = await fetch(url, { signal: AbortSignal.timeout(60_000), redirect: "error" });
21
+ if (response.status === 404 || response.status === 410) { await response.body?.cancel(); return null; }
15
22
  assert(response.ok && response.body, "Could not download the published tarball.");
16
23
  const chunks: Uint8Array[] = []; let size = 0;
17
24
  for await (const chunk of response.body) { size += chunk.length; assert(size <= 100 * 1024 * 1024, "Tarball exceeds the 100 MiB verification limit."); chunks.push(chunk); }
@@ -39,6 +46,7 @@ export async function inspectRelease(
39
46
  assert(metadata.name === expected.name && metadata.version === expected.version, "Registry returned a different package version.");
40
47
  assert(!metadata.gitHead || metadata.gitHead === expected.commit, "Registry gitHead differs from the publishing merge commit.");
41
48
  const artifact = await checkArtifact(metadata);
49
+ if (!artifact) return { done: false, value: { ok: false, reason: "Tarball has not propagated to npm.", run } };
42
50
  return { done: true, value: { ok: true, reason: "Workflow and published tarball verified.", run, ...artifact, gitHead: metadata.gitHead ?? null } };
43
51
  }
44
52
 
@@ -125,6 +125,49 @@ export function typicalRatio(entries: readonly { width: number; height: number }
125
125
  return ratios.length % 2 ? ratios[middle]! : (ratios[middle - 1]! + ratios[middle]!) / 2;
126
126
  }
127
127
 
128
+ /**
129
+ * Where a gallery is in loading its manifest. Each load gets a new request
130
+ * number, and only the current request may finish: a manifest or failure that
131
+ * arrives for a replaced request, or after disconnecting, is ignored. A
132
+ * rendered gallery is `ready`; while it first renders (`rendering`) its initial
133
+ * selection leaves the URL alone. Loading, failed, and empty galleries ignore
134
+ * selections.
135
+ */
136
+ export type GalleryPhase =
137
+ | { status: "idle" }
138
+ | { status: "loading"; request: number }
139
+ | { status: "failed" }
140
+ | { status: "empty" }
141
+ | { status: "rendering" }
142
+ | { status: "ready" };
143
+
144
+ export type GalleryEvent =
145
+ | { type: "load"; request: number }
146
+ | { type: "loaded"; request: number; empty: boolean }
147
+ | { type: "fail"; request: number }
148
+ | { type: "rendered" }
149
+ | { type: "disconnect" };
150
+
151
+ /** The only writer of a gallery's phase; an event that does not apply returns the same phase. */
152
+ export function galleryTransition(phase: GalleryPhase, event: GalleryEvent): GalleryPhase {
153
+ switch (event.type) {
154
+ case "load":
155
+ return phase.status === "loading" && phase.request === event.request ? phase : { status: "loading", request: event.request };
156
+ case "loaded":
157
+ return phase.status === "loading" && phase.request === event.request ? { status: event.empty ? "empty" : "rendering" } : phase;
158
+ case "fail":
159
+ return phase.status === "loading" && phase.request === event.request ? { status: "failed" } : phase;
160
+ case "rendered":
161
+ return phase.status === "rendering" ? { status: "ready" } : phase;
162
+ case "disconnect":
163
+ return phase.status === "idle" ? phase : { status: "idle" };
164
+ default:
165
+ return assertNever(event);
166
+ }
167
+ }
168
+
169
+ function assertNever(value: never): never { throw new Error(`Unhandled gallery event: ${JSON.stringify(value)}`); }
170
+
128
171
  export interface ScreenshotChangeDetail { project: string; theme: string; screen: string; index: number; total: number }
129
172
 
130
173
  export interface ScreenshotGalleryElement extends HTMLElement {
@@ -241,7 +284,8 @@ function defineGallery(): CustomElementConstructor {
241
284
  #target: number | null = null;
242
285
  #expanded = false;
243
286
  #overflow = "";
244
- #rendered = false;
287
+ #phase: GalleryPhase = { status: "idle" };
288
+ #requests = 0;
245
289
  #announced = "";
246
290
  #wheel = createWheelStepper();
247
291
 
@@ -259,7 +303,7 @@ function defineGallery(): CustomElementConstructor {
259
303
 
260
304
  #connected = false;
261
305
  connectedCallback(): void { this.#connected = true; this.#load(); }
262
- disconnectedCallback(): void { this.#connected = false; this.#teardown(); }
306
+ disconnectedCallback(): void { this.#connected = false; this.#send({ type: "disconnect" }); this.#teardown(); }
263
307
  // An upgrade reports initial attributes before connecting; only later changes reload.
264
308
  attributeChangedCallback(_name: string, previous: string | null, next: string | null): void {
265
309
  if (previous !== next && this.#connected && !this.#data) this.#load();
@@ -272,22 +316,37 @@ function defineGallery(): CustomElementConstructor {
272
316
  this.#expanded = false;
273
317
  }
274
318
 
319
+ /** Applies `event`; false when it did not apply, so its work must not run. */
320
+ #send(event: GalleryEvent): boolean {
321
+ const next = galleryTransition(this.#phase, event);
322
+ if (next === this.#phase) return false;
323
+ this.#phase = next;
324
+ return true;
325
+ }
326
+
275
327
  #load(): void {
276
328
  this.#teardown();
277
- const abort = new AbortController();
278
- this.#abort = abort;
279
- this.#rendered = false;
329
+ this.#abort = new AbortController();
330
+ this.#send({ type: "load", request: ++this.#requests });
280
331
  this.classList.add("shots-host");
281
- if (this.#data) { this.#render(this.#data); return; }
332
+ // The work belongs to the request just entered, never to whichever load is merely in progress.
333
+ if (this.#phase.status === "loading") this.#fetch(this.#phase.request, this.#abort.signal);
334
+ }
335
+
336
+ #fetch(request: number, signal: AbortSignal): void {
337
+ if (this.#data) { this.#receive(request, this.#data); return; }
282
338
  const url = this.getAttribute("manifest");
283
339
  status(this, "loading", "Loading screenshots…");
284
- if (!url) { this.#fail(new Error("screenshot-gallery needs a manifest attribute.")); return; }
285
- loadManifest(url, abort.signal).then(manifest => { if (!abort.signal.aborted) this.#render(manifest); }, (error: unknown) => {
286
- if (!abort.signal.aborted) this.#fail(error);
287
- });
340
+ if (!url) { this.#fail(request, new Error("screenshot-gallery needs a manifest attribute.")); return; }
341
+ loadManifest(url, signal).then(manifest => this.#receive(request, manifest), (error: unknown) => this.#fail(request, error));
288
342
  }
289
343
 
290
- #fail(error: unknown): void {
344
+ #receive(request: number, manifest: ScreenshotManifest): void {
345
+ if (this.#send({ type: "loaded", request, empty: !manifest.entries.length })) this.#render(manifest);
346
+ }
347
+
348
+ #fail(request: number, error: unknown): void {
349
+ if (!this.#send({ type: "fail", request })) return;
291
350
  status(this, "error", this.getAttribute("error-message") ?? "Screenshots didn't load. Refresh to try again.");
292
351
  this.dispatchEvent(new CustomEvent("screenshot-error", { bubbles: true, detail: { error } }));
293
352
  }
@@ -296,7 +355,7 @@ function defineGallery(): CustomElementConstructor {
296
355
 
297
356
  #render(manifest: ScreenshotManifest): void {
298
357
  this.#manifest = manifest;
299
- if (!manifest.entries.length) { status(this, "empty", this.getAttribute("empty-message") ?? "Screenshots aren't available yet."); return; }
358
+ if (this.#phase.status === "empty") { status(this, "empty", this.getAttribute("empty-message") ?? "Screenshots aren't available yet."); return; }
300
359
  const deepLink = typeof location === "undefined" ? undefined : parseScreenshotPath(this.#basePath(), location.pathname);
301
360
  const requestedProject = this.getAttribute("initial-project") ?? deepLink?.project;
302
361
  const requestedScreen = this.getAttribute("initial-screen") ?? deepLink?.screen;
@@ -357,7 +416,7 @@ function defineGallery(): CustomElementConstructor {
357
416
  }
358
417
  window.addEventListener("keydown", (event: KeyboardEvent) => this.#key(event), { signal });
359
418
  this.#renderProject(screen);
360
- this.#rendered = true;
419
+ this.#send({ type: "rendered" });
361
420
  }
362
421
 
363
422
  #key(event: KeyboardEvent): void {
@@ -564,7 +623,7 @@ function defineGallery(): CustomElementConstructor {
564
623
  const key = `${this.#project} ${this.#theme} ${name}`;
565
624
  if (key === this.#announced) return;
566
625
  this.#announced = key;
567
- if (this.#rendered || !initial) this.#syncUrl(name);
626
+ if (this.#phase.status === "ready" || !initial) this.#syncUrl(name);
568
627
  this.dispatchEvent(new CustomEvent<ScreenshotChangeDetail>("screenshot-change", { bubbles: true, detail: { project: this.#project, theme: this.#theme, screen: name, index: this.#index, total: count } }));
569
628
  }
570
629
 
@@ -627,7 +686,7 @@ function defineGallery(): CustomElementConstructor {
627
686
 
628
687
  select(selection: { project?: string; theme?: string; screen?: string }): void {
629
688
  const manifest = this.#manifest;
630
- if (!manifest || !this.#root) return;
689
+ if (!this.#shows() || !manifest) return;
631
690
  const screen = selection.screen ?? this.screen;
632
691
  if (selection.project && selection.project !== this.#project && manifest.projects.some(project => project.name === selection.project)) {
633
692
  this.#project = selection.project;
@@ -646,7 +705,14 @@ function defineGallery(): CustomElementConstructor {
646
705
  if (selection.screen && this.#screens.includes(selection.screen)) this.#select(this.#screens.indexOf(selection.screen), scrollBehavior());
647
706
  }
648
707
 
649
- step(delta: number): void { this.#select(this.#index + delta, scrollBehavior()); }
708
+ step(delta: number): void { if (this.#shows()) this.#select(this.#index + delta, scrollBehavior()); }
709
+
710
+ /**
711
+ * Whether the fields describe what the gallery shows: loading, failed, and empty galleries keep the
712
+ * last render's fields. A first render shows once its DOM is built, so a listener to its initial
713
+ * `screenshot-change` can already select.
714
+ */
715
+ #shows(): boolean { return this.#phase.status === "ready" || this.#phase.status === "rendering"; }
650
716
  };
651
717
  }
652
718