@runuai/host 0.2.2 → 0.2.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.
@@ -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,20 +233,24 @@ 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);
244
240
  // A revoked / expired refresh token can't recover — drop it to force a
245
- // clean re-grant through the OAuth flow.
241
+ // clean re-grant through the OAuth flow. Anything else is likely transient
242
+ // (network / cloud blip) — self-heal with a bounded retry.
246
243
  if (/\b40[13]\b|revoked|invalid_grant|bad_refresh/i.test(reason)) {
247
244
  deleteToken(userId);
245
+ } else {
246
+ scheduleGithubRetry(taskId, userId, 0);
248
247
  }
249
248
  authExpiredHandler?.(taskId, userId, reason);
250
249
  }
251
250
  }
252
251
 
253
252
  export function clearRefresh(taskId: string): void {
253
+ clearGithubRetry(taskId);
254
254
  const t = timers.get(taskId);
255
255
  if (t) {
256
256
  clearTimeout(t);
@@ -258,6 +258,50 @@ export function clearRefresh(taskId: string): void {
258
258
  }
259
259
  }
260
260
 
261
+ // --- self-heal: retry a failed gh setup with bounded backoff ----------------
262
+ // A gh setup/refresh can fail transiently — e.g. the cloud token-exchange fetch
263
+ // times out while the host is briefly unreachable (as happened when the bridge
264
+ // was flapping). Without a retry the task is stranded on a stale/invalid token
265
+ // until someone runs /retry-gh, because ensureSessions won't re-inject once a
266
+ // channel's sessions are started. Retry with bounded backoff; cleared on
267
+ // success (clearGithubRetry) and on task teardown (via clearRefresh).
268
+ const RETRY_BACKOFF_MS = [5_000, 20_000, 60_000, 180_000, 300_000];
269
+ const retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
270
+
271
+ function clearGithubRetry(taskId: string): void {
272
+ const t = retryTimers.get(taskId);
273
+ if (t) {
274
+ clearTimeout(t);
275
+ retryTimers.delete(taskId);
276
+ }
277
+ }
278
+
279
+ function scheduleGithubRetry(
280
+ taskId: string,
281
+ userId: string,
282
+ attempt: number,
283
+ ): void {
284
+ clearGithubRetry(taskId);
285
+ if (attempt >= RETRY_BACKOFF_MS.length) {
286
+ console.warn(
287
+ `[github] task ${taskId}: gh setup still failing after ${attempt} attempts — giving up until /retry-gh`,
288
+ );
289
+ return;
290
+ }
291
+ const delay = RETRY_BACKOFF_MS[attempt] ?? 300_000;
292
+ const timer = setTimeout(() => {
293
+ retryTimers.delete(taskId);
294
+ void setupTaskGithub(taskId, userId, {}, attempt + 1);
295
+ }, delay);
296
+ timer.unref?.();
297
+ retryTimers.set(taskId, timer);
298
+ }
299
+
300
+ /** Test/diagnostic: is a gh-setup retry currently armed for this task? */
301
+ export function hasPendingGithubRetry(taskId: string): boolean {
302
+ return retryTimers.has(taskId);
303
+ }
304
+
261
305
  /**
262
306
  * Injectable seams for {@link setupTaskGithub} (mocked in tests so the decision
263
307
  * logic needs neither the host DB nor docker). All default to the real impls.
@@ -267,7 +311,7 @@ export interface SetupTaskDeps {
267
311
  requestAccessToken?: (
268
312
  userId: string,
269
313
  ) => Promise<{ accessToken: string; expiresAt: number } | null>;
270
- inject?: (taskId: string, token: string) => void;
314
+ inject?: (taskId: string, token: string) => void | Promise<void>;
271
315
  schedule?: (taskId: string, userId: string, expiresAt: number) => void;
272
316
  }
273
317
 
@@ -287,6 +331,7 @@ export async function setupTaskGithub(
287
331
  taskId: string,
288
332
  userId: string,
289
333
  deps: SetupTaskDeps = {},
334
+ attempt = 0,
290
335
  ): Promise<boolean> {
291
336
  const _hasToken = deps.hasToken ?? hasToken;
292
337
  const _request = deps.requestAccessToken ?? requestAccessToken;
@@ -299,15 +344,17 @@ export async function setupTaskGithub(
299
344
  if (_hasToken(userId)) {
300
345
  const tok = await _request(userId);
301
346
  if (tok) {
302
- _inject(taskId, tok.accessToken);
347
+ await _inject(taskId, tok.accessToken);
303
348
  _schedule(taskId, userId, tok.expiresAt);
349
+ clearGithubRetry(taskId);
304
350
  console.log(`[github] task ${taskId}: injected user access token`);
305
351
  return true;
306
352
  }
307
353
  }
308
354
  const pat = process.env.UAI_GH_PAT_FALLBACK;
309
355
  if (pat) {
310
- _inject(taskId, pat);
356
+ await _inject(taskId, pat);
357
+ clearGithubRetry(taskId);
311
358
  console.log(`[github] task ${taskId}: using PAT fallback (no refresh)`);
312
359
  return true;
313
360
  }
@@ -317,14 +364,19 @@ export async function setupTaskGithub(
317
364
  const reason = err instanceof Error ? err.message : String(err);
318
365
  console.warn(`[github] task ${taskId}: gh setup failed: ${reason}`);
319
366
  authExpiredHandler?.(taskId, userId, reason);
367
+ // Self-heal: a transient exchange/inject failure shouldn't strand the task
368
+ // on a stale token until a manual /retry-gh. Retry with bounded backoff.
369
+ scheduleGithubRetry(taskId, userId, attempt);
320
370
  return false;
321
371
  } finally {
322
372
  setupInFlight.delete(taskId);
323
373
  }
324
374
  }
325
375
 
326
- /** Test/maintenance hook: drop all scheduled refresh timers. */
376
+ /** Test/maintenance hook: drop all scheduled refresh + retry timers. */
327
377
  export function clearAllRefreshTimers(): void {
328
378
  for (const t of timers.values()) clearTimeout(t);
329
379
  timers.clear();
380
+ for (const t of retryTimers.values()) clearTimeout(t);
381
+ retryTimers.clear();
330
382
  }
@@ -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.4",
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();