@runuai/host 0.9.0 → 0.9.2
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/images/standard/container/uai-init +38 -19
- package/lib/agent.ts +42 -16
- package/lib/agents/claude.ts +86 -14
- package/lib/agents/factory.ts +9 -1
- package/lib/agents/registry.ts +27 -0
- package/lib/agents/types.ts +3 -0
- package/lib/git-identity.ts +349 -70
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-tokens.ts +756 -110
- package/lib/orchestrator.ts +303 -91
- package/lib/repo-clone.ts +12 -101
- package/lib/ssh.ts +11 -8
- package/lib/transcript.ts +17 -2
- package/package.json +1 -1
- package/scripts/agent/_common.sh +214 -0
- package/scripts/agent/task-down.sh +35 -59
- package/scripts/agent/task-up.sh +746 -72
- package/src/index.ts +81 -7
- package/src/main.ts +112 -31
- package/src/protocol.ts +51 -0
package/lib/repo-clone.ts
CHANGED
|
@@ -1,33 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Retired bare-mirror host command.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Project creation stores metadata only. Repository access happens at
|
|
5
|
+
* task-up, after the task creator and host are known, using that user's
|
|
6
|
+
* connected GitHub credential or their explicit SSH fallback. This legacy
|
|
7
|
+
* protocol command cannot express either principal, so running Git here would
|
|
8
|
+
* necessarily risk borrowing the host operator's ambient credential.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* origin/<defaultBranch>` would fail with "invalid reference". `task-up`
|
|
13
|
-
* self-heals an existing mirror's refspec on subsequent runs, but only
|
|
14
|
-
* if the first task-up runs online — initialising the mirror correctly
|
|
15
|
-
* here closes the offline-first-task gap.
|
|
16
|
-
*
|
|
17
|
-
* Sync in v0.1 — the spec doesn't pin sync-vs-async, and async pipelines
|
|
18
|
-
* add a state machine we don't need until the UI shows progress.
|
|
19
|
-
*
|
|
20
|
-
* Idempotent: if the bare mirror already exists, (re-)assert the refspec
|
|
21
|
-
* and treat the repo as ready without re-fetching. Healing an existing
|
|
22
|
-
* mirror's refspec is cheap and protects against a mirror created before
|
|
23
|
-
* this helper learned the right refspec.
|
|
10
|
+
* Keep the command-shaped response for rolling-version compatibility, but
|
|
11
|
+
* fail closed without touching the filesystem or network. A future preflight
|
|
12
|
+
* needs a user/host-aware protocol of its own; see
|
|
13
|
+
* docs/repo-access-preflight.md.
|
|
24
14
|
*/
|
|
25
15
|
|
|
26
|
-
import { spawnSync } from "node:child_process";
|
|
27
|
-
import { existsSync, rmSync, statSync } from "node:fs";
|
|
28
|
-
import { mkdirSync } from "node:fs";
|
|
29
|
-
import { dirname } from "node:path";
|
|
30
|
-
|
|
31
16
|
import { projectRepoMirror } from "./env";
|
|
32
17
|
|
|
33
18
|
export interface CloneRepoInput {
|
|
@@ -42,86 +27,12 @@ export interface CloneRepoResult {
|
|
|
42
27
|
absolutePath: string;
|
|
43
28
|
}
|
|
44
29
|
|
|
45
|
-
const REMOTE_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
|
|
46
|
-
|
|
47
30
|
export function cloneRepo(input: CloneRepoInput): CloneRepoResult {
|
|
48
31
|
const target = projectRepoMirror(input.projectId);
|
|
49
|
-
|
|
50
|
-
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
51
|
-
// Heal an existing mirror's refspec — no-op when already correct.
|
|
52
|
-
const setRefspec = git(target, [
|
|
53
|
-
"config",
|
|
54
|
-
"remote.origin.fetch",
|
|
55
|
-
REMOTE_REFSPEC,
|
|
56
|
-
]);
|
|
57
|
-
if (setRefspec.status !== 0) {
|
|
58
|
-
return errorResult(target, setRefspec, "git config remote.origin.fetch");
|
|
59
|
-
}
|
|
60
|
-
return { status: "ready", absolutePath: target };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
64
|
-
|
|
65
|
-
const init = spawnSync("git", ["init", "--bare", target], { encoding: "utf8" });
|
|
66
|
-
if (init.status !== 0) {
|
|
67
|
-
return errorResult(target, init, "git init --bare");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const remote = git(target, ["remote", "add", "origin", input.url]);
|
|
71
|
-
if (remote.status !== 0) {
|
|
72
|
-
cleanup(target);
|
|
73
|
-
return errorResult(target, remote, "git remote add origin");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const refspec = git(target, [
|
|
77
|
-
"config",
|
|
78
|
-
"remote.origin.fetch",
|
|
79
|
-
REMOTE_REFSPEC,
|
|
80
|
-
]);
|
|
81
|
-
if (refspec.status !== 0) {
|
|
82
|
-
cleanup(target);
|
|
83
|
-
return errorResult(target, refspec, "git config remote.origin.fetch");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const fetch = git(target, ["fetch", "origin"]);
|
|
87
|
-
if (fetch.status !== 0) {
|
|
88
|
-
// A failed initial fetch leaves a half-built mirror that task-up
|
|
89
|
-
// would later mistake for a healthy one. Tear it down so the next
|
|
90
|
-
// retry starts clean.
|
|
91
|
-
cleanup(target);
|
|
92
|
-
return errorResult(target, fetch, "git fetch origin");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return { status: "ready", absolutePath: target };
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function git(
|
|
99
|
-
cwd: string,
|
|
100
|
-
args: string[],
|
|
101
|
-
): ReturnType<typeof spawnSync> {
|
|
102
|
-
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function cleanup(target: string): void {
|
|
106
|
-
try {
|
|
107
|
-
rmSync(target, { recursive: true, force: true });
|
|
108
|
-
} catch {
|
|
109
|
-
// Best-effort: the caller will surface the original error.
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function errorResult(
|
|
114
|
-
target: string,
|
|
115
|
-
result: ReturnType<typeof spawnSync>,
|
|
116
|
-
step: string,
|
|
117
|
-
): CloneRepoResult {
|
|
118
32
|
return {
|
|
119
33
|
status: "error",
|
|
120
34
|
absolutePath: target,
|
|
121
|
-
error:
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
.trim()
|
|
125
|
-
.slice(0, 1024),
|
|
35
|
+
error:
|
|
36
|
+
"repo.clone is retired because it has no task creator credential; start a task to perform the user-scoped repository fetch",
|
|
126
37
|
};
|
|
127
38
|
}
|
package/lib/ssh.ts
CHANGED
|
@@ -143,11 +143,12 @@ export function keyPairForUser(
|
|
|
143
143
|
};
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
// --- per-task on-disk identity (
|
|
146
|
+
// --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
|
|
147
147
|
//
|
|
148
|
-
// task-up.sh
|
|
149
|
-
//
|
|
150
|
-
//
|
|
148
|
+
// task-up.sh uses the creator's key for host Git only when GitHub is not
|
|
149
|
+
// connected, and installs it in the container for signing (plus that same
|
|
150
|
+
// fallback). The cloud relays ssh.key.get/ensure/delete for the Account card
|
|
151
|
+
// via the store functions above.
|
|
151
152
|
|
|
152
153
|
function taskIdentityDir(taskId: string): string {
|
|
153
154
|
return resolve(env.dataDir, "identity", "tasks", taskId);
|
|
@@ -155,10 +156,12 @@ function taskIdentityDir(taskId: string): string {
|
|
|
155
156
|
|
|
156
157
|
/**
|
|
157
158
|
* Materialize the task creator's keypair to a per-task on-disk identity that
|
|
158
|
-
* task-up.sh uses for
|
|
159
|
-
* (ADR-029)
|
|
160
|
-
*
|
|
161
|
-
*
|
|
159
|
+
* task-up.sh uses for the disconnected user's host-side SSH fallback and the
|
|
160
|
+
* container's optional signing/fallback identity (ADR-029). Connected users
|
|
161
|
+
* clone + push with their GitHub App user token over HTTPS instead.
|
|
162
|
+
* Returns the dir, or null when the creator has no key on this host. A task
|
|
163
|
+
* never borrows the host operator's identity: without a connected GitHub
|
|
164
|
+
* credential, SSH transport is available only through this owner's key. The
|
|
162
165
|
* decrypted key lives only on the trusted host (ADR-015) and is removed right
|
|
163
166
|
* after task-up — see {@link removeTaskIdentity}.
|
|
164
167
|
*/
|
package/lib/transcript.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
* isolated conversations — each only hears what it's addressed. So they can opt
|
|
4
4
|
* into cross-agent awareness, the host maintains a plain-text log of every chat
|
|
5
5
|
* message at `<workspace>/.uai/chat.md` (mounted at /workspace), which any agent
|
|
6
|
-
* can read on demand.
|
|
6
|
+
* can read on demand. Secretary mode adds a frontstage projection at
|
|
7
|
+
* `chat-front.md`; the cloud decides which projection(s) a row belongs to and
|
|
8
|
+
* sends an explicit target list. Conversational rows (including dispatch
|
|
9
|
+
* instructions) only — no tool-call execution traces.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
@@ -11,14 +14,23 @@ import { resolve } from "node:path";
|
|
|
11
14
|
|
|
12
15
|
import { taskWorkspaceDir } from "./env";
|
|
13
16
|
import { rewriteAttachmentRefs } from "./orchestrator";
|
|
17
|
+
import type { TranscriptTarget } from "../src/protocol";
|
|
14
18
|
|
|
15
19
|
/** Container path agents are pointed at. */
|
|
16
20
|
export const CONTAINER_TRANSCRIPT_PATH = "/workspace/.uai/chat.md";
|
|
21
|
+
export const CONTAINER_FRONT_TRANSCRIPT_PATH =
|
|
22
|
+
"/workspace/.uai/chat-front.md";
|
|
23
|
+
|
|
24
|
+
const TARGET_FILENAME: Record<TranscriptTarget, string> = {
|
|
25
|
+
chat: "chat.md",
|
|
26
|
+
"chat-front": "chat-front.md",
|
|
27
|
+
};
|
|
17
28
|
|
|
18
29
|
export function appendTranscript(
|
|
19
30
|
taskId: string,
|
|
20
31
|
author: string,
|
|
21
32
|
text: string,
|
|
33
|
+
targets: TranscriptTarget[],
|
|
22
34
|
): void {
|
|
23
35
|
// Rewrite cloud attachment URLs to the in-container path so an agent reading
|
|
24
36
|
// the transcript can open referenced files directly.
|
|
@@ -26,5 +38,8 @@ export function appendTranscript(
|
|
|
26
38
|
if (!body) return;
|
|
27
39
|
const dir = resolve(taskWorkspaceDir(taskId), ".uai");
|
|
28
40
|
mkdirSync(dir, { recursive: true });
|
|
29
|
-
|
|
41
|
+
const entry = `## ${author}\n\n${body}\n\n`;
|
|
42
|
+
for (const target of new Set(targets)) {
|
|
43
|
+
appendFileSync(resolve(dir, TARGET_FILENAME[target]), entry);
|
|
44
|
+
}
|
|
30
45
|
}
|
package/package.json
CHANGED
package/scripts/agent/_common.sh
CHANGED
|
@@ -204,6 +204,69 @@ UAI_CURRENT_STEP=""
|
|
|
204
204
|
UAI_ERROR_CODE="UNKNOWN"
|
|
205
205
|
UAI_TASK_ID=""
|
|
206
206
|
UAI_LOCK_HELD=""
|
|
207
|
+
UAI_PROJECT_LOCK_PATH=""
|
|
208
|
+
UAI_PROJECT_LOCK_TOKEN=""
|
|
209
|
+
|
|
210
|
+
# Shared project mirrors are mutated by task-up and task-down across different
|
|
211
|
+
# task processes. A task-row lock cannot serialize those operations, so use a
|
|
212
|
+
# portable atomic directory lock (macOS does not ship `flock`). Never reclaim
|
|
213
|
+
# a stale lock automatically: checking a PID and unlinking a fixed path creates
|
|
214
|
+
# a TOCTOU window where a new owner's lock can be deleted. A stale directory is
|
|
215
|
+
# deliberately surfaced for an operator to inspect and remove.
|
|
216
|
+
acquire_project_lock() {
|
|
217
|
+
local projects_root="$1" project_id="$2"
|
|
218
|
+
local project_parent="$projects_root/$project_id"
|
|
219
|
+
local lock_path="$project_parent/.uai-repo.lock"
|
|
220
|
+
local owner_pid attempts=0
|
|
221
|
+
mkdir -p "$project_parent"
|
|
222
|
+
while ! mkdir "$lock_path" 2>/dev/null; do
|
|
223
|
+
if [ ! -d "$lock_path" ]; then
|
|
224
|
+
emit_err "FETCH_FAILED" \
|
|
225
|
+
"project $project_id has an obsolete or invalid repository lock at $lock_path. Confirm no task-up/task-down process is active, remove that lock manually, then retry." \
|
|
226
|
+
"acquire project cache lock"
|
|
227
|
+
fi
|
|
228
|
+
owner_pid=$(cat "$lock_path/pid" 2>/dev/null || true)
|
|
229
|
+
case "$owner_pid" in
|
|
230
|
+
''|*[!0-9]*) ;;
|
|
231
|
+
*)
|
|
232
|
+
if ! kill -0 "$owner_pid" 2>/dev/null; then
|
|
233
|
+
emit_err "FETCH_FAILED" \
|
|
234
|
+
"project $project_id has a stale repository lock at $lock_path (owner pid $owner_pid). Confirm no task-up/task-down process is active, remove that lock directory manually, then retry." \
|
|
235
|
+
"acquire project cache lock"
|
|
236
|
+
fi
|
|
237
|
+
;;
|
|
238
|
+
esac
|
|
239
|
+
attempts=$((attempts + 1))
|
|
240
|
+
if [ "$attempts" -ge 300 ]; then
|
|
241
|
+
emit_err "FETCH_FAILED" \
|
|
242
|
+
"project $project_id is busy in another host operation, or has an incomplete lock at $lock_path. Confirm no task-up/task-down process is active; if none is, remove that lock directory manually, then retry." \
|
|
243
|
+
"acquire project cache lock"
|
|
244
|
+
fi
|
|
245
|
+
sleep 0.1
|
|
246
|
+
done
|
|
247
|
+
UAI_PROJECT_LOCK_TOKEN="$$:${RANDOM:-0}"
|
|
248
|
+
printf '%s\n' "$$" > "$lock_path/pid"
|
|
249
|
+
printf '%s\n' "$UAI_PROJECT_LOCK_TOKEN" > "$lock_path/owner"
|
|
250
|
+
UAI_PROJECT_LOCK_PATH="$lock_path"
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
release_project_lock() {
|
|
254
|
+
[ -n "$UAI_PROJECT_LOCK_PATH" ] || return 0
|
|
255
|
+
local owner
|
|
256
|
+
owner=$(cat "$UAI_PROJECT_LOCK_PATH/owner" 2>/dev/null || true)
|
|
257
|
+
if [ -n "$UAI_PROJECT_LOCK_TOKEN" ] \
|
|
258
|
+
&& [ "$owner" = "$UAI_PROJECT_LOCK_TOKEN" ]; then
|
|
259
|
+
rm -f "$UAI_PROJECT_LOCK_PATH/pid" "$UAI_PROJECT_LOCK_PATH/owner" \
|
|
260
|
+
2>/dev/null || true
|
|
261
|
+
rmdir "$UAI_PROJECT_LOCK_PATH" 2>/dev/null || true
|
|
262
|
+
fi
|
|
263
|
+
UAI_PROJECT_LOCK_PATH=""
|
|
264
|
+
UAI_PROJECT_LOCK_TOKEN=""
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
_uai_on_exit() {
|
|
268
|
+
release_project_lock || true
|
|
269
|
+
}
|
|
207
270
|
|
|
208
271
|
step() {
|
|
209
272
|
UAI_ERROR_CODE="$1"
|
|
@@ -230,6 +293,7 @@ _uai_on_err() {
|
|
|
230
293
|
|
|
231
294
|
setup_err_trap() {
|
|
232
295
|
trap _uai_on_err ERR
|
|
296
|
+
trap _uai_on_exit EXIT
|
|
233
297
|
}
|
|
234
298
|
|
|
235
299
|
# ---------------------------------------------------------------------------
|
|
@@ -246,3 +310,153 @@ compose_project_for_task() { printf 'task-%s' "$(_uai_lower "$1")"; }
|
|
|
246
310
|
# ever do scale, swap to discovering the container name via the
|
|
247
311
|
# `com.docker.compose.project` + `com.docker.compose.service` labels.
|
|
248
312
|
app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }
|
|
313
|
+
|
|
314
|
+
# Canonicalise every GitHub URL shape to credential-free HTTPS. task-up selects
|
|
315
|
+
# the credential per task: the creator's connected GitHub token is primary;
|
|
316
|
+
# SSH is a fallback only when that user has no GitHub connection on this host.
|
|
317
|
+
# Keeping the stored/shared origin transport-neutral avoids binding a project
|
|
318
|
+
# to whichever user's credential happened to touch its mirror first.
|
|
319
|
+
#
|
|
320
|
+
# Mirrors normalizeRepoUrl() in lib/github/repo-url.ts — keep the two in step.
|
|
321
|
+
# Non-GitHub remotes are returned untouched: we only know how to speak for
|
|
322
|
+
# GitHub, and Uai supports any git remote.
|
|
323
|
+
#
|
|
324
|
+
# Bash 3.2 (macOS /bin/bash): no ${var,,}, no associative arrays.
|
|
325
|
+
normalize_github_repo_url() {
|
|
326
|
+
local input="$1" url after_scheme authority host_port host host_lc
|
|
327
|
+
local path owner rest repo repo_lc left
|
|
328
|
+
url=$(printf '%s' "$input" | sed \
|
|
329
|
+
-e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
330
|
+
if [ "${url#*://}" != "$url" ]; then
|
|
331
|
+
after_scheme=${url#*://}
|
|
332
|
+
authority=${after_scheme%%/*}
|
|
333
|
+
[ "$after_scheme" != "$authority" ] || {
|
|
334
|
+
printf '%s' "$url"
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
host_port=${authority##*@}
|
|
338
|
+
host=${host_port%%:*}
|
|
339
|
+
host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
|
|
340
|
+
case "$host_lc" in
|
|
341
|
+
github.com|www.github.com) path=${after_scheme#*/} ;;
|
|
342
|
+
*)
|
|
343
|
+
printf '%s' "$url"
|
|
344
|
+
return
|
|
345
|
+
;;
|
|
346
|
+
esac
|
|
347
|
+
elif [ "${url#*:}" != "$url" ]; then
|
|
348
|
+
left=${url%%:*}
|
|
349
|
+
host=${left##*@}
|
|
350
|
+
host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
|
|
351
|
+
case "$host_lc" in
|
|
352
|
+
github.com|www.github.com) path=${url#*:} ;;
|
|
353
|
+
*)
|
|
354
|
+
printf '%s' "$url"
|
|
355
|
+
return
|
|
356
|
+
;;
|
|
357
|
+
esac
|
|
358
|
+
else
|
|
359
|
+
case "$url" in
|
|
360
|
+
*/*/*|/*)
|
|
361
|
+
printf '%s' "$url"
|
|
362
|
+
return
|
|
363
|
+
;;
|
|
364
|
+
*/*) path=$url ;;
|
|
365
|
+
*)
|
|
366
|
+
printf '%s' "$url"
|
|
367
|
+
return
|
|
368
|
+
;;
|
|
369
|
+
esac
|
|
370
|
+
fi
|
|
371
|
+
|
|
372
|
+
# Keep only owner/repo. Browser URLs can include /tree/branch/path, and a
|
|
373
|
+
# pasted clone URL can carry both `.git` and a trailing slash.
|
|
374
|
+
path=${path%%\?*}
|
|
375
|
+
path=${path%%\#*}
|
|
376
|
+
path=$(printf '%s' "$path" | sed -e 's#/*$##')
|
|
377
|
+
owner=${path%%/*}
|
|
378
|
+
rest=${path#*/}
|
|
379
|
+
if [ -z "$owner" ] || [ "$rest" = "$path" ]; then
|
|
380
|
+
printf '%s' "$url"
|
|
381
|
+
return
|
|
382
|
+
fi
|
|
383
|
+
repo=${rest%%/*}
|
|
384
|
+
repo=${repo%%\?*}
|
|
385
|
+
repo=${repo%%\#*}
|
|
386
|
+
repo_lc=$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')
|
|
387
|
+
case "$repo_lc" in
|
|
388
|
+
*.git) repo=${repo%????} ;;
|
|
389
|
+
esac
|
|
390
|
+
if [ -z "$repo" ]; then
|
|
391
|
+
printf '%s' "$url"
|
|
392
|
+
return
|
|
393
|
+
fi
|
|
394
|
+
printf 'https://github.com/%s/%s.git' "$owner" "$repo"
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# True for a reference whose explicit authority is github.com, even if its
|
|
398
|
+
# path is malformed. Callers use this to fail closed rather than handing a
|
|
399
|
+
# GitHub-looking URL to a generic Git command with ambient credentials.
|
|
400
|
+
is_github_hosted_url() {
|
|
401
|
+
local input="$1" url after_scheme authority host_port host host_lc left
|
|
402
|
+
url=$(printf '%s' "$input" | sed \
|
|
403
|
+
-e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
404
|
+
if [ "${url#*://}" != "$url" ]; then
|
|
405
|
+
after_scheme=${url#*://}
|
|
406
|
+
authority=${after_scheme%%/*}
|
|
407
|
+
host_port=${authority##*@}
|
|
408
|
+
host=${host_port%%:*}
|
|
409
|
+
elif [ "${url#*:}" != "$url" ]; then
|
|
410
|
+
left=${url%%:*}
|
|
411
|
+
host=${left##*@}
|
|
412
|
+
else
|
|
413
|
+
case "$url" in
|
|
414
|
+
*/*/*|/*|*://*) return 1 ;;
|
|
415
|
+
*/*) return 0 ;;
|
|
416
|
+
*) return 1 ;;
|
|
417
|
+
esac
|
|
418
|
+
fi
|
|
419
|
+
host_lc=$(printf '%s' "$host" | tr '[:upper:]' '[:lower:]')
|
|
420
|
+
case "$host_lc" in
|
|
421
|
+
github.com|www.github.com) return 0 ;;
|
|
422
|
+
*) return 1 ;;
|
|
423
|
+
esac
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
# HTTPS spelling of a github.com repository. Empty for non-GitHub remotes.
|
|
427
|
+
github_https_repo_url() {
|
|
428
|
+
local https_url rest owner repo
|
|
429
|
+
https_url=$(normalize_github_repo_url "$1")
|
|
430
|
+
case "$https_url" in
|
|
431
|
+
https://github.com/*)
|
|
432
|
+
rest=${https_url#https://github.com/}
|
|
433
|
+
owner=${rest%%/*}
|
|
434
|
+
repo=${rest#*/}
|
|
435
|
+
if [ "$repo" = "$rest" ]; then
|
|
436
|
+
printf ''
|
|
437
|
+
else
|
|
438
|
+
case "$owner/$repo" in
|
|
439
|
+
/*|*/|*/*/*) printf '' ;;
|
|
440
|
+
*) printf '%s' "$https_url" ;;
|
|
441
|
+
esac
|
|
442
|
+
fi
|
|
443
|
+
;;
|
|
444
|
+
*)
|
|
445
|
+
printf ''
|
|
446
|
+
;;
|
|
447
|
+
esac
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
# SSH spelling used only when the task owner has not connected GitHub.
|
|
451
|
+
github_ssh_repo_url() {
|
|
452
|
+
local https_url
|
|
453
|
+
https_url=$(github_https_repo_url "$1")
|
|
454
|
+
case "$https_url" in
|
|
455
|
+
https://github.com/*)
|
|
456
|
+
printf 'git@github.com:%s' "${https_url#https://github.com/}"
|
|
457
|
+
;;
|
|
458
|
+
*)
|
|
459
|
+
printf ''
|
|
460
|
+
;;
|
|
461
|
+
esac
|
|
462
|
+
}
|
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
# task-down <taskId> — tear the task stack down. Idempotent (ADR-022).
|
|
3
3
|
#
|
|
4
4
|
# 1. docker compose down -v --rmi local --remove-orphans.
|
|
5
|
-
# 2.
|
|
6
|
-
#
|
|
7
|
-
# 3.
|
|
8
|
-
# per-project bare mirrors under projects/<id>/repo.git persist.
|
|
9
|
-
# 4. Persist final state: status=killed (unless already error/shipped).
|
|
5
|
+
# 2. rm -rf the whole task dir — workspace + task-private Git repositories.
|
|
6
|
+
# Host caches under projects/<id>/host-cache.git persist.
|
|
7
|
+
# 3. Persist final state: status=killed (unless already error/shipped).
|
|
10
8
|
|
|
11
9
|
set -euo pipefail
|
|
12
10
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
@@ -30,14 +28,14 @@ if [ "$row_count" -lt 1 ]; then
|
|
|
30
28
|
log "task row unavailable — tearing down by derived names"
|
|
31
29
|
worktree_path=""
|
|
32
30
|
compose_project="$(compose_project_for_task "$task_id")"
|
|
33
|
-
projects_json="[]"
|
|
34
31
|
else
|
|
35
32
|
worktree_path=$(jq -r '.[0].worktree_path // ""' <<<"$task_json")
|
|
36
33
|
compose_project=$(jq -r '.[0].compose_project // ""' <<<"$task_json")
|
|
37
|
-
projects_json=$(db_get_projects_for_task "$task_id" 2>/dev/null) \
|
|
38
|
-
|| projects_json="[]"
|
|
39
34
|
fi
|
|
40
|
-
|
|
35
|
+
# A task-up failure can leave an error row with compose_project=NULL while its
|
|
36
|
+
# conventionally named container still exists. Bookkeeping must never suppress
|
|
37
|
+
# teardown; the compose project is deterministic from the task id.
|
|
38
|
+
[ -n "$compose_project" ] || compose_project=$(compose_project_for_task "$task_id")
|
|
41
39
|
|
|
42
40
|
# task_dir falls back to the derived path when the row's worktree_path is
|
|
43
41
|
# missing (e.g. crashed before persisting).
|
|
@@ -50,58 +48,36 @@ fi
|
|
|
50
48
|
# 1. Compose stack.
|
|
51
49
|
# -----------------------------------------------------------------------------
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
|
|
61
|
-
fi
|
|
62
|
-
# `compose down` is intentionally best-effort because an already-absent
|
|
63
|
-
# stack can make a config-less fallback fail. Destruction itself is not
|
|
64
|
-
# best-effort: before deleting the worktree or reporting success, prove that
|
|
65
|
-
# no container remains under the stable compose project name.
|
|
66
|
-
remaining_containers=""
|
|
67
|
-
if ! remaining_containers=$(docker ps -aq \
|
|
68
|
-
--filter "label=com.docker.compose.project=$compose_project"); then
|
|
69
|
-
emit_err "COMPOSE_DOWN_FAILED" \
|
|
70
|
-
"could not verify teardown for compose project $compose_project" \
|
|
71
|
-
"docker ps after compose down"
|
|
72
|
-
fi
|
|
73
|
-
if [ -n "$remaining_containers" ]; then
|
|
74
|
-
emit_err "COMPOSE_DOWN_FAILED" \
|
|
75
|
-
"compose project $compose_project still has containers after teardown" \
|
|
76
|
-
"docker ps after compose down"
|
|
77
|
-
fi
|
|
51
|
+
step "COMPOSE_DOWN_FAILED" "docker compose down -v --rmi local"
|
|
52
|
+
if [ -n "$task_dir" ] && [ -f "$task_dir/.uai/docker-compose.yml" ]; then
|
|
53
|
+
docker compose -p "$compose_project" -f "$task_dir/.uai/docker-compose.yml" \
|
|
54
|
+
down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
|
|
55
|
+
else
|
|
56
|
+
# Fallback: tear down by project name only.
|
|
57
|
+
docker compose -p "$compose_project" down -v --rmi local --remove-orphans >/dev/null 2>&1 || true
|
|
78
58
|
fi
|
|
79
|
-
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
if
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# user asked us to discard.
|
|
95
|
-
git -C "$mirror_dir" worktree remove --force "$worktree_target" 2>/dev/null \
|
|
96
|
-
|| rm -rf "$worktree_target"
|
|
97
|
-
else
|
|
98
|
-
rm -rf "$worktree_target"
|
|
99
|
-
fi
|
|
100
|
-
done < <(jq -c '.[]' <<<"$projects_json")
|
|
59
|
+
# `compose down` is intentionally best-effort because an already-absent
|
|
60
|
+
# stack can make a config-less fallback fail. Destruction itself is not
|
|
61
|
+
# best-effort: before deleting the worktree or reporting success, prove that
|
|
62
|
+
# no container remains under the stable compose project name.
|
|
63
|
+
remaining_containers=""
|
|
64
|
+
if ! remaining_containers=$(docker ps -aq \
|
|
65
|
+
--filter "label=com.docker.compose.project=$compose_project"); then
|
|
66
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
67
|
+
"could not verify teardown for compose project $compose_project" \
|
|
68
|
+
"docker ps after compose down"
|
|
69
|
+
fi
|
|
70
|
+
if [ -n "$remaining_containers" ]; then
|
|
71
|
+
emit_err "COMPOSE_DOWN_FAILED" \
|
|
72
|
+
"compose project $compose_project still has containers after teardown" \
|
|
73
|
+
"docker ps after compose down"
|
|
101
74
|
fi
|
|
102
75
|
|
|
103
76
|
# -----------------------------------------------------------------------------
|
|
104
|
-
#
|
|
77
|
+
# 2. Remove the entire task directory (workspace + task-private Git data).
|
|
78
|
+
# No host Git command reads a repository that the just-removed container could
|
|
79
|
+
# have modified. Legacy shared-mirror worktree metadata may remain stale, but
|
|
80
|
+
# that retired `repo.git` path is never trusted by task-up again.
|
|
105
81
|
# -----------------------------------------------------------------------------
|
|
106
82
|
|
|
107
83
|
if [ -n "$task_dir" ] && [ -d "$task_dir" ]; then
|
|
@@ -110,10 +86,10 @@ if [ -n "$task_dir" ] && [ -d "$task_dir" ]; then
|
|
|
110
86
|
fi
|
|
111
87
|
|
|
112
88
|
# -----------------------------------------------------------------------------
|
|
113
|
-
#
|
|
89
|
+
# 3. Persist final state.
|
|
114
90
|
# -----------------------------------------------------------------------------
|
|
115
91
|
|
|
116
|
-
current_status=$(jq -r '.[0].status' <<<"$task_json")
|
|
92
|
+
current_status=$(jq -r '.[0].status // ""' <<<"$task_json")
|
|
117
93
|
if [ "$current_status" != "shipped" ] && [ "$current_status" != "error" ]; then
|
|
118
94
|
new_status="killed"
|
|
119
95
|
else
|