vouchington-tooling 0.2.0 → 0.3.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 (57) hide show
  1. package/README.md +32 -0
  2. package/dist/agent-blackboard/index.d.mts +41 -0
  3. package/dist/agent-blackboard/index.mjs +94 -0
  4. package/dist/agent-blackboard/session-id.d.mts +1 -0
  5. package/dist/agent-blackboard/session-id.mjs +5 -0
  6. package/dist/agent-blackboard/snapshot-cleanup-directory.d.mts +11 -0
  7. package/dist/agent-blackboard/snapshot-cleanup-directory.mjs +66 -0
  8. package/dist/agent-blackboard/snapshot-cleanup-key.d.mts +14 -0
  9. package/dist/agent-blackboard/snapshot-cleanup-key.mjs +129 -0
  10. package/dist/agent-blackboard/snapshot-cleanup-receipt.d.mts +6 -0
  11. package/dist/agent-blackboard/snapshot-cleanup-receipt.mjs +117 -0
  12. package/dist/agent-blackboard/snapshot-cleanup-resume.d.mts +16 -0
  13. package/dist/agent-blackboard/snapshot-cleanup-resume.mjs +98 -0
  14. package/dist/agent-blackboard/snapshot-partition-cleanup.d.mts +13 -0
  15. package/dist/agent-blackboard/snapshot-partition-cleanup.mjs +136 -0
  16. package/dist/agent-blackboard/snapshot-partition-format.d.mts +32 -0
  17. package/dist/agent-blackboard/snapshot-partition-format.mjs +151 -0
  18. package/dist/agent-blackboard/snapshot-partition-io.d.mts +4 -0
  19. package/dist/agent-blackboard/snapshot-partition-io.mjs +37 -0
  20. package/dist/agent-blackboard/snapshot-partition-read.d.mts +9 -0
  21. package/dist/agent-blackboard/snapshot-partition-read.mjs +70 -0
  22. package/dist/agent-blackboard/snapshot-partition-validate.d.mts +4 -0
  23. package/dist/agent-blackboard/snapshot-partition-validate.mjs +40 -0
  24. package/dist/agent-blackboard/snapshot-partition-write.d.mts +2 -0
  25. package/dist/agent-blackboard/snapshot-partition-write.mjs +91 -0
  26. package/dist/agent-blackboard/snapshot-partitions.d.mts +10 -0
  27. package/dist/agent-blackboard/snapshot-partitions.mjs +102 -0
  28. package/dist/agent-blackboard/snapshot-types.d.mts +67 -0
  29. package/dist/agent-blackboard/snapshot-types.mjs +1 -0
  30. package/dist/agent-blackboard/snapshot.d.mts +3 -0
  31. package/dist/agent-blackboard/snapshot.mjs +2 -0
  32. package/dist/cli/commands/agent-blackboard.d.mts +3 -0
  33. package/dist/cli/commands/agent-blackboard.mjs +114 -0
  34. package/dist/cli/commands/retrospective-facts.d.mts +1 -0
  35. package/dist/cli/commands/retrospective-facts.mjs +33 -0
  36. package/dist/cli/index.mjs +11 -0
  37. package/dist/cli/parse.d.mts +7 -1
  38. package/dist/cli/parse.mjs +6 -0
  39. package/dist/cli/usage.d.mts +1 -1
  40. package/dist/cli/usage.mjs +12 -0
  41. package/dist/index.d.mts +4 -0
  42. package/dist/index.mjs +2 -0
  43. package/dist/retrospective-facts/exec.d.mts +3 -0
  44. package/dist/retrospective-facts/exec.mjs +19 -0
  45. package/dist/retrospective-facts/foreign.d.mts +2 -0
  46. package/dist/retrospective-facts/foreign.mjs +36 -0
  47. package/dist/retrospective-facts/format.d.mts +10 -0
  48. package/dist/retrospective-facts/format.mjs +64 -0
  49. package/dist/retrospective-facts/index.d.mts +3 -0
  50. package/dist/retrospective-facts/index.mjs +26 -0
  51. package/dist/retrospective-facts/local.d.mts +2 -0
  52. package/dist/retrospective-facts/local.mjs +144 -0
  53. package/dist/retrospective-facts/shared.d.mts +17 -0
  54. package/dist/retrospective-facts/shared.mjs +1 -0
  55. package/package.json +21 -2
  56. package/scripts/gha/harness-admission-lane.sh +26 -0
  57. package/scripts/gha/harness-assert-gates.sh +24 -0
package/README.md CHANGED
@@ -6,6 +6,8 @@ Libraries and the `vouchington` CLI.
6
6
  npm install vouchington-tooling
7
7
  # optional, only if you import vouchington-tooling/sql-ast
