@stage-labs/metro 0.1.0-beta.277 → 0.1.0-beta.279

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.
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
2
+ import { createHash } from 'node:crypto';
3
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
4
5
  import { log } from '@metro-labs/core/log';
5
6
  import { isRecord } from '@metro-labs/core/is-record';
6
7
  import type { StationName } from '@metro-labs/core/station-names';
@@ -16,6 +17,7 @@ export interface RuntimeManifest {
16
17
 
17
18
  export interface RuntimeStore {
18
19
  dir: string;
20
+ sources: string;
19
21
  manifest: RuntimeManifest;
20
22
  }
21
23
 
@@ -24,6 +26,15 @@ const ranges = (raw: unknown): Record<string, string> =>
24
26
  ? Object.fromEntries(Object.entries(raw).filter((e): e is [string, string] => typeof e[1] === 'string'))
25
27
  : {};
26
28
 
29
+ function patchesOf(raw: unknown): Record<string, string> {
30
+ if (!isRecord(raw)) throw new Error('Invalid runtime patch map');
31
+ return Object.fromEntries(Object.entries(raw).map(([name, path]) => {
32
+ if (typeof path !== 'string' || !/^patches\/[a-f0-9]{64}\.patch$/.test(path))
33
+ throw new Error(`Invalid runtime patch path for ${name}`);
34
+ return [name, path];
35
+ }));
36
+ }
37
+
27
38
  export function readManifest(path: string): RuntimeManifest {
28
39
  const raw: unknown = JSON.parse(readFileSync(path, 'utf8'));
29
40
  if (!isRecord(raw)) throw new Error(`${path} is not a runtime manifest`);
@@ -31,7 +42,7 @@ export function readManifest(path: string): RuntimeManifest {
31
42
  return {
32
43
  core: ranges(raw.core),
33
44
  stations: Object.fromEntries(Object.entries(stations).map(([name, deps]) => [name, ranges(deps)])),
34
- ...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: ranges(raw.patchedDependencies) }),
45
+ ...(raw.patchedDependencies === undefined ? {} : { patchedDependencies: patchesOf(raw.patchedDependencies) }),
35
46
  };
36
47
  }
37
48
 
@@ -39,7 +50,7 @@ export function runtimeStore(): RuntimeStore | null {
39
50
  const dir = process.env.METRO_RUNTIME_STORE?.trim() ?? '';
40
51
  const manifest = process.env.METRO_RUNTIME_MANIFEST?.trim() ?? '';
41
52
  if (dir === '' || manifest === '') return null;
42
- return { dir, manifest: readManifest(manifest) };
53
+ return { dir, sources: dirname(manifest), manifest: readManifest(manifest) };
43
54
  }
44
55
 
45
56
  export function dependenciesFor(manifest: RuntimeManifest, stations: Iterable<string>): Record<string, string> {
@@ -72,9 +83,33 @@ function current(dir: string): string | null {
72
83
  }
73
84
  }
74
85
 
86
+ function copyPatches(store: RuntimeStore): void {
87
+ const paths = Object.values(patchesOf(store.manifest.patchedDependencies ?? {}));
88
+ if (paths.length === 0) return;
89
+ const destination = join(store.dir, 'patches');
90
+ mkdirSync(destination, { recursive: true });
91
+ if (!lstatSync(join(store.sources, 'patches')).isDirectory() || !lstatSync(destination).isDirectory())
92
+ throw new Error('Invalid runtime patch directory');
93
+ for (const path of new Set(paths)) {
94
+ const source = join(store.sources, path);
95
+ const target = join(store.dir, path);
96
+ if (!lstatSync(source).isFile() || lstatSync(target, { throwIfNoEntry: false })?.isFile() === false)
97
+ throw new Error(`Invalid runtime patch file: ${path}`);
98
+ const hash = createHash('sha256').update(readFileSync(source)).digest('hex');
99
+ if (path !== `patches/${hash}.patch`) throw new Error(`Invalid runtime patch contents: ${path}`);
100
+ copyFileSync(source, target);
101
+ }
102
+ }
103
+
75
104
  export function installRuntime(store: RuntimeStore, stations: Iterable<string>): boolean {
76
105
  const wanted = packageText(dependenciesFor(store.manifest, stations), store.manifest.patchedDependencies ?? {});
77
106
  const marker = join(store.dir, 'node_modules', '.metro-installed');
107
+ try {
108
+ copyPatches(store);
109
+ } catch (err) {
110
+ rmSync(marker, { force: true });
111
+ throw err;
112
+ }
78
113
  if (existsSync(marker) && current(store.dir) === wanted) return false;
79
114
  rmSync(marker, { force: true });
80
115
  mkdirSync(store.dir, { recursive: true });
@@ -91,8 +126,10 @@ export function installRuntime(store: RuntimeStore, stations: Iterable<string>):
91
126
  return true;
92
127
  }
