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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +21 -1
- package/bin/taskchef.js +11 -1
- package/docs/dashboard-lifecycle.md +17 -0
- package/docs/spec.md +37 -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 +32 -32
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/react/App.tsx +16 -0
- package/src/dashboard/react/components/ModelSettings.test.tsx +14 -0
- package/src/dashboard/react/components/ModelSettings.tsx +14 -1
- package/src/dashboard/react/types.ts +10 -0
- package/src/dashboard-manager.js +16 -0
- package/src/dashboard.js +76 -24
- package/src/state-store.js +81 -0
- package/src/usage-tracker.js +192 -23
- package/src/usage.js +40 -12
- package/src/workspace.js +639 -37
|
@@ -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 {
|
package/src/dashboard-manager.js
CHANGED
|
@@ -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)
|
|
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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|