@runuai/host 0.2.8 → 0.3.0
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/agents/claude.ts +8 -0
- package/lib/agents/codex.ts +11 -0
- package/lib/agents/registry.ts +11 -1
- package/lib/command-db.ts +6 -2
- package/lib/git-diff.ts +67 -16
- package/lib/orchestrator.ts +55 -18
- package/lib/preview-sidecar.ts +157 -0
- package/lib/task-diff.ts +75 -20
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +22 -26
- package/src/main.ts +126 -22
- package/src/protocol.ts +13 -0
package/lib/agents/claude.ts
CHANGED
|
@@ -338,6 +338,14 @@ register({
|
|
|
338
338
|
defaultModel: CLAUDE_DEFAULT_MODEL,
|
|
339
339
|
supportedEfforts: () => [...CLAUDE_EFFORTS],
|
|
340
340
|
defaultEffort: CLAUDE_DEFAULT_EFFORT,
|
|
341
|
+
// Usable only when a Claude credential is in the env (injected into task
|
|
342
|
+
// containers at task-up). Gates advertisement (ADR-044 P2).
|
|
343
|
+
available: () =>
|
|
344
|
+
Boolean(
|
|
345
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
|
346
|
+
process.env.ANTHROPIC_API_KEY ||
|
|
347
|
+
process.env.ANTHROPIC_AUTH_TOKEN,
|
|
348
|
+
),
|
|
341
349
|
create: async ({ agent, containerName, systemPreamble }) =>
|
|
342
350
|
new ClaudeSession({ agent, containerName, systemPreamble }),
|
|
343
351
|
});
|
package/lib/agents/codex.ts
CHANGED
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
* namespaces inside it anyway.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { existsSync } from "node:fs";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
|
|
31
35
|
import { newId } from "../ulid";
|
|
32
36
|
import { dockerExecArgs, LineProcess } from "./proc";
|
|
33
37
|
import { register } from "./registry";
|
|
@@ -517,6 +521,13 @@ register({
|
|
|
517
521
|
defaultModel: CODEX_DEFAULT_MODEL,
|
|
518
522
|
supportedEfforts: () => [...CODEX_EFFORTS],
|
|
519
523
|
defaultEffort: CODEX_DEFAULT_EFFORT,
|
|
524
|
+
// Usable only when the owner's ~/.codex login exists — task-up copies it into
|
|
525
|
+
// each container from UAI_OWNER_HOME (default $HOME). Gates advertisement
|
|
526
|
+
// (ADR-044 P2).
|
|
527
|
+
available: () =>
|
|
528
|
+
existsSync(
|
|
529
|
+
join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
|
|
530
|
+
),
|
|
520
531
|
create: async ({ agent, containerName, systemPreamble }) =>
|
|
521
532
|
new CodexSession({ agent, containerName, systemPreamble }),
|
|
522
533
|
});
|
package/lib/agents/registry.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface RegisteredAdapter {
|
|
|
32
32
|
supportedEfforts(): string[];
|
|
33
33
|
/** Preferred effort when the user doesn't pick one. */
|
|
34
34
|
defaultEffort?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Whether this kind is usable on THIS host right now — i.e. its credentials
|
|
37
|
+
* are present (ADR-044 P2). Gates advertisement: an unavailable kind is left
|
|
38
|
+
* out of `capabilities()` so the cloud's task picker doesn't offer an LLM the
|
|
39
|
+
* host can't actually run. Omit (or return true) to always advertise; the
|
|
40
|
+
* session factory stays permissive regardless.
|
|
41
|
+
*/
|
|
42
|
+
available?: () => boolean;
|
|
35
43
|
/** Builds an AgentSession for a roster entry of this kind. */
|
|
36
44
|
create: AgentSessionFactory["create"];
|
|
37
45
|
}
|
|
@@ -76,7 +84,9 @@ export function factoryFor(kind: string): AgentSessionFactory | undefined {
|
|
|
76
84
|
* Each adapter's `supportedModels()` / `supportedEfforts()` is evaluated here.
|
|
77
85
|
*/
|
|
78
86
|
export function capabilities(): AgentKindCapability[] {
|
|
79
|
-
return list()
|
|
87
|
+
return list()
|
|
88
|
+
.filter((adapter) => adapter.available?.() ?? true)
|
|
89
|
+
.map((adapter) => {
|
|
80
90
|
const out: AgentKindCapability = {
|
|
81
91
|
kind: adapter.kind,
|
|
82
92
|
label: adapter.label,
|
package/lib/command-db.ts
CHANGED
|
@@ -135,12 +135,12 @@ function insertTask(db: Database.Database, task: CommandTaskRow): void {
|
|
|
135
135
|
id, owner_user_id, host_id, name, slug, branch, status,
|
|
136
136
|
global_context, reviewer_order, agents, pr_context,
|
|
137
137
|
worktree_path, compose_project, code_server_port, preview_ports,
|
|
138
|
-
locked_at, started_at, ended_at
|
|
138
|
+
preview_env, locked_at, started_at, ended_at
|
|
139
139
|
) VALUES (
|
|
140
140
|
@id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
|
|
141
141
|
@global_context, @reviewer_order, @agents, @pr_context,
|
|
142
142
|
@worktree_path, @compose_project, @code_server_port, @preview_ports,
|
|
143
|
-
@locked_at, @started_at, @ended_at
|
|
143
|
+
@preview_env, @locked_at, @started_at, @ended_at
|
|
144
144
|
)`,
|
|
145
145
|
).run(task);
|
|
146
146
|
}
|
|
@@ -166,6 +166,7 @@ interface CommandTaskRow {
|
|
|
166
166
|
compose_project: string | null;
|
|
167
167
|
code_server_port: number | null;
|
|
168
168
|
preview_ports: string;
|
|
169
|
+
preview_env: string | null;
|
|
169
170
|
locked_at: number | null;
|
|
170
171
|
started_at: number | null;
|
|
171
172
|
ended_at: number | null;
|
|
@@ -193,6 +194,7 @@ function withRuntime(
|
|
|
193
194
|
compose_project: runtime?.composeProject ?? null,
|
|
194
195
|
code_server_port: runtime?.codeServerPort ?? null,
|
|
195
196
|
preview_ports: runtime?.previewPorts ?? "[]",
|
|
197
|
+
preview_env: task.previewEnv ? JSON.stringify(task.previewEnv) : null,
|
|
196
198
|
locked_at: runtime?.lockedAt ?? null,
|
|
197
199
|
started_at: runtime?.startedAt ?? null,
|
|
198
200
|
ended_at: runtime?.endedAt ?? null,
|
|
@@ -216,6 +218,7 @@ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
|
|
|
216
218
|
compose_project: runtime?.composeProject ?? null,
|
|
217
219
|
code_server_port: runtime?.codeServerPort ?? null,
|
|
218
220
|
preview_ports: runtime?.previewPorts ?? "[]",
|
|
221
|
+
preview_env: null,
|
|
219
222
|
locked_at: runtime?.lockedAt ?? null,
|
|
220
223
|
started_at: runtime?.startedAt ?? null,
|
|
221
224
|
ended_at: runtime?.endedAt ?? null,
|
|
@@ -262,6 +265,7 @@ CREATE TABLE IF NOT EXISTS uai_tasks (
|
|
|
262
265
|
compose_project text,
|
|
263
266
|
code_server_port integer,
|
|
264
267
|
preview_ports text NOT NULL DEFAULT '[]',
|
|
268
|
+
preview_env text,
|
|
265
269
|
pr_url text,
|
|
266
270
|
locked_at integer,
|
|
267
271
|
started_at integer,
|
package/lib/git-diff.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Parse `git diff` unified output into structured per-file patches.
|
|
3
3
|
*
|
|
4
|
-
* Input is whatever `git diff
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Input is whatever `git diff <merge-base>` (committed + uncommitted) plus
|
|
5
|
+
* appended `--no-index` new-file patches for untracked files produce —
|
|
6
|
+
* multi-file, with headers, mode/rename/binary annotations, and `@@` hunks.
|
|
7
|
+
* The output is a list of `DiffFile`s the UI can render per-file with
|
|
7
8
|
* collapse/expand and syntax-highlighted hunks.
|
|
8
9
|
*
|
|
9
10
|
* Hand-rolled rather than pulled in as a dep — the unified-diff grammar
|
|
@@ -98,34 +99,84 @@ export function runGitDiffInContainer(
|
|
|
98
99
|
containerName: string,
|
|
99
100
|
cwd: string,
|
|
100
101
|
range: string,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
return runDiffCommand(
|
|
104
|
+
"docker",
|
|
105
|
+
[...dockerExecPrefix(containerName, cwd), "git", ...GIT_DIFF_ARGS, range],
|
|
106
|
+
"docker exec git diff",
|
|
107
|
+
process.env,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render a single untracked file as a new-file patch by diffing it against
|
|
113
|
+
* `/dev/null` with `--no-index`. `git diff <commit>` omits untracked files
|
|
114
|
+
* entirely, so this is how they reach the "all changes vs base" view.
|
|
115
|
+
*
|
|
116
|
+
* `--no-index` exits 1 when the inputs differ (the normal "there is a diff"
|
|
117
|
+
* case for a new file) and 0 when identical — both are success here; only
|
|
118
|
+
* code > 1 is a real error.
|
|
119
|
+
*/
|
|
120
|
+
export function runGitDiffUntracked(
|
|
121
|
+
cwd: string,
|
|
122
|
+
relPath: string,
|
|
123
|
+
): Promise<string> {
|
|
124
|
+
return runDiffCommand(
|
|
125
|
+
"git",
|
|
126
|
+
["-C", cwd, ...GIT_DIFF_ARGS, "--no-index", "--", "/dev/null", relPath],
|
|
127
|
+
"git diff --no-index",
|
|
128
|
+
{ ...process.env, ...NON_INTERACTIVE_GIT_ENV },
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** In-container variant of {@link runGitDiffUntracked}. */
|
|
134
|
+
export function runGitDiffUntrackedInContainer(
|
|
135
|
+
containerName: string,
|
|
136
|
+
cwd: string,
|
|
137
|
+
relPath: string,
|
|
101
138
|
): Promise<string> {
|
|
102
139
|
return runDiffCommand(
|
|
103
140
|
"docker",
|
|
104
141
|
[
|
|
105
|
-
|
|
106
|
-
"-e",
|
|
107
|
-
`GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
|
|
108
|
-
"-e",
|
|
109
|
-
`GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
|
|
110
|
-
"-e",
|
|
111
|
-
`GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
|
|
112
|
-
"-w",
|
|
113
|
-
cwd,
|
|
114
|
-
containerName,
|
|
142
|
+
...dockerExecPrefix(containerName, cwd),
|
|
115
143
|
"git",
|
|
116
144
|
...GIT_DIFF_ARGS,
|
|
117
|
-
|
|
145
|
+
"--no-index",
|
|
146
|
+
"--",
|
|
147
|
+
"/dev/null",
|
|
148
|
+
relPath,
|
|
118
149
|
],
|
|
119
|
-
"docker exec git diff",
|
|
150
|
+
"docker exec git diff --no-index",
|
|
120
151
|
process.env,
|
|
152
|
+
true,
|
|
121
153
|
);
|
|
122
154
|
}
|
|
123
155
|
|
|
156
|
+
/** `docker exec` argv prefix with the non-interactive git env + working dir. */
|
|
157
|
+
function dockerExecPrefix(containerName: string, cwd: string): string[] {
|
|
158
|
+
return [
|
|
159
|
+
"exec",
|
|
160
|
+
"-e",
|
|
161
|
+
`GIT_TERMINAL_PROMPT=${NON_INTERACTIVE_GIT_ENV.GIT_TERMINAL_PROMPT}`,
|
|
162
|
+
"-e",
|
|
163
|
+
`GCM_INTERACTIVE=${NON_INTERACTIVE_GIT_ENV.GCM_INTERACTIVE}`,
|
|
164
|
+
"-e",
|
|
165
|
+
`GIT_SSH_COMMAND=${NON_INTERACTIVE_GIT_ENV.GIT_SSH_COMMAND}`,
|
|
166
|
+
"-w",
|
|
167
|
+
cwd,
|
|
168
|
+
containerName,
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
function runDiffCommand(
|
|
125
173
|
command: string,
|
|
126
174
|
args: string[],
|
|
127
175
|
label: string,
|
|
128
176
|
env: NodeJS.ProcessEnv,
|
|
177
|
+
/** Treat exit code 1 as success — `git diff --no-index` returns 1 when the
|
|
178
|
+
* compared inputs differ, which for a new-file patch is the expected case. */
|
|
179
|
+
allowExitOne = false,
|
|
129
180
|
): Promise<string> {
|
|
130
181
|
return new Promise((res, rej) => {
|
|
131
182
|
// Force ASCII paths and pull a generous unified context so the UI
|
|
@@ -144,7 +195,7 @@ function runDiffCommand(
|
|
|
144
195
|
});
|
|
145
196
|
child.on("error", rej);
|
|
146
197
|
child.on("close", (code) => {
|
|
147
|
-
if (code === 0) {
|
|
198
|
+
if (code === 0 || (allowExitOne && code === 1)) {
|
|
148
199
|
res(stdout);
|
|
149
200
|
return;
|
|
150
201
|
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -235,14 +235,13 @@ class Orchestrator {
|
|
|
235
235
|
});
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
238
|
+
// First-turn delivery is owned by the CLOUD (channel-router.ensureStarted),
|
|
239
|
+
// which fires each agent's initialPrompt exactly once per task — guarded by
|
|
240
|
+
// a non-empty message history (its durable "already started" check). The
|
|
241
|
+
// host must NOT deliver it here: this path re-runs on every session spawn,
|
|
242
|
+
// so it both duplicated the opening turn AND re-injected it on a host
|
|
243
|
+
// restart, making agents redo the first turn mid-task. The persona/mission
|
|
244
|
+
// an agent needs live in the always-on system preamble, so nothing is lost.
|
|
246
245
|
return true;
|
|
247
246
|
}
|
|
248
247
|
|
|
@@ -723,7 +722,11 @@ export function buildSystemPreamble(
|
|
|
723
722
|
workspacePath: string,
|
|
724
723
|
taskBranch: string,
|
|
725
724
|
): string {
|
|
726
|
-
const
|
|
725
|
+
const channelList = roster
|
|
726
|
+
.map((a) =>
|
|
727
|
+
a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
|
|
728
|
+
)
|
|
729
|
+
.join(", ");
|
|
727
730
|
const projectLines =
|
|
728
731
|
projects.length === 0
|
|
729
732
|
? ["(none mounted)"]
|
|
@@ -739,19 +742,51 @@ export function buildSystemPreamble(
|
|
|
739
742
|
"it by id at the start of a line — e.g. `@codex please review the",
|
|
740
743
|
"diff`. uai routes that message into that agent's input.",
|
|
741
744
|
"",
|
|
742
|
-
|
|
745
|
+
`**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
|
|
746
|
+
`Introduce and refer to yourself as ${agent.label}, not as a generic`,
|
|
747
|
+
"assistant or model name.",
|
|
748
|
+
"",
|
|
749
|
+
`Agents in this channel: ${channelList}.`,
|
|
743
750
|
"",
|
|
744
|
-
"The human you're working with is **@you**.
|
|
745
|
-
"
|
|
746
|
-
"
|
|
747
|
-
"
|
|
748
|
-
"`prefer?` or `@you done — PR is up for review`).
|
|
751
|
+
"The human you're working with is **@you**. @-mentioning them sends a",
|
|
752
|
+
"NOTIFICATION, so use it sparingly — only when you actually need them: a",
|
|
753
|
+
"decision you can't make, a blocker, an approval, or you've finished your",
|
|
754
|
+
"work and are handing it back for them to act on (e.g. `@you which`",
|
|
755
|
+
"`approach do you prefer?` or `@you done — PR is up for review`). For",
|
|
756
|
+
"everything else — status updates, thinking out loud, a direct reply to",
|
|
757
|
+
"something they just asked, acknowledgments — post in the channel WITHOUT",
|
|
758
|
+
"@-mentioning @you; they can read the channel and don't need a ping for",
|
|
759
|
+
"every message. Do NOT reflexively end messages with @you.",
|
|
749
760
|
"",
|
|
750
761
|
"An agent only receives a message when it is explicitly @-mentioned",
|
|
751
762
|
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
752
763
|
"you mean. There is NO `peer` command and no shared tmux session;",
|
|
753
764
|
"hand-offs are just @-mentions in your replies.",
|
|
754
765
|
"",
|
|
766
|
+
"Collaborate with your peers — divide up the work, review each other's",
|
|
767
|
+
"changes, share concrete ideas, and debate approach decisions by",
|
|
768
|
+
"@-mentioning them. That is how the team gets things done, and you",
|
|
769
|
+
"should do it freely whenever it moves the work forward. But mentioning",
|
|
770
|
+
"a peer WAKES it and costs a turn, so make each one count: a message to",
|
|
771
|
+
"a peer should ADVANCE the work — a real proposal, a question you need",
|
|
772
|
+
"answered, a hand-off, or a review with specific findings. Do NOT",
|
|
773
|
+
"@-mention a peer just to greet, thank, agree, acknowledge, or say",
|
|
774
|
+
"you're ready — content-free replies wake them for nothing and spiral",
|
|
775
|
+
"into endless back-and-forth. If you have nothing substantive to add,",
|
|
776
|
+
"don't @-mention back. And when there's no active task yet (intros, or",
|
|
777
|
+
"you're waiting on the human), answer briefly and then wait — you don't",
|
|
778
|
+
"need to @-mention anyone (including @you); they can see the channel.",
|
|
779
|
+
"",
|
|
780
|
+
"When a message already @-mentions several participants at once (the",
|
|
781
|
+
"human asking the whole group, or a peer addressing multiple agents),",
|
|
782
|
+
"it's a group broadcast — this is a GROUP CHAT and everyone named has",
|
|
783
|
+
"ALREADY been notified and will answer for themselves. Just answer for",
|
|
784
|
+
"YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
|
|
785
|
+
"question to them, or wait on them — no `I'll let @x speak`, `@x your",
|
|
786
|
+
"turn`, or `still waiting on @x`. Re-mentioning someone who already got",
|
|
787
|
+
"the message only wakes them again and spirals into duplicate replies.",
|
|
788
|
+
"Say your piece and stop.",
|
|
789
|
+
"",
|
|
755
790
|
"Because your input is only what you're addressed, you may be missing",
|
|
756
791
|
"context from messages between the human and the other agents. The full",
|
|
757
792
|
"channel transcript — every message + who wrote it (no tool calls) — is",
|
|
@@ -763,9 +798,11 @@ export function buildSystemPreamble(
|
|
|
763
798
|
"and committed your changes, or completed a review, end your reply by",
|
|
764
799
|
"@-mentioning the agent who should act next and telling them what you",
|
|
765
800
|
"did and what you need (e.g. `@codex changes committed on <branch> —",
|
|
766
|
-
"please review`, or `@claude review done, N issues to fix`).
|
|
767
|
-
"
|
|
768
|
-
"
|
|
801
|
+
"please review`, or `@claude review done, N issues to fix`). Don't",
|
|
802
|
+
"abandon unfinished work silently — but once your part is done and no",
|
|
803
|
+
"peer needs to act, it's fine to stop; only @-mention @you if you need",
|
|
804
|
+
"their input or are handing back finished work for them to act on. Don't",
|
|
805
|
+
"prolong an agent-to-agent exchange just to fill silence.",
|
|
769
806
|
"",
|
|
770
807
|
"## Workspace layout",
|
|
771
808
|
"",
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-043: per-(task, preview) node-proxy sidecar for ad-hoc preview ports.
|
|
3
|
+
*
|
|
4
|
+
* Ad-hoc preview ports aren't Docker-published, and the app container's bridge
|
|
5
|
+
* IP isn't host-routable on macOS/OrbStack (EHOSTUNREACH). So proxy through a
|
|
6
|
+
* tiny sidecar: a container on the task's compose network that PUBLISHES a
|
|
7
|
+
* `127.0.0.1` host port (reachable on every backend) and forwards to
|
|
8
|
+
* `<app-container>:<containerPort>`. The sidecar runs the task's OWN image
|
|
9
|
+
* (always local — no registry pull) under `node` with a ~5-line `net` TCP proxy;
|
|
10
|
+
* node streams give backpressure + handle WebSockets (Metro/HMR).
|
|
11
|
+
*
|
|
12
|
+
* Hot path is cache-only (NO docker call per request — that thrashed under a
|
|
13
|
+
* browser's concurrent requests). The cached port is trusted until a tunnel
|
|
14
|
+
* connect fails, which invalidates it (mirrors the container-IP cache). Declared
|
|
15
|
+
* ports keep their published-port path; only ad-hoc ports use a sidecar.
|
|
16
|
+
*/
|
|
17
|
+
import { dockerCli } from "./docker-exec";
|
|
18
|
+
|
|
19
|
+
/** Port the proxy listens on inside the sidecar (published to a random host port). */
|
|
20
|
+
const INNER_PORT = 9000;
|
|
21
|
+
const LABEL = "com.runuai.preview-sidecar.task";
|
|
22
|
+
|
|
23
|
+
/** (taskId,name,containerPort) -> published 127.0.0.1 host port of a live sidecar. */
|
|
24
|
+
const cache = new Map<string, number>();
|
|
25
|
+
/** In-flight creates, so concurrent requests for the same key share one sidecar. */
|
|
26
|
+
const inflight = new Map<string, Promise<number | null>>();
|
|
27
|
+
|
|
28
|
+
function cacheKey(taskId: string, name: string, containerPort: number): string {
|
|
29
|
+
return `${taskId} ${name} ${containerPort}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sidecarName(taskId: string, name: string): string {
|
|
33
|
+
return `uai-preview-${taskId}-${name}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function proxyScript(appContainer: string, containerPort: number): string {
|
|
37
|
+
// Bidirectional TCP pipe: 0.0.0.0:INNER_PORT <-> appContainer:containerPort.
|
|
38
|
+
return (
|
|
39
|
+
`const net=require('net');` +
|
|
40
|
+
`net.createServer(c=>{` +
|
|
41
|
+
`const u=net.connect(${containerPort},${JSON.stringify(appContainer)});` +
|
|
42
|
+
`c.on('error',()=>u.destroy());u.on('error',()=>c.destroy());` +
|
|
43
|
+
`c.pipe(u);u.pipe(c);` +
|
|
44
|
+
`}).listen(${INNER_PORT},'0.0.0.0');`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function imageOf(appContainer: string): Promise<string | null> {
|
|
49
|
+
const r = await dockerCli(
|
|
50
|
+
["inspect", "-f", "{{.Config.Image}}", appContainer],
|
|
51
|
+
{ timeoutMs: 5_000 },
|
|
52
|
+
);
|
|
53
|
+
return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `docker port <name>` -> the published 127.0.0.1 host port, or null. */
|
|
57
|
+
async function publishedPort(name: string): Promise<number | null> {
|
|
58
|
+
const r = await dockerCli(["port", name, String(INNER_PORT)], {
|
|
59
|
+
timeoutMs: 5_000,
|
|
60
|
+
});
|
|
61
|
+
const m = r.stdout.match(/127\.0\.0\.1:(\d+)/);
|
|
62
|
+
return r.status === 0 && m ? Number(m[1]) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function createSidecar(args: {
|
|
66
|
+
taskId: string;
|
|
67
|
+
composeProject: string;
|
|
68
|
+
name: string;
|
|
69
|
+
containerPort: number;
|
|
70
|
+
}): Promise<number | null> {
|
|
71
|
+
const name = sidecarName(args.taskId, args.name);
|
|
72
|
+
const appContainer = `${args.composeProject}-app-1`;
|
|
73
|
+
|
|
74
|
+
// Reuse a still-running sidecar from a previous host process (--rm keeps it up
|
|
75
|
+
// across a host restart; the in-memory cache was cleared). `docker port` is an
|
|
76
|
+
// exact-name lookup, so no name-filter regex pitfalls.
|
|
77
|
+
const existing = await publishedPort(name);
|
|
78
|
+
if (existing !== null) return existing;
|
|
79
|
+
|
|
80
|
+
const image = await imageOf(appContainer);
|
|
81
|
+
if (!image) return null;
|
|
82
|
+
|
|
83
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
84
|
+
const run = await dockerCli(
|
|
85
|
+
[
|
|
86
|
+
"run", "-d", "--rm",
|
|
87
|
+
"--name", name,
|
|
88
|
+
"--network", `${args.composeProject}_default`,
|
|
89
|
+
"--label", `${LABEL}=${args.taskId}`,
|
|
90
|
+
"-p", `127.0.0.1::${INNER_PORT}`,
|
|
91
|
+
"--entrypoint", "node",
|
|
92
|
+
image,
|
|
93
|
+
"-e", proxyScript(appContainer, args.containerPort),
|
|
94
|
+
],
|
|
95
|
+
{ timeoutMs: 20_000 },
|
|
96
|
+
);
|
|
97
|
+
if (run.status !== 0) return null;
|
|
98
|
+
|
|
99
|
+
const hostPort = await publishedPort(name);
|
|
100
|
+
if (hostPort === null) {
|
|
101
|
+
await dockerCli(["rm", "-f", name], { timeoutMs: 10_000 });
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return hostPort;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Ensure a sidecar is proxying `<app>:<containerPort>` for (taskId, name);
|
|
109
|
+
* returns the `127.0.0.1` host port it publishes, or null on failure. Cache-only
|
|
110
|
+
* on the hot path; a cache miss creates the sidecar (deduped across concurrent
|
|
111
|
+
* callers).
|
|
112
|
+
*/
|
|
113
|
+
export async function ensurePreviewSidecar(args: {
|
|
114
|
+
taskId: string;
|
|
115
|
+
composeProject: string;
|
|
116
|
+
name: string;
|
|
117
|
+
containerPort: number;
|
|
118
|
+
}): Promise<number | null> {
|
|
119
|
+
const key = cacheKey(args.taskId, args.name, args.containerPort);
|
|
120
|
+
const cached = cache.get(key);
|
|
121
|
+
if (cached !== undefined) return cached;
|
|
122
|
+
|
|
123
|
+
let pending = inflight.get(key);
|
|
124
|
+
if (!pending) {
|
|
125
|
+
pending = createSidecar(args)
|
|
126
|
+
.then((port) => {
|
|
127
|
+
if (port !== null) cache.set(key, port);
|
|
128
|
+
return port;
|
|
129
|
+
})
|
|
130
|
+
.finally(() => inflight.delete(key));
|
|
131
|
+
inflight.set(key, pending);
|
|
132
|
+
}
|
|
133
|
+
return pending;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Drop any cached sidecar entry on `hostPort` (a tunnel connect failed). No-op
|
|
137
|
+
* for published-declared / container-IP targets, which aren't in this cache. */
|
|
138
|
+
export function invalidatePreviewSidecar(hostPort: number): void {
|
|
139
|
+
for (const [key, port] of cache) {
|
|
140
|
+
if (port === hostPort) cache.delete(key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Stop + remove all preview sidecars for a task (called on task-down). */
|
|
145
|
+
export async function stopPreviewSidecars(taskId: string): Promise<void> {
|
|
146
|
+
for (const key of [...cache.keys()]) {
|
|
147
|
+
if (key.startsWith(`${taskId} `)) cache.delete(key);
|
|
148
|
+
}
|
|
149
|
+
const r = await dockerCli(
|
|
150
|
+
["ps", "-aq", "--filter", `label=${LABEL}=${taskId}`],
|
|
151
|
+
{ timeoutMs: 5_000 },
|
|
152
|
+
);
|
|
153
|
+
if (r.status !== 0) return;
|
|
154
|
+
for (const id of r.stdout.split("\n").map((s) => s.trim()).filter(Boolean)) {
|
|
155
|
+
await dockerCli(["rm", "-f", id], { timeoutMs: 10_000 });
|
|
156
|
+
}
|
|
157
|
+
}
|
package/lib/task-diff.ts
CHANGED
|
@@ -4,9 +4,20 @@ import { join } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
|
|
6
6
|
import { taskWorkspaceDir } from "./env";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
parseGitDiff,
|
|
9
|
+
runGitDiff,
|
|
10
|
+
runGitDiffInContainer,
|
|
11
|
+
runGitDiffUntracked,
|
|
12
|
+
runGitDiffUntrackedInContainer,
|
|
13
|
+
} from "./git-diff";
|
|
8
14
|
import { getHostTask } from "./runtime-state";
|
|
9
15
|
|
|
16
|
+
// Cap how many untracked files we render as new-file patches. Each is a
|
|
17
|
+
// separate (in-container) git spawn, so a pathological untracked dir that
|
|
18
|
+
// slips past .gitignore shouldn't turn the diff into thousands of execs.
|
|
19
|
+
const MAX_UNTRACKED = 200;
|
|
20
|
+
|
|
10
21
|
export async function buildTaskDiff(
|
|
11
22
|
input: TaskDiffInput,
|
|
12
23
|
): Promise<TaskDiffResult> {
|
|
@@ -23,15 +34,40 @@ export async function buildTaskDiff(
|
|
|
23
34
|
const containerCwd = `/workspace/${project.slug}`;
|
|
24
35
|
if (!containerName && !existsSync(cwd)) continue;
|
|
25
36
|
|
|
26
|
-
// Base branch is
|
|
27
|
-
//
|
|
28
|
-
//
|
|
37
|
+
// Base branch is the remote's default branch (where task branches are cut
|
|
38
|
+
// from at task-up) — NOT the worktree's @{upstream}, which flips to
|
|
39
|
+
// origin/<task-branch> once the branch is pushed and would then hide all
|
|
40
|
+
// committed work (see resolveBase).
|
|
29
41
|
const base = resolveBase(containerName, cwd, containerCwd, project.slug);
|
|
30
|
-
|
|
42
|
+
// Diff the whole branch against base, including UNCOMMITTED work: compare
|
|
43
|
+
// the merge-base (where the branch diverged) to the WORKING TREE, not HEAD.
|
|
44
|
+
// The merge-base keeps base's own later commits from showing up reversed if
|
|
45
|
+
// it advanced; falling back to `base` itself if merge-base can't resolve.
|
|
46
|
+
const mergeBase =
|
|
47
|
+
runGitText(containerName, cwd, containerCwd, [
|
|
48
|
+
"merge-base",
|
|
49
|
+
base,
|
|
50
|
+
"HEAD",
|
|
51
|
+
]) ?? base;
|
|
31
52
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
// Tracked changes (committed + staged + unstaged): a commit-ish vs the
|
|
54
|
+
// working tree, so omitting `..HEAD` is the point.
|
|
55
|
+
let text = containerName
|
|
56
|
+
? await runGitDiffInContainer(containerName, containerCwd, mergeBase)
|
|
57
|
+
: await runGitDiff(cwd, mergeBase);
|
|
58
|
+
|
|
59
|
+
// Untracked files: `git diff <commit>` skips them, so list (honouring
|
|
60
|
+
// .gitignore) and append each as a /dev/null new-file patch.
|
|
61
|
+
const untracked = listUntracked(containerName, cwd, containerCwd);
|
|
62
|
+
for (const file of untracked) {
|
|
63
|
+
const patch = containerName
|
|
64
|
+
? await runGitDiffUntrackedInContainer(containerName, containerCwd, file)
|
|
65
|
+
: await runGitDiffUntracked(cwd, file);
|
|
66
|
+
if (patch.trim().length === 0) continue;
|
|
67
|
+
if (text.length > 0 && !text.endsWith("\n")) text += "\n";
|
|
68
|
+
text += patch;
|
|
69
|
+
}
|
|
70
|
+
|
|
35
71
|
out.push({
|
|
36
72
|
id: project.id,
|
|
37
73
|
name: project.slug,
|
|
@@ -55,10 +91,16 @@ export async function buildTaskDiff(
|
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
/**
|
|
58
|
-
* Resolve the diff base for a worktree
|
|
59
|
-
* (
|
|
60
|
-
*
|
|
61
|
-
*
|
|
94
|
+
* Resolve the diff base for a worktree: the remote's default branch
|
|
95
|
+
* (`origin/HEAD`, set at task-up via `remote set-head`), falling back to
|
|
96
|
+
* `origin/main`. Runs git in-container when the task is live so credential
|
|
97
|
+
* behavior stays inside the sandbox (see git-diff).
|
|
98
|
+
*
|
|
99
|
+
* We deliberately do NOT use the worktree's `@{upstream}`. At task-up it is
|
|
100
|
+
* `origin/<defaultBranch>`, but once the task branch is pushed (e.g. a PR is
|
|
101
|
+
* opened) its upstream flips to `origin/<task-branch>` — then
|
|
102
|
+
* `merge-base(upstream, HEAD) ≈ HEAD`, so the diff would show only uncommitted
|
|
103
|
+
* work instead of the whole branch.
|
|
62
104
|
*/
|
|
63
105
|
function resolveBase(
|
|
64
106
|
containerName: string | null,
|
|
@@ -66,14 +108,6 @@ function resolveBase(
|
|
|
66
108
|
containerCwd: string,
|
|
67
109
|
_slug: string,
|
|
68
110
|
): string {
|
|
69
|
-
const upstream = runGitText(
|
|
70
|
-
containerName,
|
|
71
|
-
hostCwd,
|
|
72
|
-
containerCwd,
|
|
73
|
-
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
74
|
-
);
|
|
75
|
-
if (upstream) return upstream;
|
|
76
|
-
|
|
77
111
|
const head = runGitText(
|
|
78
112
|
containerName,
|
|
79
113
|
hostCwd,
|
|
@@ -89,6 +123,27 @@ function stripOriginPrefix(ref: string): string {
|
|
|
89
123
|
return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
|
|
90
124
|
}
|
|
91
125
|
|
|
126
|
+
/**
|
|
127
|
+
* List untracked files in the worktree, honouring .gitignore
|
|
128
|
+
* (`--exclude-standard`), capped at {@link MAX_UNTRACKED}. Empty on error.
|
|
129
|
+
*/
|
|
130
|
+
function listUntracked(
|
|
131
|
+
containerName: string | null,
|
|
132
|
+
hostCwd: string,
|
|
133
|
+
containerCwd: string,
|
|
134
|
+
): string[] {
|
|
135
|
+
const raw = runGitText(containerName, hostCwd, containerCwd, [
|
|
136
|
+
"ls-files",
|
|
137
|
+
"--others",
|
|
138
|
+
"--exclude-standard",
|
|
139
|
+
]);
|
|
140
|
+
if (!raw) return [];
|
|
141
|
+
return raw
|
|
142
|
+
.split("\n")
|
|
143
|
+
.filter((line) => line.length > 0)
|
|
144
|
+
.slice(0, MAX_UNTRACKED);
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
/**
|
|
93
148
|
* Run a read-only git command (host or in-container) and return trimmed
|
|
94
149
|
* stdout, or null on any failure. Used for base-branch resolution where a
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -309,11 +309,11 @@ fi
|
|
|
309
309
|
done < <(jq -c '.[]' <<<"$projects_json")
|
|
310
310
|
printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
|
|
311
311
|
printf ' ports:\n'
|
|
312
|
+
# code-server (the Editor tunnel) is the only auto-published port. Preview
|
|
313
|
+
# ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —
|
|
314
|
+
# each preview the user enables is reached lazily through a per-(task,preview)
|
|
315
|
+
# sidecar attached to the compose network, so nothing is exposed by default.
|
|
312
316
|
printf ' - "127.0.0.1::8080"\n'
|
|
313
|
-
while IFS= read -r cport; do
|
|
314
|
-
[ -n "$cport" ] || continue
|
|
315
|
-
printf ' - "127.0.0.1::%s"\n' "$cport"
|
|
316
|
-
done < <(jq -r '.[].containerPort' <<<"$union_preview_ports_json")
|
|
317
317
|
printf ' environment:\n'
|
|
318
318
|
# Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
|
|
319
319
|
# uses the interactive subscription/keychain path, so it needs a token from
|
|
@@ -345,6 +345,19 @@ fi
|
|
|
345
345
|
esac
|
|
346
346
|
printf ' %s: "${%s:-}"\n' "$ekey" "$ekey"
|
|
347
347
|
done < <(jq -r '.[]' <<<"$union_env_keys_json")
|
|
348
|
+
# Preview-URL env vars (ADR-025): cloud-computed PUBLIC preview URLs exposed
|
|
349
|
+
# under operator-chosen names (e.g. EXPO_PACKAGER_PROXY_URL). Non-secret, so
|
|
350
|
+
# written LITERALLY (unlike the ${KEY:-} pass-throughs above). `preview_env`
|
|
351
|
+
# is a JSON object {VAR: url} on the task row, one entry per preview port that
|
|
352
|
+
# set `urlEnv`. Emitted last so it wins over any same-named declared key.
|
|
353
|
+
preview_env_raw=$(jq -r '.[0].preview_env // "{}"' <<<"$task_json")
|
|
354
|
+
while IFS= read -r pe_entry; do
|
|
355
|
+
[ -n "$pe_entry" ] || continue
|
|
356
|
+
pe_key=$(jq -r '.key' <<<"$pe_entry")
|
|
357
|
+
pe_val=$(jq -r '.value' <<<"$pe_entry")
|
|
358
|
+
[ -n "$pe_key" ] || continue
|
|
359
|
+
printf ' %s: "%s"\n' "$pe_key" "$pe_val"
|
|
360
|
+
done < <(jq -c 'to_entries[]?' <<<"$preview_env_raw")
|
|
348
361
|
printf 'volumes:\n'
|
|
349
362
|
printf ' %s:\n' "$ASDF_VOLUME"
|
|
350
363
|
printf ' external: true\n'
|
|
@@ -441,29 +454,12 @@ if [ -z "$code_server_port" ]; then
|
|
|
441
454
|
log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
|
|
442
455
|
fi
|
|
443
456
|
|
|
457
|
+
# ADR-043 Stage 2: preview ports are no longer published at task-up, so there is
|
|
458
|
+
# nothing to discover here. `preview_ports` stays empty and every enabled preview
|
|
459
|
+
# is reached through its sidecar (started lazily on first access via the cloud
|
|
460
|
+
# tunnel). `union_preview_ports_json` is still surfaced to the cloud (GET task
|
|
461
|
+
# lists the declared/ad-hoc names the user can turn on).
|
|
444
462
|
preview_ports_runtime_json="[]"
|
|
445
|
-
if [ "$(jq 'length' <<<"$union_preview_ports_json")" -gt 0 ]; then
|
|
446
|
-
preview_port_lines=""
|
|
447
|
-
while IFS= read -r preview_obj; do
|
|
448
|
-
[ -n "$preview_obj" ] || continue
|
|
449
|
-
preview_name=$(jq -r '.name' <<<"$preview_obj")
|
|
450
|
-
preview_container_port=$(jq -r '.containerPort' <<<"$preview_obj")
|
|
451
|
-
preview_host_port=$(mapped_host_port "$app_container" "$preview_container_port")
|
|
452
|
-
if [ -z "$preview_host_port" ]; then
|
|
453
|
-
log "warning: could not discover preview port ${preview_name}:${preview_container_port} for ${app_container}"
|
|
454
|
-
continue
|
|
455
|
-
fi
|
|
456
|
-
preview_port_lines="${preview_port_lines}$(jq -nc \
|
|
457
|
-
--arg name "$preview_name" \
|
|
458
|
-
--arg hp "$preview_host_port" \
|
|
459
|
-
'{name:$name,hostPort:($hp|tonumber)}')
|
|
460
|
-
"
|
|
461
|
-
done < <(jq -c '.[]' <<<"$union_preview_ports_json")
|
|
462
|
-
|
|
463
|
-
if [ -n "$preview_port_lines" ]; then
|
|
464
|
-
preview_ports_runtime_json=$(printf '%s' "$preview_port_lines" | jq -s -c '.')
|
|
465
|
-
fi
|
|
466
|
-
fi
|
|
467
463
|
|
|
468
464
|
step "DB_UPDATE_FAILED" "mark task running"
|
|
469
465
|
cs_sql="code_server_port=NULL"
|
package/src/main.ts
CHANGED
|
@@ -49,6 +49,12 @@ import {
|
|
|
49
49
|
} from "./paths";
|
|
50
50
|
import { dockerMemoryBytes, startUiServer } from "./ui/server";
|
|
51
51
|
import { parsePreviewPortRuntimes } from "../lib/preview-ports";
|
|
52
|
+
import { dockerCli } from "../lib/docker-exec";
|
|
53
|
+
import {
|
|
54
|
+
ensurePreviewSidecar,
|
|
55
|
+
invalidatePreviewSidecar,
|
|
56
|
+
stopPreviewSidecars,
|
|
57
|
+
} from "../lib/preview-sidecar";
|
|
52
58
|
import { newId } from "../lib/ulid";
|
|
53
59
|
import {
|
|
54
60
|
capabilities as agentKindCapabilities,
|
|
@@ -262,7 +268,7 @@ function connect(): void {
|
|
|
262
268
|
void handleCommand(socket, frame);
|
|
263
269
|
break;
|
|
264
270
|
case "tunnel.open":
|
|
265
|
-
handleTunnelOpen(socket, frame);
|
|
271
|
+
void handleTunnelOpen(socket, frame);
|
|
266
272
|
break;
|
|
267
273
|
case "tunnel.data":
|
|
268
274
|
pendingBinaryTunnelId = frame.tunnelId;
|
|
@@ -417,12 +423,12 @@ async function handleCommand(
|
|
|
417
423
|
}
|
|
418
424
|
}
|
|
419
425
|
|
|
420
|
-
function handleTunnelOpen(
|
|
426
|
+
async function handleTunnelOpen(
|
|
421
427
|
wsSocket: WebSocket,
|
|
422
428
|
frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
|
|
423
|
-
): void {
|
|
424
|
-
const
|
|
425
|
-
if (!
|
|
429
|
+
): Promise<void> {
|
|
430
|
+
const target = await resolveTunnelTarget(frame);
|
|
431
|
+
if (!target) {
|
|
426
432
|
send(wsSocket, {
|
|
427
433
|
kind: "tunnel.ack",
|
|
428
434
|
tunnelId: frame.tunnelId,
|
|
@@ -434,23 +440,23 @@ function handleTunnelOpen(
|
|
|
434
440
|
}
|
|
435
441
|
|
|
436
442
|
if (!frame.upgrade) {
|
|
437
|
-
handleHttpTunnelOpen(wsSocket, frame,
|
|
443
|
+
handleHttpTunnelOpen(wsSocket, frame, target);
|
|
438
444
|
return;
|
|
439
445
|
}
|
|
440
446
|
|
|
441
|
-
handleRawTunnelOpen(wsSocket, frame,
|
|
447
|
+
handleRawTunnelOpen(wsSocket, frame, target);
|
|
442
448
|
}
|
|
443
449
|
|
|
444
450
|
function handleHttpTunnelOpen(
|
|
445
451
|
wsSocket: WebSocket,
|
|
446
452
|
frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
|
|
447
|
-
|
|
453
|
+
target: UpstreamAddr,
|
|
448
454
|
): void {
|
|
449
455
|
let acked = false;
|
|
450
456
|
const upstream = httpRequest(
|
|
451
457
|
{
|
|
452
|
-
host:
|
|
453
|
-
port,
|
|
458
|
+
host: target.host,
|
|
459
|
+
port: target.port,
|
|
454
460
|
method: frame.reqLine.method,
|
|
455
461
|
path: frame.reqLine.url,
|
|
456
462
|
headers: requestHeaders(frame.reqLine.headers),
|
|
@@ -485,6 +491,13 @@ function handleHttpTunnelOpen(
|
|
|
485
491
|
|
|
486
492
|
upstream.on("error", (err) => {
|
|
487
493
|
tunnels.delete(frame.tunnelId);
|
|
494
|
+
// A connect failure to a cached container IP likely means the container was
|
|
495
|
+
// recreated (resume) and got a new IP — drop the cache so the next request
|
|
496
|
+
// re-resolves (ADR-036).
|
|
497
|
+
invalidateContainerIpByAddr(target.host);
|
|
498
|
+
// ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
|
|
499
|
+
// the next request recreates it (no-op for published / container-IP targets).
|
|
500
|
+
invalidatePreviewSidecar(target.port);
|
|
488
501
|
if (!acked) {
|
|
489
502
|
send(wsSocket, {
|
|
490
503
|
kind: "tunnel.ack",
|
|
@@ -511,7 +524,7 @@ function handleHttpTunnelOpen(
|
|
|
511
524
|
function handleRawTunnelOpen(
|
|
512
525
|
wsSocket: WebSocket,
|
|
513
526
|
frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
|
|
514
|
-
|
|
527
|
+
target: UpstreamAddr,
|
|
515
528
|
): void {
|
|
516
529
|
const upstream = new Socket();
|
|
517
530
|
tunnels.set(frame.tunnelId, upstream);
|
|
@@ -547,6 +560,10 @@ function handleRawTunnelOpen(
|
|
|
547
560
|
});
|
|
548
561
|
|
|
549
562
|
upstream.on("error", (err) => {
|
|
563
|
+
invalidateContainerIpByAddr(target.host);
|
|
564
|
+
// ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
|
|
565
|
+
// the next request recreates it (no-op for published / container-IP targets).
|
|
566
|
+
invalidatePreviewSidecar(target.port);
|
|
550
567
|
if (!acked) {
|
|
551
568
|
send(wsSocket, {
|
|
552
569
|
kind: "tunnel.ack",
|
|
@@ -574,7 +591,7 @@ function handleRawTunnelOpen(
|
|
|
574
591
|
});
|
|
575
592
|
});
|
|
576
593
|
|
|
577
|
-
upstream.connect(port,
|
|
594
|
+
upstream.connect(target.port, target.host);
|
|
578
595
|
}
|
|
579
596
|
|
|
580
597
|
function closeTunnel(
|
|
@@ -588,17 +605,85 @@ function closeTunnel(
|
|
|
588
605
|
send(wsSocket, { kind: "tunnel.close", tunnelId, reason });
|
|
589
606
|
}
|
|
590
607
|
|
|
591
|
-
|
|
608
|
+
/** Where a tunnel's bytes go: `127.0.0.1:<published>` for the editor, or a
|
|
609
|
+
* container's `<ip>:<containerPort>` for a preview (ADR-036). */
|
|
610
|
+
interface UpstreamAddr {
|
|
611
|
+
host: string;
|
|
612
|
+
port: number;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Container IP cache (ADR-036): `docker inspect` is too slow for the request
|
|
616
|
+
// hot path, so cache the app container's Docker-network IP per compose project.
|
|
617
|
+
// Short TTL + invalidate-on-connect-error so a resumed container's new IP heals.
|
|
618
|
+
const CONTAINER_IP_TTL_MS = 60_000;
|
|
619
|
+
const containerIpCache = new Map<string, { ip: string; ts: number }>();
|
|
620
|
+
|
|
621
|
+
async function resolveContainerIp(composeProject: string): Promise<string | null> {
|
|
622
|
+
const container = `${composeProject}-app-1`;
|
|
623
|
+
const cached = containerIpCache.get(container);
|
|
624
|
+
if (cached && Date.now() - cached.ts < CONTAINER_IP_TTL_MS) return cached.ip;
|
|
625
|
+
const res = await dockerCli(
|
|
626
|
+
[
|
|
627
|
+
"inspect",
|
|
628
|
+
"-f",
|
|
629
|
+
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
|
630
|
+
container,
|
|
631
|
+
],
|
|
632
|
+
{ timeoutMs: 5_000 },
|
|
633
|
+
);
|
|
634
|
+
if (res.status !== 0) return null;
|
|
635
|
+
const ip = res.stdout.trim();
|
|
636
|
+
if (!ip) return null;
|
|
637
|
+
containerIpCache.set(container, { ip, ts: Date.now() });
|
|
638
|
+
return ip;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Drop any cached container IP equal to `addr` (no-op for `127.0.0.1`). */
|
|
642
|
+
function invalidateContainerIpByAddr(addr: string): void {
|
|
643
|
+
if (addr === "127.0.0.1") return;
|
|
644
|
+
for (const [key, val] of containerIpCache) {
|
|
645
|
+
if (val.ip === addr) containerIpCache.delete(key);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function resolveTunnelTarget(
|
|
592
650
|
frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
|
|
593
|
-
):
|
|
651
|
+
): Promise<UpstreamAddr | null> {
|
|
594
652
|
const task = getHostTask(frame.taskId);
|
|
595
653
|
if (!task) return null;
|
|
596
|
-
if (frame.target === "editor")
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
654
|
+
if (frame.target === "editor") {
|
|
655
|
+
return task.codeServerPort
|
|
656
|
+
? { host: "127.0.0.1", port: task.codeServerPort }
|
|
657
|
+
: null;
|
|
658
|
+
}
|
|
659
|
+
// Preview. Prefer the PUBLISHED host port for declared previews (published at
|
|
660
|
+
// task-up): a 127.0.0.1 port that's reachable on every backend, incl.
|
|
661
|
+
// macOS/OrbStack where the container bridge IP is NOT host-routable (ADR-043).
|
|
662
|
+
if (frame.name) {
|
|
663
|
+
const declared = parsePreviewPortRuntimes(task.previewPorts).find(
|
|
664
|
+
(port) => port.name === frame.name,
|
|
665
|
+
);
|
|
666
|
+
if (declared) return { host: "127.0.0.1", port: declared.hostPort };
|
|
667
|
+
// Ad-hoc port (not published): proxy via a node-proxy sidecar that publishes
|
|
668
|
+
// a 127.0.0.1 port forwarding to <app>:<containerPort> (ADR-043).
|
|
669
|
+
if (frame.containerPort && task.composeProject) {
|
|
670
|
+
const hostPort = await ensurePreviewSidecar({
|
|
671
|
+
taskId: frame.taskId,
|
|
672
|
+
composeProject: task.composeProject,
|
|
673
|
+
name: frame.name,
|
|
674
|
+
containerPort: frame.containerPort,
|
|
675
|
+
});
|
|
676
|
+
if (hostPort) return { host: "127.0.0.1", port: hostPort };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Linux fallback (pre-ADR-043 / non-macOS host where bridge IPs route): proxy
|
|
680
|
+
// straight to the container IP. Unreachable on macOS/OrbStack — the
|
|
681
|
+
// connect-error path returns 502 there.
|
|
682
|
+
if (frame.containerPort && task.composeProject) {
|
|
683
|
+
const ip = await resolveContainerIp(task.composeProject);
|
|
684
|
+
if (ip) return { host: ip, port: frame.containerPort };
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
602
687
|
}
|
|
603
688
|
|
|
604
689
|
function serializeRequest(reqLine: {
|
|
@@ -727,8 +812,12 @@ function dispatchCommand(
|
|
|
727
812
|
switch (command) {
|
|
728
813
|
case "taskUp":
|
|
729
814
|
return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
|
|
730
|
-
case "taskDown":
|
|
731
|
-
|
|
815
|
+
case "taskDown": {
|
|
816
|
+
const downInput = expectTaskDownInput(args, 0);
|
|
817
|
+
// ADR-043: tear down this task's preview sidecars. Best-effort.
|
|
818
|
+
void stopPreviewSidecars(downInput.taskId).catch(() => {});
|
|
819
|
+
return hostCommands.taskDown(ctx, downInput);
|
|
820
|
+
}
|
|
732
821
|
case "taskStatus":
|
|
733
822
|
return hostCommands.taskStatus(ctx, expectString(args, 0));
|
|
734
823
|
case "channelEnsure":
|
|
@@ -821,6 +910,10 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
821
910
|
target: frame.target,
|
|
822
911
|
taskId: frame.taskId,
|
|
823
912
|
name: typeof frame.name === "string" ? frame.name : undefined,
|
|
913
|
+
// ADR-043: cloud-resolved in-container port for ad-hoc previews; feeds the
|
|
914
|
+
// node-proxy sidecar (NOT the unreachable bridge IP — see resolveTunnelTarget).
|
|
915
|
+
containerPort:
|
|
916
|
+
typeof frame.containerPort === "number" ? frame.containerPort : undefined,
|
|
824
917
|
reqLine: frame.reqLine,
|
|
825
918
|
upgrade: frame.upgrade,
|
|
826
919
|
};
|
|
@@ -1139,6 +1232,17 @@ function expectTaskCommandTask(value: unknown): TaskCommandTask {
|
|
|
1139
1232
|
expectStringValue(id, `task.reviewerOrder[${i}]`),
|
|
1140
1233
|
);
|
|
1141
1234
|
}
|
|
1235
|
+
if (
|
|
1236
|
+
row.previewEnv &&
|
|
1237
|
+
typeof row.previewEnv === "object" &&
|
|
1238
|
+
!Array.isArray(row.previewEnv)
|
|
1239
|
+
) {
|
|
1240
|
+
const pe: Record<string, string> = {};
|
|
1241
|
+
for (const [k, v] of Object.entries(row.previewEnv as Record<string, unknown>)) {
|
|
1242
|
+
if (typeof v === "string") pe[k] = v;
|
|
1243
|
+
}
|
|
1244
|
+
if (Object.keys(pe).length > 0) out.previewEnv = pe;
|
|
1245
|
+
}
|
|
1142
1246
|
return out;
|
|
1143
1247
|
}
|
|
1144
1248
|
|
package/src/protocol.ts
CHANGED
|
@@ -115,6 +115,14 @@ export interface TaskCommandTask {
|
|
|
115
115
|
globalContext?: string;
|
|
116
116
|
reviewerOrder?: string[];
|
|
117
117
|
agents: TaskAgent[];
|
|
118
|
+
/**
|
|
119
|
+
* Cloud-computed PUBLIC preview URLs to inject into the container env at
|
|
120
|
+
* task-up, keyed by the operator-chosen var name (e.g.
|
|
121
|
+
* `EXPO_PACKAGER_PROXY_URL`). Non-secret — written literally into the task's
|
|
122
|
+
* compose file. Only present on task-up commands, when a preview port set
|
|
123
|
+
* `urlEnv` and the cloud has a preview base domain configured.
|
|
124
|
+
*/
|
|
125
|
+
previewEnv?: Record<string, string>;
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
/**
|
|
@@ -299,6 +307,11 @@ export type CloudToHost =
|
|
|
299
307
|
target: TunnelTarget;
|
|
300
308
|
taskId: string;
|
|
301
309
|
name?: string;
|
|
310
|
+
// The in-container port to reach for a preview (ADR-036). The cloud
|
|
311
|
+
// resolves it from the task's declared + ad-hoc preview ports; the host
|
|
312
|
+
// proxies to <container-ip>:<containerPort> over the Docker network.
|
|
313
|
+
// Absent for the editor target (which uses the published codeServerPort).
|
|
314
|
+
containerPort?: number;
|
|
302
315
|
reqLine: TunnelReqLine;
|
|
303
316
|
upgrade: boolean;
|
|
304
317
|
}
|