@boxxy/worker-atc 0.0.1 → 0.1.4
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 +38 -5
- package/dist/create-atc-worker.d.ts +8 -0
- package/dist/find-caller-identity.d.ts +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/is-capacity-refusal.d.ts +2 -0
- package/dist/is-folder-trusted.d.ts +1 -0
- package/dist/parse-atc-error.d.ts +7 -0
- package/dist/test-utils/build-mock-atc-session-read.d.ts +8 -0
- package/dist/test-utils/create-fake-atc-client.d.ts +30 -0
- package/dist/to-session-reading.d.ts +3 -0
- package/dist/types.d.ts +32 -0
- package/package.json +30 -4
- package/src/create-atc-worker.ts +257 -0
- package/src/find-caller-identity.ts +15 -0
- package/src/index.ts +2 -0
- package/src/is-capacity-refusal.ts +8 -0
- package/src/is-folder-trusted.ts +12 -0
- package/src/parse-atc-error.ts +35 -0
- package/src/test-utils/build-mock-atc-session-read.ts +51 -0
- package/src/test-utils/create-fake-atc-client.ts +332 -0
- package/src/to-session-reading.ts +63 -0
- package/src/types.ts +40 -0
package/README.md
CHANGED
|
@@ -1,7 +1,40 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@boxxy/worker-atc</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
boxxy
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@boxxy/worker-atc"><img src="https://img.shields.io/npm/v/@boxxy/worker-atc" alt="npm version"></a>
|
|
6
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
7
|
+
</p>
|
|
5
8
|
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
<p>
|
|
10
|
+
<a href="../../docs/design/boxxy/README.md">Documentation</a>
|
|
11
|
+
</p>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
boxxy's worker port on atc: it starts, reads, sleeps, wakes, stops and forgets the agent sessions
|
|
15
|
+
that run boxxy's attempts.
|
|
16
|
+
|
|
17
|
+
`createAtcWorker({ client, trustedFolders })` takes an atc client that the app builds and connects,
|
|
18
|
+
and the folders the agent trusts on each worker target. It is the only package that imports
|
|
19
|
+
`@atcx/contract`.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Install the package from [npm](https://www.npmjs.com/package/@boxxy/worker-atc) with Bun:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bun add @boxxy/worker-atc
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The package runs on [Bun](https://bun.sh) 1.3 or newer.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- `createAtcWorker`, the `WorkerKind` port of `@boxxy/model` on atc's `session.*` calls.
|
|
34
|
+
- `AtcClient`, the atc calls the adapter makes, typed from atc's published contract.
|
|
35
|
+
- `./test-utils/create-fake-atc-client`, an atc daemon in memory for tests.
|
|
36
|
+
|
|
37
|
+
## Documentation
|
|
38
|
+
|
|
39
|
+
[The atc worker](../../docs/design/boxxy/v1-lifecycle.md#the-atc-worker) in the attempt lifecycle
|
|
40
|
+
maps each port method to its atc call, and each atc answer to the port's outcome.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WorkerKind } from '@boxxy/model';
|
|
2
|
+
import type { AtcClient } from './types.ts';
|
|
3
|
+
interface AtcWorkerInput {
|
|
4
|
+
readonly client: AtcClient;
|
|
5
|
+
readonly trustedFolders: Readonly<Record<string, readonly string[]>>;
|
|
6
|
+
}
|
|
7
|
+
export declare function createAtcWorker(input: AtcWorkerInput): WorkerKind;
|
|
8
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function isFolderTrusted(trustedFolders: readonly string[], folder: string): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AtcSession, AtcSessionRead } from '../types.ts';
|
|
2
|
+
type MockSession = Partial<Omit<AtcSession, 'lifecycle'>> & {
|
|
3
|
+
readonly lifecycle?: Partial<AtcSession['lifecycle']>;
|
|
4
|
+
};
|
|
5
|
+
export declare function buildMockAtcSessionRead(overrides?: Partial<Omit<AtcSessionRead, 'session'>> & {
|
|
6
|
+
readonly session?: MockSession;
|
|
7
|
+
}): AtcSessionRead;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ErrorCode } from '@atcx/contract';
|
|
2
|
+
import type { Clock } from '@boxxy/model';
|
|
3
|
+
import type { AtcClient, AtcSession } from '../types.ts';
|
|
4
|
+
export type FakeTargetKind = 'local' | 'suspending';
|
|
5
|
+
type FakeCall = keyof AtcClient['session'];
|
|
6
|
+
export type FakeSessionPatch = Partial<Pick<AtcSession, 'state' | 'lastAttachedAt' | 'host' | 'agentSessionID'>> & {
|
|
7
|
+
readonly harness?: AtcSession['lifecycle']['harness'];
|
|
8
|
+
readonly desired?: AtcSession['lifecycle']['desired'];
|
|
9
|
+
readonly lastActivityAt?: number;
|
|
10
|
+
};
|
|
11
|
+
export interface FakeAtcClient extends AtcClient {
|
|
12
|
+
readonly calls: readonly {
|
|
13
|
+
readonly call: FakeCall;
|
|
14
|
+
readonly input: unknown;
|
|
15
|
+
}[];
|
|
16
|
+
listMessages: (session: string) => readonly string[];
|
|
17
|
+
patchSession: (session: string, patch: FakeSessionPatch) => void;
|
|
18
|
+
failNext: (call: FakeCall, error: Error) => void;
|
|
19
|
+
}
|
|
20
|
+
export declare class FakeAtcError extends Error {
|
|
21
|
+
readonly code: ErrorCode;
|
|
22
|
+
readonly data: Readonly<Record<string, unknown>> | undefined;
|
|
23
|
+
constructor(code: ErrorCode, data?: Readonly<Record<string, unknown>>);
|
|
24
|
+
}
|
|
25
|
+
interface FakeAtcClientInput {
|
|
26
|
+
readonly clock: Clock;
|
|
27
|
+
readonly targets: Readonly<Record<string, FakeTargetKind>>;
|
|
28
|
+
}
|
|
29
|
+
export declare function createFakeAtcClient(input: FakeAtcClientInput): FakeAtcClient;
|
|
30
|
+
export {};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DAEMON_CONTRACT } from '@atcx/contract';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
type SessionProcedures = (typeof DAEMON_CONTRACT)['session'];
|
|
4
|
+
type InputOf<Procedure> = Procedure extends {
|
|
5
|
+
'~orpc': {
|
|
6
|
+
inputSchema?: infer Schema;
|
|
7
|
+
};
|
|
8
|
+
} ? z.input<Schema> : never;
|
|
9
|
+
type OutputOf<Procedure> = Procedure extends {
|
|
10
|
+
'~orpc': {
|
|
11
|
+
outputSchema?: infer Schema;
|
|
12
|
+
};
|
|
13
|
+
} ? z.output<Schema> : never;
|
|
14
|
+
type CallOf<Procedure> = (input: InputOf<Procedure>) => Promise<OutputOf<Procedure>>;
|
|
15
|
+
export type AtcSession = OutputOf<SessionProcedures['get']>['session'];
|
|
16
|
+
export type AtcSessionRead = OutputOf<SessionProcedures['get']>;
|
|
17
|
+
export interface AtcClient {
|
|
18
|
+
readonly session: {
|
|
19
|
+
readonly spawn: (input: InputOf<SessionProcedures['spawn']>) => Promise<{
|
|
20
|
+
readonly session: AtcSession;
|
|
21
|
+
}>;
|
|
22
|
+
readonly get: (input: InputOf<SessionProcedures['get']>) => Promise<AtcSessionRead>;
|
|
23
|
+
readonly list: (input: InputOf<SessionProcedures['list']>) => Promise<{
|
|
24
|
+
readonly sessions: readonly AtcSession[];
|
|
25
|
+
}>;
|
|
26
|
+
readonly kill: CallOf<SessionProcedures['kill']>;
|
|
27
|
+
readonly adopt: CallOf<SessionProcedures['adopt']>;
|
|
28
|
+
readonly forget: CallOf<SessionProcedures['forget']>;
|
|
29
|
+
readonly message: CallOf<SessionProcedures['message']>;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boxxy/worker-atc",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "boxxy's worker port on atc: starts, reads, sleeps, wakes, stops and forgets agent sessions",
|
|
5
5
|
"homepage": "https://github.com/zgeoff/boxxy/tree/main/adapters/worker-atc#readme",
|
|
6
6
|
"bugs": "https://github.com/zgeoff/boxxy/issues",
|
|
7
7
|
"license": "MIT",
|
|
@@ -11,10 +11,36 @@
|
|
|
11
11
|
"directory": "adapters/worker-atc"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"
|
|
15
|
-
"
|
|
14
|
+
"src",
|
|
15
|
+
"!src/**/*.test.ts",
|
|
16
|
+
"dist"
|
|
16
17
|
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./src/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"./test-utils/*": "./src/test-utils/*.ts"
|
|
25
|
+
},
|
|
17
26
|
"publishConfig": {
|
|
18
27
|
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "rm -rf dist && tsrs -p tsconfig.build.json",
|
|
31
|
+
"typecheck": "tsrs --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@atcx/contract": "7.13.0",
|
|
35
|
+
"@boxxy/model": "0.1.4",
|
|
36
|
+
"ts-pattern": "5.9.2",
|
|
37
|
+
"zod": "4.6.7"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@boxxy/dispatch": "0.1.4"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"bun": ">=1.3",
|
|
44
|
+
"node": ">=24"
|
|
19
45
|
}
|
|
20
46
|
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AtcSessionId,
|
|
3
|
+
AttemptRow,
|
|
4
|
+
SessionReading,
|
|
5
|
+
StartOutcome,
|
|
6
|
+
WorkerKind,
|
|
7
|
+
} from '@boxxy/model';
|
|
8
|
+
import { brandSchemas } from '@boxxy/model';
|
|
9
|
+
import { findCallerIdentity } from './find-caller-identity.ts';
|
|
10
|
+
import { isCapacityRefusal } from './is-capacity-refusal.ts';
|
|
11
|
+
import { isFolderTrusted } from './is-folder-trusted.ts';
|
|
12
|
+
import { parseAtcError } from './parse-atc-error.ts';
|
|
13
|
+
import { toSessionReading } from './to-session-reading.ts';
|
|
14
|
+
import type { AtcClient, AtcSession } from './types.ts';
|
|
15
|
+
|
|
16
|
+
interface AtcWorkerInput {
|
|
17
|
+
readonly client: AtcClient;
|
|
18
|
+
|
|
19
|
+
// by worker target: the folders that the agent on the target trusts, each with its subfolders
|
|
20
|
+
readonly trustedFolders: Readonly<Record<string, readonly string[]>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The worker port on atc. Every call reaches atc through the client; a refusal that carries an atc
|
|
24
|
+
// code answers `refused`, and a call that got no atc answer answers `unknown`.
|
|
25
|
+
export function createAtcWorker(input: AtcWorkerInput): WorkerKind {
|
|
26
|
+
const client = input.client.session;
|
|
27
|
+
|
|
28
|
+
// a spawn whose answer was lost leaves the attempt with no session, which carries its spawn name
|
|
29
|
+
const readSessionID = async (attempt: AttemptRow): Promise<AtcSessionId | null> => {
|
|
30
|
+
if (attempt.atcSession !== null) {
|
|
31
|
+
return attempt.atcSession;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const listed = await client.list({});
|
|
35
|
+
|
|
36
|
+
return findNamedSession(listed.sessions, `boxxy/${attempt.id}`);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const readSession = async (session: AtcSessionId): Promise<SessionReading> => {
|
|
40
|
+
try {
|
|
41
|
+
const read = await client.get({ session });
|
|
42
|
+
|
|
43
|
+
return toSessionReading(read);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (parseAtcError(error)?.code === 'no_such_session') {
|
|
46
|
+
return { kind: 'absent' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// wakes a stopped session; a session that runs already answers as it is
|
|
54
|
+
const resumeSession = async (session: AtcSessionId): Promise<StartOutcome> => {
|
|
55
|
+
try {
|
|
56
|
+
const read = await client.get({ session });
|
|
57
|
+
|
|
58
|
+
if (read.session.lifecycle.harness === 'running') {
|
|
59
|
+
return toStarted(read.session);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await client.adopt({ session });
|
|
63
|
+
|
|
64
|
+
const adopted = await client.get({ session });
|
|
65
|
+
|
|
66
|
+
return toStarted(adopted.session);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return toStartFailure(error);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
start: async (request) => {
|
|
74
|
+
if (!isFolderTrusted(input.trustedFolders[request.target] ?? [], request.cwd)) {
|
|
75
|
+
return { kind: 'refused', cause: 'other', reason: 'worker folder not trusted' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const answer = await client.spawn({
|
|
80
|
+
target: request.target,
|
|
81
|
+
cwd: request.cwd,
|
|
82
|
+
name: request.name,
|
|
83
|
+
prompt: request.prompt,
|
|
84
|
+
agent: request.agent,
|
|
85
|
+
model: request.model,
|
|
86
|
+
effort: request.effort,
|
|
87
|
+
idempotencyKey: request.idempotencyKey,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return toStarted(answer.session);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// after a daemon restart, a keyed spawn that ran answers this until the fleet restore
|
|
93
|
+
// lists its session
|
|
94
|
+
if (parseAtcError(error)?.code === 'no_such_session') {
|
|
95
|
+
return { kind: 'unknown', reason: 'the spawn ran, and its session is not listed yet' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return toStartFailure(error);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
reconcile: async (attempt) => {
|
|
102
|
+
const session = await readSessionID(attempt);
|
|
103
|
+
|
|
104
|
+
return session === null ? { kind: 'absent' } : readSession(session);
|
|
105
|
+
},
|
|
106
|
+
suspend: async (attempt, ifIdleSince) => {
|
|
107
|
+
try {
|
|
108
|
+
const session = await readSessionID(attempt);
|
|
109
|
+
|
|
110
|
+
if (session === null) {
|
|
111
|
+
return { kind: 'refused', reason: 'no such session' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const read = await client.get({ session });
|
|
115
|
+
|
|
116
|
+
const reading = toSessionReading(read);
|
|
117
|
+
|
|
118
|
+
if (reading.kind !== 'running') {
|
|
119
|
+
return { kind: 'suspended' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (ifIdleSince !== null && reading.lastActivityAt > ifIdleSince) {
|
|
123
|
+
return { kind: 'active' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// a plain kill of a session that already exited forgets it, so every kill is stop-only
|
|
127
|
+
await client.kill({ session, stopOnly: true });
|
|
128
|
+
|
|
129
|
+
return { kind: 'suspended' };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return toCallFailure(error);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
resume: async (attempt) => {
|
|
135
|
+
try {
|
|
136
|
+
const session = await readSessionID(attempt);
|
|
137
|
+
|
|
138
|
+
return session === null
|
|
139
|
+
? { kind: 'refused', cause: 'other', reason: 'no such session' }
|
|
140
|
+
: await resumeSession(session);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return toStartFailure(error);
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
stop: async (attempt) => {
|
|
146
|
+
try {
|
|
147
|
+
const session = await readSessionID(attempt);
|
|
148
|
+
|
|
149
|
+
if (session === null) {
|
|
150
|
+
return { kind: 'absent' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await client.kill({ session, stopOnly: true });
|
|
154
|
+
|
|
155
|
+
return { kind: 'stopped' };
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return parseAtcError(error)?.code === 'no_such_session'
|
|
158
|
+
? { kind: 'absent' }
|
|
159
|
+
: toCallFailure(error);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
forget: async (session) => {
|
|
163
|
+
try {
|
|
164
|
+
const first = await client.forget({ session, refuseLive: true });
|
|
165
|
+
|
|
166
|
+
// a target that destroys its hosts answers a confirm token first, for a second call
|
|
167
|
+
if ('confirmToken' in first) {
|
|
168
|
+
await client.forget({ session, refuseLive: true, confirmToken: first.confirmToken });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { kind: 'forgotten' };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return parseAtcError(error)?.code === 'no_such_session'
|
|
174
|
+
? { kind: 'absent' }
|
|
175
|
+
: toCallFailure(error);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
resumeKept: async (kept, request) => {
|
|
179
|
+
const woken = await resumeSession(kept.atcSession);
|
|
180
|
+
|
|
181
|
+
if (woken.kind !== 'started') {
|
|
182
|
+
return woken;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
await client.message({
|
|
187
|
+
session: kept.atcSession,
|
|
188
|
+
text: request.prompt,
|
|
189
|
+
idempotencyKey: request.idempotencyKey,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return woken;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// the claim spawns a fresh session in its place, so the kept one must not run on
|
|
195
|
+
await client.kill({ session: kept.atcSession, stopOnly: true }).catch(() => null);
|
|
196
|
+
|
|
197
|
+
return toStartFailure(error);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// the latest session with the name
|
|
204
|
+
function findNamedSession(sessions: readonly AtcSession[], name: string): AtcSessionId | null {
|
|
205
|
+
const latest = sessions
|
|
206
|
+
.filter((session) => session.name === name)
|
|
207
|
+
.toSorted((left, right) => right.createdAt - left.createdAt)
|
|
208
|
+
.at(0);
|
|
209
|
+
|
|
210
|
+
return latest === undefined ? null : brandSchemas.atcSessionId.parse(latest.id);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function toStarted(session: AtcSession): StartOutcome {
|
|
214
|
+
return {
|
|
215
|
+
kind: 'started',
|
|
216
|
+
atcSession: brandSchemas.atcSessionId.parse(session.id),
|
|
217
|
+
agentSession:
|
|
218
|
+
session.agentSessionID === undefined
|
|
219
|
+
? null
|
|
220
|
+
: brandSchemas.agentSessionId.parse(session.agentSessionID),
|
|
221
|
+
caller: findCallerIdentity(session),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function toStartFailure(error: unknown): StartOutcome {
|
|
226
|
+
const atcError = parseAtcError(error);
|
|
227
|
+
const failure = toCallFailure(error);
|
|
228
|
+
|
|
229
|
+
if (failure.kind === 'unknown' || atcError === null) {
|
|
230
|
+
return { kind: 'unknown', reason: failure.reason };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
kind: 'refused',
|
|
235
|
+
cause: isCapacityRefusal(atcError) ? 'capacity' : 'other',
|
|
236
|
+
reason: failure.reason,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// atc may or may not have run a call it answers `outcome_unknown` or `idempotency_key_unknown`
|
|
241
|
+
function toCallFailure(error: unknown): {
|
|
242
|
+
readonly kind: 'refused' | 'unknown';
|
|
243
|
+
readonly reason: string;
|
|
244
|
+
} {
|
|
245
|
+
const atcError = parseAtcError(error);
|
|
246
|
+
|
|
247
|
+
if (atcError === null) {
|
|
248
|
+
return { kind: 'unknown', reason: error instanceof Error ? error.message : String(error) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const reason = `${atcError.code}: ${atcError.message}`;
|
|
252
|
+
|
|
253
|
+
const unknown =
|
|
254
|
+
atcError.code === 'outcome_unknown' || atcError.code === 'idempotency_key_unknown';
|
|
255
|
+
|
|
256
|
+
return { kind: unknown ? 'unknown' : 'refused', reason };
|
|
257
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CallerIdentity } from '@boxxy/model';
|
|
2
|
+
import type { AtcSession } from './types.ts';
|
|
3
|
+
|
|
4
|
+
// The one place that reads who runs a session: the issuer of the host's identity token, with the
|
|
5
|
+
// machine's ID as the subject, never the name. null when the session has no host, or when the host lacks its ID or
|
|
6
|
+
// its issuer: the identity check refuses such a host, so boxxy refuses its calls the same way.
|
|
7
|
+
export function findCallerIdentity(session: AtcSession): CallerIdentity | null {
|
|
8
|
+
const host = session.host;
|
|
9
|
+
|
|
10
|
+
if (host?.id === undefined || host.issuer === undefined) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return { issuer: host.issuer, subject: host.id };
|
|
15
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AtcError } from './parse-atc-error.ts';
|
|
2
|
+
|
|
3
|
+
// The one place that decides that the worker target has no free machine. atc's imp provider refuses
|
|
4
|
+
// a host it cannot ready with `host_unavailable`, and puts impd's own error code, in lower case, in
|
|
5
|
+
// the refusal's `problem`; impd answers `RAM_BUDGET_EXCEEDED` when no budget is left for another imp.
|
|
6
|
+
export function isCapacityRefusal(error: AtcError): boolean {
|
|
7
|
+
return error.code === 'host_unavailable' && error.problem === 'ram_budget_exceeded';
|
|
8
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// A folder is trusted when it is one of the trusted folders or lies inside one.
|
|
2
|
+
export function isFolderTrusted(trustedFolders: readonly string[], folder: string): boolean {
|
|
3
|
+
const path = trimTrailingSlashes(folder);
|
|
4
|
+
|
|
5
|
+
return trustedFolders
|
|
6
|
+
.map((trusted) => trimTrailingSlashes(trusted))
|
|
7
|
+
.some((trusted) => trusted !== '' && (path === trusted || path.startsWith(`${trusted}/`)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function trimTrailingSlashes(path: string): string {
|
|
11
|
+
return path.replace(/\/+$/u, '');
|
|
12
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ErrorCode } from '@atcx/contract';
|
|
2
|
+
import { ERROR_CODES } from '@atcx/contract';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
// A call that atc refused: its code, and the `problem` that a host refusal carries in its data.
|
|
6
|
+
export interface AtcError {
|
|
7
|
+
readonly code: ErrorCode;
|
|
8
|
+
readonly problem: string | null;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const atcErrorSchema = z.object({
|
|
13
|
+
code: z.enum(ERROR_CODES),
|
|
14
|
+
message: z.string().optional(),
|
|
15
|
+
data: z.unknown(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const problemSchema = z.object({ problem: z.string() });
|
|
19
|
+
|
|
20
|
+
// null: the error carries no atc code, such as a dropped connection, so atc gave no answer
|
|
21
|
+
export function parseAtcError(error: unknown): AtcError | null {
|
|
22
|
+
const parsed = atcErrorSchema.safeParse(error);
|
|
23
|
+
|
|
24
|
+
if (!parsed.success) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const data = problemSchema.safeParse(parsed.data.data);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
code: parsed.data.code,
|
|
32
|
+
problem: data.success ? data.data.problem : null,
|
|
33
|
+
message: parsed.data.message ?? '',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { toAgentSessionID, toDaemonID, toSessionID } from '@atcx/contract';
|
|
2
|
+
import type { AtcSession, AtcSessionRead } from '../types.ts';
|
|
3
|
+
|
|
4
|
+
type MockSession = Partial<Omit<AtcSession, 'lifecycle'>> & {
|
|
5
|
+
readonly lifecycle?: Partial<AtcSession['lifecycle']>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// `session.get`'s answer for a session whose agent runs and works on an imp target.
|
|
9
|
+
export function buildMockAtcSessionRead(
|
|
10
|
+
overrides: Partial<Omit<AtcSessionRead, 'session'>> & { readonly session?: MockSession } = {},
|
|
11
|
+
): AtcSessionRead {
|
|
12
|
+
const { session = {}, ...read } = overrides;
|
|
13
|
+
const { lifecycle = {}, ...fields } = session;
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
session: {
|
|
17
|
+
id: toSessionID('s1-mock'),
|
|
18
|
+
name: 'boxxy/mock-attempt',
|
|
19
|
+
cwd: '/tmp/boxxy-test/repo',
|
|
20
|
+
state: 'running',
|
|
21
|
+
unread: false,
|
|
22
|
+
lastMsg: '',
|
|
23
|
+
agentSessionID: toAgentSessionID('agent-mock'),
|
|
24
|
+
agent: 'claude',
|
|
25
|
+
pinned: false,
|
|
26
|
+
lastAttachedAt: 1_000_000,
|
|
27
|
+
repoRoot: '/tmp/boxxy-test/repo',
|
|
28
|
+
namedBy: 'user',
|
|
29
|
+
createdAt: 1_000_000,
|
|
30
|
+
kind: 'pty',
|
|
31
|
+
alive: true,
|
|
32
|
+
canEject: false,
|
|
33
|
+
locator: { daemonID: toDaemonID('mock-daemon'), targetID: 'box' },
|
|
34
|
+
host: { name: 'imp-mock', id: 'imp-id-mock', issuer: 'https://broker.test' },
|
|
35
|
+
...fields,
|
|
36
|
+
lifecycle: {
|
|
37
|
+
desired: 'run',
|
|
38
|
+
vm: 'awake',
|
|
39
|
+
harness: 'running',
|
|
40
|
+
attachment: 'detached',
|
|
41
|
+
...lifecycle,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
prompt: null,
|
|
45
|
+
lastActivityAt: 1_000_000,
|
|
46
|
+
pending: null,
|
|
47
|
+
result: null,
|
|
48
|
+
sessionRecord: null,
|
|
49
|
+
...read,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type { ErrorCode } from '@atcx/contract';
|
|
2
|
+
import { toAgentSessionID, toDaemonID, toSessionID } from '@atcx/contract';
|
|
3
|
+
import type { Clock } from '@boxxy/model';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import type { AtcClient, AtcSession } from '../types.ts';
|
|
6
|
+
|
|
7
|
+
// How a target treats a stopped session's host: a `local` target ends the agent process, and a
|
|
8
|
+
// `suspending` target sleeps the host with the process inside, and destroys it on a forget.
|
|
9
|
+
export type FakeTargetKind = 'local' | 'suspending';
|
|
10
|
+
|
|
11
|
+
type FakeCall = keyof AtcClient['session'];
|
|
12
|
+
|
|
13
|
+
// What a test changes on a listed session. A field left out keeps the session's value.
|
|
14
|
+
export type FakeSessionPatch = Partial<
|
|
15
|
+
Pick<AtcSession, 'state' | 'lastAttachedAt' | 'host' | 'agentSessionID'>
|
|
16
|
+
> & {
|
|
17
|
+
readonly harness?: AtcSession['lifecycle']['harness'];
|
|
18
|
+
readonly desired?: AtcSession['lifecycle']['desired'];
|
|
19
|
+
readonly lastActivityAt?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface FakeAtcClient extends AtcClient {
|
|
23
|
+
// every call the client took, in order, with its input
|
|
24
|
+
readonly calls: readonly { readonly call: FakeCall; readonly input: unknown }[];
|
|
25
|
+
|
|
26
|
+
// the text of each message sent to the session, in order
|
|
27
|
+
listMessages: (session: string) => readonly string[];
|
|
28
|
+
patchSession: (session: string, patch: FakeSessionPatch) => void;
|
|
29
|
+
|
|
30
|
+
// the next call of that procedure rejects with the error and changes nothing
|
|
31
|
+
failNext: (call: FakeCall, error: Error) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A refusal as atc's client gives it: the error's `code` and `data`.
|
|
35
|
+
export class FakeAtcError extends Error {
|
|
36
|
+
readonly code: ErrorCode;
|
|
37
|
+
|
|
38
|
+
readonly data: Readonly<Record<string, unknown>> | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(code: ErrorCode, data?: Readonly<Record<string, unknown>>) {
|
|
41
|
+
super(`atc refused the call: ${code}`);
|
|
42
|
+
|
|
43
|
+
this.name = 'FakeAtcError';
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.data = data;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface FakeSession {
|
|
50
|
+
descriptor: AtcSession;
|
|
51
|
+
lastActivityAt: number;
|
|
52
|
+
readonly targetKind: FakeTargetKind;
|
|
53
|
+
readonly messages: string[];
|
|
54
|
+
readonly messageKeys: Map<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface FakeAtcClientInput {
|
|
58
|
+
readonly clock: Clock;
|
|
59
|
+
readonly targets: Readonly<Record<string, FakeTargetKind>>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sessionInputSchema = z.object({ session: z.string() });
|
|
63
|
+
|
|
64
|
+
// An atc daemon in memory, as atc's docs describe the calls the worker adapter makes. Each session
|
|
65
|
+
// runs on the host `imp-<session ID>`, with the issuer `https://broker.test`.
|
|
66
|
+
export function createFakeAtcClient(input: FakeAtcClientInput): FakeAtcClient {
|
|
67
|
+
const sessions = new Map<string, FakeSession>();
|
|
68
|
+
const spawnKeys = new Map<string, string>();
|
|
69
|
+
const confirmTokens = new Map<string, string>();
|
|
70
|
+
const failures = new Map<FakeCall, Error>();
|
|
71
|
+
|
|
72
|
+
const calls: { call: FakeCall; input: unknown }[] = [];
|
|
73
|
+
let count = 0;
|
|
74
|
+
|
|
75
|
+
// a refusal rejects the call's promise, as a remote call does
|
|
76
|
+
const answer = <Answer>(
|
|
77
|
+
call: FakeCall,
|
|
78
|
+
callInput: unknown,
|
|
79
|
+
work: () => Answer,
|
|
80
|
+
): Promise<Answer> =>
|
|
81
|
+
Promise.try(() => {
|
|
82
|
+
calls.push({ call, input: callInput });
|
|
83
|
+
|
|
84
|
+
const failure = failures.get(call);
|
|
85
|
+
|
|
86
|
+
if (failure !== undefined) {
|
|
87
|
+
failures.delete(call);
|
|
88
|
+
throw failure;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return work();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const getSession = (callInput: unknown): FakeSession => {
|
|
95
|
+
const session = sessions.get(sessionInputSchema.parse(callInput).session);
|
|
96
|
+
|
|
97
|
+
if (session === undefined) {
|
|
98
|
+
throw new FakeAtcError('no_such_session');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return session;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const setHarness = (
|
|
105
|
+
session: FakeSession,
|
|
106
|
+
harness: AtcSession['lifecycle']['harness'],
|
|
107
|
+
desired: AtcSession['lifecycle']['desired'],
|
|
108
|
+
): void => {
|
|
109
|
+
session.descriptor = {
|
|
110
|
+
...session.descriptor,
|
|
111
|
+
state: harness === 'running' ? 'running' : 'exited',
|
|
112
|
+
alive: harness === 'running',
|
|
113
|
+
lifecycle: { ...session.descriptor.lifecycle, harness, desired },
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const createSession = (spawn: Parameters<AtcClient['session']['spawn']>[0]): AtcSession => {
|
|
118
|
+
const target = spawn.target ?? 'local';
|
|
119
|
+
const targetKind = input.targets[target];
|
|
120
|
+
|
|
121
|
+
if (targetKind === undefined) {
|
|
122
|
+
throw new FakeAtcError('unknown_target');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
count += 1;
|
|
126
|
+
|
|
127
|
+
const id = `s${count}-fake`;
|
|
128
|
+
const now = input.clock.now();
|
|
129
|
+
|
|
130
|
+
const descriptor: AtcSession = {
|
|
131
|
+
id: toSessionID(id),
|
|
132
|
+
name: typeof spawn.name === 'string' ? spawn.name : '',
|
|
133
|
+
cwd: spawn.cwd ?? '',
|
|
134
|
+
state: 'running',
|
|
135
|
+
unread: false,
|
|
136
|
+
lastMsg: '',
|
|
137
|
+
agentSessionID: toAgentSessionID(`agent-${count}`),
|
|
138
|
+
agent: spawn.agent ?? 'claude',
|
|
139
|
+
pinned: false,
|
|
140
|
+
lastAttachedAt: now,
|
|
141
|
+
repoRoot: spawn.cwd ?? '',
|
|
142
|
+
namedBy: 'user',
|
|
143
|
+
createdAt: now,
|
|
144
|
+
kind: 'pty',
|
|
145
|
+
alive: true,
|
|
146
|
+
canEject: false,
|
|
147
|
+
locator: { daemonID: toDaemonID('fake-daemon'), targetID: target },
|
|
148
|
+
lifecycle: {
|
|
149
|
+
desired: 'run',
|
|
150
|
+
vm: targetKind === 'local' ? 'none' : 'awake',
|
|
151
|
+
harness: 'running',
|
|
152
|
+
attachment: targetKind === 'local' ? 'local' : 'detached',
|
|
153
|
+
},
|
|
154
|
+
host: { name: `imp-${id}`, id: `imp-id-${id}`, issuer: 'https://broker.test' },
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
sessions.set(id, {
|
|
158
|
+
descriptor,
|
|
159
|
+
lastActivityAt: now,
|
|
160
|
+
targetKind,
|
|
161
|
+
messages: [],
|
|
162
|
+
messageKeys: new Map(),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return descriptor;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
calls,
|
|
170
|
+
session: {
|
|
171
|
+
spawn: (spawn) =>
|
|
172
|
+
answer('spawn', spawn, () => {
|
|
173
|
+
const key = spawn.idempotencyKey;
|
|
174
|
+
const known = key === undefined ? undefined : sessions.get(spawnKeys.get(key) ?? '');
|
|
175
|
+
|
|
176
|
+
if (known !== undefined) {
|
|
177
|
+
return { session: known.descriptor };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const descriptor = createSession(spawn);
|
|
181
|
+
|
|
182
|
+
if (key !== undefined) {
|
|
183
|
+
spawnKeys.set(key, descriptor.id);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { session: descriptor };
|
|
187
|
+
}),
|
|
188
|
+
get: (get) =>
|
|
189
|
+
answer('get', get, () => {
|
|
190
|
+
const session = getSession(get);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
session: session.descriptor,
|
|
194
|
+
prompt: null,
|
|
195
|
+
lastActivityAt: session.lastActivityAt,
|
|
196
|
+
pending: null,
|
|
197
|
+
result: null,
|
|
198
|
+
sessionRecord: null,
|
|
199
|
+
};
|
|
200
|
+
}),
|
|
201
|
+
list: (list) =>
|
|
202
|
+
answer('list', list, () => ({
|
|
203
|
+
sessions: [...sessions.values()].map((session) => session.descriptor),
|
|
204
|
+
})),
|
|
205
|
+
|
|
206
|
+
// a plain kill of an exited session forgets it; a stop-only kill leaves it listed
|
|
207
|
+
kill: (kill) =>
|
|
208
|
+
answer('kill', kill, () => {
|
|
209
|
+
const session = getSession(kill);
|
|
210
|
+
|
|
211
|
+
if (session.descriptor.lifecycle.harness !== 'running') {
|
|
212
|
+
if (kill.stopOnly !== true) {
|
|
213
|
+
sessions.delete(session.descriptor.id);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { stopped: false };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (session.targetKind === 'local') {
|
|
220
|
+
setHarness(session, 'exited', 'stop');
|
|
221
|
+
} else {
|
|
222
|
+
setHarness(session, 'suspended', 'sleep');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { stopped: true };
|
|
226
|
+
}),
|
|
227
|
+
|
|
228
|
+
// atc refuses an adopt of a session that runs with the code of an unknown session
|
|
229
|
+
adopt: (adopt) =>
|
|
230
|
+
answer('adopt', adopt, () => {
|
|
231
|
+
const session = sessions.get(sessionInputSchema.parse(adopt).session);
|
|
232
|
+
|
|
233
|
+
if (session === undefined) {
|
|
234
|
+
throw new FakeAtcError('session_dead');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (session.descriptor.lifecycle.harness === 'running') {
|
|
238
|
+
throw new FakeAtcError('no_such_session');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
setHarness(session, 'running', 'run');
|
|
242
|
+
|
|
243
|
+
session.lastActivityAt = input.clock.now();
|
|
244
|
+
|
|
245
|
+
return {};
|
|
246
|
+
}),
|
|
247
|
+
|
|
248
|
+
// a target that destroys its hosts takes two calls: the first answers a confirm token
|
|
249
|
+
forget: (forget) =>
|
|
250
|
+
answer('forget', forget, () => {
|
|
251
|
+
const session = getSession(forget);
|
|
252
|
+
const id = session.descriptor.id;
|
|
253
|
+
|
|
254
|
+
if (forget.refuseLive === true && session.descriptor.lifecycle.harness === 'running') {
|
|
255
|
+
throw new FakeAtcError('session_live');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (session.targetKind === 'suspending' && forget.confirmToken === undefined) {
|
|
259
|
+
confirmTokens.set(`confirm-${id}`, id);
|
|
260
|
+
|
|
261
|
+
return { confirmToken: `confirm-${id}`, expiresAt: input.clock.now() + 60_000 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (
|
|
265
|
+
session.targetKind === 'suspending' &&
|
|
266
|
+
confirmTokens.get(forget.confirmToken ?? '') !== id
|
|
267
|
+
) {
|
|
268
|
+
throw new FakeAtcError('confirm_token_invalid');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
sessions.delete(id);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
forgotten: true as const,
|
|
275
|
+
destroyed: session.targetKind === 'suspending',
|
|
276
|
+
workspace: { outcome: 'none' as const },
|
|
277
|
+
};
|
|
278
|
+
}),
|
|
279
|
+
message: (message) =>
|
|
280
|
+
answer('message', message, () => {
|
|
281
|
+
const session = getSession(message);
|
|
282
|
+
|
|
283
|
+
if (session.descriptor.lifecycle.harness !== 'running') {
|
|
284
|
+
throw new FakeAtcError('session_dead');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const key = message.idempotencyKey;
|
|
288
|
+
const known = key === undefined ? undefined : session.messageKeys.get(key);
|
|
289
|
+
const id = known ?? `m${session.messages.length + 1}-${session.descriptor.id}`;
|
|
290
|
+
|
|
291
|
+
if (known === undefined) {
|
|
292
|
+
const text = typeof message.text === 'string' ? message.text : '';
|
|
293
|
+
|
|
294
|
+
session.messages.push(text);
|
|
295
|
+
|
|
296
|
+
session.lastActivityAt = input.clock.now();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (key !== undefined) {
|
|
300
|
+
session.messageKeys.set(key, id);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { message: id, status: 'queued' as const };
|
|
304
|
+
}),
|
|
305
|
+
},
|
|
306
|
+
listMessages: (session) => sessions.get(session)?.messages ?? [],
|
|
307
|
+
patchSession: (id, patch) => {
|
|
308
|
+
const session = sessions.get(id);
|
|
309
|
+
|
|
310
|
+
if (session === undefined) {
|
|
311
|
+
throw new FakeAtcError('no_such_session');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const { harness, desired, lastActivityAt, ...fields } = patch;
|
|
315
|
+
|
|
316
|
+
session.descriptor = {
|
|
317
|
+
...session.descriptor,
|
|
318
|
+
...fields,
|
|
319
|
+
lifecycle: {
|
|
320
|
+
...session.descriptor.lifecycle,
|
|
321
|
+
harness: harness ?? session.descriptor.lifecycle.harness,
|
|
322
|
+
desired: desired ?? session.descriptor.lifecycle.desired,
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
session.lastActivityAt = lastActivityAt ?? session.lastActivityAt;
|
|
327
|
+
},
|
|
328
|
+
failNext: (call, error) => {
|
|
329
|
+
failures.set(call, error);
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Attention, Instant, SessionReading } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import { findCallerIdentity } from './find-caller-identity.ts';
|
|
5
|
+
import type { AtcSession, AtcSessionRead } from './types.ts';
|
|
6
|
+
|
|
7
|
+
// atc lists a session as `exited` whenever its agent process is not running, a process asleep
|
|
8
|
+
// inside its host included, so the reading comes from the harness layer of the lifecycle.
|
|
9
|
+
export function toSessionReading(read: AtcSessionRead): SessionReading {
|
|
10
|
+
const session = read.session;
|
|
11
|
+
const atcSession = brandSchemas.atcSessionId.parse(session.id);
|
|
12
|
+
|
|
13
|
+
const agentSession =
|
|
14
|
+
session.agentSessionID === undefined
|
|
15
|
+
? null
|
|
16
|
+
: brandSchemas.agentSessionId.parse(session.agentSessionID);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
match(session.lifecycle.harness)
|
|
20
|
+
.returnType<SessionReading>()
|
|
21
|
+
.with('running', () => ({
|
|
22
|
+
kind: 'running',
|
|
23
|
+
atcSession,
|
|
24
|
+
agentSession,
|
|
25
|
+
caller: findCallerIdentity(session),
|
|
26
|
+
attention: toAttention(session.state),
|
|
27
|
+
lastActivityAt: toInstant(Math.max(read.lastActivityAt, session.lastAttachedAt)),
|
|
28
|
+
lastOwnerAt: toInstant(session.lastAttachedAt),
|
|
29
|
+
usageLimitResetsAt: findUsageLimitResetsAt(),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
// `desired` is `run` only when the host slept by itself, with nobody asking
|
|
33
|
+
.with('suspended', () => ({
|
|
34
|
+
kind: 'suspended',
|
|
35
|
+
atcSession,
|
|
36
|
+
agentSession,
|
|
37
|
+
asked: session.lifecycle.desired !== 'run',
|
|
38
|
+
}))
|
|
39
|
+
.with('exited', () => ({ kind: 'ended', atcSession, agentSession }))
|
|
40
|
+
.exhaustive()
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `done`: the turn ended and the session waits for a prompt. `exited` never comes with a running
|
|
45
|
+
// process, and reads as working.
|
|
46
|
+
function toAttention(state: AtcSession['state']): Attention {
|
|
47
|
+
return match(state)
|
|
48
|
+
.returnType<Attention>()
|
|
49
|
+
.with('running', 'exited', () => 'working')
|
|
50
|
+
.with('needs_you', () => 'needs-input')
|
|
51
|
+
.with('done', () => 'turn-ended')
|
|
52
|
+
.exhaustive();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toInstant(timeMs: number): Instant {
|
|
56
|
+
return brandSchemas.instant.parse(timeMs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// atc reports no usage limit yet, so no session reads as stopped at the limit. When atc gives the
|
|
60
|
+
// limit and its reset time, this is the one place that maps them to the reading (v1-open.md).
|
|
61
|
+
function findUsageLimitResetsAt(): Instant | null {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DAEMON_CONTRACT } from '@atcx/contract';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
type SessionProcedures = (typeof DAEMON_CONTRACT)['session'];
|
|
5
|
+
|
|
6
|
+
type InputOf<Procedure> = Procedure extends { '~orpc': { inputSchema?: infer Schema } }
|
|
7
|
+
? z.input<Schema>
|
|
8
|
+
: never;
|
|
9
|
+
|
|
10
|
+
type OutputOf<Procedure> = Procedure extends { '~orpc': { outputSchema?: infer Schema } }
|
|
11
|
+
? z.output<Schema>
|
|
12
|
+
: never;
|
|
13
|
+
|
|
14
|
+
type CallOf<Procedure> = (input: InputOf<Procedure>) => Promise<OutputOf<Procedure>>;
|
|
15
|
+
|
|
16
|
+
// `host` is the VM host that runs the session: sub-sessions on one machine share it, and a session
|
|
17
|
+
// with no VM host has none. atc reports no usage limit.
|
|
18
|
+
export type AtcSession = OutputOf<SessionProcedures['get']>['session'];
|
|
19
|
+
|
|
20
|
+
// `session.get`'s answer. `lastActivityAt` is the time of the session's last trail event, in
|
|
21
|
+
// milliseconds, or its creation time when it has none.
|
|
22
|
+
export type AtcSessionRead = OutputOf<SessionProcedures['get']>;
|
|
23
|
+
|
|
24
|
+
// The atc calls that the adapter makes, with atc's own inputs and answers. A call that atc refuses
|
|
25
|
+
// rejects with an error that carries atc's `code` and `data`.
|
|
26
|
+
export interface AtcClient {
|
|
27
|
+
readonly session: {
|
|
28
|
+
readonly spawn: (
|
|
29
|
+
input: InputOf<SessionProcedures['spawn']>,
|
|
30
|
+
) => Promise<{ readonly session: AtcSession }>;
|
|
31
|
+
readonly get: (input: InputOf<SessionProcedures['get']>) => Promise<AtcSessionRead>;
|
|
32
|
+
readonly list: (
|
|
33
|
+
input: InputOf<SessionProcedures['list']>,
|
|
34
|
+
) => Promise<{ readonly sessions: readonly AtcSession[] }>;
|
|
35
|
+
readonly kill: CallOf<SessionProcedures['kill']>;
|
|
36
|
+
readonly adopt: CallOf<SessionProcedures['adopt']>;
|
|
37
|
+
readonly forget: CallOf<SessionProcedures['forget']>;
|
|
38
|
+
readonly message: CallOf<SessionProcedures['message']>;
|
|
39
|
+
};
|
|
40
|
+
}
|