taskchef 7.28.1 → 8.0.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.
@@ -109,6 +109,7 @@ export function DashboardApp({
109
109
  return () => window.removeEventListener('hashchange', update);
110
110
  }, []);
111
111
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
112
+ const [projectIndex, setProjectIndex] = useState<DashboardSnapshot["projectIndex"]>();
112
113
  const [connected, setConnected] = useState(false);
113
114
  const [message, setMessage] = useState<string | null>(null);
114
115
  const [version, setVersion] = useState<string | null>(null);
@@ -138,6 +139,7 @@ export function DashboardApp({
138
139
 
139
140
  const applySnapshot = useCallback((snapshot: DashboardSnapshot) => {
140
141
  setTasks(snapshot.tasks);
142
+ setProjectIndex(snapshot.projectIndex);
141
143
  setNotificationState((current) => {
142
144
  const reconciled = reconcileNotifications(current, snapshot.tasks);
143
145
  return {
@@ -325,6 +327,20 @@ export function DashboardApp({
325
327
 
326
328
  <AppShell.Main>
327
329
  <Container className="taskchef-main" pb={80} size={1080}>
330
+ {projectIndex?.status === "unavailable" && (
331
+ <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Project index unavailable">
332
+ Task history is still visible. Verify the index and inspect backups before making project changes.
333
+ </Alert>
334
+ )}
335
+ {projectIndex?.status === "available" && projectIndex.missingProjects.length > 0 && (
336
+ <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Historical projects are missing from the current index">
337
+ {projectIndex.missingProjects.map((item) => (
338
+ <Text key={item.path} size="sm">
339
+ {item.snapshotNames.join(", ")}: {item.taskCount} task(s). Run <code>taskchef backup list --json</code> and preview any restore; the dashboard never restores automatically.
340
+ </Text>
341
+ ))}
342
+ </Alert>
343
+ )}
328
344
  {settings ? <Stack gap="md"><Button component="a" href="#" variant="subtle" size="sm" leftSection={<IconArrowLeft size={16} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
329
345
  <Paper className="taskchef-toolbar" p="sm" radius="md" withBorder>
330
346
  <Stack gap="sm">
@@ -19,6 +19,20 @@ vi.mock('@mantine/core', async () => {
19
19
 
20
20
  afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
21
21
 
22
+ test('shows the version reported by the active usage provider', async () => {
23
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
24
+ ok: true,
25
+ json: async () => ({
26
+ profiles: [],
27
+ usageProvider: { provider: 'ccusage', status: 'available', version: '20.0.21' },
28
+ }),
29
+ }));
30
+ render(<MantineProvider><ModelSettings /></MantineProvider>);
31
+ expect(await screen.findByText('ccusage 20.0.21')).toBeInTheDocument();
32
+ expect(screen.getByText('Usage provider')).toBeInTheDocument();
33
+ expect(screen.getByText(/Version reported by the executable/)).toBeInTheDocument();
34
+ });
35
+
22
36
  test('refresh shows configuration failures and does not label them honored', async () => {
23
37
  const role = { role: 'reviewer', source: '/fixture/reviewer.toml', effectiveSource: '/fixture/reviewer.toml', model: 'fixture', effort: 'low', status: 'configured', availability: 'not checked', fallback: 'inherit parent settings', problems: [] as string[] };
24
38
  const profile = { id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null };
@@ -6,9 +6,11 @@ import { updateModelRole } from '../api';
6
6
  type ModelOption = { value: string; label: string; efforts: string[] };
7
7
  type Role = { role: string; source: string | null; displaySource?: string | null; model: unknown; effort: unknown; status: string; problems: string[] };
8
8
  type Profile = { id: string; roles: Role[]; problems: string[]; modelOptions?: ModelOption[] };
9
+ type UsageProvider = { provider: string; status: 'available' | 'unavailable'; version: string | null };
9
10
 
10
11
  export function ModelSettings() {
11
12
  const [profiles, setProfiles] = useState<Profile[]>([]);
13
+ const [usageProvider, setUsageProvider] = useState<UsageProvider | null>(null);
12
14
  const [error, setError] = useState<string | null>(null);
13
15
  const [loading, setLoading] = useState(true);
14
16
  const [saving, setSaving] = useState<string | null>(null);
@@ -20,8 +22,9 @@ export function ModelSettings() {
20
22
  setError(null);
21
23
  fetch('/api/settings', { signal: controller.signal }).then(async (response) => {
22
24
  if (!response.ok) throw new Error('Settings preview could not be loaded.');
23
- const data = await response.json() as { profiles: Profile[] };
25
+ const data = await response.json() as { profiles: Profile[]; usageProvider?: UsageProvider };
24
26
  setProfiles(data.profiles);
27
+ setUsageProvider(data.usageProvider ?? null);
25
28
  }).catch((failure: Error) => {
26
29
  if (!controller.signal.aborted) setError(failure.message);
27
30
  }).finally(() => { if (!controller.signal.aborted) setLoading(false); });
@@ -87,5 +90,15 @@ export function ModelSettings() {
87
90
  </Stack>
88
91
  </Paper>)}
89
92
  </>}
93
+ {usageProvider && <Paper withBorder p="md">
94
+ <Stack gap="xs">
95
+ <Group justify="space-between">
96
+ <Title order={3} size="h4">Usage provider</Title>
97
+ <Badge color={usageProvider.status === 'available' ? 'teal' : 'red'}>{usageProvider.status}</Badge>
98
+ </Group>
99
+ <Text>{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
100
+ <Text c="dimmed" size="sm">Version reported by the executable TaskChef uses for token and cost estimates.</Text>
101
+ </Stack>
102
+ </Paper>}
90
103
  </Stack>;
91
104
  }
@@ -106,6 +106,16 @@ export interface NotificationSnapshot {
106
106
  export interface DashboardSnapshot {
107
107
  healthy?: boolean;
108
108
  tasks: Task[];
109
+ projectIndex?: {
110
+ status: "available" | "unavailable";
111
+ projectCount: number | null;
112
+ missingProjects: Array<{
113
+ path: string;
114
+ snapshotNames: string[];
115
+ taskCount: number;
116
+ }>;
117
+ truncated?: boolean;
118
+ };
109
119
  }
110
120
 
111
121
  export interface ManualTransitionResponse {
@@ -246,6 +246,7 @@ export function readDashboardIdentity({
246
246
  port = DEFAULT_PORT,
247
247
  maximumBytes = DASHBOARD_HEALTH_MAX_BYTES,
248
248
  timeoutMs = HEALTH_TIMEOUT_MS,
249
+ deadlineAt = Date.now() + timeoutMs,
249
250
  } = {}) {
250
251
  return new Promise((resolve, reject) => {
251
252
  let settled = false;
@@ -278,6 +279,21 @@ export function readDashboardIdentity({
278
279
  chunks.push(chunk);
279
280
  });
280
281
  response.on("end", () => {
282
+ if (response.statusCode === 503 && deadlineAt - Date.now() > 10) {
283
+ settled = true;
284
+ clearTimeout(deadline);
285
+ const remaining = deadlineAt - Date.now();
286
+ setTimeout(() => {
287
+ readDashboardIdentity({
288
+ host,
289
+ port,
290
+ maximumBytes,
291
+ timeoutMs: Math.max(1, remaining),
292
+ deadlineAt,
293
+ }).then(resolve, reject);
294
+ }, 10);
295
+ return;
296
+ }
281
297
  if (response.statusCode !== 200) {
282
298
  finish(new Error(`dashboard health returned HTTP ${response.statusCode}`));
283
299
  return;
package/src/dashboard.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  canonicalGitRoot,
21
21
  manuallyTransitionTask,
22
22
  parseTaskLogContent,
23
+ createWorkspaceBackup,
23
24
  readConfig,
24
25
  readTask,
25
26
  } from "./workspace.js";
@@ -27,7 +28,7 @@ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
27
28
  import { taskGitHubProjection } from "./dashboard/github-links.js";
28
29
  import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
29
30
  import { createUsageTracker } from "./usage-tracker.js";
30
- import { readUsageStore, usageStorePath } from "./usage.js";
31
+ import { readCcusageRuntimeInfo, readUsageStore, usageStorePath } from "./usage.js";
31
32
  import { reportedWorkSummary } from "./reported-work.js";
32
33
  import {
33
34
  MAX_DASHBOARD_SESSION_PIDS,
@@ -463,7 +464,9 @@ export class DashboardMonitor extends EventEmitter {
463
464
  const snapshot = await readBoundedTaskLog(this.tasksFile, this.maxFileBytes);
464
465
  return {
465
466
  fingerprint: snapshot.fingerprint,
466
- tasks: await parseTaskLogContent(this.workspace, snapshot.content),
467
+ tasks: await parseTaskLogContent(this.workspace, snapshot.content, {
468
+ validateWorkspace: false,
469
+ }),
467
470
  };
468
471
  });
469
472
  this.tasks = [];
@@ -472,6 +475,7 @@ export class DashboardMonitor extends EventEmitter {
472
475
  this.currentFingerprint = null;
473
476
  this.currentTaskFingerprint = null;
474
477
  this.unhealthy = false;
478
+ this.projectIndex = { status: "available", projectCount: 0, missingProjects: [] };
475
479
  this.observedUpdateTimes = new Map();
476
480
  this.refreshPromise = null;
477
481
  this.refreshQueued = false;
@@ -487,6 +491,7 @@ export class DashboardMonitor extends EventEmitter {
487
491
  revision: this.revision,
488
492
  generatedAt: new Date().toISOString(),
489
493
  healthy: !this.unhealthy,
494
+ projectIndex: this.projectIndex,
490
495
  tasks: this.tasks.map((task) => ({
491
496
  ...taskListProjection(task),
492
497
  meaningfulUpdatedAt: new Date(
@@ -513,7 +518,7 @@ export class DashboardMonitor extends EventEmitter {
513
518
  if (!this.started || this.watcher) return;
514
519
  try {
515
520
  this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
516
- if (fileName !== null && String(fileName) !== TASKS_FILE_NAME) return;
521
+ if (fileName !== null && !new Set([TASKS_FILE_NAME, "taskchef.json", "config-audit.jsonl"]).has(String(fileName))) return;
517
522
  clearTimeout(this.debounceTimer);
518
523
  this.debounceTimer = setTimeout(() => this.refresh({ force: true }), this.debounceMs);
519
524
  this.debounceTimer.unref?.();
@@ -579,8 +584,34 @@ export class DashboardMonitor extends EventEmitter {
579
584
  }
580
585
  }
581
586
  const tasks = sortTasksByMeaningfulUpdate(snapshot.tasks, this.observedUpdateTimes);
582
- const nextTaskFingerprint = taskFingerprint(tasks);
587
+ let projectIndex;
588
+ try {
589
+ const config = await readConfig(this.workspace, { checkPaths: false });
590
+ const configured = new Set(config.projects.map((project) => project.path));
591
+ const missing = new Map();
592
+ for (const task of tasks) {
593
+ if (configured.has(task.project.path)) continue;
594
+ const item = missing.get(task.project.path) ?? {
595
+ path: task.project.path, snapshotNames: new Set(), taskCount: 0,
596
+ };
597
+ item.snapshotNames.add(task.project.name);
598
+ item.taskCount += 1;
599
+ missing.set(task.project.path, item);
600
+ }
601
+ projectIndex = {
602
+ status: "available",
603
+ projectCount: config.projects.length,
604
+ missingProjects: [...missing.values()].slice(0, 50).map((item) => ({
605
+ ...item, snapshotNames: [...item.snapshotNames].sort(),
606
+ })),
607
+ truncated: missing.size > 50,
608
+ };
609
+ } catch {
610
+ projectIndex = { status: "unavailable", projectCount: null, missingProjects: [] };
611
+ }
612
+ const nextTaskFingerprint = taskFingerprint({ tasks, projectIndex });
583
613
  this.currentFingerprint = after;
614
+ this.projectIndex = projectIndex;
584
615
  if (nextTaskFingerprint === this.currentTaskFingerprint) {
585
616
  if (this.unhealthy) {
586
617
  this.unhealthy = false;
@@ -804,7 +835,9 @@ export async function createDashboardServer({
804
835
  serverVersion = DASHBOARD_SERVER_VERSION,
805
836
  usageTracker = null,
806
837
  usageSummaryMonitor = null,
838
+ usagePreloadIntervalMs = 30_000,
807
839
  resolveRoles = resolveModelRoles,
840
+ resolveUsageProvider = readCcusageRuntimeInfo,
808
841
  updateRole = updateModelRole,
809
842
  control = null,
810
843
  controlReplayCache = createDashboardControlReplayCache(),
@@ -818,6 +851,9 @@ export async function createDashboardServer({
818
851
  if (!Number.isInteger(maxEventClients) || maxEventClients < 0) {
819
852
  throw new Error("dashboard event-client limit must be a non-negative integer");
820
853
  }
854
+ if (!Number.isFinite(usagePreloadIntervalMs) || usagePreloadIntervalMs < 1_000) {
855
+ throw new Error("dashboard usage preload interval must be at least one second");
856
+ }
821
857
  if (!new Set(["mcp", "session", "standalone"]).has(launcher)) {
822
858
  throw new Error("dashboard launcher must be mcp, session, or standalone");
823
859
  }
@@ -828,11 +864,24 @@ export async function createDashboardServer({
828
864
  || typeof control?.onHandoff !== "function")))) {
829
865
  throw new Error("dashboard control requires a valid TaskChef ownership controller");
830
866
  }
867
+ let requestHandler = (_request, response) => {
868
+ sendJson(response, 503, { message: "TaskChef dashboard is initializing." });
869
+ };
870
+ const server = http.createServer((request, response) => requestHandler(request, response));
871
+ await new Promise((resolve, reject) => {
872
+ server.once("error", reject);
873
+ server.listen(port, host, resolve);
874
+ });
875
+ const address = server.address();
876
+ const boundPort = typeof address === "object" && address ? address.port : port;
877
+ const allowedAuthority = dashboardAuthority(host, boundPort);
878
+ const allowedOrigin = `http://${allowedAuthority}`;
831
879
  const monitor = new DashboardMonitor(workspace, monitorOptions);
832
880
  let taskUsageTracker;
833
881
  let taskUsageSummaryMonitor;
834
882
  let identity;
835
883
  try {
884
+ await createWorkspaceBackup(workspace, { reason: `dashboard-${launcher}-startup` });
836
885
  await monitor.start();
837
886
  taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
838
887
  taskUsageSummaryMonitor = usageSummaryMonitor
@@ -851,15 +900,16 @@ export async function createDashboardServer({
851
900
  }
852
901
  } catch (error) {
853
902
  taskUsageSummaryMonitor?.close();
903
+ taskUsageTracker?.close?.();
854
904
  monitor.close();
905
+ await new Promise((resolve) => server.close(() => resolve()));
855
906
  throw error;
856
907
  }
857
908
  const clients = new Set();
858
909
  const archiveRequests = new Set();
910
+ let usageProviderPromise = null;
859
911
  let controlHandoff = null;
860
912
  let controlHandoffTimer = null;
861
- let allowedAuthority;
862
- let allowedOrigin;
863
913
 
864
914
  const broadcast = (event, value) => {
865
915
  const payload = ssePayload(event, value);
@@ -875,12 +925,21 @@ export async function createDashboardServer({
875
925
  })),
876
926
  };
877
927
  };
878
- const snapshotListener = () => broadcast("snapshot", dashboardSnapshot());
928
+ const preloadUsage = () => {
929
+ void Promise.resolve(taskUsageTracker.preload?.(monitor.tasks)).catch(() => {});
930
+ };
931
+ const snapshotListener = () => {
932
+ preloadUsage();
933
+ broadcast("snapshot", dashboardSnapshot());
934
+ };
879
935
  const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
880
936
  const errorListener = () => broadcast("dashboard-error", publicMonitorError());
881
937
  monitor.on("snapshot", snapshotListener);
882
938
  monitor.on("monitorError", errorListener);
883
939
  taskUsageSummaryMonitor.on("change", usageSummaryListener);
940
+ preloadUsage();
941
+ const usagePreloadTimer = setInterval(preloadUsage, usagePreloadIntervalMs);
942
+ usagePreloadTimer.unref?.();
884
943
 
885
944
  const handleRequest = async (request, response) => {
886
945
  const method = request.method ?? "GET";
@@ -1183,7 +1242,12 @@ export async function createDashboardServer({
1183
1242
 
1184
1243
  if (url.pathname === "/api/settings" && method === "GET") {
1185
1244
  const profiles = [{ id: "personal", project: "Personal", ...await resolveRoles({ includeCatalog: true }) }];
1186
- sendJson(response, 200, { profiles });
1245
+ usageProviderPromise ??= Promise.resolve(resolveUsageProvider()).catch(() => ({
1246
+ provider: "ccusage",
1247
+ status: "unavailable",
1248
+ version: null,
1249
+ }));
1250
+ sendJson(response, 200, { profiles, usageProvider: await usageProviderPromise });
1187
1251
  return;
1188
1252
  }
1189
1253
 
@@ -1458,26 +1522,12 @@ export async function createDashboardServer({
1458
1522
  response.end(method === "HEAD" ? undefined : body);
1459
1523
  };
1460
1524
 
1461
- const server = http.createServer((request, response) => {
1525
+ requestHandler = (request, response) => {
1462
1526
  handleRequest(request, response).catch(() => {
1463
1527
  if (response.headersSent) response.destroy();
1464
1528
  else sendJson(response, 500, { message: "Dashboard request failed." });
1465
1529
  });
1466
- });
1467
-
1468
- await new Promise((resolve, reject) => {
1469
- server.once("error", reject);
1470
- server.listen(port, host, resolve);
1471
- }).catch((error) => {
1472
- monitor.close();
1473
- taskUsageSummaryMonitor.close();
1474
- throw error;
1475
- });
1476
-
1477
- const address = server.address();
1478
- const boundPort = typeof address === "object" && address ? address.port : port;
1479
- allowedAuthority = dashboardAuthority(host, boundPort);
1480
- allowedOrigin = `http://${allowedAuthority}`;
1530
+ };
1481
1531
  return {
1482
1532
  host,
1483
1533
  port: boundPort,
@@ -1493,6 +1543,8 @@ export async function createDashboardServer({
1493
1543
  taskUsageSummaryMonitor.off("change", usageSummaryListener);
1494
1544
  monitor.close();
1495
1545
  taskUsageSummaryMonitor.close();
1546
+ taskUsageTracker.close?.();
1547
+ clearInterval(usagePreloadTimer);
1496
1548
  for (const client of clients) client.close();
1497
1549
  const closed = new Promise((resolve, reject) => server.close((error) =>
1498
1550
  error ? reject(error) : resolve()));
@@ -0,0 +1,81 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { link, mkdir, open, rename, unlink } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ function canonicalize(value) {
6
+ if (Array.isArray(value)) return value.map(canonicalize);
7
+ if (value && typeof value === "object") {
8
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
9
+ }
10
+ return value;
11
+ }
12
+
13
+ export function canonicalJson(value) {
14
+ return JSON.stringify(canonicalize(value));
15
+ }
16
+
17
+ export function sha256(value) {
18
+ return createHash("sha256").update(value).digest("hex");
19
+ }
20
+
21
+ export function semanticHash(value, domain = "taskchef") {
22
+ return sha256(`${domain}\n${canonicalJson(value)}\n`);
23
+ }
24
+
25
+ export function taskChefError(code, message, details = {}) {
26
+ return Object.assign(new Error(message), { code, details });
27
+ }
28
+
29
+ export async function writeDurableAtomic(filePath, content, {
30
+ mode = 0o600,
31
+ exclusive = false,
32
+ } = {}) {
33
+ await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
34
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
35
+ const handle = await open(temporaryPath, "wx", mode);
36
+ try {
37
+ await handle.writeFile(content, "utf8");
38
+ await handle.sync();
39
+ } finally {
40
+ await handle.close();
41
+ }
42
+ let replacementCommitted = false;
43
+ try {
44
+ if (exclusive) {
45
+ await link(temporaryPath, filePath);
46
+ replacementCommitted = true;
47
+ await unlink(temporaryPath);
48
+ } else {
49
+ await rename(temporaryPath, filePath);
50
+ replacementCommitted = true;
51
+ }
52
+ const directory = await open(path.dirname(filePath), "r");
53
+ try {
54
+ await directory.sync();
55
+ } finally {
56
+ await directory.close();
57
+ }
58
+ } catch (error) {
59
+ await unlink(temporaryPath).catch(() => {});
60
+ if (replacementCommitted) error.replacementCommitted = true;
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ export async function removeDurable(filePath) {
66
+ let removed = false;
67
+ try {
68
+ await unlink(filePath);
69
+ removed = true;
70
+ } catch (error) {
71
+ if (error.code !== "ENOENT") throw error;
72
+ }
73
+ if (!removed) return false;
74
+ const directory = await open(path.dirname(filePath), "r");
75
+ try {
76
+ await directory.sync();
77
+ } finally {
78
+ await directory.close();
79
+ }
80
+ return true;
81
+ }