93
128
 
94
- export function ensureStationDeps(station: StationName): void {
129
+ export function ensureStationDeps(station?: StationName): void {
95
130
  const store = runtimeStore();
96
131
  if (store === null) return;
97
- installRuntime(store, [...knownAccounts().map((a) => a.station), station]);
132
+ const stations = knownAccounts().map((a) => a.station);
133
+ if (station !== undefined) stations.push(station);
134
+ installRuntime(store, stations);
98
135
  }
@@ -3,7 +3,7 @@ import { TrainError } from '@metro-labs/core/train-error';
3
3
  import { supportsHistoryDeletion } from './app-state.js';
4
4
  import { createHistory, type History } from './history.js';
5
5
 
6
- const UNAVAILABLE = 'WhatsApp local history is unavailable because this runtime lacks the deletion patch. Restart the Metro service parent to install the patched runtime; a page Restart is not enough. Prior history was invalidated to avoid stale deleted messages.';
6
+ const UNAVAILABLE = 'WhatsApp local history is unavailable because this runtime lacks the deletion patch. Ask the owner to update and restart Metro to load the patched runtime. Prior history was invalidated to avoid stale deleted messages.';
7
7
  const ignore = (): void => undefined;
8
8
 
9
9
  export function createClientHistory(accountId: string, supported = supportsHistoryDeletion()): History {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.277"
2
+ "version": "0.1.0-beta.279"
3
3
  }
@@ -9,6 +9,8 @@
9
9
  "./events": "./src/events.ts",
10
10
  "./is-record": "./src/is-record.ts",
11
11
  "./runner-activity": "./src/runner-activity.ts",
12
+ "./automation-store": "./src/automation-store.ts",
13
+ "./automation-types": "./src/automation-types.ts",
12
14
  "./call": "./src/call.ts",
13
15
  "./secure-fs": "./src/secure-fs.ts",
14
16
  "./version": "./src/version.ts",
@@ -0,0 +1,216 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs, { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, type Stats } from 'node:fs';
3
+ import { dirname, join, parse, resolve, sep } from 'node:path';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { AutomationError } from './automation-types.js';
6
+ import { isRecord } from './is-record.js';
7
+
8
+ const FILE = 'automation.sqlite';
9
+ const SCHEMA = `
10
+ CREATE TABLE requests (
11
+ uuid TEXT PRIMARY KEY NOT NULL,
12
+ routine TEXT NOT NULL,
13
+ slot INTEGER NOT NULL,
14
+ payload TEXT NOT NULL,
15
+ UNIQUE (routine, slot)
16
+ );
17
+ CREATE TABLE statuses (
18
+ uuid TEXT PRIMARY KEY NOT NULL REFERENCES requests(uuid) ON DELETE CASCADE,
19
+ payload TEXT NOT NULL
20
+ );
21
+ CREATE TABLE resolutions (
22
+ uuid TEXT NOT NULL REFERENCES requests(uuid) ON DELETE CASCADE,
23
+ outcome TEXT NOT NULL,
24
+ payload TEXT NOT NULL,
25
+ PRIMARY KEY (uuid, outcome)
26
+ );
27
+ PRAGMA user_version = 1;
28
+ `;
29
+
30
+ function hasCode(error: unknown, code: string): boolean {
31
+ return isRecord(error) && error.code === code;
32
+ }
33
+
34
+ function metadata(path: string): Stats | null {
35
+ try {
36
+ return lstatSync(path);
37
+ } catch (error) {
38
+ if (hasCode(error, 'ENOENT')) return null;
39
+ throw error;
40
+ }
41
+ }
42
+
43
+ function uid(): number {
44
+ if (process.getuid === undefined) throw new AutomationError('unsafe-storage');
45
+ return process.getuid();
46
+ }
47
+
48
+ function checkDirectory(info: Stats, privateDirectory: boolean): void {
49
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new AutomationError('unsafe-storage');
50
+ if (privateDirectory) {
51
+ if (info.uid !== uid() || (info.mode & 0o7777) !== 0o700) throw new AutomationError('unsafe-storage');
52
+ return;
53
+ }
54
+ if (info.uid !== uid() && info.uid !== 0) throw new AutomationError('unsafe-storage');
55
+ if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) throw new AutomationError('unsafe-storage');
56
+ }
57
+
58
+ function syncDirectory(path: string): void {
59
+ const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
60
+ try {
61
+ fs.fsyncSync(fd);
62
+ } finally {
63
+ closeSync(fd);
64
+ }
65
+ }
66
+
67
+ function directory(root: string, create: boolean): boolean {
68
+ let current = parse(root).root;
69
+ const base = metadata(current);
70
+ if (base === null) throw new AutomationError('unsafe-storage');
71
+ checkDirectory(base, current === root);
72
+ for (const component of root.slice(current.length).split(sep).filter(Boolean)) {
73
+ current = join(current, component);
74
+ let info = metadata(current);
75
+ if (info === null) {
76
+ if (!create) return false;
77
+ try {
78
+ mkdirSync(current, { mode: 0o700 });
79
+ syncDirectory(dirname(current));
80
+ } catch (error) {
81
+ if (!hasCode(error, 'EEXIST')) throw error;
82
+ }
83
+ info = metadata(current);
84
+ }
85
+ if (info === null) throw new AutomationError('unsafe-storage');
86
+ checkDirectory(info, current === root);
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function checkFile(info: Stats, singleLink = false): void {
92
+ if (!info.isFile() || info.uid !== uid() || (info.mode & 0o7777) !== 0o600 || (singleLink && info.nlink !== 1)) throw new AutomationError('unsafe-storage');
93
+ }
94
+
95
+ function companions(path: string): void {
96
+ for (const suffix of ['-journal', '-wal', '-shm']) {
97
+ const info = metadata(`${path}${suffix}`);
98
+ if (info !== null) checkFile(info, true);
99
+ }
100
+ }
101
+
102
+ function sameFile(path: string, opened: Stats): void {
103
+ const current = metadata(path);
104
+ if (current === null || current.dev !== opened.dev || current.ino !== opened.ino) throw new AutomationError('unsafe-storage');
105
+ checkFile(current);
106
+ }
107
+
108
+ function initializeFile(path: string, fd: number): void {
109
+ const opened = fstatSync(fd);
110
+ checkFile(opened);
111
+ const db = new DatabaseSync(path);
112
+ try {
113
+ sameFile(path, opened);
114
+ db.exec('PRAGMA trusted_schema = OFF');
115
+ transaction(db, (database) => { database.exec(SCHEMA); });
116
+ } finally {
117
+ db.close();
118
+ }
119
+ fs.fsyncSync(fd);
120
+ }
121
+
122
+ function createFile(path: string): void {
123
+ if (metadata(path) !== null) return;
124
+ const temporary = `${path}.${randomUUID()}.tmp`;
125
+ const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
126
+ try {
127
+ initializeFile(temporary, fd);
128
+ try {
129
+ fs.linkSync(temporary, path);
130
+ } catch (error) {
131
+ if (!hasCode(error, 'EEXIST')) throw error;
132
+ }
133
+ } finally {
134
+ closeSync(fd);
135
+ fs.unlinkSync(temporary);
136
+ }
137
+ syncDirectory(dirname(path));
138
+ }
139
+
140
+ function transaction<T>(db: DatabaseSync, action: (database: DatabaseSync) => T): T {
141
+ db.exec('PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; BEGIN IMMEDIATE');
142
+ try {
143
+ const result = action(db);
144
+ db.exec('COMMIT');
145
+ return result;
146
+ } catch (error) {
147
+ db.exec('ROLLBACK');
148
+ throw error;
149
+ }
150
+ }
151
+
152
+ function useDatabase<T>(path: string, writable: boolean, action: (database: DatabaseSync) => T): T {
153
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
154
+ let db: DatabaseSync | undefined;
155
+ try {
156
+ const opened = fstatSync(fd);
157
+ checkFile(opened);
158
+ companions(path);
159
+ db = new DatabaseSync(path, { readOnly: !writable });
160
+ sameFile(path, opened);
161
+ db.exec('PRAGMA busy_timeout = 5000; PRAGMA trusted_schema = OFF');
162
+ if (db.prepare('PRAGMA user_version').get()?.user_version !== 1) throw new AutomationError('corrupt-storage');
163
+ if (writable) return transaction(db, action);
164
+ if (db.prepare('PRAGMA journal_mode').get()?.journal_mode !== 'delete') throw new AutomationError('corrupt-storage');
165
+ db.exec('BEGIN');
166
+ try {
167
+ return action(db);
168
+ } finally {
169
+ db.exec('ROLLBACK');
170
+ }
171
+ } finally {
172
+ db?.close();
173
+ closeSync(fd);
174
+ }
175
+ }
176
+
177
+ function databasePath(root: string, writable: boolean, existingOnly: boolean): string | null {
178
+ const create = writable && !existingOnly;
179
+ const absolute = resolve(root);
180
+ if (!directory(absolute, create)) return null;
181
+ const path = join(absolute, FILE);
182
+ companions(path);
183
+ if (create) createFile(path);
184
+ const info = metadata(path);
185
+ if (info === null) {
186
+ if (create) throw new AutomationError('storage-unavailable');
187
+ return null;
188
+ }
189
+ checkFile(info);
190
+ return path;
191
+ }
192
+
193
+ export function validateAutomationDirectory(root: string): void {
194
+ try {
195
+ databasePath(root, false, false);
196
+ } catch (error) {
197
+ if (error instanceof AutomationError) throw error;
198
+ throw new AutomationError('storage-unavailable', { cause: error });
199
+ }
200
+ }
201
+
202
+ export function automationDatabase<T>(root: string, writable: boolean, action: (database: DatabaseSync) => T, missing: T, existingOnly = false): T {
203
+ try {
204
+ const path = databasePath(root, writable, existingOnly);
205
+ if (path === null) return missing;
206
+ const result = useDatabase(path, writable, action);
207
+ if (writable) {
208
+ companions(path);
209
+ syncDirectory(dirname(path));
210
+ }
211
+ return result;
212
+ } catch (error) {
213
+ if (error instanceof AutomationError) throw error;
214
+ throw new AutomationError('storage-unavailable', { cause: error });
215
+ }
216
+ }
@@ -0,0 +1,171 @@
1
+ import { resolve } from 'node:path';
2
+ import type { DatabaseSync } from 'node:sqlite';
3
+ import { automationDatabase, validateAutomationDirectory } from './automation-files.js';
4
+ import {
5
+ AUTOMATION_REQUEST_MAX, AUTOMATION_RETENTION_MS, AutomationError, assertAutomationUuid,
6
+ automationRequest, automationTerminal, parseAutomationRequest, parseAutomationResolution, parseAutomationStatus,
7
+ type AutomationOutcome, type AutomationRequest, type AutomationResolution, type AutomationStatus,
8
+ } from './automation-types.js';
9
+ import { isRecord } from './is-record.js';
10
+
11
+ export type { AutomationOutcome, AutomationRequest, AutomationResolution, AutomationStatus, AutomationState, AutomationUuid } from './automation-types.js';
12
+
13
+ function payload<T extends { uuid: string }>(row: unknown, parse: (value: unknown) => T): T {
14
+ try {
15
+ if (!isRecord(row) || typeof row.payload !== 'string') throw new AutomationError('corrupt-storage');
16
+ const value: unknown = JSON.parse(row.payload);
17
+ const parsed = parse(value);
18
+ if (parsed.uuid !== row.uuid) throw new AutomationError('corrupt-storage');
19
+ return parsed;
20
+ } catch (error) {
21
+ throw new AutomationError('corrupt-storage', { cause: error });
22
+ }
23
+ }
24
+
25
+ function request(row: unknown): AutomationRequest {
26
+ const parsed = payload(row, parseAutomationRequest);
27
+ if (!isRecord(row) || parsed.routine !== row.routine || parsed.slot !== row.slot) throw new AutomationError('corrupt-storage');
28
+ return parsed;
29
+ }
30
+
31
+ function requests(db: DatabaseSync): AutomationRequest[] {
32
+ const rows = db.prepare('SELECT * FROM requests ORDER BY slot, routine LIMIT 513').all();
33
+ if (rows.length > AUTOMATION_REQUEST_MAX) throw new AutomationError('corrupt-storage');
34
+ return rows.map(request);
35
+ }
36
+
37
+ function status(db: DatabaseSync, uuid: string): AutomationStatus | null {
38
+ const row = db.prepare('SELECT * FROM statuses WHERE uuid = ?').get(uuid);
39
+ return row === undefined ? null : payload(row, parseAutomationStatus);
40
+ }
41
+
42
+ function statuses(db: DatabaseSync): AutomationStatus[] {
43
+ const rows = db.prepare('SELECT * FROM statuses LIMIT 513').all();
44
+ if (rows.length > AUTOMATION_REQUEST_MAX) throw new AutomationError('corrupt-storage');
45
+ return rows.map((row) => payload(row, parseAutomationStatus));
46
+ }
47
+
48
+ function resolution(row: unknown): AutomationResolution {
49
+ const parsed = payload(row, parseAutomationResolution);
50
+ if (!isRecord(row) || parsed.outcome !== row.outcome) throw new AutomationError('corrupt-storage');
51
+ return parsed;
52
+ }
53
+
54
+ function resolutions(db: DatabaseSync): AutomationResolution[] {
55
+ const rows = db.prepare('SELECT * FROM resolutions ORDER BY uuid, outcome LIMIT 1025').all();
56
+ if (rows.length > 2 * AUTOMATION_REQUEST_MAX) throw new AutomationError('corrupt-storage');
57
+ return rows.map(resolution);
58
+ }
59
+
60
+ function requireRequest(db: DatabaseSync, uuid: string): AutomationRequest {
61
+ const row = db.prepare('SELECT * FROM requests WHERE uuid = ?').get(uuid);
62
+ if (row === undefined) throw new AutomationError('unknown-request');
63
+ return request(row);
64
+ }
65
+
66
+ function validate(db: DatabaseSync): void {
67
+ const known = new Set(requests(db).map((entry) => entry.uuid));
68
+ for (const entry of [...statuses(db), ...resolutions(db)]) {
69
+ if (!known.has(entry.uuid)) throw new AutomationError('corrupt-storage');
70
+ }
71
+ if (db.prepare('PRAGMA quick_check').get()?.quick_check !== 'ok') throw new AutomationError('corrupt-storage');
72
+ }
73
+
74
+ function expiredRequests(db: DatabaseSync, cutoff: number): AutomationRequest[] {
75
+ const saved = new Map(statuses(db).map((entry) => [entry.uuid, entry]));
76
+ return requests(db).filter((entry) => {
77
+ const current = saved.get(entry.uuid);
78
+ return current !== undefined && automationTerminal(current.state) && entry.slot < cutoff && current.updatedAt < cutoff;
79
+ });
80
+ }
81
+
82
+ export class AutomationStore {
83
+ readonly root: string;
84
+
85
+ constructor(root: string, private readonly now: () => number = Date.now) {
86
+ this.root = resolve(root);
87
+ }
88
+
89
+ validate(): void {
90
+ validateAutomationDirectory(this.root);
91
+ }
92
+
93
+ recover(): void {
94
+ automationDatabase(this.root, true, validate, undefined, true);
95
+ }
96
+
97
+ submit(routine: string, slot: number, prompt: string): AutomationRequest {
98
+ const proposed = automationRequest(routine, slot, prompt, this.now());
99
+ return automationDatabase(this.root, true, (db) => {
100
+ const accepted = automationRequest(routine, slot, prompt, this.now());
101
+ const existing = requests(db);
102
+ const found = existing.find((entry) => entry.routine === routine && entry.slot === slot);
103
+ if (found !== undefined) {
104
+ if (found.prompt !== prompt) throw new AutomationError('conflict');
105
+ return found;
106
+ }
107
+ if (existing.length >= AUTOMATION_REQUEST_MAX) throw new AutomationError('full');
108
+ db.prepare('INSERT INTO requests (uuid, routine, slot, payload) VALUES (?, ?, ?, ?)')
109
+ .run(accepted.uuid, routine, slot, JSON.stringify(accepted));
110
+ return accepted;
111
+ }, proposed);
112
+ }
113
+
114
+ requests(): AutomationRequest[] {
115
+ return automationDatabase(this.root, false, requests, []);
116
+ }
117
+
118
+ status(uuid: string): AutomationStatus | null {
119
+ assertAutomationUuid(uuid);
120
+ return automationDatabase(this.root, false, (db) => status(db, uuid), null);
121
+ }
122
+
123
+ statuses(): AutomationStatus[] {
124
+ return automationDatabase(this.root, false, statuses, []);
125
+ }
126
+
127
+ saveStatus(value: AutomationStatus): void {
128
+ const parsed = parseAutomationStatus(value);
129
+ automationDatabase(this.root, true, (db) => {
130
+ requireRequest(db, parsed.uuid);
131
+ db.prepare('INSERT INTO statuses (uuid, payload) VALUES (?, ?) ON CONFLICT(uuid) DO UPDATE SET payload = excluded.payload')
132
+ .run(parsed.uuid, JSON.stringify(parsed));
133
+ }, undefined);
134
+ }
135
+
136
+ finish(uuid: string, token: string, outcome: AutomationOutcome): AutomationResolution {
137
+ const proposed = parseAutomationResolution({ uuid, token, outcome, createdAt: this.now() });
138
+ const current = this.status(uuid);
139
+ if (current === null) throw new AutomationError('unknown-request');
140
+ if (current.token !== token) throw new AutomationError('invalid-token');
141
+ return automationDatabase(this.root, true, (db) => {
142
+ requireRequest(db, uuid);
143
+ if (status(db, uuid)?.token !== token) throw new AutomationError('invalid-token');
144
+ const row = db.prepare('SELECT * FROM resolutions WHERE uuid = ? AND outcome = ?').get(uuid, outcome);
145
+ if (row !== undefined) {
146
+ const found = resolution(row);
147
+ if (found.token !== token) throw new AutomationError('invalid-token');
148
+ return found;
149
+ }
150
+ db.prepare('INSERT INTO resolutions (uuid, outcome, payload) VALUES (?, ?, ?)').run(uuid, outcome, JSON.stringify(proposed));
151
+ return proposed;
152
+ }, proposed);
153
+ }
154
+
155
+ resolutions(): AutomationResolution[] {
156
+ return automationDatabase(this.root, false, resolutions, []);
157
+ }
158
+
159
+ prune(): number {
160
+ const cutoff = this.now() - AUTOMATION_RETENTION_MS;
161
+ if (!Number.isSafeInteger(cutoff)) throw new AutomationError('invalid-slot');
162
+ const eligible = automationDatabase(this.root, false, (db) => expiredRequests(db, cutoff).length > 0, false);
163
+ if (!eligible) return 0;
164
+ return automationDatabase(this.root, true, (db) => {
165
+ validate(db);
166
+ const expired = expiredRequests(db, cutoff);
167
+ for (const entry of expired) db.prepare('DELETE FROM requests WHERE uuid = ?').run(entry.uuid);
168
+ return expired.length;
169
+ }, 0);
170
+ }
171
+ }
@@ -0,0 +1,141 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isRecord } from './is-record.js';
3
+
4
+ export const AUTOMATION_PROMPT_MAX = 64 * 1024;
5
+ const PROMPT_JSON_MAX = 128 * 1024 + 2;
6
+ export const AUTOMATION_REQUEST_MAX = 512;
7
+ export const AUTOMATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
8
+ export const AUTOMATION_ROUTINE_RE = /^[a-z][a-z0-9-]{0,63}$/;
9
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
10
+ const STATES = ['dispatched', 'consumed', 'awaiting-completion', 'completed', 'failed', 'interrupted', 'coalesced', 'cancelled'] as const;
11
+ const DETAILS = ['sdk-completed', 'sdk-failed', 'runner-interrupted', 'newer-slot', 'owner-cancelled', 'worker-blocked', 'worker-completed'] as const;
12
+ const TERMINAL = new Set<AutomationState>(['completed', 'coalesced', 'cancelled']);
13
+ const ERRORS = {
14
+ 'invalid-routine': 'Routine must be a lowercase name of at most 64 characters.',
15
+ 'invalid-slot': 'Slot must be an integer timestamp within the past seven days or next five minutes.',
16
+ 'invalid-prompt': 'Prompt must be text no larger than 64 KiB and 128 KiB plus quotes when JSON-encoded.',
17
+ 'invalid-uuid': 'Automation identifier must be a canonical UUID.',
18
+ 'invalid-request': 'Automation request is invalid.',
19
+ 'invalid-status': 'Automation status is invalid.',
20
+ 'invalid-resolution': 'Automation completion receipt is invalid.',
21
+ conflict: 'This routine and slot already have a different prompt.',
22
+ full: 'Automation queue is full. Only old terminal requests may be pruned.',
23
+ 'unknown-request': 'Automation request does not exist.',
24
+ 'invalid-token': 'Completion token does not match the dispatched request.',
25
+ 'unsafe-storage': 'Automation storage must be private, owned by this user, and free of symlinks.',
26
+ 'corrupt-storage': 'Automation storage is corrupt. No requests were discarded.',
27
+ 'storage-unavailable': 'Automation storage is unavailable. No acceptance is confirmed.',
28
+ } as const;
29
+
30
+ export type AutomationUuid = `${string}-${string}-${string}-${string}-${string}`;
31
+ export type AutomationState = (typeof STATES)[number];
32
+ export type AutomationDetail = (typeof DETAILS)[number];
33
+ export type AutomationOutcome = 'completed' | 'blocked';
34
+ export type AutomationErrorCode = keyof typeof ERRORS;
35
+
36
+ export class AutomationError extends Error {
37
+ constructor(readonly code: AutomationErrorCode, options?: ErrorOptions) {
38
+ super(ERRORS[code], options);
39
+ this.name = 'AutomationError';
40
+ }
41
+ }
42
+
43
+ export interface AutomationRequest {
44
+ version: 1;
45
+ routine: string;
46
+ slot: number;
47
+ uuid: AutomationUuid;
48
+ createdAt: number;
49
+ prompt: string;
50
+ digest: string;
51
+ }
52
+
53
+ export interface AutomationStatus {
54
+ uuid: AutomationUuid;
55
+ state: AutomationState;
56
+ updatedAt: number;
57
+ token: AutomationUuid;
58
+ detail?: AutomationDetail;
59
+ }
60
+
61
+ export interface AutomationResolution {
62
+ uuid: AutomationUuid;
63
+ token: AutomationUuid;
64
+ outcome: AutomationOutcome;
65
+ createdAt: number;
66
+ }
67
+
68
+ export function assertRoutine(routine: unknown): asserts routine is string {
69
+ if (typeof routine !== 'string' || !AUTOMATION_ROUTINE_RE.test(routine)) throw new AutomationError('invalid-routine');
70
+ }
71
+
72
+ export function assertAutomationUuid(uuid: unknown): asserts uuid is AutomationUuid {
73
+ if (typeof uuid !== 'string' || !UUID_RE.test(uuid)) throw new AutomationError('invalid-uuid');
74
+ }
75
+
76
+ function timestamp(value: unknown): value is number {
77
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 8_640_000_000_000_000;
78
+ }
79
+
80
+ function promptValid(value: unknown): value is string {
81
+ return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= AUTOMATION_PROMPT_MAX && Buffer.byteLength(JSON.stringify(value), 'utf8') <= PROMPT_JSON_MAX;
82
+ }
83
+
84
+ function validSlot(slot: number, now: number): boolean {
85
+ return slot >= now - AUTOMATION_RETENTION_MS && slot <= now + 300_000;
86
+ }
87
+
88
+ function hash(text: string): string {
89
+ return createHash('sha256').update(text).digest('hex');
90
+ }
91
+
92
+ function requestUuid(routine: string, slot: number): AutomationUuid {
93
+ const bytes = createHash('sha256').update(`${routine}\n${slot}`).digest();
94
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x80;
95
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
96
+ const hex = bytes.subarray(0, 16).toString('hex');
97
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
98
+ }
99
+
100
+ export function automationRequest(routine: string, slot: number, prompt: string, now = Date.now()): AutomationRequest {
101
+ assertRoutine(routine);
102
+ if (!timestamp(now) || !timestamp(slot) || !validSlot(slot, now)) throw new AutomationError('invalid-slot');
103
+ if (!promptValid(prompt)) throw new AutomationError('invalid-prompt');
104
+ return { version: 1, routine, slot, uuid: requestUuid(routine, slot), createdAt: now, prompt, digest: hash(prompt) };
105
+ }
106
+
107
+ function fields(raw: Record<string, unknown>, required: string[], optional: string[] = []): boolean {
108
+ return required.every((key) => Object.hasOwn(raw, key)) && Object.keys(raw).every((key) => required.includes(key) || optional.includes(key));
109
+ }
110
+
111
+ export function parseAutomationRequest(raw: unknown): AutomationRequest {
112
+ if (!isRecord(raw) || !fields(raw, ['version', 'routine', 'slot', 'uuid', 'createdAt', 'prompt', 'digest'])) throw new AutomationError('invalid-request');
113
+ assertRoutine(raw.routine);
114
+ assertAutomationUuid(raw.uuid);
115
+ if (raw.version !== 1 || !timestamp(raw.slot) || !timestamp(raw.createdAt) || !promptValid(raw.prompt)) throw new AutomationError('invalid-request');
116
+ if (!validSlot(raw.slot, raw.createdAt) || raw.uuid !== requestUuid(raw.routine, raw.slot) || raw.digest !== hash(raw.prompt)) throw new AutomationError('invalid-request');
117
+ return { version: 1, routine: raw.routine, slot: raw.slot, uuid: raw.uuid, createdAt: raw.createdAt, prompt: raw.prompt, digest: raw.digest };
118
+ }
119
+
120
+ export function parseAutomationStatus(raw: unknown): AutomationStatus {
121
+ if (!isRecord(raw) || !fields(raw, ['uuid', 'state', 'updatedAt', 'token'], ['detail'])) throw new AutomationError('invalid-status');
122
+ assertAutomationUuid(raw.uuid);
123
+ assertAutomationUuid(raw.token);
124
+ const state = STATES.find((entry) => entry === raw.state);
125
+ if (state === undefined || !timestamp(raw.updatedAt)) throw new AutomationError('invalid-status');
126
+ const detail = DETAILS.find((entry) => entry === raw.detail);
127
+ if (raw.detail !== undefined && detail === undefined) throw new AutomationError('invalid-status');
128
+ return { uuid: raw.uuid, state, updatedAt: raw.updatedAt, token: raw.token, ...(detail === undefined ? {} : { detail }) };
129
+ }
130
+
131
+ export function parseAutomationResolution(raw: unknown): AutomationResolution {
132
+ if (!isRecord(raw) || !fields(raw, ['uuid', 'token', 'outcome', 'createdAt'])) throw new AutomationError('invalid-resolution');
133
+ assertAutomationUuid(raw.uuid);
134
+ assertAutomationUuid(raw.token);
135
+ if (!timestamp(raw.createdAt) || (raw.outcome !== 'completed' && raw.outcome !== 'blocked')) throw new AutomationError('invalid-resolution');
136
+ return { uuid: raw.uuid, token: raw.token, outcome: raw.outcome, createdAt: raw.createdAt };
137
+ }
138
+
139
+ export function automationTerminal(state: AutomationState): boolean {
140
+ return TERMINAL.has(state);
141
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.277",
2
+ "version": "0.1.0-beta.279",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -1,4 +1,6 @@
1
+ import { dirname, join } from 'node:path';
1
2
  import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk';
3
+ import { AutomationStore } from '@metro-labs/core/automation-store';
2
4
  import { errMsg, log } from '@metro-labs/core/log';
3
5
  import { failureSummary, type Activity } from './activity.js';
4
6
  import { approvalsThrough } from './approvals.js';
@@ -27,7 +29,8 @@ export interface AppHooks {
27
29
  async function connected(cfg: RunnerConfig, hooks: AppHooks, release: () => Promise<void>): Promise<RunningAgent> {
28
30
  const store = new SessionStore(cfg.statePath, cfg.claudeDir, cfg.cwd);
29
31
  let tools: MetroTools | null = null;
30
- const runner = new Runner({ store, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
32
+ const automationStore = new AutomationStore(join(dirname(cfg.statePath), 'automation'));
33
+ const runner = new Runner({ store, automationStore, readOnly: (tool) => tools?.readOnly(tool) ?? false, ...hooks });
31
34
  const link = await MetroLink.open(cfg.mcpUrl, cfg.key, {
32
35
  channel: (event) => { runner.chat(event); },
33
36
  call: (notice) => { runner.calls.notice(notice); },