@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.
- package/CELILO_CORE_MODULES.md +15 -2
- package/CELILO_SUBSYSTEMS.md +13 -0
- package/package.json +2 -2
- package/src/cli/commands/hook-run.ts +5 -8
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-doctor.ts +30 -1
- package/src/cli/commands/system-update.ts +2 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +41 -1
- package/src/hooks/artifact-retention.test.ts +136 -0
- package/src/hooks/artifact-retention.ts +159 -0
- package/src/hooks/executor.test.ts +80 -0
- package/src/hooks/executor.ts +68 -23
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
- package/src/hooks/types.ts +20 -2
- package/src/policy/module-business-baseline.ts +404 -0
- package/src/policy/no-module-business-in-core.test.ts +504 -0
- package/src/services/alerting/keys.ts +21 -1
- package/src/services/alerting/run-monitor.ts +6 -1
- package/src/services/audit/browser-pin.test.ts +167 -0
- package/src/services/audit/browser-pin.ts +185 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/types.ts +1 -0
- package/src/services/health-runner.ts +15 -1
- package/src/services/module-deploy.ts +4 -4
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/system/browser-provisioning.test.ts +67 -0
- package/src/system/prereqs.test.ts +73 -0
- package/src/system/prereqs.ts +89 -12
- package/src/templates/generator.ts +46 -28
- 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();
|
package/src/hooks/executor.ts
CHANGED
|
@@ -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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
//
|
|
545
|
-
const
|
|
546
|
-
if (
|
|
547
|
-
logger.info(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/hooks/types.ts
CHANGED
|
@@ -91,8 +91,13 @@ export interface HookResult {
|
|
|
91
91
|
success: boolean;
|
|
92
92
|
outputs: Record<string, unknown>;
|
|
93
93
|
error?: string;
|
|
94
|
-
/**
|
|
95
|
-
|
|
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
|
+
}
|