@celilo/cli 1.0.0 → 1.2.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 (32) hide show
  1. package/CELILO_CORE_MODULES.md +15 -2
  2. package/CELILO_SUBSYSTEMS.md +13 -0
  3. package/package.json +2 -2
  4. package/src/cli/commands/hook-run.ts +5 -8
  5. package/src/cli/commands/system-audit.ts +2 -0
  6. package/src/cli/commands/system-doctor.ts +30 -1
  7. package/src/cli/commands/system-update.ts +2 -0
  8. package/src/cli/tui/audit-state.ts +2 -0
  9. package/src/db/schema.ts +41 -1
  10. package/src/hooks/artifact-retention.test.ts +136 -0
  11. package/src/hooks/artifact-retention.ts +159 -0
  12. package/src/hooks/executor.test.ts +80 -0
  13. package/src/hooks/executor.ts +68 -23
  14. package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
  15. package/src/hooks/types.ts +20 -2
  16. package/src/policy/module-business-baseline.ts +404 -0
  17. package/src/policy/no-module-business-in-core.test.ts +504 -0
  18. package/src/services/alerting/keys.ts +21 -1
  19. package/src/services/alerting/run-monitor.ts +6 -1
  20. package/src/services/audit/browser-pin.test.ts +167 -0
  21. package/src/services/audit/browser-pin.ts +185 -0
  22. package/src/services/audit/index.test.ts +1 -0
  23. package/src/services/audit/index.ts +3 -0
  24. package/src/services/audit/types.ts +1 -0
  25. package/src/services/health-runner.ts +15 -1
  26. package/src/services/module-deploy.ts +4 -4
  27. package/src/services/update/orchestrator.test.ts +1 -0
  28. package/src/system/browser-provisioning.test.ts +67 -0
  29. package/src/system/prereqs.test.ts +73 -0
  30. package/src/system/prereqs.ts +89 -12
  31. package/src/templates/generator.ts +46 -28
  32. package/src/templates/{dns-ingress-ip.test.ts → ingress-ip.test.ts} +38 -22
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Retention is by AGE, and the test that matters is the one asserting the
3
+ * FIRST failing run survives a long streak. A count-based rule passes every
4
+ * other test here and fails that one — which is exactly the bug this
5
+ * policy exists to avoid, since the first artifact set carries the original
6
+ * cause and later ones repeat it.
7
+ *
8
+ * Uses a real temp directory rather than an injected filesystem: mtime
9
+ * ordering and directory sizing are the behaviour under test, and a fake
10
+ * would be asserting my own model of them.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { mkdirSync, mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { ARTIFACT_RETENTION_MS, pruneModuleArtifacts } from './artifact-retention';
18
+
19
+ const HOUR = 60 * 60 * 1000;
20
+ const NOW = Date.UTC(2026, 7, 18, 12, 0, 0);
21
+
22
+ let root: string;
23
+
24
+ beforeEach(() => {
25
+ root = mkdtempSync(join(tmpdir(), 'celilo-artifacts-'));
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(root, { recursive: true, force: true });
30
+ });
31
+
32
+ /** Create a run directory whose files are `ageMs` old, with `bytes` of content. */
33
+ function makeRun(name: string, ageMs: number, bytes = 16): string {
34
+ const dir = join(root, name);
35
+ mkdirSync(dir, { recursive: true });
36
+ const file = join(dir, 'spa-failure.png');
37
+ writeFileSync(file, Buffer.alloc(bytes));
38
+ const when = new Date(NOW - ageMs);
39
+ utimesSync(file, when, when);
40
+ utimesSync(dir, when, when);
41
+ return dir;
42
+ }
43
+
44
+ function survivors(): string[] {
45
+ return readdirSync(root).sort();
46
+ }
47
+
48
+ describe('pruneModuleArtifacts', () => {
49
+ test('keeps everything inside the retention window', () => {
50
+ makeRun('run-a', 1 * HOUR);
51
+ makeRun('run-b', 23 * HOUR);
52
+
53
+ const outcome = pruneModuleArtifacts(root, { now: NOW });
54
+
55
+ expect(outcome.removed).toEqual([]);
56
+ expect(survivors()).toEqual(['run-a', 'run-b']);
57
+ });
58
+
59
+ test('prunes what is older than the retention window', () => {
60
+ makeRun('stale', 25 * HOUR);
61
+ makeRun('fresh', 1 * HOUR);
62
+
63
+ pruneModuleArtifacts(root, { now: NOW });
64
+
65
+ expect(survivors()).toEqual(['fresh']);
66
+ });
67
+
68
+ test('the boundary is exclusive: exactly at the window, it stays', () => {
69
+ // The off-by-one is the whole risk in a rule expressed as an inequality.
70
+ makeRun('exactly-24h', ARTIFACT_RETENTION_MS);
71
+ makeRun('a-ms-older', ARTIFACT_RETENTION_MS + 1);
72
+
73
+ pruneModuleArtifacts(root, { now: NOW });
74
+
75
+ expect(survivors()).toEqual(['exactly-24h']);
76
+ });
77
+
78
+ test('THE POINT: the first failure of a long streak survives', () => {
79
+ // A 12-hour streak at the 15-minute health cadence — every run failing,
80
+ // every one writing artifacts. The first set carries the original cause;
81
+ // "keep the last N" would have discarded it hours ago.
82
+ for (let quarterHour = 0; quarterHour <= 48; quarterHour++) {
83
+ makeRun(`run-${String(quarterHour).padStart(3, '0')}`, quarterHour * 15 * 60 * 1000);
84
+ }
85
+
86
+ pruneModuleArtifacts(root, { now: NOW });
87
+
88
+ const kept = survivors();
89
+ expect(kept).toContain('run-048'); // the oldest — the first failure
90
+ expect(kept).toContain('run-000'); // the most recent
91
+ expect(kept.length).toBe(49);
92
+ });
93
+
94
+ test('the size ceiling evicts oldest-first, and only down to the ceiling', () => {
95
+ makeRun('oldest', 3 * HOUR, 1000);
96
+ makeRun('middle', 2 * HOUR, 1000);
97
+ makeRun('newest', 1 * HOUR, 1000);
98
+
99
+ const outcome = pruneModuleArtifacts(root, { now: NOW, sizeCeilingBytes: 2500 });
100
+
101
+ // 3000 bytes exceeds 2500; dropping the oldest brings it to 2000.
102
+ expect(survivors()).toEqual(['middle', 'newest']);
103
+ expect(outcome.removed).toEqual([join(root, 'oldest')]);
104
+ expect(outcome.retainedBytes).toBe(2000);
105
+ });
106
+
107
+ test('a run still being written is aged by its newest file, not the directory', () => {
108
+ // The directory's own mtime can lag a file rewritten in place, which
109
+ // would let an in-flight run read as old enough to evict.
110
+ const dir = join(root, 'in-flight');
111
+ mkdirSync(dir, { recursive: true });
112
+ const old = new Date(NOW - 30 * HOUR);
113
+ utimesSync(dir, old, old);
114
+ const stale = join(dir, 'old.txt');
115
+ writeFileSync(stale, 'x');
116
+ utimesSync(stale, old, old);
117
+ writeFileSync(join(dir, 'just-written.png'), 'y'); // now
118
+
119
+ pruneModuleArtifacts(root, { now: NOW });
120
+
121
+ expect(survivors()).toEqual(['in-flight']);
122
+ });
123
+
124
+ test('a missing artifact root is not an error', () => {
125
+ expect(() => pruneModuleArtifacts(join(root, 'never-created'), { now: NOW })).not.toThrow();
126
+ });
127
+
128
+ test('loose files beside the run directories are left alone', () => {
129
+ writeFileSync(join(root, 'cookies.json'), '{}');
130
+ makeRun('stale', 30 * HOUR);
131
+
132
+ pruneModuleArtifacts(root, { now: NOW });
133
+
134
+ expect(survivors()).toEqual(['cookies.json']);
135
+ });
136
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Retention for the per-run artifact directories hooks write into.
3
+ *
4
+ * **By AGE, not by run count**, and the difference is not cosmetic. For a
5
+ * persistent failure — the normal case, since a broken deploy stays broken
6
+ * — the FIRST artifact set carries the original cause and runs 2..N are the
7
+ * same wall re-hit. A count-based rule therefore keeps the least
8
+ * informative sets and discards the one worth having: at the 15-minute
9
+ * cadence a health monitor runs on, "keep the last 5" is seventy-five
10
+ * minutes, and the operator typically reads the alert hours later.
11
+ *
12
+ * The size ceiling is the backstop, not the policy. ~96 runs a day of
13
+ * full-page screenshots on a management host is a `disk_space` alert
14
+ * waiting to happen, and celilo has a builtin check that would fire on it.
15
+ *
16
+ * Pruning is a pure function of mtime, so it needs no streak-tracking
17
+ * state and cannot drift out of sync with what is on disk.
18
+ */
19
+
20
+ import { readdirSync, rmSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+
23
+ /** How long a run's artifacts are kept. Long enough to survive a night. */
24
+ export const ARTIFACT_RETENTION_MS = 24 * 60 * 60 * 1000;
25
+
26
+ /**
27
+ * Total bytes of retained artifacts per module. Generous — this is the
28
+ * backstop against an unforeseen writer, not the mechanism that normally
29
+ * reclaims space.
30
+ */
31
+ export const ARTIFACT_SIZE_CEILING_BYTES = 64 * 1024 * 1024;
32
+
33
+ export interface PruneOptions {
34
+ /** Defaults to `Date.now()`; injected so tests need no sleeping. */
35
+ now?: number;
36
+ retentionMs?: number;
37
+ sizeCeilingBytes?: number;
38
+ }
39
+
40
+ export interface PruneOutcome {
41
+ /** Directories removed, oldest first. */
42
+ removed: string[];
43
+ /** Bytes retained after pruning. */
44
+ retainedBytes: number;
45
+ }
46
+
47
+ interface RunDirectory {
48
+ path: string;
49
+ mtimeMs: number;
50
+ bytes: number;
51
+ }
52
+
53
+ /**
54
+ * Remove aged-out and over-ceiling run directories under a module's
55
+ * artifact root.
56
+ *
57
+ * Best effort by construction: a hook run must never fail because a stale
58
+ * directory could not be deleted, so every filesystem error is swallowed
59
+ * and the outcome reports only what actually happened.
60
+ */
61
+ export function pruneModuleArtifacts(
62
+ artifactRoot: string,
63
+ options: PruneOptions = {},
64
+ ): PruneOutcome {
65
+ const now = options.now ?? Date.now();
66
+ const retentionMs = options.retentionMs ?? ARTIFACT_RETENTION_MS;
67
+ const ceiling = options.sizeCeilingBytes ?? ARTIFACT_SIZE_CEILING_BYTES;
68
+
69
+ const runs = readRunDirectories(artifactRoot);
70
+ // Oldest first, so both passes evict from the same end.
71
+ runs.sort((a, b) => a.mtimeMs - b.mtimeMs);
72
+
73
+ const removed: string[] = [];
74
+ const surviving: RunDirectory[] = [];
75
+ for (const run of runs) {
76
+ if (now - run.mtimeMs > retentionMs) {
77
+ if (remove(run.path)) removed.push(run.path);
78
+ continue;
79
+ }
80
+ surviving.push(run);
81
+ }
82
+
83
+ let retainedBytes = surviving.reduce((sum, run) => sum + run.bytes, 0);
84
+ while (retainedBytes > ceiling && surviving.length > 0) {
85
+ // biome-ignore lint/style/noNonNullAssertion: length checked above
86
+ const oldest = surviving.shift()!;
87
+ if (remove(oldest.path)) {
88
+ removed.push(oldest.path);
89
+ retainedBytes -= oldest.bytes;
90
+ } else {
91
+ // Undeletable: stop rather than spin, and leave it counted.
92
+ break;
93
+ }
94
+ }
95
+
96
+ return { removed, retainedBytes };
97
+ }
98
+
99
+ function readRunDirectories(artifactRoot: string): RunDirectory[] {
100
+ let entries: string[];
101
+ try {
102
+ entries = readdirSync(artifactRoot);
103
+ } catch {
104
+ return []; // No artifact root yet — nothing to prune.
105
+ }
106
+
107
+ const runs: RunDirectory[] = [];
108
+ for (const entry of entries) {
109
+ const path = join(artifactRoot, entry);
110
+ try {
111
+ if (!statSync(path).isDirectory()) continue;
112
+ runs.push({ path, mtimeMs: newestMtime(path), bytes: directoryBytes(path) });
113
+ } catch {
114
+ // Vanished mid-scan, or unreadable. Not ours to fix.
115
+ }
116
+ }
117
+ return runs;
118
+ }
119
+
120
+ /**
121
+ * Age a run by the NEWEST file in it, not by the directory's own mtime.
122
+ * A directory's mtime tracks its last entry change, which on some
123
+ * filesystems does not move when a file inside it is rewritten in place —
124
+ * so a run still being appended to could otherwise read as old enough to
125
+ * evict while the check that owns it is still running.
126
+ */
127
+ function newestMtime(dir: string): number {
128
+ let newest = statSync(dir).mtimeMs;
129
+ for (const file of readdirSync(dir)) {
130
+ try {
131
+ newest = Math.max(newest, statSync(join(dir, file)).mtimeMs);
132
+ } catch {
133
+ // Skip what we cannot stat.
134
+ }
135
+ }
136
+ return newest;
137
+ }
138
+
139
+ function directoryBytes(dir: string): number {
140
+ let total = 0;
141
+ for (const file of readdirSync(dir)) {
142
+ try {
143
+ const stat = statSync(join(dir, file));
144
+ if (stat.isFile()) total += stat.size;
145
+ } catch {
146
+ // Skip what we cannot stat.
147
+ }
148
+ }
149
+ return total;
150
+ }
151
+
152
+ function remove(path: string): boolean {
153
+ try {
154
+ rmSync(path, { recursive: true, force: true });
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import type { ContractHookSignature } from '../manifest/contracts';
4
5
  import {
@@ -272,6 +273,85 @@ describe('Hook Executor', () => {
272
273
  });
273
274
  });
274
275
 
276
+ describe('invokeHook artifacts', () => {
277
+ test('collects EVERY file the hook wrote, not just the newest .png', async () => {
278
+ const { logger } = createCapturingLogger();
279
+ const result = await invokeHook(
280
+ __dirname,
281
+ 'container_created',
282
+ '1.0',
283
+ { script: './test-fixtures/artifact-writing-hook.ts', timeout: 10000 },
284
+ { vps_ip: '10.0.0.5' },
285
+ {},
286
+ {},
287
+ logger,
288
+ );
289
+
290
+ expect(result.success).toBe(true);
291
+ const names = (result.artifactPaths ?? []).map((p) => p.split('/').pop());
292
+ // The old behaviour returned exactly one of these — the .png — and
293
+ // threw away the DOM and the request log, which is the least useful
294
+ // third of a post-mortem on its own.
295
+ expect(names).toEqual(['spa-failure.html', 'spa-failure.png', 'spa-failure.requests.txt']);
296
+
297
+ // Per-run, so a consumer writing FIXED filenames cannot overwrite its
298
+ // own previous run — which is what makes retention meaningful.
299
+ const dir = (result.artifactPaths ?? [])[0];
300
+ expect(dir).toContain('/screenshots/container_created-');
301
+
302
+ rmSync(join(__dirname, 'screenshots'), { recursive: true, force: true });
303
+ });
304
+
305
+ test('a hook that writes nothing leaves no directory behind', async () => {
306
+ // Every hook invocation creates a run directory. If empty ones were
307
+ // kept, a module would accrue one per run forever — ~96 a day for a
308
+ // 15-minute monitor — and nothing would ever reclaim them.
309
+ const { logger } = createCapturingLogger();
310
+ const result = await invokeHook(
311
+ __dirname,
312
+ 'container_created',
313
+ '1.0',
314
+ { script: './test-fixtures/success-hook.ts', timeout: 10000 },
315
+ { vps_ip: '10.0.0.5' },
316
+ {},
317
+ {},
318
+ logger,
319
+ );
320
+
321
+ expect(result.success).toBe(true);
322
+ expect(result.artifactPaths).toBeUndefined();
323
+ expect(existsSync(join(__dirname, 'screenshots', 'container_created'))).toBe(false);
324
+ const runDirs = existsSync(join(__dirname, 'screenshots'))
325
+ ? readdirSync(join(__dirname, 'screenshots'))
326
+ : [];
327
+ expect(runDirs).toEqual([]);
328
+ });
329
+
330
+ test('an early return before execution leaks no directory', async () => {
331
+ // The capability pre-flight returns between "create the directory" and
332
+ // the try/finally that reclaims it. Creating the directory too early
333
+ // therefore leaked one on every such run, and nothing cleans them up.
334
+ const { logger } = createCapturingLogger();
335
+ const result = await invokeHook(
336
+ __dirname,
337
+ 'container_created',
338
+ '1.0',
339
+ { script: './test-fixtures/success-hook.ts', timeout: 10000 },
340
+ { vps_ip: '10.0.0.5' },
341
+ {},
342
+ {},
343
+ logger,
344
+ { requiredCapabilities: ['dns_internal'], capabilities: {} },
345
+ );
346
+
347
+ expect(result.success).toBe(false);
348
+ const runDirs = existsSync(join(__dirname, 'screenshots'))
349
+ ? readdirSync(join(__dirname, 'screenshots'))
350
+ : [];
351
+ expect(runDirs).toEqual([]);
352
+ });
353
+ });
354
+
275
355
  describe('invokeHook', () => {
276
356
  test('full successful invocation', async () => {
277
357
  const { logger, messages } = createCapturingLogger();
@@ -19,12 +19,13 @@
19
19
  * Execution function (Rule 10.1) - performs side effects (script execution)
20
20
  */
21
21
 
22
- import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
23
- import { join, resolve } from 'node:path';
22
+ import { existsSync, mkdirSync, readdirSync, rmdirSync, statSync } from 'node:fs';
23
+ import { dirname, join, resolve } from 'node:path';
24
24
  import {
25
25
  type DeployedSystem,
26
26
  isCompiledHook,
27
27
  isMissingProviderInputError,
28
+ moduleArtifactDir,
28
29
  } from '@celilo/capabilities';
29
30
  import {
30
31
  type ContractHookSignature,
@@ -33,6 +34,7 @@ import {
33
34
  supportedContractVersions,
34
35
  } from '../manifest/contracts';
35
36
  import { isPrivilegedCapability } from '../manifest/validate';
37
+ import { pruneModuleArtifacts } from './artifact-retention';
36
38
  import type { HookContext, HookDefinition, HookLogger, HookResult } from './types';
37
39
 
38
40
  /** Default total timeout: 60 seconds */
@@ -373,22 +375,45 @@ export function checkRequiredCapabilities(
373
375
  * @param since - Only consider files created after this timestamp (ms)
374
376
  * @returns Path to screenshot, or undefined
375
377
  */
376
- function findRecentScreenshot(dir: string, since: number): string | undefined {
377
- if (!existsSync(dir)) return undefined;
378
+ /**
379
+ * Every file the hook wrote to this run's artifact directory.
380
+ *
381
+ * No mtime filter is needed and none is wanted: the directory is created
382
+ * fresh for this run, so everything in it was written by this run. The
383
+ * previous version returned only the newest `.png`, which discarded the
384
+ * page content and the observed-request log — the two things that make a
385
+ * screenshot diagnosable.
386
+ */
387
+ function collectArtifacts(dir: string): string[] {
388
+ try {
389
+ return readdirSync(dir)
390
+ .map((file) => join(dir, file))
391
+ .filter((path) => {
392
+ try {
393
+ return statSync(path).isFile();
394
+ } catch {
395
+ return false;
396
+ }
397
+ })
398
+ .sort();
399
+ } catch {
400
+ // Best effort — never mask the original error with a readdir failure.
401
+ return [];
402
+ }
403
+ }
378
404
 
405
+ /** `undefined` rather than `[]`, so an empty result carries no field at all. */
406
+ function nonEmpty(paths: string[]): string[] | undefined {
407
+ return paths.length > 0 ? paths : undefined;
408
+ }
409
+
410
+ /** Remove a run's artifact directory when the hook wrote nothing into it. */
411
+ function discardIfEmpty(dir: string): void {
379
412
  try {
380
- const files = readdirSync(dir).filter((f) => f.endsWith('.png'));
381
- for (const file of files) {
382
- const fullPath = join(dir, file);
383
- const stat = statSync(fullPath);
384
- if (stat.mtimeMs >= since) {
385
- return fullPath;
386
- }
387
- }
413
+ if (readdirSync(dir).length === 0) rmdirSync(dir);
388
414
  } catch {
389
- // Best effort — don't mask the original error
415
+ // Best effort — a leftover directory is not worth failing a hook over.
390
416
  }
391
- return undefined;
392
417
  }
393
418
 
394
419
  /**
@@ -470,9 +495,16 @@ export async function invokeHook(
470
495
  };
471
496
  }
472
497
 
473
- // Prepare screenshot directory
474
- const screenshotDir = join(modulePath, 'screenshots');
475
- mkdirSync(screenshotDir, { recursive: true });
498
+ // Prepare this run's artifact directory. Per-run, because a hook writing
499
+ // fixed filenames a reasonable thing to do — would otherwise overwrite
500
+ // its own previous artifacts and leave retention nothing to retain.
501
+ // `moduleArtifactDir` is the one definition of that layout; a bus
502
+ // subscriber, which gets no HookContext, calls it directly.
503
+ // Path now, directory later. Creating it here would leak an empty
504
+ // directory on every early return between this point and the try/finally
505
+ // below — the capability pre-flight is one — and nothing ever cleans
506
+ // those up, so a module accrues one per affected run forever.
507
+ const screenshotDir = moduleArtifactDir(modulePath, `${hookName}-${startTime}`);
476
508
 
477
509
  // Build context
478
510
  const loadedCapabilities = options.capabilities ?? {};
@@ -515,6 +547,13 @@ export async function invokeHook(
515
547
  debug,
516
548
  );
517
549
 
550
+ // Create the artifact directory only once every early return is behind
551
+ // us, so the `finally` below is guaranteed to run and reclaim it.
552
+ mkdirSync(screenshotDir, { recursive: true });
553
+ // Prune on write, so retention needs no scheduler of its own and cannot
554
+ // fall behind a module that runs often.
555
+ pruneModuleArtifacts(dirname(screenshotDir));
556
+
518
557
  // Execute
519
558
  try {
520
559
  logger.info(`Executing hook: ${hookName}`);
@@ -535,16 +574,17 @@ export async function invokeHook(
535
574
  return {
536
575
  success: true,
537
576
  outputs,
577
+ artifactPaths: nonEmpty(collectArtifacts(screenshotDir)),
538
578
  duration: Date.now() - startTime,
539
579
  };
540
580
  } catch (error) {
541
581
  const message = error instanceof Error ? error.message : String(error);
542
582
  logger.error(`Hook ${hookName} failed: ${message}`);
543
583
 
544
- // Check for screenshots captured by the hook
545
- const screenshotPath = findRecentScreenshot(screenshotDir, startTime);
546
- if (screenshotPath) {
547
- logger.info(`Screenshot saved: ${screenshotPath}`);
584
+ // Artifacts the hook wrote before it failed — the post-mortem.
585
+ const artifactPaths = nonEmpty(collectArtifacts(screenshotDir));
586
+ if (artifactPaths) {
587
+ logger.info(`Artifacts saved:\n ${artifactPaths.join('\n ')}`);
548
588
  }
549
589
 
550
590
  // Recognise the cross-module structured error so the orchestrator can
@@ -557,7 +597,7 @@ export async function invokeHook(
557
597
  success: false,
558
598
  outputs: {},
559
599
  error: message,
560
- screenshotPath,
600
+ artifactPaths,
561
601
  duration: Date.now() - startTime,
562
602
  missingProviderInput: {
563
603
  providerModuleId: error.providerModuleId,
@@ -572,8 +612,13 @@ export async function invokeHook(
572
612
  success: false,
573
613
  outputs: {},
574
614
  error: message,
575
- screenshotPath,
615
+ artifactPaths,
576
616
  duration: Date.now() - startTime,
577
617
  };
618
+ } finally {
619
+ // Most hooks write nothing, and every hook invocation would otherwise
620
+ // leave an empty directory behind. Discard it; a run that produced
621
+ // artifacts keeps its directory.
622
+ discardIfEmpty(screenshotDir);
578
623
  }
579
624
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Test fixture: a hook that writes a post-mortem set to its artifact
3
+ * directory, the way a browser-driven check does — a screenshot, the page
4
+ * content, and the observed requests.
5
+ *
6
+ * Three files with three different extensions on purpose: the executor
7
+ * used to report only the newest `.png`, which silently discarded the two
8
+ * that make the screenshot diagnosable.
9
+ */
10
+
11
+ import { writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { defineHook } from '@celilo/capabilities';
14
+
15
+ export default defineHook({
16
+ hook: 'container_created',
17
+ requires: [],
18
+ handler: async (ctx) => {
19
+ const dir = ctx.screenshotDir as string;
20
+ writeFileSync(join(dir, 'spa-failure.png'), 'not-really-a-png');
21
+ writeFileSync(join(dir, 'spa-failure.html'), '<html></html>');
22
+ writeFileSync(join(dir, 'spa-failure.requests.txt'), 'GET /api/getActiveMonth 200');
23
+ return { api_key: 'wrote-artifacts' };
24
+ },
25
+ });
@@ -91,8 +91,13 @@ export interface HookResult {
91
91
  success: boolean;
92
92
  outputs: Record<string, unknown>;
93
93
  error?: string;
94
- /** Path to failure screenshot, if captured */
95
- screenshotPath?: string;
94
+ /**
95
+ * Every file the hook wrote to its per-run artifact directory.
96
+ * All of them, not just the newest image: a screenshot without the
97
+ * DOM and the observed requests is the least useful third of a
98
+ * post-mortem.
99
+ */
100
+ artifactPaths?: string[];
96
101
  /** Duration in milliseconds */
97
102
  duration: number;
98
103
  /**
@@ -120,3 +125,16 @@ import type { HookName } from '@celilo/capabilities';
120
125
  * Hook manifest section - maps hook names to definitions
121
126
  */
122
127
  export type HookManifest = Partial<Record<HookName, HookDefinition>>;
128
+
129
+ /**
130
+ * Render collected artifacts for an operator-facing error message.
131
+ *
132
+ * One definition, because three call sites rendered the single old
133
+ * `screenshotPath` three separate times and would have drifted the moment
134
+ * one of them learned about the others.
135
+ */
136
+ export function describeArtifacts(artifactPaths: string[] | undefined): string {
137
+ if (!artifactPaths || artifactPaths.length === 0) return '';
138
+ const label = artifactPaths.length === 1 ? 'Artifact saved' : 'Artifacts saved';
139
+ return `\n\n${label}:\n ${artifactPaths.join('\n ')}`;
140
+ }