@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.
- package/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +48 -11
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +64 -17
- package/src/agents/codex-adapter.ts +5 -0
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/gateway-adapter.ts +38 -11
- package/src/agents/grok-adapter.ts +5 -0
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -53
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +8 -12
- package/src/client/index.ts +50 -1
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-execution-targets.ts +49 -10
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +4 -1
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/daemon-connection.ts +97 -4
- package/src/daemon/daemon.ts +298 -41
- package/src/daemon/execution-provider.ts +109 -4
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +62 -3
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +41 -23
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/sessions.ts +574 -61
- package/src/daemon/start-headless-turn.ts +1 -1
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/mcp/mcp-tools.ts +9 -2
- package/src/mcp/require-daemon-features.ts +3 -0
- package/src/mcp/run-tool.ts +27 -8
- package/src/protocol/daemon-features.ts +11 -0
- package/src/protocol/protocol.ts +23 -0
- package/src/protocol/request-param-schemas.ts +66 -0
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +19 -0
- package/src/store/run-migrations.ts +76 -0
- package/src/store/state-store.ts +182 -21
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/create-workspace-clone.ts +13 -1
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/run-git.ts +2 -18
- /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
|
-
|
|
107
|
+
return segment;
|
|
108
|
+
}
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -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 = {
|