@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,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
|
+
}
|