@runuai/host 0.8.7 → 0.8.8

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.
@@ -51,7 +51,7 @@ import {
51
51
  writeAgentCli,
52
52
  } from "./agent-cli";
53
53
  import { setupBrowserTesting } from "./browser-testing";
54
- import { setupMcpTaskConfig } from "./mcp-gateway";
54
+ import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
55
55
  import { env } from "./env";
56
56
  import type {
57
57
  ChannelEnsureInput,
@@ -804,6 +804,42 @@ class Orchestrator {
804
804
  for (const session of ch.sessions.values()) await session.close();
805
805
  this.channels.delete(taskId);
806
806
  }
807
+
808
+ /**
809
+ * Stop a task's containers but KEEP its worktree + state — a resumable pause
810
+ * the host operator triggers from the local UI (ADR-028). Closes the channel
811
+ * (drops sessions) and stops every container in the compose project, then
812
+ * mirrors `stopped`. Unlike taskDown this destroys nothing, so the cloud
813
+ * reconciles it to `stopped` (worktree present) and it can be resumed. Since
814
+ * `stopped` is not an ACTIVE status, host recovery won't restart it.
815
+ */
816
+ async stopTask(taskId: string): Promise<{ ok: boolean; error?: string }> {
817
+ const task = getHostTask(taskId);
818
+ if (!task) return { ok: false, error: "unknown task" };
819
+ const project = task.composeProject;
820
+ if (!project) return { ok: false, error: "task has no running stack" };
821
+ // Close sessions first so nothing races to respawn them mid-stop.
822
+ await this.closeChannel(taskId);
823
+ const ps = await dockerCli([
824
+ "ps",
825
+ "-q",
826
+ "--filter",
827
+ `label=com.docker.compose.project=${project}`,
828
+ ]);
829
+ const ids = ps.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
830
+ if (ids.length > 0) {
831
+ const stopped = await dockerCli(["stop", ...ids], { timeoutMs: 60_000 });
832
+ if (stopped.status !== 0) {
833
+ return {
834
+ ok: false,
835
+ error: stopped.stderr.trim().slice(0, 200) || "docker stop failed",
836
+ };
837
+ }
838
+ }
839
+ upsertHostTask(taskId, { statusMirror: "stopped" });
840
+ clearTaskGatewayAcl(taskId);
841
+ return { ok: true };
842
+ }
807
843
  }
808
844
 
809
845
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
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/main.ts CHANGED
@@ -183,6 +183,16 @@ async function startLocalUi(): Promise<void> {
183
183
  // Engine connect/disconnect in the local UI re-advertises capabilities so
184
184
  // the cloud's task picker reflects a newly-configured engine promptly.
185
185
  readvertise: sendCapabilities,
186
+ // Local UI "Stop" — the operator can pause a task's containers (ADR-028).
187
+ // On success, push the status up so the cloud mirrors `stopped` (it has no
188
+ // auto-reconcile trigger); otherwise it would keep showing `running`.
189
+ stopTask: async (taskId) => {
190
+ const result = await getOrchestrator().stopTask(taskId);
191
+ if (result.ok && ws && ws.readyState === WebSocket.OPEN) {
192
+ send(ws, { kind: "task.status", taskId, status: "stopped" });
193
+ }
194
+ return result;
195
+ },
186
196
  });
187
197
  console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
188
198
  } catch (err) {
package/src/protocol.ts CHANGED
@@ -501,6 +501,9 @@ export type HostToCloud =
501
501
  result: HostCommandResult<unknown>;
502
502
  }
503
503
  | { kind: "event"; event: HostEvent }
504
+ // Host-pushed task lifecycle (ADR-028 local-UI Stop): the host operator paused
505
+ // a task's containers, so the cloud mirrors the status (currently "stopped").
506
+ | { kind: "task.status"; taskId: string; status: string }
504
507
  | { kind: "ping"; ts: number }
