taskchef 7.3.0 → 7.5.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 +58 -15
- package/assets/taskchef-dispatcher-instructions.md +9 -0
- package/docs/images/result-history-dashboard.jpg +0 -0
- package/docs/spec.md +99 -17
- package/docs/workflows.md +75 -17
- package/index.js +10 -0
- package/mcp/server.js +12 -0
- package/package.json +2 -1
- package/skills/taskchef-bootstrap/SKILL.md +4 -2
- package/skills/taskchef-report/SKILL.md +8 -6
- package/src/cli.js +40 -6
- package/src/dashboard/app.js +63 -5
- package/src/dashboard/index.html +2 -2
- package/src/dashboard/styles.css +8 -0
- package/src/dashboard-manager.js +183 -0
- package/src/dashboard.js +57 -1
- package/src/mcp.js +56 -2
- package/src/version.js +7 -0
- package/src/workspace.js +164 -68
package/src/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
initializeWorkspace,
|
|
15
15
|
listProjects,
|
|
16
16
|
listTasks,
|
|
17
|
+
migrateTaskLog,
|
|
17
18
|
prepareDispatch,
|
|
18
19
|
recordTask,
|
|
19
20
|
removeProject,
|
|
@@ -182,11 +183,28 @@ function taskDetails(task) {
|
|
|
182
183
|
`Updated by: ${singleLineDetail(task.updatedBy ?? "-")}`,
|
|
183
184
|
`Task ID: ${singleLineDetail(task.id)}`,
|
|
184
185
|
`Thread ID: ${singleLineDetail(task.threadId ?? "-")}`,
|
|
186
|
+
`Result count: ${task.results.length}`,
|
|
187
|
+
"Result history (newest first):",
|
|
188
|
+
...[...task.results].reverse().map((result) => (
|
|
189
|
+
`- ${singleLineDetail(result.updatedAt)} | ${singleLineDetail(result.status)} | turn ${singleLineDetail(result.turnId ?? "-")} | ${singleLineDetail(result.summary)}`
|
|
190
|
+
)),
|
|
185
191
|
"Instruction:",
|
|
186
192
|
task.instruction,
|
|
187
193
|
].join("\n");
|
|
188
194
|
}
|
|
189
195
|
|
|
196
|
+
async function migrate(args) {
|
|
197
|
+
validateCommandArgs(args, 2, { values: ["--workspace"], switches: ["--json"] });
|
|
198
|
+
const result = await migrateTaskLog(workspaceRoot(args));
|
|
199
|
+
print(result, args, (value) => [
|
|
200
|
+
`Task log: ${value.action}`,
|
|
201
|
+
`Tasks: ${value.taskCount}`,
|
|
202
|
+
`Migrated: ${value.migratedCount}`,
|
|
203
|
+
`Backup: ${value.backupPath ?? "not needed"}`,
|
|
204
|
+
].join("\n"));
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
190
208
|
async function readTaskForShow(workspace, taskId) {
|
|
191
209
|
const id = requireSafeId(taskId, "taskId");
|
|
192
210
|
const tasks = await listTasks(workspace);
|
|
@@ -409,10 +427,22 @@ async function dashboard(args) {
|
|
|
409
427
|
values: ["--port", "--workspace"],
|
|
410
428
|
switches: ["--json"],
|
|
411
429
|
});
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
430
|
+
const port = dashboardPort(args);
|
|
431
|
+
let server;
|
|
432
|
+
try {
|
|
433
|
+
server = await createDashboardServer({
|
|
434
|
+
workspace: workspaceRoot(args),
|
|
435
|
+
port,
|
|
436
|
+
});
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (error?.code === "EADDRINUSE") {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`dashboard port 127.0.0.1:${port} is already in use; `
|
|
441
|
+
+ "stop the existing listener or choose another --port (TaskChef will not terminate it)",
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
416
446
|
print({
|
|
417
447
|
schemaVersion: 1,
|
|
418
448
|
url: server.url,
|
|
@@ -444,6 +474,7 @@ Usage:
|
|
|
444
474
|
taskchef doctor [--json] [--workspace <path>]
|
|
445
475
|
taskchef workspace path [--json] [--workspace <path>]
|
|
446
476
|
taskchef workspace init [--register-codex] [--codex-cli <path>] [--json] [--workspace <path>]
|
|
477
|
+
taskchef workspace migrate [--json] [--workspace <path>]
|
|
447
478
|
taskchef project add <path> [--name <name>] [--description <text>] [--github-repo <url> ... | --no-github] [--json] [--workspace <path>]
|
|
448
479
|
taskchef project import [<file> | -] [--replace] [--json] [--workspace <path>]
|
|
449
480
|
taskchef project list [--json] [--workspace <path>]
|
|
@@ -461,8 +492,10 @@ Project import reads a JSON
|
|
|
461
492
|
array from a file, or from standard input when the source is '-' or omitted.
|
|
462
493
|
Workspace resolution precedence is --workspace, TASKCHEF_WORKSPACE, then
|
|
463
494
|
~/.agents/taskchef.
|
|
464
|
-
The dashboard binds to 127.0.0.1 and reads the canonical task log
|
|
465
|
-
modifying dispatcher-workspace files.
|
|
495
|
+
The foreground dashboard binds to 127.0.0.1 and reads the canonical task log
|
|
496
|
+
without modifying dispatcher-workspace files. The dispatcher MCP may reuse a
|
|
497
|
+
compatible foreground server on port 3210; neither mode terminates a listener
|
|
498
|
+
that already occupies its requested port.
|
|
466
499
|
`);
|
|
467
500
|
}
|
|
468
501
|
|
|
@@ -475,6 +508,7 @@ export async function runCli(args) {
|
|
|
475
508
|
if (args[0] === "doctor") return doctor(args);
|
|
476
509
|
if (args[0] === "workspace" && args[1] === "path") return workspacePath(args);
|
|
477
510
|
if (args[0] === "workspace" && args[1] === "init") return initialize(args);
|
|
511
|
+
if (args[0] === "workspace" && args[1] === "migrate") return migrate(args);
|
|
478
512
|
if (args[0] === "project" && args[1] === "add") return projectAdd(args);
|
|
479
513
|
if (args[0] === "project" && args[1] === "import") return projectImport(args);
|
|
480
514
|
if (args[0] === "project" && args[1] === "list") return projectList(args);
|
package/src/dashboard/app.js
CHANGED
|
@@ -17,6 +17,7 @@ const state = {
|
|
|
17
17
|
selectedTask: null,
|
|
18
18
|
};
|
|
19
19
|
let dateRefreshTimer = null;
|
|
20
|
+
let detailRequestGeneration = 0;
|
|
20
21
|
|
|
21
22
|
const elements = {
|
|
22
23
|
clearNotifications: document.querySelector("#clear-notifications"),
|
|
@@ -31,7 +32,7 @@ const elements = {
|
|
|
31
32
|
dialogInstruction: document.querySelector("#dialog-instruction"),
|
|
32
33
|
dialogMetadata: document.querySelector("#dialog-metadata"),
|
|
33
34
|
dialogProject: document.querySelector("#dialog-project"),
|
|
34
|
-
|
|
35
|
+
dialogResults: document.querySelector("#dialog-results"),
|
|
35
36
|
dialogTitle: document.querySelector("#dialog-title"),
|
|
36
37
|
dismissDashboardMessage: document.querySelector("#dismiss-dashboard-message"),
|
|
37
38
|
emptyState: document.querySelector("#empty-state"),
|
|
@@ -119,12 +120,48 @@ function detailRow(term, value) {
|
|
|
119
120
|
return [dt, dd];
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
function
|
|
123
|
-
|
|
123
|
+
function resultHistory(results) {
|
|
124
|
+
if (results.length === 0) {
|
|
125
|
+
const empty = document.createElement("p");
|
|
126
|
+
empty.className = "result-history-empty";
|
|
127
|
+
empty.textContent = "No semantic result has been reported yet.";
|
|
128
|
+
return [empty];
|
|
129
|
+
}
|
|
130
|
+
return [...results].reverse().map((result, index) => {
|
|
131
|
+
const item = document.createElement("article");
|
|
132
|
+
item.className = `result-history-item${index === 0 ? " result-history-latest" : ""}`;
|
|
133
|
+
const header = document.createElement("div");
|
|
134
|
+
header.className = "result-history-header";
|
|
135
|
+
const status = document.createElement("span");
|
|
136
|
+
status.className = `status status-${result.status}`;
|
|
137
|
+
status.textContent = result.status.replaceAll("_", " ");
|
|
138
|
+
const timestamp = document.createElement("time");
|
|
139
|
+
timestamp.dateTime = result.updatedAt;
|
|
140
|
+
timestamp.textContent = formatTime(result.updatedAt);
|
|
141
|
+
header.append(status, timestamp);
|
|
142
|
+
const summary = document.createElement("p");
|
|
143
|
+
summary.className = "preserve-lines";
|
|
144
|
+
summary.textContent = result.summary;
|
|
145
|
+
const turn = document.createElement("p");
|
|
146
|
+
turn.className = "result-history-turn";
|
|
147
|
+
turn.textContent = result.turnId ? `Turn ${result.turnId}` : "No turn ID (creation failure)";
|
|
148
|
+
item.append(header, summary, turn);
|
|
149
|
+
return item;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderDialog(task) {
|
|
154
|
+
const preservedResults = state.selectedTask?.id === task.id
|
|
155
|
+
? state.selectedTask.results
|
|
156
|
+
: null;
|
|
157
|
+
const detailedTask = {
|
|
158
|
+
...task,
|
|
159
|
+
results: task.results ?? preservedResults ?? [],
|
|
160
|
+
};
|
|
161
|
+
state.selectedTask = detailedTask;
|
|
124
162
|
elements.dialogProject.textContent = task.project.name;
|
|
125
163
|
elements.dialogTitle.textContent = task.title;
|
|
126
|
-
elements.
|
|
127
|
-
?? "No semantic result has been reported yet.";
|
|
164
|
+
elements.dialogResults.replaceChildren(...resultHistory(detailedTask.results));
|
|
128
165
|
elements.dialogInstruction.textContent = task.instruction;
|
|
129
166
|
elements.copyThreadId.disabled = !task.threadId;
|
|
130
167
|
elements.dialogMetadata.replaceChildren(
|
|
@@ -142,7 +179,28 @@ function openDialog(task) {
|
|
|
142
179
|
)),
|
|
143
180
|
...detailRow("Updated by", task.updatedBy),
|
|
144
181
|
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function openDialog(task) {
|
|
185
|
+
const requestGeneration = ++detailRequestGeneration;
|
|
186
|
+
renderDialog(task);
|
|
145
187
|
if (!elements.dialog.open) elements.dialog.showModal();
|
|
188
|
+
try {
|
|
189
|
+
const response = await fetch(`/api/tasks/${encodeURIComponent(task.id)}`);
|
|
190
|
+
if (!response.ok) throw new Error("Task details are unavailable.");
|
|
191
|
+
const detail = await response.json();
|
|
192
|
+
if (
|
|
193
|
+
requestGeneration === detailRequestGeneration
|
|
194
|
+
&& state.selectedTask?.id === task.id
|
|
195
|
+
&& elements.dialog.open
|
|
196
|
+
) {
|
|
197
|
+
renderDialog(detail.task);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
if (state.selectedTask?.id === task.id) {
|
|
201
|
+
showMessage("Task result history is temporarily unavailable.");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
146
204
|
}
|
|
147
205
|
|
|
148
206
|
function taskCard(task) {
|
package/src/dashboard/index.html
CHANGED
|
@@ -87,8 +87,8 @@
|
|
|
87
87
|
<button id="copy-thread-id" class="secondary-button" type="button">Copy thread ID</button>
|
|
88
88
|
</div>
|
|
89
89
|
<section>
|
|
90
|
-
<h3>Result</h3>
|
|
91
|
-
<
|
|
90
|
+
<h3>Result history</h3>
|
|
91
|
+
<div id="dialog-results" class="result-history"></div>
|
|
92
92
|
</section>
|
|
93
93
|
<section>
|
|
94
94
|
<h3>Original instruction</h3>
|
package/src/dashboard/styles.css
CHANGED
|
@@ -117,6 +117,14 @@ dialog::backdrop { background: rgb(18 23 21 / 50%); backdrop-filter: blur(2px);
|
|
|
117
117
|
.primary-button:disabled, .secondary-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
118
118
|
dialog section + section { margin-top: 24px; }
|
|
119
119
|
.preserve-lines { white-space: pre-wrap; }
|
|
120
|
+
.result-history { display: grid; gap: 10px; }
|
|
121
|
+
.result-history-item { padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-muted); }
|
|
122
|
+
.result-history-latest { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); }
|
|
123
|
+
.result-history-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 9px; }
|
|
124
|
+
.result-history-item p { margin-bottom: 7px; }
|
|
125
|
+
.result-history-item p:last-child { margin-bottom: 0; }
|
|
126
|
+
.result-history-turn, .result-history-empty { color: var(--muted); font-size: 0.78rem; overflow-wrap: anywhere; }
|
|
127
|
+
.result-history-empty { margin: 0; }
|
|
120
128
|
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
129
|
.metadata { display: grid; grid-template-columns: minmax(100px, 150px) 1fr; margin: 0; font-size: 0.88rem; }
|
|
122
130
|
.metadata dt, .metadata dd { padding: 7px 0; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DASHBOARD_HEALTH_MAX_BYTES,
|
|
7
|
+
DASHBOARD_HEALTH_PATH,
|
|
8
|
+
createDashboardServer,
|
|
9
|
+
dashboardAuthority,
|
|
10
|
+
} from "./dashboard.js";
|
|
11
|
+
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
14
|
+
const DEFAULT_PORT = 3210;
|
|
15
|
+
const HEALTH_TIMEOUT_MS = 750;
|
|
16
|
+
|
|
17
|
+
function expectedIdentity(workspace, taskchefVersion, serverVersion) {
|
|
18
|
+
return {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
service: "taskchef-dashboard",
|
|
21
|
+
taskchefVersion,
|
|
22
|
+
serverVersion,
|
|
23
|
+
workspace,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isExactIdentity(value, expected) {
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
29
|
+
const keys = Object.keys(value).sort();
|
|
30
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
31
|
+
return keys.length === expectedKeys.length
|
|
32
|
+
&& keys.every((key, index) => key === expectedKeys[index])
|
|
33
|
+
&& expectedKeys.every((key) => value[key] === expected[key]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listenerConflict(url, detail) {
|
|
37
|
+
return new Error(
|
|
38
|
+
`TaskChef dashboard port conflict at ${url} ${detail} `
|
|
39
|
+
+ "Stop that listener or choose another port for the foreground dashboard CLI; TaskChef will not terminate it.",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readDashboardIdentity({
|
|
44
|
+
host = DEFAULT_HOST,
|
|
45
|
+
port = DEFAULT_PORT,
|
|
46
|
+
maximumBytes = DASHBOARD_HEALTH_MAX_BYTES,
|
|
47
|
+
timeoutMs = HEALTH_TIMEOUT_MS,
|
|
48
|
+
} = {}) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let settled = false;
|
|
51
|
+
const finish = (error, value) => {
|
|
52
|
+
if (settled) return;
|
|
53
|
+
settled = true;
|
|
54
|
+
clearTimeout(deadline);
|
|
55
|
+
if (error) reject(error);
|
|
56
|
+
else resolve(value);
|
|
57
|
+
};
|
|
58
|
+
const request = http.get({
|
|
59
|
+
host,
|
|
60
|
+
port,
|
|
61
|
+
path: DASHBOARD_HEALTH_PATH,
|
|
62
|
+
headers: {
|
|
63
|
+
Accept: "application/json",
|
|
64
|
+
Host: dashboardAuthority(host, port),
|
|
65
|
+
},
|
|
66
|
+
}, (response) => {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let total = 0;
|
|
69
|
+
response.on("data", (chunk) => {
|
|
70
|
+
total += chunk.length;
|
|
71
|
+
if (total > maximumBytes) {
|
|
72
|
+
const error = new Error("dashboard health response exceeds the identity limit");
|
|
73
|
+
finish(error);
|
|
74
|
+
request.destroy(error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
chunks.push(chunk);
|
|
78
|
+
});
|
|
79
|
+
response.on("end", () => {
|
|
80
|
+
if (response.statusCode !== 200) {
|
|
81
|
+
finish(new Error(`dashboard health returned HTTP ${response.statusCode}`));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
finish(null, JSON.parse(Buffer.concat(chunks, total).toString("utf8")));
|
|
86
|
+
} catch {
|
|
87
|
+
finish(new Error("dashboard health returned invalid JSON"));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
const deadline = setTimeout(() => {
|
|
92
|
+
const error = new Error("dashboard health request timed out");
|
|
93
|
+
finish(error);
|
|
94
|
+
request.destroy(error);
|
|
95
|
+
}, timeoutMs);
|
|
96
|
+
request.on("error", (error) => finish(error));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function listenerAbsent(error) {
|
|
101
|
+
return error?.code === "ECONNREFUSED" || error?.code === "EHOSTUNREACH";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createDashboardManager({
|
|
105
|
+
workspace,
|
|
106
|
+
host = DEFAULT_HOST,
|
|
107
|
+
port = DEFAULT_PORT,
|
|
108
|
+
taskchefVersion = TASKCHEF_VERSION,
|
|
109
|
+
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
110
|
+
createServer = createDashboardServer,
|
|
111
|
+
readIdentity = readDashboardIdentity,
|
|
112
|
+
} = {}) {
|
|
113
|
+
let canonicalWorkspace;
|
|
114
|
+
let ownedServer = null;
|
|
115
|
+
let ensurePromise = null;
|
|
116
|
+
let closePromise = null;
|
|
117
|
+
|
|
118
|
+
const publicResult = (action) => ({
|
|
119
|
+
action,
|
|
120
|
+
url: `http://${dashboardAuthority(host, ownedServer?.port ?? port)}/`,
|
|
121
|
+
workspace: canonicalWorkspace,
|
|
122
|
+
taskchefVersion,
|
|
123
|
+
serverVersion,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const probe = async () => {
|
|
127
|
+
const url = `http://${dashboardAuthority(host, port)}/`;
|
|
128
|
+
let identity;
|
|
129
|
+
try {
|
|
130
|
+
identity = await readIdentity({ host, port });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (listenerAbsent(error)) return false;
|
|
133
|
+
throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
|
|
134
|
+
}
|
|
135
|
+
const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion);
|
|
136
|
+
if (!isExactIdentity(identity, expected)) {
|
|
137
|
+
throw listenerConflict(url, "belongs to an unknown, stale, or different-workspace service.");
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const ensureOnce = async () => {
|
|
143
|
+
canonicalWorkspace ??= await realpath(path.resolve(workspace));
|
|
144
|
+
if (ownedServer) return publicResult("reused");
|
|
145
|
+
if (await probe()) return publicResult("reused");
|
|
146
|
+
try {
|
|
147
|
+
ownedServer = await createServer({
|
|
148
|
+
workspace: canonicalWorkspace,
|
|
149
|
+
host,
|
|
150
|
+
port,
|
|
151
|
+
taskchefVersion,
|
|
152
|
+
serverVersion,
|
|
153
|
+
});
|
|
154
|
+
return publicResult("started");
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
157
|
+
if (await probe()) return publicResult("reused");
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
async ensure() {
|
|
164
|
+
if (closePromise) throw new Error("TaskChef dashboard manager is shutting down");
|
|
165
|
+
if (ensurePromise) {
|
|
166
|
+
await ensurePromise;
|
|
167
|
+
return publicResult("reused");
|
|
168
|
+
}
|
|
169
|
+
ensurePromise = ensureOnce().finally(() => { ensurePromise = null; });
|
|
170
|
+
return ensurePromise;
|
|
171
|
+
},
|
|
172
|
+
async close() {
|
|
173
|
+
closePromise ??= (async () => {
|
|
174
|
+
await ensurePromise?.catch(() => {});
|
|
175
|
+
const server = ownedServer;
|
|
176
|
+
ownedServer = null;
|
|
177
|
+
await server?.close();
|
|
178
|
+
})();
|
|
179
|
+
return closePromise;
|
|
180
|
+
},
|
|
181
|
+
get owned() { return ownedServer !== null; },
|
|
182
|
+
};
|
|
183
|
+
}
|
package/src/dashboard.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
parseTaskLogContent,
|
|
17
17
|
readConfig,
|
|
18
18
|
} from "./workspace.js";
|
|
19
|
+
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
19
20
|
|
|
20
21
|
const TASKS_FILE_NAME = "tasks.jsonl";
|
|
21
22
|
const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
|
|
@@ -24,6 +25,8 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
|
|
|
24
25
|
const DEFAULT_MAX_FILE_BYTES = 16 * 1024 * 1024;
|
|
25
26
|
const DEFAULT_MAX_TASKS = 2_000;
|
|
26
27
|
const DEFAULT_MAX_EVENT_CLIENTS = 16;
|
|
28
|
+
export const DASHBOARD_HEALTH_PATH = "/api/health";
|
|
29
|
+
export const DASHBOARD_HEALTH_MAX_BYTES = 8 * 1024;
|
|
27
30
|
const CONTENT_SECURITY_POLICY = [
|
|
28
31
|
"default-src 'self'",
|
|
29
32
|
"base-uri 'none'",
|
|
@@ -133,6 +136,14 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
133
136
|
boundedText(task.turnId, 512, `${name} turn ID`);
|
|
134
137
|
boundedText(task.lastResult?.summary, 2_000, `${name} last result summary`);
|
|
135
138
|
boundedText(task.lastResult?.turnId, 512, `${name} last result turn ID`);
|
|
139
|
+
const results = task.results ?? [];
|
|
140
|
+
if (results.length > 10_000) {
|
|
141
|
+
throw new Error(`${name} has too many results for the dashboard`);
|
|
142
|
+
}
|
|
143
|
+
for (const [resultIndex, result] of results.entries()) {
|
|
144
|
+
boundedText(result.summary, 2_000, `${name} result ${resultIndex + 1} summary`);
|
|
145
|
+
boundedText(result.turnId, 512, `${name} result ${resultIndex + 1} turn ID`);
|
|
146
|
+
}
|
|
136
147
|
boundedText(task.project.name, 1_000, `${name} project name`);
|
|
137
148
|
boundedText(task.project.path, 8_192, `${name} project path`);
|
|
138
149
|
boundedText(task.project.description, 4_000, `${name} project description`);
|
|
@@ -145,6 +156,11 @@ function assertDashboardTaskBounds(tasks, maximumTasks) {
|
|
|
145
156
|
}
|
|
146
157
|
}
|
|
147
158
|
|
|
159
|
+
function taskListProjection(task) {
|
|
160
|
+
const { results: _results, ...projection } = task;
|
|
161
|
+
return projection;
|
|
162
|
+
}
|
|
163
|
+
|
|
148
164
|
export class DashboardMonitor extends EventEmitter {
|
|
149
165
|
constructor(workspace, {
|
|
150
166
|
debounceMs = 75,
|
|
@@ -194,7 +210,7 @@ export class DashboardMonitor extends EventEmitter {
|
|
|
194
210
|
generatedAt: new Date().toISOString(),
|
|
195
211
|
healthy: !this.unhealthy,
|
|
196
212
|
tasks: this.tasks.map((task) => ({
|
|
197
|
-
...task,
|
|
213
|
+
...taskListProjection(task),
|
|
198
214
|
meaningfulUpdatedAt: new Date(
|
|
199
215
|
meaningfulUpdateTime(task, this.observedUpdateTimes),
|
|
200
216
|
).toISOString(),
|
|
@@ -402,6 +418,8 @@ export async function createDashboardServer({
|
|
|
402
418
|
monitorOptions = {},
|
|
403
419
|
openProject = null,
|
|
404
420
|
openThread = null,
|
|
421
|
+
taskchefVersion = TASKCHEF_VERSION,
|
|
422
|
+
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
405
423
|
} = {}) {
|
|
406
424
|
if (!LOOPBACK_HOSTS.has(host)) {
|
|
407
425
|
throw new Error("dashboard host must be a loopback address");
|
|
@@ -414,6 +432,17 @@ export async function createDashboardServer({
|
|
|
414
432
|
}
|
|
415
433
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
416
434
|
await monitor.start();
|
|
435
|
+
const identity = Object.freeze({
|
|
436
|
+
schemaVersion: 1,
|
|
437
|
+
service: "taskchef-dashboard",
|
|
438
|
+
taskchefVersion,
|
|
439
|
+
serverVersion,
|
|
440
|
+
workspace: monitor.workspace,
|
|
441
|
+
});
|
|
442
|
+
if (Buffer.byteLength(`${JSON.stringify(identity)}\n`) > DASHBOARD_HEALTH_MAX_BYTES) {
|
|
443
|
+
monitor.close();
|
|
444
|
+
throw new Error("dashboard identity exceeds the health response limit");
|
|
445
|
+
}
|
|
417
446
|
const clients = new Set();
|
|
418
447
|
let allowedAuthority;
|
|
419
448
|
let allowedOrigin;
|
|
@@ -446,6 +475,16 @@ export async function createDashboardServer({
|
|
|
446
475
|
return;
|
|
447
476
|
}
|
|
448
477
|
|
|
478
|
+
if (url.pathname === DASHBOARD_HEALTH_PATH && (method === "GET" || method === "HEAD")) {
|
|
479
|
+
if (method === "HEAD") {
|
|
480
|
+
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
481
|
+
response.end();
|
|
482
|
+
} else {
|
|
483
|
+
sendJson(response, 200, identity);
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
449
488
|
if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
|
|
450
489
|
if (method === "HEAD") {
|
|
451
490
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
@@ -484,6 +523,22 @@ export async function createDashboardServer({
|
|
|
484
523
|
return;
|
|
485
524
|
}
|
|
486
525
|
|
|
526
|
+
const detailMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)$/);
|
|
527
|
+
if (detailMatch && (method === "GET" || method === "HEAD")) {
|
|
528
|
+
const task = monitor.tasks.find((candidate) => candidate.id === detailMatch[1]);
|
|
529
|
+
if (!task) {
|
|
530
|
+
sendJson(response, 404, { message: "Task not found." });
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (method === "HEAD") {
|
|
534
|
+
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
535
|
+
response.end();
|
|
536
|
+
} else {
|
|
537
|
+
sendJson(response, 200, { schemaVersion: 1, task });
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
487
542
|
const taskMatch = url.pathname.match(/^\/api\/tasks\/([a-zA-Z0-9._-]+)\/open-codex$/);
|
|
488
543
|
if (taskMatch && method === "POST") {
|
|
489
544
|
if (request.headers.origin !== allowedOrigin) {
|
|
@@ -574,6 +629,7 @@ export async function createDashboardServer({
|
|
|
574
629
|
port: boundPort,
|
|
575
630
|
origin: allowedOrigin,
|
|
576
631
|
url: `${allowedOrigin}/`,
|
|
632
|
+
identity,
|
|
577
633
|
monitor,
|
|
578
634
|
get eventClientCount() { return clients.size; },
|
|
579
635
|
async close() {
|
package/src/mcp.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
reportTaskResult,
|
|
9
9
|
} from "./workspace.js";
|
|
10
10
|
import { parseTaskChefMarker } from "./delegation.js";
|
|
11
|
+
import { createDashboardManager } from "./dashboard-manager.js";
|
|
11
12
|
import { resolveWorkspacePath } from "./workspace-path.js";
|
|
13
|
+
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
12
14
|
|
|
13
15
|
const projectSchema = z.object({
|
|
14
16
|
name: z.string(),
|
|
@@ -19,7 +21,7 @@ const projectSchema = z.object({
|
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
const taskSchema = z.object({
|
|
22
|
-
schemaVersion: z.union([z.literal(4), z.literal(5)]),
|
|
24
|
+
schemaVersion: z.union([z.literal(4), z.literal(5), z.literal(6)]),
|
|
23
25
|
id: z.string(),
|
|
24
26
|
project: projectSchema,
|
|
25
27
|
title: z.string(),
|
|
@@ -31,6 +33,12 @@ const taskSchema = z.object({
|
|
|
31
33
|
turnId: z.string().nullable(),
|
|
32
34
|
updatedAt: z.string(),
|
|
33
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
|
+
})),
|
|
34
42
|
lastResult: z.object({
|
|
35
43
|
status: z.enum(["needs_input", "completed", "failed"]),
|
|
36
44
|
summary: z.string(),
|
|
@@ -49,6 +57,14 @@ const preparationSchema = z.object({
|
|
|
49
57
|
projects: z.array(projectSchema),
|
|
50
58
|
});
|
|
51
59
|
|
|
60
|
+
const dashboardSchema = z.object({
|
|
61
|
+
action: z.enum(["started", "reused"]),
|
|
62
|
+
url: z.string().url(),
|
|
63
|
+
workspace: z.string(),
|
|
64
|
+
taskchefVersion: z.string(),
|
|
65
|
+
serverVersion: z.string(),
|
|
66
|
+
});
|
|
67
|
+
|
|
52
68
|
function toolResult(key, value, message) {
|
|
53
69
|
return {
|
|
54
70
|
structuredContent: { [key]: value },
|
|
@@ -63,15 +79,53 @@ export function createTaskChefMcpServer({
|
|
|
63
79
|
reportResult = reportTaskResult,
|
|
64
80
|
reportState = reportTaskState,
|
|
65
81
|
link = linkTask,
|
|
82
|
+
dashboardManager = createDashboardManager({ workspace }),
|
|
66
83
|
} = {}) {
|
|
67
84
|
const server = new McpServer(
|
|
68
|
-
{ name: "taskchef", version:
|
|
85
|
+
{ name: "taskchef", version: TASKCHEF_VERSION },
|
|
69
86
|
{
|
|
70
87
|
instructions:
|
|
71
88
|
"Prepare with prepare_dispatch, call record_task before creating the Codex task, then create it natively and return immediately. Follow the active TaskChef skill for role-specific sequencing of the identity and state tools.",
|
|
72
89
|
},
|
|
73
90
|
);
|
|
74
91
|
|
|
92
|
+
const originalClose = server.close.bind(server);
|
|
93
|
+
let closePromise = null;
|
|
94
|
+
server.close = async () => {
|
|
95
|
+
closePromise ??= (async () => {
|
|
96
|
+
await dashboardManager.close();
|
|
97
|
+
await originalClose();
|
|
98
|
+
})();
|
|
99
|
+
return closePromise;
|
|
100
|
+
};
|
|
101
|
+
server.server.onclose = () => {
|
|
102
|
+
void dashboardManager.close();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
server.registerTool(
|
|
106
|
+
"ensure_dashboard",
|
|
107
|
+
{
|
|
108
|
+
title: "Ensure TaskChef dashboard",
|
|
109
|
+
description:
|
|
110
|
+
"Best-effort ensure the canonical TaskChef dashboard is available on 127.0.0.1:3210. Starts one dashboard inside this MCP process or reuses only an exact compatible TaskChef dashboard for the same canonical workspace; unknown listeners are never terminated or replaced.",
|
|
111
|
+
inputSchema: {},
|
|
112
|
+
outputSchema: { dashboard: dashboardSchema },
|
|
113
|
+
annotations: {
|
|
114
|
+
readOnlyHint: false,
|
|
115
|
+
destructiveHint: false,
|
|
116
|
+
openWorldHint: false,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
async () => {
|
|
120
|
+
const dashboard = await dashboardManager.ensure();
|
|
121
|
+
return toolResult(
|
|
122
|
+
"dashboard",
|
|
123
|
+
dashboard,
|
|
124
|
+
`${dashboard.action === "started" ? "Started" : "Reused"} TaskChef dashboard ${dashboard.url}`,
|
|
125
|
+
);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
75
129
|
server.registerTool(
|
|
76
130
|
"prepare_dispatch",
|
|
77
131
|
{
|
package/src/version.js
ADDED