taskchef 7.29.0 → 8.1.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.
Files changed (36) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +15 -0
  3. package/bin/taskchef.js +11 -1
  4. package/docs/dashboard-lifecycle.md +10 -0
  5. package/docs/spec.md +28 -0
  6. package/docs/workflows.md +5 -1
  7. package/index.js +14 -0
  8. package/package.json +1 -1
  9. package/skills/taskchef-bootstrap/SKILL.md +7 -3
  10. package/skills/taskchef-bootstrap/references/project-index.md +21 -3
  11. package/skills/taskchef-copilot/SKILL.md +2 -0
  12. package/skills/taskchef-dashboard/SKILL.md +4 -0
  13. package/skills/taskchef-delegate/SKILL.md +3 -2
  14. package/src/backups.js +359 -0
  15. package/src/cli.js +180 -16
  16. package/src/config-audit.js +99 -0
  17. package/src/config-mutations.js +98 -0
  18. package/src/dashboard/dist/app.js +58 -38
  19. package/src/dashboard/dist/app.js.map +1 -1
  20. package/src/dashboard/dist/styles.css +1 -1
  21. package/src/dashboard/github-links.js +9 -1
  22. package/src/dashboard/react/App.tsx +16 -0
  23. package/src/dashboard/react/components/RelativeTime.tsx +23 -5
  24. package/src/dashboard/react/components/TaskCard.tsx +4 -21
  25. package/src/dashboard/react/components/TaskCardStats.test.tsx +122 -0
  26. package/src/dashboard/react/components/TaskCardStats.tsx +86 -0
  27. package/src/dashboard/react/presentation.test.tsx +2 -2
  28. package/src/dashboard/react/presentation.ts +50 -20
  29. package/src/dashboard/react/styles.css +16 -11
  30. package/src/dashboard/react/types.ts +10 -0
  31. package/src/dashboard-manager.js +16 -0
  32. package/src/dashboard.js +50 -23
  33. package/src/state-store.js +81 -0
  34. package/src/workspace.js +639 -37
  35. package/src/dashboard/react/components/TaskUsageSummary.test.tsx +0 -122
  36. package/src/dashboard/react/components/TaskUsageSummary.tsx +0 -24
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
+ }