@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.
@@ -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
+ }
@@ -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 { spawnSync } from "node:child_process";
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
- export type DockerExec = (args: string[]) => {
14
- status: number | null;
15
- stderr: string;
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 = spawnSync("docker", args, {
20
- encoding: "utf8",
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",
@@ -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 = spawnSync("docker", args, {
174
- input,
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
  }
@@ -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
- setupTaskGitIdentity(channel.taskId, task.ownerName, task.ownerEmail);
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 = spawnSync(
819
- "docker",
820
- ["ps", "--all", "--filter", `label=${label}`, "--format", "{{json .}}"],
821
- { encoding: "utf8" },
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 = spawnSync("docker", ["start", containerName], {
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 = spawnSync(
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 = spawnSync("docker", ["exec", containerName, ...cmd], {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
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
- const res = spawnSync(
309
- "docker",
310
- [
311
- "stats",
312
- "--no-stream",
313
- "--format",
314
- "{{.MemUsage}}",
315
- `${composeProject}-app-1`,
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();