@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.
- package/lib/orchestrator.ts +37 -1
- package/package.json +1 -1
- package/src/main.ts +10 -0
- package/src/protocol.ts +3 -0
- package/src/ui/server.ts +29 -0
- package/ui/app.js +24 -1
- package/ui/style.css +12 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -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
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
|
|
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;
|