@runuai/host 0.2.1 → 0.2.3
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/lib/docker-exec.ts +65 -0
- package/lib/git-identity.ts +12 -15
- package/lib/github-tokens.ts +14 -18
- package/lib/orchestrator.ts +36 -29
- package/package.json +1 -1
- package/src/protocol.ts +9 -0
- package/src/ui/server.ts +9 -11
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async docker CLI runner.
|
|
3
|
+
*
|
|
4
|
+
* The host's hot paths — boot recovery (recoverRunningTasks) and the
|
|
5
|
+
* per-reconnect channel ensure (git identity + gh token injection) — used
|
|
6
|
+
* `spawnSync`, which blocks the single Node event loop for the entire docker
|
|
7
|
+
* call. With several tasks (and large containers), those blocking bursts starve
|
|
8
|
+
* the bridge heartbeat timer and the local UI server: the cloud stops getting
|
|
9
|
+
* pings, drops the host, the host reconnects, the cloud re-`ensure`s the
|
|
10
|
+
* channels → more blocking docker → the heartbeat dies again. A reconnect loop.
|
|
11
|
+
*
|
|
12
|
+
* Running docker through async `spawn` keeps the loop responsive while the
|
|
13
|
+
* command runs. Never rejects — it resolves the same `{status, stdout, stderr}`
|
|
14
|
+
* shape callers already branch on (mirrors spawnSync's result fields), with
|
|
15
|
+
* `status: null` on spawn error or timeout.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export interface DockerResult {
|
|
23
|
+
status: number | null;
|
|
24
|
+
stdout: string;
|
|
25
|
+
stderr: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function dockerCli(
|
|
29
|
+
args: string[],
|
|
30
|
+
opts: { input?: string; timeoutMs?: number } = {},
|
|
31
|
+
): Promise<DockerResult> {
|
|
32
|
+
return new Promise<DockerResult>((resolve) => {
|
|
33
|
+
let stdout = "";
|
|
34
|
+
let stderr = "";
|
|
35
|
+
let settled = false;
|
|
36
|
+
const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
child.kill("SIGKILL");
|
|
39
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
40
|
+
timer.unref?.();
|
|
41
|
+
const finish = (status: number | null) => {
|
|
42
|
+
if (settled) return;
|
|
43
|
+
settled = true;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
resolve({ status, stdout, stderr });
|
|
46
|
+
};
|
|
47
|
+
child.stdout?.setEncoding("utf8");
|
|
48
|
+
child.stderr?.setEncoding("utf8");
|
|
49
|
+
child.stdout?.on("data", (c: string) => {
|
|
50
|
+
stdout += c;
|
|
51
|
+
});
|
|
52
|
+
child.stderr?.on("data", (c: string) => {
|
|
53
|
+
stderr += c;
|
|
54
|
+
});
|
|
55
|
+
child.on("error", (err: Error) => {
|
|
56
|
+
stderr += err.message;
|
|
57
|
+
finish(null);
|
|
58
|
+
});
|
|
59
|
+
child.on("close", (code) => {
|
|
60
|
+
finish(code);
|
|
61
|
+
});
|
|
62
|
+
if (opts.input !== undefined) child.stdin?.write(opts.input);
|
|
63
|
+
child.stdin?.end();
|
|
64
|
+
});
|
|
65
|
+
}
|
package/lib/git-identity.ts
CHANGED
|
@@ -5,30 +5,27 @@
|
|
|
5
5
|
* task-up. Name falls back to the email when no display name is known.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { dockerCli } from "./docker-exec";
|
|
9
9
|
|
|
10
10
|
const EXEC_TIMEOUT_MS = 10_000;
|
|
11
11
|
|
|
12
|
-
/** Injectable docker-exec seam (mocked in tests).
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
12
|
+
/** Injectable docker-exec seam (mocked in tests). Async so the docker call
|
|
13
|
+
* doesn't block the host event loop (this runs on every channel ensure). */
|
|
14
|
+
export type DockerExec = (
|
|
15
|
+
args: string[],
|
|
16
|
+
) => Promise<{ status: number | null; stderr: string }>;
|
|
17
17
|
|
|
18
|
-
const defaultExec: DockerExec = (args) => {
|
|
19
|
-
const res =
|
|
20
|
-
|
|
21
|
-
timeout: EXEC_TIMEOUT_MS,
|
|
22
|
-
});
|
|
23
|
-
return { status: res.status, stderr: res.stderr ?? "" };
|
|
18
|
+
const defaultExec: DockerExec = async (args) => {
|
|
19
|
+
const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
|
|
20
|
+
return { status: res.status, stderr: res.stderr };
|
|
24
21
|
};
|
|
25
22
|
|
|
26
|
-
export function setupTaskGitIdentity(
|
|
23
|
+
export async function setupTaskGitIdentity(
|
|
27
24
|
taskId: string,
|
|
28
25
|
name: string | null,
|
|
29
26
|
email: string | null,
|
|
30
27
|
exec: DockerExec = defaultExec,
|
|
31
|
-
): boolean {
|
|
28
|
+
): Promise<boolean> {
|
|
32
29
|
if (!email) {
|
|
33
30
|
console.log(`[git] task ${taskId}: no owner email — leaving git identity`);
|
|
34
31
|
return false;
|
|
@@ -42,7 +39,7 @@ export function setupTaskGitIdentity(
|
|
|
42
39
|
["user.email", email],
|
|
43
40
|
];
|
|
44
41
|
for (const [key, value] of entries) {
|
|
45
|
-
const res = exec([
|
|
42
|
+
const res = await exec([
|
|
46
43
|
"exec",
|
|
47
44
|
"-u",
|
|
48
45
|
"node",
|
package/lib/github-tokens.ts
CHANGED
|
@@ -7,12 +7,11 @@
|
|
|
7
7
|
* exchange is a host→cloud HTTP POST to /api/github/oauth/exchange.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { spawnSync } from "node:child_process";
|
|
11
|
-
|
|
12
10
|
import { and, eq, isNull } from "drizzle-orm";
|
|
13
11
|
|
|
14
12
|
import { getDb, schema } from "./db";
|
|
15
13
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
14
|
+
import { dockerCli } from "./docker-exec";
|
|
16
15
|
import type { CloudToHost } from "../src/protocol";
|
|
17
16
|
|
|
18
17
|
const REFRESH_LEAD_MS = 5 * 60 * 1000; // refresh 5 min before expiry
|
|
@@ -163,27 +162,24 @@ export async function requestAccessToken(
|
|
|
163
162
|
|
|
164
163
|
// --- container injection ----------------------------------------------------
|
|
165
164
|
|
|
166
|
-
/** Injectable docker-exec seam (mocked in tests).
|
|
165
|
+
/** Injectable docker-exec seam (mocked in tests). Async so the docker call
|
|
166
|
+
* doesn't block the host event loop (runs on every channel ensure + refresh). */
|
|
167
167
|
export type DockerExec = (
|
|
168
168
|
args: string[],
|
|
169
169
|
input: string,
|
|
170
|
-
) => { status: number | null; stderr: string }
|
|
170
|
+
) => Promise<{ status: number | null; stderr: string }>;
|
|
171
171
|
|
|
172
|
-
const defaultExec: DockerExec = (args, input) => {
|
|
173
|
-
const res =
|
|
174
|
-
|
|
175
|
-
encoding: "utf8",
|
|
176
|
-
timeout: EXEC_TIMEOUT_MS,
|
|
177
|
-
});
|
|
178
|
-
return { status: res.status, stderr: res.stderr ?? "" };
|
|
172
|
+
const defaultExec: DockerExec = async (args, input) => {
|
|
173
|
+
const res = await dockerCli(args, { input, timeoutMs: EXEC_TIMEOUT_MS });
|
|
174
|
+
return { status: res.status, stderr: res.stderr };
|
|
179
175
|
};
|
|
180
176
|
|
|
181
177
|
/** Write the access token into the container's gh config via `gh auth login`. */
|
|
182
|
-
export function injectIntoContainer(
|
|
178
|
+
export async function injectIntoContainer(
|
|
183
179
|
taskId: string,
|
|
184
180
|
accessToken: string,
|
|
185
181
|
exec: DockerExec = defaultExec,
|
|
186
|
-
): void {
|
|
182
|
+
): Promise<void> {
|
|
187
183
|
const container = `task-${taskId}-app-1`;
|
|
188
184
|
const args = [
|
|
189
185
|
"exec",
|
|
@@ -198,7 +194,7 @@ export function injectIntoContainer(
|
|
|
198
194
|
"--hostname",
|
|
199
195
|
"github.com",
|
|
200
196
|
];
|
|
201
|
-
const res = exec(args, `${accessToken}\n`);
|
|
197
|
+
const res = await exec(args, `${accessToken}\n`);
|
|
202
198
|
if (res.status !== 0) {
|
|
203
199
|
throw new Error(`gh auth login failed in ${container}: ${res.stderr.trim()}`);
|
|
204
200
|
}
|
|
@@ -237,7 +233,7 @@ async function runRefresh(taskId: string, userId: string): Promise<void> {
|
|
|
237
233
|
authExpiredHandler?.(taskId, userId, "no GitHub token on host");
|
|
238
234
|
return;
|
|
239
235
|
}
|
|
240
|
-
injectIntoContainer(taskId, tok.accessToken);
|
|
236
|
+
await injectIntoContainer(taskId, tok.accessToken);
|
|
241
237
|
scheduleRefresh(taskId, userId, tok.expiresAt);
|
|
242
238
|
} catch (err) {
|
|
243
239
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -267,7 +263,7 @@ export interface SetupTaskDeps {
|
|
|
267
263
|
requestAccessToken?: (
|
|
268
264
|
userId: string,
|
|
269
265
|
) => Promise<{ accessToken: string; expiresAt: number } | null>;
|
|
270
|
-
inject?: (taskId: string, token: string) => void
|
|
266
|
+
inject?: (taskId: string, token: string) => void | Promise<void>;
|
|
271
267
|
schedule?: (taskId: string, userId: string, expiresAt: number) => void;
|
|
272
268
|
}
|
|
273
269
|
|
|
@@ -299,7 +295,7 @@ export async function setupTaskGithub(
|
|
|
299
295
|
if (_hasToken(userId)) {
|
|
300
296
|
const tok = await _request(userId);
|
|
301
297
|
if (tok) {
|
|
302
|
-
_inject(taskId, tok.accessToken);
|
|
298
|
+
await _inject(taskId, tok.accessToken);
|
|
303
299
|
_schedule(taskId, userId, tok.expiresAt);
|
|
304
300
|
console.log(`[github] task ${taskId}: injected user access token`);
|
|
305
301
|
return true;
|
|
@@ -307,7 +303,7 @@ export async function setupTaskGithub(
|
|
|
307
303
|
}
|
|
308
304
|
const pat = process.env.UAI_GH_PAT_FALLBACK;
|
|
309
305
|
if (pat) {
|
|
310
|
-
_inject(taskId, pat);
|
|
306
|
+
await _inject(taskId, pat);
|
|
311
307
|
console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
|
|
312
308
|
return true;
|
|
313
309
|
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { ACTIVE_STATUSES } from "./task-status";
|
|
|
36
36
|
import { getHostTask, upsertHostTask } from "./runtime-state";
|
|
37
37
|
import { setupTaskGithub } from "./github-tokens";
|
|
38
38
|
import { setupTaskGitIdentity } from "./git-identity";
|
|
39
|
+
import { dockerCli } from "./docker-exec";
|
|
39
40
|
import type { ChannelEnsureInput, HostEvent } from "../src/protocol";
|
|
40
41
|
|
|
41
42
|
export type HostEventSubscriber = (event: HostEvent) => void;
|
|
@@ -178,8 +179,10 @@ class Orchestrator {
|
|
|
178
179
|
|
|
179
180
|
// Set the task creator's git author identity in the container (ADR-029).
|
|
180
181
|
// The SSH key itself is installed earlier by task-up.sh (host clone +
|
|
181
|
-
// container), using the creator's per-user key. Best-effort.
|
|
182
|
-
|
|
182
|
+
// container), using the creator's per-user key. Best-effort. Awaited but
|
|
183
|
+
// async (docker exec via dockerCli) so it doesn't block the event loop —
|
|
184
|
+
// this runs on every channel ensure, including each post-restart reconnect.
|
|
185
|
+
await setupTaskGitIdentity(channel.taskId, task.ownerName, task.ownerEmail);
|
|
183
186
|
|
|
184
187
|
// GitHub auth for `gh` (ADR-027): mint + inject the access token and
|
|
185
188
|
// (re)start its refresh schedule. Done here — not only at task-up — so it
|
|
@@ -375,7 +378,16 @@ class Orchestrator {
|
|
|
375
378
|
});
|
|
376
379
|
break;
|
|
377
380
|
}
|
|
378
|
-
case "turn_complete":
|
|
381
|
+
case "turn_complete": {
|
|
382
|
+
// Turn boundary — the cloud flushes any @-mentions buffered across this
|
|
383
|
+
// turn's messages and wakes the mentioned peers with the full turn.
|
|
384
|
+
this.emitHost({
|
|
385
|
+
kind: "agent.turn_complete",
|
|
386
|
+
taskId: channel.taskId,
|
|
387
|
+
agentId,
|
|
388
|
+
});
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
379
391
|
case "exit":
|
|
380
392
|
break;
|
|
381
393
|
}
|
|
@@ -805,12 +817,15 @@ interface DockerPs {
|
|
|
805
817
|
State: string; // "running" | "exited" | "created" | "paused"
|
|
806
818
|
}
|
|
807
819
|
|
|
808
|
-
function dockerListContainersByLabel(label: string): DockerPs[] {
|
|
809
|
-
const res =
|
|
810
|
-
"
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
820
|
+
async function dockerListContainersByLabel(label: string): Promise<DockerPs[]> {
|
|
821
|
+
const res = await dockerCli([
|
|
822
|
+
"ps",
|
|
823
|
+
"--all",
|
|
824
|
+
"--filter",
|
|
825
|
+
`label=${label}`,
|
|
826
|
+
"--format",
|
|
827
|
+
"{{json .}}",
|
|
828
|
+
]);
|
|
814
829
|
if (res.status !== 0) return [];
|
|
815
830
|
return res.stdout
|
|
816
831
|
.split("\n")
|
|
@@ -826,10 +841,8 @@ function dockerListContainersByLabel(label: string): DockerPs[] {
|
|
|
826
841
|
.filter((x): x is DockerPs => x !== null);
|
|
827
842
|
}
|
|
828
843
|
|
|
829
|
-
function dockerStart(containerName: string): boolean {
|
|
830
|
-
const res =
|
|
831
|
-
encoding: "utf8",
|
|
832
|
-
});
|
|
844
|
+
async function dockerStart(containerName: string): Promise<boolean> {
|
|
845
|
+
const res = await dockerCli(["start", containerName]);
|
|
833
846
|
if (res.status !== 0) {
|
|
834
847
|
console.error(
|
|
835
848
|
`[orchestrator] docker start ${containerName} failed: ${res.stderr.trim()}`,
|
|
@@ -839,15 +852,11 @@ function dockerStart(containerName: string): boolean {
|
|
|
839
852
|
return true;
|
|
840
853
|
}
|
|
841
854
|
|
|
842
|
-
function dockerPort(
|
|
855
|
+
async function dockerPort(
|
|
843
856
|
containerName: string,
|
|
844
857
|
containerPort: number,
|
|
845
|
-
): number | null {
|
|
846
|
-
const res =
|
|
847
|
-
"docker",
|
|
848
|
-
["port", containerName, String(containerPort)],
|
|
849
|
-
{ encoding: "utf8" },
|
|
850
|
-
);
|
|
858
|
+
): Promise<number | null> {
|
|
859
|
+
const res = await dockerCli(["port", containerName, String(containerPort)]);
|
|
851
860
|
if (res.status !== 0) return null;
|
|
852
861
|
// Output: "127.0.0.1:32785\n"
|
|
853
862
|
const firstLine = res.stdout.split("\n")[0]?.trim() ?? "";
|
|
@@ -857,10 +866,8 @@ function dockerPort(
|
|
|
857
866
|
return Number.isFinite(port) ? port : null;
|
|
858
867
|
}
|
|
859
868
|
|
|
860
|
-
function dockerExec(containerName: string, cmd: string[]): boolean {
|
|
861
|
-
const res =
|
|
862
|
-
encoding: "utf8",
|
|
863
|
-
});
|
|
869
|
+
async function dockerExec(containerName: string, cmd: string[]): Promise<boolean> {
|
|
870
|
+
const res = await dockerCli(["exec", containerName, ...cmd]);
|
|
864
871
|
return res.status === 0;
|
|
865
872
|
}
|
|
866
873
|
|
|
@@ -905,7 +912,7 @@ async function recoverOneTask(
|
|
|
905
912
|
return;
|
|
906
913
|
}
|
|
907
914
|
const containerName = `${composeProject}-app-1`;
|
|
908
|
-
const containers = dockerListContainersByLabel(
|
|
915
|
+
const containers = await dockerListContainersByLabel(
|
|
909
916
|
`com.docker.compose.project=${composeProject}`,
|
|
910
917
|
);
|
|
911
918
|
|
|
@@ -931,7 +938,7 @@ async function recoverOneTask(
|
|
|
931
938
|
// Container is up. Re-discover the host port in case Docker
|
|
932
939
|
// remapped it across restarts (it usually does for ephemeral
|
|
933
940
|
// bindings).
|
|
934
|
-
const port = dockerPort(containerName, 8080);
|
|
941
|
+
const port = await dockerPort(containerName, 8080);
|
|
935
942
|
if (port && port !== task.codeServerPort) {
|
|
936
943
|
db_setRuntime(task.taskId, {
|
|
937
944
|
codeServerPort: port,
|
|
@@ -946,19 +953,19 @@ async function recoverOneTask(
|
|
|
946
953
|
console.log(
|
|
947
954
|
`[orchestrator] recovery: ${task.taskId} starting exited container ${containerName}`,
|
|
948
955
|
);
|
|
949
|
-
if (!dockerStart(containerName)) {
|
|
956
|
+
if (!(await dockerStart(containerName))) {
|
|
950
957
|
db_setStatus(task.taskId, "stopped", {
|
|
951
958
|
codeServerPort: null,
|
|
952
959
|
previewPorts: "[]",
|
|
953
960
|
});
|
|
954
961
|
return;
|
|
955
962
|
}
|
|
956
|
-
if (!dockerExec(containerName, ["/usr/local/bin/uai-init"])) {
|
|
963
|
+
if (!(await dockerExec(containerName, ["/usr/local/bin/uai-init"]))) {
|
|
957
964
|
console.warn(
|
|
958
965
|
`[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
|
|
959
966
|
);
|
|
960
967
|
}
|
|
961
|
-
const port = dockerPort(containerName, 8080) ?? null;
|
|
968
|
+
const port = (await dockerPort(containerName, 8080)) ?? null;
|
|
962
969
|
db_setRuntime(task.taskId, {
|
|
963
970
|
codeServerPort: port,
|
|
964
971
|
});
|
package/package.json
CHANGED
package/src/protocol.ts
CHANGED
|
@@ -399,6 +399,15 @@ export type HostEvent =
|
|
|
399
399
|
fullText: string;
|
|
400
400
|
mentions: string[];
|
|
401
401
|
}
|
|
402
|
+
// The agent finished a turn (it may have emitted several message_complete
|
|
403
|
+
// items within it). The cloud waits for this before waking @-mentioned peers,
|
|
404
|
+
// so a peer is handed the agent's COMPLETE turn rather than a mid-turn
|
|
405
|
+
// fragment that happened to contain the mention.
|
|
406
|
+
| {
|
|
407
|
+
kind: "agent.turn_complete";
|
|
408
|
+
taskId: string;
|
|
409
|
+
agentId: string;
|
|
410
|
+
}
|
|
402
411
|
| {
|
|
403
412
|
kind: "agent.tool_call";
|
|
404
413
|
taskId: string;
|
package/src/ui/server.ts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* so the API can never emit an off-contract shape.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawnSync } from "node:child_process";
|
|
12
11
|
import { existsSync } from "node:fs";
|
|
13
12
|
import { readFile, writeFile } from "node:fs/promises";
|
|
14
13
|
import {
|
|
@@ -27,6 +26,7 @@ import type { ZodType } from "zod";
|
|
|
27
26
|
import { schema, type Db } from "../../lib/db";
|
|
28
27
|
import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
|
|
29
28
|
import { getCloudState } from "../../lib/cloud-state";
|
|
29
|
+
import { dockerCli } from "../../lib/docker-exec";
|
|
30
30
|
import {
|
|
31
31
|
CloudResponse,
|
|
32
32
|
EventsResponse,
|
|
@@ -305,16 +305,14 @@ export async function dockerMemoryBytes(
|
|
|
305
305
|
composeProject: string,
|
|
306
306
|
): Promise<number | null> {
|
|
307
307
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
],
|
|
317
|
-
{ encoding: "utf8", timeout: 3_000 },
|
|
308
|
+
// Async (not spawnSync): this runs per task on every status request, on the
|
|
309
|
+
// SAME event loop as the cloud-bridge heartbeat. `docker stats --no-stream`
|
|
310
|
+
// samples for ~1-2s; doing it synchronously for N tasks blocked the loop
|
|
311
|
+
// long enough that the bridge heartbeat timed out and the cloud dropped the
|
|
312
|
+
// host into a reconnect loop.
|
|
313
|
+
const res = await dockerCli(
|
|
314
|
+
["stats", "--no-stream", "--format", "{{.MemUsage}}", `${composeProject}-app-1`],
|
|
315
|
+
{ timeoutMs: 3_000 },
|
|
318
316
|
);
|
|
319
317
|
if (res.status !== 0 || !res.stdout) return null;
|
|
320
318
|
const used = res.stdout.trim().split("/")[0]?.trim();
|