taskchef 5.7.2 → 5.9.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.
@@ -0,0 +1,597 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { constants, watch } from "node:fs";
4
+ import { open, readFile, realpath, stat } from "node:fs/promises";
5
+ import http from "node:http";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ import { openWorkspaceInCodex } from "./codex-app.js";
10
+ import {
11
+ canonicalDirectory,
12
+ canonicalGitRoot,
13
+ parseTaskLogContent,
14
+ readConfig,
15
+ } from "./workspace.js";
16
+
17
+ const TASKS_FILE_NAME = "tasks.jsonl";
18
+ const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
19
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
20
+ const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
21
+ const DEFAULT_MAX_TASKS = 2_000;
22
+ const DEFAULT_MAX_EVENT_CLIENTS = 16;
23
+ const CONTENT_SECURITY_POLICY = [
24
+ "default-src 'self'",
25
+ "base-uri 'none'",
26
+ "connect-src 'self'",
27
+ "form-action 'none'",
28
+ "frame-ancestors 'none'",
29
+ "img-src 'self' data:",
30
+ "object-src 'none'",
31
+ "script-src 'self'",
32
+ "style-src 'self'",
33
+ ].join("; ");
34
+
35
+ const STATIC_FILES = new Map([
36
+ ["/", ["index.html", "text/html; charset=utf-8"]],
37
+ ["/app.js", ["app.js", "text/javascript; charset=utf-8"]],
38
+ ["/state.js", ["state.js", "text/javascript; charset=utf-8"]],
39
+ ["/styles.css", ["styles.css", "text/css; charset=utf-8"]],
40
+ ]);
41
+
42
+ export function dashboardAuthority(host, port) {
43
+ const address = host === "::1" ? "[::1]" : host;
44
+ return port === 80 ? address : `${address}:${port}`;
45
+ }
46
+
47
+ function meaningfulUpdateTime(task, observedUpdateTimes) {
48
+ return Math.max(
49
+ Date.parse(task.updatedAt ?? task.createdAt),
50
+ observedUpdateTimes.get(task.id) ?? Number.NEGATIVE_INFINITY,
51
+ );
52
+ }
53
+
54
+ export function sortTasksByMeaningfulUpdate(tasks, observedUpdateTimes = new Map()) {
55
+ return tasks
56
+ .map((task, index) => ({ task, index }))
57
+ .sort((left, right) =>
58
+ meaningfulUpdateTime(right.task, observedUpdateTimes)
59
+ - meaningfulUpdateTime(left.task, observedUpdateTimes)
60
+ || left.index - right.index)
61
+ .map(({ task }) => task);
62
+ }
63
+
64
+ function taskFingerprint(tasks) {
65
+ return JSON.stringify(tasks);
66
+ }
67
+
68
+ async function fileFingerprint(filePath) {
69
+ const details = await stat(filePath);
70
+ return statFingerprint(details);
71
+ }
72
+
73
+ function statFingerprint(details) {
74
+ return `${details.dev}:${details.ino}:${details.size}:${details.mtimeMs}`;
75
+ }
76
+
77
+ export async function readBoundedTaskLog(filePath, maximumBytes, { afterOpen = null } = {}) {
78
+ const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
79
+ const handle = await open(filePath, flags);
80
+ try {
81
+ const before = await handle.stat();
82
+ if (!before.isFile()) throw new Error("task log is not a regular file");
83
+ if (afterOpen) await afterOpen();
84
+ const chunks = [];
85
+ let total = 0;
86
+ while (total <= maximumBytes) {
87
+ const buffer = Buffer.alloc(Math.min(64 * 1024, maximumBytes + 1 - total));
88
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
89
+ if (bytesRead === 0) break;
90
+ chunks.push(buffer.subarray(0, bytesRead));
91
+ total += bytesRead;
92
+ }
93
+ if (total > maximumBytes) {
94
+ throw new Error(`task log exceeds the dashboard limit of ${maximumBytes} bytes`);
95
+ }
96
+ const after = await handle.stat();
97
+ if (statFingerprint(before) !== statFingerprint(after)) {
98
+ throw new Error("task log changed while the dashboard was reading it");
99
+ }
100
+ return {
101
+ content: Buffer.concat(chunks, total).toString("utf8"),
102
+ fingerprint: statFingerprint(after),
103
+ };
104
+ } finally {
105
+ await handle.close();
106
+ }
107
+ }
108
+
109
+ function boundedText(value, maximum, name) {
110
+ if (value !== null && value !== undefined && String(value).length > maximum) {
111
+ throw new Error(`${name} exceeds the dashboard display limit`);
112
+ }
113
+ }
114
+
115
+ function assertDashboardTaskBounds(tasks, maximumTasks) {
116
+ if (tasks.length > maximumTasks) {
117
+ throw new Error(`task log exceeds the dashboard limit of ${maximumTasks} tasks`);
118
+ }
119
+ for (const [index, task] of tasks.entries()) {
120
+ const name = `task ${index + 1}`;
121
+ boundedText(task.id, 512, `${name} ID`);
122
+ boundedText(task.title, 1_000, `${name} title`);
123
+ boundedText(task.instruction, 250_000, `${name} instruction`);
124
+ boundedText(task.summary, 2_000, `${name} summary`);
125
+ boundedText(task.threadId, 512, `${name} thread ID`);
126
+ boundedText(task.turnId, 512, `${name} turn ID`);
127
+ boundedText(task.project.name, 1_000, `${name} project name`);
128
+ boundedText(task.project.path, 8_192, `${name} project path`);
129
+ boundedText(task.project.description, 4_000, `${name} project description`);
130
+ if (task.project.githubRepos.length > 100) {
131
+ throw new Error(`${name} has too many GitHub repositories for the dashboard`);
132
+ }
133
+ for (const repository of task.project.githubRepos) {
134
+ boundedText(repository, 2_048, `${name} GitHub repository`);
135
+ }
136
+ }
137
+ }
138
+
139
+ export class DashboardMonitor extends EventEmitter {
140
+ constructor(workspace, {
141
+ debounceMs = 75,
142
+ fingerprint = fileFingerprint,
143
+ maxFileBytes = DEFAULT_MAX_FILE_BYTES,
144
+ maxTasks = DEFAULT_MAX_TASKS,
145
+ readSnapshot = null,
146
+ pollIntervalMs = 5_000,
147
+ watchDirectory = watch,
148
+ watchReconnectMs = 1_000,
149
+ } = {}) {
150
+ super();
151
+ this.workspace = workspace;
152
+ this.debounceMs = debounceMs;
153
+ this.pollIntervalMs = pollIntervalMs;
154
+ this.watchDirectory = watchDirectory;
155
+ this.watchReconnectMs = watchReconnectMs;
156
+ this.fingerprint = fingerprint;
157
+ this.maxFileBytes = maxFileBytes;
158
+ this.maxTasks = maxTasks;
159
+ this.readSnapshot = readSnapshot ?? (async () => {
160
+ const snapshot = await readBoundedTaskLog(this.tasksFile, this.maxFileBytes);
161
+ return {
162
+ fingerprint: snapshot.fingerprint,
163
+ tasks: await parseTaskLogContent(this.workspace, snapshot.content),
164
+ };
165
+ });
166
+ this.tasks = [];
167
+ this.revision = 0;
168
+ this.started = false;
169
+ this.currentFingerprint = null;
170
+ this.currentTaskFingerprint = null;
171
+ this.unhealthy = false;
172
+ this.observedUpdateTimes = new Map();
173
+ this.refreshPromise = null;
174
+ this.refreshQueued = false;
175
+ this.watcher = null;
176
+ this.debounceTimer = null;
177
+ this.pollTimer = null;
178
+ this.reconnectTimer = null;
179
+ }
180
+
181
+ snapshot() {
182
+ return {
183
+ schemaVersion: 1,
184
+ revision: this.revision,
185
+ generatedAt: new Date().toISOString(),
186
+ healthy: !this.unhealthy,
187
+ tasks: this.tasks,
188
+ };
189
+ }
190
+
191
+ async start() {
192
+ if (this.started) return this.snapshot();
193
+ this.workspace = await realpath(path.resolve(this.workspace));
194
+ this.tasksFile = path.join(this.workspace, TASKS_FILE_NAME);
195
+ await this.refresh({ force: true });
196
+ if (this.revision === 0) throw new Error("dashboard could not read a valid task log");
197
+ this.started = true;
198
+ this.startWatcher();
199
+ this.pollTimer = setInterval(() => this.refresh(), this.pollIntervalMs);
200
+ this.pollTimer.unref?.();
201
+ return this.snapshot();
202
+ }
203
+
204
+ startWatcher() {
205
+ if (!this.started || this.watcher) return;
206
+ try {
207
+ this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
208
+ if (fileName !== null && String(fileName) !== TASKS_FILE_NAME) return;
209
+ clearTimeout(this.debounceTimer);
210
+ this.debounceTimer = setTimeout(() => this.refresh({ force: true }), this.debounceMs);
211
+ this.debounceTimer.unref?.();
212
+ });
213
+ this.watcher.on("error", (error) => {
214
+ this.emit("watcherError", error);
215
+ this.watcher?.close();
216
+ this.watcher = null;
217
+ this.scheduleWatcherReconnect();
218
+ });
219
+ } catch (error) {
220
+ this.emit("watcherError", error);
221
+ this.scheduleWatcherReconnect();
222
+ }
223
+ }
224
+
225
+ scheduleWatcherReconnect() {
226
+ if (!this.started || this.reconnectTimer) return;
227
+ this.reconnectTimer = setTimeout(() => {
228
+ this.reconnectTimer = null;
229
+ this.startWatcher();
230
+ this.refresh({ force: true });
231
+ }, this.watchReconnectMs);
232
+ this.reconnectTimer.unref?.();
233
+ }
234
+
235
+ async refresh({ force = false } = {}) {
236
+ if (this.refreshPromise) {
237
+ this.refreshQueued ||= force;
238
+ return this.refreshPromise;
239
+ }
240
+ this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
241
+ this.refreshPromise = null;
242
+ if (this.refreshQueued) {
243
+ this.refreshQueued = false;
244
+ await this.refresh({ force: true });
245
+ }
246
+ });
247
+ return this.refreshPromise;
248
+ }
249
+
250
+ async refreshOnce({ force }) {
251
+ try {
252
+ const observed = await this.fingerprint(this.tasksFile);
253
+ if (!force && observed === this.currentFingerprint) return false;
254
+ const snapshot = await this.readSnapshot();
255
+ assertDashboardTaskBounds(snapshot.tasks, this.maxTasks);
256
+ const after = await this.fingerprint(this.tasksFile);
257
+ if (snapshot.fingerprint !== after) {
258
+ this.refreshQueued = true;
259
+ return false;
260
+ }
261
+ const previous = new Map(this.tasks.map((task) => [task.id, task]));
262
+ const currentIds = new Set(snapshot.tasks.map((task) => task.id));
263
+ for (const taskId of this.observedUpdateTimes.keys()) {
264
+ if (!currentIds.has(taskId)) this.observedUpdateTimes.delete(taskId);
265
+ }
266
+ const observedAt = Date.now();
267
+ for (const task of snapshot.tasks) {
268
+ const former = previous.get(task.id);
269
+ if (former && JSON.stringify(former) !== JSON.stringify(task)) {
270
+ this.observedUpdateTimes.set(task.id, observedAt);
271
+ }
272
+ }
273
+ const tasks = sortTasksByMeaningfulUpdate(snapshot.tasks, this.observedUpdateTimes);
274
+ const nextTaskFingerprint = taskFingerprint(tasks);
275
+ this.currentFingerprint = after;
276
+ if (nextTaskFingerprint === this.currentTaskFingerprint) {
277
+ if (this.unhealthy) {
278
+ this.unhealthy = false;
279
+ this.emit("snapshot", this.snapshot());
280
+ }
281
+ return false;
282
+ }
283
+ this.tasks = tasks;
284
+ this.currentTaskFingerprint = nextTaskFingerprint;
285
+ this.revision += 1;
286
+ this.unhealthy = false;
287
+ this.emit("snapshot", this.snapshot());
288
+ return true;
289
+ } catch (error) {
290
+ if (!this.unhealthy) this.emit("monitorError", error);
291
+ this.unhealthy = true;
292
+ return false;
293
+ }
294
+ }
295
+
296
+ close() {
297
+ this.started = false;
298
+ this.watcher?.close();
299
+ clearTimeout(this.debounceTimer);
300
+ clearTimeout(this.reconnectTimer);
301
+ clearInterval(this.pollTimer);
302
+ this.watcher = null;
303
+ }
304
+ }
305
+
306
+ function securityHeaders(contentType) {
307
+ return {
308
+ "Cache-Control": "no-store",
309
+ "Content-Security-Policy": CONTENT_SECURITY_POLICY,
310
+ "Content-Type": contentType,
311
+ "Cross-Origin-Opener-Policy": "same-origin",
312
+ "Referrer-Policy": "no-referrer",
313
+ "X-Content-Type-Options": "nosniff",
314
+ "X-Frame-Options": "DENY",
315
+ };
316
+ }
317
+
318
+ function sendJson(response, status, value) {
319
+ response.writeHead(status, securityHeaders("application/json; charset=utf-8"));
320
+ response.end(`${JSON.stringify(value)}\n`);
321
+ }
322
+
323
+ function ssePayload(event, value) {
324
+ return `event: ${event}\ndata: ${JSON.stringify(value)}\n\n`;
325
+ }
326
+
327
+ export function writeSseEvent(response, event, value) {
328
+ return response.write(ssePayload(event, value));
329
+ }
330
+
331
+ export function createSseClient(response, {
332
+ drainTimeoutMs = 5_000,
333
+ onClose = () => {},
334
+ } = {}) {
335
+ let blocked = false;
336
+ let closed = false;
337
+ let drainTimer = null;
338
+ let queuedPayload = null;
339
+ const drained = () => {
340
+ blocked = false;
341
+ clearTimeout(drainTimer);
342
+ drainTimer = null;
343
+ const payload = queuedPayload;
344
+ queuedPayload = null;
345
+ if (payload !== null) client.write(payload);
346
+ };
347
+ const client = {
348
+ get blocked() { return blocked; },
349
+ write(payload) {
350
+ if (closed) return false;
351
+ if (blocked) {
352
+ queuedPayload = payload;
353
+ return false;
354
+ }
355
+ const accepted = response.write(payload);
356
+ if (!accepted) {
357
+ blocked = true;
358
+ response.once("drain", drained);
359
+ drainTimer = setTimeout(() => client.close(), drainTimeoutMs);
360
+ drainTimer.unref?.();
361
+ }
362
+ return accepted;
363
+ },
364
+ close() {
365
+ if (closed) return;
366
+ closed = true;
367
+ clearTimeout(drainTimer);
368
+ queuedPayload = null;
369
+ response.off("drain", drained);
370
+ response.destroy();
371
+ onClose();
372
+ },
373
+ };
374
+ return client;
375
+ }
376
+
377
+ function requestSessionCookie(request, cookieName) {
378
+ return request.headers.cookie
379
+ ?.split(";")
380
+ .map((part) => part.trim())
381
+ .find((part) => part.startsWith(`${cookieName}=`))
382
+ ?.slice(cookieName.length + 1) ?? null;
383
+ }
384
+
385
+ function publicMonitorError() {
386
+ return {
387
+ message: "The task log is temporarily unavailable. Showing the last valid snapshot.",
388
+ };
389
+ }
390
+
391
+ export async function createDashboardServer({
392
+ workspace,
393
+ host = "127.0.0.1",
394
+ maxEventClients = DEFAULT_MAX_EVENT_CLIENTS,
395
+ port = 3210,
396
+ monitorOptions = {},
397
+ openProject = null,
398
+ } = {}) {
399
+ if (!LOOPBACK_HOSTS.has(host)) {
400
+ throw new Error("dashboard host must be a loopback address");
401
+ }
402
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
403
+ throw new Error("dashboard port must be an integer from 0 to 65535");
404
+ }
405
+ if (!Number.isInteger(maxEventClients) || maxEventClients < 0) {
406
+ throw new Error("dashboard event-client limit must be a non-negative integer");
407
+ }
408
+ const monitor = new DashboardMonitor(workspace, monitorOptions);
409
+ await monitor.start();
410
+ const clients = new Set();
411
+ const capabilityToken = randomBytes(32).toString("base64url");
412
+ let launchToken = capabilityToken;
413
+ const sessionToken = randomBytes(32).toString("base64url");
414
+ const sessionCookieName = `taskchef_session_${randomBytes(12).toString("hex")}`;
415
+ let allowedAuthority;
416
+ let allowedOrigin;
417
+
418
+ const broadcast = (event, value) => {
419
+ const payload = ssePayload(event, value);
420
+ for (const client of clients) client.write(payload);
421
+ };
422
+ const snapshotListener = (snapshot) => broadcast("snapshot", snapshot);
423
+ const errorListener = () => broadcast("dashboard-error", publicMonitorError());
424
+ monitor.on("snapshot", snapshotListener);
425
+ monitor.on("monitorError", errorListener);
426
+
427
+ const handleRequest = async (request, response) => {
428
+ const method = request.method ?? "GET";
429
+ let url;
430
+ try {
431
+ url = new URL(request.url ?? "/", "http://localhost");
432
+ } catch {
433
+ sendJson(response, 400, { message: "Malformed request target." });
434
+ return;
435
+ }
436
+ if (request.headers.host !== allowedAuthority) {
437
+ sendJson(response, 421, { message: "Misdirected request." });
438
+ return;
439
+ }
440
+ const authenticated = requestSessionCookie(request, sessionCookieName) === sessionToken;
441
+ if (
442
+ (method === "GET" || method === "HEAD")
443
+ && launchToken !== null
444
+ && url.pathname === "/"
445
+ && url.searchParams.get("token") === launchToken
446
+ ) {
447
+ launchToken = null;
448
+ response.writeHead(303, {
449
+ ...securityHeaders("text/plain; charset=utf-8"),
450
+ Location: "/",
451
+ "Set-Cookie": `${sessionCookieName}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`,
452
+ });
453
+ response.end("Opening TaskChef dashboard.\n");
454
+ return;
455
+ }
456
+ if (!authenticated) {
457
+ sendJson(response, 401, { message: "Dashboard launch capability required." });
458
+ return;
459
+ }
460
+ if (method !== "GET" && method !== "HEAD" && method !== "POST") {
461
+ response.writeHead(405, { Allow: "GET, HEAD, POST" });
462
+ response.end();
463
+ return;
464
+ }
465
+
466
+ if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
467
+ if (method === "HEAD") {
468
+ response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
469
+ response.end();
470
+ } else {
471
+ sendJson(response, 200, monitor.snapshot());
472
+ }
473
+ return;
474
+ }
475
+
476
+ if (url.pathname === "/api/events" && method === "GET") {
477
+ if (clients.size >= maxEventClients) {
478
+ sendJson(response, 503, { message: "Dashboard event-stream limit reached." });
479
+ return;
480
+ }
481
+ response.writeHead(200, {
482
+ ...securityHeaders("text/event-stream; charset=utf-8"),
483
+ Connection: "keep-alive",
484
+ });
485
+ let client;
486
+ client = createSseClient(response, {
487
+ onClose: () => {
488
+ clearInterval(client.heartbeat);
489
+ clients.delete(client);
490
+ },
491
+ });
492
+ clients.add(client);
493
+ client.write(`retry: 2000\n\n${ssePayload("snapshot", monitor.snapshot())}`);
494
+ client.heartbeat = setInterval(() => {
495
+ if (!client.blocked) client.write(": heartbeat\n\n");
496
+ }, 15_000);
497
+ const { heartbeat } = client;
498
+ heartbeat.unref?.();
499
+ request.on("close", () => client.close());
500
+ response.on("error", () => client.close());
501
+ return;
502
+ }
503
+
504
+ const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-project$/);
505
+ if (taskMatch && method === "POST") {
506
+ if (request.headers.origin !== allowedOrigin) {
507
+ sendJson(response, 403, { message: "Dashboard session validation failed." });
508
+ return;
509
+ }
510
+ const task = monitor.tasks.find((candidate) => candidate.id === taskMatch[1]);
511
+ if (!task) {
512
+ sendJson(response, 404, { message: "Task not found." });
513
+ return;
514
+ }
515
+ try {
516
+ const trustedProject = (await readConfig(monitor.workspace, { checkPaths: false })).projects
517
+ .find((project) => project.path === task.project.path);
518
+ if (!trustedProject) {
519
+ sendJson(response, 409, {
520
+ message: "This historical task no longer matches a configured project.",
521
+ });
522
+ return;
523
+ }
524
+ const canonicalProjectPath = trustedProject.isGitRepository
525
+ ? await canonicalGitRoot(trustedProject.path)
526
+ : await canonicalDirectory(trustedProject.path);
527
+ if (canonicalProjectPath !== trustedProject.path) {
528
+ sendJson(response, 409, {
529
+ message: "This configured project has moved. Repair the TaskChef project first.",
530
+ });
531
+ return;
532
+ }
533
+ if (openProject) await openProject(canonicalProjectPath);
534
+ else await openWorkspaceInCodex(canonicalProjectPath);
535
+ sendJson(response, 202, {
536
+ message: "Opened the project in Codex. Select the recorded task there.",
537
+ });
538
+ } catch {
539
+ sendJson(response, 503, {
540
+ message: "Codex could not be opened. Run codex app with the project path instead.",
541
+ });
542
+ }
543
+ return;
544
+ }
545
+
546
+ if (method === "POST") {
547
+ sendJson(response, 404, { message: "Not found." });
548
+ return;
549
+ }
550
+
551
+ const staticFile = STATIC_FILES.get(url.pathname);
552
+ if (!staticFile) {
553
+ sendJson(response, 404, { message: "Not found." });
554
+ return;
555
+ }
556
+ const [fileName, contentType] = staticFile;
557
+ const body = await readFile(path.join(STATIC_ROOT, fileName));
558
+ response.writeHead(200, securityHeaders(contentType));
559
+ response.end(method === "HEAD" ? undefined : body);
560
+ };
561
+
562
+ const server = http.createServer((request, response) => {
563
+ handleRequest(request, response).catch(() => {
564
+ if (response.headersSent) response.destroy();
565
+ else sendJson(response, 500, { message: "Dashboard request failed." });
566
+ });
567
+ });
568
+
569
+ await new Promise((resolve, reject) => {
570
+ server.once("error", reject);
571
+ server.listen(port, host, resolve);
572
+ }).catch((error) => {
573
+ monitor.close();
574
+ throw error;
575
+ });
576
+
577
+ const address = server.address();
578
+ const boundPort = typeof address === "object" && address ? address.port : port;
579
+ allowedAuthority = dashboardAuthority(host, boundPort);
580
+ allowedOrigin = `http://${allowedAuthority}`;
581
+ return {
582
+ host,
583
+ port: boundPort,
584
+ origin: allowedOrigin,
585
+ url: `${allowedOrigin}/?token=${encodeURIComponent(capabilityToken)}`,
586
+ monitor,
587
+ get eventClientCount() { return clients.size; },
588
+ async close() {
589
+ monitor.off("snapshot", snapshotListener);
590
+ monitor.off("monitorError", errorListener);
591
+ monitor.close();
592
+ for (const client of clients) client.close();
593
+ await new Promise((resolve, reject) => server.close((error) =>
594
+ error ? reject(error) : resolve()));
595
+ },
596
+ };
597
+ }