@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,278 @@
1
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import Database from "better-sqlite3";
5
+
6
+ import { env } from "./env";
7
+ import type { HostTask } from "../db/schema";
8
+ import type {
9
+ TaskCommandProject,
10
+ TaskCommandTask,
11
+ TaskDownInput,
12
+ TaskLaunchInput,
13
+ } from "../src/protocol";
14
+
15
+ export function createTaskUpCommandDb(input: TaskLaunchInput): string {
16
+ const dbPath = newCommandDbPath(input.task.id);
17
+ const db = openCommandDb(dbPath);
18
+ try {
19
+ seedMetadata(db, withRuntime(input.task, null), input.projects);
20
+ } finally {
21
+ db.close();
22
+ }
23
+ return dbPath;
24
+ }
25
+
26
+ export function createTaskDownCommandDb(
27
+ input: TaskDownInput,
28
+ runtime: HostTask | null,
29
+ ): string {
30
+ const dbPath = newCommandDbPath(input.taskId);
31
+ const db = openCommandDb(dbPath);
32
+ try {
33
+ seedMetadata(db, withRuntime(input.task, runtime), input.projects);
34
+ } finally {
35
+ db.close();
36
+ }
37
+ return dbPath;
38
+ }
39
+
40
+ export function createTaskStatusCommandDb(
41
+ taskId: string,
42
+ runtime: HostTask | null,
43
+ ): string {
44
+ const dbPath = newCommandDbPath(taskId);
45
+ const db = openCommandDb(dbPath);
46
+ try {
47
+ insertTask(db, runtimeTask(taskId, runtime));
48
+ } finally {
49
+ db.close();
50
+ }
51
+ return dbPath;
52
+ }
53
+
54
+ export function removeCommandDb(path: string): void {
55
+ rmSync(path, { force: true });
56
+ }
57
+
58
+ function newCommandDbPath(taskId: string): string {
59
+ if (!existsSync(env.commandDbDir)) {
60
+ mkdirSync(env.commandDbDir, { recursive: true, mode: 0o700 });
61
+ }
62
+ const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
63
+ return join(env.commandDbDir, `${taskId}-${suffix}.sqlite`);
64
+ }
65
+
66
+ function openCommandDb(path: string): Database.Database {
67
+ const db = new Database(path);
68
+ db.pragma("journal_mode = WAL");
69
+ db.exec(schemaSql);
70
+ return db;
71
+ }
72
+
73
+ /**
74
+ * Seed the per-task command DB the bash scripts read (ADR-022 shape):
75
+ * - one uai_users row for the owner,
76
+ * - one uai_projects row per selected project,
77
+ * - one uai_task_projects row per (task, project, position),
78
+ * - one uai_tasks row for the task (+ mirrored runtime fields).
79
+ * Column names mirror the cloud Drizzle schema.
80
+ */
81
+ function seedMetadata(
82
+ db: Database.Database,
83
+ task: CommandTaskRow,
84
+ projects: TaskCommandProject[],
85
+ ): void {
86
+ db.prepare(
87
+ `INSERT OR IGNORE INTO uai_users (id, clerk_user_id, email)
88
+ VALUES (@id, @clerk_user_id, @email)`,
89
+ ).run({
90
+ id: task.owner_user_id,
91
+ clerk_user_id: null,
92
+ email: "host-command@example.invalid",
93
+ });
94
+
95
+ const insertProject = db.prepare(
96
+ `INSERT INTO uai_projects (
97
+ id, owner_user_id, name, slug, repo_url, default_prompt,
98
+ preview_ports, env, tool_versions, extra
99
+ ) VALUES (
100
+ @id, @owner_user_id, @name, @slug, @repo_url, @default_prompt,
101
+ @preview_ports, @env, @tool_versions, @extra
102
+ )`,
103
+ );
104
+ const insertTaskProject = db.prepare(
105
+ `INSERT INTO uai_task_projects (task_id, project_id, position)
106
+ VALUES (@task_id, @project_id, @position)`,
107
+ );
108
+
109
+ for (const project of projects) {
110
+ insertProject.run({
111
+ id: project.id,
112
+ owner_user_id: project.ownerUserId,
113
+ name: project.name,
114
+ slug: project.slug,
115
+ repo_url: project.repoUrl,
116
+ default_prompt: project.defaultPrompt,
117
+ preview_ports: project.previewPorts,
118
+ env: project.env,
119
+ tool_versions: project.toolVersions ?? null,
120
+ extra: project.extra ?? null,
121
+ });
122
+ insertTaskProject.run({
123
+ task_id: task.id,
124
+ project_id: project.id,
125
+ position: project.position,
126
+ });
127
+ }
128
+
129
+ insertTask(db, task);
130
+ }
131
+
132
+ function insertTask(db: Database.Database, task: CommandTaskRow): void {
133
+ db.prepare(
134
+ `INSERT INTO uai_tasks (
135
+ id, owner_user_id, host_id, name, slug, branch, status,
136
+ global_context, reviewer_order, agents, pr_context,
137
+ worktree_path, compose_project, code_server_port, preview_ports,
138
+ locked_at, started_at, ended_at
139
+ ) VALUES (
140
+ @id, @owner_user_id, @host_id, @name, @slug, @branch, @status,
141
+ @global_context, @reviewer_order, @agents, @pr_context,
142
+ @worktree_path, @compose_project, @code_server_port, @preview_ports,
143
+ @locked_at, @started_at, @ended_at
144
+ )`,
145
+ ).run(task);
146
+ }
147
+
148
+ /**
149
+ * The shape of a row inserted into `uai_tasks`. The leading columns mirror
150
+ * the cloud `tasks` schema; the trailing columns are host runtime fields the
151
+ * bash scripts write back during task-up / task-down.
152
+ */
153
+ interface CommandTaskRow {
154
+ id: string;
155
+ owner_user_id: string;
156
+ host_id: string;
157
+ name: string;
158
+ slug: string;
159
+ branch: string;
160
+ status: string;
161
+ global_context: string | null;
162
+ reviewer_order: string | null;
163
+ agents: string;
164
+ pr_context: string | null;
165
+ worktree_path: string | null;
166
+ compose_project: string | null;
167
+ code_server_port: number | null;
168
+ preview_ports: string;
169
+ locked_at: number | null;
170
+ started_at: number | null;
171
+ ended_at: number | null;
172
+ }
173
+
174
+ function withRuntime(
175
+ task: TaskCommandTask,
176
+ runtime: HostTask | null,
177
+ ): CommandTaskRow {
178
+ return {
179
+ id: task.id,
180
+ owner_user_id: task.ownerUserId,
181
+ host_id: task.hostId,
182
+ name: task.name,
183
+ slug: task.slug,
184
+ branch: task.branch,
185
+ status: runtime?.statusMirror ?? task.status,
186
+ global_context: task.globalContext ?? null,
187
+ reviewer_order: task.reviewerOrder
188
+ ? JSON.stringify(task.reviewerOrder)
189
+ : null,
190
+ agents: JSON.stringify(task.agents ?? []),
191
+ pr_context: null,
192
+ worktree_path: runtime?.worktreePath ?? null,
193
+ compose_project: runtime?.composeProject ?? null,
194
+ code_server_port: runtime?.codeServerPort ?? null,
195
+ preview_ports: runtime?.previewPorts ?? "[]",
196
+ locked_at: runtime?.lockedAt ?? null,
197
+ started_at: runtime?.startedAt ?? null,
198
+ ended_at: runtime?.endedAt ?? null,
199
+ };
200
+ }
201
+
202
+ function runtimeTask(taskId: string, runtime: HostTask | null): CommandTaskRow {
203
+ return {
204
+ id: taskId,
205
+ owner_user_id: "host-runtime",
206
+ host_id: "host-runtime",
207
+ name: taskId,
208
+ slug: taskId,
209
+ branch: `task/${taskId}`,
210
+ status: runtime?.statusMirror ?? "queued",
211
+ global_context: null,
212
+ reviewer_order: null,
213
+ agents: "[]",
214
+ pr_context: null,
215
+ worktree_path: runtime?.worktreePath ?? null,
216
+ compose_project: runtime?.composeProject ?? null,
217
+ code_server_port: runtime?.codeServerPort ?? null,
218
+ preview_ports: runtime?.previewPorts ?? "[]",
219
+ locked_at: runtime?.lockedAt ?? null,
220
+ started_at: runtime?.startedAt ?? null,
221
+ ended_at: runtime?.endedAt ?? null,
222
+ };
223
+ }
224
+
225
+ const schemaSql = `
226
+ CREATE TABLE IF NOT EXISTS uai_users (
227
+ id text PRIMARY KEY,
228
+ clerk_user_id text UNIQUE,
229
+ email text NOT NULL
230
+ );
231
+ CREATE TABLE IF NOT EXISTS uai_projects (
232
+ id text PRIMARY KEY,
233
+ owner_user_id text NOT NULL,
234
+ name text NOT NULL,
235
+ slug text NOT NULL,
236
+ repo_url text NOT NULL,
237
+ default_prompt text NOT NULL DEFAULT '',
238
+ preview_ports text NOT NULL DEFAULT '[]',
239
+ env text NOT NULL DEFAULT '[]',
240
+ tool_versions text,
241
+ extra text
242
+ );
243
+ CREATE TABLE IF NOT EXISTS uai_task_projects (
244
+ task_id text NOT NULL,
245
+ project_id text NOT NULL,
246
+ position integer NOT NULL DEFAULT 0,
247
+ PRIMARY KEY (task_id, project_id)
248
+ );
249
+ CREATE TABLE IF NOT EXISTS uai_tasks (
250
+ id text PRIMARY KEY,
251
+ owner_user_id text NOT NULL,
252
+ host_id text NOT NULL,
253
+ name text NOT NULL,
254
+ slug text NOT NULL,
255
+ branch text NOT NULL,
256
+ status text NOT NULL DEFAULT 'queued',
257
+ global_context text,
258
+ reviewer_order text,
259
+ agents text NOT NULL DEFAULT '[]',
260
+ pr_context text,
261
+ worktree_path text,
262
+ compose_project text,
263
+ code_server_port integer,
264
+ preview_ports text NOT NULL DEFAULT '[]',
265
+ pr_url text,
266
+ locked_at integer,
267
+ started_at integer,
268
+ ended_at integer,
269
+ updated_at integer NOT NULL DEFAULT (unixepoch() * 1000)
270
+ );
271
+ CREATE TABLE IF NOT EXISTS uai_task_events (
272
+ id text PRIMARY KEY,
273
+ task_id text NOT NULL,
274
+ ts integer NOT NULL DEFAULT (unixepoch() * 1000),
275
+ kind text NOT NULL,
276
+ payload text NOT NULL DEFAULT '{}'
277
+ );
278
+ `;
package/lib/db.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Host-local SQLite access (ADR-020).
3
+ *
4
+ * This DB contains only runtime state for tasks assigned to this host. Cloud
5
+ * metadata remains in the cloud DB and arrives over command arguments.
6
+ */
7
+
8
+ import { existsSync, mkdirSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import Database from "better-sqlite3";
13
+ import { drizzle } from "drizzle-orm/better-sqlite3";
14
+ import { migrate } from "drizzle-orm/better-sqlite3/migrator";
15
+
16
+ import { env } from "./env";
17
+ import * as schema from "../db/schema";
18
+
19
+ let cached: ReturnType<typeof drizzle> | null = null;
20
+ let migrated = false;
21
+
22
+ function migrationsFolder(): string {
23
+ return resolve(
24
+ dirname(fileURLToPath(import.meta.url)),
25
+ "..",
26
+ "db",
27
+ "migrations",
28
+ );
29
+ }
30
+
31
+ function ensureDataDir(): void {
32
+ const dir = dirname(env.hostDbPath);
33
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
34
+ }
35
+
36
+ export function migrateHostDb(): void {
37
+ if (migrated) return;
38
+ ensureDataDir();
39
+ const sqlite = new Database(env.hostDbPath);
40
+ try {
41
+ const journalMode = String(
42
+ sqlite.pragma("journal_mode = WAL", { simple: true }),
43
+ ).toLowerCase();
44
+ if (journalMode !== "wal") {
45
+ throw new Error(
46
+ `host.sqlite journal_mode must be WAL; got "${journalMode}"`,
47
+ );
48
+ }
49
+ sqlite.pragma("foreign_keys = ON");
50
+ migrate(drizzle(sqlite), { migrationsFolder: migrationsFolder() });
51
+ migrated = true;
52
+ } finally {
53
+ sqlite.close();
54
+ }
55
+ }
56
+
57
+ export function getDb(): ReturnType<typeof drizzle> {
58
+ if (cached) return cached;
59
+ migrateHostDb();
60
+ const sqlite = new Database(env.hostDbPath);
61
+ sqlite.pragma("journal_mode = WAL");
62
+ sqlite.pragma("foreign_keys = ON");
63
+ cached = drizzle(sqlite, { schema });
64
+ return cached;
65
+ }
66
+
67
+ export type Db = ReturnType<typeof getDb>;
68
+ export { schema };
package/lib/env.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Host-agent env access. Runtime state lives in host.sqlite on the host
3
+ * machine; cloud metadata arrives over the host protocol.
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import { z } from "zod";
12
+
13
+ const schema = z.object({
14
+ // Base dir for mutable host state (.env.local + the data dir). See the
15
+ // resolution of `uaiHome` below. Optional — defaults are repo-aware.
16
+ UAI_HOME: z.string().min(1).optional(),
17
+ UAI_DATA_DIR: z.string().min(1).optional(),
18
+ UAI_AGENT_DIR: z.string().min(1).optional(),
19
+ UAI_HOST_TOKEN: z.string().min(32).optional(),
20
+ UAI_HOST_BRIDGE_PORT: z.coerce.number().int().min(1).max(65535).default(8789),
21
+ UAI_CLOUD_URL: z.string().min(1).optional(),
22
+ UAI_HOST_ID: z.string().min(1).optional(),
23
+ // Host master key file (ADR-027) — 32 bytes, AES-256-GCM for the host
24
+ // secret store. Defaults to $UAI_DATA_DIR/host-master.key; generated on
25
+ // first use. Optional transitional GitHub PAT for unconnected users.
26
+ UAI_HOST_KEY_FILE: z.string().min(1).optional(),
27
+ UAI_GH_PAT_FALLBACK: z.string().min(1).optional(),
28
+ UAI_WORKSPACE_ROOT: z.string().min(1).default("~/.uai"),
29
+ NODE_ENV: z
30
+ .enum(["development", "production", "test"])
31
+ .default("development"),
32
+ });
33
+
34
+ const parsed = schema.safeParse(process.env);
35
+
36
+ if (!parsed.success) {
37
+ console.error(
38
+ "uai host-agent: invalid environment",
39
+ parsed.error.flatten().fieldErrors,
40
+ );
41
+ }
42
+
43
+ const raw = parsed.success ? parsed.data : schema.parse({});
44
+ const here = dirname(fileURLToPath(import.meta.url)); // <pkg>/lib
45
+
46
+ function expandHome(path: string): string {
47
+ if (path === "~") return homedir();
48
+ if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
49
+ return path;
50
+ }
51
+
52
+ /** Walk up from `start` until `pred` holds; null at the filesystem root. */
53
+ function findUp(start: string, pred: (dir: string) => boolean): string | null {
54
+ let dir = start;
55
+ for (;;) {
56
+ if (pred(dir)) return dir;
57
+ const parent = dirname(dir);
58
+ if (parent === dir) return null;
59
+ dir = parent;
60
+ }
61
+ }
62
+
63
+ // Monorepo root iff we're running from a checkout: a dir holding BOTH
64
+ // pnpm-workspace.yaml and a host-agent/ child. null when installed under
65
+ // node_modules — then state lives under ~/.uai, never inside the package.
66
+ const repoRoot = findUp(
67
+ here,
68
+ (d) =>
69
+ existsSync(resolve(d, "pnpm-workspace.yaml")) &&
70
+ existsSync(resolve(d, "host-agent")),
71
+ );
72
+
73
+ // UAI_HOME: where .env.local + the data dir live. Explicit UAI_HOME wins; then
74
+ // the parent of an explicit UAI_DATA_DIR; then the repo root (dev checkout);
75
+ // then ~/.uai (installed via npm). Keep this in sync with src/load-env.ts,
76
+ // which resolves the same base independently (it runs before this module).
77
+ const uaiHome = raw.UAI_HOME
78
+ ? resolve(expandHome(raw.UAI_HOME))
79
+ : raw.UAI_DATA_DIR
80
+ ? dirname(resolve(expandHome(raw.UAI_DATA_DIR)))
81
+ : (repoRoot ?? resolve(homedir(), ".uai"));
82
+
83
+ // Data dir: explicit UAI_DATA_DIR wins, kept verbatim (e.g. the live host's
84
+ // pinned <repo>/.data); else <repo>/.data in a checkout, or ~/.uai/data when
85
+ // installed.
86
+ const dataDir = raw.UAI_DATA_DIR
87
+ ? resolve(expandHome(raw.UAI_DATA_DIR))
88
+ : resolve(uaiHome, repoRoot ? ".data" : "data");
89
+
90
+ const workspaceRoot = resolve(expandHome(raw.UAI_WORKSPACE_ROOT));
91
+
92
+ export const env = {
93
+ ...raw,
94
+ uaiHome,
95
+ dataDir,
96
+ hostDbPath: resolve(dataDir, "host.sqlite"),
97
+ hostKeyPath: raw.UAI_HOST_KEY_FILE
98
+ ? resolve(uaiHome, expandHome(raw.UAI_HOST_KEY_FILE))
99
+ : resolve(dataDir, "host-master.key"),
100
+ commandDbDir: resolve(dataDir, "host-command-dbs"),
101
+ // Agent scripts ship inside the package; lib/agent.ts resolves them off the
102
+ // package root by default. Only an explicit UAI_AGENT_DIR override surfaces
103
+ // here (undefined → use the shipped scripts).
104
+ agentDir: raw.UAI_AGENT_DIR
105
+ ? resolve(uaiHome, expandHome(raw.UAI_AGENT_DIR))
106
+ : undefined,
107
+ workspaceRoot,
108
+ } as const;
109
+
110
+ export type Env = typeof env;
111
+
112
+ /** Absolute path to a project's uai-owned directory (holds its bare mirror). */
113
+ export function projectDir(projectId: string): string {
114
+ return resolve(env.workspaceRoot, "projects", projectId);
115
+ }
116
+
117
+ /** Absolute path to a project's single bare mirror (ADR-022: one repo/project). */
118
+ export function projectRepoMirror(projectId: string): string {
119
+ return resolve(projectDir(projectId), "repo.git");
120
+ }
121
+
122
+ /** Absolute path to a task's directory (top-level; a task spans 0..N projects). */
123
+ export function taskDir(taskId: string): string {
124
+ return resolve(env.workspaceRoot, "tasks", taskId);
125
+ }
126
+
127
+ /** Absolute path to a task's workspace root (parent of per-project worktrees). */
128
+ export function taskWorkspaceDir(taskId: string): string {
129
+ return resolve(taskDir(taskId), "workspace");
130
+ }
131
+
132
+ /** Absolute path to one selected project's worktree within a task. */
133
+ export function taskProjectWorktree(taskId: string, projectSlug: string): string {
134
+ return resolve(taskWorkspaceDir(taskId), projectSlug);
135
+ }
136
+
137
+ /** Absolute path to a task's ephemeral `.uai/` render dir (generated compose). */
138
+ export function taskUaiDir(taskId: string): string {
139
+ return resolve(taskDir(taskId), ".uai");
140
+ }