@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 +1 -1
- package/src/commands/group.ts +64 -7
- package/src/release/commands.ts +9 -1
- package/src/screenshots/gallery.ts +82 -16
package/package.json
CHANGED
package/src/commands/group.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
58
|
-
code = await runCommand(["--label", step.label, "--", ...step.command], receipt => {
|
|
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;
|
|
64
|
-
|
|
65
|
-
|
|
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
|
}
|
package/src/release/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
278
|
-
this.#
|
|
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
|
-
|
|
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,
|
|
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
|
-
#
|
|
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 (
|
|
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
|
|
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.#
|
|
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 (!
|
|
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
|
|