taskchef 7.4.0 → 7.6.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 +31 -14
- package/assets/codex.svg +3 -0
- package/docs/images/result-history-dashboard.jpg +0 -0
- package/docs/spec.md +40 -14
- package/docs/workflows.md +35 -17
- package/index.js +1 -0
- package/package.json +2 -1
- package/skills/taskchef-report/SKILL.md +8 -6
- package/src/cli.js +20 -0
- package/src/dashboard/app.js +128 -20
- package/src/dashboard/index.html +6 -3
- package/src/dashboard/styles.css +16 -4
- package/src/dashboard/time.js +146 -0
- package/src/dashboard.js +32 -1
- package/src/mcp.js +7 -1
- package/src/workspace.js +164 -68
package/src/dashboard/app.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
taskWithinDateFilter,
|
|
9
9
|
} from "./state.js";
|
|
10
10
|
import { openTaskFromControl } from "./actions.js";
|
|
11
|
+
import { RelativeTimeController, parsedTimestamp } from "./time.js";
|
|
11
12
|
|
|
12
13
|
const state = {
|
|
13
14
|
tasks: [],
|
|
@@ -17,6 +18,8 @@ const state = {
|
|
|
17
18
|
selectedTask: null,
|
|
18
19
|
};
|
|
19
20
|
let dateRefreshTimer = null;
|
|
21
|
+
let detailRequestGeneration = 0;
|
|
22
|
+
const relativeTimes = new RelativeTimeController();
|
|
20
23
|
|
|
21
24
|
const elements = {
|
|
22
25
|
clearNotifications: document.querySelector("#clear-notifications"),
|
|
@@ -31,7 +34,7 @@ const elements = {
|
|
|
31
34
|
dialogInstruction: document.querySelector("#dialog-instruction"),
|
|
32
35
|
dialogMetadata: document.querySelector("#dialog-metadata"),
|
|
33
36
|
dialogProject: document.querySelector("#dialog-project"),
|
|
34
|
-
|
|
37
|
+
dialogResults: document.querySelector("#dialog-results"),
|
|
35
38
|
dialogTitle: document.querySelector("#dialog-title"),
|
|
36
39
|
dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
|
|
37
40
|
emptyState: document.querySelector("#empty-state"),
|
|
@@ -44,12 +47,42 @@ const elements = {
|
|
|
44
47
|
toastList: document.querySelector("#toast-list"),
|
|
45
48
|
};
|
|
46
49
|
|
|
47
|
-
function
|
|
48
|
-
if (!value)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
function timestampControl(value, { accessibleName, key, prefix = "" }) {
|
|
51
|
+
if (!parsedTimestamp(value)) {
|
|
52
|
+
const missing = document.createElement("span");
|
|
53
|
+
missing.className = "timestamp-missing";
|
|
54
|
+
missing.textContent = `${prefix}—`;
|
|
55
|
+
return missing;
|
|
56
|
+
}
|
|
57
|
+
const control = document.createElement("button");
|
|
58
|
+
control.type = "button";
|
|
59
|
+
control.className = "timestamp-toggle";
|
|
60
|
+
const time = document.createElement("time");
|
|
61
|
+
control.append(time);
|
|
62
|
+
relativeTimes.register(key, value, ({ exact, iso, label }) => {
|
|
63
|
+
time.dateTime = iso;
|
|
64
|
+
time.textContent = `${prefix}${label}`;
|
|
65
|
+
const action = exact ? "Show relative time" : "Show exact date and time";
|
|
66
|
+
control.title = action;
|
|
67
|
+
control.setAttribute("aria-label", `${accessibleName}: ${label}. ${action}.`);
|
|
68
|
+
}, { isActive: () => control.isConnected });
|
|
69
|
+
control.addEventListener("click", () => relativeTimes.toggle(key));
|
|
70
|
+
return control;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function codexIcon() {
|
|
74
|
+
const icon = document.createElement("span");
|
|
75
|
+
icon.className = "codex-icon";
|
|
76
|
+
icon.setAttribute("aria-hidden", "true");
|
|
77
|
+
return icon;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function configureOpenTaskControl(control, ariaLabel) {
|
|
81
|
+
control.classList.add("task-action");
|
|
82
|
+
control.setAttribute("aria-label", ariaLabel);
|
|
83
|
+
const label = document.createElement("span");
|
|
84
|
+
label.textContent = "Open task";
|
|
85
|
+
control.replaceChildren(codexIcon(), label);
|
|
53
86
|
}
|
|
54
87
|
|
|
55
88
|
function setConnection(connected) {
|
|
@@ -115,16 +148,55 @@ function detailRow(term, value) {
|
|
|
115
148
|
const dt = document.createElement("dt");
|
|
116
149
|
dt.textContent = term;
|
|
117
150
|
const dd = document.createElement("dd");
|
|
118
|
-
|
|
151
|
+
if (value instanceof Node) dd.append(value);
|
|
152
|
+
else dd.textContent = value ?? "—";
|
|
119
153
|
return [dt, dd];
|
|
120
154
|
}
|
|
121
155
|
|
|
122
|
-
function
|
|
123
|
-
|
|
156
|
+
function resultHistory(taskId, results) {
|
|
157
|
+
if (results.length === 0) {
|
|
158
|
+
const empty = document.createElement("p");
|
|
159
|
+
empty.className = "result-history-empty";
|
|
160
|
+
empty.textContent = "No semantic result has been reported yet.";
|
|
161
|
+
return [empty];
|
|
162
|
+
}
|
|
163
|
+
return [...results].reverse().map((result, index) => {
|
|
164
|
+
const item = document.createElement("article");
|
|
165
|
+
item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
|
|
166
|
+
const header = document.createElement("div");
|
|
167
|
+
header.className = "result-history-header";
|
|
168
|
+
const status = document.createElement("span");
|
|
169
|
+
status.className = `status status-${result.status}`;
|
|
170
|
+
status.textContent = result.status.replaceAll("_", " ");
|
|
171
|
+
const resultKey = result.turnId ?? `${result.status}:${index}`;
|
|
172
|
+
const timestamp = timestampControl(result.updatedAt, {
|
|
173
|
+
accessibleName: `Result updated time for ${result.status.replaceAll("_", " ")}`,
|
|
174
|
+
key: `detail:${taskId}:result:${resultKey}`,
|
|
175
|
+
});
|
|
176
|
+
header.append(status, timestamp);
|
|
177
|
+
const summary = document.createElement("p");
|
|
178
|
+
summary.className = "preserve-lines";
|
|
179
|
+
summary.textContent = result.summary;
|
|
180
|
+
const turn = document.createElement("p");
|
|
181
|
+
turn.className = "result-history-turn";
|
|
182
|
+
turn.textContent = result.turnId ? `Turn ${result.turnId}` : "No turn ID (creation failure)";
|
|
183
|
+
item.append(header, summary, turn);
|
|
184
|
+
return item;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderDialog(task) {
|
|
189
|
+
const preservedResults = state.selectedTask?.id === task.id
|
|
190
|
+
? state.selectedTask.results
|
|
191
|
+
: null;
|
|
192
|
+
const detailedTask = {
|
|
193
|
+
...task,
|
|
194
|
+
results: task.results ?? preservedResults ?? [],
|
|
195
|
+
};
|
|
196
|
+
state.selectedTask = detailedTask;
|
|
124
197
|
elements.dialogProject.textContent = task.project.name;
|
|
125
198
|
elements.dialogTitle.textContent = task.title;
|
|
126
|
-
elements.
|
|
127
|
-
?? "No semantic result has been reported yet.";
|
|
199
|
+
elements.dialogResults.replaceChildren(...resultHistory(task.id, detailedTask.results));
|
|
128
200
|
elements.dialogInstruction.textContent = task.instruction;
|
|
129
201
|
elements.copyThreadId.disabled = !task.threadId;
|
|
130
202
|
elements.dialogMetadata.replaceChildren(
|
|
@@ -132,17 +204,49 @@ function openDialog(task) {
|
|
|
132
204
|
...detailRow("Current turn ID", task.turnId),
|
|
133
205
|
...detailRow("Last result status", task.lastResult?.status?.replaceAll("_", " ")),
|
|
134
206
|
...detailRow("Last result turn ID", task.lastResult?.turnId),
|
|
135
|
-
...detailRow("Last result updated",
|
|
207
|
+
...detailRow("Last result updated", timestampControl(task.lastResult?.updatedAt, {
|
|
208
|
+
accessibleName: `Last result updated time for ${task.title}`,
|
|
209
|
+
key: `detail:${task.id}:last-result-updated`,
|
|
210
|
+
})),
|
|
136
211
|
...detailRow("Task ID", task.id),
|
|
137
212
|
...detailRow("Thread ID", task.threadId),
|
|
138
213
|
...detailRow("Project path", task.project.path),
|
|
139
|
-
...detailRow("Created",
|
|
140
|
-
|
|
214
|
+
...detailRow("Created", timestampControl(task.createdAt, {
|
|
215
|
+
accessibleName: `Created time for ${task.title}`,
|
|
216
|
+
key: `detail:${task.id}:created`,
|
|
217
|
+
})),
|
|
218
|
+
...detailRow("Updated", timestampControl(
|
|
141
219
|
task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
|
|
220
|
+
{
|
|
221
|
+
accessibleName: `Updated time for ${task.title}`,
|
|
222
|
+
key: `detail:${task.id}:updated`,
|
|
223
|
+
},
|
|
142
224
|
)),
|
|
143
225
|
...detailRow("Updated by", task.updatedBy),
|
|
144
226
|
);
|
|
227
|
+
configureOpenTaskControl(elements.openProject, `Open ${task.title} in Codex`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function openDialog(task) {
|
|
231
|
+
const requestGeneration = ++detailRequestGeneration;
|
|
232
|
+
renderDialog(task);
|
|
145
233
|
if (!elements.dialog.open) elements.dialog.showModal();
|
|
234
|
+
try {
|
|
235
|
+
const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
|
|
236
|
+
if (!response.ok) throw new Error("Task details are unavailable.");
|
|
237
|
+
const detail = await response.json();
|
|
238
|
+
if (
|
|
239
|
+
requestGeneration === detailRequestGeneration
|
|
240
|
+
&& state.selectedTask?.id === task.id
|
|
241
|
+
&& elements.dialog.open
|
|
242
|
+
) {
|
|
243
|
+
renderDialog(detail.task);
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
if (state.selectedTask?.id === task.id) {
|
|
247
|
+
showMessage("Task result history is temporarily unavailable.");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
146
250
|
}
|
|
147
251
|
|
|
148
252
|
function taskCard(task) {
|
|
@@ -165,16 +269,20 @@ function taskCard(task) {
|
|
|
165
269
|
const summary = document.createElement("p");
|
|
166
270
|
summary.className = "task-summary";
|
|
167
271
|
summary.textContent = task.lastResult?.summary ?? "No semantic result reported yet.";
|
|
168
|
-
const time =
|
|
169
|
-
|
|
170
|
-
|
|
272
|
+
const time = timestampControl(
|
|
273
|
+
task.meaningfulUpdatedAt ?? task.updatedAt ?? task.createdAt,
|
|
274
|
+
{
|
|
275
|
+
accessibleName: `Updated time for ${task.title}`,
|
|
276
|
+
key: `list:${task.id}:updated`,
|
|
277
|
+
prefix: "Updated ",
|
|
278
|
+
},
|
|
279
|
+
);
|
|
171
280
|
const footer = document.createElement("div");
|
|
172
281
|
footer.className = "task-footer";
|
|
173
282
|
const openTask = document.createElement("button");
|
|
174
283
|
openTask.type = "button";
|
|
175
284
|
openTask.className = "secondary-button task-open";
|
|
176
|
-
openTask
|
|
177
|
-
openTask.setAttribute("aria-label", `Open ${task.title} in Codex`);
|
|
285
|
+
configureOpenTaskControl(openTask, `Open ${task.title} in Codex`);
|
|
178
286
|
openTask.addEventListener("click", (event) => openTaskFromControl(event, task.id, {
|
|
179
287
|
showMessage,
|
|
180
288
|
}));
|
package/src/dashboard/index.html
CHANGED
|
@@ -83,12 +83,15 @@
|
|
|
83
83
|
<button id="close-dialog" class="icon-button" type="button" aria-label="Close task details">×</button>
|
|
84
84
|
</div>
|
|
85
85
|
<div class="dialog-actions">
|
|
86
|
-
<button id="open-codex" class="primary-button" type="button"
|
|
86
|
+
<button id="open-codex" class="primary-button task-action" type="button" aria-label="Open this task in Codex">
|
|
87
|
+
<span class="codex-icon" aria-hidden="true"></span>
|
|
88
|
+
<span>Open task</span>
|
|
89
|
+
</button>
|
|
87
90
|
<button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
|
|
88
91
|
</div>
|
|
89
92
|
<section>
|
|
90
|
-
<h3>Result</h3>
|
|
91
|
-
<
|
|
93
|
+
<h3>Result history</h3>
|
|
94
|
+
<div id="dialog-results" class="result-history"></div>
|
|
92
95
|
</section>
|
|
93
96
|
<section>
|
|
94
97
|
<h3>Original instruction</h3>
|
package/src/dashboard/styles.css
CHANGED
|
@@ -51,7 +51,7 @@ h3 { margin-bottom: 8px; font-size: 0.84rem; letter-spacing: 0.08em; text-transf
|
|
|
51
51
|
.dashboard-icon img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
52
52
|
|
|
53
53
|
.eyebrow { margin-bottom: 10px; color: var(--accent); font-size: 0.78rem; font-weight: 750; letter-spacing: 0.15em; text-transform: uppercase; }
|
|
54
|
-
.subtitle, .task-project, time { color: var(--muted); }
|
|
54
|
+
.subtitle, .task-project, time, .timestamp-missing { color: var(--muted); }
|
|
55
55
|
.subtitle { margin-bottom: 0; }
|
|
56
56
|
|
|
57
57
|
.connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.9rem; }
|
|
@@ -78,9 +78,13 @@ select { min-width: 180px; padding: 9px 34px 9px 11px; border: 1px solid var(--b
|
|
|
78
78
|
.task-title:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
|
79
79
|
.task-project { margin: 3px 0 12px; font-size: 0.86rem; }
|
|
80
80
|
.task-summary { max-width: 78ch; margin-bottom: 14px; }
|
|
81
|
-
time { font-size: 0.78rem; }
|
|
81
|
+
time, .timestamp-missing { font-size: 0.78rem; }
|
|
82
|
+
.timestamp-toggle { padding: 2px 0; border: 0; background: none; cursor: pointer; text-align: left; }
|
|
83
|
+
.timestamp-toggle:hover time { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
|
|
82
84
|
.task-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
83
85
|
.task-open { flex: none; }
|
|
86
|
+
.task-action { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 32px; padding: 5px 9px; font-size: 0.82rem; line-height: 1.2; white-space: nowrap; }
|
|
87
|
+
.codex-icon { display: inline-block; flex: none; width: 16px; height: 16px; background: currentColor; mask: url("/assets/codex.svg") center / contain no-repeat; }
|
|
84
88
|
|
|
85
89
|
.status { flex: none; padding: 4px 9px; border-radius: 999px; background: var(--surface-muted); color: var(--muted); font-size: 0.72rem; font-weight: 750; text-transform: capitalize; }
|
|
86
90
|
.status-completed { background: var(--accent-soft); color: var(--accent); }
|
|
@@ -117,6 +121,14 @@ dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px);
|
|
|
117
121
|
.primary-button:disabled, .secondary-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
118
122
|
dialog section + section { margin-top: 24px; }
|
|
119
123
|
.preserve-lines { white-space: pre-wrap; }
|
|
124
|
+
.result-history { display: grid; gap: 10px; }
|
|
125
|
+
.result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
|
|
126
|
+
.result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
|
|
127
|
+
.result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
|
|
128
|
+
.result-history-item p { margin-bottom: 7px; }
|
|
129
|
+
.result-history-item p:last-child { margin-bottom: 0; }
|
|
130
|
+
.result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
131
|
+
.result-history-empty { margin: 0; }
|
|
120
132
|
pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius: 7px; background: var(--surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.86rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
121
133
|
.metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
|
|
122
134
|
.metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
@@ -134,8 +146,8 @@ pre { max-height: 280px; margin: 0; padding: 14px; overflow: auto; border-radius
|
|
|
134
146
|
.task-count { margin: 0; }
|
|
135
147
|
.task-heading { align-items: flex-start; }
|
|
136
148
|
.task-footer { align-items: flex-start; flex-direction: column; }
|
|
137
|
-
.task-open {
|
|
138
|
-
.dialog-actions { align-items:
|
|
149
|
+
.task-open { align-self: flex-start; }
|
|
150
|
+
.dialog-actions { align-items: center; flex-flow: row wrap; }
|
|
139
151
|
.metadata { grid-template-columns: 1fr; }
|
|
140
152
|
.metadata dt { padding-bottom: 0; border-bottom: 0; }
|
|
141
153
|
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
export const RELATIVE_TIME_REFRESH_MS = 30_000;
|
|
2
|
+
export const RELATIVE_DATE_LIMIT_DAYS = 30;
|
|
3
|
+
|
|
4
|
+
const MINUTE_MS = 60_000;
|
|
5
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
6
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
7
|
+
|
|
8
|
+
export function parsedTimestamp(value) {
|
|
9
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
10
|
+
const milliseconds = Date.parse(value);
|
|
11
|
+
if (!Number.isFinite(milliseconds)) return null;
|
|
12
|
+
return {
|
|
13
|
+
date: new Date(milliseconds),
|
|
14
|
+
iso: new Date(milliseconds).toISOString(),
|
|
15
|
+
milliseconds,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatExactTime(value, { locale, timeZone } = {}) {
|
|
20
|
+
const parsed = parsedTimestamp(value);
|
|
21
|
+
if (!parsed) return "—";
|
|
22
|
+
return new Intl.DateTimeFormat(locale, {
|
|
23
|
+
dateStyle: "medium",
|
|
24
|
+
timeStyle: "short",
|
|
25
|
+
timeZone,
|
|
26
|
+
}).format(parsed.date);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function hourPhrase(milliseconds, direction) {
|
|
30
|
+
const hours = Math.floor(milliseconds / HOUR_MS);
|
|
31
|
+
const minutes = Math.floor((milliseconds % HOUR_MS) / MINUTE_MS);
|
|
32
|
+
const hourText = hours === 1 ? "1 hour" : `${hours} hours`;
|
|
33
|
+
const detail = hours < 6 && minutes > 0 ? ` ${minutes} minutes` : "";
|
|
34
|
+
return direction === "future"
|
|
35
|
+
? `in ${hourText}${detail}`
|
|
36
|
+
: `${hourText}${detail} ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatRelativeTime(value, {
|
|
40
|
+
now = Date.now(),
|
|
41
|
+
locale,
|
|
42
|
+
timeZone,
|
|
43
|
+
} = {}) {
|
|
44
|
+
const parsed = parsedTimestamp(value);
|
|
45
|
+
if (!parsed) return "—";
|
|
46
|
+
const difference = now - parsed.milliseconds;
|
|
47
|
+
const future = difference < 0;
|
|
48
|
+
const elapsed = Math.abs(difference);
|
|
49
|
+
if (elapsed < MINUTE_MS) return "just now";
|
|
50
|
+
if (elapsed < 2 * MINUTE_MS) return future ? "in 1 minute" : "1 minute ago";
|
|
51
|
+
if (elapsed < HOUR_MS) {
|
|
52
|
+
const minutes = future
|
|
53
|
+
? Math.ceil(elapsed / MINUTE_MS)
|
|
54
|
+
: Math.floor(elapsed / MINUTE_MS);
|
|
55
|
+
return future ? `in ${minutes} minutes` : `${minutes} minutes ago`;
|
|
56
|
+
}
|
|
57
|
+
if (elapsed < 2 * HOUR_MS) return future ? "in 1 hour" : "1 hour ago";
|
|
58
|
+
if (elapsed < DAY_MS) return hourPhrase(elapsed, future ? "future" : "past");
|
|
59
|
+
if (elapsed < 2 * DAY_MS) return future ? "in 1 day" : "1 day ago";
|
|
60
|
+
if (elapsed < RELATIVE_DATE_LIMIT_DAYS * DAY_MS) {
|
|
61
|
+
const days = future
|
|
62
|
+
? Math.ceil(elapsed / DAY_MS)
|
|
63
|
+
: Math.floor(elapsed / DAY_MS);
|
|
64
|
+
return future ? `in ${days} days` : `${days} days ago`;
|
|
65
|
+
}
|
|
66
|
+
return new Intl.DateTimeFormat(locale, {
|
|
67
|
+
dateStyle: "medium",
|
|
68
|
+
timeZone,
|
|
69
|
+
}).format(parsed.date);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function timestampPresentation(value, {
|
|
73
|
+
exact = false,
|
|
74
|
+
now = Date.now(),
|
|
75
|
+
locale,
|
|
76
|
+
timeZone,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const parsed = parsedTimestamp(value);
|
|
79
|
+
if (!parsed) return { valid: false, label: "—", iso: null };
|
|
80
|
+
return {
|
|
81
|
+
exact,
|
|
82
|
+
iso: parsed.iso,
|
|
83
|
+
label: exact
|
|
84
|
+
? formatExactTime(value, { locale, timeZone })
|
|
85
|
+
: formatRelativeTime(value, { now, locale, timeZone }),
|
|
86
|
+
valid: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class RelativeTimeController {
|
|
91
|
+
constructor({
|
|
92
|
+
now = () => Date.now(),
|
|
93
|
+
setIntervalFn = (...args) => globalThis.setInterval(...args),
|
|
94
|
+
clearIntervalFn = (...args) => globalThis.clearInterval(...args),
|
|
95
|
+
refreshEveryMs = RELATIVE_TIME_REFRESH_MS,
|
|
96
|
+
} = {}) {
|
|
97
|
+
this.now = now;
|
|
98
|
+
this.setIntervalFn = setIntervalFn;
|
|
99
|
+
this.clearIntervalFn = clearIntervalFn;
|
|
100
|
+
this.refreshEveryMs = refreshEveryMs;
|
|
101
|
+
this.entries = new Map();
|
|
102
|
+
this.exactKeys = new Set();
|
|
103
|
+
this.timer = null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
register(key, value, render, { isActive = () => true } = {}) {
|
|
107
|
+
const token = Symbol(key);
|
|
108
|
+
this.entries.set(token, { isActive, key, render, value });
|
|
109
|
+
this.renderEntry(this.entries.get(token));
|
|
110
|
+
if (this.timer === null) {
|
|
111
|
+
this.timer = this.setIntervalFn(() => this.refresh(), this.refreshEveryMs);
|
|
112
|
+
}
|
|
113
|
+
return () => this.entries.delete(token);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
renderEntry(entry) {
|
|
117
|
+
entry.render(timestampPresentation(entry.value, {
|
|
118
|
+
exact: this.exactKeys.has(entry.key),
|
|
119
|
+
now: this.now(),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
toggle(key) {
|
|
124
|
+
if (this.exactKeys.has(key)) this.exactKeys.delete(key);
|
|
125
|
+
else this.exactKeys.add(key);
|
|
126
|
+
for (const entry of this.entries.values()) {
|
|
127
|
+
if (entry.key === key) this.renderEntry(entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
refresh() {
|
|
132
|
+
for (const [token, entry] of this.entries) {
|
|
133
|
+
if (!entry.isActive()) {
|
|
134
|
+
this.entries.delete(token);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!this.exactKeys.has(entry.key)) this.renderEntry(entry);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
stop() {
|
|
142
|
+
if (this.timer !== null) this.clearIntervalFn(this.timer);
|
|
143
|
+
this.timer = null;
|
|
144
|
+
this.entries.clear();
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/dashboard.js
CHANGED
|
@@ -43,10 +43,12 @@ const STATIC_FILES = new Map([
|
|
|
43
43
|
["/", [path.join(STATIC_ROOT, "index.html"), "text/html; charset=utf-8"]],
|
|
44
44
|
["/actions.js", [path.join(STATIC_ROOT, "actions.js"), "text/javascript; charset=utf-8"]],
|
|
45
45
|
["/app.js", [path.join(STATIC_ROOT, "app.js"), "text/javascript; charset=utf-8"]],
|
|
46
|
+
["/time.js", [path.join(STATIC_ROOT, "time.js"), "text/javascript; charset=utf-8"]],
|
|
46
47
|
["/state.js", [path.join(STATIC_ROOT, "state.js"), "text/javascript; charset=utf-8"]],
|
|
47
48
|
["/styles.css", [path.join(STATIC_ROOT, "styles.css"), "text/css; charset=utf-8"]],
|
|
48
49
|
["/assets/taskchef-dark.svg", [path.join(ASSET_ROOT, "taskchef-dark.svg"), "image/svg+xml"]],
|
|
49
50
|
["/assets/taskchef.svg", [path.join(ASSET_ROOT, "taskchef.svg"), "image/svg+xml"]],
|
|
51
|
+
["/assets/codex.svg", [path.join(ASSET_ROOT, "codex.svg"), "image/svg+xml"]],
|
|
50
52
|
]);
|
|
51
53
|
|
|
52
54
|
export function dashboardAuthority(host, port) {
|
|
@@ -136,6 +138,14 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
136
138
|
boundedText(task.turnId, 512, `${name} turn ID`);
|
|
137
139
|
boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
|
|
138
140
|
boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
|
|
141
|
+
const results = task.results ?? [];
|
|
142
|
+
if (results.length > 10_000) {
|
|
143
|
+
throw new Error(`${name} has too many results for the dashboard`);
|
|
144
|
+
}
|
|
145
|
+
for (const [resultIndex, result] of results.entries()) {
|
|
146
|
+
boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
|
|
147
|
+
boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
|
|
148
|
+
}
|
|
139
149
|
boundedText(task.project.name, 1_000, `${name} project name`);
|
|
140
150
|
boundedText(task.project.path, 8_192, `${name} project path`);
|
|
141
151
|
boundedText(task.project.description, 4_000, `${name} project description`);
|
|
@@ -148,6 +158,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
148
158
|
}
|
|
149
159
|
}
|
|
150
160
|
|
|
161
|
+
function taskListProjection(task) {
|
|
162
|
+
const { results: _results, ...projection } = task;
|
|
163
|
+
return projection;
|
|
164
|
+
}
|
|
165
|
+
|
|
151
166
|
export class DashboardMonitor extends EventEmitter {
|
|
152
167
|
constructor(workspace, {
|
|
153
168
|
debounceMs = 75,
|
|
@@ -197,7 +212,7 @@ export class DashboardMonitor extends EventEmitter {
|
|
|
197
212
|
generatedAt: new Date().toISOString(),
|
|
198
213
|
healthy: !this.unhealthy,
|
|
199
214
|
tasks: this.tasks.map((task) => ({
|
|
200
|
-
...task,
|
|
215
|
+
...taskListProjection(task),
|
|
201
216
|
meaningfulUpdatedAt: new Date(
|
|
202
217
|
meaningfulUpdateTime(task, this.observedUpdateTimes),
|
|
203
218
|
).toISOString(),
|
|
@@ -510,6 +525,22 @@ export async function createDashboardServer({
|
|
|
510
525
|
return;
|
|
511
526
|
}
|
|
512
527
|
|
|
528
|
+
const detailMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)$/);
|
|
529
|
+
if (detailMatch && (method === "GET" || method === "HEAD")) {
|
|
530
|
+
const task = monitor.tasks.find((candidate) => candidate.id === detailMatch[1]);
|
|
531
|
+
if (!task) {
|
|
532
|
+
sendJson(response, 404, { message: "Task not found." });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (method === "HEAD") {
|
|
536
|
+
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
537
|
+
response.end();
|
|
538
|
+
} else {
|
|
539
|
+
sendJson(response, 200, { schemaVersion: 1, task });
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
513
544
|
const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
|
|
514
545
|
if (taskMatch && method === "POST") {
|
|
515
546
|
if (request.headers.origin !== allowedOrigin) {
|
package/src/mcp.js
CHANGED
|
@@ -21,7 +21,7 @@ const projectSchema = z.object({
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
const taskSchema = z.object({
|
|
24
|
-
schemaVersion: z.union([z.literal(4), z.literal(5)]),
|
|
24
|
+
schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
|
|
25
25
|
id: z.string(),
|
|
26
26
|
project: projectSchema,
|
|
27
27
|
title: z.string(),
|
|
@@ -33,6 +33,12 @@ const taskSchema = z.object({
|
|
|
33
33
|
turnId: z.string().nullable(),
|
|
34
34
|
updatedAt: z.string(),
|
|
35
35
|
updatedBy: z.enum(["dispatcher", "mcp"]),
|
|
36
|
+
results: z.array(z.object({
|
|
37
|
+
status: z.enum(["needs_input", "completed", "failed"]),
|
|
38
|
+
summary: z.string(),
|
|
39
|
+
turnId: z.string().nullable(),
|
|
40
|
+
updatedAt: z.string(),
|
|
41
|
+
})),
|
|
36
42
|
lastResult: z.object({
|
|
37
43
|
status: z.enum(["needs_input", "completed", "failed"]),
|
|
38
44
|
summary: z.string(),
|