@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,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# task-down <taskId> — tear the task stack down. Idempotent (ADR-022).
|
|
3
|
+
#
|
|
4
|
+
# 1. docker compose down -v --rmi local --remove-orphans.
|
|
5
|
+
# 2. For each selected project, git worktree remove --force its worktree
|
|
6
|
+
# (falls back to rm -rf when git refuses).
|
|
7
|
+
# 3. rm -rf the whole task dir — workspace + ephemeral .uai/ render. The
|
|
8
|
+
# per-project bare mirrors under projects/<id>/repo.git persist.
|
|
9
|
+
# 4. Persist final state: status=killed (unless already error/shipped).
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
# shellcheck source=_common.sh
|
|
14
|
+
. "$SCRIPT_DIR/_common.sh"
|
|
15
|
+
|
|
16
|
+
[ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-down <taskId>" "args"
|
|
17
|
+
|
|
18
|
+
task_id="$1"
|
|
19
|
+
UAI_TASK_ID="$task_id"
|
|
20
|
+
setup_err_trap
|
|
21
|
+
|
|
22
|
+
step "TASK_NOT_FOUND" "read task row"
|
|
23
|
+
task_json=$(db_get_task "$task_id")
|
|
24
|
+
# Idempotent: it's fine if the task row is gone — emit success and return.
|
|
25
|
+
if [ "$(jq 'length' <<<"$task_json")" -lt 1 ]; then
|
|
26
|
+
emit_ok '{"alreadyGone":true}'
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
|
|
31
|
+
compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
|
|
32
|
+
|
|
33
|
+
projects_json=$(db_get_projects_for_task "$task_id")
|
|
34
|
+
projects_root="$UAI_WORKSPACE_ROOT/projects"
|
|
35
|
+
|
|
36
|
+
# task_dir falls back to the derived path when the row's worktree_path is
|
|
37
|
+
# missing (e.g. crashed before persisting).
|
|
38
|
+
task_dir="$worktree_path"
|
|
39
|
+
if [ -z "$task_dir" ]; then
|
|
40
|
+
task_dir="$UAI_WORKSPACE_ROOT/tasks/$task_id"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# -----------------------------------------------------------------------------
|
|
44
|
+
# 1. Compose stack.
|
|
45
|
+
# -----------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
if [ -n "$compose_project" ]; then
|
|
48
|
+
step "COMPOSE_DOWN_FAILED" "docker compose down -v --rmi local"
|
|
49
|
+
if [ -n "$task_dir" ] && [ -f "$task_dir/.uai/docker-compose.yml" ]; then
|
|
50
|
+
docker compose -p "$compose_project" -f "$task_dir/.uai/docker-compose.yml" \
|
|
51
|
+
down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
|
|
52
|
+
else
|
|
53
|
+
# Fallback: tear down by project name only.
|
|
54
|
+
docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
|
|
55
|
+
fi
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# -----------------------------------------------------------------------------
|
|
59
|
+
# 2. Worktrees — one per selected project.
|
|
60
|
+
# -----------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
if [ -n "$task_dir" ] && [ -d "$task_dir/workspace" ]; then
|
|
63
|
+
while IFS= read -r project_obj; do
|
|
64
|
+
[ -n "$project_obj" ] || continue
|
|
65
|
+
project_id=$(jq -r '.id' <<<"$project_obj")
|
|
66
|
+
project_slug=$(jq -r '.slug' <<<"$project_obj")
|
|
67
|
+
mirror_dir="$projects_root/$project_id/repo.git"
|
|
68
|
+
worktree_target="$task_dir/workspace/$project_slug"
|
|
69
|
+
[ -d "$worktree_target" ] || continue
|
|
70
|
+
if [ -d "$mirror_dir" ]; then
|
|
71
|
+
# `--force` because the worktree may carry uncommitted changes the
|
|
72
|
+
# user asked us to discard.
|
|
73
|
+
git -C "$mirror_dir" worktree remove --force "$worktree_target" 2>/dev/null \
|
|
74
|
+
|| rm -rf "$worktree_target"
|
|
75
|
+
else
|
|
76
|
+
rm -rf "$worktree_target"
|
|
77
|
+
fi
|
|
78
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
# -----------------------------------------------------------------------------
|
|
82
|
+
# 3. Remove the entire task directory (workspace + .uai/).
|
|
83
|
+
# -----------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
if [ -n "$task_dir" ] && [ -d "$task_dir" ]; then
|
|
86
|
+
step "WORKTREE_REMOVE_FAILED" "rm -rf task dir"
|
|
87
|
+
rm -rf "$task_dir"
|
|
88
|
+
fi
|
|
89
|
+
|
|
90
|
+
# -----------------------------------------------------------------------------
|
|
91
|
+
# 4. Persist final state.
|
|
92
|
+
# -----------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
current_status=$(jq -r '.[0].status' <<<"$task_json")
|
|
95
|
+
if [ "$current_status" != "shipped" ] && [ "$current_status" != "error" ]; then
|
|
96
|
+
new_status="killed"
|
|
97
|
+
else
|
|
98
|
+
new_status="$current_status"
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
step "DB_UPDATE_FAILED" "persist final state"
|
|
102
|
+
db_update_task "$task_id" \
|
|
103
|
+
"status='$new_status'" \
|
|
104
|
+
"code_server_port=NULL" \
|
|
105
|
+
"preview_ports='[]'" \
|
|
106
|
+
"compose_project=NULL" \
|
|
107
|
+
"worktree_path=NULL" \
|
|
108
|
+
"locked_at=NULL" \
|
|
109
|
+
"ended_at=(unixepoch()*1000)"
|
|
110
|
+
|
|
111
|
+
emit_event "$task_id" "status_changed" "$(jq -nc --arg f "$current_status" --arg t "$new_status" '{from:$f,to:$t}')"
|
|
112
|
+
|
|
113
|
+
emit_ok "$(jq -nc --arg s "$new_status" '{status:$s}')"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# task-status <taskId> — introspect the actual runtime state.
|
|
3
|
+
#
|
|
4
|
+
# Returns the truth from docker / git / process listings rather than
|
|
5
|
+
# trusting the Task row. Used by POST /api/tasks/:id/reconcile to detect
|
|
6
|
+
# drift after a manual `docker compose down` or similar.
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
10
|
+
# shellcheck source=_common.sh
|
|
11
|
+
. "$SCRIPT_DIR/_common.sh"
|
|
12
|
+
|
|
13
|
+
[ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-status <taskId>" "args"
|
|
14
|
+
|
|
15
|
+
task_id="$1"
|
|
16
|
+
UAI_TASK_ID="$task_id"
|
|
17
|
+
setup_err_trap
|
|
18
|
+
|
|
19
|
+
step "TASK_NOT_FOUND" "read task row"
|
|
20
|
+
task_json=$(db_get_task "$task_id")
|
|
21
|
+
[ "$(jq 'length' <<<"$task_json")" -ge 1 ] \
|
|
22
|
+
|| emit_err "TASK_NOT_FOUND" "no such task: $task_id" "read task row"
|
|
23
|
+
|
|
24
|
+
compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
|
|
25
|
+
worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
|
|
26
|
+
|
|
27
|
+
# Containers running under this compose project, JSON array of names.
|
|
28
|
+
step "DOCKER_PS_FAILED" "docker ps"
|
|
29
|
+
containers_json="[]"
|
|
30
|
+
if [ -n "$compose_project" ]; then
|
|
31
|
+
raw=$(docker ps --filter "label=com.docker.compose.project=$compose_project" --format '{{.Names}}' || true)
|
|
32
|
+
if [ -n "$raw" ]; then
|
|
33
|
+
containers_json=$(printf '%s\n' "$raw" | jq -R . | jq -sc .)
|
|
34
|
+
fi
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
# Compose running iff at least one container is up.
|
|
38
|
+
if [ "$containers_json" = "[]" ]; then
|
|
39
|
+
compose_running="false"
|
|
40
|
+
else
|
|
41
|
+
compose_running="true"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Worktree present iff the directory exists.
|
|
45
|
+
worktree_present="false"
|
|
46
|
+
if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then
|
|
47
|
+
worktree_present="true"
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
emit_ok "$(jq -nc \
|
|
51
|
+
--argjson cr "$compose_running" \
|
|
52
|
+
--argjson cs "$containers_json" \
|
|
53
|
+
--argjson wp "$worktree_present" \
|
|
54
|
+
'{composeRunning:$cr,containers:$cs,worktreePresent:$wp}')"
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# task-up <taskId> — bring up the task stack end-to-end (ADR-022).
|
|
3
|
+
#
|
|
4
|
+
# The "standard image" model: every host runs one prebuilt image,
|
|
5
|
+
# `uai-standard:dev`. A task selects 0..N projects (0 = empty workspace /
|
|
6
|
+
# scratchpad). Each selected project contributes a git worktree under the
|
|
7
|
+
# task's workspace, plus a union of preview ports and env keys.
|
|
8
|
+
#
|
|
9
|
+
# Steps:
|
|
10
|
+
# 1. Read task (-> branch) + the task's selected projects.
|
|
11
|
+
# 2. Acquire lock; mark status=starting.
|
|
12
|
+
# 3. Per project: ensure a bare mirror, fetch, add a worktree on the task
|
|
13
|
+
# branch, seed .tool-versions when missing.
|
|
14
|
+
# 4. Generate .uai/docker-compose.yml in bash (and a derived .uai/Dockerfile
|
|
15
|
+
# when any project declares `extra`).
|
|
16
|
+
# 5. docker compose -p task-<id> up -d (--build only when derived).
|
|
17
|
+
# 6. docker cp AI auth into the container, then docker exec uai-init.
|
|
18
|
+
# 7. Discover code-server + preview ports; mark status=running; unlock.
|
|
19
|
+
#
|
|
20
|
+
# The AIs are NOT started here — the chat orchestrator drives them and
|
|
21
|
+
# delivers the task's initial prompt once the task is running.
|
|
22
|
+
|
|
23
|
+
set -euo pipefail
|
|
24
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
25
|
+
# shellcheck source=_common.sh
|
|
26
|
+
. "$SCRIPT_DIR/_common.sh"
|
|
27
|
+
|
|
28
|
+
[ "$#" -eq 1 ] || emit_err "BAD_ARGS" "usage: task-up <taskId>" "args"
|
|
29
|
+
|
|
30
|
+
task_id="$1"
|
|
31
|
+
UAI_TASK_ID="$task_id"
|
|
32
|
+
setup_err_trap
|
|
33
|
+
|
|
34
|
+
# Pinned constants (all clusters must agree — see ADR-022).
|
|
35
|
+
STANDARD_IMAGE="uai-standard:dev"
|
|
36
|
+
ASDF_VOLUME="uai-asdf-data"
|
|
37
|
+
DERIVED_IMAGE="uai-task-${task_id}"
|
|
38
|
+
|
|
39
|
+
# -----------------------------------------------------------------------------
|
|
40
|
+
# Paths (mirror host-agent/lib/env.ts).
|
|
41
|
+
# -----------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
task_dir="$UAI_WORKSPACE_ROOT/tasks/$task_id"
|
|
44
|
+
task_workspace="$task_dir/workspace"
|
|
45
|
+
task_uai_dir="$task_dir/.uai"
|
|
46
|
+
projects_root="$UAI_WORKSPACE_ROOT/projects"
|
|
47
|
+
compose_project=$(compose_project_for_task "$task_id")
|
|
48
|
+
app_container=$(app_container_for_task "$task_id")
|
|
49
|
+
|
|
50
|
+
mapped_host_port() {
|
|
51
|
+
local container="$1" container_port="$2"
|
|
52
|
+
docker port "$container" "$container_port" 2>/dev/null \
|
|
53
|
+
| awk -F: 'NR==1 {print $NF}' \
|
|
54
|
+
| tr -d '[:space:]' \
|
|
55
|
+
|| true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# -----------------------------------------------------------------------------
|
|
59
|
+
# 1. Read rows.
|
|
60
|
+
# -----------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
step "TASK_NOT_FOUND" "read task row"
|
|
63
|
+
task_json=$(db_get_task "$task_id")
|
|
64
|
+
[ "$(jq 'length' <<<"$task_json")" -ge 1 ] \
|
|
65
|
+
|| emit_err "TASK_NOT_FOUND" "no such task: $task_id" "read task row"
|
|
66
|
+
|
|
67
|
+
task_branch=$(jq -r '.[0].branch' <<<"$task_json")
|
|
68
|
+
|
|
69
|
+
step "PROJECT_NOT_FOUND" "read task projects"
|
|
70
|
+
projects_json=$(db_get_projects_for_task "$task_id")
|
|
71
|
+
project_count=$(jq 'length' <<<"$projects_json")
|
|
72
|
+
|
|
73
|
+
# -----------------------------------------------------------------------------
|
|
74
|
+
# 2. Lock + status=starting.
|
|
75
|
+
# -----------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
step "LOCKED" "acquire task lock"
|
|
78
|
+
lock_task "$task_id"
|
|
79
|
+
|
|
80
|
+
step "DB_UPDATE_FAILED" "mark task starting"
|
|
81
|
+
db_update_task "$task_id" \
|
|
82
|
+
"status='starting'" \
|
|
83
|
+
"started_at=(unixepoch()*1000)" \
|
|
84
|
+
"code_server_port=NULL" \
|
|
85
|
+
"preview_ports='[]'" \
|
|
86
|
+
"worktree_path='$(sql_escape "$task_dir")'" \
|
|
87
|
+
"compose_project='$(sql_escape "$compose_project")'"
|
|
88
|
+
|
|
89
|
+
# -----------------------------------------------------------------------------
|
|
90
|
+
# 3. Bare mirrors + worktrees, one per selected project.
|
|
91
|
+
# -----------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
# Use the uai-managed host identity for all git-over-SSH (ADR-015: git auth is
|
|
94
|
+
# a host-resident credential). `IdentitiesOnly=yes` + `IdentityAgent=none` keep
|
|
95
|
+
# it from falling through to the operator's personal SSH agent (e.g. 1Password),
|
|
96
|
+
# which both leaks the wrong key and blocks unattended task-up on its approval
|
|
97
|
+
# prompt. `BatchMode=yes` fails fast instead of prompting. Falls back to the
|
|
98
|
+
# host default SSH when the identity hasn't been set up (`pnpm setup-identity`).
|
|
99
|
+
#
|
|
100
|
+
# ADR-029: prefer the TASK CREATOR's per-user key (written to
|
|
101
|
+
# $UAI_TASK_IDENTITY_DIR by the orchestrator) so the clone + push run as them,
|
|
102
|
+
# not the operator. Fall back to the shared operator identity when the creator
|
|
103
|
+
# has no key on this host (transitional / pre-ADR-029 users).
|
|
104
|
+
if [ -n "${UAI_TASK_IDENTITY_DIR:-}" ] && [ -f "${UAI_TASK_IDENTITY_DIR}/id_ed25519" ]; then
|
|
105
|
+
uai_identity_key="$UAI_TASK_IDENTITY_DIR/id_ed25519"
|
|
106
|
+
log "git over SSH using the task creator's per-user key"
|
|
107
|
+
else
|
|
108
|
+
uai_identity_key="$UAI_DATA_DIR/identity/id_ed25519"
|
|
109
|
+
fi
|
|
110
|
+
if [ -f "$uai_identity_key" ]; then
|
|
111
|
+
uai_known_hosts="$UAI_DATA_DIR/identity/known_hosts"
|
|
112
|
+
GIT_SSH_COMMAND="ssh -i $uai_identity_key -o IdentitiesOnly=yes -o IdentityAgent=none -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
|
|
113
|
+
[ -f "$uai_known_hosts" ] && GIT_SSH_COMMAND="$GIT_SSH_COMMAND -o UserKnownHostsFile=$uai_known_hosts"
|
|
114
|
+
export GIT_SSH_COMMAND
|
|
115
|
+
log "git over SSH using $uai_identity_key (1Password agent bypassed)"
|
|
116
|
+
else
|
|
117
|
+
log "no uai identity key at $uai_identity_key — git uses host default SSH (run: pnpm setup-identity)"
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
mkdir -p "$task_workspace"
|
|
121
|
+
|
|
122
|
+
# Iterate using jq -c so each project is a single JSON object per line.
|
|
123
|
+
# Bash 3.2 friendly: a while-read loop rather than mapfile/readarray.
|
|
124
|
+
while IFS= read -r project_obj; do
|
|
125
|
+
[ -n "$project_obj" ] || continue
|
|
126
|
+
|
|
127
|
+
project_id=$(jq -r '.id' <<<"$project_obj")
|
|
128
|
+
project_slug=$(jq -r '.slug' <<<"$project_obj")
|
|
129
|
+
repo_url=$(jq -r '.repo_url' <<<"$project_obj")
|
|
130
|
+
tool_versions=$(jq -r '.tool_versions // ""' <<<"$project_obj")
|
|
131
|
+
|
|
132
|
+
mirror_dir="$projects_root/$project_id/repo.git"
|
|
133
|
+
worktree_target="$task_workspace/$project_slug"
|
|
134
|
+
|
|
135
|
+
# Ensure the bare mirror exists with **remote-tracking refs**. Plain
|
|
136
|
+
# `git clone --bare` copies upstream heads straight into refs/heads/*
|
|
137
|
+
# and never populates refs/remotes/origin/*, so `worktree add ...
|
|
138
|
+
# origin/<defaultBranch>` would fail with "invalid reference". Init +
|
|
139
|
+
# remote + standard refspec + fetch gives a proper bare repo whose
|
|
140
|
+
# origin/* refs match the remote tip.
|
|
141
|
+
needs_initial_fetch=0
|
|
142
|
+
if [ ! -d "$mirror_dir" ]; then
|
|
143
|
+
step "CLONE_FAILED" "bare-clone ($project_id)"
|
|
144
|
+
mkdir -p "$(dirname "$mirror_dir")"
|
|
145
|
+
git init --bare "$mirror_dir" >/dev/null
|
|
146
|
+
git -C "$mirror_dir" remote add origin "$repo_url"
|
|
147
|
+
needs_initial_fetch=1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
# Always (re-)assert the remote-tracking refspec. Self-heals mirrors
|
|
151
|
+
# created before this script learned the right refspec — idempotent.
|
|
152
|
+
git -C "$mirror_dir" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'
|
|
153
|
+
|
|
154
|
+
if [ "$needs_initial_fetch" = "1" ]; then
|
|
155
|
+
# First fetch on a brand-new mirror — required. Bail and clean up so
|
|
156
|
+
# the next attempt starts fresh rather than re-using a half-init.
|
|
157
|
+
if ! git -C "$mirror_dir" fetch origin; then
|
|
158
|
+
rm -rf "$mirror_dir"
|
|
159
|
+
emit_err "CLONE_FAILED" "initial fetch from $repo_url failed" "bare-clone ($project_id)"
|
|
160
|
+
fi
|
|
161
|
+
else
|
|
162
|
+
# Existing mirror — refresh best-effort. Offline / network blip
|
|
163
|
+
# shouldn't break task-up.
|
|
164
|
+
step "FETCH_FAILED" "git fetch origin ($project_id)"
|
|
165
|
+
if ! git -C "$mirror_dir" fetch origin >/dev/null 2>&1; then
|
|
166
|
+
log "warning: git fetch failed for $project_id ($repo_url); using cached refs"
|
|
167
|
+
fi
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
# Resolve the remote default branch (best-effort) — fall back to main.
|
|
171
|
+
git -C "$mirror_dir" remote set-head origin -a >/dev/null 2>&1 || true
|
|
172
|
+
project_default=$(git -C "$mirror_dir" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null \
|
|
173
|
+
| sed 's#^origin/##' || true)
|
|
174
|
+
[ -n "$project_default" ] || project_default="main"
|
|
175
|
+
|
|
176
|
+
# Create the worktree on a fresh task branch off origin/<defaultBranch>.
|
|
177
|
+
step "WORKTREE_FAILED" "git worktree add ($project_id)"
|
|
178
|
+
mkdir -p "$(dirname "$worktree_target")"
|
|
179
|
+
git -C "$mirror_dir" worktree add "$worktree_target" \
|
|
180
|
+
-b "$task_branch" "origin/$project_default"
|
|
181
|
+
|
|
182
|
+
# Tool-version seeding: only when the worktree has no checked-in
|
|
183
|
+
# .tool-versions AND the project declares one. Never overwrite a file
|
|
184
|
+
# the repo already ships.
|
|
185
|
+
if [ ! -e "$worktree_target/.tool-versions" ] \
|
|
186
|
+
&& [ -n "$tool_versions" ] && [ "$tool_versions" != "null" ]; then
|
|
187
|
+
printf '%s\n' "$tool_versions" > "$worktree_target/.tool-versions"
|
|
188
|
+
fi
|
|
189
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
190
|
+
|
|
191
|
+
# -----------------------------------------------------------------------------
|
|
192
|
+
# 4. Generate .uai/docker-compose.yml (bash). Union preview ports + env keys
|
|
193
|
+
# across the selected projects; one app service. Derived Dockerfile only
|
|
194
|
+
# when a project declares `extra`.
|
|
195
|
+
# -----------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
step "RENDER_FAILED" "generate compose"
|
|
198
|
+
|
|
199
|
+
mkdir -p "$task_uai_dir"
|
|
200
|
+
|
|
201
|
+
# Union of preview ports across projects, de-duped by containerPort
|
|
202
|
+
# (first-in-position wins the name). Validated: name non-empty, 1..65535.
|
|
203
|
+
union_preview_ports_json=$(jq -c '
|
|
204
|
+
[
|
|
205
|
+
.[]
|
|
206
|
+
| (.preview_ports // "[]") as $raw
|
|
207
|
+
| (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
|
|
208
|
+
elif ($raw | type) == "array" then $raw
|
|
209
|
+
else [] end)
|
|
210
|
+
| .[]?
|
|
211
|
+
| {
|
|
212
|
+
name: (.name // ""),
|
|
213
|
+
containerPort: (((.containerPort // 0) | tonumber?) // 0)
|
|
214
|
+
}
|
|
215
|
+
| select(.name != "" and .containerPort > 0 and .containerPort <= 65535)
|
|
216
|
+
]
|
|
217
|
+
| reduce .[] as $p ({seen: [], out: []};
|
|
218
|
+
if (.seen | index($p.containerPort)) then .
|
|
219
|
+
else {seen: (.seen + [$p.containerPort]), out: (.out + [$p])} end)
|
|
220
|
+
| .out
|
|
221
|
+
' <<<"$projects_json")
|
|
222
|
+
|
|
223
|
+
# Union of declared env keys across projects (env JSON is an array of
|
|
224
|
+
# {key,...} objects per ADR-015). De-duped, first occurrence wins.
|
|
225
|
+
union_env_keys_json=$(jq -c '
|
|
226
|
+
[
|
|
227
|
+
.[]
|
|
228
|
+
| (.env // "[]") as $raw
|
|
229
|
+
| (if ($raw | type) == "string" then (try ($raw | fromjson) catch [])
|
|
230
|
+
elif ($raw | type) == "array" then $raw
|
|
231
|
+
else [] end)
|
|
232
|
+
| .[]?
|
|
233
|
+
| (.key // "")
|
|
234
|
+
| select(. != "")
|
|
235
|
+
]
|
|
236
|
+
| reduce .[] as $k ([]; if (index($k)) then . else . + [$k] end)
|
|
237
|
+
' <<<"$projects_json")
|
|
238
|
+
|
|
239
|
+
# Concatenated `extra` RUN snippets across projects (in position order).
|
|
240
|
+
extra_block=$(jq -r '
|
|
241
|
+
[ .[] | (.extra // "") | select(. != "" and . != "null") ]
|
|
242
|
+
| join("\n")
|
|
243
|
+
' <<<"$projects_json")
|
|
244
|
+
|
|
245
|
+
has_derived=0
|
|
246
|
+
if [ -n "$extra_block" ]; then
|
|
247
|
+
has_derived=1
|
|
248
|
+
fi
|
|
249
|
+
|
|
250
|
+
# Write the compose file. No top-level name: (the -p flag sets the project),
|
|
251
|
+
# no container_name:. Env values live host-side (ADR-015): we emit only
|
|
252
|
+
# pass-through references `${KEY:-}`.
|
|
253
|
+
{
|
|
254
|
+
printf 'services:\n'
|
|
255
|
+
printf ' app:\n'
|
|
256
|
+
if [ "$has_derived" = "1" ]; then
|
|
257
|
+
printf ' build:\n'
|
|
258
|
+
printf ' context: .\n'
|
|
259
|
+
printf ' dockerfile: Dockerfile\n'
|
|
260
|
+
printf ' image: %s\n' "$DERIVED_IMAGE"
|
|
261
|
+
else
|
|
262
|
+
printf ' image: %s\n' "$STANDARD_IMAGE"
|
|
263
|
+
fi
|
|
264
|
+
printf ' init: true\n'
|
|
265
|
+
printf ' working_dir: /workspace\n'
|
|
266
|
+
printf ' volumes:\n'
|
|
267
|
+
printf ' - "%s:/workspace"\n' "$task_workspace"
|
|
268
|
+
# Identical-host-path binds so each git worktree's absolute gitdir pointer
|
|
269
|
+
# (-> projects/<id>/repo.git/worktrees/<slug>) resolves INSIDE the container
|
|
270
|
+
# (ADR-014). Without them in-container git sees a dangling .git and every
|
|
271
|
+
# commit fails with "not a git repository". The mirror is bound rw because
|
|
272
|
+
# commits write objects back into the shared object store.
|
|
273
|
+
printf ' - "%s:%s"\n' "$task_workspace" "$task_workspace"
|
|
274
|
+
while IFS= read -r vol_obj; do
|
|
275
|
+
[ -n "$vol_obj" ] || continue
|
|
276
|
+
vol_pid=$(jq -r '.id' <<<"$vol_obj")
|
|
277
|
+
printf ' - "%s/%s/repo.git:%s/%s/repo.git"\n' \
|
|
278
|
+
"$projects_root" "$vol_pid" "$projects_root" "$vol_pid"
|
|
279
|
+
done < <(jq -c '.[]' <<<"$projects_json")
|
|
280
|
+
printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
|
|
281
|
+
printf ' ports:\n'
|
|
282
|
+
printf ' - "127.0.0.1::8080"\n'
|
|
283
|
+
while IFS= read -r cport; do
|
|
284
|
+
[ -n "$cport" ] || continue
|
|
285
|
+
printf ' - "127.0.0.1::%s"\n' "$cport"
|
|
286
|
+
done < <(jq -r '.[].containerPort' <<<"$union_preview_ports_json")
|
|
287
|
+
printf ' environment:\n'
|
|
288
|
+
# Host-resident Claude auth (ADR-021). Headless `claude --print` no longer
|
|
289
|
+
# uses the interactive subscription/keychain path, so it needs a token from
|
|
290
|
+
# `claude setup-token`. Interpolated from the host-agent env at up-time, so
|
|
291
|
+
# the literal token is never written to the compose file on disk. Putting it
|
|
292
|
+
# in the container env (rather than the host's ~/.claude mount, which broke
|
|
293
|
+
# with EROFS on session-env writes) means both the orchestrator's headless
|
|
294
|
+
# claude and the code-server terminal's interactive claude authenticate.
|
|
295
|
+
printf ' %s: "${%s:-}"\n' \
|
|
296
|
+
"CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
|
|
297
|
+
# No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
|
|
298
|
+
# an env var over its stored credentials, which blocks the per-user
|
|
299
|
+
# `gh auth login --with-token` the host runs (and would re-attribute every PR
|
|
300
|
+
# to the env token — breaking multi-user). The host's GitHub token reaches
|
|
301
|
+
# `gh` through its config file instead (lib/github-tokens.ts); git rides the
|
|
302
|
+
# per-user SSH identity, not an HTTPS token (uai-init routes remotes over SSH).
|
|
303
|
+
# A project may declare GH_TOKEN/GITHUB_TOKEN as an env key, so skip them here
|
|
304
|
+
# rather than trust callers — this is where the rule is enforced.
|
|
305
|
+
while IFS= read -r ekey; do
|
|
306
|
+
[ -n "$ekey" ] || continue
|
|
307
|
+
case "$ekey" in
|
|
308
|
+
GH_TOKEN | GITHUB_TOKEN) continue ;;
|
|
309
|
+
esac
|
|
310
|
+
printf ' %s: "${%s:-}"\n' "$ekey" "$ekey"
|
|
311
|
+
done < <(jq -r '.[]' <<<"$union_env_keys_json")
|
|
312
|
+
printf 'volumes:\n'
|
|
313
|
+
printf ' %s:\n' "$ASDF_VOLUME"
|
|
314
|
+
printf ' external: true\n'
|
|
315
|
+
} > "$task_uai_dir/docker-compose.yml"
|
|
316
|
+
|
|
317
|
+
if [ "$has_derived" = "1" ]; then
|
|
318
|
+
# Run `extra` as root so apt/system installs work (the standard image ends
|
|
319
|
+
# as USER node), then drop back to node for the running container.
|
|
320
|
+
{
|
|
321
|
+
printf 'FROM %s\n' "$STANDARD_IMAGE"
|
|
322
|
+
printf 'USER root\n'
|
|
323
|
+
printf '%s\n' "$extra_block"
|
|
324
|
+
printf 'USER node\n'
|
|
325
|
+
} > "$task_uai_dir/Dockerfile"
|
|
326
|
+
fi
|
|
327
|
+
|
|
328
|
+
# -----------------------------------------------------------------------------
|
|
329
|
+
# 5. Compose up.
|
|
330
|
+
# -----------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
# The shared asdf cache volume is host-wide and declared external — make
|
|
333
|
+
# sure it exists before compose references it. Idempotent.
|
|
334
|
+
docker volume create "$ASDF_VOLUME" >/dev/null 2>&1 || true
|
|
335
|
+
|
|
336
|
+
step "COMPOSE_UP_FAILED" "docker compose up -d"
|
|
337
|
+
if [ "$has_derived" = "1" ]; then
|
|
338
|
+
docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d --build >/dev/null
|
|
339
|
+
else
|
|
340
|
+
docker compose -p "$compose_project" -f "$task_uai_dir/docker-compose.yml" up -d >/dev/null
|
|
341
|
+
fi
|
|
342
|
+
|
|
343
|
+
# -----------------------------------------------------------------------------
|
|
344
|
+
# 6. Container init — copy AI creds in, install deps, launch code-server.
|
|
345
|
+
# -----------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
step "CONTAINER_INIT_FAILED" "uai-init (deps + code-server)"
|
|
348
|
+
|
|
349
|
+
# Copy `.claude.json` into the container instead of bind-mounting it.
|
|
350
|
+
# Single-file bind mounts on Docker Desktop macOS are fragile — claude
|
|
351
|
+
# CLI atomically rewrites the file on every start, which can invalidate
|
|
352
|
+
# the bind mount and leave a stale dentry, after which claude refuses to
|
|
353
|
+
# start. docker cp gives the container its own stable copy. Best-effort.
|
|
354
|
+
if [ -f "$UAI_OWNER_HOME/.claude.json" ]; then
|
|
355
|
+
docker cp "$UAI_OWNER_HOME/.claude.json" \
|
|
356
|
+
"$app_container":/home/node/.claude.json >/dev/null 2>&1 \
|
|
357
|
+
|| log "warning: docker cp of .claude.json failed; claude may need re-login"
|
|
358
|
+
docker exec -u root "$app_container" \
|
|
359
|
+
chown node:node /home/node/.claude.json >/dev/null 2>&1 || true
|
|
360
|
+
fi
|
|
361
|
+
|
|
362
|
+
# Copy Codex credentials/config into a task-private /home/node/.codex.
|
|
363
|
+
# Do not mount the host ~/.codex writable: Codex stores live SQLite state
|
|
364
|
+
# there, and concurrent host/container access can corrupt it.
|
|
365
|
+
docker exec -u root "$app_container" \
|
|
366
|
+
mkdir -p /home/node/.codex >/dev/null 2>&1 || true
|
|
367
|
+
for codex_item in auth.json config.toml AGENTS.md version.json installation_id rules; do
|
|
368
|
+
if [ -e "$UAI_OWNER_HOME/.codex/$codex_item" ]; then
|
|
369
|
+
docker cp "$UAI_OWNER_HOME/.codex/$codex_item" \
|
|
370
|
+
"$app_container":/home/node/.codex/ >/dev/null 2>&1 \
|
|
371
|
+
|| log "warning: docker cp of .codex/$codex_item failed; codex may need re-login"
|
|
372
|
+
fi
|
|
373
|
+
done
|
|
374
|
+
docker exec -u root "$app_container" \
|
|
375
|
+
chown -R node:node /home/node/.codex >/dev/null 2>&1 || true
|
|
376
|
+
|
|
377
|
+
# Copy the same resolved SSH identity (task creator's per-user key when present,
|
|
378
|
+
# else the operator identity — see above) into the container, so the agent signs
|
|
379
|
+
# + pushes with the key whose .pub the user registered on GitHub. ADR-027 drops
|
|
380
|
+
# the HTTPS/GH_TOKEN path; uai-init routes every GitHub remote over SSH.
|
|
381
|
+
# Best-effort: signing + SSH push just stay off if the identity is absent.
|
|
382
|
+
if [ -f "$uai_identity_key" ]; then
|
|
383
|
+
docker exec -u root "$app_container" \
|
|
384
|
+
mkdir -p /home/node/.ssh >/dev/null 2>&1 || true
|
|
385
|
+
docker cp "$uai_identity_key" \
|
|
386
|
+
"$app_container":/home/node/.ssh/id_ed25519 >/dev/null 2>&1 || true
|
|
387
|
+
docker cp "${uai_identity_key}.pub" \
|
|
388
|
+
"$app_container":/home/node/.ssh/id_ed25519.pub >/dev/null 2>&1 || true
|
|
389
|
+
docker exec -u root "$app_container" sh -c \
|
|
390
|
+
'chown -R node:node /home/node/.ssh && chmod 700 /home/node/.ssh && chmod 600 /home/node/.ssh/id_ed25519' \
|
|
391
|
+
>/dev/null 2>&1 || true
|
|
392
|
+
fi
|
|
393
|
+
|
|
394
|
+
docker exec "$app_container" /usr/local/bin/uai-init >/dev/null
|
|
395
|
+
|
|
396
|
+
# -----------------------------------------------------------------------------
|
|
397
|
+
# 7. Discover code-server + preview ports, mark running, unlock.
|
|
398
|
+
# -----------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
# code-server: container exposes 8080; Docker auto-maps to a random host
|
|
401
|
+
# port. Discover via `docker port`. Best-effort — if it fails the Editor
|
|
402
|
+
# pane is just unavailable.
|
|
403
|
+
code_server_port=$(mapped_host_port "$app_container" 8080)
|
|
404
|
+
if [ -z "$code_server_port" ]; then
|
|
405
|
+
log "warning: could not discover code-server port for ${app_container} (editor pane unavailable)"
|
|
406
|
+
fi
|
|
407
|
+
|
|
408
|
+
preview_ports_runtime_json="[]"
|
|
409
|
+
if [ "$(jq 'length' <<<"$union_preview_ports_json")" -gt 0 ]; then
|
|
410
|
+
preview_port_lines=""
|
|
411
|
+
while IFS= read -r preview_obj; do
|
|
412
|
+
[ -n "$preview_obj" ] || continue
|
|
413
|
+
preview_name=$(jq -r '.name' <<<"$preview_obj")
|
|
414
|
+
preview_container_port=$(jq -r '.containerPort' <<<"$preview_obj")
|
|
415
|
+
preview_host_port=$(mapped_host_port "$app_container" "$preview_container_port")
|
|
416
|
+
if [ -z "$preview_host_port" ]; then
|
|
417
|
+
log "warning: could not discover preview port ${preview_name}:${preview_container_port} for ${app_container}"
|
|
418
|
+
continue
|
|
419
|
+
fi
|
|
420
|
+
preview_port_lines="${preview_port_lines}$(jq -nc \
|
|
421
|
+
--arg name "$preview_name" \
|
|
422
|
+
--arg hp "$preview_host_port" \
|
|
423
|
+
'{name:$name,hostPort:($hp|tonumber)}')
|
|
424
|
+
"
|
|
425
|
+
done < <(jq -c '.[]' <<<"$union_preview_ports_json")
|
|
426
|
+
|
|
427
|
+
if [ -n "$preview_port_lines" ]; then
|
|
428
|
+
preview_ports_runtime_json=$(printf '%s' "$preview_port_lines" | jq -s -c '.')
|
|
429
|
+
fi
|
|
430
|
+
fi
|
|
431
|
+
|
|
432
|
+
step "DB_UPDATE_FAILED" "mark task running"
|
|
433
|
+
cs_sql="code_server_port=NULL"
|
|
434
|
+
if [ -n "$code_server_port" ]; then
|
|
435
|
+
cs_sql="code_server_port=${code_server_port}"
|
|
436
|
+
fi
|
|
437
|
+
db_update_task "$task_id" \
|
|
438
|
+
"status='running'" \
|
|
439
|
+
"$cs_sql" \
|
|
440
|
+
"preview_ports='$(sql_escape "$preview_ports_runtime_json")'"
|
|
441
|
+
|
|
442
|
+
unlock_task "$task_id"
|
|
443
|
+
|
|
444
|
+
emit_event "$task_id" "status_changed" "$(jq -nc '{from:"starting",to:"running"}')"
|
|
445
|
+
|
|
446
|
+
# NOTE: `codeServerPort` is added only when discovered. Do NOT inline a
|
|
447
|
+
# `select(.!="")` into the object literal — when it filters to empty, jq
|
|
448
|
+
# collapses the *entire* object to empty and emit_ok then defaults to
|
|
449
|
+
# `{}`, producing AGENT_BAD_OUTPUT. Add the key with object-merge instead.
|
|
450
|
+
emit_ok "$(jq -nc \
|
|
451
|
+
--arg cp "$compose_project" \
|
|
452
|
+
--arg wp "$task_dir" \
|
|
453
|
+
--arg csport "${code_server_port:-}" \
|
|
454
|
+
--argjson previewPorts "$preview_ports_runtime_json" \
|
|
455
|
+
'{composeProject:$cp,worktreePath:$wp}
|
|
456
|
+
+ (if $csport == "" then {} else {codeServerPort:($csport|tonumber)} end)
|
|
457
|
+
+ {previewPorts:$previewPorts}')"
|