taskchef 7.24.0 → 7.25.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/index.js +2 -0
- package/package.json +1 -1
- package/src/dashboard/dist/app.js +40 -40
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/github-links.js +12 -4
- package/src/dashboard/react/App.tsx +27 -8
- package/src/dashboard/react/components/ActivityTimeline.tsx +5 -2
- package/src/dashboard/react/components/GitHubLinks.tsx +37 -16
- package/src/dashboard/react/components/LinkedText.tsx +63 -0
- package/src/dashboard/react/components/NotificationCenter.test.tsx +6 -1
- package/src/dashboard/react/components/TaskCard.tsx +13 -6
- package/src/dashboard/react/components/TaskUsageSummary.test.tsx +85 -0
- package/src/dashboard/react/components/TaskUsageSummary.tsx +24 -0
- package/src/dashboard/react/components/UsagePanel.tsx +41 -9
- package/src/dashboard/react/overlay-state.test.ts +31 -0
- package/src/dashboard/react/overlay-state.ts +23 -1
- package/src/dashboard/react/presentation.test.tsx +119 -6
- package/src/dashboard/react/presentation.ts +73 -4
- package/src/dashboard/react/styles.css +12 -0
- package/src/dashboard/react/types.ts +3 -0
- package/src/dashboard.js +243 -15
- package/src/usage.js +3 -3
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { MantineProvider } from "@mantine/core";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { render, screen, within } from "@testing-library/react";
|
|
3
3
|
import { describe, expect, test } from "vitest";
|
|
4
4
|
import { fixtureTask } from "./fixtures";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
formatCompactTokens,
|
|
7
|
+
formatEstimatedCost,
|
|
8
|
+
turnUsageView,
|
|
9
|
+
usageStillCalculating,
|
|
10
|
+
usageView,
|
|
11
|
+
} from "./presentation";
|
|
6
12
|
import { ShimmerText } from "./components/ShimmerText";
|
|
7
13
|
import { GitHubLinks } from "./components/GitHubLinks";
|
|
14
|
+
import { LinkedText } from "./components/LinkedText";
|
|
8
15
|
import { UsagePanel } from "./components/UsagePanel";
|
|
9
16
|
|
|
10
17
|
describe("token and working presentation", () => {
|
|
@@ -21,7 +28,7 @@ describe("token and working presentation", () => {
|
|
|
21
28
|
status: "available",
|
|
22
29
|
task: { totalTokens: 12345, estimatedCostUsd: 0.1234 },
|
|
23
30
|
},
|
|
24
|
-
})).label).toBe("12
|
|
31
|
+
})).label).toBe("12.35K tokens · Estimated cost $0.12");
|
|
25
32
|
expect(usageView(fixtureTask({ status: "completed", usage: { status: "unavailable" } })).label)
|
|
26
33
|
.toBe("Token usage unavailable");
|
|
27
34
|
expect(usageView(fixtureTask({
|
|
@@ -43,7 +50,7 @@ describe("token and working presentation", () => {
|
|
|
43
50
|
},
|
|
44
51
|
}));
|
|
45
52
|
expect(view.kind).toBe("ready");
|
|
46
|
-
expect(view.label).toBe("12
|
|
53
|
+
expect(view.label).toBe("12.35K tokens · Estimated cost $0.12 · known so far");
|
|
47
54
|
if (view.kind === "ready") expect(view.knownSoFar).toBe(true);
|
|
48
55
|
});
|
|
49
56
|
|
|
@@ -64,7 +71,7 @@ describe("token and working presentation", () => {
|
|
|
64
71
|
updatedAt: task.updatedAt,
|
|
65
72
|
},
|
|
66
73
|
};
|
|
67
|
-
expect(turnUsageView(task, completedTurn).label).toBe("330 tokens · Estimated cost $0.
|
|
74
|
+
expect(turnUsageView(task, completedTurn).label).toBe("330 tokens · Estimated cost $0.12");
|
|
68
75
|
expect(turnUsageView(task, { ...completedTurn, result: null }).label).toBe("Turn usage pending");
|
|
69
76
|
expect(turnUsageView({
|
|
70
77
|
...task,
|
|
@@ -115,7 +122,7 @@ describe("token and working presentation", () => {
|
|
|
115
122
|
);
|
|
116
123
|
|
|
117
124
|
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("330");
|
|
118
|
-
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("$0.
|
|
125
|
+
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
|
|
119
126
|
expect(screen.getByText("Model").nextSibling).toHaveTextContent("gpt-5.6-sol, gpt-5.6-luna");
|
|
120
127
|
expect(screen.getByText("Cache ratio").nextSibling).toHaveTextContent("67%");
|
|
121
128
|
expect(screen.queryByText(/API-equivalent/i)).not.toBeInTheDocument();
|
|
@@ -125,4 +132,110 @@ describe("token and working presentation", () => {
|
|
|
125
132
|
render(<MantineProvider><GitHubLinks task={fixtureTask()} /></MantineProvider>);
|
|
126
133
|
expect(screen.getByRole("link")).toHaveClass("taskchef-github-link");
|
|
127
134
|
});
|
|
135
|
+
|
|
136
|
+
test("formats compact token boundaries without scientific notation", () => {
|
|
137
|
+
expect([
|
|
138
|
+
0,
|
|
139
|
+
999,
|
|
140
|
+
1_000,
|
|
141
|
+
1_100,
|
|
142
|
+
12_345,
|
|
143
|
+
999_999,
|
|
144
|
+
1_320_000,
|
|
145
|
+
1_000_000_000,
|
|
146
|
+
].map((value) => formatCompactTokens(value, "en-US"))).toEqual([
|
|
147
|
+
"0",
|
|
148
|
+
"999",
|
|
149
|
+
"1K",
|
|
150
|
+
"1.1K",
|
|
151
|
+
"12.35K",
|
|
152
|
+
"1M",
|
|
153
|
+
"1.32M",
|
|
154
|
+
"1B",
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("formats estimates to two decimals across zero, sub-cent, missing, and large values", () => {
|
|
159
|
+
expect(formatEstimatedCost(0, "en-US")).toBe("$0.00");
|
|
160
|
+
expect(formatEstimatedCost(0.0049, "en-US")).toBe("$0.00");
|
|
161
|
+
expect(formatEstimatedCost(12.344, "en-US")).toBe("$12.34");
|
|
162
|
+
expect(formatEstimatedCost(12.345, "en-US")).toBe("$12.35");
|
|
163
|
+
expect(formatEstimatedCost(null, "en-US")).toBe("—");
|
|
164
|
+
expect(formatEstimatedCost(Number.NaN, "en-US")).toBe("—");
|
|
165
|
+
expect(formatEstimatedCost(1_234_567_890.125, "en-US")).toBe("$1,234,567,890.13");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("exposes full token precision and the unrounded estimate on ready metric cards", () => {
|
|
169
|
+
render(
|
|
170
|
+
<MantineProvider>
|
|
171
|
+
<UsagePanel task={fixtureTask({
|
|
172
|
+
status: "completed",
|
|
173
|
+
usage: {
|
|
174
|
+
status: "available",
|
|
175
|
+
task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
|
|
176
|
+
},
|
|
177
|
+
})} />
|
|
178
|
+
</MantineProvider>,
|
|
179
|
+
);
|
|
180
|
+
expect(screen.getByLabelText("1,324,567 tokens")).toHaveTextContent("1.32M");
|
|
181
|
+
expect(screen.getByLabelText("1,324,567 tokens")).toHaveAttribute("title", "1,324,567 tokens");
|
|
182
|
+
expect(screen.getByLabelText("Estimated cost $12.34")).toHaveTextContent("$12.34");
|
|
183
|
+
expect(screen.getByLabelText("Estimated cost $12.34")).toHaveAttribute(
|
|
184
|
+
"title",
|
|
185
|
+
"Unrounded estimate: $12.3449",
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("groups, deduplicates, and naturally sorts compact related links by repository", () => {
|
|
190
|
+
const task = fixtureTask({
|
|
191
|
+
relatedGitHubLinks: [
|
|
192
|
+
["taskchef", "83", "pull"],
|
|
193
|
+
["guzuoshou-workspace", "124", "issue"],
|
|
194
|
+
["taskchef", "79", "issue"],
|
|
195
|
+
["guzuoshou-workspace", "108", "issue"],
|
|
196
|
+
["taskchef", "80", "issue"],
|
|
197
|
+
["guzuoshou-workspace", "109", "pull"],
|
|
198
|
+
["taskchef", "79", "pull"],
|
|
199
|
+
].map(([repository, number, type]) => ({
|
|
200
|
+
label: `favoyang/${repository}#${number}`,
|
|
201
|
+
number,
|
|
202
|
+
owner: "favoyang",
|
|
203
|
+
repository,
|
|
204
|
+
type: type as "issue" | "pull",
|
|
205
|
+
url: `https://github.com/favoyang/${repository}/${type === "pull" ? "pull" : "issues"}/${number}`,
|
|
206
|
+
})),
|
|
207
|
+
});
|
|
208
|
+
const { container } = render(<MantineProvider><GitHubLinks task={task} /></MantineProvider>);
|
|
209
|
+
const groups = container.querySelectorAll(".taskchef-github-group");
|
|
210
|
+
expect(groups).toHaveLength(2);
|
|
211
|
+
expect(within(groups[0] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
|
|
212
|
+
.toEqual(["taskchef #79", "#80", "#83"]);
|
|
213
|
+
expect(within(groups[1] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
|
|
214
|
+
.toEqual(["guzuoshou-workspace #108", "#109", "#124"]);
|
|
215
|
+
const first = screen.getByRole("link", { name: /favoyang\/taskchef Issue #79/ });
|
|
216
|
+
expect(first).toHaveAttribute("href", "https://github.com/favoyang/taskchef/issues/79");
|
|
217
|
+
expect(first).toHaveAttribute("target", "_blank");
|
|
218
|
+
expect(first).toHaveAttribute("rel", "noopener noreferrer");
|
|
219
|
+
expect(screen.queryByText(/PR #|Issue #/)).not.toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("keeps established inline request and result link labels", () => {
|
|
223
|
+
const task = fixtureTask({
|
|
224
|
+
project: {
|
|
225
|
+
name: "TaskChef Preview",
|
|
226
|
+
path: "/tmp/taskchef-preview-project",
|
|
227
|
+
githubRepos: ["https://github.com/favoyang/taskchef"],
|
|
228
|
+
},
|
|
229
|
+
relatedGitHubRepository: "favoyang/taskchef",
|
|
230
|
+
});
|
|
231
|
+
const { container } = render(
|
|
232
|
+
<MantineProvider>
|
|
233
|
+
<div>
|
|
234
|
+
<LinkedText task={task} text="Review https://github.com/favoyang/taskchef/pull/83." />
|
|
235
|
+
</div>
|
|
236
|
+
</MantineProvider>,
|
|
237
|
+
);
|
|
238
|
+
expect(within(container).getByRole("link", { name: /favoyang\/taskchef PR #83/ }))
|
|
239
|
+
.toHaveTextContent("taskchef PR #83");
|
|
240
|
+
});
|
|
128
241
|
});
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import type { Task, UsageNumbers, UsageProjection } from "./types";
|
|
2
2
|
|
|
3
|
+
export function formatCompactTokens(value: number, locales?: Intl.LocalesArgument) {
|
|
4
|
+
return new Intl.NumberFormat(locales, {
|
|
5
|
+
maximumFractionDigits: 2,
|
|
6
|
+
notation: "compact",
|
|
7
|
+
}).format(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatFullTokens(value: number, locales?: Intl.LocalesArgument) {
|
|
11
|
+
return new Intl.NumberFormat(locales, { maximumFractionDigits: 2 }).format(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatEstimatedCost(value: number | null | undefined, locales?: Intl.LocalesArgument) {
|
|
15
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
|
16
|
+
return new Intl.NumberFormat(locales, {
|
|
17
|
+
currency: "USD",
|
|
18
|
+
currencyDisplay: "narrowSymbol",
|
|
19
|
+
maximumFractionDigits: 2,
|
|
20
|
+
minimumFractionDigits: 2,
|
|
21
|
+
style: "currency",
|
|
22
|
+
}).format(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
3
25
|
export type UsageView =
|
|
4
26
|
| { kind: "pending"; label: "Token usage pending" }
|
|
5
27
|
| { kind: "calculating"; label: "Calculating token usage" }
|
|
@@ -29,10 +51,10 @@ export function usageView(task: Task): UsageView {
|
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
function readyUsageView(usage: UsageNumbers, knownSoFar: boolean): UsageView {
|
|
32
|
-
const tokens =
|
|
54
|
+
const tokens = formatCompactTokens(usage.totalTokens);
|
|
33
55
|
const cost = usage.estimatedCostUsd == null
|
|
34
56
|
? "Estimated cost unavailable"
|
|
35
|
-
: `Estimated cost
|
|
57
|
+
: `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
36
58
|
return {
|
|
37
59
|
kind: "ready",
|
|
38
60
|
knownSoFar,
|
|
@@ -49,10 +71,10 @@ export function turnUsageView(task: Task, turn: NonNullable<Task["turns"]>[numbe
|
|
|
49
71
|
return { kind: "calculating" as const, label: "Calculating turn usage" };
|
|
50
72
|
}
|
|
51
73
|
if (usage?.status === "available" && typeof usage.totalTokens === "number") {
|
|
52
|
-
const tokens =
|
|
74
|
+
const tokens = formatFullTokens(usage.totalTokens);
|
|
53
75
|
const cost = usage.estimatedCostUsd == null
|
|
54
76
|
? "Estimated cost unavailable"
|
|
55
|
-
: `Estimated cost
|
|
77
|
+
: `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
56
78
|
return { kind: "ready" as const, label: `${tokens} tokens · ${cost}` };
|
|
57
79
|
}
|
|
58
80
|
return {
|
|
@@ -67,6 +89,53 @@ export function usageStillCalculating(task: Task) {
|
|
|
67
89
|
|| Object.values(task.usage?.turns ?? {}).some((turn) => turn.status === "calculating"));
|
|
68
90
|
}
|
|
69
91
|
|
|
92
|
+
export type ListUsageView = {
|
|
93
|
+
accessibleLabel: string;
|
|
94
|
+
kind: "pending" | "calculating" | "ready" | "unavailable";
|
|
95
|
+
label: string;
|
|
96
|
+
title: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function listUsageView(task: Task): ListUsageView {
|
|
100
|
+
const view = usageView(task);
|
|
101
|
+
if (view.kind === "pending" || view.kind === "calculating") {
|
|
102
|
+
return {
|
|
103
|
+
accessibleLabel: view.label,
|
|
104
|
+
kind: view.kind,
|
|
105
|
+
label: view.label,
|
|
106
|
+
title: view.label,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (view.kind === "unavailable") {
|
|
110
|
+
return {
|
|
111
|
+
accessibleLabel: `Token usage unavailable${view.label === "Token usage unavailable" ? "" : `: ${view.label}`}`,
|
|
112
|
+
kind: "unavailable",
|
|
113
|
+
label: "Token usage unavailable",
|
|
114
|
+
title: view.label,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const compactTokens = formatCompactTokens(view.usage.totalTokens);
|
|
118
|
+
const fullTokens = formatFullTokens(view.usage.totalTokens);
|
|
119
|
+
const cost = view.usage.estimatedCostUsd == null
|
|
120
|
+
? "cost unavailable"
|
|
121
|
+
: `est. ${formatEstimatedCost(view.usage.estimatedCostUsd)}`;
|
|
122
|
+
const qualifier = view.knownSoFar ? " · known so far" : "";
|
|
123
|
+
const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
|
|
124
|
+
const freshnessLabel = freshness && !Number.isNaN(Date.parse(freshness))
|
|
125
|
+
? ` Cached usage updated ${new Date(freshness).toLocaleString()}.`
|
|
126
|
+
: "";
|
|
127
|
+
return {
|
|
128
|
+
accessibleLabel: `${fullTokens} tokens; ${view.usage.estimatedCostUsd == null
|
|
129
|
+
? "estimated cost unavailable"
|
|
130
|
+
: `estimated cost ${formatEstimatedCost(view.usage.estimatedCostUsd)}`}${view.knownSoFar ? "; known so far" : ""}.${freshnessLabel}`,
|
|
131
|
+
kind: "ready",
|
|
132
|
+
label: `${compactTokens} tokens · ${cost}${qualifier}`,
|
|
133
|
+
title: `${fullTokens} tokens · ${view.usage.estimatedCostUsd == null
|
|
134
|
+
? "estimated cost unavailable"
|
|
135
|
+
: `unrounded estimate $${view.usage.estimatedCostUsd}`}${view.knownSoFar ? " · known so far" : ""}.${freshnessLabel}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
70
139
|
export function statusColor(status: Task["status"] | "interrupted") {
|
|
71
140
|
if (status === "completed") return "teal";
|
|
72
141
|
if (status === "needs_input") return "yellow";
|
|
@@ -33,7 +33,10 @@ body {
|
|
|
33
33
|
.taskchef-task-row { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
|
|
34
34
|
.taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
|
|
35
35
|
.taskchef-task-main { min-width: 0; flex: 1; }
|
|
36
|
+
.taskchef-card-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--mantine-spacing-sm); }
|
|
37
|
+
.taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem, 45vw); }
|
|
36
38
|
.taskchef-status-badge { flex: none; }
|
|
39
|
+
.taskchef-task-usage { max-width: 100%; text-align: right; line-height: 1.25; overflow-wrap: anywhere; }
|
|
37
40
|
.taskchef-title-button {
|
|
38
41
|
all: unset;
|
|
39
42
|
border-radius: 3px;
|
|
@@ -48,6 +51,11 @@ body {
|
|
|
48
51
|
.taskchef-preserve-lines { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
49
52
|
.taskchef-github-link { color: var(--taskchef-accent); text-decoration: none; }
|
|
50
53
|
.taskchef-github-link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
54
|
+
.taskchef-github-link:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; border-radius: 2px; }
|
|
55
|
+
.taskchef-github-groups { display: grid; min-width: 0; gap: 3px; }
|
|
56
|
+
.taskchef-github-group { min-width: 0; }
|
|
57
|
+
.taskchef-github-reference-ambiguous { border-bottom: 2px dotted var(--mantine-color-yellow-7); cursor: help; }
|
|
58
|
+
.taskchef-github-reference-ambiguous:focus-visible { outline: 2px solid var(--mantine-color-yellow-7); outline-offset: 2px; }
|
|
51
59
|
.taskchef-time { display: inline-flex; align-items: center; gap: 4px; width: fit-content; padding: 2px 3px; border-radius: 4px; color: var(--taskchef-muted); font-size: 0.7rem; line-height: 1.2; }
|
|
52
60
|
.taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
|
|
53
61
|
.taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
@@ -120,4 +128,8 @@ body {
|
|
|
120
128
|
@media (max-width: 34em) {
|
|
121
129
|
.taskchef-header { padding-top: 22px; padding-bottom: 18px; }
|
|
122
130
|
.taskchef-brand-icon, .taskchef-brand-icon img { width: 36px; height: 36px; }
|
|
131
|
+
.taskchef-card-heading { grid-template-columns: minmax(0, 1fr); }
|
|
132
|
+
.taskchef-card-metadata { align-items: flex-start; width: 100%; max-width: 100%; }
|
|
133
|
+
.taskchef-task-usage { text-align: left; }
|
|
134
|
+
.taskchef-card-footer { align-items: flex-start; flex-direction: column; }
|
|
123
135
|
}
|
|
@@ -41,6 +41,8 @@ export interface UsageNumbers {
|
|
|
41
41
|
reasoningOutputTokens?: number;
|
|
42
42
|
totalTokens: number;
|
|
43
43
|
estimatedCostUsd?: number | null;
|
|
44
|
+
sampledAt?: string | null;
|
|
45
|
+
sourceUpdatedAt?: string | null;
|
|
44
46
|
models?: Record<string, unknown>;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -54,6 +56,7 @@ export interface UsageProjection {
|
|
|
54
56
|
}>;
|
|
55
57
|
reason?: string;
|
|
56
58
|
message?: string;
|
|
59
|
+
updatedAt?: string | null;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
export interface Task {
|
package/src/dashboard.js
CHANGED
|
@@ -26,6 +26,7 @@ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
|
26
26
|
import { taskGitHubProjection } from "./dashboard/github-links.js";
|
|
27
27
|
import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
|
|
28
28
|
import { createUsageTracker } from "./usage-tracker.js";
|
|
29
|
+
import { readUsageStore, usageStorePath } from "./usage.js";
|
|
29
30
|
import {
|
|
30
31
|
MAX_DASHBOARD_SESSION_PIDS,
|
|
31
32
|
MAX_TRANSFERRED_DASHBOARD_SESSION_PIDS,
|
|
@@ -52,6 +53,7 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
|
|
|
52
53
|
const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
53
54
|
const DEFAULT_MAX_TASKS = 2_000;
|
|
54
55
|
const DEFAULT_MAX_EVENT_CLIENTS = 16;
|
|
56
|
+
const DEFAULT_CALCULATING_USAGE_STALE_MS = 120_000;
|
|
55
57
|
const DASHBOARD_HANDOFF_PREPARE_TTL_MS = 5_000;
|
|
56
58
|
const DASHBOARD_HANDOFF_COMMIT_GRACE_MS = 250;
|
|
57
59
|
const DASHBOARD_HANDOFF_COMMIT_RETRY_MS = 1_000;
|
|
@@ -235,6 +237,204 @@ function taskDetailProjection(task) {
|
|
|
235
237
|
return { ...task, ...taskGitHubProjection(task) };
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
function cachedUsageSummary(record) {
|
|
241
|
+
if (!record) return null;
|
|
242
|
+
return Object.freeze({
|
|
243
|
+
generationTurnRef: record.generationTurnRef ?? null,
|
|
244
|
+
status: record.status,
|
|
245
|
+
updatedAt: record.updatedAt ?? null,
|
|
246
|
+
...(record.reason ? { reason: record.reason } : {}),
|
|
247
|
+
...(record.task ? {
|
|
248
|
+
task: Object.freeze({
|
|
249
|
+
totalTokens: record.task.totalTokens,
|
|
250
|
+
estimatedCostUsd: record.task.estimatedCostUsd ?? null,
|
|
251
|
+
sampledAt: record.task.sampledAt ?? null,
|
|
252
|
+
sourceUpdatedAt: record.task.sourceUpdatedAt ?? null,
|
|
253
|
+
}),
|
|
254
|
+
} : {}),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function cachedUsageRecord(record) {
|
|
259
|
+
return Object.freeze({
|
|
260
|
+
threadId: record.threadId,
|
|
261
|
+
...cachedUsageSummary(record),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function calculatingUsageIsStale(cached, now, staleMs) {
|
|
266
|
+
if (cached?.status !== "calculating") return false;
|
|
267
|
+
const updatedAt = Date.parse(cached.updatedAt ?? "");
|
|
268
|
+
return !Number.isFinite(updatedAt) || updatedAt + staleMs <= now;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function taskListUsageProjection(task, cached, {
|
|
272
|
+
now = Date.now(),
|
|
273
|
+
calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
|
|
274
|
+
} = {}) {
|
|
275
|
+
if (!task.threadId) {
|
|
276
|
+
return { status: "unavailable", reason: "Token usage unavailable" };
|
|
277
|
+
}
|
|
278
|
+
if (!task.latestTurn) {
|
|
279
|
+
return { status: "unavailable", reason: "Token usage unavailable" };
|
|
280
|
+
}
|
|
281
|
+
if (!cached || cached.threadId !== task.threadId) {
|
|
282
|
+
return task.status === "working"
|
|
283
|
+
? { generationTurnRef: task.latestTurn.turnRef ?? null, status: "pending" }
|
|
284
|
+
: { generationTurnRef: task.latestTurn.turnRef ?? null, status: "unavailable", reason: "Token usage unavailable" };
|
|
285
|
+
}
|
|
286
|
+
const latestTurnRef = task.latestTurn.turnRef ?? task.latestTurn.turnId ?? null;
|
|
287
|
+
if (task.status !== "working" && cached.generationTurnRef !== latestTurnRef) {
|
|
288
|
+
return {
|
|
289
|
+
generationTurnRef: latestTurnRef,
|
|
290
|
+
status: "unavailable",
|
|
291
|
+
reason: "No cached usage boundary is available for this task state.",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (calculatingUsageIsStale(cached, now, calculatingStaleMs)) {
|
|
295
|
+
return {
|
|
296
|
+
generationTurnRef: cached.generationTurnRef ?? latestTurnRef,
|
|
297
|
+
status: "unavailable",
|
|
298
|
+
reason: "Token usage calculation was interrupted.",
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
return cachedUsageSummary(cached);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function optionalFileFingerprint(filePath) {
|
|
305
|
+
return fileFingerprint(filePath).catch((error) => {
|
|
306
|
+
if (error?.code === "ENOENT") return "missing";
|
|
307
|
+
throw error;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export class UsageSummaryMonitor extends EventEmitter {
|
|
312
|
+
constructor(workspace, {
|
|
313
|
+
calculatingStaleMs = DEFAULT_CALCULATING_USAGE_STALE_MS,
|
|
314
|
+
fingerprint = optionalFileFingerprint,
|
|
315
|
+
now = Date.now,
|
|
316
|
+
pollIntervalMs = 5_000,
|
|
317
|
+
readStore = readUsageStore,
|
|
318
|
+
watchDirectory = watch,
|
|
319
|
+
} = {}) {
|
|
320
|
+
super();
|
|
321
|
+
this.workspace = workspace;
|
|
322
|
+
this.calculatingStaleMs = calculatingStaleMs;
|
|
323
|
+
this.fingerprint = fingerprint;
|
|
324
|
+
this.now = now;
|
|
325
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
326
|
+
this.readStore = readStore;
|
|
327
|
+
this.watchDirectory = watchDirectory;
|
|
328
|
+
this.currentFingerprint = null;
|
|
329
|
+
this.records = new Map();
|
|
330
|
+
this.started = false;
|
|
331
|
+
this.refreshPromise = null;
|
|
332
|
+
this.refreshQueued = false;
|
|
333
|
+
this.watcher = null;
|
|
334
|
+
this.pollTimer = null;
|
|
335
|
+
this.debounceTimer = null;
|
|
336
|
+
this.lifecycleFingerprint = "";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async start() {
|
|
340
|
+
if (this.started) return;
|
|
341
|
+
this.workspace = await realpath(path.resolve(this.workspace));
|
|
342
|
+
this.file = usageStorePath(this.workspace);
|
|
343
|
+
await this.refresh({ force: true });
|
|
344
|
+
this.started = true;
|
|
345
|
+
try {
|
|
346
|
+
this.watcher = this.watchDirectory(this.workspace, { persistent: false }, (_event, fileName) => {
|
|
347
|
+
if (fileName !== null && String(fileName) !== path.basename(this.file)) return;
|
|
348
|
+
clearTimeout(this.debounceTimer);
|
|
349
|
+
this.debounceTimer = setTimeout(() => {
|
|
350
|
+
void this.refresh({ force: true }).catch(() => {});
|
|
351
|
+
}, 75);
|
|
352
|
+
this.debounceTimer.unref?.();
|
|
353
|
+
});
|
|
354
|
+
this.watcher.on("error", () => {
|
|
355
|
+
this.watcher?.close();
|
|
356
|
+
this.watcher = null;
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
this.watcher = null;
|
|
360
|
+
}
|
|
361
|
+
this.pollTimer = setInterval(() => {
|
|
362
|
+
void this.refresh().catch(() => {});
|
|
363
|
+
}, this.pollIntervalMs);
|
|
364
|
+
this.pollTimer.unref?.();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
project(task) {
|
|
368
|
+
return taskListUsageProjection(task, this.records.get(task.id) ?? null, {
|
|
369
|
+
calculatingStaleMs: this.calculatingStaleMs,
|
|
370
|
+
now: this.now(),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
updateLifecycleFingerprint() {
|
|
375
|
+
const timestamp = this.now();
|
|
376
|
+
const next = JSON.stringify([...this.records].flatMap(([taskId, record]) => (
|
|
377
|
+
calculatingUsageIsStale(record, timestamp, this.calculatingStaleMs) ? [taskId] : []
|
|
378
|
+
)));
|
|
379
|
+
const changed = next !== this.lifecycleFingerprint;
|
|
380
|
+
this.lifecycleFingerprint = next;
|
|
381
|
+
return changed;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async refresh({ force = false } = {}) {
|
|
385
|
+
if (this.refreshPromise) {
|
|
386
|
+
this.refreshQueued ||= force;
|
|
387
|
+
return this.refreshPromise;
|
|
388
|
+
}
|
|
389
|
+
this.refreshPromise = this.refreshOnce({ force }).finally(async () => {
|
|
390
|
+
this.refreshPromise = null;
|
|
391
|
+
if (this.refreshQueued) {
|
|
392
|
+
this.refreshQueued = false;
|
|
393
|
+
await this.refresh({ force: true });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
return this.refreshPromise;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async refreshOnce({ force }) {
|
|
400
|
+
const observed = await this.fingerprint(this.file);
|
|
401
|
+
if (!force && observed === this.currentFingerprint) {
|
|
402
|
+
const changed = this.updateLifecycleFingerprint();
|
|
403
|
+
if (changed) this.emit("change");
|
|
404
|
+
return changed;
|
|
405
|
+
}
|
|
406
|
+
let store;
|
|
407
|
+
try {
|
|
408
|
+
store = await this.readStore(this.workspace);
|
|
409
|
+
} catch {
|
|
410
|
+
store = { tasks: {} };
|
|
411
|
+
}
|
|
412
|
+
const after = await this.fingerprint(this.file);
|
|
413
|
+
if (observed !== after) {
|
|
414
|
+
this.refreshQueued = true;
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
const records = new Map(Object.entries(store.tasks ?? {}).map(
|
|
418
|
+
([taskId, record]) => [taskId, cachedUsageRecord(record)],
|
|
419
|
+
));
|
|
420
|
+
const recordsChanged = JSON.stringify([...records]) !== JSON.stringify([...this.records]);
|
|
421
|
+
this.records = records;
|
|
422
|
+
this.currentFingerprint = after;
|
|
423
|
+
const lifecycleChanged = this.updateLifecycleFingerprint();
|
|
424
|
+
const changed = recordsChanged || lifecycleChanged;
|
|
425
|
+
if (changed) this.emit("change");
|
|
426
|
+
return changed;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
close() {
|
|
430
|
+
this.started = false;
|
|
431
|
+
this.watcher?.close();
|
|
432
|
+
clearTimeout(this.debounceTimer);
|
|
433
|
+
clearInterval(this.pollTimer);
|
|
434
|
+
this.watcher = null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
238
438
|
export class DashboardMonitor extends EventEmitter {
|
|
239
439
|
constructor(workspace, {
|
|
240
440
|
debounceMs = 75,
|
|
@@ -599,6 +799,7 @@ export async function createDashboardServer({
|
|
|
599
799
|
taskchefVersion = TASKCHEF_VERSION,
|
|
600
800
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
601
801
|
usageTracker = null,
|
|
802
|
+
usageSummaryMonitor = null,
|
|
602
803
|
control = null,
|
|
603
804
|
controlReplayCache = createDashboardControlReplayCache(),
|
|
604
805
|
} = {}) {
|
|
@@ -622,19 +823,30 @@ export async function createDashboardServer({
|
|
|
622
823
|
throw new Error("dashboard control requires a valid TaskChef ownership controller");
|
|
623
824
|
}
|
|
624
825
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
826
|
+
let taskUsageTracker;
|
|
827
|
+
let taskUsageSummaryMonitor;
|
|
828
|
+
let identity;
|
|
829
|
+
try {
|
|
830
|
+
await monitor.start();
|
|
831
|
+
taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
|
|
832
|
+
taskUsageSummaryMonitor = usageSummaryMonitor
|
|
833
|
+
?? new UsageSummaryMonitor(monitor.workspace);
|
|
834
|
+
await taskUsageSummaryMonitor.start();
|
|
835
|
+
identity = Object.freeze({
|
|
836
|
+
schemaVersion: 1,
|
|
837
|
+
service: "taskchef-dashboard",
|
|
838
|
+
taskchefVersion,
|
|
839
|
+
serverVersion,
|
|
840
|
+
workspace: monitor.workspace,
|
|
841
|
+
launcher,
|
|
842
|
+
});
|
|
843
|
+
if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
|
|
844
|
+
throw new Error("dashboard identity exceeds the health response limit");
|
|
845
|
+
}
|
|
846
|
+
} catch (error) {
|
|
847
|
+
taskUsageSummaryMonitor?.close();
|
|
636
848
|
monitor.close();
|
|
637
|
-
throw
|
|
849
|
+
throw error;
|
|
638
850
|
}
|
|
639
851
|
const clients = new Set();
|
|
640
852
|
const archiveRequests = new Set();
|
|
@@ -647,10 +859,22 @@ export async function createDashboardServer({
|
|
|
647
859
|
const payload = ssePayload(event, value);
|
|
648
860
|
for (const client of clients) client.write(payload);
|
|
649
861
|
};
|
|
650
|
-
const
|
|
862
|
+
const dashboardSnapshot = () => {
|
|
863
|
+
const snapshot = monitor.snapshot();
|
|
864
|
+
return {
|
|
865
|
+
...snapshot,
|
|
866
|
+
tasks: snapshot.tasks.map((task) => ({
|
|
867
|
+
...task,
|
|
868
|
+
usage: taskUsageSummaryMonitor.project(task),
|
|
869
|
+
})),
|
|
870
|
+
};
|
|
871
|
+
};
|
|
872
|
+
const snapshotListener = () => broadcast("snapshot", dashboardSnapshot());
|
|
873
|
+
const usageSummaryListener = () => broadcast("snapshot", dashboardSnapshot());
|
|
651
874
|
const errorListener = () => broadcast("dashboard-error", publicMonitorError());
|
|
652
875
|
monitor.on("snapshot", snapshotListener);
|
|
653
876
|
monitor.on("monitorError", errorListener);
|
|
877
|
+
taskUsageSummaryMonitor.on("change", usageSummaryListener);
|
|
654
878
|
|
|
655
879
|
const handleRequest = async (request, response) => {
|
|
656
880
|
const method = request.method ?? "GET";
|
|
@@ -956,7 +1180,7 @@ export async function createDashboardServer({
|
|
|
956
1180
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
957
1181
|
response.end();
|
|
958
1182
|
} else {
|
|
959
|
-
sendJson(response, 200,
|
|
1183
|
+
sendJson(response, 200, dashboardSnapshot());
|
|
960
1184
|
}
|
|
961
1185
|
return;
|
|
962
1186
|
}
|
|
@@ -978,7 +1202,7 @@ export async function createDashboardServer({
|
|
|
978
1202
|
},
|
|
979
1203
|
});
|
|
980
1204
|
clients.add(client);
|
|
981
|
-
client.write(`retry: 2000\n\n${ssePayload("snapshot",
|
|
1205
|
+
client.write(`retry: 2000\n\n${ssePayload("snapshot", dashboardSnapshot())}`);
|
|
982
1206
|
client.heartbeat = setInterval(() => {
|
|
983
1207
|
if (!client.blocked) client.write(": heartbeat\n\n");
|
|
984
1208
|
}, 15_000);
|
|
@@ -1203,6 +1427,7 @@ export async function createDashboardServer({
|
|
|
1203
1427
|
server.listen(port, host, resolve);
|
|
1204
1428
|
}).catch((error) => {
|
|
1205
1429
|
monitor.close();
|
|
1430
|
+
taskUsageSummaryMonitor.close();
|
|
1206
1431
|
throw error;
|
|
1207
1432
|
});
|
|
1208
1433
|
|
|
@@ -1217,11 +1442,14 @@ export async function createDashboardServer({
|
|
|
1217
1442
|
url: `${allowedOrigin}/`,
|
|
1218
1443
|
identity,
|
|
1219
1444
|
monitor,
|
|
1445
|
+
usageSummaryMonitor: taskUsageSummaryMonitor,
|
|
1220
1446
|
get eventClientCount() { return clients.size; },
|
|
1221
1447
|
async close() {
|
|
1222
1448
|
monitor.off("snapshot", snapshotListener);
|
|
1223
1449
|
monitor.off("monitorError", errorListener);
|
|
1450
|
+
taskUsageSummaryMonitor.off("change", usageSummaryListener);
|
|
1224
1451
|
monitor.close();
|
|
1452
|
+
taskUsageSummaryMonitor.close();
|
|
1225
1453
|
for (const client of clients) client.close();
|
|
1226
1454
|
const closed = new Promise((resolve, reject) => server.close((error) =>
|
|
1227
1455
|
error ? reject(error) : resolve()));
|
package/src/usage.js
CHANGED
|
@@ -589,12 +589,12 @@ export async function readCcusageThreadUsage(threadId, {
|
|
|
589
589
|
}
|
|
590
590
|
}
|
|
591
591
|
|
|
592
|
-
function
|
|
592
|
+
export function usageStorePath(workspace) {
|
|
593
593
|
return path.join(workspace, USAGE_FILE_NAME);
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
export async function readUsageStore(workspace) {
|
|
597
|
-
const filePath =
|
|
597
|
+
const filePath = usageStorePath(workspace);
|
|
598
598
|
const details = await lstat(filePath).catch((error) => {
|
|
599
599
|
if (error.code === "ENOENT") return null;
|
|
600
600
|
throw error;
|
|
@@ -649,7 +649,7 @@ export function compactUsageStore(store) {
|
|
|
649
649
|
|
|
650
650
|
export async function writeUsageStore(workspace, store) {
|
|
651
651
|
await mkdir(workspace, { recursive: true });
|
|
652
|
-
const filePath =
|
|
652
|
+
const filePath = usageStorePath(workspace);
|
|
653
653
|
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
654
654
|
const serialized = `${JSON.stringify(compactUsageStore(store), null, 2)}\n`;
|
|
655
655
|
if (Buffer.byteLength(serialized, "utf8") > MAX_USAGE_FILE_BYTES) {
|