@zgeoff/atc 2.11.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 +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -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 +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- 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 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- 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 +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- 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/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/client/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { match } from 'ts-pattern';
|
|
|
2
2
|
import { createActor } from 'xstate';
|
|
3
3
|
import type { AgentID } from '../agents/agent-adapter';
|
|
4
4
|
import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
|
|
5
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
5
6
|
import type { EventMsg } from '../protocol/protocol';
|
|
6
7
|
import { loadConfig } from '../shared/config';
|
|
7
8
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
@@ -20,6 +21,10 @@ import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar,
|
|
|
20
21
|
let overlaySelected = 0;
|
|
21
22
|
let confirmKill = false;
|
|
22
23
|
|
|
24
|
+
// The session whose forget waits on a confirm because it destroys the
|
|
25
|
+
// session's host, or null.
|
|
26
|
+
let confirmDestroyID: string | null = null;
|
|
27
|
+
|
|
23
28
|
// The client's mirror of the daemon's fleet, kept fresh by events.
|
|
24
29
|
let fleet: MirrorSession[] = [];
|
|
25
30
|
|
|
@@ -95,6 +100,36 @@ async function sendQuiet(m: string, p?: Readonly<Record<string, unknown>>) {
|
|
|
95
100
|
} catch {}
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
// A kill the daemon refuses because forgetting the session destroys its
|
|
104
|
+
// host asks once more before the forget.
|
|
105
|
+
async function sendKill(sessionID: string) {
|
|
106
|
+
try {
|
|
107
|
+
await client.sendRequest('session.kill', { session: sessionID });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof DaemonError && error.code === 'confirmation_required') {
|
|
110
|
+
confirmDestroyID = sessionID;
|
|
111
|
+
|
|
112
|
+
if (service.getSnapshot().value === 'overlay') {
|
|
113
|
+
renderOverlay();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Takes the confirm token the daemon offers and hands it straight back:
|
|
120
|
+
// the operator already confirmed in the overlay.
|
|
121
|
+
async function sendForget(sessionID: string) {
|
|
122
|
+
try {
|
|
123
|
+
const offered = await client.sendRequest('session.forget', { session: sessionID });
|
|
124
|
+
|
|
125
|
+
const confirmToken = offered['confirmToken'];
|
|
126
|
+
|
|
127
|
+
if (typeof confirmToken === 'string') {
|
|
128
|
+
await client.sendRequest('session.forget', { session: sessionID, confirmToken });
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
|
|
98
133
|
async function attach(sessionID: string) {
|
|
99
134
|
service.send({ type: 'ATTACH', sessionID });
|
|
100
135
|
|
|
@@ -215,6 +250,7 @@ function renderOverlay() {
|
|
|
215
250
|
agentMarks,
|
|
216
251
|
selected: overlaySelected,
|
|
217
252
|
confirmKill,
|
|
253
|
+
confirmDestroy: confirmDestroyID !== null,
|
|
218
254
|
filter: overlayFilter,
|
|
219
255
|
stale: daemonStale && !daemonRestarting,
|
|
220
256
|
grouped: overlayGrouped,
|
|
@@ -225,6 +261,7 @@ function renderOverlay() {
|
|
|
225
261
|
|
|
226
262
|
function openOverlay() {
|
|
227
263
|
confirmKill = false;
|
|
264
|
+
confirmDestroyID = null;
|
|
228
265
|
overlayFilter = null;
|
|
229
266
|
|
|
230
267
|
const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
|
|
@@ -510,7 +547,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
510
547
|
|
|
511
548
|
if (confirmKill) {
|
|
512
549
|
if (buf[0] === 0x79 /* y */ && sel !== undefined) {
|
|
513
|
-
void
|
|
550
|
+
void sendKill(sel.id);
|
|
514
551
|
}
|
|
515
552
|
|
|
516
553
|
confirmKill = false;
|
|
@@ -520,6 +557,18 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
520
557
|
return;
|
|
521
558
|
}
|
|
522
559
|
|
|
560
|
+
if (confirmDestroyID !== null) {
|
|
561
|
+
if (buf[0] === 0x79 /* y */) {
|
|
562
|
+
void sendForget(confirmDestroyID);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
confirmDestroyID = null;
|
|
566
|
+
|
|
567
|
+
renderOverlay();
|
|
568
|
+
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
|
|
523
572
|
if (applyOverlayFilterKey(buf)) {
|
|
524
573
|
return;
|
|
525
574
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import type { AgentID } from '../agents/agent-adapter';
|
|
3
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
3
4
|
import { loadConfig } from '../shared/config';
|
|
4
5
|
import { collectAgentPicks } from './collect-agent-picks';
|
|
5
6
|
import type { AgentPick } from './collect-agent-picks';
|
|
@@ -32,7 +33,8 @@ export interface SpawnPickerDeps<TMirror> {
|
|
|
32
33
|
/**
|
|
33
34
|
* The modal flow behind n and r: agent, then directory, then name, then an
|
|
34
35
|
* optional first prompt. Every path out of it either attaches the new
|
|
35
|
-
* session or returns the client to the screen it came from.
|
|
36
|
+
* session or returns the client to the screen it came from. A spawn the
|
|
37
|
+
* daemon refuses is no path out: the flow stays open and shows why.
|
|
36
38
|
*/
|
|
37
39
|
export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
38
40
|
private readonly deps: SpawnPickerDeps<TMirror>;
|
|
@@ -60,6 +62,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
60
62
|
|
|
61
63
|
private agent: AgentID = 'claude';
|
|
62
64
|
|
|
65
|
+
// The daemon's refusal of the last spawn, shown in place of the hint until
|
|
66
|
+
// the next key.
|
|
67
|
+
private refusal: string | null = null;
|
|
68
|
+
|
|
63
69
|
constructor(deps: SpawnPickerDeps<TMirror>) {
|
|
64
70
|
this.deps = deps;
|
|
65
71
|
}
|
|
@@ -82,12 +88,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
82
88
|
|
|
83
89
|
this.agent = this.picks[this.selected]?.agent ?? this.deps.getLastUsedAgent();
|
|
84
90
|
this.step = 'agent';
|
|
91
|
+
this.refusal = null;
|
|
85
92
|
|
|
86
93
|
process.stdout.write(ansi.clear);
|
|
87
94
|
this.render();
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
applyKey(buf: Buffer) {
|
|
98
|
+
this.refusal = null;
|
|
99
|
+
|
|
91
100
|
const edit = planTextEdit(buf, this.input, {
|
|
92
101
|
isLeaderKey: this.deps.isLeaderKey,
|
|
93
102
|
moves: this.step === 'agent' || this.step === 'dir',
|
|
@@ -168,7 +177,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
168
177
|
selected: -1,
|
|
169
178
|
input: this.input,
|
|
170
179
|
placeholder: formatDirName(this.dir),
|
|
171
|
-
hint: `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
|
|
180
|
+
hint: this.refusal ?? `session name for ${formatDir(this.dir)} · ⏎ accept · esc back`,
|
|
172
181
|
});
|
|
173
182
|
} else {
|
|
174
183
|
drawPicker({
|
|
@@ -177,7 +186,7 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
177
186
|
selected: -1,
|
|
178
187
|
input: this.input,
|
|
179
188
|
placeholder: 'optional — ⏎ to start interactive',
|
|
180
|
-
hint: 'first message for the session · ⏎ spawn · esc back',
|
|
189
|
+
hint: this.refusal ?? 'first message for the session · ⏎ spawn · esc back',
|
|
181
190
|
});
|
|
182
191
|
}
|
|
183
192
|
|
|
@@ -320,7 +329,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
|
|
|
320
329
|
|
|
321
330
|
return;
|
|
322
331
|
}
|
|
323
|
-
} catch {
|
|
332
|
+
} catch (error) {
|
|
333
|
+
// A refused spawn keeps the picker on the step that sent it, with the
|
|
334
|
+
// entered text back in the input, so the user can fix the cause and
|
|
335
|
+
// retry or back out.
|
|
336
|
+
const reason =
|
|
337
|
+
error instanceof DaemonError ? `${error.code}: ${error.message}` : String(error);
|
|
338
|
+
|
|
339
|
+
this.refusal = `${reason} · ⏎ retry · esc back`;
|
|
340
|
+
this.input = this.resume ? this.name : prompt;
|
|
341
|
+
|
|
342
|
+
process.stdout.write(ansi.clear);
|
|
343
|
+
this.render();
|
|
344
|
+
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
324
347
|
|
|
325
348
|
this.deps.toBase();
|
|
326
349
|
}
|
package/src/client/ui.ts
CHANGED
|
@@ -196,6 +196,10 @@ export interface OverlayView {
|
|
|
196
196
|
agentMarks: Readonly<Record<AgentID, string>>;
|
|
197
197
|
selected: number;
|
|
198
198
|
confirmKill: boolean;
|
|
199
|
+
|
|
200
|
+
// A kill the daemon refused because forgetting the session destroys its
|
|
201
|
+
// host waits on a second confirm.
|
|
202
|
+
confirmDestroy: boolean;
|
|
199
203
|
filter: string | null;
|
|
200
204
|
stale: boolean;
|
|
201
205
|
grouped: boolean;
|
|
@@ -271,6 +275,10 @@ export function drawOverlay(view: OverlayView) {
|
|
|
271
275
|
hint = formatKillConfirm(selected, view.sessions);
|
|
272
276
|
}
|
|
273
277
|
|
|
278
|
+
if (view.confirmDestroy) {
|
|
279
|
+
hint = 'forget destroys its host and everything on it? y / n';
|
|
280
|
+
}
|
|
281
|
+
|
|
274
282
|
rowsList.push(dimRow(width, hint), boxBottom(width));
|
|
275
283
|
|
|
276
284
|
drawBox(rowsList);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
1
|
+
import type { AgentAdapter, SpawnOptionSpec } from '../agents/agent-adapter';
|
|
2
2
|
|
|
3
3
|
interface AgentCapabilities {
|
|
4
4
|
// Only an installed agent can start a session.
|
|
@@ -21,6 +21,19 @@ export interface AgentEntry {
|
|
|
21
21
|
readonly installed: boolean;
|
|
22
22
|
readonly capabilities: AgentCapabilities;
|
|
23
23
|
readonly models: Readonly<Record<string, string>> | null;
|
|
24
|
+
readonly spawnOptions: SpawnOptionEntries;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A spawn option as `agents.list` shows it: the agent's own spec, plus
|
|
28
|
+
// whether a spawn on this host can pass it now. Only an installed agent's
|
|
29
|
+
// supported option is available.
|
|
30
|
+
export interface SpawnOptionEntry extends SpawnOptionSpec {
|
|
31
|
+
readonly available: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SpawnOptionEntries {
|
|
35
|
+
readonly model: SpawnOptionEntry;
|
|
36
|
+
readonly effort: SpawnOptionEntry;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
/**
|
|
@@ -53,6 +66,29 @@ export function buildAgentList(
|
|
|
53
66
|
input: true,
|
|
54
67
|
},
|
|
55
68
|
models: profile?.models ?? null,
|
|
69
|
+
spawnOptions: {
|
|
70
|
+
model: buildSpawnOptionEntry(profile?.spawnOptions.model, installed),
|
|
71
|
+
effort: buildSpawnOptionEntry(profile?.spawnOptions.effort, installed),
|
|
72
|
+
},
|
|
56
73
|
};
|
|
57
74
|
});
|
|
58
75
|
}
|
|
76
|
+
|
|
77
|
+
// A stand-in adapter declares no spawn options, so it takes none.
|
|
78
|
+
const NO_SPAWN_OPTION: SpawnOptionSpec = {
|
|
79
|
+
supported: false,
|
|
80
|
+
values: null,
|
|
81
|
+
examples: [],
|
|
82
|
+
default: null,
|
|
83
|
+
backendEffect: null,
|
|
84
|
+
note: null,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function buildSpawnOptionEntry(
|
|
88
|
+
spec: SpawnOptionSpec | undefined,
|
|
89
|
+
installed: boolean,
|
|
90
|
+
): SpawnOptionEntry {
|
|
91
|
+
const resolved = spec ?? NO_SPAWN_OPTION;
|
|
92
|
+
|
|
93
|
+
return { ...resolved, available: installed && resolved.supported };
|
|
94
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { TargetConfigError } from '../shared/collect-targets';
|
|
3
|
+
import type { ExecutionTarget } from './build-execution-targets';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A short digest of the target config a daemon runs with: each target's id
|
|
7
|
+
* and identity, the default target, and the config errors. Two daemons with
|
|
8
|
+
* the same targets return the same revision, and any change to them
|
|
9
|
+
* changes it.
|
|
10
|
+
*/
|
|
11
|
+
export function buildConfigRevision(
|
|
12
|
+
targets: readonly ExecutionTarget[],
|
|
13
|
+
defaultTarget: string | null,
|
|
14
|
+
errors: readonly TargetConfigError[],
|
|
15
|
+
): string {
|
|
16
|
+
const canonical = JSON.stringify({
|
|
17
|
+
targets: targets.map((target) => [target.id, target.identity]),
|
|
18
|
+
defaultTarget,
|
|
19
|
+
errors: errors.map((error) =>
|
|
20
|
+
error.scope === 'config'
|
|
21
|
+
? [error.scope, error.path, error.problem, error.detail]
|
|
22
|
+
: [error.scope, error.target ?? null, error.problem],
|
|
23
|
+
),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
|
|
27
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { TargetConfig, TargetConfigError } from '../shared/collect-targets';
|
|
2
|
+
import { buildImpProvider } from './build-imp-provider';
|
|
3
|
+
import { buildTargetIdentity } from './build-target-identity';
|
|
4
|
+
import type { ExecutionProvider } from './execution-provider';
|
|
5
|
+
import { LocalPTYProvider } from './local-pty-provider';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A configured place sessions run, with the provider that serves it. The
|
|
9
|
+
* provider is null when this daemon has no provider of the configured kind:
|
|
10
|
+
* the target still lists, and a spawn to it fails rather than running
|
|
11
|
+
* anywhere else.
|
|
12
|
+
*/
|
|
13
|
+
export interface ExecutionTarget {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
readonly options: Readonly<Record<string, unknown>>;
|
|
17
|
+
|
|
18
|
+
// The identity a session spawned here binds to.
|
|
19
|
+
readonly identity: string;
|
|
20
|
+
readonly provider: ExecutionProvider | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The targets the daemon serves, and the problems with their entries that
|
|
25
|
+
* only the daemon's environment shows.
|
|
26
|
+
*/
|
|
27
|
+
export interface ExecutionTargetsBuild {
|
|
28
|
+
readonly targets: ExecutionTarget[];
|
|
29
|
+
readonly errors: TargetConfigError[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds one target per configured entry, in config order, each with a
|
|
34
|
+
* provider of its kind when this daemon has one. An entry whose provider
|
|
35
|
+
* cannot start from `env` keeps no provider and adds a target error.
|
|
36
|
+
*/
|
|
37
|
+
export function buildExecutionTargets(
|
|
38
|
+
configs: readonly TargetConfig[],
|
|
39
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
40
|
+
): ExecutionTargetsBuild {
|
|
41
|
+
const errors: TargetConfigError[] = [];
|
|
42
|
+
|
|
43
|
+
const targets = configs.map((config) => {
|
|
44
|
+
const built = buildProvider(config, env);
|
|
45
|
+
|
|
46
|
+
if (built.problem !== null) {
|
|
47
|
+
errors.push({ scope: 'target', target: config.id, problem: built.problem });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id: config.id,
|
|
52
|
+
kind: config.provider,
|
|
53
|
+
options: config.options,
|
|
54
|
+
identity: buildTargetIdentity(config.provider, config.options),
|
|
55
|
+
provider: built.provider,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return { targets, errors };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildProvider(
|
|
63
|
+
config: TargetConfig,
|
|
64
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
65
|
+
): Readonly<{ provider: ExecutionProvider | null; problem: string | null }> {
|
|
66
|
+
if (config.provider === 'local-pty') {
|
|
67
|
+
return { provider: new LocalPTYProvider(), problem: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return config.provider === 'imp'
|
|
71
|
+
? buildImpProvider(config.id, config.options, env)
|
|
72
|
+
: { provider: null, problem: null };
|
|
73
|
+
}
|
|
@@ -23,7 +23,8 @@ export function buildFleetEvents(
|
|
|
23
23
|
sessions: readonly SessionDescriptor[],
|
|
24
24
|
): FleetEvent[] {
|
|
25
25
|
return rows.map((row) => {
|
|
26
|
-
//
|
|
26
|
+
// Rows written before atc session ids stayed stable across restores
|
|
27
|
+
// carry an earlier atc id, so the agent session id links them.
|
|
27
28
|
const live =
|
|
28
29
|
(row.agentSessionID === null
|
|
29
30
|
? undefined
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ImpClientPort } from './imp-client-port';
|
|
2
|
+
import { ImpProvider } from './imp-provider';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The provider of an `imp` target, or the problem that keeps it from one.
|
|
6
|
+
* A problem never includes the token or any other environment value.
|
|
7
|
+
*/
|
|
8
|
+
export interface ImpProviderBuild {
|
|
9
|
+
readonly provider: ImpProvider | null;
|
|
10
|
+
readonly problem: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The provider of target `id` from its `imp` options: `url` is where impd
|
|
15
|
+
* listens, and `tokenEnv` holds the name of the daemon's environment
|
|
16
|
+
* variable that carries the token, so no credential sits in the config.
|
|
17
|
+
* `image`, `memoryMib`, `guestDir`, and `guestATC` pass through. No
|
|
18
|
+
* provider when `url` is missing, so the target lists and refuses every
|
|
19
|
+
* spawn. A `tokenEnv` that is not a non-empty string, or whose variable is
|
|
20
|
+
* unset or empty in `env`, is a problem and leaves no provider; a target
|
|
21
|
+
* without `tokenEnv` calls impd with no token.
|
|
22
|
+
*/
|
|
23
|
+
export function buildImpProvider(
|
|
24
|
+
id: string,
|
|
25
|
+
options: Readonly<Record<string, unknown>>,
|
|
26
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
27
|
+
): ImpProviderBuild {
|
|
28
|
+
const url = options['url'];
|
|
29
|
+
const tokenEnv = options['tokenEnv'];
|
|
30
|
+
|
|
31
|
+
if (typeof url !== 'string' || url === '') {
|
|
32
|
+
return { provider: null, problem: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (tokenEnv !== undefined && (typeof tokenEnv !== 'string' || tokenEnv === '')) {
|
|
36
|
+
return {
|
|
37
|
+
provider: null,
|
|
38
|
+
problem: `target ${JSON.stringify(id)} must give tokenEnv as a non-empty string`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const token = tokenEnv === undefined ? null : env[tokenEnv];
|
|
43
|
+
|
|
44
|
+
if (token === undefined || token === '') {
|
|
45
|
+
return {
|
|
46
|
+
provider: null,
|
|
47
|
+
problem: `target ${JSON.stringify(id)} reads its impd token from ${String(tokenEnv)}, which is unset or empty in the daemon's environment`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const provider = new ImpProvider(new ImpClientPort({ url, token }), {
|
|
52
|
+
...pickString(options, 'image'),
|
|
53
|
+
...pickString(options, 'guestDir'),
|
|
54
|
+
...pickString(options, 'guestATC'),
|
|
55
|
+
...(typeof options['memoryMib'] === 'number' ? { memoryMib: options['memoryMib'] } : {}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { provider, problem: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pickString(
|
|
62
|
+
options: Readonly<Record<string, unknown>>,
|
|
63
|
+
key: 'image' | 'guestDir' | 'guestATC',
|
|
64
|
+
): Partial<Record<typeof key, string>> {
|
|
65
|
+
const value = options[key];
|
|
66
|
+
|
|
67
|
+
return typeof value === 'string' && value !== '' ? { [key]: value } : {};
|
|
68
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The SHA-256 of a request's params as canonical JSON: object keys sorted
|
|
6
|
+
* at every depth, with the idempotency key itself left out. Two requests
|
|
7
|
+
* that differ only in key order or in their idempotency key hash the same.
|
|
8
|
+
*/
|
|
9
|
+
export function buildPayloadHash(params: Readonly<Record<string, unknown>>): string {
|
|
10
|
+
const { idempotencyKey: _ignored, ...payload } = params;
|
|
11
|
+
|
|
12
|
+
return createHash('sha256')
|
|
13
|
+
.update(JSON.stringify(sortKeys(payload)))
|
|
14
|
+
.digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sortKeys(value: unknown): unknown {
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.map((item) => sortKeys(item));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!isRecord(value)) {
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Object.fromEntries(
|
|
27
|
+
Object.keys(value)
|
|
28
|
+
.toSorted()
|
|
29
|
+
.map((key) => [key, sortKeys(value[key])]),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -5,14 +5,15 @@ import type { TrailEntry } from '../store/trail-entry';
|
|
|
5
5
|
import type { NoteReport } from './parse-report';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The trail entry for one note a session reported, carrying its label
|
|
9
|
-
* preview of its text.
|
|
8
|
+
* The trail entry for one note a session reported, carrying its label, a
|
|
9
|
+
* preview of its text, and the id its reporter gave it, when it gave one.
|
|
10
10
|
*/
|
|
11
11
|
export function buildReportTrailEntry(
|
|
12
12
|
sessionID: SessionID,
|
|
13
13
|
agentSessionID: AgentSessionID | undefined,
|
|
14
14
|
report: Readonly<NoteReport>,
|
|
15
15
|
reportedAt: number,
|
|
16
|
+
reportID?: string,
|
|
16
17
|
): TrailEntry {
|
|
17
18
|
return {
|
|
18
19
|
at: reportedAt,
|
|
@@ -21,5 +22,6 @@ export function buildReportTrailEntry(
|
|
|
21
22
|
kind: 'report',
|
|
22
23
|
label: report.label,
|
|
23
24
|
detail: truncateDetail(report.text),
|
|
25
|
+
...(reportID === undefined ? {} : { reportID }),
|
|
24
26
|
};
|
|
25
27
|
}
|