@zgeoff/atc 2.13.0 → 2.15.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 +2 -1
- package/package.json +1 -1
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/daemon-client.ts +5 -22
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +54 -1
- package/src/daemon/build-imp-provider.ts +70 -23
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +5 -0
- package/src/daemon/daemon-connection.ts +42 -16
- package/src/daemon/daemon.ts +8 -1
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +83 -27
- package/src/daemon/imp-provider.ts +5 -11
- package/src/daemon/materialize-workspace.ts +5 -2
- package/src/daemon/read-imp-token.ts +30 -0
- package/src/daemon/sessions.ts +18 -3
- package/src/mcp/mcp-tools.ts +32 -1
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +5 -0
- package/src/mcp-server.ts +3 -14
- package/src/protocol/daemon-features.ts +3 -0
- package/src/protocol/line-decoder.ts +34 -0
- package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/report.ts +1 -1
- package/src/run-bridge-tap.ts +2 -2
- package/src/statusline.ts +1 -1
- package/src/store/fleet-entry.ts +4 -1
- package/src/store/run-migrations.ts +9 -0
- package/src/store/state-store.ts +119 -15
- package/src/store/trail-entry.ts +3 -0
- /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
package/README.md
CHANGED
|
@@ -94,7 +94,8 @@ list.
|
|
|
94
94
|
If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
|
|
95
95
|
After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
|
|
96
96
|
restores the fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart
|
|
97
|
-
finishes.
|
|
97
|
+
finishes. When the running daemon speaks another protocol version, `atc` asks before it restarts the
|
|
98
|
+
daemon, since the restart ends every session the daemon hosts.
|
|
98
99
|
|
|
99
100
|
atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
|
|
100
101
|
|
package/package.json
CHANGED
|
@@ -4,15 +4,18 @@ import { readFileSync } from 'node:fs';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { toAgentID } from '../agents/agent-adapter';
|
|
6
6
|
import type { AgentID } from '../agents/agent-adapter';
|
|
7
|
+
import { DaemonError } from '../protocol/daemon-error';
|
|
7
8
|
import type { DaemonFeature } from '../protocol/daemon-features';
|
|
8
9
|
import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
|
|
10
|
+
import { PROTOCOL_V } from '../protocol/protocol';
|
|
9
11
|
import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
|
|
10
12
|
import { findDaemonRecord } from '../shared/find-daemon-record';
|
|
11
13
|
import { getBuild } from '../shared/get-build';
|
|
12
14
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
13
15
|
import { makeSingleFlight } from '../shared/make-single-flight';
|
|
14
|
-
import { isRecord } from '../shared/report';
|
|
15
16
|
import { DaemonClient } from './daemon-client';
|
|
17
|
+
import { formatProtocolMismatch } from './format-protocol-mismatch';
|
|
18
|
+
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
16
19
|
import { pickStaleDaemonPID } from './pick-stale-daemon-pid';
|
|
17
20
|
|
|
18
21
|
export interface DaemonBoot {
|
|
@@ -28,18 +31,28 @@ export interface DaemonBoot {
|
|
|
28
31
|
readonly socketPath: string;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export interface DaemonBootOptions {
|
|
35
|
+
// Called when the daemon speaks another protocol version. Resolving true
|
|
36
|
+
// stops that daemon and boots one from this build, which ends every
|
|
37
|
+
// session it hosts; without the callback, or resolving false, the boot
|
|
38
|
+
// rejects and the daemon keeps running.
|
|
39
|
+
readonly onProtocolMismatch?: (mismatch: ProtocolMismatch) => Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/**
|
|
32
43
|
* Opens a handshaken client to the daemon, booting the daemon first when
|
|
33
44
|
* neither the computed socket nor the one in the daemon's record answers.
|
|
34
|
-
* Overlapping calls in one process share a single boot. A daemon from an
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
45
|
+
* Overlapping calls in one process share a single boot. A daemon from an
|
|
46
|
+
* older build stays in service, since stopping it would end every hosted
|
|
47
|
+
* session, and is reported as stale so the caller can offer a deliberate
|
|
48
|
+
* restart. A daemon on another protocol version stays in service too: the
|
|
49
|
+
* boot rejects with `protocol_mismatch` and a message holding both builds,
|
|
50
|
+
* both versions, and the way to restart it, unless the caller's
|
|
51
|
+
* `onProtocolMismatch` confirms a restart. The expected build is read from
|
|
52
|
+
* disk on every attempt: a long-lived caller holding a build string from
|
|
53
|
+
* its own boot would otherwise flag daemons that are already current.
|
|
41
54
|
*/
|
|
42
|
-
export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
55
|
+
export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
|
|
43
56
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
44
57
|
const build = getBuild();
|
|
45
58
|
|
|
@@ -60,21 +73,35 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
|
|
|
60
73
|
} catch (error) {
|
|
61
74
|
client.stop();
|
|
62
75
|
|
|
63
|
-
if (attempt > 0 || !
|
|
76
|
+
if (attempt > 0 || !(error instanceof DaemonError) || error.code !== 'protocol_mismatch') {
|
|
64
77
|
throw error;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
|
-
|
|
80
|
+
const mismatch: ProtocolMismatch = {
|
|
81
|
+
socketPath: opened.socketPath,
|
|
82
|
+
daemonPID: findDaemonPID(opened.socketPath),
|
|
83
|
+
clientBuild: build,
|
|
84
|
+
clientProtocol: PROTOCOL_V,
|
|
85
|
+
daemonMessage: error.message,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Without a pid there is no daemon this client could stop, so the
|
|
89
|
+
// caller is not asked.
|
|
90
|
+
if (
|
|
91
|
+
mismatch.daemonPID === null ||
|
|
92
|
+
options.onProtocolMismatch === undefined ||
|
|
93
|
+
!(await options.onProtocolMismatch(mismatch))
|
|
94
|
+
) {
|
|
95
|
+
throw new DaemonError('protocol_mismatch', formatProtocolMismatch(mismatch));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await stopDaemon(mismatch.daemonPID);
|
|
68
99
|
}
|
|
69
100
|
}
|
|
70
101
|
|
|
71
102
|
throw new Error('the atc daemon could not be restarted');
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
function isProtocolMismatch(error: unknown): boolean {
|
|
75
|
-
return isRecord(error) && error['code'] === 'protocol_mismatch';
|
|
76
|
-
}
|
|
77
|
-
|
|
78
105
|
interface OpenedDaemon {
|
|
79
106
|
readonly client: DaemonClient;
|
|
80
107
|
readonly socketPath: string;
|
|
@@ -177,18 +204,17 @@ function isProcessAlive(pid: number): boolean {
|
|
|
177
204
|
}
|
|
178
205
|
}
|
|
179
206
|
|
|
180
|
-
|
|
181
|
-
|
|
207
|
+
// The pid of the daemon behind the socket that refused the handshake.
|
|
208
|
+
function findDaemonPID(socketPath: string): number | null {
|
|
209
|
+
return pickStaleDaemonPID({
|
|
182
210
|
socketPath,
|
|
183
211
|
record: findDaemonRecord(daemonRecordFile),
|
|
184
212
|
pidFileSocketPath: daemonSocketPath,
|
|
185
213
|
pidFilePID: findPidFilePID(),
|
|
186
214
|
});
|
|
215
|
+
}
|
|
187
216
|
|
|
188
|
-
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
|
|
217
|
+
async function stopDaemon(pid: number): Promise<void> {
|
|
192
218
|
try {
|
|
193
219
|
process.kill(pid, 'SIGTERM');
|
|
194
220
|
} catch {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
2
3
|
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
3
4
|
import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
|
|
4
5
|
import type { EventMsg, ResponseMsg } from '../protocol/protocol';
|
|
@@ -20,9 +21,7 @@ export class DaemonClient {
|
|
|
20
21
|
|
|
21
22
|
private queue: OutboundQueue | null = null;
|
|
22
23
|
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
private readonly decoder = new TextDecoder();
|
|
24
|
+
private readonly lines = new LineDecoder();
|
|
26
25
|
|
|
27
26
|
private nextID = 1;
|
|
28
27
|
|
|
@@ -41,7 +40,7 @@ export class DaemonClient {
|
|
|
41
40
|
unix: socketPath,
|
|
42
41
|
socket: {
|
|
43
42
|
data(_s, buf) {
|
|
44
|
-
client.applyChunk(
|
|
43
|
+
client.applyChunk(buf);
|
|
45
44
|
},
|
|
46
45
|
drain() {
|
|
47
46
|
client.queue?.drain();
|
|
@@ -99,25 +98,9 @@ export class DaemonClient {
|
|
|
99
98
|
this.drainPending('client closed');
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
// Decodes with state kept across reads, so a multi-byte character split
|
|
103
|
-
// between two reads decodes whole.
|
|
104
101
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
105
|
-
private
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private applyChunk(chunk: string): void {
|
|
110
|
-
this.buffer += chunk;
|
|
111
|
-
|
|
112
|
-
const lines = this.buffer.split('\n');
|
|
113
|
-
|
|
114
|
-
this.buffer = lines.pop() ?? '';
|
|
115
|
-
|
|
116
|
-
for (const line of lines) {
|
|
117
|
-
if (line.trim() === '') {
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
102
|
+
private applyChunk(buf: Uint8Array): void {
|
|
103
|
+
for (const line of this.lines.splitChunk(buf)) {
|
|
121
104
|
this.applyLine(line);
|
|
122
105
|
}
|
|
123
106
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A daemon that refused this client's handshake because it speaks another
|
|
2
|
+
// protocol version.
|
|
3
|
+
export interface ProtocolMismatch {
|
|
4
|
+
readonly socketPath: string;
|
|
5
|
+
|
|
6
|
+
// The daemon's pid from its record or pid file, or null when neither
|
|
7
|
+
// belongs to the socket that refused.
|
|
8
|
+
readonly daemonPID: number | null;
|
|
9
|
+
|
|
10
|
+
readonly clientBuild: string;
|
|
11
|
+
readonly clientProtocol: number;
|
|
12
|
+
|
|
13
|
+
// The daemon's own refusal, which holds its build and protocol version.
|
|
14
|
+
readonly daemonMessage: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Describes a daemon on another protocol version: both builds and both
|
|
19
|
+
* versions, that it was left running, and how to restart it on purpose.
|
|
20
|
+
*/
|
|
21
|
+
export function formatProtocolMismatch(mismatch: ProtocolMismatch): string {
|
|
22
|
+
const pid = mismatch.daemonPID === null ? 'pid unknown' : `pid ${mismatch.daemonPID}`;
|
|
23
|
+
|
|
24
|
+
// Without a pid the TUI cannot stop the daemon either, so the restart is
|
|
25
|
+
// left to the user.
|
|
26
|
+
const restart =
|
|
27
|
+
mismatch.daemonPID === null
|
|
28
|
+
? `To restart it, find its pid with \`ss -xlp | grep ${mismatch.socketPath}\` on Linux or \`lsof -U | grep ${mismatch.socketPath}\` on macOS, stop that process, and run \`atc\` again: every hosted session ends, and \`R\` respawns them from their transcripts.`
|
|
29
|
+
: 'To restart it, run `atc` from the build you want and confirm its restart prompt: every hosted session ends, and the fleet is restored on the new daemon.';
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
`the atc daemon (${pid}, socket ${mismatch.socketPath}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
|
|
33
|
+
`The daemon answered: ${mismatch.daemonMessage}`,
|
|
34
|
+
'It was left running, so the sessions it hosts keep running.',
|
|
35
|
+
restart,
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { bootDaemonClient } from './boot-daemon';
|
|
|
10
10
|
import { buildClientMachine } from './build-client-machine';
|
|
11
11
|
import { buildLeaderChords } from './build-leader-chords';
|
|
12
12
|
import { findFuzzyScore, formatDir } from './dirs';
|
|
13
|
+
import type { ProtocolMismatch } from './format-protocol-mismatch';
|
|
13
14
|
import { KEY, isDown, isUp, planTextEdit } from './keys';
|
|
14
15
|
import { parseDaemonEvent } from './parse-daemon-event';
|
|
15
16
|
import { pickTabTarget } from './pick-tab-target';
|
|
@@ -781,7 +782,17 @@ const service = createActor(
|
|
|
781
782
|
}),
|
|
782
783
|
);
|
|
783
784
|
|
|
784
|
-
|
|
785
|
+
// A daemon on another protocol is restarted only when the user confirms
|
|
786
|
+
// it, and the fleet it hosted is then restored on the new one.
|
|
787
|
+
let restartedOnBoot = false;
|
|
788
|
+
|
|
789
|
+
const boot = await bootDaemonClient({
|
|
790
|
+
onProtocolMismatch: async (mismatch) => {
|
|
791
|
+
restartedOnBoot = await waitForRestartConsent(mismatch);
|
|
792
|
+
|
|
793
|
+
return restartedOnBoot;
|
|
794
|
+
},
|
|
795
|
+
});
|
|
785
796
|
|
|
786
797
|
let client = boot.client;
|
|
787
798
|
let daemonStale = boot.stale;
|
|
@@ -790,8 +801,50 @@ let daemonRestarting = false;
|
|
|
790
801
|
lastUsedAgent = boot.lastUsedAgent;
|
|
791
802
|
client.onEvent = applyDaemonEvent;
|
|
792
803
|
|
|
804
|
+
if (restartedOnBoot) {
|
|
805
|
+
await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
|
|
806
|
+
}
|
|
807
|
+
|
|
793
808
|
await refreshMirror();
|
|
794
809
|
|
|
810
|
+
/**
|
|
811
|
+
* Asks on the terminal whether to restart a daemon that speaks another
|
|
812
|
+
* protocol, reading a single key. Anything but `y` declines, and so does a
|
|
813
|
+
* terminal that cannot answer.
|
|
814
|
+
*/
|
|
815
|
+
async function waitForRestartConsent(mismatch: ProtocolMismatch): Promise<boolean> {
|
|
816
|
+
if (!process.stdin.isTTY) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
process.stderr.write(
|
|
821
|
+
[
|
|
822
|
+
`atc: the daemon (pid ${mismatch.daemonPID}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
|
|
823
|
+
`The daemon answered: ${mismatch.daemonMessage}`,
|
|
824
|
+
'Restart it now? Every session it hosts ends, then the fleet is restored on the new daemon. [y/N] ',
|
|
825
|
+
].join('\n'),
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
process.stdin.setRawMode(true);
|
|
829
|
+
process.stdin.resume();
|
|
830
|
+
|
|
831
|
+
const key = await new Promise<string>((resolve) => {
|
|
832
|
+
process.stdin.once('data', (buf: Buffer) => {
|
|
833
|
+
resolve(buf.toString());
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
process.stdin.setRawMode(false);
|
|
838
|
+
process.stdin.pause();
|
|
839
|
+
|
|
840
|
+
const confirmed = key === 'y' || key === 'Y';
|
|
841
|
+
const echo = confirmed ? 'y\n' : 'n\n';
|
|
842
|
+
|
|
843
|
+
process.stderr.write(echo);
|
|
844
|
+
|
|
845
|
+
return confirmed;
|
|
846
|
+
}
|
|
847
|
+
|
|
795
848
|
async function refreshMirror() {
|
|
796
849
|
const list = await client.sendRequest('session.list');
|
|
797
850
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { ImpClientPort } from './imp-client-port';
|
|
2
2
|
import { ImpProvider } from './imp-provider';
|
|
3
|
+
import { readImpToken } from './read-imp-token';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The provider of an `imp` target, or the problem that keeps it from one.
|
|
6
|
-
* A problem never includes
|
|
7
|
+
* A problem never includes a token or any other environment value.
|
|
7
8
|
*/
|
|
8
9
|
export interface ImpProviderBuild {
|
|
9
10
|
readonly provider: ImpProvider | null;
|
|
@@ -12,13 +13,16 @@ export interface ImpProviderBuild {
|
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The provider of target `id` from its `imp` options: `url` is where impd
|
|
15
|
-
* listens, and `tokenEnv`
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
16
|
+
* listens, and at most one of `tokenEnv` and `tokenFile` gives the token,
|
|
17
|
+
* so no credential sits in the config. `tokenEnv` holds the name of the
|
|
18
|
+
* daemon's environment variable that carries the token, read once here.
|
|
19
|
+
* `tokenFile` holds the path of a file that carries it, read here and again
|
|
20
|
+
* before each impd call and connection. `image`, `memoryMib`, `guestDir`,
|
|
21
|
+
* and `guestATC` pass through. No provider when `url` is missing, so the
|
|
22
|
+
* target lists and refuses every spawn. Both token options, either one not
|
|
23
|
+
* a non-empty string, an unset or empty variable, or an empty or unreadable
|
|
24
|
+
* file is a problem and leaves no provider; a target without either calls
|
|
25
|
+
* impd with no token.
|
|
22
26
|
*/
|
|
23
27
|
export function buildImpProvider(
|
|
24
28
|
id: string,
|
|
@@ -26,29 +30,18 @@ export function buildImpProvider(
|
|
|
26
30
|
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
27
31
|
): ImpProviderBuild {
|
|
28
32
|
const url = options['url'];
|
|
29
|
-
const tokenEnv = options['tokenEnv'];
|
|
30
33
|
|
|
31
34
|
if (typeof url !== 'string' || url === '') {
|
|
32
35
|
return { provider: null, problem: null };
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
|
|
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];
|
|
38
|
+
const source = loadTokenSource(id, options, env);
|
|
43
39
|
|
|
44
|
-
if (
|
|
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
|
-
};
|
|
40
|
+
if (typeof source === 'string') {
|
|
41
|
+
return { provider: null, problem: source };
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
const provider = new ImpProvider(new ImpClientPort({ url,
|
|
44
|
+
const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
|
|
52
45
|
...pickString(options, 'image'),
|
|
53
46
|
...pickString(options, 'guestDir'),
|
|
54
47
|
...pickString(options, 'guestATC'),
|
|
@@ -58,6 +51,60 @@ export function buildImpProvider(
|
|
|
58
51
|
return { provider, problem: null };
|
|
59
52
|
}
|
|
60
53
|
|
|
54
|
+
// How the port reads the token: a function, so a file token rereads.
|
|
55
|
+
interface TokenSource {
|
|
56
|
+
readonly readToken: () => string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The token source the options give, or the problem that keeps the target
|
|
60
|
+
// from one. A problem holds a variable's name or a file's path, never a
|
|
61
|
+
// token.
|
|
62
|
+
function loadTokenSource(
|
|
63
|
+
id: string,
|
|
64
|
+
options: Readonly<Record<string, unknown>>,
|
|
65
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
66
|
+
): TokenSource | string {
|
|
67
|
+
const tokenEnv = options['tokenEnv'];
|
|
68
|
+
const tokenFile = options['tokenFile'];
|
|
69
|
+
const target = JSON.stringify(id);
|
|
70
|
+
|
|
71
|
+
if (tokenEnv !== undefined && tokenFile !== undefined) {
|
|
72
|
+
return `target ${target} must give its impd token through tokenEnv or tokenFile, not both`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (tokenFile !== undefined) {
|
|
76
|
+
if (typeof tokenFile !== 'string' || tokenFile === '') {
|
|
77
|
+
return `target ${target} must give tokenFile as a non-empty string`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
readImpToken(tokenFile);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
84
|
+
|
|
85
|
+
return `target ${target} reads its impd token from a file it cannot use: ${detail}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { readToken: () => readImpToken(tokenFile) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (tokenEnv === undefined) {
|
|
92
|
+
return { readToken: () => null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof tokenEnv !== 'string' || tokenEnv === '') {
|
|
96
|
+
return `target ${target} must give tokenEnv as a non-empty string`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const token = env[tokenEnv];
|
|
100
|
+
|
|
101
|
+
if (token === undefined || token === '') {
|
|
102
|
+
return `target ${target} reads its impd token from ${tokenEnv}, which is unset or empty in the daemon's environment`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { readToken: () => token };
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
function pickString(
|
|
62
109
|
options: Readonly<Record<string, unknown>>,
|
|
63
110
|
key: 'image' | 'guestDir' | 'guestATC',
|
|
@@ -5,8 +5,9 @@ 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
|
|
8
|
+
* The trail entry for one note a session reported, carrying its label, its
|
|
9
|
+
* text and a preview of it, and the id its reporter gave it, when it gave
|
|
10
|
+
* one.
|
|
10
11
|
*/
|
|
11
12
|
export function buildReportTrailEntry(
|
|
12
13
|
sessionID: SessionID,
|
|
@@ -22,6 +23,7 @@ export function buildReportTrailEntry(
|
|
|
22
23
|
kind: 'report',
|
|
23
24
|
label: report.label,
|
|
24
25
|
detail: truncateDetail(report.text),
|
|
26
|
+
text: report.text,
|
|
25
27
|
...(reportID === undefined ? {} : { reportID }),
|
|
26
28
|
};
|
|
27
29
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { encodeCursor } from '../protocol/encode-cursor';
|
|
2
|
+
import type { StoredReport } from '../store/state-store';
|
|
3
|
+
import type { SessionDescriptor } from './sessions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One report as `report.get` returns it: the cursor of its event, when it
|
|
7
|
+
* arrived, the session that sent it and that session's name, its label, its
|
|
8
|
+
* text, and whether that text is whole.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReportView {
|
|
11
|
+
readonly report: string;
|
|
12
|
+
readonly at: number;
|
|
13
|
+
readonly session: string;
|
|
14
|
+
readonly name: string | null;
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly text: string;
|
|
17
|
+
readonly complete: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildReportView(
|
|
21
|
+
stored: StoredReport,
|
|
22
|
+
sessions: readonly SessionDescriptor[],
|
|
23
|
+
): ReportView {
|
|
24
|
+
// A row written before atc session ids stayed stable across restores
|
|
25
|
+
// carries an earlier atc id, so the agent session id links it.
|
|
26
|
+
const live =
|
|
27
|
+
(stored.agentSessionID === null
|
|
28
|
+
? undefined
|
|
29
|
+
: sessions.find((s) => s.agentSessionID === stored.agentSessionID)) ??
|
|
30
|
+
sessions.find((s) => s.id === stored.atcID);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
report: encodeCursor({ kind: 'events', id: stored.id }),
|
|
34
|
+
at: stored.at,
|
|
35
|
+
session: live?.id ?? stored.atcID,
|
|
36
|
+
name: live?.name ?? null,
|
|
37
|
+
label: stored.label,
|
|
38
|
+
text: stored.text,
|
|
39
|
+
complete: stored.complete,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -203,6 +203,11 @@ export function buildScopedContext(
|
|
|
203
203
|
|
|
204
204
|
return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
|
|
205
205
|
},
|
|
206
|
+
readReport: (id, outer) => {
|
|
207
|
+
const merged = outer === null ? access : outer.merge(access);
|
|
208
|
+
|
|
209
|
+
return ctx.readReport(id, merged);
|
|
210
|
+
},
|
|
206
211
|
writeSessionMessage: (sessionID, from, text, keyed) =>
|
|
207
212
|
canSee(sessionID)
|
|
208
213
|
? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
|
|
@@ -4,6 +4,7 @@ import { DaemonError } from '../protocol/daemon-error';
|
|
|
4
4
|
import { DAEMON_FEATURES } from '../protocol/daemon-features';
|
|
5
5
|
import { decodeCursor } from '../protocol/decode-cursor';
|
|
6
6
|
import { encodeCursor } from '../protocol/encode-cursor';
|
|
7
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
7
8
|
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
8
9
|
import type { SocketWriter } from '../protocol/outbound-queue';
|
|
9
10
|
import { parseRequestParams } from '../protocol/parse-request-params';
|
|
@@ -28,6 +29,7 @@ import type { Dims } from './attach-registry';
|
|
|
28
29
|
import type { AgentEntry } from './build-agent-list';
|
|
29
30
|
import type { FleetEvent } from './build-fleet-events';
|
|
30
31
|
import { buildPayloadHash } from './build-payload-hash';
|
|
32
|
+
import type { ReportView } from './build-report-view';
|
|
31
33
|
import { buildScopedContext } from './build-scoped-context';
|
|
32
34
|
import type { TargetEntry } from './build-target-list';
|
|
33
35
|
import type { KeyedRequest } from './idempotency-ledger';
|
|
@@ -189,6 +191,11 @@ export interface DaemonContext {
|
|
|
189
191
|
access: TargetAccess | null,
|
|
190
192
|
) => Promise<EventsPage>;
|
|
191
193
|
|
|
194
|
+
// One report by the trail id of its event, or null for a trail id that
|
|
195
|
+
// holds no report, or whose report's session is outside the access when
|
|
196
|
+
// there is one.
|
|
197
|
+
readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
|
|
198
|
+
|
|
192
199
|
// Answers with the `session.message` ok payload, which a keyed retry
|
|
193
200
|
// replays with the message's current status, or with the refusal.
|
|
194
201
|
readonly writeSessionMessage: (
|
|
@@ -272,9 +279,7 @@ export class DaemonConnection {
|
|
|
272
279
|
|
|
273
280
|
private readonly queue: OutboundQueue;
|
|
274
281
|
|
|
275
|
-
private
|
|
276
|
-
|
|
277
|
-
private readonly decoder = new TextDecoder();
|
|
282
|
+
private readonly lines = new LineDecoder();
|
|
278
283
|
|
|
279
284
|
private helloed = false;
|
|
280
285
|
|
|
@@ -346,28 +351,18 @@ export class DaemonConnection {
|
|
|
346
351
|
// between two reads decodes whole.
|
|
347
352
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
348
353
|
decodeChunk(buf: Uint8Array): string {
|
|
349
|
-
return this.
|
|
354
|
+
return this.lines.decodeText(buf);
|
|
350
355
|
}
|
|
351
356
|
|
|
352
357
|
applyChunk(chunk: string): void {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (buffered.length > MAX_LINE) {
|
|
358
|
+
if (this.lines.pendingLength + chunk.length > MAX_LINE) {
|
|
356
359
|
this.sendErr(0, 'bad_args', `line exceeds ${MAX_LINE} bytes`);
|
|
357
360
|
this.peer.end();
|
|
358
361
|
|
|
359
362
|
return;
|
|
360
363
|
}
|
|
361
364
|
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
this.buffer = lines.pop() ?? '';
|
|
365
|
-
|
|
366
|
-
for (const line of lines) {
|
|
367
|
-
if (line.trim() === '') {
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
|
|
365
|
+
for (const line of this.lines.splitText(chunk)) {
|
|
371
366
|
if (!this.applyLine(line)) {
|
|
372
367
|
this.peer.end();
|
|
373
368
|
|
|
@@ -799,6 +794,11 @@ export class DaemonConnection {
|
|
|
799
794
|
|
|
800
795
|
return;
|
|
801
796
|
}
|
|
797
|
+
case 'report.get': {
|
|
798
|
+
await this.applyReportGet(req, ctx);
|
|
799
|
+
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
802
|
default: {
|
|
803
803
|
this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
|
|
804
804
|
}
|
|
@@ -1150,6 +1150,32 @@ export class DaemonConnection {
|
|
|
1150
1150
|
});
|
|
1151
1151
|
}
|
|
1152
1152
|
|
|
1153
|
+
// A cursor that is not an events cursor, one at a row that holds no
|
|
1154
|
+
// report, and one at a report outside the access all get one refusal, so
|
|
1155
|
+
// the refusal is the same for a report out of reach and a missing one.
|
|
1156
|
+
private async applyReportGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
1157
|
+
const parsed = parseRequestParams('report.get', req.p);
|
|
1158
|
+
|
|
1159
|
+
if (!parsed.ok) {
|
|
1160
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
1161
|
+
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const decoded = decodeCursor(parsed.data.report);
|
|
1166
|
+
|
|
1167
|
+
const view =
|
|
1168
|
+
decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
|
|
1169
|
+
|
|
1170
|
+
if (view === null) {
|
|
1171
|
+
this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
|
|
1172
|
+
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
this.sendOk(req.id, { ...view });
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1153
1179
|
private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
|
|
1154
1180
|
const parsed = parseRequestParams('session.message', req.p);
|
|
1155
1181
|
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { hostname } from 'node:os';
|
|
3
|
+
import { hostname, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
|
|
6
6
|
import { planTypedLineInput } from '../agents/plan-typed-line-input';
|
|
@@ -32,6 +32,7 @@ import type { ExecutionTarget } from './build-execution-targets';
|
|
|
32
32
|
import { buildFleetEvents } from './build-fleet-events';
|
|
33
33
|
import { buildMessageTrailEntry } from './build-message-trail-entry';
|
|
34
34
|
import { buildReportTrailEntry } from './build-report-trail-entry';
|
|
35
|
+
import { buildReportView } from './build-report-view';
|
|
35
36
|
import { buildSessionEvent } from './build-session-event';
|
|
36
37
|
import { buildSessionMessageEvent } from './build-session-message-event';
|
|
37
38
|
import { buildSessionReportEvent } from './build-session-report-event';
|
|
@@ -907,6 +908,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
907
908
|
log: (line) => {
|
|
908
909
|
mgr.log(line);
|
|
909
910
|
},
|
|
911
|
+
stagingRoot: tmpdir(),
|
|
910
912
|
},
|
|
911
913
|
);
|
|
912
914
|
};
|
|
@@ -1568,6 +1570,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
1568
1570
|
await eventSignal.waitForNext(generation, remaining);
|
|
1569
1571
|
}
|
|
1570
1572
|
},
|
|
1573
|
+
readReport: async (id, access) => {
|
|
1574
|
+
const stored = await store.findReport(id, buildEventScope(null, access));
|
|
1575
|
+
|
|
1576
|
+
return stored === null ? null : buildReportView(stored, mgr.collectDescriptors());
|
|
1577
|
+
},
|
|
1571
1578
|
writeSessionMessage: async (sessionID, from, text, keyed) => {
|
|
1572
1579
|
if (keyed === null) {
|
|
1573
1580
|
const refusal = findMessageRefusal(sessionID);
|