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

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.
@@ -0,0 +1,56 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { findBun, runtimeDir } from './runtime.js';
5
+ const TASK_HELP = `metro task: queue work for the existing Agent SDK conversation
6
+
7
+ metro task submit <routine> <prompt-file> [--slot <UTC ISO timestamp>] [--dry-run]
8
+ queue a local prompt file (regular UTF-8 file, at most 64 KiB);
9
+ default slot is the current UTC hour; use --slot for other cadences;
10
+ accepted means saved, not executed; duplicates show their current state;
11
+ --dry-run writes nothing
12
+ metro task status [routine]
13
+ read safe local metadata, including the token needed to finish;
14
+ never print prompts
15
+ metro task finish <uuid> <token> completed|blocked
16
+ save a receipt pending runner verification, not mark work complete
17
+
18
+ These commands never start a session, install a runtime or connect to the daemon.
19
+ Local task authorization is not fresh owner approval for external actions.
20
+ `;
21
+ function taskRuntime() {
22
+ let dir;
23
+ try {
24
+ dir = runtimeDir();
25
+ }
26
+ catch {
27
+ throw new Error('metro task needs the shipped runtime; reinstall @stage-labs/metro@beta');
28
+ }
29
+ const entry = join(dir, 'sdk-runner', 'src', 'automation-cli.ts');
30
+ if (!existsSync(entry))
31
+ throw new Error('metro task is missing its shipped entry; reinstall @stage-labs/metro@beta');
32
+ try {
33
+ return { bun: findBun(), entry };
34
+ }
35
+ catch {
36
+ throw new Error('metro task needs Bun on PATH; it never installs Bun or the runner');
37
+ }
38
+ }
39
+ export async function automationCommand(args) {
40
+ if (args.length === 0 || (args.length === 1 && ['help', '--help', '-h'].includes(args[0] ?? ''))) {
41
+ process.stderr.write(TASK_HELP);
42
+ return args.length === 0 ? 1 : 0;
43
+ }
44
+ const { bun, entry } = taskRuntime();
45
+ return new Promise((resolve) => {
46
+ const child = spawn(bun, ['--no-install', entry, ...args], {
47
+ stdio: ['ignore', 'inherit', 'inherit'],
48
+ env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: '0' },
49
+ });
50
+ child.once('error', () => {
51
+ process.stderr.write('metro task: unable to run the shipped task command\n');
52
+ resolve(1);
53
+ });
54
+ child.once('exit', (code) => { resolve(code ?? 1); });
55
+ });
56
+ }
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { whoisAuthorized } from './api.js';
3
3
  import { stopAll } from './control.js';
4
4
  import { tailEvents } from './tail.js';
5
5
  import { agentSession } from './agent.js';
6
+ import { automationCommand } from './automation.js';
6
7
  import { launchClaude } from './claude.js';
7
8
  import { memoryPaths, memoryRoutine } from './memory.js';
8
9
  import { update } from './update.js';
@@ -40,6 +41,13 @@ const USAGE = `metro: run your agent on this machine
40
41
  metro agent --prepare [cli|sdk]
41
42
  check the runner before selecting it; install the SDK runtime if needed,
42
43
  without starting a session or connecting to chat
44
+ metro task submit <routine> <prompt-file> [--slot <UTC ISO timestamp>] [--dry-run]
45
+ metro task status [routine]
46
+ metro task finish <uuid> <token> completed|blocked
47
+ queue work without starting a session, or read safe local task metadata;
48
+ default slot is the current UTC hour; use --slot for other cadences;
49
+ accepted is saved, not executed; finish awaits runner verification;
50
+ --dry-run writes nothing; local authorization is not fresh owner approval
43
51
  metro memory [skill file]
44
52
  run the memory routine once: when Claude Code transcripts changed since
45
53
  the last run, Claude Code reads the memory skill (or the file given) and
@@ -85,6 +93,7 @@ const COMMANDS = {
85
93
  tail: () => tailEvents(process.argv.slice(3)),
86
94
  claude: () => launchClaude(process.argv.slice(3)),
87
95
  agent: () => agentSession(process.argv.slice(3)),
96
+ task: () => automationCommand(process.argv.slice(3)),
88
97
  memory: () => memoryRoutine(memoryPaths(process.env, process.argv[3])),
89
98
  voice: () => voiceSession(process.argv.slice(3)),
90
99
  update: () => update(process.argv.slice(3)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.277",
3
+ "version": "0.1.0-beta.278",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.277",
3
+ "version": "0.1.0-beta.278",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -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,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.277"
2
+ "version": "0.1.0-beta.278"
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",