@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
package/src/ui/server.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local host UI HTTP server (ADR-028, docs/host-ui.md).
|
|
3
|
+
*
|
|
4
|
+
* Bound to 127.0.0.1 ONLY — never 0.0.0.0, never the tailnet. No auth (the
|
|
5
|
+
* localhost binding is the perimeter — see ADR-028 security stance), no CORS,
|
|
6
|
+
* read-only. Serves the static UI from `host-agent/ui/` and a small JSON API
|
|
7
|
+
* under `/api/*`. Every JSON body is parsed through its zod schema before send
|
|
8
|
+
* so the API can never emit an off-contract shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
14
|
+
import {
|
|
15
|
+
createServer,
|
|
16
|
+
type IncomingMessage,
|
|
17
|
+
type Server,
|
|
18
|
+
type ServerResponse,
|
|
19
|
+
} from "node:http";
|
|
20
|
+
import type { AddressInfo } from "node:net";
|
|
21
|
+
import { hostname } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import { desc, isNull } from "drizzle-orm";
|
|
25
|
+
import type { ZodType } from "zod";
|
|
26
|
+
|
|
27
|
+
import { schema, type Db } from "../../lib/db";
|
|
28
|
+
import { parsePreviewPortRuntimes } from "../../lib/preview-ports";
|
|
29
|
+
import { getCloudState } from "../../lib/cloud-state";
|
|
30
|
+
import {
|
|
31
|
+
CloudResponse,
|
|
32
|
+
EventsResponse,
|
|
33
|
+
StatusResponse,
|
|
34
|
+
TasksResponse,
|
|
35
|
+
UsersResponse,
|
|
36
|
+
type TaskRow,
|
|
37
|
+
} from "./types";
|
|
38
|
+
|
|
39
|
+
const HOST = "127.0.0.1";
|
|
40
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
41
|
+
|
|
42
|
+
export interface UiServerOptions {
|
|
43
|
+
/** Host runtime DB (read-only here). */
|
|
44
|
+
db: Db;
|
|
45
|
+
/** Static asset directory (`host-agent/ui`). */
|
|
46
|
+
uiDir: string;
|
|
47
|
+
/** File to persist the chosen port to (`$UAI_DATA_DIR/host.port`). */
|
|
48
|
+
portFilePath: string;
|
|
49
|
+
/** First port to try (5876 or UAI_HOST_UI_PORT). 0 lets the OS pick (tests). */
|
|
50
|
+
startPort: number;
|
|
51
|
+
// Facts for /api/status:
|
|
52
|
+
version: string;
|
|
53
|
+
cloudUrl: string;
|
|
54
|
+
hostId: string;
|
|
55
|
+
logPath: string;
|
|
56
|
+
/** Best-effort container memory by compose project; null on failure. */
|
|
57
|
+
taskMemory?: (composeProject: string) => Promise<number | null>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UiServerHandle {
|
|
61
|
+
server: Server;
|
|
62
|
+
port: number;
|
|
63
|
+
close: () => Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Start the local UI server, binding to 127.0.0.1 with port fallback. */
|
|
67
|
+
export async function startUiServer(
|
|
68
|
+
opts: UiServerOptions,
|
|
69
|
+
): Promise<UiServerHandle> {
|
|
70
|
+
const server = createServer((req, res) => {
|
|
71
|
+
void handle(req, res, opts);
|
|
72
|
+
});
|
|
73
|
+
const port = await listenWithFallback(server, opts.startPort);
|
|
74
|
+
await writeFile(opts.portFilePath, `${port}\n`, "utf8");
|
|
75
|
+
return {
|
|
76
|
+
server,
|
|
77
|
+
port,
|
|
78
|
+
close: () =>
|
|
79
|
+
new Promise<void>((resolve) => server.close(() => resolve())),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function listenWithFallback(
|
|
84
|
+
server: Server,
|
|
85
|
+
startPort: number,
|
|
86
|
+
): Promise<number> {
|
|
87
|
+
let lastErr: NodeJS.ErrnoException | null = null;
|
|
88
|
+
for (let i = 0; i < MAX_PORT_ATTEMPTS; i += 1) {
|
|
89
|
+
const port = startPort === 0 ? 0 : startPort + i;
|
|
90
|
+
try {
|
|
91
|
+
await new Promise<void>((resolve, reject) => {
|
|
92
|
+
const onError = (err: NodeJS.ErrnoException): void => {
|
|
93
|
+
server.removeListener("listening", onListening);
|
|
94
|
+
reject(err);
|
|
95
|
+
};
|
|
96
|
+
const onListening = (): void => {
|
|
97
|
+
server.removeListener("error", onError);
|
|
98
|
+
resolve();
|
|
99
|
+
};
|
|
100
|
+
server.once("error", onError);
|
|
101
|
+
server.once("listening", onListening);
|
|
102
|
+
// 127.0.0.1 ONLY. Binding to 0.0.0.0 here would expose the UI off-box.
|
|
103
|
+
server.listen(port, HOST);
|
|
104
|
+
});
|
|
105
|
+
return (server.address() as AddressInfo).port;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
lastErr = err as NodeJS.ErrnoException;
|
|
108
|
+
if (lastErr.code === "EADDRINUSE" && startPort !== 0) continue;
|
|
109
|
+
throw lastErr;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw new Error(
|
|
113
|
+
`no free UI port in [${startPort}, ${startPort + MAX_PORT_ATTEMPTS - 1}]` +
|
|
114
|
+
(lastErr ? `: ${lastErr.message}` : ""),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function handle(
|
|
119
|
+
req: IncomingMessage,
|
|
120
|
+
res: ServerResponse,
|
|
121
|
+
opts: UiServerOptions,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
124
|
+
try {
|
|
125
|
+
if (req.method !== "GET") {
|
|
126
|
+
return sendError(res, 405, "method not allowed");
|
|
127
|
+
}
|
|
128
|
+
switch (path) {
|
|
129
|
+
case "/api/status":
|
|
130
|
+
return sendJson(res, StatusResponse, {
|
|
131
|
+
pid: process.pid,
|
|
132
|
+
uptime: process.uptime(),
|
|
133
|
+
version: opts.version,
|
|
134
|
+
logPath: opts.logPath,
|
|
135
|
+
cloudUrl: opts.cloudUrl,
|
|
136
|
+
hostName: hostname(),
|
|
137
|
+
hostId: opts.hostId,
|
|
138
|
+
});
|
|
139
|
+
case "/api/cloud":
|
|
140
|
+
return sendJson(res, CloudResponse, getCloudState());
|
|
141
|
+
case "/api/tasks":
|
|
142
|
+
return sendJson(res, TasksResponse, await tasksBody(opts));
|
|
143
|
+
case "/api/events":
|
|
144
|
+
return sendJson(res, EventsResponse, eventsBody(opts));
|
|
145
|
+
case "/api/users":
|
|
146
|
+
return sendJson(res, UsersResponse, usersBody(opts));
|
|
147
|
+
}
|
|
148
|
+
if (path.startsWith("/api/")) {
|
|
149
|
+
return sendError(res, 404, `no such endpoint: ${path}`);
|
|
150
|
+
}
|
|
151
|
+
return await serveStatic(res, opts.uiDir, path);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return sendError(res, 500, err instanceof Error ? err.message : "internal error");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- handlers ---------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async function tasksBody(opts: UiServerOptions): Promise<TasksResponse> {
|
|
160
|
+
const rows = opts.db
|
|
161
|
+
.select()
|
|
162
|
+
.from(schema.hostTasks)
|
|
163
|
+
.where(isNull(schema.hostTasks.endedAt))
|
|
164
|
+
.all();
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const tasks: TaskRow[] = await Promise.all(
|
|
167
|
+
rows.map(async (r) => ({
|
|
168
|
+
taskId: r.taskId,
|
|
169
|
+
status: r.statusMirror,
|
|
170
|
+
ownerEmail: r.ownerEmail,
|
|
171
|
+
projectSlugs: parseSlugs(r.projectSlugs),
|
|
172
|
+
startedAt: r.startedAt,
|
|
173
|
+
ageMs: r.startedAt != null ? now - r.startedAt : null,
|
|
174
|
+
composeProject: r.composeProject,
|
|
175
|
+
codeServerPort: r.codeServerPort,
|
|
176
|
+
previewPorts: parsePreviewPortRuntimes(r.previewPorts).map((p) => ({
|
|
177
|
+
name: p.name,
|
|
178
|
+
hostPort: p.hostPort ?? null,
|
|
179
|
+
})),
|
|
180
|
+
memoryBytes:
|
|
181
|
+
r.composeProject && opts.taskMemory
|
|
182
|
+
? await opts.taskMemory(r.composeProject)
|
|
183
|
+
: null,
|
|
184
|
+
})),
|
|
185
|
+
);
|
|
186
|
+
return { tasks };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function eventsBody(opts: UiServerOptions): EventsResponse {
|
|
190
|
+
const rows = opts.db
|
|
191
|
+
.select()
|
|
192
|
+
.from(schema.hostEvents)
|
|
193
|
+
.orderBy(desc(schema.hostEvents.ts))
|
|
194
|
+
.limit(50)
|
|
195
|
+
.all();
|
|
196
|
+
return {
|
|
197
|
+
events: rows
|
|
198
|
+
.filter((r) => isKnownEventKind(r.kind))
|
|
199
|
+
.map((r) => ({
|
|
200
|
+
id: r.id,
|
|
201
|
+
taskId: r.taskId,
|
|
202
|
+
kind: r.kind as EventsResponse["events"][number]["kind"],
|
|
203
|
+
ts: r.ts,
|
|
204
|
+
detail: r.detail,
|
|
205
|
+
})),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function usersBody(opts: UiServerOptions): UsersResponse {
|
|
210
|
+
const rows = opts.db
|
|
211
|
+
.select()
|
|
212
|
+
.from(schema.hostTasks)
|
|
213
|
+
.where(isNull(schema.hostTasks.endedAt))
|
|
214
|
+
.all();
|
|
215
|
+
const byUser = new Map<string, { email: string | null; count: number }>();
|
|
216
|
+
for (const r of rows) {
|
|
217
|
+
if (!r.ownerUserId) continue;
|
|
218
|
+
const cur = byUser.get(r.ownerUserId) ?? { email: r.ownerEmail, count: 0 };
|
|
219
|
+
cur.count += 1;
|
|
220
|
+
if (!cur.email && r.ownerEmail) cur.email = r.ownerEmail;
|
|
221
|
+
byUser.set(r.ownerUserId, cur);
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
users: [...byUser].map(([userId, v]) => ({
|
|
225
|
+
userId,
|
|
226
|
+
email: v.email,
|
|
227
|
+
activeTasks: v.count,
|
|
228
|
+
})),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- static -----------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
const STATIC_FILES: Record<string, string> = {
|
|
235
|
+
"/": "index.html",
|
|
236
|
+
"/index.html": "index.html",
|
|
237
|
+
"/style.css": "style.css",
|
|
238
|
+
"/app.js": "app.js",
|
|
239
|
+
"/uai-logo-black.svg": "uai-logo-black.svg",
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
async function serveStatic(
|
|
243
|
+
res: ServerResponse,
|
|
244
|
+
uiDir: string,
|
|
245
|
+
path: string,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
const file = STATIC_FILES[path];
|
|
248
|
+
if (!file) return sendError(res, 404, `not found: ${path}`);
|
|
249
|
+
const full = join(uiDir, file);
|
|
250
|
+
if (!existsSync(full)) return sendError(res, 404, `not found: ${path}`);
|
|
251
|
+
const body = await readFile(full);
|
|
252
|
+
res.writeHead(200, { "content-type": contentType(file) });
|
|
253
|
+
res.end(body);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function contentType(file: string): string {
|
|
257
|
+
if (file.endsWith(".html")) return "text/html; charset=utf-8";
|
|
258
|
+
if (file.endsWith(".css")) return "text/css; charset=utf-8";
|
|
259
|
+
if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
260
|
+
if (file.endsWith(".svg")) return "image/svg+xml";
|
|
261
|
+
return "application/octet-stream";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- response helpers -------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function sendJson<T>(res: ServerResponse, shape: ZodType<T>, body: T): void {
|
|
267
|
+
// Parse before send: the API can never emit an off-contract body.
|
|
268
|
+
const validated = shape.parse(body);
|
|
269
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
270
|
+
res.end(JSON.stringify(validated));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sendError(res: ServerResponse, status: number, message: string): void {
|
|
274
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
275
|
+
res.end(JSON.stringify({ error: message }));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- misc -------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
function parseSlugs(raw: string): string[] {
|
|
281
|
+
try {
|
|
282
|
+
const parsed: unknown = JSON.parse(raw);
|
|
283
|
+
return Array.isArray(parsed)
|
|
284
|
+
? parsed.filter((s): s is string => typeof s === "string")
|
|
285
|
+
: [];
|
|
286
|
+
} catch {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isKnownEventKind(kind: string): boolean {
|
|
292
|
+
return (
|
|
293
|
+
kind === "task.created" ||
|
|
294
|
+
kind === "task.started" ||
|
|
295
|
+
kind === "task.ended" ||
|
|
296
|
+
kind === "task.ship"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Best-effort container memory for a task's compose project via `docker stats`.
|
|
302
|
+
* Returns null on any failure (docker down, container gone, parse miss).
|
|
303
|
+
*/
|
|
304
|
+
export async function dockerMemoryBytes(
|
|
305
|
+
composeProject: string,
|
|
306
|
+
): Promise<number | null> {
|
|
307
|
+
try {
|
|
308
|
+
const res = spawnSync(
|
|
309
|
+
"docker",
|
|
310
|
+
[
|
|
311
|
+
"stats",
|
|
312
|
+
"--no-stream",
|
|
313
|
+
"--format",
|
|
314
|
+
"{{.MemUsage}}",
|
|
315
|
+
`${composeProject}-app-1`,
|
|
316
|
+
],
|
|
317
|
+
{ encoding: "utf8", timeout: 3_000 },
|
|
318
|
+
);
|
|
319
|
+
if (res.status !== 0 || !res.stdout) return null;
|
|
320
|
+
const used = res.stdout.trim().split("/")[0]?.trim();
|
|
321
|
+
return used ? parseMemUsage(used) : null;
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseMemUsage(text: string): number | null {
|
|
328
|
+
const m = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(text);
|
|
329
|
+
if (!m || !m[1] || !m[2]) return null;
|
|
330
|
+
const value = Number(m[1]);
|
|
331
|
+
const unit = m[2].toLowerCase();
|
|
332
|
+
const scale: Record<string, number> = {
|
|
333
|
+
b: 1,
|
|
334
|
+
kb: 1e3,
|
|
335
|
+
mb: 1e6,
|
|
336
|
+
gb: 1e9,
|
|
337
|
+
kib: 1024,
|
|
338
|
+
mib: 1024 ** 2,
|
|
339
|
+
gib: 1024 ** 3,
|
|
340
|
+
};
|
|
341
|
+
const factor = scale[unit];
|
|
342
|
+
return factor ? Math.round(value * factor) : null;
|
|
343
|
+
}
|
package/src/ui/types.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schemas for every local JSON API response (ADR-028, docs/host-ui.md).
|
|
3
|
+
*
|
|
4
|
+
* The server parses each response body through these before sending, so the
|
|
5
|
+
* API can never emit an off-contract shape. This file is the single source of
|
|
6
|
+
* truth for the wire shapes; the static UI assumes them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// GET /api/status — process + config facts.
|
|
12
|
+
export const StatusResponse = z.object({
|
|
13
|
+
pid: z.number().int(),
|
|
14
|
+
uptime: z.number(), // seconds, process.uptime()
|
|
15
|
+
version: z.string(), // @runuai/host package version
|
|
16
|
+
logPath: z.string(), // service log file path (per-OS)
|
|
17
|
+
cloudUrl: z.string(), // UAI_CLOUD_URL
|
|
18
|
+
hostName: z.string(), // os.hostname()
|
|
19
|
+
hostId: z.string(), // UAI_HOST_ID
|
|
20
|
+
});
|
|
21
|
+
export type StatusResponse = z.infer<typeof StatusResponse>;
|
|
22
|
+
|
|
23
|
+
// GET /api/cloud — live WSS connection state (in-memory, from the bridge).
|
|
24
|
+
export const CloudResponse = z.object({
|
|
25
|
+
state: z.enum(["connected", "reconnecting", "disconnected"]),
|
|
26
|
+
lastConnectedAt: z.number().nullable(), // epoch ms
|
|
27
|
+
reconnectCount: z.number().int(),
|
|
28
|
+
lastError: z.string().nullable(),
|
|
29
|
+
});
|
|
30
|
+
export type CloudResponse = z.infer<typeof CloudResponse>;
|
|
31
|
+
|
|
32
|
+
// One active task. Runtime fields come from host.sqlite; owner/slugs are
|
|
33
|
+
// persisted at task-up; memory is best-effort docker.
|
|
34
|
+
export const TaskRow = z.object({
|
|
35
|
+
taskId: z.string(),
|
|
36
|
+
status: z.string().nullable(), // statusMirror
|
|
37
|
+
ownerEmail: z.string().nullable(),
|
|
38
|
+
projectSlugs: z.array(z.string()),
|
|
39
|
+
startedAt: z.number().nullable(), // epoch ms
|
|
40
|
+
ageMs: z.number().nullable(), // now - startedAt
|
|
41
|
+
composeProject: z.string().nullable(),
|
|
42
|
+
codeServerPort: z.number().int().nullable(),
|
|
43
|
+
previewPorts: z.array(
|
|
44
|
+
z.object({ name: z.string(), hostPort: z.number().int().nullable() }),
|
|
45
|
+
),
|
|
46
|
+
memoryBytes: z.number().nullable(), // docker stats, null on failure
|
|
47
|
+
});
|
|
48
|
+
export type TaskRow = z.infer<typeof TaskRow>;
|
|
49
|
+
|
|
50
|
+
export const TasksResponse = z.object({ tasks: z.array(TaskRow) });
|
|
51
|
+
export type TasksResponse = z.infer<typeof TasksResponse>;
|
|
52
|
+
|
|
53
|
+
// GET /api/events — last 50 host-recorded lifecycle events, newest first.
|
|
54
|
+
export const EventRow = z.object({
|
|
55
|
+
id: z.string(),
|
|
56
|
+
taskId: z.string(),
|
|
57
|
+
kind: z.enum(["task.created", "task.started", "task.ended", "task.ship"]),
|
|
58
|
+
ts: z.number(), // epoch ms
|
|
59
|
+
detail: z.string().nullable(),
|
|
60
|
+
});
|
|
61
|
+
export type EventRow = z.infer<typeof EventRow>;
|
|
62
|
+
|
|
63
|
+
export const EventsResponse = z.object({ events: z.array(EventRow) });
|
|
64
|
+
export type EventsResponse = z.infer<typeof EventsResponse>;
|
|
65
|
+
|
|
66
|
+
// GET /api/users — distinct owners across active tasks. v1: the single operator.
|
|
67
|
+
export const UserRow = z.object({
|
|
68
|
+
userId: z.string(),
|
|
69
|
+
email: z.string().nullable(),
|
|
70
|
+
activeTasks: z.number().int(),
|
|
71
|
+
});
|
|
72
|
+
export type UserRow = z.infer<typeof UserRow>;
|
|
73
|
+
|
|
74
|
+
export const UsersResponse = z.object({ users: z.array(UserRow) });
|
|
75
|
+
export type UsersResponse = z.infer<typeof UsersResponse>;
|
|
76
|
+
|
|
77
|
+
export const ErrorResponse = z.object({ error: z.string() });
|
|
78
|
+
export type ErrorResponse = z.infer<typeof ErrorResponse>;
|
package/ui/app.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/* Local host UI (ADR-028). Vanilla JS, no framework, no build step.
|
|
2
|
+
Polls the read-only JSON API every 2s and renders four panels. */
|
|
3
|
+
|
|
4
|
+
"use strict";
|
|
5
|
+
|
|
6
|
+
const $ = (id) => document.getElementById(id);
|
|
7
|
+
const expanded = new Set(); // task ids whose events are expanded (persist across polls)
|
|
8
|
+
let latestTasks = [];
|
|
9
|
+
let latestEvents = [];
|
|
10
|
+
|
|
11
|
+
const KIND_CLASS = {
|
|
12
|
+
"task.created": "created",
|
|
13
|
+
"task.started": "started",
|
|
14
|
+
"task.ended": "ended",
|
|
15
|
+
"task.ship": "ship",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function getJSON(path) {
|
|
19
|
+
const res = await fetch(path, { cache: "no-store" });
|
|
20
|
+
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function poll() {
|
|
25
|
+
try {
|
|
26
|
+
const [status, cloud, tasks, events] = await Promise.all([
|
|
27
|
+
getJSON("/api/status"),
|
|
28
|
+
getJSON("/api/cloud"),
|
|
29
|
+
getJSON("/api/tasks"),
|
|
30
|
+
getJSON("/api/events"),
|
|
31
|
+
]);
|
|
32
|
+
renderStatus(status);
|
|
33
|
+
renderCloud(cloud);
|
|
34
|
+
renderEvents(events.events);
|
|
35
|
+
renderTasks(tasks.tasks, events.events);
|
|
36
|
+
$("foot-note").textContent =
|
|
37
|
+
`read-only · 127.0.0.1 · updated ${new Date().toLocaleTimeString()}`;
|
|
38
|
+
} catch {
|
|
39
|
+
setBadge("bad", "unreachable");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- service ----------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function renderStatus(s) {
|
|
46
|
+
$("host-name").textContent = s.hostName;
|
|
47
|
+
$("host-sub").textContent = `host monitor · v${s.version}`;
|
|
48
|
+
const kv = $("service-kv");
|
|
49
|
+
kv.replaceChildren();
|
|
50
|
+
addKv(kv, "PID", String(s.pid));
|
|
51
|
+
addKv(kv, "Uptime", fmtDuration(s.uptime * 1000));
|
|
52
|
+
addKv(kv, "Cloud", s.cloudUrl);
|
|
53
|
+
addKv(kv, "Host ID", s.hostId);
|
|
54
|
+
addKv(kv, "Log", s.logPath);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addKv(dl, key, value) {
|
|
58
|
+
const dt = document.createElement("dt");
|
|
59
|
+
dt.textContent = key;
|
|
60
|
+
const dd = document.createElement("dd");
|
|
61
|
+
dd.textContent = value;
|
|
62
|
+
dl.append(dt, dd);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- connection badge -------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function renderCloud(c) {
|
|
68
|
+
const map = {
|
|
69
|
+
connected: ["ok", "connected"],
|
|
70
|
+
reconnecting: ["warn", "reconnecting"],
|
|
71
|
+
disconnected: ["bad", "disconnected"],
|
|
72
|
+
};
|
|
73
|
+
const [cls, label] = map[c.state] || ["bad", c.state];
|
|
74
|
+
setBadge(cls, label);
|
|
75
|
+
|
|
76
|
+
const d = $("badge-detail");
|
|
77
|
+
d.replaceChildren();
|
|
78
|
+
d.append(detailRow("Last connected", c.lastConnectedAt ? fmtTime(c.lastConnectedAt) : "never"));
|
|
79
|
+
d.append(detailRow("Reconnects", String(c.reconnectCount)));
|
|
80
|
+
if (c.lastError) {
|
|
81
|
+
const e = document.createElement("div");
|
|
82
|
+
e.className = "err";
|
|
83
|
+
e.textContent = c.lastError;
|
|
84
|
+
d.append(e);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setBadge(cls, label) {
|
|
89
|
+
$("badge-dot").className = "dot " + cls;
|
|
90
|
+
$("badge-label").textContent = label;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function detailRow(key, value) {
|
|
94
|
+
const row = document.createElement("div");
|
|
95
|
+
row.className = "row";
|
|
96
|
+
const k = document.createElement("span");
|
|
97
|
+
k.textContent = key;
|
|
98
|
+
const v = document.createElement("span");
|
|
99
|
+
v.textContent = value;
|
|
100
|
+
row.append(k, v);
|
|
101
|
+
return row;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- tasks ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function renderTasks(tasks, events) {
|
|
107
|
+
latestTasks = tasks;
|
|
108
|
+
latestEvents = events;
|
|
109
|
+
const wrap = $("tasks");
|
|
110
|
+
$("tasks-count").textContent = tasks.length ? `(${tasks.length})` : "";
|
|
111
|
+
$("tasks-empty").hidden = tasks.length > 0;
|
|
112
|
+
wrap.querySelectorAll(".task").forEach((n) => n.remove());
|
|
113
|
+
for (const t of tasks) wrap.append(taskNode(t, events));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function taskNode(t, events) {
|
|
117
|
+
const open = expanded.has(t.taskId);
|
|
118
|
+
const node = document.createElement("div");
|
|
119
|
+
node.className = "task" + (open ? " open" : "");
|
|
120
|
+
|
|
121
|
+
const head = document.createElement("div");
|
|
122
|
+
head.className = "task-head";
|
|
123
|
+
head.addEventListener("click", () => {
|
|
124
|
+
if (expanded.has(t.taskId)) expanded.delete(t.taskId);
|
|
125
|
+
else expanded.add(t.taskId);
|
|
126
|
+
renderTasks(latestTasks, latestEvents);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const main = document.createElement("div");
|
|
130
|
+
main.className = "task-main";
|
|
131
|
+
|
|
132
|
+
const title = document.createElement("div");
|
|
133
|
+
title.className = "task-title";
|
|
134
|
+
const owner = document.createElement("span");
|
|
135
|
+
owner.className = "task-owner";
|
|
136
|
+
owner.textContent = t.ownerEmail || "unknown owner";
|
|
137
|
+
title.append(owner);
|
|
138
|
+
for (const slug of t.projectSlugs) {
|
|
139
|
+
const s = document.createElement("span");
|
|
140
|
+
s.className = "slug";
|
|
141
|
+
s.textContent = slug;
|
|
142
|
+
title.append(s);
|
|
143
|
+
}
|
|
144
|
+
if (t.projectSlugs.length === 0) {
|
|
145
|
+
const s = document.createElement("span");
|
|
146
|
+
s.className = "slug";
|
|
147
|
+
s.textContent = "scratchpad";
|
|
148
|
+
title.append(s);
|
|
149
|
+
}
|
|
150
|
+
main.append(title);
|
|
151
|
+
|
|
152
|
+
const meta = document.createElement("div");
|
|
153
|
+
meta.className = "task-meta";
|
|
154
|
+
const bits = [
|
|
155
|
+
t.status || "—",
|
|
156
|
+
t.ageMs != null ? `up ${fmtDuration(t.ageMs)}` : null,
|
|
157
|
+
t.memoryBytes != null ? fmtBytes(t.memoryBytes) : null,
|
|
158
|
+
].filter(Boolean);
|
|
159
|
+
meta.append(document.createTextNode(bits.join(" · ") + " "));
|
|
160
|
+
const id = document.createElement("span");
|
|
161
|
+
id.className = "task-id";
|
|
162
|
+
id.textContent = t.taskId;
|
|
163
|
+
meta.append(id);
|
|
164
|
+
main.append(meta);
|
|
165
|
+
|
|
166
|
+
const chev = document.createElement("span");
|
|
167
|
+
chev.className = "chev";
|
|
168
|
+
chev.textContent = "›";
|
|
169
|
+
|
|
170
|
+
head.append(main, chev);
|
|
171
|
+
node.append(head);
|
|
172
|
+
|
|
173
|
+
if (open) {
|
|
174
|
+
const ev = document.createElement("div");
|
|
175
|
+
ev.className = "task-events";
|
|
176
|
+
const list = document.createElement("ol");
|
|
177
|
+
const mine = events.filter((e) => e.taskId === t.taskId);
|
|
178
|
+
if (mine.length === 0) {
|
|
179
|
+
const li = document.createElement("li");
|
|
180
|
+
li.className = "empty";
|
|
181
|
+
li.textContent = "No events for this task.";
|
|
182
|
+
list.append(li);
|
|
183
|
+
} else {
|
|
184
|
+
for (const e of mine) {
|
|
185
|
+
const li = document.createElement("li");
|
|
186
|
+
li.textContent = `${e.kind} — ${fmtTime(e.ts)}${e.detail ? " · " + e.detail : ""}`;
|
|
187
|
+
list.append(li);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
ev.append(list);
|
|
191
|
+
node.append(ev);
|
|
192
|
+
}
|
|
193
|
+
return node;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- events -----------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
function renderEvents(events) {
|
|
199
|
+
latestEvents = events;
|
|
200
|
+
const ol = $("events");
|
|
201
|
+
ol.querySelectorAll("li:not(.empty)").forEach((n) => n.remove());
|
|
202
|
+
$("events-empty").hidden = events.length > 0;
|
|
203
|
+
for (const e of events) {
|
|
204
|
+
const li = document.createElement("li");
|
|
205
|
+
const kind = document.createElement("span");
|
|
206
|
+
kind.className = "ev-kind " + (KIND_CLASS[e.kind] || "");
|
|
207
|
+
kind.textContent = e.kind;
|
|
208
|
+
const time = document.createElement("span");
|
|
209
|
+
time.className = "ev-time";
|
|
210
|
+
time.textContent = fmtAgo(e.ts);
|
|
211
|
+
const task = document.createElement("span");
|
|
212
|
+
task.className = "ev-task";
|
|
213
|
+
task.textContent = e.taskId;
|
|
214
|
+
li.append(kind, time, task);
|
|
215
|
+
ol.append(li);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- formatting -------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
function fmtDuration(ms) {
|
|
222
|
+
const s = Math.floor(ms / 1000);
|
|
223
|
+
if (s < 60) return `${s}s`;
|
|
224
|
+
const m = Math.floor(s / 60);
|
|
225
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
226
|
+
const h = Math.floor(m / 60);
|
|
227
|
+
if (h < 24) return `${h}h ${m % 60}m`;
|
|
228
|
+
const d = Math.floor(h / 24);
|
|
229
|
+
return `${d}d ${h % 24}h`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function fmtAgo(epochMs) {
|
|
233
|
+
const diff = Date.now() - epochMs;
|
|
234
|
+
if (diff < 60_000) return "just now";
|
|
235
|
+
return fmtDuration(diff) + " ago";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fmtTime(epochMs) {
|
|
239
|
+
return new Date(epochMs).toLocaleString();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fmtBytes(n) {
|
|
243
|
+
if (n < 1024) return `${n} B`;
|
|
244
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
245
|
+
let v = n / 1024;
|
|
246
|
+
let i = 0;
|
|
247
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
248
|
+
v /= 1024;
|
|
249
|
+
i += 1;
|
|
250
|
+
}
|
|
251
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- wire up ----------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
$("badge").addEventListener("click", () => {
|
|
257
|
+
const detail = $("badge-detail");
|
|
258
|
+
const show = detail.hidden;
|
|
259
|
+
detail.hidden = !show;
|
|
260
|
+
$("badge").setAttribute("aria-expanded", String(show));
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
poll();
|
|
264
|
+
setInterval(poll, 2000);
|