8
8
  npm install @libpg-query/parser
9
+ # optional, only for vouchington-tooling/agent-blackboard and agent-blackboard CLI commands
10
+ npm install agent-blackboard@^0.3.1
9
11
  ```
10
12
 
11
13
  ## CLI
@@ -39,8 +41,16 @@ vouchington lint-links --offline
39
41
  vouchington materialize-pr-context
40
42
  vouchington wait-for-apt-locks
41
43
  vouchington retrospective-transcript --jsonl /path/to/transcript.jsonl
44
+ vouchington retrospective-facts --pr 49 --repo vouchington/vouchington-infra --raw
45
+ vouchington agent-blackboard probe
46
+ vouchington agent-blackboard journal append --session-id <uuid> --agent codex --version 1 --file note.md
47
+ vouchington agent-blackboard journal entries --session-id <uuid>
48
+ vouchington agent-blackboard snapshot partition --snapshot <snapshot.jsonl> --checksum <sha256> --counts <counts.json>
49
+ vouchington agent-blackboard snapshot cleanup --snapshot <snapshot.jsonl> --partition-directory <partitions-dir> --receipt <receipt-json>
42
50
  vouchington install-playwright-chromium-arm64
43
51
  vouchington ghcr-package-retention example%2Fapi
52
+ vouchington harness-admission-lane 4
53
+ vouchington harness-assert-gates HARNESS_DISPATCH_ENABLED HARNESS_SHEPHERD_ENABLED
44
54
  vouchington nuget-central-version trusted.props candidate.props metadata.json out.props
45
55
  vouchington swift-semantic-equal BASE HEAD App.swift
46
56
  vouchington post-review
@@ -68,6 +78,27 @@ layout when `GROK_SESSION_ID` is set. Use `--grok-sessions-dir` to point discove
68
78
  Grok session root. Without `--session-id`, it reads those session identities from the host
69
79
  environment.
70
80
 
81
+ `retrospective-facts` keeps local Git evidence separate from GitHub PR data. `Commits ahead of
82
+ origin/main` is populated only from a local ancestry range; GitHub responses instead populate
83
+ `PR commits`. API-derived file and directory counts are labelled `GitHub API`. When a named local
84
+ branch is absent, the command refreshes `origin/<branch>` before using it and refuses a stale
85
+ remote ref when that refresh fails.
86
+ For an explicit `--repo`, it performs no local Git checks: `Merged to main` is `yes` only when
87
+ GitHub reports a merged PR whose `baseRefName` is `main`; a merged PR into another base is reported
88
+ as not merged to main, and a missing base is unavailable.
89
+
90
+ Agent Blackboard support is optional: only the `agent-blackboard` subpath and its CLI commands
91
+ need `agent-blackboard@^0.3.1`. Snapshot cleanup accepts only package-generated temporary paths.
92
+ It captures a target under a private tombstone, validates partition names, permissions, JSONL,
93
+ ordering, terminal manifests, and the identity-bound cleanup receipt before deleting files, and
94
+ restores the original path on a validation failure. Once deletion begins, it retains a private
95
+ tombstone plus signed resume metadata instead; retry cleanup with the original partition-directory
96
+ path and the same receipt until it completes. The partition command returns that receipt; directory
97
+ cleanup requires it.
98
+ The receipt is authenticated with an owner-only per-user HMAC key stored under a dedicated
99
+ `0700` directory in the system temporary directory. This small host-local state is the trust
100
+ boundary: a copied or caller-created receipt cannot authorize cleanup without that key.
101
+
71
102
  Host-lock environment:
72
103
 
73
104
  | Variable | Default | Meaning |
@@ -144,6 +175,7 @@ import {
144
175
  readDiagnosticReportSummaries,
145
176
  } from 'vouchington-tooling/vitest-diagnostics'
146
177
  import { runRetrospectiveTranscript } from 'vouchington-tooling/retrospective-transcript'
178
+ import { appendJournal, probeBlackboard } from 'vouchington-tooling/agent-blackboard'
147
179
  import { buildSessionFrictionReport, recordFriction } from 'vouchington-tooling/session-friction'
148
180
  ```
149
181
 
