@zgeoff/atc 2.12.0 → 2.13.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 (71) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +48 -11
  4. package/src/agents/build-atc-bridge-files.ts +13 -0
  5. package/src/agents/build-claude-query-options.ts +72 -0
  6. package/src/agents/build-cli-command.ts +7 -5
  7. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  8. package/src/agents/build-hook-settings.ts +8 -2
  9. package/src/agents/build-restore-mode-args.ts +23 -0
  10. package/src/agents/claude-adapter.ts +64 -17
  11. package/src/agents/codex-adapter.ts +5 -0
  12. package/src/agents/find-claude-permission-mode.ts +24 -0
  13. package/src/agents/gateway-adapter.ts +38 -11
  14. package/src/agents/grok-adapter.ts +5 -0
  15. package/src/agents/make-claude-headless-runner.ts +46 -0
  16. package/src/agents/plan-pasted-line-input.ts +22 -0
  17. package/src/agents/plan-typed-line-input.ts +8 -0
  18. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  19. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
  20. package/src/agents/write-atc-bridge.ts +2 -6
  21. package/src/cli.ts +8 -12
  22. package/src/client/index.ts +50 -1
  23. package/src/client/ui.ts +8 -0
  24. package/src/daemon/build-execution-targets.ts +49 -10
  25. package/src/daemon/build-imp-provider.ts +68 -0
  26. package/src/daemon/build-report-trail-entry.ts +4 -2
  27. package/src/daemon/build-scoped-context.ts +4 -1
  28. package/src/daemon/build-session-lifecycle.ts +52 -0
  29. package/src/daemon/build-tar-archive.ts +85 -0
  30. package/src/daemon/daemon-connection.ts +97 -4
  31. package/src/daemon/daemon.ts +298 -41
  32. package/src/daemon/execution-provider.ts +109 -4
  33. package/src/daemon/hooks.ts +5 -17
  34. package/src/daemon/imp-client-port.ts +343 -0
  35. package/src/daemon/imp-harness.ts +618 -0
  36. package/src/daemon/imp-port-error.ts +18 -0
  37. package/src/daemon/imp-port.ts +246 -0
  38. package/src/daemon/imp-provider.ts +601 -0
  39. package/src/daemon/is-binding-current.ts +43 -0
  40. package/src/daemon/local-pty-provider.ts +62 -3
  41. package/src/daemon/materialize-workspace.ts +571 -0
  42. package/src/daemon/parse-hook-line.ts +31 -0
  43. package/src/daemon/pick-session-state.ts +15 -0
  44. package/src/daemon/restore-fleet.ts +41 -23
  45. package/src/daemon/screen-model.ts +7 -0
  46. package/src/daemon/sessions.ts +574 -61
  47. package/src/daemon/start-headless-turn.ts +1 -1
  48. package/src/daemon/start-session-bridge.ts +294 -0
  49. package/src/mcp/mcp-tools.ts +9 -2
  50. package/src/mcp/require-daemon-features.ts +3 -0
  51. package/src/mcp/run-tool.ts +27 -8
  52. package/src/protocol/daemon-features.ts +11 -0
  53. package/src/protocol/protocol.ts +23 -0
  54. package/src/protocol/request-param-schemas.ts +66 -0
  55. package/src/report.ts +37 -2
  56. package/src/run-bridge-tap.ts +241 -0
  57. package/src/shared/collect-clean-env.ts +9 -1
  58. package/src/shared/open-bridge-socket.ts +86 -0
  59. package/src/shared/send-bridge-request.ts +47 -0
  60. package/src/statusline.ts +34 -3
  61. package/src/store/fleet-entry.ts +19 -0
  62. package/src/store/run-migrations.ts +76 -0
  63. package/src/store/state-store.ts +182 -21
  64. package/src/store/trail-entry.ts +4 -0
  65. package/src/store/workspace-materialization.ts +70 -0
  66. package/src/tap.ts +11 -0
  67. package/src/workspace/check-url-credentials.ts +59 -0
  68. package/src/workspace/create-workspace-clone.ts +13 -1
  69. package/src/workspace/repository-env-vars.ts +22 -0
  70. package/src/workspace/run-git.ts +2 -18
  71. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -0,0 +1,241 @@
