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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +15 -0
- package/bin/taskchef.js +11 -1
- package/docs/dashboard-lifecycle.md +10 -0
- package/docs/spec.md +28 -0
- package/docs/workflows.md +5 -1
- package/index.js +14 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +7 -3
- package/skills/taskchef-bootstrap/references/project-index.md +21 -3
- package/skills/taskchef-copilot/SKILL.md +2 -0
- package/skills/taskchef-dashboard/SKILL.md +4 -0
- package/skills/taskchef-delegate/SKILL.md +3 -2
- package/src/backups.js +359 -0
- package/src/cli.js +180 -16
- package/src/config-audit.js +99 -0
- package/src/config-mutations.js +98 -0
- package/src/dashboard/dist/app.js +58 -38
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/github-links.js +9 -1
- package/src/dashboard/react/App.tsx +16 -0
- package/src/dashboard/react/components/RelativeTime.tsx +23 -5
- package/src/dashboard/react/components/TaskCard.tsx +4 -21
- package/src/dashboard/react/components/TaskCardStats.test.tsx +122 -0
- package/src/dashboard/react/components/TaskCardStats.tsx +86 -0
- package/src/dashboard/react/presentation.test.tsx +2 -2
- package/src/dashboard/react/presentation.ts +50 -20
- package/src/dashboard/react/styles.css +16 -11
- package/src/dashboard/react/types.ts +10 -0
- package/src/dashboard-manager.js +16 -0
- package/src/dashboard.js +50 -23
- package/src/state-store.js +81 -0
- package/src/workspace.js +639 -37
- package/src/dashboard/react/components/TaskUsageSummary.test.tsx +0 -122
- 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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|