@@ -0,0 +1,41 @@
1
+ export { cleanupSnapshotPartitions, partitionSnapshot } from './snapshot.mts';
2
+ export { assertSessionId } from './session-id.mts';
3
+ export type * from './snapshot-types.mts';
4
+ export type BlackboardConnection = {
5
+ baseUrl: string;
6
+ token: string;
7
+ readRetry: Record<string, never>;
8
+ };
9
+ export type BlackboardClientModule = {
10
+ Sessions: new (connection: BlackboardConnection) => {
11
+ ensure(input: unknown): Promise<{
12
+ status: 'created' | 'exists';
13
+ }>;
14
+ list(input: unknown): Promise<unknown>;
15
+ get(id: string): Promise<unknown>;
16
+ };
17
+ Entries: new (connection: BlackboardConnection) => {
18
+ append(input: unknown): Promise<{
19
+ createdAt: string;
20
+ }>;
21
+ get(input: unknown): AsyncIterable<unknown>;
22
+ };
23
+ };
24
+ type BlackboardClientLoader = () => Promise<BlackboardClientModule>;
25
+ export type BlackboardClientDependencies = {
26
+ loadClient?: BlackboardClientLoader;
27
+ };
28
+ export declare function resolveBlackboardConnection(env?: NodeJS.ProcessEnv): BlackboardConnection;
29
+ export declare function probeBlackboard(env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies): Promise<void>;
30
+ export declare function appendJournal(input: {
31
+ sessionId: string;
32
+ agent: string;
33
+ version: string;
34
+ markdownFile: string;
35
+ parentSessionId?: string | null;
36
+ timestamp?: string;
37
+ env?: NodeJS.ProcessEnv;
38
+ dependencies?: BlackboardClientDependencies;
39
+ }): Promise<string>;
40
+ export declare function readJournal(sessionId: string, env?: NodeJS.ProcessEnv, dependencies?: BlackboardClientDependencies): Promise<unknown[]>;
41
+ export declare function formatJournalEntries(sessionId: string, entries: unknown[]): string;
@@ -0,0 +1,94 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { assertSessionId } from './session-id.mjs';
3
+ export { cleanupSnapshotPartitions, partitionSnapshot } from './snapshot.mjs';
4
+ export { assertSessionId } from './session-id.mjs';
5
+ export function resolveBlackboardConnection(env = process.env) {
6
+ const baseUrl = env.AGENT_BLACKBOARD_URL;
7
+ const token = env.AGENT_BLACKBOARD_TOKEN;
8
+ if (!baseUrl)
9
+ throw new Error('AGENT_BLACKBOARD_URL is not set');
10
+ if (!token)
11
+ throw new Error('AGENT_BLACKBOARD_TOKEN is not set');
12
+ return { baseUrl, token, readRetry: {} };
13
+ }
14
+ export async function probeBlackboard(env, dependencies) {
15
+ const { Sessions } = await loadClient(dependencies?.loadClient);
16
+ await new Sessions(resolveBlackboardConnection(env)).list({ limit: 1 });
17
+ }
18
+ export async function appendJournal(input) {
19
+ assertSessionId(input.sessionId);
20
+ if (input.parentSessionId != null)
21
+ assertSessionId(input.parentSessionId, 'parent session id');
22
+ const timestamp = input.timestamp === undefined ? new Date() : new Date(input.timestamp);
23
+ if (Number.isNaN(timestamp.valueOf()))
24
+ throw new Error('journal timestamp is not a valid date-time');
25
+ let markdown;
26
+ try {
27
+ markdown = new TextDecoder('utf-8', { fatal: true }).decode(await readFile(input.markdownFile));
28
+ }
29
+ catch (error) {
30
+ throw new Error(`note file is not valid UTF-8: ${input.markdownFile}`, { cause: error });
31
+ }
32
+ if (!markdown)
33
+ throw new Error(`note file is empty: ${input.markdownFile}`);
34
+ const connection = resolveBlackboardConnection(input.env);
35
+ const { Sessions, Entries } = await loadClient(input.dependencies?.loadClient);
36
+ await new Sessions(connection).ensure({
37
+ id: input.sessionId,
38
+ parentSessionId: input.parentSessionId ?? null,
39
+ agent: input.agent,
40
+ version: input.version,
41
+ });
42
+ const entry = await new Entries(connection).append({
43
+ sessionId: input.sessionId,
44
+ data: { type: 'journal', markdown, timestamp: timestamp.toISOString() },
45
+ });
46
+ return `Journaled to agent-blackboard session ${input.sessionId} (entry created at ${entry.createdAt}).`;
47
+ }
48
+ export async function readJournal(sessionId, env, dependencies) {
49
+ assertSessionId(sessionId);
50
+ const { Entries } = await loadClient(dependencies?.loadClient);
51
+ const entries = [];
52
+ for await (const entry of new Entries(resolveBlackboardConnection(env)).get({
53
+ sessionId,
54
+ format: 'json',
55
+ }))
56
+ entries.push(entry);
57
+ return entries;
58
+ }
59
+ export function formatJournalEntries(sessionId, entries) {
60
+ const journals = entries.flatMap((entry) => {
61
+ if (typeof entry !== 'object' ||
62
+ entry === null ||
63
+ !('createdAt' in entry) ||
64
+ !('data' in entry) ||
65
+ typeof entry.createdAt !== 'string' ||
66
+ typeof entry.data !== 'object' ||
67
+ entry.data === null ||
68
+ !('type' in entry.data) ||
69
+ !('markdown' in entry.data) ||
70
+ entry.data.type !== 'journal' ||
71
+ typeof entry.data.markdown !== 'string')
72
+ return [];
73
+ return [{ createdAt: entry.createdAt, markdown: entry.data.markdown }];
74
+ });
75
+ if (!journals.length)
76
+ return `No journal entries found for session ${sessionId}.`;
77
+ return journals
78
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt))
79
+ .map(({ createdAt, markdown }) => `## ${createdAt}\n\n${markdown}`)
80
+ .join('\n\n');
81
+ }
82
+ async function loadClient(loader = defaultClientLoader) {
83
+ try {
84
+ return await loader();
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND')
88
+ throw new Error('agent-blackboard is not installed; install the optional agent-blackboard peer dependency', { cause: error });
89
+ throw error;
90
+ }
91
+ }
92
+ async function defaultClientLoader() {
93
+ return (await import('agent-blackboard'));
94
+ }
@@ -0,0 +1 @@
1
+ export declare function assertSessionId(sessionId: string, label?: string): void;
@@ -0,0 +1,5 @@
1
+ const SESSION_ID = /^[A-Za-z0-9._:-]+$/;
2
+ export function assertSessionId(sessionId, label = 'session id') {
3
+ if (!SESSION_ID.test(sessionId))
4
+ throw new Error(`${label} must be URL-safe`);
5
+ }
@@ -0,0 +1,11 @@
1
+ import type { lstat, readFile, readdir, rm, rmdir } from 'node:fs/promises';
2
+ import type { SnapshotCleanupReceipt } from './snapshot-types.mts';
3
+ type Filesystem = {
4
+ lstat: typeof lstat;
5
+ readFile: typeof readFile;
6
+ readdir: typeof readdir;
7
+ rm: typeof rm;
8
+ rmdir: typeof rmdir;
9
+ };
10
+ export declare function removePartitionDirectory(filesystem: Filesystem, path: string, originalPath: string, receipt: SnapshotCleanupReceipt, directory: Awaited<ReturnType<typeof lstat>>, resume: boolean, startDeleting: () => void): Promise<void>;
11
+ export {};
@@ -0,0 +1,66 @@
1
+ import { join } from 'node:path';
2
+ import { RECEIPT_NAME, sameCleanupReceipt } from './snapshot-cleanup-receipt.mjs';
3
+ import { removeResumeReceipt, writeResumeReceipt } from './snapshot-cleanup-resume.mjs';
4
+ import { assertPartitionFile, validatePartition } from './snapshot-partition-validate.mjs';
5
+ const PARTITION = /^partition-([1-9][0-9]*)\.jsonl$/;
6
+ export async function removePartitionDirectory(filesystem, path, originalPath, receipt, directory, resume, startDeleting) {
7
+ const names = await filesystem.readdir(path);
8
+ const ordered = names
9
+ .filter((name) => PARTITION.test(name))
10
+ .map((name) => ({ name, number: Number(PARTITION.exec(name)?.[1]) }))
11
+ .sort((left, right) => left.number - right.number);
12
+ const expected = receipt.partitions.map(({ name }) => name);
13
+ const remaining = expected.slice(expected.length - ordered.length);
14
+ const hasReceipt = names.includes(RECEIPT_NAME);
15
+ if ((!hasReceipt && !resume) ||
16
+ names.length !== ordered.length + Number(hasReceipt) ||
17
+ ordered.some(({ name }, index) => name !== remaining[index]) ||
18
+ (!resume && ordered.length !== expected.length))
19
+ throw new Error('partition directory contains unexpected content');
20
+ const partitionNames = ordered.map(({ name }) => name);
21
+ if (hasReceipt)
22
+ await validateReceipt(filesystem, originalPath, path, receipt, directory, partitionNames);
23
+ else
24
+ validateReceiptOutput(originalPath, receipt, directory, partitionNames);
25
+ for (const { name } of ordered) {
26
+ const file = join(path, name);
27
+ const info = await filesystem.lstat(file);
28
+ assertPartitionFile(info);
29
+ await validatePartition(file, info, receipt.partitions.find((partition) => partition.name === name)?.checksum);
30
+ }
31
+ if (!resume)
32
+ await writeResumeReceipt(receipt);
33
+ startDeleting();
34
+ for (const { name } of ordered)
35
+ await filesystem.rm(join(path, name), { force: false });
36
+ if (hasReceipt)
37
+ await filesystem.rm(join(path, RECEIPT_NAME), { force: false });
38
+ await filesystem.rmdir(path);
39
+ await removeResumeReceipt(receipt);
40
+ }
41
+ async function validateReceipt(filesystem, originalPath, path, receipt, directory, names) {
42
+ const marker = join(path, RECEIPT_NAME);
43
+ const info = await filesystem.lstat(marker);
44
+ assertPartitionFile(info);
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(await filesystem.readFile(marker, 'utf8'));
48
+ }
49
+ catch {
50
+ throw new Error('partition directory cleanup receipt is invalid');
51
+ }
52
+ if (!sameCleanupReceipt(parsed, receipt))
53
+ throw new Error('partition directory cleanup receipt does not match generated output');
54
+ validateReceiptOutput(originalPath, receipt, directory, names);
55
+ }
56
+ function validateReceiptOutput(originalPath, receipt, directory, names) {
57
+ if (receipt.schemaVersion !== 1 ||
58
+ receipt.directory !== originalPath ||
59
+ receipt.directoryDev !== directory.dev ||
60
+ receipt.directoryIno !== directory.ino ||
61
+ receipt.partitions.filter(({ name }) => names.includes(name)).length !== names.length ||
62
+ receipt.partitions
63
+ .filter(({ name }) => names.includes(name))
64
+ .some(({ name, checksum }, index) => name !== names[index] || checksum.algorithm !== 'sha256' || !checksum.value))
65
+ throw new Error('partition directory cleanup receipt does not match generated output');
66
+ }
@@ -0,0 +1,14 @@
1
+ import { link, lstat, mkdir, open, readdir, unlink } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ declare const defaults: {
4
+ lstat: typeof lstat;
5
+ mkdir: typeof mkdir;
6
+ open: typeof open;
7
+ readdir: typeof readdir;
8
+ link: typeof link;
9
+ unlink: typeof unlink;
10
+ };
11
+ export declare function setCleanupKeyTempDirectoryForTest(directory?: typeof tmpdir): void;
12
+ export declare function setCleanupKeyFilesystemForTest(overrides?: Partial<typeof defaults>): void;
13
+ export declare function loadCleanupSigningKey(): Promise<Buffer>;
14
+ export {};
@@ -0,0 +1,129 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { link, lstat, mkdir, open, readdir, unlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ const KEY_NAME = 'receipt-hmac-sha256.key';
7
+ const KEY_BYTES = 32;
8
+ const defaults = { lstat, mkdir, open, readdir, link, unlink };
9
+ let filesystem = defaults;
10
+ let temporaryDirectory = tmpdir;
11
+ export function setCleanupKeyTempDirectoryForTest(directory = tmpdir) {
12
+ temporaryDirectory = directory;
13
+ }
14
+ export function setCleanupKeyFilesystemForTest(overrides) {
15
+ filesystem = { ...defaults, ...overrides };
16
+ }
17
+ function euid() {
18
+ if (typeof process.geteuid !== 'function')
19
+ throw new Error('snapshot cleanup receipts require a POSIX effective user ID');
20
+ return process.geteuid();
21
+ }
22
+ function assertDirectory(info, uid) {
23
+ if (info.isSymbolicLink() ||
24
+ !info.isDirectory() ||
25
+ info.uid !== uid ||
26
+ (Number(info.mode) & 0o777) !== 0o700)
27
+ throw new Error('snapshot cleanup key directory is not owner-only');
28
+ }
29
+ function assertKey(info, uid, links = 1) {
30
+ if (info.isSymbolicLink() ||
31
+ !info.isFile() ||
32
+ info.nlink !== links ||
33
+ info.uid !== uid ||
34
+ (Number(info.mode) & 0o777) !== 0o600)
35
+ throw new Error('snapshot cleanup key is not an owner-only regular file');
36
+ }
37
+ async function keyPath() {
38
+ const uid = euid();
39
+ const directory = join(temporaryDirectory(), `agent-blackboard-cleanup-${uid}`);
40
+ try {
41
+ await filesystem.mkdir(directory, { mode: 0o700 });
42
+ }
43
+ catch (error) {
44
+ if (error.code !== 'EEXIST')
45
+ throw error;
46
+ }
47
+ assertDirectory(await filesystem.lstat(directory), uid);
48
+ return { path: join(directory, KEY_NAME), uid };
49
+ }
50
+ async function readKey(path, uid) {
51
+ try {
52
+ return await readPresentKey(path, uid);
53
+ }
54
+ catch (error) {
55
+ if (error.code !== 'ENOENT')
56
+ throw error;
57
+ return readPresentKey(path, uid);
58
+ }
59
+ }
60
+ async function readPresentKey(path, uid) {
61
+ let before = await filesystem.lstat(path);
62
+ if (before.nlink === 2) {
63
+ assertKey(before, uid, 2);
64
+ const candidates = (await filesystem.readdir(dirname(path))).filter((name) => new RegExp(`^\\.${KEY_NAME.replaceAll('.', '\\.')}\\.[0-9a-f-]{36}$`).test(name));
65
+ const linked = await Promise.all(candidates.map(async (name) => ({
66
+ name,
67
+ info: await filesystem.lstat(join(dirname(path), name)),
68
+ })));
69
+ const matches = linked.filter(({ info }) => info.dev === before.dev && info.ino === before.ino);
70
+ if (matches.length !== 1)
71
+ throw new Error('snapshot cleanup key has an unsafe temporary link');
72
+ const temporary = join(dirname(path), matches[0].name);
73
+ const staged = matches[0].info;
74
+ assertKey(staged, uid, 2);
75
+ await filesystem.unlink(temporary);
76
+ before = await filesystem.lstat(path);
77
+ }
78
+ assertKey(before, uid);
79
+ const file = await filesystem.open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
80
+ try {
81
+ const after = await file.stat();
82
+ assertKey(after, uid);
83
+ if (after.dev !== before.dev || after.ino !== before.ino)
84
+ throw new Error('snapshot cleanup key changed while it was opened');
85
+ const key = await file.readFile();
86
+ if (key.byteLength !== KEY_BYTES)
87
+ throw new Error('snapshot cleanup key has an invalid length');
88
+ return key;
89
+ }
90
+ finally {
91
+ await file.close();
92
+ }
93
+ }
94
+ async function publishKey(path, uid) {
95
+ const temporary = join(dirname(path), `.${KEY_NAME}.${randomUUID()}`);
96
+ const file = await filesystem.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
97
+ try {
98
+ await file.writeFile(randomBytes(KEY_BYTES));
99
+ await file.sync();
100
+ }
101
+ finally {
102
+ await file.close();
103
+ }
104
+ try {
105
+ await filesystem.link(temporary, path);
106
+ }
107
+ finally {
108
+ await filesystem.unlink(temporary).catch(() => undefined);
109
+ }
110
+ await readKey(path, uid);
111
+ }
112
+ export async function loadCleanupSigningKey() {
113
+ const { path, uid } = await keyPath();
114
+ try {
115
+ return await readKey(path, uid);
116
+ }
117
+ catch (error) {
118
+ if (error.code !== 'ENOENT')
119
+ throw error;
120
+ }
121
+ try {
122
+ await publishKey(path, uid);
123
+ }
124
+ catch (error) {
125
+ if (error.code !== 'EEXIST')
126
+ throw error;
127
+ }
128
+ return readKey(path, uid);
129
+ }
@@ -0,0 +1,6 @@
1
+ import type { SnapshotCleanupReceipt, SnapshotPartition } from './snapshot-types.mts';
2
+ export declare const RECEIPT_NAME = ".agent-blackboard-cleanup-receipt.json";
3
+ export declare function serializeCleanupReceipt(receipt: SnapshotCleanupReceipt): string;
4
+ export declare function sameCleanupReceipt(left: unknown, right: SnapshotCleanupReceipt): boolean;
5
+ export declare function writeCleanupReceipt(directory: string, partitions: Array<Pick<SnapshotPartition, 'path' | 'checksum'>>): Promise<SnapshotCleanupReceipt>;
6
+ export declare function verifyCleanupReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
@@ -0,0 +1,117 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { chmod, lstat, writeFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { loadCleanupSigningKey } from './snapshot-cleanup-key.mjs';
5
+ export const RECEIPT_NAME = '.agent-blackboard-cleanup-receipt.json';
6
+ function record(value) {
7
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ }
9
+ function exact(value, keys) {
10
+ return Object.keys(value).length === keys.length && keys.every((key) => key in value);
11
+ }
12
+ function canonical(receipt, signature) {
13
+ if (!record(receipt) ||
14
+ !exact(receipt, signature
15
+ ? [
16
+ 'schemaVersion',
17
+ 'directory',
18
+ 'directoryDev',
19
+ 'directoryIno',
20
+ 'token',
21
+ 'partitions',
22
+ 'signature',
23
+ ]
24
+ : ['schemaVersion', 'directory', 'directoryDev', 'directoryIno', 'token', 'partitions']))
25
+ throw new Error('partition directory cleanup receipt is invalid');
26
+ const { schemaVersion, directory, directoryDev, directoryIno, token, partitions } = receipt;
27
+ if (schemaVersion !== 1 ||
28
+ typeof directory !== 'string' ||
29
+ !directory ||
30
+ !Number.isSafeInteger(directoryDev) ||
31
+ directoryDev < 0 ||
32
+ !Number.isSafeInteger(directoryIno) ||
33
+ directoryIno < 0 ||
34
+ typeof token !== 'string' ||
35
+ !/^[0-9a-f-]{36}$/.test(token) ||
36
+ !Array.isArray(partitions))
37
+ throw new Error('partition directory cleanup receipt is invalid');
38
+ const normalizedDirectoryDev = directoryDev;
39
+ const normalizedDirectoryIno = directoryIno;
40
+ const normalized = partitions.map((partition) => {
41
+ if (!record(partition) ||
42
+ !exact(partition, ['name', 'checksum']) ||
43
+ typeof partition.name !== 'string' ||
44
+ !/^partition-[1-9][0-9]*\.jsonl$/.test(partition.name) ||
45
+ !record(partition.checksum) ||
46
+ !exact(partition.checksum, ['algorithm', 'value']) ||
47
+ partition.checksum.algorithm !== 'sha256' ||
48
+ typeof partition.checksum.value !== 'string' ||
49
+ !/^[0-9a-f]{64}$/.test(partition.checksum.value))
50
+ throw new Error('partition directory cleanup receipt is invalid');
51
+ return {
52
+ name: partition.name,
53
+ checksum: { algorithm: 'sha256', value: partition.checksum.value },
54
+ };
55
+ });
56
+ if (new Set(normalized.map(({ name }) => name)).size !== normalized.length)
57
+ throw new Error('partition directory cleanup receipt is invalid');
58
+ const unsigned = {
59
+ schemaVersion: 1,
60
+ directory,
61
+ directoryDev: normalizedDirectoryDev,
62
+ directoryIno: normalizedDirectoryIno,
63
+ token,
64
+ partitions: normalized,
65
+ };
66
+ if (!signature)
67
+ return JSON.stringify(unsigned);
68
+ if (typeof receipt.signature !== 'string' || !/^[0-9a-f]{64}$/.test(receipt.signature))
69
+ throw new Error('partition directory cleanup receipt signature is invalid');
70
+ return JSON.stringify({ ...unsigned, signature: receipt.signature });
71
+ }
72
+ function payload(receipt) {
73
+ return canonical(receipt, false);
74
+ }
75
+ export function serializeCleanupReceipt(receipt) {
76
+ return canonical(receipt, true);
77
+ }
78
+ export function sameCleanupReceipt(left, right) {
79
+ try {
80
+ return (serializeCleanupReceipt(left) === serializeCleanupReceipt(right));
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ export async function writeCleanupReceipt(directory, partitions) {
87
+ const info = await lstat(directory);
88
+ const unsigned = {
89
+ schemaVersion: 1,
90
+ directory,
91
+ directoryDev: info.dev,
92
+ directoryIno: info.ino,
93
+ token: randomUUID(),
94
+ partitions: partitions.map(({ path, checksum }) => ({ name: basename(path), checksum })),
95
+ };
96
+ const signature = createHmac('sha256', await loadCleanupSigningKey())
97
+ .update(payload(unsigned))
98
+ .digest('hex');
99
+ const receipt = { ...unsigned, signature };
100
+ const marker = join(directory, RECEIPT_NAME);
101
+ await writeFile(marker, serializeCleanupReceipt(receipt), { flag: 'wx', mode: 0o400 });
102
+ await chmod(marker, 0o400);
103
+ return receipt;
104
+ }
105
+ export async function verifyCleanupReceipt(receipt) {
106
+ if (!record(receipt) ||
107
+ typeof receipt.signature !== 'string' ||
108
+ !/^[0-9a-f]{64}$/.test(receipt.signature))
109
+ throw new Error('partition directory cleanup receipt signature is invalid');
110
+ const serialized = serializeCleanupReceipt(receipt);
111
+ const { signature, ...unsigned } = JSON.parse(serialized);
112
+ const expected = createHmac('sha256', await loadCleanupSigningKey())
113
+ .update(payload(unsigned))
114
+ .digest('hex');
115
+ if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex')))
116
+ throw new Error('partition directory cleanup receipt signature is invalid');
117
+ }
@@ -0,0 +1,16 @@
1
+ import { link, lstat, open, readFile, readdir, rm, unlink } from 'node:fs/promises';
2
+ import type { SnapshotCleanupReceipt } from './snapshot-types.mts';
3
+ declare const defaults: {
4
+ link: typeof link;
5
+ lstat: typeof lstat;
6
+ open: typeof open;
7
+ readFile: typeof readFile;
8
+ readdir: typeof readdir;
9
+ rm: typeof rm;
10
+ unlink: typeof unlink;
11
+ };
12
+ export declare function setResumeFilesystemForTest(overrides?: Partial<typeof defaults>): void;
13
+ export declare function writeResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
14
+ export declare function removeResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
15
+ export declare function requireResumeReceipt(receipt: SnapshotCleanupReceipt): Promise<void>;
16
+ export {};
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { link, lstat, open, readFile, readdir, rm, unlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { sameCleanupReceipt, verifyCleanupReceipt } from './snapshot-cleanup-receipt.mjs';
7
+ const defaults = { link, lstat, open, readFile, readdir, rm, unlink };
8
+ let filesystem = defaults;
9
+ export function setResumeFilesystemForTest(overrides) {
10
+ filesystem = { ...defaults, ...overrides };
11
+ }
12
+ function pathFor(receipt) {
13
+ if (!/^[0-9a-f-]{36}$/.test(receipt.token))
14
+ throw new Error('partition directory cleanup receipt has an invalid token');
15
+ return join(tmpdir(), `.agent-blackboard-cleanup-${receipt.token}.resume.json`);
16
+ }
17
+ function temporaryPath(receipt) {
18
+ return `${pathFor(receipt)}.${randomUUID()}.tmp`;
19
+ }
20
+ function assertResumeFile(info, links = 1) {
21
+ if (info.isSymbolicLink() ||
22
+ !info.isFile() ||
23
+ info.nlink !== links ||
24
+ (Number(info.mode) & 0o777) !== 0o400 ||
25
+ (typeof process.geteuid === 'function' && info.uid !== process.geteuid()))
26
+ throw new Error('partition directory cleanup resume metadata is unsafe');
27
+ }
28
+ async function read(receipt) {
29
+ const path = pathFor(receipt);
30
+ let before = await filesystem.lstat(path);
31
+ if (before.nlink === 2) {
32
+ assertResumeFile(before, 2);
33
+ const candidates = (await filesystem.readdir(tmpdir())).filter((name) => new RegExp(`^${path.split('/').at(-1).replaceAll('.', '\\.')}(?:\\.[0-9a-f-]{36}\\.tmp)$`).test(name));
34
+ const linked = await Promise.all(candidates.map(async (name) => ({
35
+ name,
36
+ info: await filesystem.lstat(join(tmpdir(), name)),
37
+ })));
38
+ const matches = linked.filter(({ info }) => info.dev === before.dev && info.ino === before.ino);
39
+ if (matches.length !== 1)
40
+ throw new Error('partition directory cleanup resume metadata is unsafe');
41
+ const temporary = join(tmpdir(), matches[0].name);
42
+ const staged = matches[0].info;
43
+ assertResumeFile(staged, 2);
44
+ await filesystem.unlink(temporary);
45
+ before = await filesystem.lstat(path);
46
+ }
47
+ assertResumeFile(before);
48
+ const contents = await filesystem.readFile(path, 'utf8');
49
+ const after = await filesystem.lstat(path);
50
+ assertResumeFile(after);
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(contents);
54
+ }
55
+ catch {
56
+ throw new Error('partition directory cleanup resume metadata does not match receipt');
57
+ }
58
+ if (before.dev !== after.dev || before.ino !== after.ino || !sameCleanupReceipt(parsed, receipt))
59
+ throw new Error('partition directory cleanup resume metadata does not match receipt');
60
+ await verifyCleanupReceipt(receipt);
61
+ }
62
+ export async function writeResumeReceipt(receipt) {
63
+ const path = pathFor(receipt);
64
+ const temporary = temporaryPath(receipt);
65
+ let file;
66
+ try {
67
+ file = await filesystem.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o400);
68
+ const { serializeCleanupReceipt } = await import('./snapshot-cleanup-receipt.mjs');
69
+ await file.writeFile(serializeCleanupReceipt(receipt));
70
+ await file.sync();
71
+ await file.close();
72
+ file = undefined;
73
+ await filesystem.link(temporary, path);
74
+ }
75
+ catch (error) {
76
+ if (error.code !== 'EEXIST')
77
+ throw error;
78
+ }
79
+ finally {
80
+ await file?.close();
81
+ await filesystem.unlink(temporary).catch(() => undefined);
82
+ }
83
+ await read(receipt);
84
+ }
85
+ export async function removeResumeReceipt(receipt) {
86
+ try {
87
+ await read(receipt);
88
+ }
89
+ catch (error) {
90
+ if (error.code === 'ENOENT')
91
+ return;
92
+ throw error;
93
+ }
94
+ await filesystem.rm(pathFor(receipt), { force: false });
95
+ }
96
+ export async function requireResumeReceipt(receipt) {
97
+ await read(receipt);
98
+ }