@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/uai-host.mjs +14 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/meta/_journal.json +41 -0
  10. package/db/schema.ts +82 -0
  11. package/images/standard/Dockerfile +232 -0
  12. package/images/standard/README.md +122 -0
  13. package/images/standard/container/code-server-settings.json +36 -0
  14. package/images/standard/container/uai-init +215 -0
  15. package/images/standard/tool-versions +2 -0
  16. package/lib/agent.ts +292 -0
  17. package/lib/agents/claude.ts +343 -0
  18. package/lib/agents/codex.ts +522 -0
  19. package/lib/agents/factory.ts +34 -0
  20. package/lib/agents/mock.ts +133 -0
  21. package/lib/agents/proc.ts +172 -0
  22. package/lib/agents/registry.ts +109 -0
  23. package/lib/agents/types.ts +133 -0
  24. package/lib/attachments.ts +46 -0
  25. package/lib/cloud-state.ts +56 -0
  26. package/lib/command-db.ts +278 -0
  27. package/lib/db.ts +68 -0
  28. package/lib/env.ts +140 -0
  29. package/lib/git-diff.ts +370 -0
  30. package/lib/git-identity.ts +65 -0
  31. package/lib/github-tokens.ts +321 -0
  32. package/lib/orchestrator.ts +975 -0
  33. package/lib/preview-ports.ts +85 -0
  34. package/lib/repo-clone.ts +127 -0
  35. package/lib/runtime-state.ts +120 -0
  36. package/lib/secrets.ts +71 -0
  37. package/lib/ssh.ts +186 -0
  38. package/lib/standard-image.ts +152 -0
  39. package/lib/task-diff.ts +113 -0
  40. package/lib/task-status.ts +46 -0
  41. package/lib/transcript.ts +30 -0
  42. package/lib/ulid.ts +7 -0
  43. package/package.json +85 -0
  44. package/scripts/agent/_common.sh +248 -0
  45. package/scripts/agent/task-down.sh +113 -0
  46. package/scripts/agent/task-status.sh +54 -0
  47. package/scripts/agent/task-up.sh +457 -0
  48. package/scripts/install/darwin.ts +167 -0
  49. package/scripts/install/linux.ts +115 -0
  50. package/scripts/install/types.ts +35 -0
  51. package/scripts/install/util.ts +39 -0
  52. package/scripts/install/win.ts +130 -0
  53. package/src/cli.ts +445 -0
  54. package/src/index.ts +375 -0
  55. package/src/load-env.ts +52 -0
  56. package/src/main.ts +1156 -0
  57. package/src/paths.ts +64 -0
  58. package/src/protocol.ts +413 -0
  59. package/src/ui/server.ts +343 -0
  60. package/src/ui/types.ts +78 -0
  61. package/ui/app.js +264 -0
  62. package/ui/index.html +55 -0
  63. package/ui/style.css +359 -0
  64. package/ui/uai-logo-black.svg +9 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Standard-image build pipeline (ADR-022 / docs/runtime.md).
