@runuai/host 0.1.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/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/uai-host.mjs +14 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/meta/_journal.json +41 -0
- package/db/schema.ts +82 -0
- package/images/standard/Dockerfile +232 -0
- package/images/standard/README.md +122 -0
- package/images/standard/container/code-server-settings.json +36 -0
- package/images/standard/container/uai-init +215 -0
- package/images/standard/tool-versions +2 -0
- package/lib/agent.ts +292 -0
- package/lib/agents/claude.ts +343 -0
- package/lib/agents/codex.ts +522 -0
- package/lib/agents/factory.ts +34 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/proc.ts +172 -0
- package/lib/agents/registry.ts +109 -0
- package/lib/agents/types.ts +133 -0
- package/lib/attachments.ts +46 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/command-db.ts +278 -0
- package/lib/db.ts +68 -0
- package/lib/env.ts +140 -0
- package/lib/git-diff.ts +370 -0
- package/lib/git-identity.ts +65 -0
- package/lib/github-tokens.ts +321 -0
- package/lib/orchestrator.ts +975 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/repo-clone.ts +127 -0
- package/lib/runtime-state.ts +120 -0
- package/lib/secrets.ts +71 -0
- package/lib/ssh.ts +186 -0
- package/lib/standard-image.ts +152 -0
- package/lib/task-diff.ts +113 -0
- package/lib/task-status.ts +46 -0
- package/lib/transcript.ts +30 -0
- package/lib/ulid.ts +7 -0
- package/package.json +85 -0
- package/scripts/agent/_common.sh +248 -0
- package/scripts/agent/task-down.sh +113 -0
- package/scripts/agent/task-status.sh +54 -0
- package/scripts/agent/task-up.sh +457 -0
- package/scripts/install/darwin.ts +167 -0
- package/scripts/install/linux.ts +115 -0
- package/scripts/install/types.ts +35 -0
- package/scripts/install/util.ts +39 -0
- package/scripts/install/win.ts +130 -0
- package/src/cli.ts +445 -0
- package/src/index.ts +375 -0
- package/src/load-env.ts +52 -0
- package/src/main.ts +1156 -0
- package/src/paths.ts +64 -0
- package/src/protocol.ts +413 -0
- package/src/ui/server.ts +343 -0
- package/src/ui/types.ts +78 -0
- package/ui/app.js +264 -0
- package/ui/index.html +55 -0
- package/ui/style.css +359 -0
- package/ui/uai-logo-black.svg +9 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "uai — slimmed code-server defaults. Seeded into the User settings",
|
|
3
|
+
"//2": "dir by uai-init when none exists yet. Trims welcome/telemetry/chat",
|
|
4
|
+
"//3": "chrome so the Editor pane is a focused editor, not a full AI-IDE.",
|
|
5
|
+
|
|
6
|
+
"telemetry.telemetryLevel": "off",
|
|
7
|
+
"workbench.startupEditor": "none",
|
|
8
|
+
"workbench.tips.enabled": false,
|
|
9
|
+
"workbench.welcomePage.walkthroughs.openOnInstall": false,
|
|
10
|
+
"window.commandCenter": false,
|
|
11
|
+
"chat.commandCenter.enabled": false,
|
|
12
|
+
"update.mode": "none",
|
|
13
|
+
"extensions.autoCheckUpdates": false,
|
|
14
|
+
"extensions.autoUpdate": false,
|
|
15
|
+
"git.openRepositoryInParentFolders": "always",
|
|
16
|
+
"workbench.activityBar.location": "top",
|
|
17
|
+
"chat.viewSessions.enabled": false,
|
|
18
|
+
"workbench.secondarySideBar.defaultVisibility": "hidden",
|
|
19
|
+
"workbench.colorTheme": "Dark 2026",
|
|
20
|
+
|
|
21
|
+
"//4": "Workspace Trust — every task runs in an isolated container off the",
|
|
22
|
+
"//5": "user's own repos. The 'do you trust this folder?' prompt is pure",
|
|
23
|
+
"//6": "friction here; the user already trusts their code and the container",
|
|
24
|
+
"//7": "is the security boundary.",
|
|
25
|
+
"security.workspace.trust.enabled": false,
|
|
26
|
+
|
|
27
|
+
"//8": "Port forwarding — code-server's own auto-forward / 'Open in Browser'",
|
|
28
|
+
"//9": "popup can't reach anything useful here: dev servers are exposed",
|
|
29
|
+
"//10": "through uai's preview tunnel (ADR-025), not code-server's forwarder.",
|
|
30
|
+
"//11": "Disable it so the popup doesn't mislead.",
|
|
31
|
+
"remote.autoForwardPorts": false,
|
|
32
|
+
"remote.restoreForwardedPorts": false,
|
|
33
|
+
|
|
34
|
+
"//12": "Integrated terminal uses zsh + oh-my-zsh (the node login shell).",
|
|
35
|
+
"terminal.integrated.defaultProfile.linux": "zsh"
|
|
36
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# uai-init — prepare a standard-image task container (ADR-022).
|
|
3
|
+
#
|
|
4
|
+
# Runs INSIDE the container (Linux, bash 5 — may use anything). Invoked by
|
|
5
|
+
# the host via `docker exec <app> /usr/local/bin/uai-init` after compose up.
|
|
6
|
+
# Spec: ../../../docs/runtime.md §uai-init.
|
|
7
|
+
#
|
|
8
|
+
# Workspace layout (ADR-022): /workspace/<project-slug>/ — one git worktree
|
|
9
|
+
# per selected project. A 0-project (scratchpad) task has an empty /workspace.
|
|
10
|
+
#
|
|
11
|
+
# Responsibilities:
|
|
12
|
+
# 1. For each /workspace/<slug>/ that carries a .tool-versions: `asdf
|
|
13
|
+
# install` (downloads cached on the host-wide /opt/asdf-data volume),
|
|
14
|
+
# then a dependency-install heuristic for the folder's stack.
|
|
15
|
+
# 2. Launch code-server on 0.0.0.0:8080 (auth disabled — reached only via
|
|
16
|
+
# the authenticated tunnel), opening /workspace.
|
|
17
|
+
#
|
|
18
|
+
# Best-effort + idempotent: a failing folder logs and is skipped; it never
|
|
19
|
+
# aborts the whole init. Re-running when things are already up is a no-op.
|
|
20
|
+
# uai-init itself always exits 0 so task-up's `docker exec ... uai-init` step
|
|
21
|
+
# succeeds as long as the container is reachable.
|
|
22
|
+
|
|
23
|
+
set -uo pipefail
|
|
24
|
+
|
|
25
|
+
WORKSPACE="${UAI_WORKSPACE:-/workspace}"
|
|
26
|
+
ASDF_SH="${ASDF_DIR:-/opt/asdf}/asdf.sh"
|
|
27
|
+
|
|
28
|
+
log() { echo "uai-init: $*"; }
|
|
29
|
+
|
|
30
|
+
# Make asdf + its shims available to this non-interactive shell. A bare
|
|
31
|
+
# `docker exec` sources no profile, so source asdf explicitly here.
|
|
32
|
+
if [ -f "$ASDF_SH" ]; then
|
|
33
|
+
# shellcheck disable=SC1090
|
|
34
|
+
. "$ASDF_SH"
|
|
35
|
+
else
|
|
36
|
+
log "warning: asdf not found at $ASDF_SH; runtime install will be skipped"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# 0. GitHub auth for in-container git (ADR-027). git rides the host's uai SSH
|
|
41
|
+
# identity (docker-cp'd to ~/.ssh, registered on GitHub for auth + signing),
|
|
42
|
+
# NOT an HTTPS token. Route every GitHub remote — including https:// ones —
|
|
43
|
+
# over SSH so a project's clone scheme doesn't matter, and trust github's
|
|
44
|
+
# host key non-interactively so a fresh container's first push doesn't stall
|
|
45
|
+
# on a prompt. `gh` is separate: it authenticates from its own config file,
|
|
46
|
+
# which the host writes via `gh auth login --with-token` with the user's
|
|
47
|
+
# short-lived token. We must NOT set GH_TOKEN in the env — `gh` would prefer
|
|
48
|
+
# it over that stored credential (and re-attribute every PR to it).
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
if [ -f "$HOME/.ssh/id_ed25519" ]; then
|
|
52
|
+
log "routing git GitHub remotes over the uai SSH identity"
|
|
53
|
+
git config --global url."git@github.com:".insteadOf "https://github.com/"
|
|
54
|
+
git config --global core.sshCommand "ssh -o StrictHostKeyChecking=accept-new"
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# Signed commits (policy): when the uai SSH identity is present, configure git
|
|
58
|
+
# to SSH-sign every commit + tag with it. The pubkey is registered on GitHub
|
|
59
|
+
# (via `setup-identity`) so commits show as Verified. The same key also carries
|
|
60
|
+
# git push over SSH (configured above).
|
|
61
|
+
if [ -f "$HOME/.ssh/id_ed25519.pub" ]; then
|
|
62
|
+
log "enabling SSH commit signing with the uai identity"
|
|
63
|
+
git config --global gpg.format ssh
|
|
64
|
+
git config --global user.signingkey "$HOME/.ssh/id_ed25519.pub"
|
|
65
|
+
git config --global commit.gpgsign true
|
|
66
|
+
git config --global tag.gpgsign true
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# Attribution policy: never co-author with the agents. Claude Code honors
|
|
70
|
+
# `includeCoAuthoredBy: false` (drops the "Co-Authored-By: Claude" commit
|
|
71
|
+
# trailer and the "Generated with Claude Code" PR footer). Seed it into the
|
|
72
|
+
# per-task Claude user settings if absent. Codex is covered by the
|
|
73
|
+
# orchestrator's system preamble.
|
|
74
|
+
cc_settings="$HOME/.claude/settings.json"
|
|
75
|
+
if [ ! -f "$cc_settings" ]; then
|
|
76
|
+
mkdir -p "$HOME/.claude"
|
|
77
|
+
printf '{\n "includeCoAuthoredBy": false\n}\n' > "$cc_settings"
|
|
78
|
+
log "seeded ~/.claude/settings.json (includeCoAuthoredBy: false)"
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# 1. Per-folder runtime + dependency install.
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
install_node_deps() {
|
|
86
|
+
# Pick the package manager from the packageManager field, else a lockfile,
|
|
87
|
+
# else npm. corepack provisions pnpm/yarn from the packageManager field.
|
|
88
|
+
local pm=""
|
|
89
|
+
if [ -f package.json ]; then
|
|
90
|
+
pm=$(jq -r '.packageManager // ""' package.json 2>/dev/null | sed 's/@.*//')
|
|
91
|
+
fi
|
|
92
|
+
if [ -z "$pm" ]; then
|
|
93
|
+
if [ -f pnpm-lock.yaml ]; then pm="pnpm"
|
|
94
|
+
elif [ -f yarn.lock ]; then pm="yarn"
|
|
95
|
+
elif [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then pm="npm"
|
|
96
|
+
elif [ -f bun.lockb ] || [ -f bun.lock ]; then pm="bun"
|
|
97
|
+
else pm="npm"
|
|
98
|
+
fi
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# corepack ships with Node and resolves pnpm/yarn per the packageManager
|
|
102
|
+
# field; harmless no-op when already enabled.
|
|
103
|
+
corepack enable >/dev/null 2>&1 || true
|
|
104
|
+
|
|
105
|
+
case "$pm" in
|
|
106
|
+
pnpm) log "pnpm install"; pnpm install ;;
|
|
107
|
+
yarn) log "yarn install"; yarn install ;;
|
|
108
|
+
bun) log "bun install"; bun install ;;
|
|
109
|
+
*) log "npm install"; npm install --no-audit --no-fund ;;
|
|
110
|
+
esac
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
install_python_deps() {
|
|
114
|
+
# uv handles both requirements.txt and pyproject.toml; create a project .venv
|
|
115
|
+
# so the install is isolated to the folder. Pin uv to ASDF's Python (the
|
|
116
|
+
# version asdf just resolved from the in-scope .tool-versions) so the venv
|
|
117
|
+
# matches the project's declared runtime. uv otherwise selects via
|
|
118
|
+
# .python-version / requires-python, which can disagree with .tool-versions
|
|
119
|
+
# (or be junk, e.g. a stray pyenv-virtualenv name) — asdf is the authority.
|
|
120
|
+
local py
|
|
121
|
+
py="$(asdf which python 2>/dev/null || true)"
|
|
122
|
+
if [ -f pyproject.toml ]; then
|
|
123
|
+
log "uv sync (pyproject.toml)${py:+ — asdf python $py}"
|
|
124
|
+
if [ -n "$py" ]; then uv sync --python "$py"; else uv sync; fi
|
|
125
|
+
elif [ -f requirements.txt ]; then
|
|
126
|
+
log "uv venv + pip install -r requirements.txt${py:+ — asdf python $py}"
|
|
127
|
+
if [ -n "$py" ]; then uv venv --python "$py"; else uv venv; fi \
|
|
128
|
+
&& uv pip install -r requirements.txt
|
|
129
|
+
fi
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
install_folder() {
|
|
133
|
+
local dir="$1" name
|
|
134
|
+
name=$(basename "$dir")
|
|
135
|
+
|
|
136
|
+
# Enter the folder; asdf auto-resolves the nearest .tool-versions here.
|
|
137
|
+
cd "$dir" || { log "cannot cd into $name — skipping"; return; }
|
|
138
|
+
|
|
139
|
+
# Runtime install. asdf reads the in-scope .tool-versions and downloads any
|
|
140
|
+
# missing versions (cached on the /opt/asdf-data volume).
|
|
141
|
+
if command -v asdf >/dev/null 2>&1; then
|
|
142
|
+
log "asdf install ($name)"
|
|
143
|
+
asdf install || log "asdf install failed in $name — continuing"
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
# Dependency-install heuristic. Run every matcher that applies (a polyglot
|
|
147
|
+
# folder may have more than one); each is independently best-effort.
|
|
148
|
+
if [ -f package.json ]; then
|
|
149
|
+
install_node_deps || log "node deps failed in $name — continuing"
|
|
150
|
+
fi
|
|
151
|
+
if [ -f pyproject.toml ] || [ -f requirements.txt ]; then
|
|
152
|
+
install_python_deps || log "python deps failed in $name — continuing"
|
|
153
|
+
fi
|
|
154
|
+
if [ -f Cargo.toml ]; then
|
|
155
|
+
log "cargo build ($name)"
|
|
156
|
+
cargo build || log "cargo build failed in $name — continuing"
|
|
157
|
+
fi
|
|
158
|
+
if [ -f go.mod ]; then
|
|
159
|
+
log "go mod download ($name)"
|
|
160
|
+
go mod download || log "go mod download failed in $name — continuing"
|
|
161
|
+
fi
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if [ -d "$WORKSPACE" ]; then
|
|
165
|
+
shopt -s nullglob
|
|
166
|
+
for folder in "$WORKSPACE"/*/; do
|
|
167
|
+
[ -d "$folder" ] || continue
|
|
168
|
+
if [ -f "${folder}.tool-versions" ]; then
|
|
169
|
+
# Run in a subshell so a `cd` (or a failing command under the relaxed
|
|
170
|
+
# error mode) in one folder never leaks into the next.
|
|
171
|
+
( install_folder "$folder" )
|
|
172
|
+
else
|
|
173
|
+
log "no .tool-versions in $(basename "$folder") — skipping per-folder install"
|
|
174
|
+
fi
|
|
175
|
+
done
|
|
176
|
+
shopt -u nullglob
|
|
177
|
+
else
|
|
178
|
+
log "workspace $WORKSPACE missing — scratchpad/no-project task, skipping installs"
|
|
179
|
+
fi
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# 2. code-server — the Editor pane. Bound to 0.0.0.0:8080, auth disabled
|
|
183
|
+
# (reached only through the authenticated tunnel / editor proxy). Opens
|
|
184
|
+
# /workspace so all worktrees are visible at the top level. Idempotent:
|
|
185
|
+
# skips launch if already running.
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
editor_root="$WORKSPACE"
|
|
189
|
+
[ -d "$editor_root" ] || editor_root="/home/node"
|
|
190
|
+
|
|
191
|
+
# Seed the curated code-server defaults (slim chrome, telemetry off, trust
|
|
192
|
+
# off) into the User settings dir when the task hasn't written its own yet.
|
|
193
|
+
cs_user_dir="$HOME/.local/share/code-server/User"
|
|
194
|
+
cs_seed="/usr/local/share/uai/code-server-settings.json"
|
|
195
|
+
if [ -f "$cs_seed" ] && [ ! -f "$cs_user_dir/settings.json" ]; then
|
|
196
|
+
mkdir -p "$cs_user_dir"
|
|
197
|
+
cp "$cs_seed" "$cs_user_dir/settings.json"
|
|
198
|
+
log "seeded code-server settings"
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
if pgrep -f 'code-server.*--bind-addr' >/dev/null 2>&1; then
|
|
202
|
+
log "code-server already running"
|
|
203
|
+
else
|
|
204
|
+
log "launching code-server on 0.0.0.0:8080"
|
|
205
|
+
nohup code-server \
|
|
206
|
+
--auth none \
|
|
207
|
+
--disable-telemetry \
|
|
208
|
+
--bind-addr 0.0.0.0:8080 \
|
|
209
|
+
"$editor_root" \
|
|
210
|
+
>/tmp/code-server.log 2>&1 &
|
|
211
|
+
disown
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
# Always succeed: the container is up and code-server has been (re)launched.
|
|
215
|
+
exit 0
|
package/lib/agent.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed wrapper around the host-agent shell scripts.
|
|
3
|
+
*
|
|
4
|
+
* The agent's wire contract (docs/agent.md):
|
|
5
|
+
* success → stdout: { ok: true, data: ... }
|
|
6
|
+
* failure → stderr: { ok: false, error: { code, message, step?, exit_code? } }
|
|
7
|
+
* non-zero exit on failure.
|
|
8
|
+
*
|
|
9
|
+
* This module shells out to scripts/agent/*.sh (or UAI_AGENT_DIR/*.sh in
|
|
10
|
+
* production), pipes UAI_DB_PATH + UAI_DATA_DIR into the child, parses
|
|
11
|
+
* the JSON, and returns typed result objects — or throws AgentError.
|
|
12
|
+
*
|
|
13
|
+
* Same-process for MVP (per ADR-004). When uai is extracted to a hosted
|
|
14
|
+
* product, this module becomes a JSON-RPC client over a websocket; the
|
|
15
|
+
* surface stays the same.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { dirname, resolve } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
createTaskDownCommandDb,
|
|
25
|
+
createTaskStatusCommandDb,
|
|
26
|
+
createTaskUpCommandDb,
|
|
27
|
+
removeCommandDb,
|
|
28
|
+
} from "./command-db";
|
|
29
|
+
import { env } from "./env";
|
|
30
|
+
import { PreviewPortRuntimesSchema } from "./preview-ports";
|
|
31
|
+
import { getHostTask } from "./runtime-state";
|
|
32
|
+
import { removeTaskIdentity, writeTaskIdentity } from "./ssh";
|
|
33
|
+
import type { TaskDownInput, TaskLaunchInput } from "../src/protocol";
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Locate the agent scripts. They ship inside the package, so by default we
|
|
37
|
+
// resolve them relative to this module — which works identically in a repo
|
|
38
|
+
// checkout (host-agent/scripts/agent) and an npm install
|
|
39
|
+
// (node_modules/@runuai/host/scripts/agent). An explicit UAI_AGENT_DIR
|
|
40
|
+
// overrides this (e.g. hacking on scripts against a running host).
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function packageAgentScriptsDir(): string {
|
|
44
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "agent");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function agentDir(): string {
|
|
48
|
+
return env.agentDir ?? packageAgentScriptsDir();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Result schemas. Each command shares the success envelope and produces a
|
|
53
|
+
// command-specific `data` payload.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const ErrorPayload = z.object({
|
|
57
|
+
code: z.string(),
|
|
58
|
+
message: z.string(),
|
|
59
|
+
step: z.string().optional(),
|
|
60
|
+
exit_code: z.number().optional(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const SuccessEnvelope = <T extends z.ZodTypeAny>(data: T) =>
|
|
64
|
+
z.object({ ok: z.literal(true), data });
|
|
65
|
+
|
|
66
|
+
const FailureEnvelope = z.object({
|
|
67
|
+
ok: z.literal(false),
|
|
68
|
+
error: ErrorPayload,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// task-up
|
|
72
|
+
const TaskUpData = z.object({
|
|
73
|
+
composeProject: z.string(),
|
|
74
|
+
worktreePath: z.string(),
|
|
75
|
+
codeServerPort: z.number().int().positive().optional(),
|
|
76
|
+
previewPorts: PreviewPortRuntimesSchema.optional(),
|
|
77
|
+
});
|
|
78
|
+
export type TaskUpResult = z.infer<typeof TaskUpData>;
|
|
79
|
+
|
|
80
|
+
// task-down
|
|
81
|
+
const TaskDownData = z
|
|
82
|
+
.object({
|
|
83
|
+
status: z.string().optional(),
|
|
84
|
+
alreadyGone: z.boolean().optional(),
|
|
85
|
+
})
|
|
86
|
+
.passthrough();
|
|
87
|
+
export type TaskDownResult = z.infer<typeof TaskDownData>;
|
|
88
|
+
|
|
89
|
+
// task-status
|
|
90
|
+
const TaskStatusData = z.object({
|
|
91
|
+
composeRunning: z.boolean(),
|
|
92
|
+
containers: z.array(z.string()),
|
|
93
|
+
worktreePresent: z.boolean(),
|
|
94
|
+
});
|
|
95
|
+
export type TaskStatusResult = z.infer<typeof TaskStatusData>;
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Error type. All call sites should `catch (err)` and use `AgentError.is`.
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export class AgentError extends Error {
|
|
102
|
+
override readonly name = "AgentError";
|
|
103
|
+
readonly code: string;
|
|
104
|
+
readonly step: string | undefined;
|
|
105
|
+
readonly exitCode: number | undefined;
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
code: string,
|
|
109
|
+
message: string,
|
|
110
|
+
options: { step?: string; exitCode?: number; cause?: unknown } = {},
|
|
111
|
+
) {
|
|
112
|
+
super(
|
|
113
|
+
message,
|
|
114
|
+
options.cause === undefined ? undefined : { cause: options.cause },
|
|
115
|
+
);
|
|
116
|
+
this.code = code;
|
|
117
|
+
this.step = options.step;
|
|
118
|
+
this.exitCode = options.exitCode;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
static is(err: unknown): err is AgentError {
|
|
122
|
+
return err instanceof AgentError;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Internal: run an agent script and parse the JSON envelope.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async function runAgent<T extends z.ZodTypeAny>(
|
|
131
|
+
scriptName: string,
|
|
132
|
+
args: string[],
|
|
133
|
+
dataSchema: T,
|
|
134
|
+
commandDbPath: string,
|
|
135
|
+
extraEnv: Record<string, string> = {},
|
|
136
|
+
): Promise<z.infer<T>> {
|
|
137
|
+
const scriptPath = resolve(agentDir(), scriptName);
|
|
138
|
+
|
|
139
|
+
const child = spawn(scriptPath, args, {
|
|
140
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
141
|
+
env: {
|
|
142
|
+
...process.env,
|
|
143
|
+
UAI_DB_PATH: commandDbPath,
|
|
144
|
+
UAI_DATA_DIR: env.dataDir,
|
|
145
|
+
...extraEnv,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let stdoutBuf = "";
|
|
150
|
+
let stderrBuf = "";
|
|
151
|
+
child.stdout.on("data", (chunk) => {
|
|
152
|
+
stdoutBuf += chunk.toString("utf8");
|
|
153
|
+
});
|
|
154
|
+
child.stderr.on("data", (chunk) => {
|
|
155
|
+
stderrBuf += chunk.toString("utf8");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const exitCode: number = await new Promise((res, rej) => {
|
|
159
|
+
child.on("error", rej);
|
|
160
|
+
child.on("close", (code) => res(code ?? 0));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Try to parse whichever stream has the JSON. On failure the agent puts
|
|
164
|
+
// its envelope on stderr; on success, on stdout.
|
|
165
|
+
if (exitCode !== 0) {
|
|
166
|
+
// Log the full stderr so the dev terminal shows the underlying tool's
|
|
167
|
+
// output (docker's stderr, git's stderr, etc.) — the shell agent only
|
|
168
|
+
// surfaces the code + step on the structured envelope.
|
|
169
|
+
if (stderrBuf.trim()) {
|
|
170
|
+
console.error(
|
|
171
|
+
`[uai-agent] ${scriptName} stderr (exit ${exitCode}):\n${stderrBuf.trim()}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const parsed = tryParseLastJsonLine(stderrBuf);
|
|
175
|
+
if (parsed) {
|
|
176
|
+
const failure = FailureEnvelope.safeParse(parsed);
|
|
177
|
+
if (failure.success) {
|
|
178
|
+
throw new AgentError(
|
|
179
|
+
failure.data.error.code,
|
|
180
|
+
failure.data.error.message,
|
|
181
|
+
{
|
|
182
|
+
step: failure.data.error.step,
|
|
183
|
+
exitCode: failure.data.error.exit_code ?? exitCode,
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
throw new AgentError(
|
|
189
|
+
"AGENT_UNKNOWN_FAILURE",
|
|
190
|
+
`${scriptName} exited ${exitCode}: ${stderrBuf.trim() || "no stderr"}`,
|
|
191
|
+
{ exitCode },
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const parsed = tryParseLastJsonLine(stdoutBuf);
|
|
196
|
+
if (!parsed) {
|
|
197
|
+
throw new AgentError(
|
|
198
|
+
"AGENT_BAD_OUTPUT",
|
|
199
|
+
`${scriptName} stdout was not JSON: ${stdoutBuf.trim().slice(0, 200)}`,
|
|
200
|
+
{ exitCode },
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const envelope = SuccessEnvelope(dataSchema).safeParse(parsed);
|
|
204
|
+
if (!envelope.success) {
|
|
205
|
+
throw new AgentError(
|
|
206
|
+
"AGENT_BAD_OUTPUT",
|
|
207
|
+
`${scriptName} returned unexpected shape: ${envelope.error.message}`,
|
|
208
|
+
{ exitCode },
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return envelope.data.data;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The agent may log info lines to stderr alongside the JSON envelope.
|
|
216
|
+
* On stdout we expect a single trailing JSON object; on stderr the
|
|
217
|
+
* envelope is the last line. This helper takes the last non-empty line
|
|
218
|
+
* and parses it as JSON.
|
|
219
|
+
*/
|
|
220
|
+
function tryParseLastJsonLine(buf: string): unknown {
|
|
221
|
+
const trimmed = buf.trim();
|
|
222
|
+
if (!trimmed) return null;
|
|
223
|
+
const lines = trimmed.split(/\r?\n/);
|
|
224
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
225
|
+
const line = (lines[i] ?? "").trim();
|
|
226
|
+
if (!line) continue;
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(line);
|
|
229
|
+
} catch {
|
|
230
|
+
// Keep walking back; agent log lines are not JSON.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Public API. One function per agent command.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export const agent = {
|
|
241
|
+
async taskUp(input: TaskLaunchInput): Promise<TaskUpResult> {
|
|
242
|
+
const commandDbPath = createTaskUpCommandDb(input);
|
|
243
|
+
// Materialize the creator's per-user SSH key so task-up.sh clones + pushes
|
|
244
|
+
// as them (ADR-029); null → task-up.sh falls back to the operator identity.
|
|
245
|
+
const identityDir = writeTaskIdentity(input.task.id, input.task.ownerUserId);
|
|
246
|
+
try {
|
|
247
|
+
return await runAgent(
|
|
248
|
+
"task-up.sh",
|
|
249
|
+
[input.task.id],
|
|
250
|
+
TaskUpData,
|
|
251
|
+
commandDbPath,
|
|
252
|
+
identityDir ? { UAI_TASK_IDENTITY_DIR: identityDir } : {},
|
|
253
|
+
);
|
|
254
|
+
} finally {
|
|
255
|
+
removeCommandDb(commandDbPath);
|
|
256
|
+
removeTaskIdentity(input.task.id);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async taskDown(input: TaskDownInput): Promise<TaskDownResult> {
|
|
261
|
+
const commandDbPath = createTaskDownCommandDb(
|
|
262
|
+
input,
|
|
263
|
+
getHostTask(input.taskId),
|
|
264
|
+
);
|
|
265
|
+
try {
|
|
266
|
+
return await runAgent(
|
|
267
|
+
"task-down.sh",
|
|
268
|
+
[input.taskId],
|
|
269
|
+
TaskDownData,
|
|
270
|
+
commandDbPath,
|
|
271
|
+
);
|
|
272
|
+
} finally {
|
|
273
|
+
removeCommandDb(commandDbPath);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
async taskStatus(taskId: string): Promise<TaskStatusResult> {
|
|
278
|
+
const commandDbPath = createTaskStatusCommandDb(taskId, getHostTask(taskId));
|
|
279
|
+
try {
|
|
280
|
+
return await runAgent(
|
|
281
|
+
"task-status.sh",
|
|
282
|
+
[taskId],
|
|
283
|
+
TaskStatusData,
|
|
284
|
+
commandDbPath,
|
|
285
|
+
);
|
|
286
|
+
} finally {
|
|
287
|
+
removeCommandDb(commandDbPath);
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export type Agent = typeof agent;
|