@runuai/host 0.8.42 → 0.8.44
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/browser-testing.ts +1056 -90
- package/lib/codex-auth.ts +22 -15
- package/lib/engine-accounts.ts +23 -4
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-gateway.ts +393 -21
- package/lib/orchestrator.ts +1351 -217
- package/lib/preview-sidecar.ts +123 -3
- package/package.json +1 -1
- package/scripts/agent/task-down.sh +16 -0
- package/src/index.ts +215 -97
- package/src/main.ts +19 -10
package/lib/preview-sidecar.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* connect fails, which invalidates it (mirrors the container-IP cache). Declared
|
|
15
15
|
* ports keep their published-port path; only ad-hoc ports use a sidecar.
|
|
16
16
|
*/
|
|
17
|
+
import net from "node:net";
|
|
18
|
+
|
|
17
19
|
import { dockerCli } from "./docker-exec";
|
|
18
20
|
|
|
19
21
|
/** Port the proxy listens on inside the sidecar (published to a random host port). */
|
|
@@ -45,6 +47,86 @@ function proxyScript(appContainer: string, containerPort: number): string {
|
|
|
45
47
|
);
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** How long to wait for a new sidecar's proxy to start accepting. */
|
|
51
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
52
|
+
const READY_POLL_MS = 100;
|
|
53
|
+
/** A connection that survives this long AFTER connecting has a live upstream. */
|
|
54
|
+
const PROBE_SETTLE_MS = 150;
|
|
55
|
+
/** A handshake that hasn't completed by now isn't going to. */
|
|
56
|
+
const PROBE_CONNECT_MS = 1_000;
|
|
57
|
+
|
|
58
|
+
/** The bit of `net.Socket` the probe uses — so a test can supply its own. */
|
|
59
|
+
interface SocketLike {
|
|
60
|
+
on: (event: string, fn: () => void) => unknown;
|
|
61
|
+
destroy: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether the whole chain behind `hostPort` is live: the proxy is listening AND
|
|
66
|
+
* its upstream connect succeeded.
|
|
67
|
+
*
|
|
68
|
+
* A plain "did the TCP connect succeed" check proves nothing here, which is the
|
|
69
|
+
* trap this exists for. Docker publishes the port when the CONTAINER starts,
|
|
70
|
+
* not when the process inside binds — so `docker-proxy` accepts the connection
|
|
71
|
+
* either way and then closes it, and an HTTP client reports that as
|
|
72
|
+
* `socket hang up`. The sidecar's proxy behaves identically when it is up but
|
|
73
|
+
* its own upstream connect fails (`u.on('error',()=>c.destroy())`).
|
|
74
|
+
*
|
|
75
|
+
* So the signal is the connection SURVIVING briefly, not opening. Both failure
|
|
76
|
+
* modes close it immediately; a healthy pipe just sits there waiting for a
|
|
77
|
+
* request, which is the one thing neither of them does.
|
|
78
|
+
*/
|
|
79
|
+
function probeSidecar(
|
|
80
|
+
hostPort: number,
|
|
81
|
+
/** Seam for tests: a socket that never connects can't be produced locally. */
|
|
82
|
+
connect: (port: number) => SocketLike = (port) =>
|
|
83
|
+
net.connect(port, "127.0.0.1"),
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const socket = connect(hostPort);
|
|
87
|
+
let settled = false;
|
|
88
|
+
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
89
|
+
const done = (ok: boolean): void => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(settleTimer);
|
|
93
|
+
clearTimeout(connectTimer);
|
|
94
|
+
socket.destroy();
|
|
95
|
+
resolve(ok);
|
|
96
|
+
};
|
|
97
|
+
// The settle window starts at `connect`, NOT at the call. Starting it here
|
|
98
|
+
// would count a connection still stuck in the handshake as healthy — the
|
|
99
|
+
// one state that is neither of the two we can distinguish, and the one a
|
|
100
|
+
// wedged host is most likely to be in.
|
|
101
|
+
//
|
|
102
|
+
// The connect deadline is disarmed at the same moment, or the two overlap:
|
|
103
|
+
// a connection established at 900ms would be failed at 1000ms, 50ms into a
|
|
104
|
+
// 150ms settle window it was on course to pass. The deadlines are
|
|
105
|
+
// sequential — first "did it connect", then "did it stay" — so only one may
|
|
106
|
+
// be running.
|
|
107
|
+
socket.on("connect", () => {
|
|
108
|
+
clearTimeout(connectTimer);
|
|
109
|
+
settleTimer = setTimeout(() => done(true), PROBE_SETTLE_MS);
|
|
110
|
+
});
|
|
111
|
+
const connectTimer = setTimeout(() => done(false), PROBE_CONNECT_MS);
|
|
112
|
+
socket.on("error", () => done(false));
|
|
113
|
+
socket.on("close", () => done(false));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Test seam: the probe with an injectable socket. Not used in production. */
|
|
118
|
+
export const probeForTest = probeSidecar;
|
|
119
|
+
|
|
120
|
+
/** Poll `probeSidecar` until the chain answers, or give up. */
|
|
121
|
+
async function waitUntilReady(hostPort: number): Promise<boolean> {
|
|
122
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
123
|
+
for (;;) {
|
|
124
|
+
if (await probeSidecar(hostPort)) return true;
|
|
125
|
+
if (Date.now() >= deadline) return false;
|
|
126
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
48
130
|
async function imageOf(appContainer: string): Promise<string | null> {
|
|
49
131
|
const r = await dockerCli(
|
|
50
132
|
["inspect", "-f", "{{.Config.Image}}", appContainer],
|
|
@@ -74,8 +156,18 @@ async function createSidecar(args: {
|
|
|
74
156
|
// Reuse a still-running sidecar from a previous host process (--rm keeps it up
|
|
75
157
|
// across a host restart; the in-memory cache was cleared). `docker port` is an
|
|
76
158
|
// exact-name lookup, so no name-filter regex pitfalls.
|
|
159
|
+
//
|
|
160
|
+
// PROBED, not just found. A sidecar outlives the app container it proxies to,
|
|
161
|
+
// so one left over from a previous container generation is still running and
|
|
162
|
+
// still publishing its port while its route is dead. Reused blind, that
|
|
163
|
+
// sidecar serves `socket hang up` forever: the recovery path invalidates the
|
|
164
|
+
// CACHE on a failed connect, then lands right back here and returns the same
|
|
165
|
+
// corpse. Nothing could break the cycle short of removing it by hand.
|
|
77
166
|
const existing = await publishedPort(name);
|
|
78
|
-
if (existing !== null)
|
|
167
|
+
if (existing !== null) {
|
|
168
|
+
if (await probeSidecar(existing)) return existing;
|
|
169
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
170
|
+
}
|
|
79
171
|
|
|
80
172
|
const image = await imageOf(appContainer);
|
|
81
173
|
if (!image) return null;
|
|
@@ -101,6 +193,20 @@ async function createSidecar(args: {
|
|
|
101
193
|
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
102
194
|
return null;
|
|
103
195
|
}
|
|
196
|
+
|
|
197
|
+
// Do not hand back a port that isn't answering yet. `docker run -d` returns
|
|
198
|
+
// when the CONTAINER starts and the mapping exists from that instant, but the
|
|
199
|
+
// node proxy inside has not called `listen` — so every request arriving in
|
|
200
|
+
// that window is accepted by docker-proxy and dropped.
|
|
201
|
+
//
|
|
202
|
+
// A browser makes that window very expensive. It fetches the document, then
|
|
203
|
+
// fires every sub-resource at once: one page load put ~20 requests into a
|
|
204
|
+
// ~3s gap and lost all of them, which renders as an unstyled page with broken
|
|
205
|
+
// images rather than as anything resembling "not ready yet".
|
|
206
|
+
if (!(await waitUntilReady(hostPort))) {
|
|
207
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
104
210
|
return hostPort;
|
|
105
211
|
}
|
|
106
212
|
|
|
@@ -133,8 +239,22 @@ export async function ensurePreviewSidecar(args: {
|
|
|
133
239
|
return pending;
|
|
134
240
|
}
|
|
135
241
|
|
|
136
|
-
/**
|
|
137
|
-
*
|
|
242
|
+
/**
|
|
243
|
+
* Drop the cached port for a sidecar whose route just failed. No-op for
|
|
244
|
+
* published-declared / container-IP targets, which aren't in this cache.
|
|
245
|
+
*
|
|
246
|
+
* CACHE-ONLY, deliberately. This fires from the host's `upstream.on("error")`,
|
|
247
|
+
* which cannot tell a dead sidecar from a client that hit stop mid-download or
|
|
248
|
+
* a request that tripped the tunnel's ack deadline — both destroy the socket
|
|
249
|
+
* with ECONNRESET. Removing the container here would let one abandoned asset
|
|
250
|
+
* kill a sidecar that is serving twenty other requests fine, which is a worse
|
|
251
|
+
* failure than the one being recovered from.
|
|
252
|
+
*
|
|
253
|
+
* Forgetting the port is enough now: the next `createSidecar` finds the
|
|
254
|
+
* container by name and PROBES it, so a genuinely dead one is removed there,
|
|
255
|
+
* where the evidence is direct rather than inferred. That is the escape hatch
|
|
256
|
+
* this used to lack — invalidation alone kept landing back on the same corpse.
|
|
257
|
+
*/
|
|
138
258
|
export function invalidatePreviewSidecar(hostPort: number): void {
|
|
139
259
|
for (const [key, port] of cache) {
|
|
140
260
|
if (port === hostPort) cache.delete(key);
|
package/package.json
CHANGED
|
@@ -59,6 +59,22 @@ if [ -n "$compose_project" ]; then
|
|
|
59
59
|
# Fallback: tear down by project name only.
|
|
60
60
|
docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
|
|
61
61
|
fi
|
|
62
|
+
# `compose down` is intentionally best-effort because an already-absent
|
|
63
|
+
# stack can make a config-less fallback fail. Destruction itself is not
|
|
64
|
+
# best-effort: before deleting the worktree or reporting success, prove that
|
|
65
|
+
# no container remains under the stable compose project name.
|
|
66
|
+
remaining_containers=""
|
|
67
|
+
if ! remaining_containers=$(docker ps -aq \
|
|
68
|
+
--filter "label=com.docker.compose.project=$compose_project"); then
|
|
69
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
70
|
+
"could not verify teardown for compose project $compose_project" \
|
|
71
|
+
"docker ps after compose down"
|
|
72
|
+
fi
|
|
73
|
+
if [ -n "$remaining_containers" ]; then
|
|
74
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
75
|
+
"compose project $compose_project still has containers after teardown" \
|
|
76
|
+
"docker ps after compose down"
|
|
77
|
+
fi
|
|
62
78
|
fi
|
|
63
79
|
|
|
64
80
|
# -----------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
} from "../lib/runtime-state";
|
|
20
20
|
import { clearTaskGatewayAcl } from "../lib/mcp-gateway";
|
|
21
21
|
import { parsePreviewPortRuntimes } from "../lib/preview-ports";
|
|
22
|
-
import {
|
|
22
|
+
import { dockerCli } from "../lib/docker-exec";
|
|
23
|
+
import {
|
|
24
|
+
ensurePreviewSidecar,
|
|
25
|
+
stopPreviewSidecars,
|
|
26
|
+
} from "../lib/preview-sidecar";
|
|
23
27
|
import {
|
|
24
28
|
HostErrorCode,
|
|
25
29
|
type CommandContext,
|
|
@@ -106,75 +110,180 @@ export const hostEvents: HostEventStream = {
|
|
|
106
110
|
export const hostCommands: HostCommands = {
|
|
107
111
|
async taskUp(ctx, input) {
|
|
108
112
|
logCommand(ctx, "taskUp", input.task.id);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
input.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
113
|
+
const orchestrator = getOrchestrator();
|
|
114
|
+
return orchestrator.runTaskLifecycle(input.task.id, async () => {
|
|
115
|
+
// A direct duplicate teardown may still be draining operations that
|
|
116
|
+
// target this task's stable compose/container names. Never recreate
|
|
117
|
+
// those names until every close caller has crossed the shared fence.
|
|
118
|
+
await orchestrator.waitForChannelClose(input.task.id);
|
|
119
|
+
// A command retry that arrives after the original start completed must
|
|
120
|
+
// not rerender/compose-up/uai-init underneath live agent sessions. Treat
|
|
121
|
+
// that lost-response retry as the successful operation it already was:
|
|
122
|
+
// every cloud caller records a non-ok taskUp as an errored task.
|
|
123
|
+
const existingTask = getHostTask(input.task.id);
|
|
124
|
+
if (existingTask?.statusMirror === "running") {
|
|
125
|
+
if (!existingTask.composeProject || !existingTask.worktreePath) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
code: HostErrorCode.Internal,
|
|
129
|
+
message:
|
|
130
|
+
`task ${input.task.id} is running but its persisted runtime ` +
|
|
131
|
+
"metadata is incomplete",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// The mirror may be stale after a manual `docker compose down`.
|
|
135
|
+
// Confirm the app itself is live before treating this as a lost-response
|
|
136
|
+
// retry; otherwise fall through to the normal taskUp self-heal.
|
|
137
|
+
const appRuntime = await dockerCli(
|
|
138
|
+
[
|
|
139
|
+
"ps",
|
|
140
|
+
"-a",
|
|
141
|
+
"--filter",
|
|
142
|
+
`label=com.docker.compose.project=${existingTask.composeProject}`,
|
|
143
|
+
"--filter",
|
|
144
|
+
"label=com.docker.compose.service=app",
|
|
145
|
+
"--format",
|
|
146
|
+
"{{.State}}",
|
|
147
|
+
],
|
|
148
|
+
{ timeoutMs: 10_000 },
|
|
140
149
|
);
|
|
150
|
+
if (appRuntime.status !== 0) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
code: HostErrorCode.HostUnavailable,
|
|
154
|
+
message:
|
|
155
|
+
`could not verify runtime state for task ${input.task.id}: ` +
|
|
156
|
+
(appRuntime.stderr.trim().slice(0, 200) ||
|
|
157
|
+
"docker did not answer"),
|
|
158
|
+
retryable: true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
appRuntime.stdout
|
|
163
|
+
.split(/\r?\n/)
|
|
164
|
+
.some((state) => state.trim() === "running")
|
|
165
|
+
) {
|
|
166
|
+
// waitForChannelClose crossed the teardown fence above. A completed
|
|
167
|
+
// close-only teardown may have left this running task tombstoned, so
|
|
168
|
+
// the idempotent-success transition must reopen delivery just like a
|
|
169
|
+
// fresh successful taskUp does.
|
|
170
|
+
orchestrator.allowChannel(input.task.id);
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
value: {
|
|
174
|
+
composeProject: existingTask.composeProject,
|
|
175
|
+
worktreePath: existingTask.worktreePath,
|
|
176
|
+
codeServerPort: existingTask.codeServerPort ?? undefined,
|
|
177
|
+
previewPorts: parsePreviewPortRuntimes(
|
|
178
|
+
existingTask.previewPorts,
|
|
179
|
+
),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// Docker positively proved the mirrored app is absent/stopped. Drain
|
|
184
|
+
// and invalidate any stale in-memory channel before taskUp recreates
|
|
185
|
+
// the same stable compose/container names.
|
|
186
|
+
await orchestrator.closeChannel(input.task.id);
|
|
141
187
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
);
|
|
188
|
+
recordTaskStarting(input.task.id);
|
|
189
|
+
recordTaskOwner(
|
|
190
|
+
input.task.id,
|
|
191
|
+
input.task.ownerUserId,
|
|
192
|
+
input.ownerEmail ?? null,
|
|
193
|
+
input.ownerName ?? null,
|
|
194
|
+
input.projects.map((p) => p.slug),
|
|
195
|
+
);
|
|
196
|
+
// ADR-048: persist the cloud-issued per-task cli secret (host-private) so the
|
|
197
|
+
// `uai` CLI's per-agent tokens can be minted now + after a host restart.
|
|
198
|
+
if (input.task.cliSecret) {
|
|
199
|
+
storeTaskCliSecret(input.task.id, input.task.cliSecret);
|
|
154
200
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
201
|
+
recordHostEvent(input.task.id, "task.created");
|
|
202
|
+
const result = await wrapAgent(ctx, "taskUp", async () => {
|
|
203
|
+
// Self-heal the standard base image before task-up needs it. The
|
|
204
|
+
// boot-time build is best-effort + one-shot: a host that started before
|
|
205
|
+
// Docker was ready (common on Docker Desktop) never built
|
|
206
|
+
// uai-standard:dev, so every task-up died on a doomed registry pull
|
|
207
|
+
// (live 2026-07-22). This is idempotent — a fast inspect when present,
|
|
208
|
+
// a real build only when missing/stale (Docker is definitely up now, a
|
|
209
|
+
// task just arrived). On a build FAILURE, surface WHY to the cloud (not
|
|
210
|
+
// just the host log — the operator can't read a remote user's machine).
|
|
211
|
+
const img = await ensureStandardImage();
|
|
212
|
+
if (!img.ok) {
|
|
213
|
+
throw new AgentError(
|
|
214
|
+
"STANDARD_IMAGE_UNAVAILABLE",
|
|
215
|
+
img.error
|
|
216
|
+
? `could not prepare the host base image uai-standard:dev — the build failed. ${toolStderrDetail(img.error)}`
|
|
217
|
+
: "could not prepare the host base image uai-standard:dev — see the host log.",
|
|
218
|
+
{},
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return agent.taskUp(input);
|
|
222
|
+
});
|
|
223
|
+
if (result.ok) {
|
|
224
|
+
orchestrator.allowChannel(input.task.id);
|
|
225
|
+
recordTaskUpResult(input.task.id, result.value);
|
|
226
|
+
recordHostEvent(input.task.id, "task.started");
|
|
227
|
+
// Degraded start (uai-init failed twice): the task is up, but say so
|
|
228
|
+
// in the feed — a silent half-start reads as a broken product.
|
|
229
|
+
if (result.value.initWarning) {
|
|
230
|
+
orchestrator.emitSystemNote(
|
|
231
|
+
input.task.id,
|
|
232
|
+
result.value.initWarning,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
// GitHub auth for the container is best-effort (ADR-027) and runs in the
|
|
236
|
+
// background — it must never block or fail task-up. Awaiting it here would
|
|
237
|
+
// couple the command result to a network token-exchange: a slow/hung
|
|
238
|
+
// exchange (e.g. cloud mid-deploy) would trip the cloud's command timeout
|
|
239
|
+
// and mark a running task as errored. setupTaskGithub injects + schedules
|
|
240
|
+
// (or emits a system note on failure) on its own; the agents come up
|
|
241
|
+
// regardless and the token lands well before the first `gh` call.
|
|
242
|
+
void setupTaskGithub(input.task.id, input.task.ownerUserId);
|
|
243
|
+
} else {
|
|
244
|
+
recordTaskError(input.task.id);
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
});
|
|
167
248
|
},
|
|
168
249
|
|
|
169
250
|
async taskDown(ctx, input) {
|
|
170
251
|
logCommand(ctx, "taskDown", input.taskId);
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
252
|
+
const orchestrator = getOrchestrator();
|
|
253
|
+
return orchestrator.runTaskLifecycle(input.taskId, async () => {
|
|
254
|
+
// Tombstone + close before container destruction. A concurrent cloud
|
|
255
|
+
// ensure cannot reopen the channel while teardown is awaiting Docker.
|
|
256
|
+
await orchestrator.closeChannel(input.taskId);
|
|
257
|
+
// Ad-hoc preview proxies share this task's compose network and stable
|
|
258
|
+
// names. Remove them inside the same lifecycle slot before compose down.
|
|
259
|
+
try {
|
|
260
|
+
await stopPreviewSidecars(input.taskId);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.warn(
|
|
263
|
+
`[host-agent] task ${input.taskId}: preview cleanup failed: ${
|
|
264
|
+
err instanceof Error ? err.message : String(err)
|
|
265
|
+
}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const result = await wrapAgent(ctx, "taskDown", () =>
|
|
269
|
+
agent.taskDown(input),
|
|
270
|
+
);
|
|
271
|
+
if (result.ok) {
|
|
272
|
+
// Revoke only after destruction succeeds. On failure the task is
|
|
273
|
+
// reopened below and its on-disk MCP definitions must retain the same
|
|
274
|
+
// gateway token; deleting it early would make those exact definitions
|
|
275
|
+
// look foreign and permanently strand the recovered channel.
|
|
276
|
+
clearTaskGatewayAcl(input.taskId);
|
|
277
|
+
recordTaskDown(input.taskId, result.value.status);
|
|
278
|
+
recordHostEvent(input.taskId, "task.ended");
|
|
279
|
+
clearRefresh(input.taskId);
|
|
280
|
+
} else {
|
|
281
|
+
// Destruction failed; let a subsequent ensure restore the still-running
|
|
282
|
+
// task instead of leaving it permanently tombstoned.
|
|
283
|
+
orchestrator.allowChannel(input.taskId);
|
|
284
|
+
}
|
|
285
|
+
return result;
|
|
286
|
+
});
|
|
178
287
|
},
|
|
179
288
|
|
|
180
289
|
async taskStatus(ctx, taskId) {
|
|
@@ -209,14 +318,17 @@ export const hostCommands: HostCommands = {
|
|
|
209
318
|
|
|
210
319
|
async channelTeardown(ctx, taskId) {
|
|
211
320
|
logCommand(ctx, "channelTeardown", taskId);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
321
|
+
const orchestrator = getOrchestrator();
|
|
322
|
+
return orchestrator.runTaskLifecycle(taskId, async () => {
|
|
323
|
+
try {
|
|
324
|
+
await orchestrator.closeChannel(taskId);
|
|
325
|
+
// ADR-057: the task's gateway routes die with the channel.
|
|
326
|
+
clearTaskGatewayAcl(taskId);
|
|
327
|
+
return ok(undefined);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return failFromUnknown(err);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
220
332
|
},
|
|
221
333
|
|
|
222
334
|
async channelDeliver(ctx, taskId, agentId, text) {
|
|
@@ -238,33 +350,39 @@ export const hostCommands: HostCommands = {
|
|
|
238
350
|
|
|
239
351
|
async previewEnsure(ctx, taskId, name, containerPort) {
|
|
240
352
|
logCommand(ctx, "previewEnsure", taskId, name);
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
353
|
+
const orchestrator = getOrchestrator();
|
|
354
|
+
return orchestrator.runTaskLifecycle(taskId, async () => {
|
|
355
|
+
try {
|
|
356
|
+
const task = getHostTask(taskId);
|
|
357
|
+
if (!task) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false as const,
|
|
360
|
+
code: HostErrorCode.TaskNotFound,
|
|
361
|
+
message: `no such task: ${taskId}`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (task.statusMirror !== "running") {
|
|
365
|
+
return ok({ hostPort: null });
|
|
366
|
+
}
|
|
367
|
+
// Task-up published port (preview enabled at launch) wins.
|
|
368
|
+
const declared = parsePreviewPortRuntimes(task.previewPorts).find(
|
|
369
|
+
(port) => port.name === name,
|
|
370
|
+
);
|
|
371
|
+
if (declared) return ok({ hostPort: declared.hostPort });
|
|
372
|
+
// Else start (or find) the node-proxy sidecar NOW — the same one the
|
|
373
|
+
// tunnel lazy-starts on first access — and hand back its host port.
|
|
374
|
+
if (!task.composeProject) return ok({ hostPort: null });
|
|
375
|
+
const hostPort = await ensurePreviewSidecar({
|
|
376
|
+
taskId,
|
|
377
|
+
composeProject: task.composeProject,
|
|
378
|
+
name,
|
|
379
|
+
containerPort,
|
|
380
|
+
});
|
|
381
|
+
return ok({ hostPort });
|
|
382
|
+
} catch (err) {
|
|
383
|
+
return failFromUnknown(err);
|
|
249
384
|
}
|
|
250
|
-
|
|
251
|
-
const declared = parsePreviewPortRuntimes(task.previewPorts).find(
|
|
252
|
-
(port) => port.name === name,
|
|
253
|
-
);
|
|
254
|
-
if (declared) return ok({ hostPort: declared.hostPort });
|
|
255
|
-
// Else start (or find) the node-proxy sidecar NOW — the same one the
|
|
256
|
-
// tunnel lazy-starts on first access — and hand back its host port.
|
|
257
|
-
if (!task.composeProject) return ok({ hostPort: null });
|
|
258
|
-
const hostPort = await ensurePreviewSidecar({
|
|
259
|
-
taskId,
|
|
260
|
-
composeProject: task.composeProject,
|
|
261
|
-
name,
|
|
262
|
-
containerPort,
|
|
263
|
-
});
|
|
264
|
-
return ok({ hostPort });
|
|
265
|
-
} catch (err) {
|
|
266
|
-
return failFromUnknown(err);
|
|
267
|
-
}
|
|
385
|
+
});
|
|
268
386
|
},
|
|
269
387
|
|
|
270
388
|
async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
|
package/src/main.ts
CHANGED
|
@@ -58,7 +58,6 @@ import { dockerCli } from "../lib/docker-exec";
|
|
|
58
58
|
import {
|
|
59
59
|
ensurePreviewSidecar,
|
|
60
60
|
invalidatePreviewSidecar,
|
|
61
|
-
stopPreviewSidecars,
|
|
62
61
|
} from "../lib/preview-sidecar";
|
|
63
62
|
import { newId } from "../lib/ulid";
|
|
64
63
|
import {
|
|
@@ -87,7 +86,7 @@ import {
|
|
|
87
86
|
type TaskDiffInput,
|
|
88
87
|
type TaskDownInput,
|
|
89
88
|
type TaskLaunchInput,
|
|
90
|
-
FilesOpInput,
|
|
89
|
+
type FilesOpInput,
|
|
91
90
|
} from "./protocol";
|
|
92
91
|
|
|
93
92
|
// Crash reporting (ADR-071) — default-ON for installed hosts via the baked
|
|
@@ -736,12 +735,24 @@ async function resolveTunnelTarget(
|
|
|
736
735
|
// Ad-hoc port (not published): proxy via a node-proxy sidecar that publishes
|
|
737
736
|
// a 127.0.0.1 port forwarding to <app>:<containerPort> (ADR-043).
|
|
738
737
|
if (frame.containerPort && task.composeProject) {
|
|
739
|
-
const hostPort = await
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
738
|
+
const hostPort = await getOrchestrator().runTaskLifecycle(
|
|
739
|
+
frame.taskId,
|
|
740
|
+
async () => {
|
|
741
|
+
const current = getHostTask(frame.taskId);
|
|
742
|
+
if (
|
|
743
|
+
current?.statusMirror !== "running" ||
|
|
744
|
+
!current.composeProject
|
|
745
|
+
) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
return ensurePreviewSidecar({
|
|
749
|
+
taskId: frame.taskId,
|
|
750
|
+
composeProject: current.composeProject,
|
|
751
|
+
name: frame.name!,
|
|
752
|
+
containerPort: frame.containerPort!,
|
|
753
|
+
});
|
|
754
|
+
},
|
|
755
|
+
);
|
|
745
756
|
if (hostPort) return { host: "127.0.0.1", port: hostPort };
|
|
746
757
|
}
|
|
747
758
|
}
|
|
@@ -883,8 +894,6 @@ function dispatchCommand(
|
|
|
883
894
|
return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
|
|
884
895
|
case "taskDown": {
|
|
885
896
|
const downInput = expectTaskDownInput(args, 0);
|
|
886
|
-
// ADR-043: tear down this task's preview sidecars. Best-effort.
|
|
887
|
-
void stopPreviewSidecars(downInput.taskId).catch(() => {});
|
|
888
897
|
return hostCommands.taskDown(ctx, downInput);
|
|
889
898
|
}
|
|
890
899
|
case "taskStatus":
|