@runuai/host 0.4.2 → 0.5.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/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/meta/_journal.json +15 -1
- package/db/schema.ts +61 -0
- package/images/standard/Dockerfile +15 -0
- package/lib/agent-cli.ts +6 -0
- package/lib/agents/claude.ts +22 -16
- package/lib/agents/codex.ts +36 -22
- package/lib/agents/durable-proc.ts +306 -0
- package/lib/agents/transport.ts +229 -0
- package/lib/browser-testing.ts +235 -0
- package/lib/mcp-connections.ts +554 -0
- package/lib/mcp-gateway.ts +342 -0
- package/lib/orchestrator.ts +274 -14
- package/lib/standard-image.ts +137 -10
- package/package.json +2 -1
- package/runner/runner.mjs +208 -0
- package/scripts/agent/task-up.sh +10 -0
- package/src/index.ts +52 -1
- package/src/main.ts +102 -0
- package/src/protocol.ts +82 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- ADR-057: user-authed MCP connections. Secrets sealed with the host master
|
|
2
|
+
-- key (ct.nonce base64, like host_project_env.value_enc); the cloud holds only
|
|
3
|
+
-- non-secret metadata.
|
|
4
|
+
CREATE TABLE `host_mcp_connections` (
|
|
5
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
6
|
+
`user_id` text NOT NULL,
|
|
7
|
+
`url` text NOT NULL,
|
|
8
|
+
`auth_kind` text NOT NULL,
|
|
9
|
+
`token_endpoint` text,
|
|
10
|
+
`redirect_uri` text,
|
|
11
|
+
`client_id` text,
|
|
12
|
+
`client_secret_enc` text,
|
|
13
|
+
`pkce_verifier_enc` text,
|
|
14
|
+
`secret_enc` text,
|
|
15
|
+
`status` text DEFAULT 'pending' NOT NULL,
|
|
16
|
+
`scopes` text,
|
|
17
|
+
`updated_at` integer NOT NULL
|
|
18
|
+
);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- ADR-061: durable agent sessions. One row per (task, agent) tracking the
|
|
2
|
+
-- live in-container runner and how far the host has consumed its outbox —
|
|
3
|
+
-- the byte offset is what makes a host restart resume instead of respawn.
|
|
4
|
+
CREATE TABLE `host_agent_sessions` (
|
|
5
|
+
`task_id` text NOT NULL,
|
|
6
|
+
`agent_id` text NOT NULL,
|
|
7
|
+
`session_dir` text NOT NULL,
|
|
8
|
+
`container_name` text NOT NULL,
|
|
9
|
+
`kind` text NOT NULL,
|
|
10
|
+
`outbox_offset` integer DEFAULT 0 NOT NULL,
|
|
11
|
+
`status` text DEFAULT 'running' NOT NULL,
|
|
12
|
+
`created_at` integer NOT NULL,
|
|
13
|
+
`updated_at` integer NOT NULL,
|
|
14
|
+
PRIMARY KEY(`task_id`, `agent_id`)
|
|
15
|
+
);
|
|
@@ -57,6 +57,20 @@
|
|
|
57
57
|
"when": 1779900008000,
|
|
58
58
|
"tag": "0007_host_github_token_kind",
|
|
59
59
|
"breakpoints": true
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"idx": 8,
|
|
63
|
+
"version": "6",
|
|
64
|
+
"when": 1779900009000,
|
|
65
|
+
"tag": "0008_host_mcp_connections",
|
|
66
|
+
"breakpoints": true
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"idx": 9,
|
|
70
|
+
"version": "6",
|
|
71
|
+
"when": 1779900010000,
|
|
72
|
+
"tag": "0009_host_agent_sessions",
|
|
73
|
+
"breakpoints": true
|
|
60
74
|
}
|
|
61
75
|
]
|
|
62
|
-
}
|
|
76
|
+
}
|
package/db/schema.ts
CHANGED
|
@@ -115,3 +115,64 @@ export const hostProjectEnv = sqliteTable(
|
|
|
115
115
|
|
|
116
116
|
export type HostProjectEnv = typeof hostProjectEnv.$inferSelect;
|
|
117
117
|
export type NewHostProjectEnv = typeof hostProjectEnv.$inferInsert;
|
|
118
|
+
|
|
119
|
+
// User-authed MCP connections (ADR-057). One row per cloud connection id; all
|
|
120
|
+
// secret material (static header, OAuth client secret, PKCE verifier, tokens)
|
|
121
|
+
// is sealed with the host master key in single-column `ct.nonce` base64 form
|
|
122
|
+
// (same packing as host_project_env). The cloud only ever holds the non-secret
|
|
123
|
+
// metadata; acks never echo secrets back.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// host_agent_sessions — ADR-061 durable agent sessions. One row per
|
|
126
|
+
// (task, agent): where the live in-container runner's session dir is and how
|
|
127
|
+
// far the host has consumed its outbox. The byte offset is what turns a host
|
|
128
|
+
// restart into "resume tailing" instead of "kill and respawn"; attachability
|
|
129
|
+
// itself is judged live off the runner's heartbeat file, not this row.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export const hostAgentSessions = sqliteTable(
|
|
133
|
+
"host_agent_sessions",
|
|
134
|
+
{
|
|
135
|
+
taskId: text("task_id").notNull(),
|
|
136
|
+
agentId: text("agent_id").notNull(),
|
|
137
|
+
// Host-side absolute path of the session dir (on the workspace mount,
|
|
138
|
+
// identical in-container — task-up bind-mounts the workspace at its own
|
|
139
|
+
// host path).
|
|
140
|
+
sessionDir: text("session_dir").notNull(),
|
|
141
|
+
containerName: text("container_name").notNull(),
|
|
142
|
+
kind: text("kind").notNull(), // "claude" | "codex" | ...
|
|
143
|
+
outboxOffset: integer("outbox_offset", { mode: "number" })
|
|
144
|
+
.notNull()
|
|
145
|
+
.default(0),
|
|
146
|
+
status: text("status").notNull().default("running"), // running|closed
|
|
147
|
+
createdAt: integer("created_at", { mode: "number" }).notNull(),
|
|
148
|
+
updatedAt: integer("updated_at", { mode: "number" }).notNull(),
|
|
149
|
+
},
|
|
150
|
+
(t) => ({
|
|
151
|
+
pk: primaryKey({ columns: [t.taskId, t.agentId] }),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
export type HostAgentSession = typeof hostAgentSessions.$inferSelect;
|
|
156
|
+
|
|
157
|
+
export const mcpConnections = sqliteTable("host_mcp_connections", {
|
|
158
|
+
id: text("id").primaryKey(), // cloud uai_mcp_connections id
|
|
159
|
+
userId: text("user_id").notNull(),
|
|
160
|
+
url: text("url").notNull(),
|
|
161
|
+
// "oauth" | "token" | "none"
|
|
162
|
+
authKind: text("auth_kind").notNull(),
|
|
163
|
+
// OAuth machinery discovered at probe time.
|
|
164
|
+
tokenEndpoint: text("token_endpoint"),
|
|
165
|
+
redirectUri: text("redirect_uri"),
|
|
166
|
+
clientId: text("client_id"),
|
|
167
|
+
clientSecretEnc: text("client_secret_enc"),
|
|
168
|
+
// PKCE verifier held between probe and oauth.complete.
|
|
169
|
+
pkceVerifierEnc: text("pkce_verifier_enc"),
|
|
170
|
+
// Sealed JSON: {"headerName","headerValue"} (token kind) or
|
|
171
|
+
// {"accessToken","refreshToken","expiresAt"} (oauth kind). Null for "none".
|
|
172
|
+
secretEnc: text("secret_enc"),
|
|
173
|
+
status: text("status").notNull().default("pending"), // pending|connected
|
|
174
|
+
scopes: text("scopes"),
|
|
175
|
+
updatedAt: integer("updated_at", { mode: "number" }).notNull(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export type McpConnection = typeof mcpConnections.$inferSelect;
|
|
@@ -70,6 +70,21 @@ RUN apt-get update \
|
|
|
70
70
|
zsh \
|
|
71
71
|
&& rm -rf /var/lib/apt/lists/*
|
|
72
72
|
|
|
73
|
+
# ADR-053: agent browser (Playwright/Chromium) runtime libs + the watchable
|
|
74
|
+
# display stack (Xvfb → x11vnc → noVNC). Baked here because the
|
|
75
|
+
# per-container background apt raced session start (headless-stuck MCP
|
|
76
|
+
# server, viewer lagging the preview by minutes — Debian's novnc drags a
|
|
77
|
+
# large Python tree). The Chromium BINARY still comes from the shared
|
|
78
|
+
# uai-playwright volume (downloaded once per host); these are its deps.
|
|
79
|
+
RUN apt-get update \
|
|
80
|
+
&& apt-get install -y --no-install-recommends \
|
|
81
|
+
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
82
|
+
libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 \
|
|
83
|
+
libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
|
84
|
+
fonts-liberation fonts-unifont \
|
|
85
|
+
xvfb x11vnc novnc websockify \
|
|
86
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
87
|
+
|
|
73
88
|
# GitHub CLI (`gh`) — used by the ship/PR flow inside the container.
|
|
74
89
|
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
75
90
|
| tee /usr/share/keyrings/githubcli-archive-keyring.gpg >/dev/null \
|
package/lib/agent-cli.ts
CHANGED
|
@@ -212,6 +212,11 @@ async function main() {
|
|
|
212
212
|
break;
|
|
213
213
|
}
|
|
214
214
|
case "whoami ": case "whoami undefined": out({ apiUrl: API_URL }); break;
|
|
215
|
+
case "react heart": case "react check": case "react x": {
|
|
216
|
+
const r = await api("POST", "/api/agent/react", { emoji: action, msg: flags.msg || undefined });
|
|
217
|
+
out((r.reacted ? "reacted to " : "un-reacted from ") + r.messageId);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
215
220
|
default:
|
|
216
221
|
console.error([
|
|
217
222
|
"uai — agent CLI. Commands:",
|
|
@@ -222,6 +227,7 @@ async function main() {
|
|
|
222
227
|
" uai memory search <query>",
|
|
223
228
|
" uai memory save <text> [--project id] [--tags a,b]",
|
|
224
229
|
" uai memory delete <id>",
|
|
230
|
+
" uai react <heart|check|x> [--msg #id] (no --msg = the message you were last handed)",
|
|
225
231
|
" uai whoami",
|
|
226
232
|
].join("\\n"));
|
|
227
233
|
process.exit(argv.length ? 1 : 0);
|
package/lib/agents/claude.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { newId } from "../ulid";
|
|
24
|
-
import {
|
|
24
|
+
import { createAgentTransport, type LineTransport } from "./transport";
|
|
25
25
|
import { register } from "./registry";
|
|
26
26
|
import type {
|
|
27
27
|
AgentEvent,
|
|
@@ -35,10 +35,11 @@ import type {
|
|
|
35
35
|
// resolves these aliases to the current dated snapshots, so this list is
|
|
36
36
|
// stable across point releases. UPDATE WHEN MODELS CHANGE (new family or a
|
|
37
37
|
// retired alias). Order is display order in the cloud picker.
|
|
38
|
-
const CLAUDE_MODELS = ["opus", "sonnet", "haiku"];
|
|
38
|
+
const CLAUDE_MODELS = ["fable", "opus", "sonnet", "haiku"];
|
|
39
39
|
|
|
40
|
-
// Opus ("opus" alias = Opus 4.8)
|
|
41
|
-
//
|
|
40
|
+
// Opus ("opus" alias = Opus 4.8) stays the default; "fable" (Claude
|
|
41
|
+
// Fable 5, the Mythos-class flagship) is opt-in per agent. Update
|
|
42
|
+
// alongside CLAUDE_MODELS.
|
|
42
43
|
const CLAUDE_DEFAULT_MODEL = "opus";
|
|
43
44
|
|
|
44
45
|
// Reasoning levels passed through via `claude --effort <level>`. Taken from
|
|
@@ -192,11 +193,12 @@ export class ClaudeSession implements AgentSession {
|
|
|
192
193
|
readonly agentId: string;
|
|
193
194
|
readonly kind: AgentKind = "claude";
|
|
194
195
|
|
|
195
|
-
private readonly proc:
|
|
196
|
+
private readonly proc: LineTransport;
|
|
196
197
|
private readonly handlers = new Set<AgentEventHandler>();
|
|
197
198
|
private closed = false;
|
|
198
199
|
|
|
199
200
|
constructor(args: {
|
|
201
|
+
taskId: string;
|
|
200
202
|
agent: RosterAgent;
|
|
201
203
|
containerName: string;
|
|
202
204
|
systemPreamble: string;
|
|
@@ -230,16 +232,20 @@ export class ClaudeSession implements AgentSession {
|
|
|
230
232
|
// it needs CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or an
|
|
231
233
|
// API key. These live in the host-agent's env (never the cloud, ADR-015);
|
|
232
234
|
// only ones actually set are forwarded.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
// ADR-061: durable by default — the CLI is owned by an in-container
|
|
236
|
+
// runner and survives host restarts (attach resumes it); legacy pipes
|
|
237
|
+
// behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
|
|
238
|
+
// live runner can be re-attached (allowAttach).
|
|
239
|
+
this.proc = createAgentTransport({
|
|
240
|
+
taskId: args.taskId,
|
|
241
|
+
agentId: this.agentId,
|
|
242
|
+
containerName: args.containerName,
|
|
243
|
+
cli: "claude",
|
|
236
244
|
cliArgs,
|
|
237
|
-
["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
238
|
-
args.agentEnv ?? {},
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
command,
|
|
242
|
-
args: argv,
|
|
245
|
+
passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
246
|
+
explicitEnv: args.agentEnv ?? {},
|
|
247
|
+
allowAttach: true,
|
|
248
|
+
kind: "claude",
|
|
243
249
|
debugLabel: `claude:${this.agentId}`,
|
|
244
250
|
});
|
|
245
251
|
this.proc.onLine((line) => {
|
|
@@ -348,6 +354,6 @@ register({
|
|
|
348
354
|
process.env.ANTHROPIC_API_KEY ||
|
|
349
355
|
process.env.ANTHROPIC_AUTH_TOKEN,
|
|
350
356
|
),
|
|
351
|
-
create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
|
|
352
|
-
new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
|
|
357
|
+
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
358
|
+
new ClaudeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
353
359
|
});
|
package/lib/agents/codex.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { homedir } from "node:os";
|
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
|
|
35
35
|
import { newId } from "../ulid";
|
|
36
|
-
import {
|
|
36
|
+
import { createAgentTransport, type LineTransport } from "./transport";
|
|
37
37
|
import { register } from "./registry";
|
|
38
38
|
import type {
|
|
39
39
|
AgentEvent,
|
|
@@ -49,6 +49,9 @@ import type {
|
|
|
49
49
|
// Sourced from that picker; UPDATE WHEN MODELS CHANGE. Order = display order
|
|
50
50
|
// in the cloud picker. Legacy models remain reachable via config.toml.
|
|
51
51
|
const CODEX_MODELS = [
|
|
52
|
+
"gpt-5.6-sol",
|
|
53
|
+
"gpt-5.6-terra",
|
|
54
|
+
"gpt-5.6-luna",
|
|
52
55
|
"gpt-5.5",
|
|
53
56
|
"gpt-5.4",
|
|
54
57
|
"gpt-5.4-mini",
|
|
@@ -57,16 +60,20 @@ const CODEX_MODELS = [
|
|
|
57
60
|
"gpt-5.2",
|
|
58
61
|
];
|
|
59
62
|
|
|
60
|
-
// gpt-5.
|
|
61
|
-
// CODEX_MODELS. When an agent's model is null the adapter
|
|
62
|
-
// entirely and Codex uses the user's own configured
|
|
63
|
-
|
|
63
|
+
// gpt-5.6-sol is Codex's current frontier coding model (the CLI default).
|
|
64
|
+
// Update alongside CODEX_MODELS. When an agent's model is null the adapter
|
|
65
|
+
// omits the override entirely and Codex uses the user's own configured
|
|
66
|
+
// default.
|
|
67
|
+
const CODEX_DEFAULT_MODEL = "gpt-5.6-sol";
|
|
64
68
|
|
|
65
|
-
// Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is
|
|
66
|
-
// picker's "Extra high".
|
|
67
|
-
|
|
69
|
+
// Reasoning levels set via `-c model_reasoning_effort=<level>`. "xhigh" is
|
|
70
|
+
// the picker's "Extra high"; gpt-5.6 added "max" and "ultra" (ultra =
|
|
71
|
+
// maximum reasoning with automatic task delegation). UPDATE WHEN CODEX
|
|
72
|
+
// CHANGES its reasoning levels.
|
|
73
|
+
const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
|
|
68
74
|
|
|
69
|
-
// medium
|
|
75
|
+
// medium stays OUR default (the 5.6 CLI defaults to low — too light for
|
|
76
|
+
// agentic task work). Update alongside CODEX_EFFORTS.
|
|
70
77
|
const CODEX_DEFAULT_EFFORT = "medium";
|
|
71
78
|
|
|
72
79
|
// ---------------------------------------------------------------------------
|
|
@@ -206,7 +213,7 @@ export class CodexSession implements AgentSession {
|
|
|
206
213
|
readonly agentId: string;
|
|
207
214
|
readonly kind: AgentKind = "codex";
|
|
208
215
|
|
|
209
|
-
private readonly proc:
|
|
216
|
+
private readonly proc: LineTransport;
|
|
210
217
|
private readonly handlers = new Set<AgentEventHandler>();
|
|
211
218
|
private readonly systemPreamble: string;
|
|
212
219
|
private closed = false;
|
|
@@ -224,6 +231,7 @@ export class CodexSession implements AgentSession {
|
|
|
224
231
|
private readonly ready: Promise<void>;
|
|
225
232
|
|
|
226
233
|
constructor(args: {
|
|
234
|
+
taskId: string;
|
|
227
235
|
agent: RosterAgent;
|
|
228
236
|
containerName: string;
|
|
229
237
|
systemPreamble: string;
|
|
@@ -244,16 +252,22 @@ export class CodexSession implements AgentSession {
|
|
|
244
252
|
codexArgs.push("-c", `model_reasoning_effort=${args.agent.effort}`);
|
|
245
253
|
}
|
|
246
254
|
codexArgs.push("app-server");
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
255
|
+
// ADR-061: the CLI is owned by an in-container runner (durable across
|
|
256
|
+
// host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
|
|
257
|
+
// re-handshakes per host process (initialize + thread/start live in this
|
|
258
|
+
// object), so a leftover runner is never attached — allowAttach:false
|
|
259
|
+
// stops it and spawns fresh. Its final output before the stop still
|
|
260
|
+
// reaches the cloud via the outbox.
|
|
261
|
+
this.proc = createAgentTransport({
|
|
262
|
+
taskId: args.taskId,
|
|
263
|
+
agentId: this.agentId,
|
|
264
|
+
containerName: args.containerName,
|
|
265
|
+
cli: "codex",
|
|
266
|
+
cliArgs: codexArgs,
|
|
267
|
+
passEnv: [],
|
|
268
|
+
explicitEnv: args.agentEnv ?? {},
|
|
269
|
+
allowAttach: false,
|
|
270
|
+
kind: "codex",
|
|
257
271
|
debugLabel: `codex:${this.agentId}`,
|
|
258
272
|
});
|
|
259
273
|
this.proc.onLine((line) => this.onLine(line));
|
|
@@ -531,6 +545,6 @@ register({
|
|
|
531
545
|
existsSync(
|
|
532
546
|
join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
|
|
533
547
|
),
|
|
534
|
-
create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
|
|
535
|
-
new CodexSession({ agent, containerName, systemPreamble, agentEnv }),
|
|
548
|
+
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
549
|
+
new CodexSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
536
550
|
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableProcess (ADR-061) — LineProcess's durable twin. Instead of holding
|
|
3
|
+
* the agent CLI's pipes, it talks to the in-container runner
|
|
4
|
+
* (runner/runner.mjs) through two append-only JSONL files on the
|
|
5
|
+
* task-workspace bind mount:
|
|
6
|
+
*
|
|
7
|
+
* writeLine() → append to <sessionDir>/inbox.jsonl
|
|
8
|
+
* onLine() ← poll-tail <sessionDir>/outbox.jsonl from a byte offset
|
|
9
|
+
*
|
|
10
|
+
* The CLI's lifetime is decoupled from this object: dropping it (host
|
|
11
|
+
* restart) leaves the runner and CLI running; `attach()` with the persisted
|
|
12
|
+
* outbox offset resumes exactly where consumption stopped. `close()` is the
|
|
13
|
+
* real teardown — it sends the runner a stop control.
|
|
14
|
+
*
|
|
15
|
+
* Exit is observed via the runner's `{"__uai":"exit"}` meta line, with a
|
|
16
|
+
* stale-heartbeat check as the backstop for a killed container. Meta lines
|
|
17
|
+
* never reach onLine handlers — adapters see the same protocol stream
|
|
18
|
+
* LineProcess gave them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import {
|
|
23
|
+
closeSync,
|
|
24
|
+
fstatSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
openSync,
|
|
27
|
+
promises as fsp,
|
|
28
|
+
readSync,
|
|
29
|
+
statSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { fileURLToPath } from "node:url";
|
|
33
|
+
|
|
34
|
+
import type { ExitHandler, LineHandler } from "./proc";
|
|
35
|
+
|
|
36
|
+
const POLL_MS = 75;
|
|
37
|
+
/** Runner beats every 5 s; 25 s of silence without an exit meta = dead. */
|
|
38
|
+
const HEARTBEAT_STALE_MS = 25_000;
|
|
39
|
+
/** After close(), how long to wait for the exit meta before giving up. */
|
|
40
|
+
const CLOSE_GRACE_MS = 8_000;
|
|
41
|
+
|
|
42
|
+
/** Host-side absolute path of the bundled in-container runner script. */
|
|
43
|
+
export function runnerScriptPath(): string {
|
|
44
|
+
return fileURLToPath(new URL("../../runner/runner.mjs", import.meta.url));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DurableProcessOptions {
|
|
48
|
+
/** Session dir as seen FROM THE HOST (on the workspace bind mount). */
|
|
49
|
+
hostSessionDir: string;
|
|
50
|
+
/**
|
|
51
|
+
* Command that launches the runner (e.g. `docker exec -d … node
|
|
52
|
+
* /opt/uai/runner.mjs <containerSessionDir> -- claude …`). Omit to ATTACH
|
|
53
|
+
* to a runner that is already alive (boot reconciliation).
|
|
54
|
+
*/
|
|
55
|
+
spawnCommand?: { command: string; args: string[] };
|
|
56
|
+
/** Resume consumption from this outbox byte offset (attach path). */
|
|
57
|
+
initialOutboxOffset?: number;
|
|
58
|
+
/** Called after each poll batch whose lines were delivered — persist this
|
|
59
|
+
* offset so a host restart resumes here instead of replaying. */
|
|
60
|
+
onOffsetAdvance?: (offset: number) => void;
|
|
61
|
+
/** Called the moment close() is requested (before the runner has actually
|
|
62
|
+
* died) — lets the owner mark the session unattachable immediately, so a
|
|
63
|
+
* re-create during the teardown grace window spawns fresh instead of
|
|
64
|
+
* attaching to a dying runner. */
|
|
65
|
+
onCloseRequested?: () => void;
|
|
66
|
+
/** Mirrors LineProcess: log raw traffic when UAI_DEBUG_AGENTS is set. */
|
|
67
|
+
debugLabel?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RunnerMeta {
|
|
71
|
+
__uai: string;
|
|
72
|
+
code?: number | null;
|
|
73
|
+
stderrTail?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class DurableProcess {
|
|
77
|
+
private readonly dir: string;
|
|
78
|
+
private readonly outboxPath: string;
|
|
79
|
+
private readonly inboxPath: string;
|
|
80
|
+
private readonly heartbeatPath: string;
|
|
81
|
+
|
|
82
|
+
private readonly lineHandlers = new Set<LineHandler>();
|
|
83
|
+
private readonly exitHandlers = new Set<ExitHandler>();
|
|
84
|
+
|
|
85
|
+
private offset: number;
|
|
86
|
+
private lineBuf = "";
|
|
87
|
+
private stderrBuf = "";
|
|
88
|
+
private closed = false;
|
|
89
|
+
private detached = false;
|
|
90
|
+
private sawSpawnMeta = false;
|
|
91
|
+
private readonly startedAt = Date.now();
|
|
92
|
+
private poller: ReturnType<typeof setInterval> | null = null;
|
|
93
|
+
private closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
94
|
+
private inboxChain: Promise<void> = Promise.resolve();
|
|
95
|
+
private readonly onOffsetAdvance: ((offset: number) => void) | null;
|
|
96
|
+
private readonly onCloseRequested: (() => void) | null;
|
|
97
|
+
private readonly debug: string | null;
|
|
98
|
+
|
|
99
|
+
constructor(opts: DurableProcessOptions) {
|
|
100
|
+
this.dir = opts.hostSessionDir;
|
|
101
|
+
this.outboxPath = join(this.dir, "outbox.jsonl");
|
|
102
|
+
this.inboxPath = join(this.dir, "inbox.jsonl");
|
|
103
|
+
this.heartbeatPath = join(this.dir, "heartbeat");
|
|
104
|
+
this.offset = opts.initialOutboxOffset ?? 0;
|
|
105
|
+
this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
|
|
106
|
+
this.onCloseRequested = opts.onCloseRequested ?? null;
|
|
107
|
+
this.debug =
|
|
108
|
+
opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
|
|
109
|
+
|
|
110
|
+
mkdirSync(this.dir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
if (opts.spawnCommand) {
|
|
113
|
+
if (this.debug) {
|
|
114
|
+
this.log(
|
|
115
|
+
`spawn: ${opts.spawnCommand.command} ${opts.spawnCommand.args.join(" ")}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const child = spawn(opts.spawnCommand.command, opts.spawnCommand.args, {
|
|
119
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
120
|
+
});
|
|
121
|
+
child.stderr?.setEncoding("utf8");
|
|
122
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
123
|
+
this.stderrBuf = (this.stderrBuf + chunk).slice(-8192);
|
|
124
|
+
});
|
|
125
|
+
// `docker exec -d` exits 0 immediately on success; non-zero means the
|
|
126
|
+
// runner never started (bad container, bad mount) — fail loudly now.
|
|
127
|
+
child.on("exit", (code) => {
|
|
128
|
+
if (code !== null && code !== 0) this.finish(null);
|
|
129
|
+
});
|
|
130
|
+
child.on("error", () => this.finish(null));
|
|
131
|
+
child.unref();
|
|
132
|
+
} else {
|
|
133
|
+
this.sawSpawnMeta = true; // attach: the runner pre-exists
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.poller = setInterval(() => this.poll(), POLL_MS);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private log(msg: string): void {
|
|
140
|
+
console.error(`[uai-agent ${this.debug}] ${msg}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- outbox tail ---------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
private poll(): void {
|
|
146
|
+
if (this.closed || this.detached) return;
|
|
147
|
+
let fd: number;
|
|
148
|
+
try {
|
|
149
|
+
fd = openSync(this.outboxPath, "r");
|
|
150
|
+
} catch {
|
|
151
|
+
this.checkLiveness();
|
|
152
|
+
return; // outbox not created yet
|
|
153
|
+
}
|
|
154
|
+
const before = this.offset;
|
|
155
|
+
try {
|
|
156
|
+
const size = fstatSync(fd).size;
|
|
157
|
+
while (this.offset < size) {
|
|
158
|
+
const len = Math.min(size - this.offset, 256 * 1024);
|
|
159
|
+
const buf = Buffer.alloc(len);
|
|
160
|
+
const read = readSync(fd, buf, 0, len, this.offset);
|
|
161
|
+
if (read <= 0) break;
|
|
162
|
+
this.offset += read;
|
|
163
|
+
this.lineBuf += buf.toString("utf8", 0, read);
|
|
164
|
+
this.drainLines();
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
closeSync(fd);
|
|
168
|
+
}
|
|
169
|
+
if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
|
|
170
|
+
try {
|
|
171
|
+
this.onOffsetAdvance(this.offset);
|
|
172
|
+
} catch {
|
|
173
|
+
// Persistence hiccup — the next batch retries with a larger offset.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
this.checkLiveness();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private drainLines(): void {
|
|
180
|
+
let nl: number;
|
|
181
|
+
while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
|
|
182
|
+
const line = this.lineBuf.slice(0, nl).trim();
|
|
183
|
+
this.lineBuf = this.lineBuf.slice(nl + 1);
|
|
184
|
+
if (line.length === 0) continue;
|
|
185
|
+
if (line.startsWith('{"__uai"')) {
|
|
186
|
+
this.handleMeta(line);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
|
|
190
|
+
for (const h of this.lineHandlers) {
|
|
191
|
+
try {
|
|
192
|
+
h(line);
|
|
193
|
+
} catch {
|
|
194
|
+
// A broken handler must not wedge the tail loop.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private handleMeta(line: string): void {
|
|
201
|
+
let meta: RunnerMeta;
|
|
202
|
+
try {
|
|
203
|
+
meta = JSON.parse(line) as RunnerMeta;
|
|
204
|
+
} catch {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (meta.__uai === "spawn") {
|
|
208
|
+
this.sawSpawnMeta = true;
|
|
209
|
+
} else if (meta.__uai === "exit") {
|
|
210
|
+
if (typeof meta.stderrTail === "string" && meta.stderrTail) {
|
|
211
|
+
this.stderrBuf = meta.stderrTail.slice(-8192);
|
|
212
|
+
}
|
|
213
|
+
if (this.debug) this.log(`exit meta: code ${meta.code ?? null}`);
|
|
214
|
+
this.finish(typeof meta.code === "number" ? meta.code : null);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Backstop: no exit meta, but the runner stopped beating → it's gone. */
|
|
219
|
+
private checkLiveness(): void {
|
|
220
|
+
const path = this.sawSpawnMeta ? this.heartbeatPath : null;
|
|
221
|
+
if (!path) {
|
|
222
|
+
// Runner never wrote anything; give the spawn a grace window.
|
|
223
|
+
if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const age = Date.now() - statSync(path).mtimeMs;
|
|
228
|
+
if (age > HEARTBEAT_STALE_MS) this.finish(null);
|
|
229
|
+
} catch {
|
|
230
|
+
if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private finish(code: number | null): void {
|
|
235
|
+
if (this.closed) return;
|
|
236
|
+
this.closed = true;
|
|
237
|
+
if (this.poller) clearInterval(this.poller);
|
|
238
|
+
if (this.closeTimer) clearTimeout(this.closeTimer);
|
|
239
|
+
for (const h of this.exitHandlers) h(code);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---- LineProcess-compatible surface ---------------------------------------
|
|
243
|
+
|
|
244
|
+
onLine(handler: LineHandler): void {
|
|
245
|
+
this.lineHandlers.add(handler);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
onExit(handler: ExitHandler): void {
|
|
249
|
+
this.exitHandlers.add(handler);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Serialise `value` as one JSONL line appended to the runner's inbox. */
|
|
253
|
+
writeLine(value: unknown): void {
|
|
254
|
+
if (this.closed || this.detached) return;
|
|
255
|
+
const json = JSON.stringify(value);
|
|
256
|
+
if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
|
|
257
|
+
// Chain appends so concurrent writes can't interleave bytes.
|
|
258
|
+
this.inboxChain = this.inboxChain
|
|
259
|
+
.then(() => fsp.appendFile(this.inboxPath, `${json}\n`))
|
|
260
|
+
.catch(() => {
|
|
261
|
+
// Disk error — liveness checks will surface a dead session.
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
get stderrTail(): string {
|
|
266
|
+
return this.stderrBuf;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get isClosed(): boolean {
|
|
270
|
+
return this.closed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Consumed-outbox byte offset — persist this to survive host restarts. */
|
|
274
|
+
get outboxOffset(): number {
|
|
275
|
+
return this.offset;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Stop consuming WITHOUT touching the runner — the host-restart path in
|
|
280
|
+
* miniature (a replaced consumer, a shutting-down host). The CLI lives on;
|
|
281
|
+
* a later attach() resumes from the persisted offset.
|
|
282
|
+
*/
|
|
283
|
+
detach(): void {
|
|
284
|
+
if (this.detached || this.closed) return;
|
|
285
|
+
this.detached = true;
|
|
286
|
+
if (this.poller) clearInterval(this.poller);
|
|
287
|
+
if (this.closeTimer) clearTimeout(this.closeTimer);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Real teardown: ask the runner to stop its CLI, then observe the exit. */
|
|
291
|
+
async close(): Promise<void> {
|
|
292
|
+
if (this.closed || this.detached) return;
|
|
293
|
+
try {
|
|
294
|
+
this.onCloseRequested?.();
|
|
295
|
+
} catch {
|
|
296
|
+
// Bookkeeping only — never blocks the teardown itself.
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await this.inboxChain;
|
|
300
|
+
await fsp.appendFile(this.inboxPath, '{"__uai":"stop"}\n');
|
|
301
|
+
} catch {
|
|
302
|
+
// Inbox unwritable — fall through to the grace timer.
|
|
303
|
+
}
|
|
304
|
+
this.closeTimer = setTimeout(() => this.finish(null), CLOSE_GRACE_MS);
|
|
305
|
+
}
|
|
306
|
+
}
|