taskchef 7.24.1 → 7.25.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/.codex-plugin/plugin.json +1 -1
- 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 +85 -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 +47 -0
- package/src/dashboard/react/styles.css +7 -0
- package/src/dashboard/react/types.ts +3 -0
- package/src/dashboard.js +243 -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,204 @@ 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
|
+
generationTurnRef: cached.generationTurnRef ?? latestTurnRef,
|
|
297
|
+
status: "unavailable",
|
|
298
|
+
reason: "Token usage calculation was interrupted.",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return cachedUsageSummary(cached);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function optionalFileFingerprint(filePath) {
|
|
305
|
+
return fileFingerprint(filePath).catch((error) => {
|
|
306
|
+
if (error?.code === "ENOENT") return "missing";
|
|
307
|
+
throw error;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export class UsageSummaryMonitor extends EventEmitter {
|
|
312
|
+
constructor(workspace, {
|
|
313
|
+
calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
|
|
314
|
+
fingerprint = optionalFileFingerprint,
|
|
315
|
+
now = Date.now,
|
|
316
|
+
pollIntervalMs = 5_000,
|
|
317
|
+
readStore = readUsageStore,
|
|
318
|
+
watchDirectory = watch,
|
|
319
|
+
} = {}) {
|
|
320
|
+
super();
|
|
321
|
+
this.workspace = workspace;
|
|
322
|
+
this.calculatingStaleMs = calculatingStaleMs;
|
|
323
|
+
this.fingerprint = fingerprint;
|
|
324
|
+
this.now = now;
|
|
325
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
326
|
+
this.readStore = readStore;
|
|
327
|
+
this.watchDirectory = watchDirectory;
|
|
328
|
+
this.currentFingerprint = null;
|
|
329
|
+
this.records = new Map();
|
|
330
|
+
this.started = false;
|
|
331
|
+
this.refreshPromise = null;
|
|
332
|
+
this.refreshQueued = false;
|
|
333
|
+
this.watcher = null;
|
|
334
|
+
this.pollTimer = null;
|
|
335
|
+
this.debounceTimer = null;
|
|
336
|
+
this.lifecycleFingerprint = "";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async start() {
|
|
340
|
+
if (this.started) return;
|
|
341
|
+
this.workspace = await realpath(path.resolve(this.workspace));
|
|
342
|
+
this.file = usageStorePath(this.workspace);
|
|
343
|
+
await this.refresh({ force: true });
|
|
344
|
+
this.started = true;
|
|
345
|
+
try {
|
|
346
|
+
this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
|
|
347
|
+
if (fileName !== null && String(fileName) !== path.basename(this.file)) return;
|
|
348
|
+
clearTimeout(this.debounceTimer);
|
|
349
|
+
this.debounceTimer = setTimeout(() => {
|
|
350
|
+
void this.refresh({ force: true }).catch(() => {});
|
|
351
|
+
}, 75);
|
|
352
|
+
this.debounceTimer.unref?.();
|
|
353
|
+
});
|
|
354
|
+
this.watcher.on("error", () => {
|
|
355
|
+
this.watcher?.close();
|
|
356
|
+
this.watcher = null;
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
this.watcher = null;
|
|
360
|
+
}
|
|
361
|
+
this.pollTimer = setInterval(() => {
|
|
362
|
+
void this.refresh().catch(() => {});
|
|
363
|
+
}, this.pollIntervalMs);
|
|
364
|
+
this.pollTimer.unref?.();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
project(task) {
|
|
368
|
+
return taskListUsageProjection(task, this.records.get(task.id) ?? null, {
|
|
369
|
+
calculatingStaleMs: this.calculatingStaleMs,
|
|
370
|
+
now: this.now(),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
updateLifecycleFingerprint() {
|
|
375
|
+
const timestamp = this.now();
|
|
376
|
+
const next = JSON.stringify([...this.records].flatMap(([taskId, record]) => (
|
|
377
|
+
calculatingUsageIsStale(record, timestamp, this.calculatingStaleMs) ? [taskId] : []
|
|
378
|
+
)));
|
|
379
|
+
const changed = next !== this.lifecycleFingerprint;
|
|
380
|
+
this.lifecycleFingerprint = next;
|
|
381
|
+
return changed;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async refresh({ force = false } = {}) {
|
|
385
|
+
if (this.refreshPromise) {
|
|
386
|
+
this.refreshQueued ||= force;
|
|
387
|
+
return this.refreshPromise;
|
|
388
|
+
}
|
|
389
|
+
this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
|
|
390
|
+
this.refreshPromise = null;
|
|
391
|
+
if (this.refreshQueued) {
|
|
392
|
+
this.refreshQueued = false;
|
|
393
|
+
await this.refresh({ force: true });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
return this.refreshPromise;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async refreshOnce({ force }) {
|
|
400
|
+
const observed = await this.fingerprint(this.file);
|
|
401
|
+
if (!force && observed === this.currentFingerprint) {
|
|
402
|
+
const changed = this.updateLifecycleFingerprint();
|
|
403
|
+
if (changed) this.emit("change");
|
|
404
|
+
return changed;
|
|
405
|
+
}
|
|
406
|
+
let store;
|
|
407
|
+
try {
|
|
408
|
+
store = await this.readStore(this.workspace);
|
|
409
|
+
} catch {
|
|
410
|
+
store = { tasks: {} };
|
|
411
|
+
}
|
|
412
|
+
const after = await this.fingerprint(this.file);
|
|
413
|
+
if (observed !== after) {
|
|
414
|
+
this.refreshQueued = true;
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
const records = new Map(Object.entries(store.tasks ?? {}).map(
|
|
418
|
+
([taskId, record]) => [taskId, cachedUsageRecord(record)],
|
|
419
|
+
));
|
|
420
|
+
const recordsChanged = JSON.stringify([...records]) !== JSON.stringify([...this.records]);
|
|
421
|
+
this.records = records;
|
|
422
|
+
this.currentFingerprint = after;
|
|
423
|
+
const lifecycleChanged = this.updateLifecycleFingerprint();
|
|
424
|
+
const changed = recordsChanged || lifecycleChanged;
|
|
425
|
+
if (changed) this.emit("change");
|
|
426
|
+
return changed;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
close() {
|
|
430
|
+
this.started = false;
|
|
431
|
+
this.watcher?.close();
|
|
432
|
+
clearTimeout(this.debounceTimer);
|
|
433
|
+
clearInterval(this.pollTimer);
|
|
434
|
+
this.watcher = null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
238
438
|
export class DashboardMonitor extends EventEmitter {
|
|
239
439
|
constructor(workspace, {
|
|
240
440
|
debounceMs = 75,
|
|
@@ -599,6 +799,7 @@ export async function createDashboardServer({
|
|
|
599
799
|
taskchefVersion = TASKCHEF_VERSION,
|
|
600
800
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
601
801
|
usageTracker = null,
|
|
802
|
+
usageSummaryMonitor = null,
|
|
602
803
|
control = null,
|
|
603
804
|
controlReplayCache = createDashboardControlReplayCache(),
|
|
604
805
|
} = {}) {
|
|
@@ -622,19 +823,30 @@ export async function createDashboardServer({
|
|
|
622
823
|
throw new Error("dashboard control requires a valid TaskChef ownership controller");
|
|
623
824
|
}
|
|
624
825
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
826
|
+
let taskUsageTracker;
|
|
827
|
+
let taskUsageSummaryMonitor;
|
|
828
|
+
let identity;
|
|
829
|
+
try {
|
|
830
|
+
await monitor.start();
|
|
831
|
+
taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
|
|
832
|
+
taskUsageSummaryMonitor = usageSummaryMonitor
|
|
833
|
+
?? new UsageSummaryMonitor(monitor.workspace);
|
|
834
|
+
await taskUsageSummaryMonitor.start();
|
|
835
|
+
identity = Object.freeze({
|
|
836
|
+
schemaVersion: 1,
|
|
837
|
+
service: "taskchef-dashboard",
|
|
838
|
+
taskchefVersion,
|
|
839
|
+
serverVersion,
|
|
840
|
+
workspace: monitor.workspace,
|
|
841
|
+
launcher,
|
|
842
|
+
});
|
|
843
|
+
if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
|
|
844
|
+
throw new Error("dashboard identity exceeds the health response limit");
|
|
845
|
+
}
|
|
846
|
+
} catch (error) {
|
|
847
|
+
taskUsageSummaryMonitor?.close();
|
|
636
848
|
monitor.close();
|
|
637
|
-
throw
|
|
849
|
+
throw error;
|
|
638
850
|
}
|
|
639
851
|
const clients = new Set();
|
|
640
852
|
const archiveRequests = new Set();
|
|
@@ -647,10 +859,22 @@ export async function createDashboardServer({
|
|
|
647
859
|
const payload = ssePayload(event, value);
|
|
648
860
|
for (const client of clients) client.write(payload);
|
|
649
861
|
};
|
|
650
|
-
const
|
|
862
|
+
const dashboardSnapshot = () => {
|
|
863
|
+
const snapshot = monitor.snapshot();
|
|
864
|
+
return {
|
|
865
|
+
...snapshot,
|
|
866
|
+
tasks: snapshot.tasks.map((task) => ({
|
|
867
|
+
...task,
|
|
868
|
+
usage: taskUsageSummaryMonitor.project(task),
|
|
869
|
+
})),
|
|
870
|
+
};
|
|
871
|
+
};
|
|
872
|
+
const snapshotListener = () => broadcast("snapshot", dashboardSnapshot());
|
|
873
|
+
const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
|
|
651
874
|
const errorListener = () => broadcast("dashboard-error", publicMonitorError());
|
|
652
875
|
monitor.on("snapshot", snapshotListener);
|
|
653
876
|
monitor.on("monitorError", errorListener);
|
|
877
|
+
taskUsageSummaryMonitor.on("change", usageSummaryListener);
|
|
654
878
|
|
|
655
879
|
const handleRequest = async (request, response) => {
|
|
656
880
|
const method = request.method ?? "GET";
|
|
@@ -956,7 +1180,7 @@ export async function createDashboardServer({
|
|
|
956
1180
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
957
1181
|
response.end();
|
|
958
1182
|
} else {
|
|
959
|
-
sendJson(response, 200,
|
|
1183
|
+
sendJson(response, 200, dashboardSnapshot());
|
|
960
1184
|
}
|
|
961
1185
|
return;
|
|
962
1186
|
}
|
|
@@ -978,7 +1202,7 @@ export async function createDashboardServer({
|
|
|
978
1202
|
},
|
|
979
1203
|
});
|
|
980
1204
|
clients.add(client);
|
|
981
|
-
client.write(`retry: 2000\n\n${ssePayload("snapshot",
|
|
1205
|
+
client.write(`retry: 2000\n\n${ssePayload("snapshot", dashboardSnapshot())}`);
|
|
982
1206
|
client.heartbeat = setInterval(() => {
|
|
983
1207
|
if (!client.blocked) client.write(": heartbeat\n\n");
|
|
984
1208
|
}, 15_000);
|
|
@@ -1203,6 +1427,7 @@ export async function createDashboardServer({
|
|
|
1203
1427
|
server.listen(port, host, resolve);
|
|
1204
1428
|
}).catch((error) => {
|
|
1205
1429
|
monitor.close();
|
|
1430
|
+
taskUsageSummaryMonitor.close();
|
|
1206
1431
|
throw error;
|
|
1207
1432
|
});
|
|
1208
1433
|
|
|
@@ -1217,11 +1442,14 @@ export async function createDashboardServer({
|
|
|
1217
1442
|
url: `${allowedOrigin}/`,
|
|
1218
1443
|
identity,
|
|
1219
1444
|
monitor,
|
|
1445
|
+
usageSummaryMonitor: taskUsageSummaryMonitor,
|
|
1220
1446
|
get eventClientCount() { return clients.size; },
|
|
1221
1447
|
async close() {
|
|
1222
1448
|
monitor.off("snapshot", snapshotListener);
|
|
1223
1449
|
monitor.off("monitorError", errorListener);
|
|
1450
|
+
taskUsageSummaryMonitor.off("change", usageSummaryListener);
|
|
1224
1451
|
monitor.close();
|
|
1452
|
+
taskUsageSummaryMonitor.close();
|
|
1225
1453
|
for (const client of clients) client.close();
|
|
1226
1454
|
const closed = new Promise((resolve, reject) => server.close((error) =>
|
|
1227
1455
|
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) {
|