1
+ import { readFileSync, readdirSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { openBridgeSocket } from './shared/open-bridge-socket';
5
+ import type { BridgeSocket } from './shared/open-bridge-socket';
6
+
7
+ const INBOX_MESSAGE_SCHEMA = z.looseObject({
8
+ ev: z.literal('InboxMessage'),
9
+ message: z.string(),
10
+ from: z.string(),
11
+ text: z.string(),
12
+ sentAt: z.number(),
13
+ });
14
+
15
+ const OUTBOX_REPORT_SCHEMA = z.object({
16
+ reportID: z.string().min(1),
17
+ payload: z.record(z.string(), z.unknown()),
18
+ });
19
+
20
+ // The wait before the first reconnect, doubling up to the longest.
21
+ const FIRST_RETRY_MS = 250;
22
+ const LONGEST_RETRY_MS = 5000;
23
+
24
+ // How many message ids the tap remembers having printed.
25
+ const SEEN_LIMIT = 10_000;
26
+
27
+ // How one connection to the bridge ended: the inbox closed for good, the
28
+ // bridge refused the tap, or the connection dropped after or before it
29
+ // opened the tap.
30
+ type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
31
+
32
+ /**
33
+ * Streams the session's inbox to stdout from inside a remote host, through
34
+ * the session bridge at the given socket. It prints each message once as
35
+ * one NDJSON line and acks it after the line is written. A dropped
36
+ * connection, as a host's sleep or a daemon restart leaves it, reconnects
37
+ * with a growing wait of at most 5 seconds and no end; each new connection
38
+ * replays the messages not yet acked, and one already printed is acked
39
+ * again and not printed twice. Each connection also sends the reports the
40
+ * outbox still holds. Exits 0 once the inbox closes and 1 when the bridge
41
+ * refuses the tap.
42
+ */
43
+ export async function runBridgeTap(socketPath: string, outbox: string): Promise<void> {
44
+ const seen = new Set<string>();
45
+
46
+ let wait = FIRST_RETRY_MS;
47
+
48
+ for (;;) {
49
+ const end = await runTapConnection(socketPath, outbox, seen);
50
+
51
+ if (end === 'closed') {
52
+ process.exit(0);
53
+ }
54
+
55
+ if (end === 'refused') {
56
+ console.error('atc tap: the session bridge refused the tap');
57
+ process.exit(1);
58
+ }
59
+
60
+ if (end === 'lost') {
61
+ wait = FIRST_RETRY_MS;
62
+ }
63
+
64
+ await Bun.sleep(wait);
65
+
66
+ wait = Math.min(wait * 2, LONGEST_RETRY_MS);
67
+ }
68
+ }
69
+
70
+ async function runTapConnection(
71
+ socketPath: string,
72
+ outbox: string,
73
+
74
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the ids the tap printed, which every connection adds to
75
+ seen: Set<string>,
76
+ ): Promise<TapEnd> {
77
+ const ended = Promise.withResolvers<TapEnd>();
78
+ let opened = false;
79
+ let printing: Promise<void> = Promise.resolve();
80
+ let socket: BridgeSocket;
81
+
82
+ // The outbox file behind each report this connection sent, by request id.
83
+ let sent = new Map<string, string>();
84
+
85
+ const onLine = (line: Readonly<Record<string, unknown>>) => {
86
+ if (line['ev'] === 'InboxClosed') {
87
+ ended.resolve('closed');
88
+
89
+ return;
90
+ }
91
+
92
+ if (line['id'] === 'tap.open') {
93
+ if (line['ok'] !== true) {
94
+ ended.resolve('refused');
95
+
96
+ return;
97
+ }
98
+
99
+ opened = true;
100
+ sent = sendOutboxReports(socket, outbox);
101
+
102
+ return;
103
+ }
104
+
105
+ // A report the bridge took, or one it refuses outright, leaves the
106
+ // outbox: no resend would change the answer. Only the file this
107
+ // connection sent under the answered id is removed.
108
+ if (typeof line['id'] === 'string' && line['id'].startsWith('report:')) {
109
+ const path = sent.get(line['id']);
110
+
111
+ if (path !== undefined && (line['ok'] === true || line['code'] === 'forbidden')) {
112
+ sent.delete(line['id']);
113
+
114
+ rmSync(path, { force: true });
115
+ }
116
+
117
+ return;
118
+ }
119
+
120
+ if (line['ok'] === false && line['code'] === 'stale_binding') {
121
+ ended.resolve('refused');
122
+
123
+ return;
124
+ }
125
+
126
+ const msg = INBOX_MESSAGE_SCHEMA.safeParse(line);
127
+
128
+ if (!msg.success) {
129
+ return;
130
+ }
131
+
132
+ const message = msg.data;
133
+ const previous = printing;
134
+
135
+ printing = (async () => {
136
+ await previous;
137
+
138
+ if (!seen.has(message.message)) {
139
+ const printed = `${JSON.stringify({ id: message.message, from: message.from, text: message.text, sentAt: message.sentAt })}\n`;
140
+
141
+ try {
142
+ await Bun.write(Bun.stdout, printed);
143
+ } catch {
144
+ process.exit(1);
145
+ }
146
+
147
+ updateSeenIDs(seen, message.message);
148
+ }
149
+
150
+ socket.writeLine({
151
+ v: 1,
152
+ id: `ack:${message.message}`,
153
+ op: 'tap.ack',
154
+ message: message.message,
155
+ });
156
+ })();
157
+ };
158
+
159
+ try {
160
+ socket = await openBridgeSocket(socketPath, onLine);
161
+ } catch {
162
+ return 'unreachable';
163
+ }
164
+
165
+ void (async () => {
166
+ await socket.closed;
167
+
168
+ const end: TapEnd = opened ? 'lost' : 'unreachable';
169
+
170
+ ended.resolve(end);
171
+ })();
172
+
173
+ socket.writeLine({ v: 1, id: 'tap.open', op: 'tap.open' });
174
+
175
+ const end = await ended.promise;
176
+
177
+ await printing.catch(() => {});
178
+
179
+ socket.end();
180
+
181
+ return end;
182
+ }
183
+
184
+ // Sends every report the outbox holds, and returns the file behind each
185
+ // request id it sent; a file that is no report is removed, since no answer
186
+ // would ever clear it.
187
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket is a live handle
188
+ function sendOutboxReports(socket: BridgeSocket, outbox: string): Map<string, string> {
189
+ const sent = new Map<string, string>();
190
+
191
+ let files: string[];
192
+
193
+ try {
194
+ files = readdirSync(outbox).filter((file) => file.endsWith('.json'));
195
+ } catch {
196
+ return sent;
197
+ }
198
+
199
+ for (const file of files) {
200
+ const path = join(outbox, file);
201
+ let report: z.infer<typeof OUTBOX_REPORT_SCHEMA> | null = null;
202
+
203
+ try {
204
+ const parsed = OUTBOX_REPORT_SCHEMA.safeParse(JSON.parse(readFileSync(path, 'utf8')));
205
+
206
+ report = parsed.success ? parsed.data : null;
207
+ } catch {}
208
+
209
+ if (report === null) {
210
+ rmSync(path, { force: true });
211
+ continue;
212
+ }
213
+
214
+ const id = `report:${report.reportID}`;
215
+
216
+ sent.set(id, path);
217
+
218
+ socket.writeLine({
219
+ v: 1,
220
+ id,
221
+ op: 'report',
222
+ reportID: report.reportID,
223
+ payload: report.payload,
224
+ });
225
+ }
226
+
227
+ return sent;
228
+ }
229
+
230
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the set grows in place
231
+ function updateSeenIDs(seen: Set<string>, id: string): void {
232
+ seen.add(id);
233
+
234
+ for (const oldest of seen) {
235
+ if (seen.size <= SEEN_LIMIT) {
236
+ break;
237
+ }
238
+
239
+ seen.delete(oldest);
240
+ }
241
+ }
@@ -3,14 +3,22 @@
3
3
  * scrubbed out. A child that inherits those variables behaves as part of
4
4
  * the parent session — Claude transcripts nest, and Grok joins the
5
5
  * parent's in-process dashboard — which breaks resume and isolation.
6
+ *
7
+ * Variables named in `withheld` are left out of what the child inherits,
8
+ * such as a workspace credential that only the daemon's own git commands
9
+ * may see. The extras the caller sets explicitly are added after, so one
10
+ * of them is kept even when it shares a withheld name.
6
11
  */
7
12
  export function collectCleanEnv(
8
13
  extra: Readonly<Record<string, string>> = {},
14
+ withheld: readonly string[] = [],
9
15
  ): Record<string, string> {
10
16
  const env: Record<string, string> = {};
11
17
 
18
+ const skipped = new Set(withheld);
19
+
12
20
  for (const [key, value] of Object.entries(process.env)) {
13
- if (value === undefined || isParentSessionKey(key)) {
21
+ if (value === undefined || isParentSessionKey(key) || skipped.has(key)) {
14
22
  continue;
15
23
  }
16
24
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * One line-framed connection to a session bridge, from inside a remote
3
+ * session: each line the bridge writes arrives whole, and each line written
4
+ * here goes out in order, across as many socket writes as it takes.
5
+ */
6
+ export interface BridgeSocket {
7
+ readonly writeLine: (value: Readonly<Record<string, unknown>>) => void;
8
+ readonly end: () => void;
9
+
10
+ // Settles once the connection has closed, from either side.
11
+ readonly closed: Promise<void>;
12
+ }
13
+
14
+ /**
15
+ * Connects to the session bridge at a unix socket path. Rejects when
16
+ * nothing listens there.
17
+ */
18
+ export async function openBridgeSocket(
19
+ path: string,
20
+ onLine: (line: Readonly<Record<string, unknown>>) => void,
21
+ ): Promise<BridgeSocket> {
22
+ const encoder = new TextEncoder();
23
+ const decoder = new TextDecoder();
24
+
25
+ const closed = Promise.withResolvers<void>();
26
+ let pending = '';
27
+
28
+ let unsent = new Uint8Array(0);
29
+
30
+ const socket = await Bun.connect({
31
+ unix: path,
32
+ socket: {
33
+ data(_socket, buf) {
34
+ const lines = `${pending}${decoder.decode(buf, { stream: true })}`.split('\n');
35
+
36
+ pending = lines.pop() ?? '';
37
+
38
+ for (const line of lines) {
39
+ const parsed = parseLine(line);
40
+
41
+ if (parsed !== null) {
42
+ onLine(parsed);
43
+ }
44
+ }
45
+ },
46
+ drain(s) {
47
+ unsent = unsent.subarray(s.write(unsent));
48
+ },
49
+ close() {
50
+ closed.resolve();
51
+ },
52
+ error() {
53
+ closed.resolve();
54
+ },
55
+ },
56
+ });
57
+
58
+ return {
59
+ writeLine: (value) => {
60
+ const bytes = encoder.encode(`${JSON.stringify(value)}\n`);
61
+
62
+ const joined = new Uint8Array(unsent.length + bytes.length);
63
+
64
+ joined.set(unsent, 0);
65
+ joined.set(bytes, unsent.length);
66
+
67
+ unsent = joined.subarray(socket.write(joined));
68
+ },
69
+ end: () => {
70
+ socket.end();
71
+ },
72
+ closed: closed.promise,
73
+ };
74
+ }
75
+
76
+ function parseLine(line: string): Readonly<Record<string, unknown>> | null {
77
+ try {
78
+ const parsed: unknown = JSON.parse(line);
79
+
80
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
81
+ ? Object.fromEntries(Object.entries(parsed))
82
+ : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { openBridgeSocket } from './open-bridge-socket';
3
+
4
+ /**
5
+ * Sends one request to the session bridge at a unix socket path and
6
+ * returns its answer line, or null when nothing listens there, the
7
+ * connection closes first, or no answer arrives in time. Never throws.
8
+ */
9
+ export async function sendBridgeRequest(
10
+ path: string,
11
+ op: string,
12
+ args: Readonly<Record<string, unknown>>,
13
+ timeoutMs: number,
14
+ ): Promise<Readonly<Record<string, unknown>> | null> {
15
+ const id = randomUUID();
16
+ const answered = Promise.withResolvers<Readonly<Record<string, unknown>> | null>();
17
+
18
+ try {
19
+ const socket = await openBridgeSocket(path, (line) => {
20
+ if (line['id'] === id) {
21
+ answered.resolve(line);
22
+ }
23
+ });
24
+
25
+ void (async () => {
26
+ await socket.closed;
27
+
28
+ answered.resolve(null);
29
+ })();
30
+
31
+ const timer = setTimeout(() => {
32
+ answered.resolve(null);
33
+ }, timeoutMs);
34
+
35
+ socket.writeLine({ ...args, v: 1, id, op });
36
+
37
+ const answer = await answered.promise;
38
+
39
+ clearTimeout(timer);
40
+
41
+ socket.end();
42
+
43
+ return answer;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
package/src/statusline.ts CHANGED
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { statusFile } from './shared/config';
5
5
  import { isRecord, sendReport } from './shared/report';
6
+ import { sendBridgeRequest } from './shared/send-bridge-request';
6
7
 
7
8
  /**
8
9
  * Runs as the statusLine command injected into wrangled sessions. Chains the
@@ -60,6 +61,19 @@ export async function runStatusline(): Promise<void> {
60
61
  }
61
62
  } catch {}
62
63
 
64
+ const segment =
65
+ process.env['ATC_BRIDGE'] === '1' && sock !== undefined && sock !== ''
66
+ ? await readOwnSegment(sock)
67
+ : readFleetSegment();
68
+
69
+ const line = [chained, segment].filter((part) => part !== '').join(' \u001B[90m▏\u001B[0m ');
70
+
71
+ console.log(line);
72
+ process.exit(0);
73
+ }
74
+
75
+ // The fleet segment, from the status file the daemon writes beside it.
76
+ function readFleetSegment(): string {
63
77
  let segment = '';
64
78
 
65
79
  try {
@@ -90,10 +104,27 @@ export async function runStatusline(): Promise<void> {
90
104
  }
91
105
  } catch {}
92
106
 
93
- const line = [chained, segment].filter((part) => part !== '').join(' \u001B[90m▏\u001B[0m ');
107
+ return segment;
108
+ }
94
109
 
95
- console.log(line);
96
- process.exit(0);
110
+ // Inside a remote host the status file is out of reach, and the session
111
+ // bridge returns the session's own state alone, never the rest of the fleet.
112
+ async function readOwnSegment(sock: string): Promise<string> {
113
+ const status = await sendBridgeRequest(sock, 'status.read', {}, 500);
114
+
115
+ if (status === null || status['ok'] !== true) {
116
+ return '';
117
+ }
118
+
119
+ if (status['state'] === 'needs_you') {
120
+ return '\u001B[1;31m● needs you\u001B[0m';
121
+ }
122
+
123
+ if (status['state'] === 'done') {
124
+ return '\u001B[32m✓ done\u001B[0m';
125
+ }
126
+
127
+ return status['state'] === 'running' ? '\u001B[36m◐ running\u001B[0m' : '';
97
128
  }
98
129
 
99
130
  // A user statusline that is atc's own injected command would chain into
@@ -8,6 +8,7 @@ import type { DaemonID } from '../shared/daemon-id';
8
8
  import { isRecord } from '../shared/report';
9
9
  import type { SessionID } from '../shared/session-id';
10
10
  import { toAgentSessionID } from '../shared/to-agent-session-id';
11
+ import type { SessionWorkspace } from './workspace-materialization';
11
12
 
12
13
  // One fleet row. The atc session id keys it and stays the same for the
13
14
  // session's whole life, across daemon restarts and fleet restores.
@@ -48,6 +49,24 @@ export interface FleetEntry {
48
49
  // The identity the target had when the session started on it; a row
49
50
  // without one is bound to the implicit `local` target's identity.
50
51
  readonly targetIdentity?: string;
52
+
53
+ // What the session's workspace was materialized from, for a session
54
+ // spawned with a workspace source; a fleet write leaves it to the
55
+ // materialization record that holds it.
56
+ readonly workspace?: SessionWorkspace;
57
+
58
+ // The environment variable names every harness the session starts goes
59
+ // without, held with its ready workspace; never their values.
60
+ readonly withheldEnv?: readonly string[];
61
+
62
+ // What the operator asked of the harness: absent keeps it running,
63
+ // `sleep` keeps its host asleep, and `stop` leaves it ended.
64
+ readonly desired?: 'sleep' | 'stop';
65
+
66
+ // The session whose host the harness runs on: its own id, or its
67
+ // parent's when the two share one host. A row without one runs on a host
68
+ // of its own.
69
+ readonly hostKey?: SessionID;
51
70
  }
52
71
 
53
72
  export interface FleetStore {
@@ -5,6 +5,7 @@ import { DEFAULT_MIGRATION_TABLE, Migrator } from 'kysely/migration';
5
5
  import type { Migration, MigrationProvider, MigrationResultSet } from 'kysely/migration';
6
6
  import type { IdempotencyState } from './idempotency-record';
7
7
  import type { MessageStatus } from './message-record';
8
+ import type { MaterializationPhase } from './workspace-materialization';
8
9
 
9
10
  interface FleetTable {
10
11
  session_id: string;
@@ -23,6 +24,15 @@ interface FleetTable {
23
24
  effort: string | null;
24
25
  target: string | null;
25
26
  target_identity: string | null;
27
+
28
+ // What the operator asked of the session's harness: null keeps it
29
+ // running, `sleep` keeps its host asleep, and `stop` leaves it ended.
30
+ desired: string | null;
31
+
32
+ // The session whose host this session's harness runs on: its own id, or
33
+ // its parent's when the two share one host. Null for a row from before
34
+ // hosts were recorded, which runs on its own.
35
+ host_key: string | null;
26
36
  }
27
37
 
28
38
  interface EventsTable {
@@ -34,6 +44,10 @@ interface EventsTable {
34
44
  session_id: string | null;
35
45
  kind: string | null;
36
46
  detail: string | null;
47
+
48
+ // The id a remote session's reporter gave a report row, unique so a
49
+ // resent report lands once; null on every other row.
50
+ report_id: string | null;
37
51
  }
38
52
 
39
53
  interface SpawnHistoryTable {
@@ -77,6 +91,27 @@ interface IdempotencyTable {
77
91
  effect_target_identity: string | null;
78
92
  }
79
93
 
94
+ // One spawn's workspace on its way to the execution target, keyed by the
95
+ // session it is for.
96
+ interface WorkspaceMaterializationTable {
97
+ session_id: string;
98
+ target: string;
99
+ dir: string;
100
+ source_kind: 'path' | 'git';
101
+ phase: MaterializationPhase;
102
+ repo_url: string | null;
103
+ sha: string | null;
104
+ ref: string | null;
105
+ error_code: string | null;
106
+ started_at: number;
107
+ updated_at: number;
108
+ materialized_at: number | null;
109
+
110
+ // JSON array of the environment variable names every harness the session
111
+ // starts goes without; names only, never their values.
112
+ withheld_env: string | null;
113
+ }
114
+
80
115
  interface MessagesTable {
81
116
  id: string;
82
117
  atc_id: string;
@@ -106,6 +141,7 @@ export interface StateStoreSchema {
106
141
  messages: MessagesTable;
107
142
  session_owner: SessionOwnerTable;
108
143
  idempotency: IdempotencyTable;
144
+ workspace_materialization: WorkspaceMaterializationTable;
109
145
  }
110
146
 
111
147
  // Every shape the fleet table has shipped with: the oldest carries only
@@ -361,6 +397,46 @@ const MIGRATIONS: Record<string, Migration> = {
361
397
  .execute();
362
398
  },
363
399
  },
400
+ '020_create_workspace_materialization': {
401
+ async up(db: Kysely<StateStoreSchema>) {
402
+ await db.schema
403
+ .createTable('workspace_materialization')
404
+ .ifNotExists()
405
+ .addColumn('session_id', 'text', (c) => c.primaryKey())
406
+ .addColumn('target', 'text', (c) => c.notNull())
407
+ .addColumn('dir', 'text', (c) => c.notNull())
408
+ .addColumn('source_kind', 'text', (c) => c.notNull())
409
+ .addColumn('phase', 'text', (c) => c.notNull())
410
+ .addColumn('repo_url', 'text')
411
+ .addColumn('sha', 'text')
412
+ .addColumn('ref', 'text')
413
+ .addColumn('error_code', 'text')
414
+ .addColumn('started_at', 'integer', (c) => c.notNull())
415
+ .addColumn('updated_at', 'integer', (c) => c.notNull())
416
+ .addColumn('materialized_at', 'integer')
417
+ .addColumn('withheld_env', 'text')
418
+ .execute();
419
+ },
420
+ },
421
+ '021_add_fleet_lifecycle': {
422
+ async up(db: Kysely<StateStoreSchema>) {
423
+ await db.schema.alterTable('fleet').addColumn('desired', 'text').execute();
424
+ await db.schema.alterTable('fleet').addColumn('host_key', 'text').execute();
425
+ },
426
+ },
427
+ '022_add_events_report_id': {
428
+ async up(db: Kysely<StateStoreSchema>) {
429
+ await db.schema.alterTable('events').addColumn('report_id', 'text').execute();
430
+
431
+ await db.schema
432
+ .createIndex('events_report_id')
433
+ .ifNotExists()
434
+ .unique()
435
+ .on('events')
436
+ .column('report_id')
437
+ .execute();
438
+ },
439
+ },
364
440
  };
365
441
 
366
442
  const PROVIDER: MigrationProvider = {