taskchef 7.29.0 → 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">
@@ -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";
@@ -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;
@@ -833,11 +864,24 @@ export async function createDashboardServer({
833
864
  || typeof control?.onHandoff !== "function")))) {
834
865
  throw new Error("dashboard control requires a valid TaskChef ownership controller");
835
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}`;
836
879
  const monitor = new DashboardMonitor(workspace, monitorOptions);
837
880
  let taskUsageTracker;
838
881
  let taskUsageSummaryMonitor;
839
882
  let identity;
840
883
  try {
884
+ await createWorkspaceBackup(workspace, { reason: `dashboard-${launcher}-startup` });
841
885
  await monitor.start();
842
886
  taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
843
887
  taskUsageSummaryMonitor = usageSummaryMonitor
@@ -858,6 +902,7 @@ export async function createDashboardServer({
858
902
  taskUsageSummaryMonitor?.close();
859
903
  taskUsageTracker?.close?.();
860
904
  monitor.close();
905
+ await new Promise((resolve) => server.close(() => resolve()));
861
906
  throw error;
862
907
  }
863
908
  const clients = new Set();
@@ -865,8 +910,6 @@ export async function createDashboardServer({
865
910
  let usageProviderPromise = null;
866
911
  let controlHandoff = null;
867
912
  let controlHandoffTimer = null;
868
- let allowedAuthority;
869
- let allowedOrigin;
870
913
 
871
914
  const broadcast = (event, value) => {
872
915
  const payload = ssePayload(event, value);
@@ -1479,28 +1522,12 @@ export async function createDashboardServer({
1479
1522
  response.end(method === "HEAD" ? undefined : body);
1480
1523
  };
1481
1524
 
1482
- const server = http.createServer((request, response) => {
1525
+ requestHandler = (request, response) => {
1483
1526
  handleRequest(request, response).catch(() => {
1484
1527
  if (response.headersSent) response.destroy();
1485
1528
  else sendJson(response, 500, { message: "Dashboard request failed." });
1486
1529
  });
1487
- });
1488
-
1489
- await new Promise((resolve, reject) => {
1490
- server.once("error", reject);
1491
- server.listen(port, host, resolve);
1492
- }).catch((error) => {
1493
- clearInterval(usagePreloadTimer);
1494
- monitor.close();
1495
- taskUsageSummaryMonitor.close();
1496
- taskUsageTracker.close?.();
1497
- throw error;
1498
- });
1499
-
1500
- const address = server.address();
1501
- const boundPort = typeof address === "object" && address ? address.port : port;
1502
- allowedAuthority = dashboardAuthority(host, boundPort);
1503
- allowedOrigin = `http://${allowedAuthority}`;
1530
+ };
1504
1531
  return {
1505
1532
  host,
1506
1533
  port: boundPort,
@@ -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
+ }