3
+ *
4
+ * Every host builds exactly one standard image (`uai-standard:dev`) and
5
+ * caches it. `ensureStandardImage()` is called once at host startup:
6
+ *
7
+ * 1. `docker volume create uai-asdf-data` — the host-wide asdf data volume
8
+ * that caches lazily-installed runtime versions across tasks (idempotent;
9
+ * `docker volume create` is a no-op if the volume already exists).
10
+ * 2. `docker image inspect uai-standard:dev` — if it resolves, the image is
11
+ * present and we skip the build. Otherwise `docker build` it from
12
+ * `host-agent/images/standard/`.
13
+ *
14
+ * Best-effort: any failure (docker missing, daemon down, build error) is
15
+ * logged and swallowed so the host process still boots and serves tunnels.
16
+ * task-up surfaces the real error to the user if a build is genuinely needed.
17
+ */
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ /** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
24
+ export const STANDARD_IMAGE_TAG = "uai-standard:dev";
25
+ export const ASDF_DATA_VOLUME = "uai-asdf-data";
26
+
27
+ /**
28
+ * Runtimes the standard image's asdf plugins can satisfy, with a sensible set
29
+ * of advertised versions per kind (ADR-021 `runtimes`). These are the
30
+ * versions the cloud's task-creation picker hints; asdf will lazily install
31
+ * any of them on first use (docs/runtime.md). The first entry of each list is
32
+ * the root `/etc/.tool-versions` default baked into the image.
33
+ *
34
+ * KEEP IN SYNC with host-agent/images/standard/ (the asdf plugins and the
35
+ * root tool-versions). Treat that directory as authoritative for the numbers;
36
+ * this list is the advertisement surface.
37
+ */
38
+ export const STANDARD_RUNTIMES: ReadonlyArray<{
39
+ kind: string;
40
+ availableVersions: string[];
41
+ }> = [
42
+ { kind: "node", availableVersions: ["22.11.0", "20.18.0", "18.20.4"] },
43
+ { kind: "python", availableVersions: ["3.12.4", "3.11.9"] },
44
+ { kind: "go", availableVersions: ["1.23.2", "1.22.8"] },
45
+ { kind: "ruby", availableVersions: ["3.3.5", "3.2.5"] },
46
+ { kind: "rust", availableVersions: ["1.81.0"] },
47
+ ];
48
+
49
+ /** A fresh, mutable copy of the advertised runtimes for the capability frame. */
50
+ export function standardRuntimes(): Array<{
51
+ kind: string;
52
+ availableVersions: string[];
53
+ }> {
54
+ return STANDARD_RUNTIMES.map((r) => ({
55
+ kind: r.kind,
56
+ availableVersions: [...r.availableVersions],
57
+ }));
58
+ }
59
+
60
+ /** Absolute path to the standard image build context. */
61
+ function standardImageDir(): string {
62
+ const here = dirname(fileURLToPath(import.meta.url));
63
+ // lib/standard-image.ts -> host-agent/ -> images/standard
64
+ return resolve(here, "..", "images", "standard");
65
+ }
66
+
67
+ interface RunResult {
68
+ code: number | null;
69
+ stdout: string;
70
+ stderr: string;
71
+ }
72
+
73
+ /** Run a command to completion, capturing stdio. Never rejects. */
74
+ function run(command: string, args: string[]): Promise<RunResult> {
75
+ return new Promise<RunResult>((resolveRun) => {
76
+ let stdout = "";
77
+ let stderr = "";
78
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
79
+ child.stdout?.setEncoding("utf8");
80
+ child.stderr?.setEncoding("utf8");
81
+ child.stdout?.on("data", (chunk: string) => {
82
+ stdout += chunk;
83
+ });
84
+ child.stderr?.on("data", (chunk: string) => {
85
+ stderr += chunk;
86
+ });
87
+ child.on("error", (err: Error) => {
88
+ resolveRun({ code: null, stdout, stderr: stderr + err.message });
89
+ });
90
+ child.on("close", (code) => {
91
+ resolveRun({ code, stdout, stderr });
92
+ });
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Ensure the standard image and the shared asdf data volume exist. Builds the
98
+ * image only when `docker image inspect` fails. Best-effort: logs and returns
99
+ * on any error so the host keeps booting.
100
+ */
101
+ export async function ensureStandardImage(): Promise<void> {
102
+ // 1. Shared asdf data volume — idempotent.
103
+ const vol = await run("docker", ["volume", "create", ASDF_DATA_VOLUME]);
104
+ if (vol.code !== 0) {
105
+ console.warn(
106
+ `[host-agent] could not create asdf data volume (${ASDF_DATA_VOLUME}); ` +
107
+ `continuing. ${vol.stderr.trim()}`,
108
+ );
109
+ // If docker itself is unavailable, the image step will also fail; bail
110
+ // early so we don't double-log a confusing build error.
111
+ if (vol.code === null) return;
112
+ }
113
+
114
+ // 2. Is the image already built?
115
+ const inspect = await run("docker", [
116
+ "image",
117
+ "inspect",
118
+ STANDARD_IMAGE_TAG,
119
+ ]);
120
+ if (inspect.code === 0) {
121
+ console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} present`);
122
+ return;
123
+ }
124
+ if (inspect.code === null) {
125
+ console.warn(
126
+ "[host-agent] docker unavailable; skipping standard image build. " +
127
+ "Tasks will fail until docker is running.",
128
+ );
129
+ return;
130
+ }
131
+
132
+ // 3. Build it.
133
+ const context = standardImageDir();
134
+ console.log(
135
+ `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
136
+ );
137
+ const build = await run("docker", [
138
+ "build",
139
+ "-t",
140
+ STANDARD_IMAGE_TAG,
141
+ context,
142
+ ]);
143
+ if (build.code === 0) {
144
+ console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
145
+ return;
146
+ }
147
+ console.warn(
148
+ `[host-agent] standard image build failed (exit ${build.code ?? "spawn"}); ` +
149
+ "continuing. Tasks needing the image will surface this error.\n" +
150
+ build.stderr.trim(),
151
+ );
152
+ }
@@ -0,0 +1,113 @@
1
+ import { existsSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+
5
+ import type { TaskDiffInput, TaskDiffResult } from "../src/protocol";
6
+ import { taskWorkspaceDir } from "./env";
7
+ import { parseGitDiff, runGitDiff, runGitDiffInContainer } from "./git-diff";
8
+ import { getHostTask } from "./runtime-state";
9
+
10
+ export async function buildTaskDiff(
11
+ input: TaskDiffInput,
12
+ ): Promise<TaskDiffResult> {
13
+ const workspaceRoot = taskWorkspaceDir(input.taskId);
14
+ const runtime = getHostTask(input.taskId);
15
+ const containerName =
16
+ runtime?.statusMirror === "running" && runtime.composeProject
17
+ ? `${runtime.composeProject}-app-1`
18
+ : null;
19
+ const out: TaskDiffResult["repos"] = [];
20
+
21
+ for (const project of input.projects) {
22
+ const cwd = join(workspaceRoot, project.slug);
23
+ const containerCwd = `/workspace/${project.slug}`;
24
+ if (!containerName && !existsSync(cwd)) continue;
25
+
26
+ // Base branch is derived from the worktree's upstream (the worktree was
27
+ // created off `origin/<defaultBranch>` at task-up; ADR-022 derives and
28
+ // persists the default branch on the worktree, not the project record).
29
+ const base = resolveBase(containerName, cwd, containerCwd, project.slug);
30
+ const range = `${base}..HEAD`;
31
+ try {
32
+ const text = containerName
33
+ ? await runGitDiffInContainer(containerName, containerCwd, range)
34
+ : await runGitDiff(cwd, range);
35
+ out.push({
36
+ id: project.id,
37
+ name: project.slug,
38
+ mountPath: project.slug,
39
+ defaultBranch: stripOriginPrefix(base),
40
+ files: parseGitDiff(text),
41
+ });
42
+ } catch (err) {
43
+ out.push({
44
+ id: project.id,
45
+ name: project.slug,
46
+ mountPath: project.slug,
47
+ defaultBranch: stripOriginPrefix(base),
48
+ files: [],
49
+ error: err instanceof Error ? err.message : String(err),
50
+ });
51
+ }
52
+ }
53
+
54
+ return { repos: out };
55
+ }
56
+
57
+ /**
58
+ * Resolve the diff base for a worktree. Prefer the configured upstream
59
+ * (`@{upstream}`); fall back to the remote's default branch
60
+ * (`origin/HEAD`), then `origin/main`. Runs git in-container when the task
61
+ * is live so credential behavior stays inside the sandbox (see git-diff).
62
+ */
63
+ function resolveBase(
64
+ containerName: string | null,
65
+ hostCwd: string,
66
+ containerCwd: string,
67
+ _slug: string,
68
+ ): string {
69
+ const upstream = runGitText(
70
+ containerName,
71
+ hostCwd,
72
+ containerCwd,
73
+ ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
74
+ );
75
+ if (upstream) return upstream;
76
+
77
+ const head = runGitText(
78
+ containerName,
79
+ hostCwd,
80
+ containerCwd,
81
+ ["rev-parse", "--abbrev-ref", "origin/HEAD"],
82
+ );
83
+ if (head) return head;
84
+
85
+ return "origin/main";
86
+ }
87
+
88
+ function stripOriginPrefix(ref: string): string {
89
+ return ref.startsWith("origin/") ? ref.slice("origin/".length) : ref;
90
+ }
91
+
92
+ /**
93
+ * Run a read-only git command (host or in-container) and return trimmed
94
+ * stdout, or null on any failure. Used for base-branch resolution where a
95
+ * missing upstream is expected, not exceptional.
96
+ */
97
+ function runGitText(
98
+ containerName: string | null,
99
+ hostCwd: string,
100
+ containerCwd: string,
101
+ args: string[],
102
+ ): string | null {
103
+ const result = containerName
104
+ ? spawnSync(
105
+ "docker",
106
+ ["exec", "-w", containerCwd, containerName, "git", ...args],
107
+ { encoding: "utf8" },
108
+ )
109
+ : spawnSync("git", ["-C", hostCwd, ...args], { encoding: "utf8" });
110
+ if (result.status !== 0) return null;
111
+ const text = (result.stdout ?? "").trim();
112
+ return text.length > 0 ? text : null;
113
+ }
@@ -0,0 +1,46 @@
1
+ export const ACTIVE_STATUSES = [
2
+ "queued",
3
+ "starting",
4
+ "running",
5
+ "error",
6
+ ] as const;
7
+
8
+ export const STOPPED_STATUSES = ["stopped"] as const;
9
+
10
+ export const TERMINAL_STATUSES = [
11
+ "finished",
12
+ "canceled",
13
+ "killed",
14
+ "shipped",
15
+ ] as const;
16
+
17
+ export const DASHBOARD_STATUSES = [
18
+ ...ACTIVE_STATUSES,
19
+ ...STOPPED_STATUSES,
20
+ ] as const;
21
+
22
+ export const NON_TERMINAL_STATUSES = [
23
+ ...ACTIVE_STATUSES,
24
+ ...STOPPED_STATUSES,
25
+ ] as const;
26
+
27
+ export type TaskStatus =
28
+ | (typeof ACTIVE_STATUSES)[number]
29
+ | (typeof STOPPED_STATUSES)[number]
30
+ | (typeof TERMINAL_STATUSES)[number];
31
+
32
+ export function isActive(status: string): boolean {
33
+ return (ACTIVE_STATUSES as readonly string[]).includes(status);
34
+ }
35
+
36
+ export function isStopped(status: string): boolean {
37
+ return (STOPPED_STATUSES as readonly string[]).includes(status);
38
+ }
39
+
40
+ export function isTerminal(status: string): boolean {
41
+ return (TERMINAL_STATUSES as readonly string[]).includes(status);
42
+ }
43
+
44
+ export function isOnDashboard(status: string): boolean {
45
+ return isActive(status) || isStopped(status);
46
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The shared channel transcript (ADR-027-adjacent). Agents have private,
3
+ * isolated conversations — each only hears what it's addressed. So they can opt
4
+ * into cross-agent awareness, the host maintains a plain-text log of every chat
5
+ * message at `<workspace>/.uai/chat.md` (mounted at /workspace), which any agent
6
+ * can read on demand. Messages only — no tool calls / actions.
7
+ */
8
+
9
+ import { appendFileSync, mkdirSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+
12
+ import { taskWorkspaceDir } from "./env";
13
+ import { rewriteAttachmentRefs } from "./orchestrator";
14
+
15
+ /** Container path agents are pointed at. */
16
+ export const CONTAINER_TRANSCRIPT_PATH = "/workspace/.uai/chat.md";
17
+
18
+ export function appendTranscript(
19
+ taskId: string,
20
+ author: string,
21
+ text: string,
22
+ ): void {
23
+ // Rewrite cloud attachment URLs to the in-container path so an agent reading
24
+ // the transcript can open referenced files directly.
25
+ const body = rewriteAttachmentRefs(text).trim();
26
+ if (!body) return;
27
+ const dir = resolve(taskWorkspaceDir(taskId), ".uai");
28
+ mkdirSync(dir, { recursive: true });
29
+ appendFileSync(resolve(dir, "chat.md"), `## ${author}\n\n${body}\n\n`);
30
+ }
package/lib/ulid.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { ulid } from "ulid";
2
+
3
+ export function newId(): string {
4
+ return ulid().toLowerCase();
5
+ }
6
+
7
+ export { ulid as monotonicUlid };
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@runuai/host",
3
+ "version": "0.1.0",
4
+ "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
+ "license": "MIT",
6
+ "author": "Diogo Perillo <diogo.perillo@gmail.com>",
7
+ "homepage": "https://github.com/runuai/uai#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/runuai/uai.git",
11
+ "directory": "host-agent"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/runuai/uai/issues"
15
+ },
16
+ "keywords": [
17
+ "ai",
18
+ "coding-agent",
19
+ "claude",
20
+ "codex",
21
+ "docker",
22
+ "dev-container",
23
+ "self-hosted",
24
+ "orchestrator"
25
+ ],
26
+ "type": "module",
27
+ "bin": {
28
+ "uai-host": "./bin/uai-host.mjs"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "src",
39
+ "lib",
40
+ "db",
41
+ "scripts/agent",
42
+ "scripts/install",
43
+ "images/standard",
44
+ "ui",
45
+ "README.md",
46
+ "!**/*.test.ts",
47
+ "!**/*.bats",
48
+ "!scripts/agent/tests"
49
+ ],
50
+ "exports": {
51
+ ".": {
52
+ "types": "./src/index.ts",
53
+ "default": "./src/index.ts"
54
+ },
55
+ "./protocol": {
56
+ "types": "./src/protocol.ts",
57
+ "default": "./src/protocol.ts"
58
+ }
59
+ },
60
+ "scripts": {
61
+ "lint": "eslint .",
62
+ "typecheck": "tsc -p tsconfig.json",
63
+ "test:unit": "vitest run --config vitest.config.ts",
64
+ "start": "tsx src/cli.ts run",
65
+ "uai-host": "tsx src/cli.ts"
66
+ },
67
+ "dependencies": {
68
+ "better-sqlite3": "^11.3.0",
69
+ "dotenv": "^16.4.5",
70
+ "drizzle-orm": "^0.36.0",
71
+ "tsx": "^4.19.2",
72
+ "ulid": "^2.3.0",
73
+ "ws": "^8.18.3",
74
+ "zod": "^3.23.8"
75
+ },
76
+ "devDependencies": {
77
+ "@typescript-eslint/eslint-plugin": "^8.59.4",
78
+ "@typescript-eslint/parser": "^8.59.4",
79
+ "@types/node": "^22.9.0",
80
+ "@types/ws": "^8.18.1",
81
+ "eslint": "^9.14.0",
82
+ "typescript": "^5.6.3",
83
+ "vitest": "^2.1.4"
84
+ }
85
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bash
2
+ # uai agent — shared helpers.
3
+ #
4
+ # Every agent script source's this file at the top. It establishes the
5
+ # strict-mode posture, structured JSON output contract, sqlite access
6
+ # helpers, and a uniform error trap.
7
+ #
8
+ # Output contract (docs/agent.md):
9
+ #
10
+ # success → stdout: {"ok":true,"data":{...}}
11
+ # failure → stderr: {"ok":false,"error":{"code":"...","message":"...","step":"..."}}
12
+ # exit non-zero.
13
+ #
14
+ # Environment:
15
+ # UAI_DB_PATH absolute path to uai.sqlite (required)
16
+ # UAI_OWNER_HOME host $HOME (for ~/.claude, ~/.codex) (default: $HOME)
17
+ # UAI_TTYD_BIND ttyd listen address (default: 127.0.0.1)
18
+ # UAI_LOG_DIR directory for ttyd / docker logs (default: $UAI_DATA_DIR/logs)
19
+ # UAI_DATA_DIR base data dir (default: dirname of UAI_DB_PATH)
20
+
21
+ set -euo pipefail
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Strict env.
25
+ # ---------------------------------------------------------------------------
26
+
27
+ : "${UAI_DB_PATH:?UAI_DB_PATH must be set (path to uai.sqlite)}"
28
+
29
+ UAI_DATA_DIR="${UAI_DATA_DIR:-$(dirname "$UAI_DB_PATH")}"
30
+ UAI_OWNER_HOME="${UAI_OWNER_HOME:-$HOME}"
31
+ # uai-owned storage tree (~/.uai by default). task-up / task-down derive
32
+ # the per-project mirrors and per-task dirs from here (mirrors lib/env.ts).
33
+ UAI_WORKSPACE_ROOT="${UAI_WORKSPACE_ROOT:-$UAI_OWNER_HOME/.uai}"
34
+ UAI_TTYD_BIND="${UAI_TTYD_BIND:-127.0.0.1}"
35
+ UAI_LOG_DIR="${UAI_LOG_DIR:-$UAI_DATA_DIR/logs}"
36
+ mkdir -p "$UAI_LOG_DIR"
37
+
38
+ # Optional dependencies — fail fast with a structured error if missing.
39
+ for bin in sqlite3 jq docker git; do
40
+ if ! command -v "$bin" >/dev/null 2>&1; then
41
+ printf '{"ok":false,"error":{"code":"MISSING_DEPENDENCY","message":"%s not found on PATH"}}\n' "$bin" >&2
42
+ exit 127
43
+ fi
44
+ done
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Logger. All log lines go to stderr so stdout stays reserved for JSON.
48
+ # ---------------------------------------------------------------------------
49
+
50
+ _uai_ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
51
+
52
+ log() { printf '[uai-agent %s] %s\n' "$(_uai_ts)" "$*" >&2; }
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # JSON output helpers.
56
+ # ---------------------------------------------------------------------------
57
+
58
+ # emit_ok '{"foo":"bar"}'
59
+ #
60
+ # NOTE: do not use `${1:-{}}` here — bash parses `{` inside the default
61
+ # as a literal and the matching `}` closes the substitution prematurely,
62
+ # so a real payload gets a trailing `}` appended. Use an explicit check.
63
+ emit_ok() {
64
+ local data="$1"
65
+ if [ -z "$data" ]; then data='{}'; fi
66
+ printf '{"ok":true,"data":%s}\n' "$data"
67
+ }
68
+
69
+ # emit_err <code> <message> [step]
70
+ #
71
+ # Persists status='error' on the current task (if UAI_TASK_ID is set),
72
+ # logs an `error` event, prints structured JSON to stderr, and exits 1.
73
+ emit_err() {
74
+ local code="$1" msg="$2" step="${3:-${UAI_CURRENT_STEP:-}}"
75
+ _uai_mark_error "$code" "$step"
76
+ local json
77
+ if [ -n "$step" ]; then
78
+ json=$(jq -nc --arg c "$code" --arg m "$msg" --arg s "$step" \
79
+ '{ok:false,error:{code:$c,message:$m,step:$s}}')
80
+ else
81
+ json=$(jq -nc --arg c "$code" --arg m "$msg" \
82
+ '{ok:false,error:{code:$c,message:$m}}')
83
+ fi
84
+ printf '%s\n' "$json" >&2
85
+ exit 1
86
+ }
87
+
88
+ # _uai_mark_error <code> <step> — best-effort persist of status=error.
89
+ # Shared between emit_err (explicit) and the ERR trap (set -e).
90
+ _uai_mark_error() {
91
+ local code="$1" step="$2"
92
+ if [ -n "${UAI_TASK_ID:-}" ] && [ -n "${UAI_DB_PATH:-}" ]; then
93
+ sqlite_exec "UPDATE uai_tasks SET status='error', locked_at=NULL, updated_at=(unixepoch()*1000) WHERE id='$(sql_escape "$UAI_TASK_ID")'" 2>/dev/null || true
94
+ local payload
95
+ payload=$(jq -nc --arg c "$code" --arg s "$step" '{code:$c,step:$s}')
96
+ sqlite_exec "INSERT INTO uai_task_events (id, task_id, kind, payload) VALUES ('$(sql_escape "$(_uai_event_id)")', '$(sql_escape "$UAI_TASK_ID")', 'error', '$(sql_escape "$payload")')" 2>/dev/null || true
97
+ fi
98
+ }
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # SQLite helpers. The DB is local SQLite; concurrency is single-writer per
102
+ # task (enforced via lock_task below + Next.js).
103
+ # ---------------------------------------------------------------------------
104
+
105
+ # sqlite_exec "<sql>"
106
+ sqlite_exec() {
107
+ sqlite3 "$UAI_DB_PATH" "$1"
108
+ }
109
+
110
+ # sqlite_json "<sql>" → JSON array
111
+ sqlite_json() {
112
+ sqlite3 -json "$UAI_DB_PATH" "$1"
113
+ }
114
+
115
+ # sql_escape <value> → single-quote-safe string for inline SQL.
116
+ #
117
+ # We pipe through sed instead of using bash parameter expansion because
118
+ # bash 3.2 (macOS default) keeps backslashes in `${var//\'/\'\'}` —
119
+ # producing `you\'\'re` from `you're` and breaking SQL string parsing.
120
+ # sed handles it identically on bash 3.2 and 5+.
121
+ sql_escape() {
122
+ printf '%s' "$1" | sed "s/'/''/g"
123
+ }
124
+
125
+ # db_get_task <id> → JSON array (one row) or "[]"
126
+ db_get_task() {
127
+ local out
128
+ out=$(sqlite_json "SELECT * FROM uai_tasks WHERE id='$(sql_escape "$1")'")
129
+ printf '%s' "${out:-[]}"
130
+ }
131
+
132
+ # db_get_projects_for_task <taskId> → JSON array of the task's selected
133
+ # projects, in position order. Each element carries every uai_projects
134
+ # column plus the `position` from uai_task_projects. Empty array when the
135
+ # task has no selected projects (scratchpad).
136
+ db_get_projects_for_task() {
137
+ local out
138
+ out=$(sqlite_json "SELECT p.*, tp.position AS position FROM uai_projects p JOIN uai_task_projects tp ON tp.project_id = p.id WHERE tp.task_id = '$(sql_escape "$1")' ORDER BY tp.position ASC")
139
+ printf '%s' "${out:-[]}"
140
+ }
141
+
142
+ # db_update_task <id> "col=value" "col=value" ...
143
+ #
144
+ # Values must be pre-quoted by the caller (e.g. status='running', port=42).
145
+ db_update_task() {
146
+ local id="$1"; shift
147
+ local set_clause
148
+ set_clause=$(IFS=','; echo "$*")
149
+ sqlite_exec "UPDATE uai_tasks SET ${set_clause}, updated_at=(unixepoch()*1000) WHERE id='$(sql_escape "$id")'"
150
+ }
151
+
152
+ # emit_event <task_id> <kind> <payload-json>
153
+ emit_event() {
154
+ local tid="$1" kind="$2" payload="$3"
155
+ if [ -z "$payload" ]; then payload='{}'; fi
156
+ local eid
157
+ eid=$(_uai_event_id)
158
+ sqlite_exec "INSERT INTO uai_task_events (id, task_id, kind, payload) VALUES ('$(sql_escape "$eid")', '$(sql_escape "$tid")', '$(sql_escape "$kind")', '$(sql_escape "$payload")')"
159
+ }
160
+
161
+ # _uai_event_id — ULID-shaped fallback id generator (Crockford base32 prefix).
162
+ # Real ULIDs come from the Next.js layer; agent-side events use ev_<unix><rand>.
163
+ _uai_event_id() {
164
+ printf 'ev_%s%s' "$(date +%s%N | cut -c1-13)" "$(_uai_rand 10)"
165
+ }
166
+
167
+ _uai_rand() {
168
+ # Lowercase alphanumeric, length $1. Avoids depending on `uuidgen` etc.
169
+ local n="$1"
170
+ LC_ALL=C tr -dc 'a-z0-9' </dev/urandom | head -c "$n"
171
+ }
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Locking. The Task row carries `locked_at`. Next.js also checks this
175
+ # before invoking the agent; the shell-side lock is a safety net for
176
+ # concurrent invocations of the same script.
177
+ # ---------------------------------------------------------------------------
178
+
179
+ lock_task() {
180
+ local id="$1"
181
+ # Atomic CAS via UPDATE WHERE locked_at IS NULL; success = changes()==1.
182
+ local changes
183
+ changes=$(sqlite_exec "UPDATE uai_tasks SET locked_at=(unixepoch()*1000), updated_at=(unixepoch()*1000) WHERE id='$(sql_escape "$id")' AND locked_at IS NULL; SELECT changes();")
184
+ [ "$changes" = "1" ] || emit_err "TASK_LOCKED" "task $id is already locked" "lock"
185
+ UAI_LOCK_HELD="$id"
186
+ }
187
+
188
+ unlock_task() {
189
+ local id="$1"
190
+ sqlite_exec "UPDATE uai_tasks SET locked_at=NULL, updated_at=(unixepoch()*1000) WHERE id='$(sql_escape "$id")'"
191
+ UAI_LOCK_HELD=""
192
+ }
193
+
194
+ # ---------------------------------------------------------------------------
195
+ # Step tracking + ERR trap.
196
+ #
197
+ # `step <code> <label>` declares the next risky operation. If the script
198
+ # exits non-zero, the trap emits a structured error tagged with the
199
+ # current step's code + label, marks the task `status=error`, and
200
+ # releases the lock.
201
+ # ---------------------------------------------------------------------------
202
+
203
+ UAI_CURRENT_STEP=""
204
+ UAI_ERROR_CODE="UNKNOWN"
205
+ UAI_TASK_ID=""
206
+ UAI_LOCK_HELD=""
207
+
208
+ step() {
209
+ UAI_ERROR_CODE="$1"
210
+ UAI_CURRENT_STEP="$2"
211
+ log "step: $UAI_CURRENT_STEP"
212
+ }
213
+
214
+ _uai_on_err() {
215
+ local rc=$?
216
+ # Disable further ERR trap recursion.
217
+ trap - ERR
218
+
219
+ local code="$UAI_ERROR_CODE"
220
+ local stepname="$UAI_CURRENT_STEP"
221
+
222
+ _uai_mark_error "$code" "$stepname"
223
+
224
+ local out
225
+ out=$(jq -nc --arg c "$code" --arg m "agent step failed" --arg s "$stepname" --argjson rc "$rc" \
226
+ '{ok:false,error:{code:$c,message:$m,step:$s,exit_code:$rc}}')
227
+ printf '%s\n' "$out" >&2
228
+ exit "$rc"
229
+ }
230
+
231
+ setup_err_trap() {
232
+ trap _uai_on_err ERR
233
+ }
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Compose project name + container name conventions.
237
+ # ---------------------------------------------------------------------------
238
+
239
+ # Belt-and-braces: docker compose only accepts lowercase project names.
240
+ # We also lowercase ulids at generation (lib/ulid.ts) but defensive
241
+ # lowercasing here protects against any caller passing mixed case.
242
+ _uai_lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
243
+ compose_project_for_task() { printf 'task-%s' "$(_uai_lower "$1")"; }
244
+ # Compose v2 names containers as `<project>-<service>-<replica>`. We
245
+ # never scale beyond one replica, so the suffix is always `-1`. If we
246
+ # ever do scale, swap to discovering the container name via the
247
+ # `com.docker.compose.project` + `com.docker.compose.service` labels.
248
+ app_container_for_task() { printf 'task-%s-app-1' "$(_uai_lower "$1")"; }