taskchef 7.24.1 → 7.25.1
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/.codex-plugin/plugin.json +1 -1
- package/docs/dashboard-lifecycle.md +3 -2
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/dashboard/dist/app.js +34 -34
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.tsx +27 -8
- package/src/dashboard/react/components/NotificationCenter.test.tsx +6 -1
- package/src/dashboard/react/components/TaskCard.tsx +8 -4
- package/src/dashboard/react/components/TaskUsageSummary.test.tsx +101 -0
- package/src/dashboard/react/components/TaskUsageSummary.tsx +24 -0
- package/src/dashboard/react/overlay-state.test.ts +31 -0
- package/src/dashboard/react/overlay-state.ts +23 -1
- package/src/dashboard/react/presentation.ts +48 -0
- package/src/dashboard/react/styles.css +7 -0
- package/src/dashboard/react/types.ts +3 -0
- package/src/dashboard.js +244 -15
- package/src/usage.js +3 -3
package/src/dashboard.js
CHANGED
|
@@ -26,6 +26,7 @@ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
|
26
26
|
import { taskGitHubProjection } from "./dashboard/github-links.js";
|
|
27
27
|
import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
|
|
28
28
|
import { createUsageTracker } from "./usage-tracker.js";
|
|
29
|
+
import { readUsageStore, usageStorePath } from "./usage.js";
|
|
29
30
|
import {
|
|
30
31
|
MAX_DASHBOARD_SESSION_PIDS,
|
|
31
32
|
MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS,
|
|
@@ -52,6 +53,7 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
|
|
|
52
53
|
const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
53
54
|
const DEFAULT_MAX_TASKS = 2_000;
|
|
54
55
|
const DEFAULT_MAX_EVENT_CLIENTS = 16;
|
|
56
|
+
const DEFAULT_CALCULATING_USAGE_STALE_MS = 120_000;
|
|
55
57
|
const DASHBOARD_HANDOFF_PREPARE_TTL_MS = 5_000;
|
|
56
58
|
const DASHBOARD_HANDOFF_COMMIT_GRACE_MS = 250;
|
|
57
59
|
const DASHBOARD_HANDOFF_COMMIT_RETRY_MS = 1_000;
|
|
@@ -235,6 +237,205 @@ function taskDetailProjection(task) {
|
|
|
235
237
|
return { ...task, ...taskGitHubProjection(task) };
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
function cachedUsageSummary(record) {
|
|
241
|
+
if (!record) return null;
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
generationTurnRef: record.generationTurnRef ?? null,
|
|
244
|
+
status: record.status,
|
|
245
|
+
updatedAt: record.updatedAt ?? null,
|
|
246
|
+
...(record.reason ? { reason: record.reason } : {}),
|
|
247
|
+
...(record.task ? {
|
|
248
|
+
task: Object.freeze({
|
|
249
|
+
totalTokens: record.task.totalTokens,
|
|
250
|
+
estimatedCostUsd: record.task.estimatedCostUsd ?? null,
|
|
251
|
+
sampledAt: record.task.sampledAt ?? null,
|
|
252
|
+
sourceUpdatedAt: record.task.sourceUpdatedAt ?? null,
|
|
253
|
+
}),
|
|
254
|
+
} : {}),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function cachedUsageRecord(record) {
|
|
259
|
+
return Object.freeze({
|
|
260
|
+
threadId: record.threadId,
|
|
261
|
+
...cachedUsageSummary(record),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function calculatingUsageIsStale(cached, now, staleMs) {
|
|
266
|
+
if (cached?.status !== "calculating") return false;
|
|
267
|
+
const updatedAt = Date.parse(cached.updatedAt ?? "");
|
|
268
|
+
return !Number.isFinite(updatedAt) || updatedAt + staleMs <= now;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function taskListUsageProjection(task, cached, {
|
|
272
|
+
now = Date.now(),
|
|
273
|
+
calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
|
|
274
|
+
} = {}) {
|
|
275
|
+
if (!task.threadId) {
|
|
276
|
+
return { status: "unavailable", reason: "Token usage unavailable" };
|
|
277
|
+
}
|
|
278
|
+
if (!task.latestTurn) {
|
|
279
|
+
return { status: "unavailable", reason: "Token usage unavailable" };
|
|
280
|
+
}
|
|
281
|
+
if (!cached || cached.threadId !== task.threadId) {
|
|
282
|
+
return task.status === "working"
|
|
283
|
+
? { generationTurnRef: task.latestTurn.turnRef ?? null, status: "pending" }
|
|
284
|
+
: { generationTurnRef: task.latestTurn.turnRef ?? null, status: "unavailable", reason: "Token usage unavailable" };
|
|
285
|
+
}
|
|
286
|
+
const latestTurnRef = task.latestTurn.turnRef ?? task.latestTurn.turnId ?? null;
|
|
287
|
+
if (task.status !== "working" && cached.generationTurnRef !== latestTurnRef) {
|
|
288
|
+
return {
|
|
289
|
+
generationTurnRef: latestTurnRef,
|
|
290
|
+
status: "unavailable",
|
|
291
|
+
reason: "No cached usage boundary is available for this task state.",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (calculatingUsageIsStale(cached, now, calculatingStaleMs)) {
|
|
295
|
+
return {
|
|
296
|
+
...cachedUsageSummary(cached),
|
|
297
|
+
generationTurnRef: cached.generationTurnRef ?? latestTurnRef,
|
|
298
|
+
status: "unavailable",
|
|
299
|
+
reason: "Token usage calculation was interrupted.",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return cachedUsageSummary(cached);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function optionalFileFingerprint(filePath) {
|
|
306
|
+
return fileFingerprint(filePath).catch((error) => {
|
|
307
|
+
if (error?.code === "ENOENT") return "missing";
|
|
308
|
+
throw error;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export class UsageSummaryMonitor extends EventEmitter {
|
|
313
|
+
constructor(workspace, {
|
|
314
|
+
calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
|
|
315
|
+
fingerprint = optionalFileFingerprint,
|
|
316
|
+
now = Date.now,
|
|
317
|
+
pollIntervalMs = 5_000,
|
|
318
|
+
readStore = readUsageStore,
|
|
319
|
+
watchDirectory = watch,
|
|
320
|
+
} = {}) {
|
|
321
|
+
super();
|
|
322
|
+
this.workspace = workspace;
|
|
323
|
+
this.calculatingStaleMs = calculatingStaleMs;
|
|
324
|
+
this.fingerprint = fingerprint;
|
|
325
|
+
this.now = now;
|
|
326
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
327
|
+
this.readStore = readStore;
|
|
328
|
+
this.watchDirectory = watchDirectory;
|
|
329
|
+
this.currentFingerprint = null;
|
|
330
|
+
this.records = new Map();
|
|
331
|
+
this.started = false;
|
|
332
|
+
this.refreshPromise = null;
|
|
333
|
+
this.refreshQueued = false;
|
|
334
|
+
this.watcher = null;
|
|
335
|
+
this.pollTimer = null;
|
|
336
|
+
this.debounceTimer = null;
|
|
337
|
+
this.lifecycleFingerprint = "";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async start() {
|
|
341
|
+
if (this.started) return;
|
|
342
|
+
this.workspace = await realpath(path.resolve(this.workspace));
|
|
343
|
+
this.file = usageStorePath(this.workspace);
|
|
344
|
+
await this.refresh({ force: true });
|
|
345
|
+
this.started = true;
|
|
346
|
+
try {
|
|
347
|
+
this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
|
|
348
|
+
if (fileName !== null && String(fileName) !== path.basename(this.file)) return;
|
|
349
|
+
clearTimeout(this.debounceTimer);
|
|
350
|
+
this.debounceTimer = setTimeout(() => {
|
|
351
|
+
void this.refresh({ force: true }).catch(() => {});
|
|
352
|
+
}, 75);
|
|
353
|
+
this.debounceTimer.unref?.();
|
|
354
|
+
});
|
|
355
|
+
this.watcher.on("error", () => {
|
|
356
|
+
this.watcher?.close();
|
|
357
|
+
this.watcher = null;
|
|
358
|
+
});
|
|
359
|
+
} catch {
|
|
360
|
+
this.watcher = null;
|
|
361
|
+
}
|
|
362
|
+
this.pollTimer = setInterval(() => {
|
|
363
|
+
void this.refresh().catch(() => {});
|
|
364
|
+
}, this.pollIntervalMs);
|
|
365
|
+
this.pollTimer.unref?.();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
project(task) {
|
|
369
|
+
return taskListUsageProjection(task, this.records.get(task.id) ?? null, {
|
|
370
|
+
calculatingStaleMs: this.calculatingStaleMs,
|
|
371
|
+
now: this.now(),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
updateLifecycleFingerprint() {
|
|
376
|
+
const timestamp = this.now();
|
|
377
|
+
const next = JSON.stringify([...this.records].flatMap(([taskId, record]) => (
|
|
378
|
+
calculatingUsageIsStale(record, timestamp, this.calculatingStaleMs) ? [taskId] : []
|
|
379
|
+
)));
|
|
380
|
+
const changed = next !== this.lifecycleFingerprint;
|
|
381
|
+
this.lifecycleFingerprint = next;
|
|
382
|
+
return changed;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async refresh({ force = false } = {}) {
|
|
386
|
+
if (this.refreshPromise) {
|
|
387
|
+
this.refreshQueued ||= force;
|
|
388
|
+
return this.refreshPromise;
|
|
389
|
+
}
|
|
390
|
+
this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
|
|
391
|
+
this.refreshPromise = null;
|
|
392
|
+
if (this.refreshQueued) {
|
|
393
|
+
this.refreshQueued = false;
|
|
394
|
+
await this.refresh({ force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
return this.refreshPromise;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async refreshOnce({ force }) {
|
|
401
|
+
const observed = await this.fingerprint(this.file);
|
|
402
|
+
if (!force && observed === this.currentFingerprint) {
|
|
403
|
+
const changed = this.updateLifecycleFingerprint();
|
|
404
|
+
if (changed) this.emit("change");
|
|
405
|
+
return changed;
|
|
406
|
+
}
|
|
407
|
+
let store;
|
|
408
|
+
try {
|
|
409
|
+
store = await this.readStore(this.workspace);
|
|
410
|
+
} catch {
|
|
411
|
+
store = { tasks: {} };
|
|
412
|
+
}
|
|
413
|
+
const after = await this.fingerprint(this.file);
|
|
414
|
+
if (observed !== after) {
|
|
415
|
+
this.refreshQueued = true;
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
const records = new Map(Object.entries(store.tasks ?? {}).map(
|
|
419
|
+
([taskId, record]) => [taskId, cachedUsageRecord(record)],
|
|
420
|
+
));
|
|
421
|
+
const recordsChanged = JSON.stringify([...records]) !== JSON.stringify([...this.records]);
|
|
422
|
+
this.records = records;
|
|
423
|
+
this.currentFingerprint = after;
|
|
424
|
+
const lifecycleChanged = this.updateLifecycleFingerprint();
|
|
425
|
+
const changed = recordsChanged || lifecycleChanged;
|
|
426
|
+
if (changed) this.emit("change");
|
|
427
|
+
return changed;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
close() {
|
|
431
|
+
this.started = false;
|
|
432
|
+
this.watcher?.close();
|
|
433
|
+
clearTimeout(this.debounceTimer);
|
|
434
|
+
clearInterval(this.pollTimer);
|
|
435
|
+
this.watcher = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
238
439
|
export class DashboardMonitor extends EventEmitter {
|
|
239
440
|
constructor(workspace, {
|
|
240
441
|
debounceMs = 75,
|
|
@@ -599,6 +800,7 @@ export async function createDashboardServer({
|
|
|
599
800
|
taskchefVersion = TASKCHEF_VERSION,
|
|
600
801
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
601
802
|
usageTracker = null,
|
|
803
|
+
usageSummaryMonitor = null,
|
|
602
804
|
control = null,
|
|
603
805
|
controlReplayCache = createDashboardControlReplayCache(),
|
|
604
806
|
} = {}) {
|
|
@@ -622,19 +824,30 @@ export async function createDashboardServer({
|
|
|
622
824
|
throw new Error("dashboard control requires a valid TaskChef ownership controller");
|
|
623
825
|
}
|
|
624
826
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
827
|
+
let taskUsageTracker;
|
|
828
|
+
let taskUsageSummaryMonitor;
|
|
829
|
+
let identity;
|
|
830
|
+
try {
|
|
831
|
+
await monitor.start();
|
|
832
|
+
taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
|
|
833
|
+
taskUsageSummaryMonitor = usageSummaryMonitor
|
|
834
|
+
?? new UsageSummaryMonitor(monitor.workspace);
|
|
835
|
+
await taskUsageSummaryMonitor.start();
|
|
836
|
+
identity = Object.freeze({
|
|
837
|
+
schemaVersion: 1,
|
|
838
|
+
service: "taskchef-dashboard",
|
|
839
|
+
taskchefVersion,
|
|
840
|
+
serverVersion,
|
|
841
|
+
workspace: monitor.workspace,
|
|
842
|
+
launcher,
|
|
843
|
+
});
|
|
844
|
+
if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
|
|
845
|
+
throw new Error("dashboard identity exceeds the health response limit");
|
|
846
|
+
}
|
|
847
|
+
} catch (error) {
|
|
848
|
+
taskUsageSummaryMonitor?.close();
|
|
636
849
|
monitor.close();
|
|
637
|
-
throw
|
|
850
|
+
throw error;
|
|
638
851
|
}
|
|
639
852
|
const clients = new Set();
|
|
640
853
|
const archiveRequests = new Set();
|
|
@@ -647,10 +860,22 @@ export async function createDashboardServer({
|
|
|
647
860
|
const payload = ssePayload(event, value);
|
|
648
861
|
for (const client of clients) client.write(payload);
|
|
649
862
|
};
|
|
650
|
-
const
|
|
863
|
+
const dashboardSnapshot = () => {
|
|
864
|
+
const snapshot = monitor.snapshot();
|
|
865
|
+
return {
|
|
866
|
+
...snapshot,
|
|
867
|
+
tasks: snapshot.tasks.map((task) => ({
|
|
868
|
+
...task,
|
|
869
|
+
usage: taskUsageSummaryMonitor.project(task),
|
|
870
|
+
})),
|
|
871
|
+
};
|
|
872
|
+
};
|
|
873
|
+
const snapshotListener = () => broadcast("snapshot", dashboardSnapshot());
|
|
874
|
+
const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
|
|
651
875
|
const errorListener = () => broadcast("dashboard-error", publicMonitorError());
|
|
652
876
|
monitor.on("snapshot", snapshotListener);
|
|
653
877
|
monitor.on("monitorError", errorListener);
|
|
878
|
+
taskUsageSummaryMonitor.on("change", usageSummaryListener);
|
|
654
879
|
|
|
655
880
|
const handleRequest = async (request, response) => {
|
|
656
881
|
const method = request.method ?? "GET";
|
|
@@ -956,7 +1181,7 @@ export async function createDashboardServer({
|
|
|
956
1181
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
957
1182
|
response.end();
|
|
958
1183
|
} else {
|
|
959
|
-
sendJson(response, 200,
|
|
1184
|
+
sendJson(response, 200, dashboardSnapshot());
|
|
960
1185
|
}
|
|
961
1186
|
return;
|
|
962
1187
|
}
|
|
@@ -978,7 +1203,7 @@ export async function createDashboardServer({
|
|
|
978
1203
|
},
|
|
979
1204
|
});
|
|
980
1205
|
clients.add(client);
|
|
981
|
-
client.write(`retry: 2000\n\n${ssePayload("snapshot",
|
|
1206
|
+
client.write(`retry: 2000\n\n${ssePayload("snapshot", dashboardSnapshot())}`);
|
|
982
1207
|
client.heartbeat = setInterval(() => {
|
|
983
1208
|
if (!client.blocked) client.write(": heartbeat\n\n");
|
|
984
1209
|
}, 15_000);
|
|
@@ -1203,6 +1428,7 @@ export async function createDashboardServer({
|
|
|
1203
1428
|
server.listen(port, host, resolve);
|
|
1204
1429
|
}).catch((error) => {
|
|
1205
1430
|
monitor.close();
|
|
1431
|
+
taskUsageSummaryMonitor.close();
|
|
1206
1432
|
throw error;
|
|
1207
1433
|
});
|
|
1208
1434
|
|
|
@@ -1217,11 +1443,14 @@ export async function createDashboardServer({
|
|
|
1217
1443
|
url: `${allowedOrigin}/`,
|
|
1218
1444
|
identity,
|
|
1219
1445
|
monitor,
|
|
1446
|
+
usageSummaryMonitor: taskUsageSummaryMonitor,
|
|
1220
1447
|
get eventClientCount() { return clients.size; },
|
|
1221
1448
|
async close() {
|
|
1222
1449
|
monitor.off("snapshot", snapshotListener);
|
|
1223
1450
|
monitor.off("monitorError", errorListener);
|
|
1451
|
+
taskUsageSummaryMonitor.off("change", usageSummaryListener);
|
|
1224
1452
|
monitor.close();
|
|
1453
|
+
taskUsageSummaryMonitor.close();
|
|
1225
1454
|
for (const client of clients) client.close();
|
|
1226
1455
|
const closed = new Promise((resolve, reject) => server.close((error) =>
|
|
1227
1456
|
error ? reject(error) : resolve()));
|
package/src/usage.js
CHANGED
|
@@ -589,12 +589,12 @@ export async function readCcusageThreadUsage(threadId, {
|
|
|
589
589
|
}
|
|
590
590
|
}
|
|
591
591
|
|
|
592
|
-
function
|
|
592
|
+
export function usageStorePath(workspace) {
|
|
593
593
|
return path.join(workspace, USAGE_FILE_NAME);
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
export async function readUsageStore(workspace) {
|
|
597
|
-
const filePath =
|
|
597
|
+
const filePath = usageStorePath(workspace);
|
|
598
598
|
const details = await lstat(filePath).catch((error) => {
|
|
599
599
|
if (error.code === "ENOENT") return null;
|
|
600
600
|
throw error;
|
|
@@ -649,7 +649,7 @@ export function compactUsageStore(store) {
|
|
|
649
649
|
|
|
650
650
|
export async function writeUsageStore(workspace, store) {
|
|
651
651
|
await mkdir(workspace, { recursive: true });
|
|
652
|
-
const filePath =
|
|
652
|
+
const filePath = usageStorePath(workspace);
|
|
653
653
|
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
654
654
|
const serialized = `${JSON.stringify(compactUsageStore(store), null, 2)}\n`;
|
|
655
655
|
if (Buffer.byteLength(serialized, "utf8") > MAX_USAGE_FILE_BYTES) {
|