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