@runuai/host 0.2.2 → 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 +26 -28
- package/package.json +1 -1
- 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
|
|
@@ -814,12 +817,15 @@ interface DockerPs {
|
|
|
814
817
|
State: string; // "running" | "exited" | "created" | "paused"
|
|
815
818
|
}
|
|
816
819
|
|
|
817
|
-
function dockerListContainersByLabel(label: string): DockerPs[] {
|
|
818
|
-
const res =
|
|
819
|
-
"
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
+
]);
|
|
823
829
|
if (res.status !== 0) return [];
|
|
824
830
|
return res.stdout
|
|
825
831
|
.split("\n")
|
|
@@ -835,10 +841,8 @@ function dockerListContainersByLabel(label: string): DockerPs[] {
|
|
|
835
841
|
.filter((x): x is DockerPs => x !== null);
|
|
836
842
|
}
|
|
837
843
|
|
|
838
|
-
function dockerStart(containerName: string): boolean {
|
|
839
|
-
const res =
|
|
840
|
-
encoding: "utf8",
|
|
841
|
-
});
|
|
844
|
+
async function dockerStart(containerName: string): Promise<boolean> {
|
|
845
|
+
const res = await dockerCli(["start", containerName]);
|
|
842
846
|
if (res.status !== 0) {
|
|
843
847
|
console.error(
|
|
844
848
|
`[orchestrator] docker start ${containerName} failed: ${res.stderr.trim()}`,
|
|
@@ -848,15 +852,11 @@ function dockerStart(containerName: string): boolean {
|
|
|
848
852
|
return true;
|
|
849
853
|
}
|
|
850
854
|
|
|
851
|
-
function dockerPort(
|
|
855
|
+
async function dockerPort(
|
|
852
856
|
containerName: string,
|
|
853
857
|
containerPort: number,
|
|
854
|
-
): number | null {
|
|
855
|
-
const res =
|
|
856
|
-
"docker",
|
|
857
|
-
["port", containerName, String(containerPort)],
|
|
858
|
-
{ encoding: "utf8" },
|
|
859
|
-
);
|
|
858
|
+
): Promise<number | null> {
|
|
859
|
+
const res = await dockerCli(["port", containerName, String(containerPort)]);
|
|
860
860
|
if (res.status !== 0) return null;
|
|
861
861
|
// Output: "127.0.0.1:32785\n"
|
|
862
862
|
const firstLine = res.stdout.split("\n")[0]?.trim() ?? "";
|
|
@@ -866,10 +866,8 @@ function dockerPort(
|
|
|
866
866
|
return Number.isFinite(port) ? port : null;
|
|
867
867
|
}
|
|
868
868
|
|
|
869
|
-
function dockerExec(containerName: string, cmd: string[]): boolean {
|
|
870
|
-
const res =
|
|
871
|
-
encoding: "utf8",
|
|
872
|
-
});
|
|
869
|
+
async function dockerExec(containerName: string, cmd: string[]): Promise<boolean> {
|
|
870
|
+
const res = await dockerCli(["exec", containerName, ...cmd]);
|
|
873
871
|
return res.status === 0;
|
|
874
872
|
}
|
|
875
873
|
|
|
@@ -914,7 +912,7 @@ async function recoverOneTask(
|
|
|
914
912
|
return;
|
|
915
913
|
}
|
|
916
914
|
const containerName = `${composeProject}-app-1`;
|
|
917
|
-
const containers = dockerListContainersByLabel(
|
|
915
|
+
const containers = await dockerListContainersByLabel(
|
|
918
916
|
`com.docker.compose.project=${composeProject}`,
|
|
919
917
|
);
|
|
920
918
|
|
|
@@ -940,7 +938,7 @@ async function recoverOneTask(
|
|
|
940
938
|
// Container is up. Re-discover the host port in case Docker
|
|
941
939
|
// remapped it across restarts (it usually does for ephemeral
|
|
942
940
|
// bindings).
|
|
943
|
-
const port = dockerPort(containerName, 8080);
|
|
941
|
+
const port = await dockerPort(containerName, 8080);
|
|
944
942
|
if (port && port !== task.codeServerPort) {
|
|
945
943
|
db_setRuntime(task.taskId, {
|
|
946
944
|
codeServerPort: port,
|
|
@@ -955,19 +953,19 @@ async function recoverOneTask(
|
|
|
955
953
|
console.log(
|
|
956
954
|
`[orchestrator] recovery: ${task.taskId} starting exited container ${containerName}`,
|
|
957
955
|
);
|
|
958
|
-
if (!dockerStart(containerName)) {
|
|
956
|
+
if (!(await dockerStart(containerName))) {
|
|
959
957
|
db_setStatus(task.taskId, "stopped", {
|
|
960
958
|
codeServerPort: null,
|
|
961
959
|
previewPorts: "[]",
|
|
962
960
|
});
|
|
963
961
|
return;
|
|
964
962
|
}
|
|
965
|
-
if (!dockerExec(containerName, ["/usr/local/bin/uai-init"])) {
|
|
963
|
+
if (!(await dockerExec(containerName, ["/usr/local/bin/uai-init"]))) {
|
|
966
964
|
console.warn(
|
|
967
965
|
`[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
|
|
968
966
|
);
|
|
969
967
|
}
|
|
970
|
-
const port = dockerPort(containerName, 8080) ?? null;
|
|
968
|
+
const port = (await dockerPort(containerName, 8080)) ?? null;
|
|
971
969
|
db_setRuntime(task.taskId, {
|
|
972
970
|
codeServerPort: port,
|
|
973
971
|
});
|
package/package.json
CHANGED
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();
|