505
508
  | {
506
509
  kind: "tunnel.ack";
package/src/ui/server.ts CHANGED
@@ -71,6 +71,12 @@ export interface UiServerOptions {
71
71
  * task picker promptly. Optional (tests omit it).
72
72
  */
73
73
  readvertise?: () => void;
74
+ /**
75
+ * Stop a task's containers (resumable) — wired from main.ts to the
76
+ * orchestrator. The host operator's "reclaim my machine" control (ADR-028).
77
+ * Optional (tests omit it).
78
+ */
79
+ stopTask?: (taskId: string) => Promise<{ ok: boolean; error?: string }>;
74
80
  }
75
81
 
76
82
  export interface UiServerHandle {
@@ -147,6 +153,8 @@ async function handle(
147
153
  return await handleEngineConnect(req, res, opts);
148
154
  case "/api/engines/disconnect":
149
155
  return await handleEngineDisconnect(req, res, opts);
156
+ case "/api/tasks/stop":
157
+ return await handleTaskStop(req, res, opts);
150
158
  }
151
159
  return sendError(res, 404, `no such endpoint: ${path}`);
152
160
  }
@@ -260,6 +268,27 @@ async function handleEngineDisconnect(
260
268
  return sendJson(res, EngineOpResponse, { ok: true });
261
269
  }
262
270
 
271
+ /** POST /api/tasks/stop `{taskId}` → compose-stop the task (resumable). */
272
+ async function handleTaskStop(
273
+ req: IncomingMessage,
274
+ res: ServerResponse,
275
+ opts: UiServerOptions,
276
+ ): Promise<void> {
277
+ const body = await readJsonBody(req);
278
+ const taskId = body?.taskId;
279
+ if (typeof taskId !== "string" || taskId.length === 0) {
280
+ return sendError(res, 400, "missing taskId");
281
+ }
282
+ if (!opts.stopTask) {
283
+ return sendError(res, 501, "stop not available on this host");
284
+ }
285
+ const result = await opts.stopTask(taskId);
286
+ return sendJson(res, EngineOpResponse, {
287
+ ok: result.ok,
288
+ message: result.error,
289
+ });
290
+ }
291
+
263
292
  /** Read a request body and parse it as a JSON object; null on empty/invalid. */
264
293
  async function readJsonBody(
265
294
  req: IncomingMessage,
package/ui/app.js CHANGED
@@ -172,7 +172,30 @@ function taskNode(t, events) {
172
172
  chev.className = "chev";
173
173
  chev.textContent = "›";
174
174
 
175
- head.append(main, chev);
175
+ head.append(main);
176
+ // Stop — pause a running task's containers (resumable). Owner-agnostic: the
177
+ // operator controls what runs on their machine (ADR-028).
178
+ if (t.status === "running" || t.status === "starting") {
179
+ const stop = document.createElement("button");
180
+ stop.className = "link-btn danger task-stop";
181
+ stop.type = "button";
182
+ stop.textContent = "Stop";
183
+ stop.addEventListener("click", async (ev) => {
184
+ ev.stopPropagation();
185
+ if (!confirm(`Stop this task?\n\n${t.taskId}\n\nContainers stop (freeing memory); the worktree is kept so it can be resumed.`)) return;
186
+ stop.disabled = true;
187
+ stop.textContent = "Stopping…";
188
+ try {
189
+ const r = await postJSON("/api/tasks/stop", { taskId: t.taskId });
190
+ if (!r.ok) alert(`Stop failed: ${r.message || "unknown error"}`);
191
+ } catch {
192
+ /* poll re-syncs truth */
193
+ }
194
+ await poll();
195
+ });
196
+ head.append(stop);
197
+ }
198
+ head.append(chev);
176
199
  node.append(head);
177
200
 
178
201
  if (open) {
package/ui/style.css CHANGED
@@ -280,6 +280,18 @@ code,
280
280
  color: var(--fg-muted);
281
281
  }
282
282
 
283
+ .task-stop {
284
+ flex: none;
285
+ font-size: 0.85rem;
286
+ opacity: 0;
287
+ transition: opacity 0.12s ease;
288
+ }
289
+ /* Reveal Stop on row hover; keep it visible mid-action so it doesn't vanish. */
290
+ .task-head:hover .task-stop,
291
+ .task-stop:disabled {
292
+ opacity: 1;
293
+ }
294
+
283
295
  .chev {
284
296
  color: var(--fg-muted);
285
297
  transition: transform 0.15s ease;