@runuai/host 0.9.14 → 0.9.42
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/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +33 -2
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3060 -218
- package/lib/preview-sidecar.ts +57 -13
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1405 -107
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +766 -42
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort task.status frames have no acknowledgement, but they must at
|
|
3
|
+
* least survive bridge disconnects within this host process. The local task
|
|
4
|
+
* mirror remains the authority at flush time so a later Resume wins over an
|
|
5
|
+
* older queued stop.
|
|
6
|
+
*/
|
|
7
|
+
export class StoppedTaskStatusQueue {
|
|
8
|
+
private readonly pending = new Set<string>();
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly statusForTask: (
|
|
12
|
+
taskId: string,
|
|
13
|
+
) => string | null | undefined,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
enqueue(taskId: string): void {
|
|
17
|
+
this.pending.add(taskId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A failed taskUp may have stopped a legacy runtime before recreation lost
|
|
21
|
+
* Docker. Queue the physical truth after the command result is published. */
|
|
22
|
+
enqueueStoppedTaskUp(command: string, args: readonly unknown[]): boolean {
|
|
23
|
+
if (command !== "taskUp") return false;
|
|
24
|
+
const input = args[0];
|
|
25
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const task = (input as { task?: unknown }).task;
|
|
29
|
+
if (!task || typeof task !== "object" || Array.isArray(task)) return false;
|
|
30
|
+
const taskId = (task as { id?: unknown }).id;
|
|
31
|
+
if (typeof taskId !== "string" || this.statusForTask(taskId) !== "stopped") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
this.enqueue(taskId);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
flush(publish: (taskId: string) => void): void {
|
|
39
|
+
for (const taskId of [...this.pending]) {
|
|
40
|
+
if (this.statusForTask(taskId) === "stopped") publish(taskId);
|
|
41
|
+
this.pending.delete(taskId);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend-aware CLI access to TASK app containers for the auxiliary
|
|
3
|
+
* subsystems that predate the TaskEnvironment provider (GitHub credential
|
|
4
|
+
* injection, git identity, Codex reinjection, skills). Found live 2026-08-16:
|
|
5
|
+
* these spawned the literal `docker` binary with Compose replica names, so on
|
|
6
|
+
* a Docker-free Apple host every agent session start died at the credential
|
|
7
|
+
* step with spawn ENOENT.
|
|
8
|
+
*
|
|
9
|
+
* The exec/cp verbs are flag-compatible across Docker and the bundled Apple
|
|
10
|
+
* CLI, so most callers only need the command + container-name translation
|
|
11
|
+
* here. Verbs that differ (ps/rm/inventory) get explicit helpers.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
appleContainerRuntimeBinding,
|
|
18
|
+
pinnedContainerRuntimeProvider,
|
|
19
|
+
} from "./container-runtime";
|
|
20
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
21
|
+
import { assertSafeHostTaskId } from "./task-identity";
|
|
22
|
+
|
|
23
|
+
export interface TaskContainerBackend {
|
|
24
|
+
command: string;
|
|
25
|
+
apple: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The pin, not the readiness-gated identity: session ensure and recovery
|
|
29
|
+
* both run while the public state can still be `checking`. */
|
|
30
|
+
export function taskContainerBackend(): TaskContainerBackend {
|
|
31
|
+
if (pinnedContainerRuntimeProvider() === "apple-container") {
|
|
32
|
+
const binding = appleContainerRuntimeBinding();
|
|
33
|
+
if (binding !== null) {
|
|
34
|
+
return { command: binding.containerCliPath, apple: true };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { command: "docker", apple: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The app container's name under the selected backend: Compose replica
|
|
41
|
+
* naming for Docker, the provider's fixed name for Apple (must match
|
|
42
|
+
* appleTaskContainerName in task-environment/apple-container.ts). */
|
|
43
|
+
export function taskAppContainerName(taskId: string): string {
|
|
44
|
+
assertSafeHostTaskId(taskId);
|
|
45
|
+
const lowered = taskId.toLowerCase();
|
|
46
|
+
return taskContainerBackend().apple
|
|
47
|
+
? `task-${lowered}-app`
|
|
48
|
+
: `task-${lowered}-app-1`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** dockerCli-compatible runner that targets the selected backend. Every kill
|
|
52
|
+
* (deadline or overflow) forces settlement within a bounded grace. */
|
|
53
|
+
export function taskContainerCli(
|
|
54
|
+
args: string[],
|
|
55
|
+
opts: {
|
|
56
|
+
input?: string;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
maxOutputBytes?: number;
|
|
59
|
+
} = {},
|
|
60
|
+
): Promise<DockerResult> {
|
|
61
|
+
const backend = taskContainerBackend();
|
|
62
|
+
if (!backend.apple) return dockerCli(args, opts);
|
|
63
|
+
return new Promise<DockerResult>((resolve) => {
|
|
64
|
+
const child = nodeSpawn(backend.command, args, {
|
|
65
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
66
|
+
});
|
|
67
|
+
let stdout = "";
|
|
68
|
+
let stderr = "";
|
|
69
|
+
let settled = false;
|
|
70
|
+
const finish = (status: number | null): void => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
resolve({ status, stdout, stderr });
|
|
75
|
+
};
|
|
76
|
+
const killAndSettle = (): void => {
|
|
77
|
+
child.kill("SIGKILL");
|
|
78
|
+
const settle = setTimeout(() => finish(null), 5_000);
|
|
79
|
+
settle.unref?.();
|
|
80
|
+
};
|
|
81
|
+
const timer = setTimeout(killAndSettle, opts.timeoutMs ?? 30_000);
|
|
82
|
+
timer.unref?.();
|
|
83
|
+
child.stdout.setEncoding("utf8");
|
|
84
|
+
child.stderr.setEncoding("utf8");
|
|
85
|
+
child.stdout.on("data", (chunk: string) => {
|
|
86
|
+
stdout += chunk;
|
|
87
|
+
if (
|
|
88
|
+
opts.maxOutputBytes !== undefined &&
|
|
89
|
+
stdout.length + stderr.length > opts.maxOutputBytes
|
|
90
|
+
) {
|
|
91
|
+
killAndSettle();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
child.stderr.on("data", (chunk: string) => {
|
|
95
|
+
stderr += chunk;
|
|
96
|
+
if (
|
|
97
|
+
opts.maxOutputBytes !== undefined &&
|
|
98
|
+
stdout.length + stderr.length > opts.maxOutputBytes
|
|
99
|
+
) {
|
|
100
|
+
killAndSettle();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
child.stdin.on("error", () => {});
|
|
104
|
+
if (opts.input !== undefined) child.stdin.write(opts.input);
|
|
105
|
+
child.stdin.end();
|
|
106
|
+
child.on("error", () => finish(null));
|
|
107
|
+
child.on("close", (code) => finish(code));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type TaskContainerState =
|
|
112
|
+
| { kind: "running" }
|
|
113
|
+
| { kind: "stopped"; state: string }
|
|
114
|
+
| { kind: "absent" }
|
|
115
|
+
| { kind: "unknown"; detail: string };
|
|
116
|
+
|
|
117
|
+
/** Backend-aware state probe for one exact task app container. Absence is
|
|
118
|
+
* proven only by the complete not-found error naming the container (Apple)
|
|
119
|
+
* or an empty exact-name filter answer (Docker); anything else is unknown. */
|
|
120
|
+
export async function taskAppContainerState(
|
|
121
|
+
containerName: string,
|
|
122
|
+
run: typeof taskContainerCli = taskContainerCli,
|
|
123
|
+
): Promise<TaskContainerState> {
|
|
124
|
+
const backend = taskContainerBackend();
|
|
125
|
+
if (backend.apple) {
|
|
126
|
+
const inspected = await run(["inspect", containerName], {
|
|
127
|
+
timeoutMs: 30_000,
|
|
128
|
+
maxOutputBytes: 1024 * 1024,
|
|
129
|
+
});
|
|
130
|
+
if (inspected.status !== 0) {
|
|
131
|
+
if (
|
|
132
|
+
inspected.stdout.trim() === "" &&
|
|
133
|
+
inspected.stderr
|
|
134
|
+
.split("\n")
|
|
135
|
+
.some(
|
|
136
|
+
(line) =>
|
|
137
|
+
line.trim() === `Error: container not found: ${containerName}`,
|
|
138
|
+
)
|
|
139
|
+
) {
|
|
140
|
+
return { kind: "absent" };
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
kind: "unknown",
|
|
144
|
+
detail: inspected.stderr.trim().slice(0, 300) || "inspect failed",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
const parsed = JSON.parse(inspected.stdout) as Array<{
|
|
149
|
+
id?: unknown;
|
|
150
|
+
status?: { state?: unknown };
|
|
151
|
+
}>;
|
|
152
|
+
if (
|
|
153
|
+
!Array.isArray(parsed) ||
|
|
154
|
+
parsed.length !== 1 ||
|
|
155
|
+
parsed[0]?.id !== containerName
|
|
156
|
+
) {
|
|
157
|
+
return { kind: "unknown", detail: "inspect shape is invalid" };
|
|
158
|
+
}
|
|
159
|
+
const state = parsed[0]?.status?.state;
|
|
160
|
+
if (typeof state !== "string") {
|
|
161
|
+
return { kind: "unknown", detail: "inspect state is missing" };
|
|
162
|
+
}
|
|
163
|
+
return state === "running"
|
|
164
|
+
? { kind: "running" }
|
|
165
|
+
: { kind: "stopped", state };
|
|
166
|
+
} catch {
|
|
167
|
+
return { kind: "unknown", detail: "inspect output is not JSON" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const listed = await run(
|
|
171
|
+
[
|
|
172
|
+
"ps",
|
|
173
|
+
"--all",
|
|
174
|
+
"--filter",
|
|
175
|
+
`name=^/${containerName}$`,
|
|
176
|
+
"--format",
|
|
177
|
+
"{{.State}}",
|
|
178
|
+
],
|
|
179
|
+
{ timeoutMs: 30_000, maxOutputBytes: 64 * 1024 },
|
|
180
|
+
);
|
|
181
|
+
if (listed.status !== 0) {
|
|
182
|
+
return {
|
|
183
|
+
kind: "unknown",
|
|
184
|
+
detail: listed.stderr.trim().slice(0, 300) || "docker ps failed",
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const states = listed.stdout
|
|
188
|
+
.split(/\r?\n/)
|
|
189
|
+
.map((line) => line.trim())
|
|
190
|
+
.filter(Boolean);
|
|
191
|
+
if (states.length === 0) return { kind: "absent" };
|
|
192
|
+
if (states.length !== 1) {
|
|
193
|
+
return { kind: "unknown", detail: `${states.length} containers matched` };
|
|
194
|
+
}
|
|
195
|
+
return states[0] === "running"
|
|
196
|
+
? { kind: "running" }
|
|
197
|
+
: { kind: "stopped", state: states[0]! };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Force-remove one exact task app container and prove its absence. */
|
|
201
|
+
export async function removeTaskAppContainer(
|
|
202
|
+
containerName: string,
|
|
203
|
+
run: typeof taskContainerCli = taskContainerCli,
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
const backend = taskContainerBackend();
|
|
206
|
+
const removed = await run(
|
|
207
|
+
backend.apple
|
|
208
|
+
? ["delete", "--force", containerName]
|
|
209
|
+
: ["rm", "--force", containerName],
|
|
210
|
+
{ timeoutMs: 60_000, maxOutputBytes: 64 * 1024 },
|
|
211
|
+
);
|
|
212
|
+
if (removed.status !== 0 && backend.apple) {
|
|
213
|
+
// Deleting an already-absent container is success; anything else is not.
|
|
214
|
+
const state = await taskAppContainerState(containerName, run);
|
|
215
|
+
if (state.kind === "absent") return;
|
|
216
|
+
throw new Error(
|
|
217
|
+
`remove ${containerName} failed: ${removed.stderr.trim().slice(0, 300)}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const after = await taskAppContainerState(containerName, run);
|
|
221
|
+
if (after.kind !== "absent") {
|
|
222
|
+
throw new Error(`remove ${containerName} was not verified`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Names of RUNNING task app containers under the selected backend. */
|
|
227
|
+
export async function listRunningTaskAppContainerNames(
|
|
228
|
+
run: typeof taskContainerCli = taskContainerCli,
|
|
229
|
+
): Promise<string[] | null> {
|
|
230
|
+
const backend = taskContainerBackend();
|
|
231
|
+
if (backend.apple) {
|
|
232
|
+
const listed = await run(["list", "--format", "json"], {
|
|
233
|
+
timeoutMs: 30_000,
|
|
234
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
235
|
+
});
|
|
236
|
+
if (listed.status !== 0) return null;
|
|
237
|
+
try {
|
|
238
|
+
const parsed = JSON.parse(listed.stdout) as Array<{
|
|
239
|
+
configuration?: { id?: unknown };
|
|
240
|
+
status?: { state?: unknown };
|
|
241
|
+
}>;
|
|
242
|
+
if (!Array.isArray(parsed)) return null;
|
|
243
|
+
// `container list` without --all already returns running containers,
|
|
244
|
+
// but the state check keeps this honest against CLI behavior drift.
|
|
245
|
+
return parsed
|
|
246
|
+
.filter(
|
|
247
|
+
(item) =>
|
|
248
|
+
item?.status?.state === undefined ||
|
|
249
|
+
item?.status?.state === "running",
|
|
250
|
+
)
|
|
251
|
+
.map((item) => item?.configuration?.id)
|
|
252
|
+
.filter(
|
|
253
|
+
(id): id is string =>
|
|
254
|
+
typeof id === "string" && /^task-[a-z0-9][a-z0-9_-]*-app$/.test(id),
|
|
255
|
+
);
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const listed = await run(
|
|
261
|
+
["ps", "--filter", "status=running", "--format", "{{.Names}}"],
|
|
262
|
+
{ timeoutMs: 30_000, maxOutputBytes: 1024 * 1024 },
|
|
263
|
+
);
|
|
264
|
+
if (listed.status !== 0) return null;
|
|
265
|
+
return listed.stdout
|
|
266
|
+
.split(/\r?\n/)
|
|
267
|
+
.map((line) => line.trim())
|
|
268
|
+
.filter(Boolean);
|
|
269
|
+
}
|
package/lib/task-diff.ts
CHANGED
|
@@ -1,67 +1,89 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { join, posix } from "node:path";
|
|
4
4
|
|
|
5
5
|
import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
|
|
6
6
|
import { taskWorkspaceDir } from "./env";
|
|
7
7
|
import {
|
|
8
8
|
parseGitDiff,
|
|
9
9
|
runGitDiff,
|
|
10
|
-
|
|
10
|
+
runGitDiffInEnvironment,
|
|
11
11
|
runGitDiffUntracked,
|
|
12
|
-
|
|
12
|
+
runGitDiffUntrackedInEnvironment,
|
|
13
|
+
tryRunGitTextInEnvironment,
|
|
13
14
|
} from "./git-diff";
|
|
14
|
-
import {
|
|
15
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
15
16
|
|
|
16
17
|
// Cap how many untracked files we render as new-file patches. Each is a
|
|
17
|
-
// separate
|
|
18
|
+
// separate in-environment Git execution, so a pathological untracked dir that
|
|
18
19
|
// slips past .gitignore shouldn't turn the diff into thousands of execs.
|
|
19
20
|
const MAX_UNTRACKED = 200;
|
|
20
21
|
|
|
22
|
+
export type TaskDiffEnvironment = Pick<
|
|
23
|
+
TaskEnvironmentHandle,
|
|
24
|
+
"descriptor" | "exec"
|
|
25
|
+
>;
|
|
26
|
+
|
|
21
27
|
export async function buildTaskDiff(
|
|
22
28
|
input: TaskDiffInput,
|
|
29
|
+
environment: TaskDiffEnvironment | null = null,
|
|
23
30
|
): Promise<TaskDiffResult> {
|
|
31
|
+
if (
|
|
32
|
+
environment !== null &&
|
|
33
|
+
environment.descriptor.taskId !== input.taskId
|
|
34
|
+
) {
|
|
35
|
+
throw new Error("task diff environment belongs to a different task");
|
|
36
|
+
}
|
|
24
37
|
const workspaceRoot = taskWorkspaceDir(input.taskId);
|
|
25
|
-
const runtime = getHostTask(input.taskId);
|
|
26
|
-
const containerName =
|
|
27
|
-
runtime?.statusMirror === "running" && runtime.composeProject
|
|
28
|
-
? `${runtime.composeProject}-app-1`
|
|
29
|
-
: null;
|
|
30
38
|
const out: TaskDiffResult["repos"] = [];
|
|
31
39
|
|
|
32
40
|
for (const project of input.projects) {
|
|
33
41
|
const cwd = join(workspaceRoot, project.slug);
|
|
34
|
-
const
|
|
35
|
-
|
|
42
|
+
const environmentCwd = environment
|
|
43
|
+
? posix.join(environment.descriptor.workspacePath, project.slug)
|
|
44
|
+
: null;
|
|
45
|
+
if (!environment && !existsSync(cwd)) continue;
|
|
36
46
|
|
|
37
47
|
// Base branch is the remote's default branch (where task branches are cut
|
|
38
48
|
// from at task-up) — NOT the worktree's @{upstream}, which flips to
|
|
39
49
|
// origin/<task-branch> once the branch is pushed and would then hide all
|
|
40
50
|
// committed work (see resolveBase).
|
|
41
|
-
const base = resolveBase(
|
|
51
|
+
const base = await resolveBase(environment, cwd, environmentCwd);
|
|
42
52
|
// Diff the whole branch against base, including UNCOMMITTED work: compare
|
|
43
53
|
// the merge-base (where the branch diverged) to the WORKING TREE, not HEAD.
|
|
44
54
|
// The merge-base keeps base's own later commits from showing up reversed if
|
|
45
55
|
// it advanced; falling back to `base` itself if merge-base can't resolve.
|
|
46
56
|
const mergeBase =
|
|
47
|
-
runGitText(
|
|
57
|
+
(await runGitText(environment, cwd, environmentCwd, [
|
|
48
58
|
"merge-base",
|
|
49
59
|
base,
|
|
50
60
|
"HEAD",
|
|
51
|
-
]) ?? base;
|
|
61
|
+
])) ?? base;
|
|
52
62
|
try {
|
|
53
63
|
// Tracked changes (committed + staged + unstaged): a commit-ish vs the
|
|
54
64
|
// working tree, so omitting `..HEAD` is the point.
|
|
55
|
-
let text =
|
|
56
|
-
? await
|
|
65
|
+
let text = environment
|
|
66
|
+
? await runGitDiffInEnvironment(
|
|
67
|
+
environment,
|
|
68
|
+
environmentCwd!,
|
|
69
|
+
mergeBase,
|
|
70
|
+
)
|
|
57
71
|
: await runGitDiff(cwd, mergeBase);
|
|
58
72
|
|
|
59
73
|
// Untracked files: `git diff <commit>` skips them, so list (honouring
|
|
60
74
|
// .gitignore) and append each as a /dev/null new-file patch.
|
|
61
|
-
const untracked = listUntracked(
|
|
75
|
+
const untracked = await listUntracked(
|
|
76
|
+
environment,
|
|
77
|
+
cwd,
|
|
78
|
+
environmentCwd,
|
|
79
|
+
);
|
|
62
80
|
for (const file of untracked) {
|
|
63
|
-
const patch =
|
|
64
|
-
? await
|
|
81
|
+
const patch = environment
|
|
82
|
+
? await runGitDiffUntrackedInEnvironment(
|
|
83
|
+
environment,
|
|
84
|
+
environmentCwd!,
|
|
85
|
+
file,
|
|
86
|
+
)
|
|
65
87
|
: await runGitDiffUntracked(cwd, file);
|
|
66
88
|
if (patch.trim().length === 0) continue;
|
|
67
89
|
if (text.length > 0 && !text.endsWith("\n")) text += "\n";
|
|
@@ -93,7 +115,7 @@ export async function buildTaskDiff(
|
|
|
93
115
|
/**
|
|
94
116
|
* Resolve the diff base for a worktree: the remote's default branch
|
|
95
117
|
* (`origin/HEAD`, set at task-up via `remote set-head`), falling back to
|
|
96
|
-
* `origin/main`. Runs
|
|
118
|
+
* `origin/main`. Runs Git in-environment when the task is live so credential
|
|
97
119
|
* behavior stays inside the sandbox (see git-diff).
|
|
98
120
|
*
|
|
99
121
|
* We deliberately do NOT use the worktree's `@{upstream}`. At task-up it is
|
|
@@ -102,16 +124,15 @@ export async function buildTaskDiff(
|
|
|
102
124
|
* `merge-base(upstream, HEAD) ≈ HEAD`, so the diff would show only uncommitted
|
|
103
125
|
* work instead of the whole branch.
|
|
104
126
|
*/
|
|
105
|
-
function resolveBase(
|
|
106
|
-
|
|
127
|
+
async function resolveBase(
|
|
128
|
+
environment: TaskDiffEnvironment | null,
|
|
107
129
|
hostCwd: string,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
containerName,
|
|
130
|
+
environmentCwd: string | null,
|
|
131
|
+
): Promise<string> {
|
|
132
|
+
const head = await runGitText(
|
|
133
|
+
environment,
|
|
113
134
|
hostCwd,
|
|
114
|
-
|
|
135
|
+
environmentCwd,
|
|
115
136
|
["rev-parse", "--abbrev-ref", "origin/HEAD"],
|
|
116
137
|
);
|
|
117
138
|
if (head) return head;
|
|
@@ -127,12 +148,12 @@ function stripOriginPrefix(ref: string): string {
|
|
|
127
148
|
* List untracked files in the worktree, honouring .gitignore
|
|
128
149
|
* (`--exclude-standard`), capped at {@link MAX_UNTRACKED}. Empty on error.
|
|
129
150
|
*/
|
|
130
|
-
function listUntracked(
|
|
131
|
-
|
|
151
|
+
async function listUntracked(
|
|
152
|
+
environment: TaskDiffEnvironment | null,
|
|
132
153
|
hostCwd: string,
|
|
133
|
-
|
|
134
|
-
): string[] {
|
|
135
|
-
const raw = runGitText(
|
|
154
|
+
environmentCwd: string | null,
|
|
155
|
+
): Promise<string[]> {
|
|
156
|
+
const raw = await runGitText(environment, hostCwd, environmentCwd, [
|
|
136
157
|
"ls-files",
|
|
137
158
|
"--others",
|
|
138
159
|
"--exclude-standard",
|
|
@@ -145,23 +166,22 @@ function listUntracked(
|
|
|
145
166
|
}
|
|
146
167
|
|
|
147
168
|
/**
|
|
148
|
-
* Run a read-only
|
|
169
|
+
* Run a read-only Git command (host or in-environment) and return trimmed
|
|
149
170
|
* stdout, or null on any failure. Used for base-branch resolution where a
|
|
150
171
|
* missing upstream is expected, not exceptional.
|
|
151
172
|
*/
|
|
152
|
-
function runGitText(
|
|
153
|
-
|
|
173
|
+
async function runGitText(
|
|
174
|
+
environment: TaskDiffEnvironment | null,
|
|
154
175
|
hostCwd: string,
|
|
155
|
-
|
|
176
|
+
environmentCwd: string | null,
|
|
156
177
|
args: string[],
|
|
157
|
-
): string | null {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
: spawnSync("git", ["-C", hostCwd, ...args], { encoding: "utf8" });
|
|
178
|
+
): Promise<string | null> {
|
|
179
|
+
if (environment) {
|
|
180
|
+
return tryRunGitTextInEnvironment(environment, environmentCwd!, args);
|
|
181
|
+
}
|
|
182
|
+
const result = spawnSync("git", ["-C", hostCwd, ...args], {
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
});
|
|
165
185
|
if (result.status !== 0) return null;
|
|
166
186
|
const text = (result.stdout ?? "").trim();
|
|
167
187
|
return text.length > 0 ? text : null;
|