taskchef 8.7.0 → 9.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.
Files changed (149) hide show
  1. package/.codex-plugin/plugin.json +7 -10
  2. package/.mcp.json +3 -2
  3. package/README.md +31 -158
  4. package/docs/mcp-app.md +21 -4
  5. package/docs/spec.md +38 -746
  6. package/docs/taskchef-next-storage.md +2 -2
  7. package/index.js +2 -99
  8. package/package.json +12 -49
  9. package/skills/delegate/SKILL.md +44 -0
  10. package/skills/delegate/agents/openai.yaml +4 -0
  11. package/src/codex-app.js +3 -152
  12. package/src/codex-session-scanner.js +2 -2
  13. package/src/file-lock.js +25 -0
  14. package/src/mcp-app/react/NextTaskDetail.test.tsx +1 -1
  15. package/src/mcp-app/react/NextTaskDetail.tsx +8 -8
  16. package/src/mcp-app/react/main.test.tsx +3 -3
  17. package/src/mcp-app/react/main.tsx +7 -7
  18. package/src/mcp-app.js +4 -4
  19. package/src/mcp.js +4 -542
  20. package/src/next-cache.js +2 -2
  21. package/src/next-github.js +3 -3
  22. package/src/next-notifications.js +2 -2
  23. package/src/{dashboard/react → ui}/components/CardStatusIcons.tsx +1 -1
  24. package/src/{dashboard/react → ui}/components/GitHubLinks.tsx +1 -1
  25. package/src/{dashboard/react → ui}/components/LinkedText.tsx +1 -1
  26. package/src/{dashboard/react → ui}/components/RelativeTime.test.tsx +1 -1
  27. package/src/{dashboard/react → ui}/components/RelativeTime.tsx +1 -1
  28. package/src/{dashboard/react → ui}/components/ReplyImagePreview.test.tsx +1 -1
  29. package/src/{dashboard/react → ui}/components/TaskBoard.tsx +1 -1
  30. package/src/{dashboard/react → ui}/components/TaskCard.tsx +1 -1
  31. package/src/{dashboard/react → ui}/components/TurnContent.tsx +1 -1
  32. package/src/{dashboard/react → ui}/presentation.ts +1 -1
  33. package/src/version.js +2 -1
  34. package/BACKLOG.md +0 -48
  35. package/assets/e2e-benchmark-example.json +0 -50
  36. package/assets/taskchef-dispatcher-instructions.md +0 -44
  37. package/bin/taskchef.js +0 -20
  38. package/docs/advanced-guide.md +0 -497
  39. package/docs/dashboard-lifecycle.md +0 -149
  40. package/docs/dashboard-ui.md +0 -40
  41. package/docs/firstmate-taskchef-comparison.md +0 -283
  42. package/docs/images/ccusage-token-consumption.png +0 -0
  43. package/docs/images/dashboard-identity.jpg +0 -0
  44. package/docs/images/dashboard-token-pending.jpg +0 -0
  45. package/docs/images/interrupted-turn-recovery.jpg +0 -0
  46. package/docs/images/notification-event-snapshots.jpg +0 -0
  47. package/docs/images/readme-board.png +0 -0
  48. package/docs/images/readme-completed.png +0 -0
  49. package/docs/images/readme-list.png +0 -0
  50. package/docs/images/readme-needs-input.png +0 -0
  51. package/docs/images/readme-roles.png +0 -0
  52. package/docs/images/reported-work-duration-desktop.jpg +0 -0
  53. package/docs/images/reported-work-duration-mobile.jpg +0 -0
  54. package/docs/images/result-history-dashboard.jpg +0 -0
  55. package/docs/images/taskchef-pr-icon-comparison.jpg +0 -0
  56. package/docs/images/taskchef-unified-status-popup.jpg +0 -0
  57. package/docs/workflows.md +0 -476
  58. package/mcp/dashboard-session.js +0 -51
  59. package/scripts/benchmark-dispatch-prepare.js +0 -90
  60. package/scripts/e2e-benchmark.js +0 -206
  61. package/scripts/roles/resolve_roles.py +0 -293
  62. package/skills/taskchef-bootstrap/SKILL.md +0 -96
  63. package/skills/taskchef-bootstrap/agents/openai.yaml +0 -4
  64. package/skills/taskchef-bootstrap/references/project-index.md +0 -154
  65. package/skills/taskchef-copilot/SKILL.md +0 -75
  66. package/skills/taskchef-copilot/agents/openai.yaml +0 -4
  67. package/skills/taskchef-dashboard/SKILL.md +0 -24
  68. package/skills/taskchef-dashboard/agents/openai.yaml +0 -4
  69. package/skills/taskchef-delegate/SKILL.md +0 -102
  70. package/skills/taskchef-delegate/agents/openai.yaml +0 -4
  71. package/skills/taskchef-delegate/references/model-roles.md +0 -29
  72. package/skills/taskchef-executor/SKILL.md +0 -154
  73. package/skills/taskchef-executor/agents/openai.yaml +0 -4
  74. package/skills/taskchef-executor/references/compatibility.md +0 -22
  75. package/skills/taskchef-executor/references/ending-actions.md +0 -26
  76. package/src/backups.js +0 -359
  77. package/src/cli.js +0 -733
  78. package/src/config-audit.js +0 -99
  79. package/src/config-mutations.js +0 -106
  80. package/src/dashboard/actions.js +0 -136
  81. package/src/dashboard/app.js +0 -1172
  82. package/src/dashboard/dist/app.js +0 -269
  83. package/src/dashboard/dist/app.js.map +0 -1
  84. package/src/dashboard/dist/index.html +0 -16
  85. package/src/dashboard/dist/styles.css +0 -1
  86. package/src/dashboard/index.html +0 -148
  87. package/src/dashboard/react/App.test.tsx +0 -346
  88. package/src/dashboard/react/App.tsx +0 -515
  89. package/src/dashboard/react/api.test.ts +0 -40
  90. package/src/dashboard/react/api.ts +0 -103
  91. package/src/dashboard/react/components/ActivityTimeline.tsx +0 -118
  92. package/src/dashboard/react/components/ModelSettings.test.tsx +0 -180
  93. package/src/dashboard/react/components/ModelSettings.tsx +0 -106
  94. package/src/dashboard/react/components/NotificationCenter.test.tsx +0 -76
  95. package/src/dashboard/react/components/NotificationCenter.tsx +0 -111
  96. package/src/dashboard/react/components/PullRequestBadges.tsx +0 -18
  97. package/src/dashboard/react/components/TaskDetail.test.tsx +0 -255
  98. package/src/dashboard/react/components/TaskDetail.tsx +0 -271
  99. package/src/dashboard/react/components/UsagePanel.tsx +0 -96
  100. package/src/dashboard/react/index.html +0 -15
  101. package/src/dashboard/react/main.tsx +0 -12
  102. package/src/dashboard/react/overlay-state.test.ts +0 -110
  103. package/src/dashboard/react/overlay-state.ts +0 -60
  104. package/src/dashboard/react/presentation.test.tsx +0 -571
  105. package/src/dashboard/styles.css +0 -261
  106. package/src/dashboard-control.js +0 -34
  107. package/src/dashboard-manager.js +0 -339
  108. package/src/dashboard-session-process.js +0 -88
  109. package/src/dashboard-session.js +0 -86
  110. package/src/dashboard.js +0 -1303
  111. package/src/delegation.js +0 -277
  112. package/src/github.js +0 -107
  113. package/src/model-roles.js +0 -70
  114. package/src/recorded-usage-tracker.js +0 -92
  115. package/src/usage-tracker.js +0 -650
  116. package/src/usage.js +0 -367
  117. package/src/workspace-path.js +0 -48
  118. package/src/workspace.js +0 -3629
  119. /package/src/{dashboard/react → ui}/components/AnimatedUsage.test.tsx +0 -0
  120. /package/src/{dashboard/react → ui}/components/CardStatusIcons.test.tsx +0 -0
  121. /package/src/{dashboard/react → ui}/components/ImageGallery.tsx +0 -0
  122. /package/src/{dashboard/react → ui}/components/OpenChatButton.test.tsx +0 -0
  123. /package/src/{dashboard/react → ui}/components/OpenChatButton.tsx +0 -0
  124. /package/src/{dashboard/react → ui}/components/PrGlyph.tsx +0 -0
  125. /package/src/{dashboard/react → ui}/components/QueuedPrompts.test.tsx +0 -0
  126. /package/src/{dashboard/react → ui}/components/QueuedPrompts.tsx +0 -0
  127. /package/src/{dashboard/react → ui}/components/ReplyCover.test.tsx +0 -0
  128. /package/src/{dashboard/react → ui}/components/ReplyCover.tsx +0 -0
  129. /package/src/{dashboard/react → ui}/components/ReplyImagePreview.tsx +0 -0
  130. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.test.tsx +0 -0
  131. /package/src/{dashboard/react → ui}/components/ReplyMarkdown.tsx +0 -0
  132. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.test.tsx +0 -0
  133. /package/src/{dashboard/react → ui}/components/SavedUsageFormat.tsx +0 -0
  134. /package/src/{dashboard/react → ui}/components/ShimmerText.tsx +0 -0
  135. /package/src/{dashboard/react → ui}/components/StatsGrid.tsx +0 -0
  136. /package/src/{dashboard/react → ui}/components/StatusBadge.tsx +0 -0
  137. /package/src/{dashboard/react → ui}/components/TaskBoard.test.tsx +0 -0
  138. /package/src/{dashboard/react → ui}/components/TaskCardStats.test.tsx +0 -0
  139. /package/src/{dashboard/react → ui}/components/TaskCardStats.tsx +0 -0
  140. /package/src/{dashboard/react → ui}/components/TurnContent.test.tsx +0 -0
  141. /package/src/{dashboard/react → ui}/fixtures.ts +0 -0
  142. /package/src/{dashboard → ui}/github-links.js +0 -0
  143. /package/src/{dashboard/react → ui}/image-cache.test.ts +0 -0
  144. /package/src/{dashboard/react → ui}/image-cache.ts +0 -0
  145. /package/src/{dashboard → ui}/state.js +0 -0
  146. /package/src/{dashboard/react → ui}/styles.css +0 -0
  147. /package/src/{dashboard/react → ui}/test-setup.ts +0 -0
  148. /package/src/{dashboard → ui}/time.js +0 -0
  149. /package/src/{dashboard/react → ui}/types.ts +0 -0
package/src/workspace.js DELETED
@@ -1,3629 +0,0 @@
1
- import { resolveModelRoles } from "./model-roles.js";
2
- import { execFile as execFileCallback } from "node:child_process";
3
- import {
4
- access,
5
- chmod,
6
- lstat,
7
- mkdir,
8
- readFile,
9
- realpath,
10
- rename,
11
- stat,
12
- unlink,
13
- writeFile,
14
- } from "node:fs/promises";
15
- import { randomUUID } from "node:crypto";
16
- import path from "node:path";
17
- import { promisify } from "node:util";
18
- import lockfile from "proper-lockfile";
19
- import {
20
- normalizeCodexThreadId,
21
- normalizeDurableThreadId,
22
- parseTaskChefMarker,
23
- taskChefMarker,
24
- } from "./delegation.js";
25
- import {
26
- canonicalGithubRepository,
27
- normalizeGithubRepositories,
28
- } from "./github.js";
29
- import {
30
- configHash,
31
- projectDiff,
32
- projectSetHash,
33
- } from "./config-mutations.js";
34
- import {
35
- createBackupUnlocked,
36
- listBackups,
37
- pruneBackupsUnlocked,
38
- readBackupPayloads,
39
- verifyBackup,
40
- } from "./backups.js";
41
- import {
42
- appendAuditUnlocked,
43
- reconcileAuditUnlocked,
44
- supersedeAuditTransactionsUnlocked,
45
- validateAuditUnlocked,
46
- } from "./config-audit.js";
47
- import {
48
- removeDurable,
49
- semanticHash,
50
- sha256,
51
- taskChefError,
52
- writeDurableAtomic,
53
- } from "./state-store.js";
54
-
55
- const execFile = promisify(execFileCallback);
56
- const DISPATCHER_INSTRUCTIONS_URL = new URL(
57
- "../assets/taskchef-dispatcher-instructions.md",
58
- import.meta.url,
59
- );
60
- const DISPATCHER_INSTRUCTIONS_START = "<!-- taskchef:dispatcher-instructions:start -->";
61
- const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -->";
62
- const DISPATCH_FILE_NAME = "tasks.jsonl";
63
- const WORKSPACE_LOCK_NAME = ".taskchef-workspace.lock";
64
- const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
65
- const CURRENT_CONFIG_SCHEMA_VERSION = 2;
66
- const CURRENT_TASK_SCHEMA_VERSION = 10;
67
- const PREVIOUS_TASK_SCHEMA_VERSION = 9;
68
- const TURN_REF_TASK_SCHEMA_VERSION = 9;
69
- const FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION = 7;
70
- const INTERRUPTED_TURN_TASK_SCHEMA_VERSION = 8;
71
- const LEGACY_RESULTS_TASK_SCHEMA_VERSION = 6;
72
- const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
73
- const CONFIG_FIELDS = new Set(["schemaVersion", "projects", "dashboard", "projectIndex"]);
74
- const DASHBOARD_CONFIG_FIELDS = new Set(["autostart"]);
75
- const PROJECT_FIELDS = new Set([
76
- "name",
77
- "path",
78
- "isGitRepository",
79
- "githubRepos",
80
- "description",
81
- ]);
82
- const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
83
- const PROJECT_INDEX_FIELDS = new Set(["bindings", "exclusions", "routingHints"]);
84
- const PROJECT_BINDING_FIELDS = new Set(["hostId", "projectId", "path"]);
85
- const ROUTING_HINT_FIELDS = new Set(["path", "aliases", "githubRepos", "responsibilities", "forgotten"]);
86
- const ROUTING_FACT_FIELDS = new Set(["value", "provenance"]);
87
- const FORGOTTEN_FACT_FIELDS = new Set(["kind", "value"]);
88
- const ROUTING_PROVENANCE = new Set(["explicit_user", "verified_repository", "accepted_terminal_report"]);
89
- const ROUTING_PROVENANCE_FIELDS = new Set([
90
- "kind", "evidence", "taskId", "threadId", "turnRef", "repositoryPath",
91
- ]);
92
- const ROUTING_HINT_KINDS = new Set(["alias", "githubRepo", "responsibility"]);
93
- const MAX_ROUTING_HINTS = 128;
94
- const STATEFUL_DISPATCH_FIELDS = new Set([
95
- "schemaVersion",
96
- "id",
97
- "project",
98
- "title",
99
- "instruction",
100
- "threadId",
101
- "createdAt",
102
- "status",
103
- "summary",
104
- "turnId",
105
- "updatedAt",
106
- "updatedBy",
107
- ]);
108
- const SCHEMA_5_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "lastResult"]);
109
- const SCHEMA_6_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "results"]);
110
- const LEGACY_TURN_DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turns"]);
111
- const DISPATCH_FIELDS = new Set([...STATEFUL_DISPATCH_FIELDS, "turnRef", "turns"]);
112
- const RECORD_DISPATCH_FIELDS = new Set([
113
- "id",
114
- "project",
115
- "title",
116
- "instruction",
117
- "threadId",
118
- ]);
119
- const RESULT_STATUSES = new Set(["needs_input", "completed", "failed"]);
120
- const TURN_RESULT_STATUSES = new Set([...RESULT_STATUSES, "interrupted"]);
121
- const TASK_STATUSES = new Set(["working", ...RESULT_STATUSES]);
122
- const TASK_UPDATE_SOURCES = new Set(["dispatcher", "mcp", "dashboard"]);
123
- const MAX_RESULT_SUMMARY_LENGTH = 2_000;
124
- const MAX_REQUEST_SUMMARY_LENGTH = 1_000;
125
- const RESULT_FIELDS = new Set(["status", "summary", "turnId", "updatedAt"]);
126
- const TURN_RESULT_FIELDS = new Set(["status", "summary", "updatedAt"]);
127
- const LEGACY_TURN_FIELDS = new Set(["turnId", "requestSummary", "startedAt", "result"]);
128
- const SCHEMA_9_TURN_FIELDS = new Set([
129
- "turnRef", "turnId", "requestSummary", "startedAt", "result",
130
- ]);
131
- const TURN_FIELDS = new Set([...SCHEMA_9_TURN_FIELDS, "provenance"]);
132
- const TURN_PROVENANCE_KINDS = new Set(["legacy", "mcp", "dashboard_manual"]);
133
- const SIMPLE_TURN_PROVENANCE_FIELDS = new Set(["kind"]);
134
- const MANUAL_TURN_PROVENANCE_FIELDS = new Set([
135
- "kind",
136
- "actionId",
137
- "fromStatus",
138
- "toStatus",
139
- "expectedTurnRef",
140
- "expectedThreadId",
141
- "expectedUpdatedAt",
142
- ]);
143
- const INTERRUPTED_TURN_SUMMARY = "Turn interrupted before a terminal report.";
144
- const MANUAL_TRANSITION_STATUSES = new Set([
145
- "working", "needs_input", "completed", "failed",
146
- ]);
147
- const MANUAL_TARGET_STATUSES = new Set(["completed", "failed"]);
148
- const MANUAL_TRANSITION_FIELDS = new Set([
149
- "actionId", "expected", "targetStatus",
150
- ]);
151
- const MANUAL_EXPECTED_FIELDS = new Set([
152
- "status", "turnRef", "threadId", "updatedAt",
153
- ]);
154
-
155
- function requireExactFields(value, fields, name) {
156
- if (!value || typeof value !== "object" || Array.isArray(value)) {
157
- throw new Error(`${name} must be an object`);
158
- }
159
- const unexpected = Object.keys(value).find((key) => !fields.has(key));
160
- if (unexpected) throw new Error(`${name} has unsupported field: ${unexpected}`);
161
- const missing = [...fields].find((key) => !(key in value));
162
- if (missing) throw new Error(`${name} is missing field: ${missing}`);
163
- return value;
164
- }
165
-
166
- function requireString(value, name) {
167
- if (typeof value !== "string" || value.trim().length === 0) {
168
- throw new Error(`${name} must be a non-empty string`);
169
- }
170
- return value;
171
- }
172
-
173
- function requireTimestamp(value, name) {
174
- requireString(value, name);
175
- if (!/^\d{4}-\d{2}-\d{2}T/.test(value) || Number.isNaN(Date.parse(value))) {
176
- throw new Error(`${name} must be an ISO 8601 timestamp`);
177
- }
178
- return value;
179
- }
180
-
181
- function transitionTimestamp(now, currentUpdatedAt) {
182
- const candidate = requireTimestamp(now ?? new Date().toISOString(), "transition timestamp");
183
- return Date.parse(candidate) < Date.parse(currentUpdatedAt)
184
- ? currentUpdatedAt
185
- : candidate;
186
- }
187
-
188
- function optionalString(value, name, { maxLength = null } = {}) {
189
- if (value === null) return null;
190
- const normalized = requireString(value, name).trim();
191
- if (maxLength !== null && normalized.length > maxLength) {
192
- throw new Error(`${name} must be at most ${maxLength} characters`);
193
- }
194
- return normalized;
195
- }
196
-
197
- function normalizeTurnRef(value, name = "turnRef") {
198
- const normalized = requireString(value, name).trim();
199
- if (normalized.length > 256) throw new Error(`${name} must be at most 256 characters`);
200
- return /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(normalized)
201
- ? normalized.toLowerCase()
202
- : normalized;
203
- }
204
-
205
- function normalizeExecutorTurnRef(value, name = "turnRef") {
206
- const normalized = normalizeTurnRef(value, name);
207
- if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
208
- throw new Error(`${name} must be a UUID for a self-linked task`);
209
- }
210
- return normalized;
211
- }
212
-
213
- function normalizeTurnProvenance(value, name) {
214
- if (!value || typeof value !== "object" || Array.isArray(value)) {
215
- throw new Error(`${name} must be an object`);
216
- }
217
- const kind = requireEnum(value.kind, TURN_PROVENANCE_KINDS, `${name}.kind`);
218
- if (kind !== "dashboard_manual") {
219
- requireExactFields(value, SIMPLE_TURN_PROVENANCE_FIELDS, name);
220
- return { kind };
221
- }
222
- requireExactFields(value, MANUAL_TURN_PROVENANCE_FIELDS, name);
223
- return {
224
- kind,
225
- actionId: normalizeExecutorTurnRef(value.actionId, `${name}.actionId`),
226
- fromStatus: requireEnum(value.fromStatus, MANUAL_TRANSITION_STATUSES, `${name}.fromStatus`),
227
- toStatus: requireEnum(value.toStatus, MANUAL_TARGET_STATUSES, `${name}.toStatus`),
228
- expectedTurnRef: value.expectedTurnRef === null
229
- ? null
230
- : normalizeTurnRef(value.expectedTurnRef, `${name}.expectedTurnRef`),
231
- expectedThreadId: value.expectedThreadId === null
232
- ? null
233
- : normalizeDurableThreadId(value.expectedThreadId, `${name}.expectedThreadId`),
234
- expectedUpdatedAt: requireTimestamp(value.expectedUpdatedAt, `${name}.expectedUpdatedAt`),
235
- };
236
- }
237
-
238
- function requireEnum(value, allowed, name) {
239
- if (!allowed.has(value)) {
240
- throw new Error(`${name} must be one of: ${[...allowed].join(", ")}`);
241
- }
242
- return value;
243
- }
244
-
245
- export function requireSafeId(value, name = "id") {
246
- requireString(value, name);
247
- if (!SAFE_ID.test(value)) {
248
- throw new Error(`${name} contains unsupported characters`);
249
- }
250
- return value;
251
- }
252
-
253
- async function pathExists(filePath) {
254
- try {
255
- await access(filePath);
256
- return true;
257
- } catch {
258
- return false;
259
- }
260
- }
261
-
262
- async function ensureManagedDirectory(root, ...segments) {
263
- let current = root;
264
- for (const segment of segments) {
265
- current = path.join(current, segment);
266
- let details = await lstat(current).catch((error) => {
267
- if (error.code === "ENOENT") return null;
268
- throw error;
269
- });
270
- if (details === null) {
271
- await mkdir(current);
272
- details = await lstat(current);
273
- }
274
- if (details.isSymbolicLink() || !details.isDirectory()) {
275
- throw new Error(`managed workspace path is not a real directory: ${current}`);
276
- }
277
- if (await realpath(current) !== current) {
278
- throw new Error(`managed workspace path escapes the workspace: ${current}`);
279
- }
280
- }
281
- return current;
282
- }
283
-
284
- async function managedRegularFileExists(filePath) {
285
- const details = await lstat(filePath).catch((error) => {
286
- if (error.code === "ENOENT") return null;
287
- throw error;
288
- });
289
- if (details === null) return false;
290
- if (details.isSymbolicLink() || !details.isFile()) {
291
- throw new Error(`managed workspace path is not a regular file: ${filePath}`);
292
- }
293
- return true;
294
- }
295
-
296
- function assertWorkspaceOutsideProject(workspaceRoot, projectPath) {
297
- const relative = path.relative(projectPath, workspaceRoot);
298
- if (relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))) {
299
- throw new Error(
300
- "TaskChef workspace cannot be configured as its own delegation project or inside a delegation project",
301
- );
302
- }
303
- }
304
-
305
- export async function acquireWorkspaceLock(workspaceRoot, {
306
- lock = lockfile.lock,
307
- waitImpl = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
308
- } = {}) {
309
- const lockPath = path.join(workspaceRoot, WORKSPACE_LOCK_NAME);
310
- for (let attempt = 0; attempt <= 70; attempt += 1) {
311
- try {
312
- return await lock(workspaceRoot, {
313
- realpath: false,
314
- lockfilePath: lockPath,
315
- stale: 600_000,
316
- update: 10_000,
317
- retries: 0,
318
- });
319
- } catch (error) {
320
- if (error.code !== "ELOCKED" || attempt === 70) throw error;
321
- await waitImpl(100);
322
- }
323
- }
324
- throw new Error("workspace lock retry loop ended unexpectedly");
325
- }
326
-
327
- async function withWorkspaceLock(workspaceRoot, operation, { allowMaintenance = false } = {}) {
328
- const release = await acquireWorkspaceLock(workspaceRoot);
329
- try {
330
- if (!allowMaintenance && await pathExists(path.join(workspaceRoot, ".taskchef-maintenance.json"))) {
331
- throw taskChefError(
332
- "STATE_RECOVERY_REQUIRED",
333
- "workspace has an incomplete maintenance transaction; inspect and explicitly resume or roll it back",
334
- );
335
- }
336
- return await operation();
337
- } finally {
338
- await release();
339
- }
340
- }
341
-
342
- async function appendDispatchesAtomic(workspaceRoot, dispatches) {
343
- if (dispatches.length === 0) return;
344
- const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
345
- const content = await readFile(dispatchPath, "utf8");
346
- const appended = dispatches
347
- .map((dispatch) => `${JSON.stringify(currentSchemaTask(dispatch))}\n`)
348
- .join("");
349
- await writeTextAtomic(dispatchPath, `${content}${appended}`);
350
- }
351
-
352
- async function writeDispatchLinesAtomic(workspaceRoot, lines) {
353
- const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
354
- const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
355
- await writeTextAtomic(dispatchPath, content);
356
- }
357
-
358
- async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
359
- if (exclusive && (await pathExists(filePath))) {
360
- throw new Error(`file already exists: ${filePath}`);
361
- }
362
- await writeDurableAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`, { exclusive });
363
- }
364
-
365
- async function writeTextAtomic(filePath, value) {
366
- await mkdir(path.dirname(filePath), { recursive: true });
367
- const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
368
- const mode = await stat(filePath)
369
- .then((details) => details.mode & 0o777)
370
- .catch((error) => {
371
- if (error.code === "ENOENT") return 0o600;
372
- throw error;
373
- });
374
- await writeFile(temporaryPath, value, {
375
- encoding: "utf8",
376
- mode,
377
- flag: "wx",
378
- });
379
- try {
380
- await rename(temporaryPath, filePath);
381
- } catch (error) {
382
- await unlink(temporaryPath).catch(() => {});
383
- throw error;
384
- }
385
- }
386
-
387
- async function readDispatcherInstructions() {
388
- const instructions = await readFile(DISPATCHER_INSTRUCTIONS_URL, "utf8");
389
- const startCount = instructions.split(DISPATCHER_INSTRUCTIONS_START).length - 1;
390
- const endCount = instructions.split(DISPATCHER_INSTRUCTIONS_END).length - 1;
391
- if (
392
- startCount !== 1 ||
393
- endCount !== 1 ||
394
- instructions.indexOf(DISPATCHER_INSTRUCTIONS_START) >
395
- instructions.indexOf(DISPATCHER_INSTRUCTIONS_END)
396
- ) {
397
- throw new Error("TaskChef dispatcher instructions must contain exactly one managed block");
398
- }
399
- return instructions.endsWith("\n") ? instructions : `${instructions}\n`;
400
- }
401
-
402
- function mergeDispatcherInstructions(existing, managed) {
403
- const startCount = existing.split(DISPATCHER_INSTRUCTIONS_START).length - 1;
404
- const endCount = existing.split(DISPATCHER_INSTRUCTIONS_END).length - 1;
405
- if (startCount !== endCount || startCount > 1) {
406
- throw new Error("AGENTS.md contains malformed TaskChef managed-block markers");
407
- }
408
- if (startCount === 1) {
409
- const start = existing.indexOf(DISPATCHER_INSTRUCTIONS_START);
410
- const end = existing.indexOf(DISPATCHER_INSTRUCTIONS_END, start);
411
- if (end === -1) {
412
- throw new Error("AGENTS.md contains malformed TaskChef managed-block markers");
413
- }
414
- return `${existing.slice(0, start)}${managed.trimEnd()}${existing.slice(end + DISPATCHER_INSTRUCTIONS_END.length)}`;
415
- }
416
- if (existing.trim().length === 0) return managed;
417
- return `${existing.trimEnd()}\n\n${managed}`;
418
- }
419
-
420
- export async function ensureWorkspaceInstructions(workspaceRoot) {
421
- const requestedRoot = path.resolve(workspaceRoot);
422
- await mkdir(requestedRoot, { recursive: true });
423
- const root = await realpath(requestedRoot);
424
- const filePath = path.join(root, "AGENTS.md");
425
- const managed = await readDispatcherInstructions();
426
- const existing = await managedRegularFileExists(filePath)
427
- ? await readFile(filePath, "utf8")
428
- : null;
429
- const merged = mergeDispatcherInstructions(existing ?? "", managed);
430
- const action = existing === null
431
- ? "created"
432
- : existing === merged
433
- ? "unchanged"
434
- : existing.includes(DISPATCHER_INSTRUCTIONS_START)
435
- ? "updated"
436
- : "merged";
437
- if (action !== "unchanged") await writeTextAtomic(filePath, merged);
438
- return { path: filePath, action };
439
- }
440
-
441
- export async function canonicalGitRoot(projectPath) {
442
- const requested = await canonicalDirectory(projectPath);
443
- const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], {
444
- cwd: requested,
445
- }).catch(() => {
446
- throw new Error(`project is not a Git repository: ${requested}`);
447
- });
448
- const gitRoot = await realpath(stdout.trim());
449
- if (gitRoot !== requested) {
450
- throw new Error(`project must be the Git repository root: ${requested}`);
451
- }
452
- return requested;
453
- }
454
-
455
- export async function canonicalDirectory(projectPath) {
456
- const requested = await realpath(path.resolve(requireString(projectPath, "project")))
457
- .catch((error) => {
458
- if (error.code === "ENOENT") throw new Error(`project does not exist: ${projectPath}`);
459
- throw error;
460
- });
461
- if (!(await stat(requested)).isDirectory()) {
462
- throw new Error(`project must be a directory: ${requested}`);
463
- }
464
- return requested;
465
- }
466
-
467
- async function normalizeProject(
468
- project,
469
- index,
470
- { checkPath = true } = {},
471
- ) {
472
- const field = `projects[${index}]`;
473
- if (!project || typeof project !== "object" || Array.isArray(project)) {
474
- throw new Error(`${field} must be an object`);
475
- }
476
- const unexpected = Object.keys(project).find((key) => !PROJECT_FIELDS.has(key));
477
- if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
478
- for (const required of ["name", "path", "isGitRepository", "githubRepos"]) {
479
- if (!(required in project)) throw new Error(`${field} is missing field: ${required}`);
480
- }
481
- const name = requireString(project.name, `${field}.name`).trim();
482
- if (typeof project.isGitRepository !== "boolean") {
483
- throw new Error(`${field}.isGitRepository must be a boolean`);
484
- }
485
- let projectPath;
486
- if (checkPath) {
487
- projectPath = project.isGitRepository
488
- ? await canonicalGitRoot(project.path)
489
- : await canonicalDirectory(project.path);
490
- } else {
491
- projectPath = requireString(project.path, `${field}.path`).trim();
492
- if (!path.isAbsolute(projectPath) || path.normalize(projectPath) !== projectPath) {
493
- throw new Error(`${field}.path must be a normalized absolute path`);
494
- }
495
- }
496
- const githubRepos = normalizeGithubRepositories(project.githubRepos, `${field}.githubRepos`);
497
- const normalized = {
498
- name,
499
- path: projectPath,
500
- isGitRepository: project.isGitRepository,
501
- githubRepos,
502
- };
503
- if ("description" in project) {
504
- normalized.description = requireString(
505
- project.description,
506
- `${field}.description`,
507
- ).trim();
508
- }
509
- return normalized;
510
- }
511
-
512
- async function normalizeProjects(
513
- projects,
514
- { checkPaths = true } = {},
515
- ) {
516
- if (!Array.isArray(projects)) throw new Error("projects must be an array");
517
- const normalized = [];
518
- for (const [index, project] of projects.entries()) {
519
- normalized.push(await normalizeProject(project, index, {
520
- checkPath: checkPaths,
521
- }));
522
- }
523
- if (new Set(normalized.map((project) => project.path)).size !== normalized.length) {
524
- throw new Error("project paths must not contain duplicates");
525
- }
526
- if (
527
- new Set(normalized.map((project) => project.name.toLowerCase())).size !==
528
- normalized.length
529
- ) {
530
- throw new Error("project names must not contain duplicates");
531
- }
532
- return normalized;
533
- }
534
-
535
- function normalizedAbsolutePath(value, name) {
536
- const normalized = requireString(value, name).trim();
537
- if (!path.isAbsolute(normalized) || path.normalize(normalized) !== normalized) {
538
- throw new Error(`${name} must be a normalized absolute path`);
539
- }
540
- return normalized;
541
- }
542
-
543
- function normalizeRoutingFacts(value, name, { kind, maxCount, maxLength }) {
544
- if (!Array.isArray(value) || value.length > maxCount) {
545
- throw new Error(`${name} must be an array with at most ${maxCount} entries`);
546
- }
547
- const seen = new Set();
548
- return value.map((fact, index) => {
549
- requireExactFields(fact, ROUTING_FACT_FIELDS, `${name}[${index}]`);
550
- let normalized = requireString(fact.value, `${name}[${index}].value`).trim();
551
- if (normalized.length > maxLength) throw new Error(`${name}[${index}].value is too long`);
552
- if (kind === "githubRepo") normalized = canonicalGithubRepository(normalized, `${name}[${index}].value`);
553
- requireExactFields(fact.provenance, ROUTING_PROVENANCE_FIELDS, `${name}[${index}].provenance`);
554
- const provenanceKind = requireEnum(
555
- fact.provenance.kind,
556
- ROUTING_PROVENANCE,
557
- `${name}[${index}].provenance.kind`,
558
- );
559
- const provenance = {
560
- kind: provenanceKind,
561
- evidence: requireString(fact.provenance.evidence, `${name}[${index}].provenance.evidence`).trim(),
562
- taskId: fact.provenance.taskId === null
563
- ? null : requireSafeId(fact.provenance.taskId, `${name}[${index}].provenance.taskId`),
564
- threadId: fact.provenance.threadId === null
565
- ? null : normalizeDurableThreadId(fact.provenance.threadId, `${name}[${index}].provenance.threadId`),
566
- turnRef: fact.provenance.turnRef === null
567
- ? null : normalizeTurnRef(fact.provenance.turnRef, `${name}[${index}].provenance.turnRef`),
568
- repositoryPath: fact.provenance.repositoryPath === null
569
- ? null : normalizedAbsolutePath(
570
- fact.provenance.repositoryPath,
571
- `${name}[${index}].provenance.repositoryPath`,
572
- ),
573
- };
574
- if (provenance.evidence.length > 240) throw new Error(`${name}[${index}].provenance.evidence is too long`);
575
- if (provenanceKind === "accepted_terminal_report"
576
- && [provenance.taskId, provenance.threadId, provenance.turnRef].some((part) => part === null)) {
577
- throw new Error(`${name}[${index}].provenance requires taskId, threadId, and turnRef`);
578
- }
579
- if (provenanceKind !== "accepted_terminal_report"
580
- && [provenance.taskId, provenance.threadId, provenance.turnRef].some((part) => part !== null)) {
581
- throw new Error(`${name}[${index}].provenance task identity is only valid for accepted reports`);
582
- }
583
- const item = {
584
- value: normalized,
585
- provenance,
586
- };
587
- const key = item.value.toLowerCase();
588
- if (seen.has(key)) throw new Error(`${name} must not contain duplicate values`);
589
- seen.add(key);
590
- return item;
591
- });
592
- }
593
-
594
- function normalizeProjectIndex(value) {
595
- requireExactFields(value, PROJECT_INDEX_FIELDS, "taskchef.json.projectIndex");
596
- if (!Array.isArray(value.bindings) || !Array.isArray(value.exclusions)
597
- || !Array.isArray(value.routingHints)) {
598
- throw new Error("taskchef.json.projectIndex lists must be arrays");
599
- }
600
- if (value.routingHints.length > MAX_ROUTING_HINTS) {
601
- throw new Error(`taskchef.json.projectIndex.routingHints must have at most ${MAX_ROUTING_HINTS} entries`);
602
- }
603
- const bindings = value.bindings.map((binding, index) => {
604
- const name = `taskchef.json.projectIndex.bindings[${index}]`;
605
- requireExactFields(binding, PROJECT_BINDING_FIELDS, name);
606
- return {
607
- hostId: requireEnum(binding.hostId, new Set(["local"]), `${name}.hostId`),
608
- projectId: requireString(binding.projectId, `${name}.projectId`).trim(),
609
- path: normalizedAbsolutePath(binding.path, `${name}.path`),
610
- };
611
- });
612
- const exclusions = value.exclusions.map((entry, index) => {
613
- const name = `taskchef.json.projectIndex.exclusions[${index}]`;
614
- requireExactFields(entry, PROJECT_BINDING_FIELDS, name);
615
- return {
616
- hostId: requireEnum(entry.hostId, new Set(["local"]), `${name}.hostId`),
617
- projectId: entry.projectId === null
618
- ? null : requireString(entry.projectId, `${name}.projectId`).trim(),
619
- path: normalizedAbsolutePath(entry.path, `${name}.path`),
620
- };
621
- });
622
- const routingHints = value.routingHints.map((hint, index) => {
623
- const name = `taskchef.json.projectIndex.routingHints[${index}]`;
624
- requireExactFields(hint, ROUTING_HINT_FIELDS, name);
625
- if (!Array.isArray(hint.forgotten) || hint.forgotten.length > 32) {
626
- throw new Error(`${name}.forgotten must be an array with at most 32 entries`);
627
- }
628
- const forgotten = hint.forgotten.map((fact, factIndex) => {
629
- const factName = `${name}.forgotten[${factIndex}]`;
630
- requireExactFields(fact, FORGOTTEN_FACT_FIELDS, factName);
631
- const kind = requireEnum(fact.kind, ROUTING_HINT_KINDS, `${factName}.kind`);
632
- let normalized = requireString(fact.value, `${factName}.value`).trim();
633
- if (kind === "githubRepo") normalized = canonicalGithubRepository(normalized, `${factName}.value`);
634
- return { kind, value: normalized };
635
- });
636
- return {
637
- path: normalizedAbsolutePath(hint.path, `${name}.path`),
638
- aliases: normalizeRoutingFacts(hint.aliases, `${name}.aliases`, {
639
- kind: "alias", maxCount: 16, maxLength: 80,
640
- }),
641
- githubRepos: normalizeRoutingFacts(hint.githubRepos, `${name}.githubRepos`, {
642
- kind: "githubRepo", maxCount: 16, maxLength: 256,
643
- }),
644
- responsibilities: normalizeRoutingFacts(hint.responsibilities, `${name}.responsibilities`, {
645
- kind: "responsibility", maxCount: 8, maxLength: 240,
646
- }),
647
- forgotten,
648
- };
649
- });
650
- for (const [name, entries] of [["bindings", bindings], ["exclusions", exclusions]]) {
651
- const ids = entries.map((entry) => `${entry.hostId}\0${entry.projectId ?? `path:${entry.path}`}`);
652
- if (new Set(ids).size !== ids.length) throw new Error(`projectIndex ${name} must not contain duplicate native IDs`);
653
- }
654
- if (new Set(bindings.map((entry) => entry.path)).size !== bindings.length) {
655
- throw new Error("projectIndex bindings must not contain duplicate paths");
656
- }
657
- if (new Set(exclusions.map((entry) => entry.path)).size !== exclusions.length) {
658
- throw new Error("projectIndex exclusions must not contain duplicate paths");
659
- }
660
- if (new Set(routingHints.map((entry) => entry.path)).size !== routingHints.length) {
661
- throw new Error("projectIndex routingHints must not contain duplicate paths");
662
- }
663
- return { bindings, exclusions, routingHints };
664
- }
665
-
666
- function emptyProjectIndex() {
667
- return { bindings: [], exclusions: [], routingHints: [] };
668
- }
669
-
670
- function projectIndexForProjects(projectIndex, projects) {
671
- const paths = new Set(projects.map((project) => project.path));
672
- return {
673
- bindings: projectIndex.bindings.filter((entry) => paths.has(entry.path)),
674
- exclusions: projectIndex.exclusions,
675
- routingHints: projectIndex.routingHints.filter((entry) => paths.has(entry.path)),
676
- };
677
- }
678
-
679
- function normalizeGithubRemote(remote) {
680
- if (typeof remote !== "string" || remote.trim().length === 0) return null;
681
- try {
682
- return canonicalGithubRepository(remote, "GitHub origin");
683
- } catch {
684
- return null;
685
- }
686
- }
687
-
688
- function gitInspectionError(action, error) {
689
- const detail = typeof error.stderr === "string" && error.stderr.trim()
690
- ? error.stderr.trim()
691
- : error.message;
692
- return new Error(`${action}: ${detail}`);
693
- }
694
-
695
- async function inspectProject(input, index = 0) {
696
- const field = `projects[${index}]`;
697
- if (!input || typeof input !== "object" || Array.isArray(input)) {
698
- throw new Error(`${field} must be an object`);
699
- }
700
- const unexpected = Object.keys(input).find((key) => !PROJECT_INPUT_FIELDS.has(key));
701
- if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
702
- if (!("path" in input)) throw new Error(`${field} is missing field: path`);
703
- const projectPath = await canonicalDirectory(input.path);
704
- let isGitRepository = false;
705
- let gitRoot = null;
706
- try {
707
- const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], {
708
- cwd: projectPath,
709
- });
710
- gitRoot = await realpath(stdout.trim());
711
- isGitRepository = true;
712
- } catch (error) {
713
- if (error.code === 128 && /not a git repository/i.test(error.stderr ?? "")) {
714
- isGitRepository = false;
715
- } else {
716
- throw gitInspectionError("failed to inspect Git repository", error);
717
- }
718
- }
719
- if (isGitRepository && gitRoot !== projectPath) {
720
- throw new Error(`project must be the Git repository root: ${projectPath}`);
721
- }
722
- let githubRepos = [];
723
- if (isGitRepository) {
724
- if ("githubRepos" in input) {
725
- githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
726
- } else {
727
- const remote = await execFile("git", ["remote", "get-url", "origin"], {
728
- cwd: projectPath,
729
- }).then(({ stdout }) => stdout.trim()).catch((error) => {
730
- if (error.code === 2 && /No such remote/i.test(error.stderr ?? "")) return null;
731
- throw gitInspectionError("failed to inspect GitHub origin", error);
732
- });
733
- const detected = normalizeGithubRemote(remote);
734
- githubRepos = detected === null ? [] : [detected];
735
- }
736
- } else if ("githubRepos" in input) {
737
- githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
738
- }
739
- const project = {
740
- name: "name" in input
741
- ? requireString(input.name, `${field}.name`).trim()
742
- : path.basename(projectPath),
743
- path: projectPath,
744
- isGitRepository,
745
- githubRepos,
746
- };
747
- if ("description" in input) {
748
- project.description = requireString(input.description, `${field}.description`).trim();
749
- }
750
- return project;
751
- }
752
-
753
- export async function validateConfig(config, { checkPaths = true } = {}) {
754
- if (!config || typeof config !== "object" || Array.isArray(config)) {
755
- throw new Error("taskchef.json must be an object");
756
- }
757
- const unexpected = Object.keys(config).find((key) => !CONFIG_FIELDS.has(key));
758
- if (unexpected) throw new Error(`taskchef.json has unsupported field: ${unexpected}`);
759
- for (const required of ["schemaVersion", "projects"]) {
760
- if (!(required in config)) throw new Error(`taskchef.json is missing field: ${required}`);
761
- }
762
- if (config.schemaVersion !== CURRENT_CONFIG_SCHEMA_VERSION) {
763
- throw new Error("unsupported configuration schemaVersion");
764
- }
765
- let dashboard;
766
- if ("dashboard" in config) {
767
- requireExactFields(config.dashboard, DASHBOARD_CONFIG_FIELDS, "taskchef.json.dashboard");
768
- if (typeof config.dashboard.autostart !== "boolean") {
769
- throw new Error("taskchef.json.dashboard.autostart must be a boolean");
770
- }
771
- dashboard = { autostart: config.dashboard.autostart };
772
- }
773
- const projectIndex = "projectIndex" in config
774
- ? normalizeProjectIndex(config.projectIndex)
775
- : null;
776
- const projects = await normalizeProjects(config.projects, { checkPaths });
777
- if (projectIndex) {
778
- const projectPaths = new Set(projects.map((project) => project.path));
779
- if (projectIndex.bindings.some((entry) => !projectPaths.has(entry.path))) {
780
- throw new Error("projectIndex binding path must identify a configured project");
781
- }
782
- if (projectIndex.routingHints.some((entry) => !projectPaths.has(entry.path))) {
783
- throw new Error("projectIndex routing hint path must identify a configured project");
784
- }
785
- }
786
- return {
787
- schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
788
- projects,
789
- ...(dashboard ? { dashboard } : {}),
790
- ...(projectIndex ? { projectIndex } : {}),
791
- };
792
- }
793
-
794
- export function dashboardAutostartEnabled(config) {
795
- return config?.dashboard?.autostart !== false;
796
- }
797
-
798
- async function ensureDispatchFile(workspaceRoot) {
799
- const filePath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
800
- const exists = await managedRegularFileExists(filePath);
801
- if (!exists) {
802
- await writeFile(filePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" });
803
- }
804
- return { path: filePath, action: exists ? "unchanged" : "created" };
805
- }
806
-
807
- async function createStateBackupUnlocked(root, options = {}) {
808
- return createBackupUnlocked(root, {
809
- ...options,
810
- validateFile: async (name, bytes) => {
811
- if (name === "taskchef.json") {
812
- await validateConfig(JSON.parse(bytes.toString("utf8")), { checkPaths: false });
813
- } else if (name === DISPATCH_FILE_NAME) {
814
- await parseTaskLogContent(root, bytes.toString("utf8"), { validateWorkspace: false });
815
- }
816
- },
817
- });
818
- }
819
-
820
- export async function initializeWorkspace(workspaceRoot) {
821
- const requestedRoot = path.resolve(workspaceRoot);
822
- await mkdir(requestedRoot, { recursive: true, mode: 0o700 });
823
- const requestedDetails = await lstat(requestedRoot);
824
- if (requestedDetails.isSymbolicLink() || !requestedDetails.isDirectory()) {
825
- throw new Error(`workspace path is not a real directory: ${requestedRoot}`);
826
- }
827
- if (typeof process.getuid === "function" && requestedDetails.uid !== process.getuid()) {
828
- throw new Error(`workspace is not owned by the current user: ${requestedRoot}`);
829
- }
830
- const root = await realpath(requestedRoot);
831
- await chmod(root, 0o700);
832
- return withWorkspaceLock(root, async () => {
833
- const configPath = path.join(root, "taskchef.json");
834
- const configExists = await managedRegularFileExists(configPath);
835
- if (configExists) {
836
- await createStateBackupUnlocked(root, { reason: "pre-workspace-init" });
837
- }
838
- const config = configExists
839
- ? await readConfig(root, { checkPaths: false })
840
- : {
841
- schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
842
- projects: [],
843
- dashboard: { autostart: true },
844
- };
845
- const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
846
- if (configExists && (await managedRegularFileExists(dispatchPath))) {
847
- await readDispatchesUnlocked(root);
848
- }
849
- if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
850
- const tasks = await ensureDispatchFile(root);
851
- const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
852
- if (!configExists) await unlink(configPath).catch(() => {});
853
- throw error;
854
- });
855
- await Promise.all([configPath, tasks.path, instructions.path]
856
- .map((filePath) => chmod(filePath, 0o600)));
857
- return {
858
- workspace: root,
859
- config: {
860
- path: configPath,
861
- action: configExists ? "unchanged" : "created",
862
- value: config,
863
- },
864
- tasks,
865
- instructions,
866
- };
867
- });
868
- }
869
-
870
- export async function readConfig(workspaceRoot, { checkPaths = true } = {}) {
871
- const root = path.resolve(workspaceRoot);
872
- const configPath = path.join(root, "taskchef.json");
873
- if (!(await managedRegularFileExists(configPath))) {
874
- throw new Error(`configuration does not exist: ${configPath}`);
875
- }
876
- const config = await validateConfig(JSON.parse(await readFile(configPath, "utf8")), { checkPaths });
877
- const canonicalRoot = await realpath(root);
878
- for (const project of config.projects) assertWorkspaceOutsideProject(canonicalRoot, project.path);
879
- return config;
880
- }
881
-
882
- export async function listProjects(workspaceRoot) {
883
- const config = await readConfig(workspaceRoot);
884
- return [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
885
- }
886
-
887
- export async function readProjectIndex(workspaceRoot, { checkPaths = true } = {}) {
888
- const config = await readConfig(workspaceRoot, { checkPaths });
889
- const projects = [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
890
- return {
891
- schemaVersion: 1,
892
- projectCount: projects.length,
893
- configHash: configHash(config),
894
- projectSetHash: projectSetHash(config.projects),
895
- projects,
896
- ...(config.projectIndex ? { projectIndex: config.projectIndex } : {}),
897
- };
898
- }
899
-
900
- function mutationPreview(root, operation, current, updated) {
901
- const diff = projectDiff(current.projects, updated.projects);
902
- const beforeIndex = current.projectIndex ?? emptyProjectIndex();
903
- const afterIndex = updated.projectIndex ?? emptyProjectIndex();
904
- const projectIndexDiff = JSON.stringify(beforeIndex) === JSON.stringify(afterIndex)
905
- ? null
906
- : { before: beforeIndex, after: afterIndex };
907
- const preview = {
908
- schemaVersion: 1,
909
- operation,
910
- beforeCount: current.projects.length,
911
- afterCount: updated.projects.length,
912
- beforeConfigHash: configHash(current),
913
- afterConfigHash: configHash(updated),
914
- beforeProjectSetHash: projectSetHash(current.projects),
915
- afterProjectSetHash: projectSetHash(updated.projects),
916
- beforeProjects: current.projects,
917
- afterProjects: updated.projects,
918
- diff,
919
- projectIndexDiff,
920
- };
921
- return {
922
- ...preview,
923
- planHash: semanticHash({ workspace: root, ...preview }, "taskchef-mutation-plan-v1"),
924
- };
925
- }
926
-
927
- function exactStringSet(values, name) {
928
- if (!Array.isArray(values) || values.some((value) => typeof value !== "string" || value.length === 0)) {
929
- throw taskChefError("INVALID_INPUT", `${name} must be an array of exact project names`);
930
- }
931
- if (new Set(values).size !== values.length) {
932
- throw taskChefError("INVALID_INPUT", `${name} contains duplicate names`);
933
- }
934
- return [...values].sort();
935
- }
936
-
937
- function requirePreviewAuthorization(preview, options, { replacement = false } = {}) {
938
- if (options.expectedConfigHash !== preview.beforeConfigHash) {
939
- throw taskChefError(
940
- options.expectedConfigHash ? "STALE_PREVIEW" : "CONFIRMATION_REQUIRED",
941
- "mutation requires the current --expect-config-hash from a dry-run",
942
- { preview },
943
- );
944
- }
945
- if (!replacement && preview.diff.removed.length === 0) return;
946
- const required = [
947
- ["expectedProjectCount", preview.beforeCount],
948
- ["expectedAfterCount", preview.afterCount],
949
- ["confirmPlan", preview.planHash],
950
- ];
951
- for (const [name, expected] of required) {
952
- if (options[name] !== expected) {
953
- throw taskChefError("CONFIRMATION_REQUIRED", "destructive mutation confirmation does not match its preview", { preview });
954
- }
955
- }
956
- const removed = exactStringSet(preview.diff.removed.map((project) => project.name), "removed projects");
957
- const confirmed = exactStringSet(options.confirmRemoved, "--confirm-removed");
958
- if (JSON.stringify(removed) !== JSON.stringify(confirmed)) {
959
- throw taskChefError("CONFIRMATION_REQUIRED", "confirm every exact removed project name", { preview });
960
- }
961
- const collapse = preview.beforeCount > 0
962
- && (preview.afterCount === 0 || preview.afterCount / preview.beforeCount <= 0.5);
963
- if (collapse && options.confirmCountCollapse !== `${preview.beforeCount}:${preview.afterCount}`) {
964
- throw taskChefError("CONFIRMATION_REQUIRED", "large project-count collapse requires exact confirmation", { preview });
965
- }
966
- }
967
-
968
- async function commitConfigMutation(root, operation, current, updated, preview, {
969
- now = () => new Date().toISOString(),
970
- attribution = null,
971
- existingBackup = null,
972
- allowForensicBackup = false,
973
- skipReconcile = false,
974
- supersedePending = false,
975
- writeConfigFile = writeDurableAtomic,
976
- } = {}) {
977
- if (!skipReconcile) await reconcileAuditUnlocked(root, preview.beforeConfigHash);
978
- const transactionId = randomUUID();
979
- const backup = existingBackup ?? await createStateBackupUnlocked(root, {
980
- reason: `pre-${operation}`,
981
- now,
982
- });
983
- if (!backup.usable && !(allowForensicBackup && backup.integrityValid)) {
984
- throw taskChefError(
985
- "BACKUP_FAILED",
986
- "current state was captured forensically but is not valid enough to protect a mutation",
987
- { backupId: backup.id },
988
- );
989
- }
990
- if (backup.storagePressure) {
991
- throw taskChefError(
992
- "BACKUP_FAILED",
993
- "backup storage exceeds the protected retention budget; prune or free space before mutating configuration",
994
- { backupId: backup.id },
995
- );
996
- }
997
- const audit = {
998
- transactionId,
999
- timestamp: now(),
1000
- operation,
1001
- beforeHash: preview.beforeConfigHash,
1002
- afterHash: preview.afterConfigHash,
1003
- beforeCount: preview.beforeCount,
1004
- afterCount: preview.afterCount,
1005
- added: preview.diff.added.map((project) => project.name),
1006
- changed: preview.diff.changed.map((project) => project.after.name),
1007
- removed: preview.diff.removed.map((project) => project.name),
1008
- backupId: backup.id,
1009
- ...(attribution ? { attribution } : {}),
1010
- };
1011
- await appendAuditUnlocked(root, { ...audit, phase: "prepared" });
1012
- let replaced = false;
1013
- try {
1014
- await writeConfigFile(
1015
- path.join(root, "taskchef.json"),
1016
- `${JSON.stringify(updated, null, 2)}\n`,
1017
- { mode: 0o600 },
1018
- );
1019
- replaced = true;
1020
- const persisted = await readConfig(root, { checkPaths: false });
1021
- if (configHash(persisted) !== preview.afterConfigHash) {
1022
- throw new Error("persisted configuration does not match the planned mutation");
1023
- }
1024
- } catch (error) {
1025
- replaced ||= error.replacementCommitted === true;
1026
- if (!replaced) {
1027
- await appendAuditUnlocked(root, { ...audit, phase: "aborted", error: "configuration write failed" })
1028
- .catch(() => {});
1029
- throw error;
1030
- }
1031
- await appendAuditUnlocked(root, {
1032
- ...audit,
1033
- phase: "recovery-required",
1034
- error: "post-write verification failed",
1035
- }).catch(() => {});
1036
- throw taskChefError(
1037
- "STATE_RECOVERY_REQUIRED",
1038
- `configuration changed but transaction ${transactionId} needs reconciliation`,
1039
- { transactionId, backupId: backup.id },
1040
- );
1041
- }
1042
- try {
1043
- if (supersedePending) {
1044
- await supersedeAuditTransactionsUnlocked(root, transactionId);
1045
- }
1046
- await appendAuditUnlocked(root, { ...audit, phase: "committed" });
1047
- } catch (error) {
1048
- await appendAuditUnlocked(root, {
1049
- ...audit,
1050
- phase: "recovery-required",
1051
- error: "audit completion failed",
1052
- }).catch(() => {});
1053
- throw taskChefError(
1054
- "STATE_RECOVERY_REQUIRED",
1055
- `configuration changed but audit completion failed for transaction ${transactionId}: ${error.message}`,
1056
- { transactionId },
1057
- );
1058
- }
1059
- return { transactionId, backupId: backup.id, backupDeduplicated: backup.deduplicated };
1060
- }
1061
-
1062
- function validateNativeProjectSnapshot(snapshot) {
1063
- if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)
1064
- || snapshot.schemaVersion !== 2 || !Array.isArray(snapshot.projects)) {
1065
- throw new Error("native project snapshot must be a schemaVersion 2 object with a projects array");
1066
- }
1067
- return snapshot.projects.map((project, index) => {
1068
- const name = `native projects[${index}]`;
1069
- if (!project || typeof project !== "object" || Array.isArray(project)) {
1070
- throw new Error(`${name} must be an object`);
1071
- }
1072
- for (const field of ["projectId", "projectKind", "label"]) {
1073
- if (!(field in project)) throw new Error(`${name} is missing field: ${field}`);
1074
- }
1075
- const normalized = {
1076
- projectId: requireString(project.projectId, `${name}.projectId`).trim(),
1077
- projectKind: requireString(project.projectKind, `${name}.projectKind`).trim(),
1078
- label: requireString(project.label, `${name}.label`).trim(),
1079
- path: typeof project.path === "string" ? project.path.trim() : null,
1080
- hostId: typeof project.hostId === "string" ? project.hostId.trim() : null,
1081
- hostDisplayName: typeof project.hostDisplayName === "string"
1082
- ? project.hostDisplayName.trim() : null,
1083
- isGitRepository: typeof project.isGitRepository === "boolean"
1084
- ? project.isGitRepository : null,
1085
- };
1086
- if (normalized.projectKind === "local"
1087
- && (normalized.path === null || normalized.path.length === 0
1088
- || normalized.hostId === null || normalized.hostId.length === 0
1089
- || normalized.isGitRepository === null)) {
1090
- throw new Error(`${name} local project requires path, hostId, and isGitRepository`);
1091
- }
1092
- return normalized;
1093
- });
1094
- }
1095
-
1096
- function uniqueProjectName(preferred, projects) {
1097
- const base = preferred.trim();
1098
- const names = new Set(projects.map((project) => project.name.toLowerCase()));
1099
- if (!names.has(base.toLowerCase())) return base;
1100
- for (let suffix = 2; suffix < 10_000; suffix += 1) {
1101
- const candidate = `${base} (${suffix})`;
1102
- if (!names.has(candidate.toLowerCase())) return candidate;
1103
- }
1104
- throw new Error(`could not allocate a unique project name for ${base}`);
1105
- }
1106
-
1107
- function routingView(config) {
1108
- return (config.projectIndex?.routingHints ?? []).map((hint) => ({
1109
- path: hint.path,
1110
- aliases: hint.aliases.map((fact) => fact.value),
1111
- githubRepos: hint.githubRepos.map((fact) => fact.value),
1112
- responsibilities: hint.responsibilities.map((fact) => fact.value),
1113
- }));
1114
- }
1115
-
1116
- export async function reconcileProjects(workspaceRoot, nativeSnapshot, options = {}) {
1117
- const nativeProjects = validateNativeProjectSnapshot(nativeSnapshot);
1118
- const root = await realpath(path.resolve(workspaceRoot));
1119
- return withWorkspaceLock(root, async () => {
1120
- const current = await readConfig(root, { checkPaths: false });
1121
- const storedIndex = current.projectIndex ?? emptyProjectIndex();
1122
- const diagnostics = [];
1123
- const candidates = [];
1124
- for (const native of nativeProjects) {
1125
- if (native.projectKind !== "local" || native.hostId !== "local") {
1126
- diagnostics.push({
1127
- projectId: native.projectId,
1128
- code: "ineligible-host",
1129
- message: "Only local projects on the active local execution host are eligible.",
1130
- });
1131
- continue;
1132
- }
1133
- try {
1134
- const canonicalPath = await canonicalDirectory(native.path);
1135
- assertWorkspaceOutsideProject(root, canonicalPath);
1136
- candidates.push({ ...native, path: canonicalPath });
1137
- } catch (error) {
1138
- diagnostics.push({
1139
- projectId: native.projectId,
1140
- code: /own delegation project|inside a delegation project/.test(error.message)
1141
- ? "workspace-excluded" : "unavailable-path",
1142
- message: error.message,
1143
- });
1144
- }
1145
- }
1146
- const storedPaths = new Set([
1147
- ...current.projects.map((project) => project.path),
1148
- ...storedIndex.bindings.map((entry) => entry.path),
1149
- ...storedIndex.exclusions.map((entry) => entry.path),
1150
- ...storedIndex.routingHints.map((entry) => entry.path),
1151
- ]);
1152
- const resolvedPaths = new Map();
1153
- for (const storedPath of storedPaths) {
1154
- resolvedPaths.set(storedPath, await canonicalDirectory(storedPath).catch(() => null));
1155
- }
1156
- const configuredCanonicalCounts = new Map();
1157
- const candidatePaths = new Set(candidates.map((candidate) => candidate.path));
1158
- for (const project of current.projects) {
1159
- const canonicalPath = resolvedPaths.get(project.path);
1160
- if (canonicalPath) {
1161
- configuredCanonicalCounts.set(
1162
- canonicalPath,
1163
- (configuredCanonicalCounts.get(canonicalPath) ?? 0) + 1,
1164
- );
1165
- }
1166
- }
1167
- const canCanonicalize = (storedPath, canonicalPath) => (
1168
- storedPath === canonicalPath
1169
- || [storedIndex.bindings, storedIndex.exclusions, storedIndex.routingHints].every((entries) => (
1170
- !entries.some((entry) => entry.path === canonicalPath && entry.path !== storedPath)
1171
- ))
1172
- );
1173
- const canonicalizedPaths = new Map();
1174
- for (const project of current.projects) {
1175
- const canonicalPath = resolvedPaths.get(project.path);
1176
- if (canonicalPath && candidatePaths.has(canonicalPath)
1177
- && configuredCanonicalCounts.get(canonicalPath) === 1
1178
- && canCanonicalize(project.path, canonicalPath)) {
1179
- canonicalizedPaths.set(project.path, canonicalPath);
1180
- }
1181
- }
1182
- const canonicalizeIndexedPath = (entry) => ({
1183
- ...entry,
1184
- path: canonicalizedPaths.get(entry.path) ?? entry.path,
1185
- });
1186
- const projects = current.projects.map((project) => ({
1187
- ...project,
1188
- path: canonicalizedPaths.get(project.path) ?? project.path,
1189
- }));
1190
- const currentIndex = {
1191
- bindings: storedIndex.bindings.map(canonicalizeIndexedPath),
1192
- exclusions: storedIndex.exclusions.map(canonicalizeIndexedPath),
1193
- routingHints: storedIndex.routingHints.map(canonicalizeIndexedPath),
1194
- };
1195
- const resolvedStoredPath = (storedPath) => resolvedPaths.get(storedPath) ?? storedPath;
1196
- const idCounts = new Map();
1197
- const pathCounts = new Map();
1198
- for (const candidate of candidates) {
1199
- const id = `${candidate.hostId}\0${candidate.projectId}`;
1200
- idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
1201
- pathCounts.set(candidate.path, (pathCounts.get(candidate.path) ?? 0) + 1);
1202
- }
1203
- const bindings = [...currentIndex.bindings];
1204
- const added = [];
1205
- const bound = [];
1206
- const available = [];
1207
- for (const candidate of candidates) {
1208
- const id = `${candidate.hostId}\0${candidate.projectId}`;
1209
- if (idCounts.get(id) > 1 || pathCounts.get(candidate.path) > 1) {
1210
- diagnostics.push({
1211
- projectId: candidate.projectId,
1212
- code: idCounts.get(id) > 1 ? "duplicate-native-id" : "duplicate-canonical-path",
1213
- message: "Ambiguous native project identity was ignored.",
1214
- });
1215
- continue;
1216
- }
1217
- const excluded = currentIndex.exclusions.some((entry) => (
1218
- entry.hostId === candidate.hostId
1219
- && (entry.projectId === candidate.projectId
1220
- || resolvedStoredPath(entry.path) === candidate.path)
1221
- ));
1222
- if (excluded) {
1223
- diagnostics.push({
1224
- projectId: candidate.projectId,
1225
- code: "explicitly-excluded",
1226
- message: "Project remains excluded until explicitly included.",
1227
- });
1228
- continue;
1229
- }
1230
- const idBinding = bindings.find((entry) => (
1231
- entry.hostId === candidate.hostId && entry.projectId === candidate.projectId
1232
- ));
1233
- if (idBinding && resolvedStoredPath(idBinding.path) !== candidate.path) {
1234
- diagnostics.push({
1235
- projectId: candidate.projectId,
1236
- code: "identity-path-conflict",
1237
- message: `Native identity is still bound to ${idBinding.path}; review the move explicitly.`,
1238
- });
1239
- continue;
1240
- }
1241
- const pathBindings = bindings.filter(
1242
- (entry) => resolvedStoredPath(entry.path) === candidate.path,
1243
- );
1244
- if (pathBindings.length > 1) {
1245
- diagnostics.push({
1246
- projectId: candidate.projectId,
1247
- code: "ambiguous-configured-path",
1248
- message: "Multiple configured index paths resolve to this native project.",
1249
- });
1250
- continue;
1251
- }
1252
- const [pathBinding] = pathBindings;
1253
- if (pathBinding && (pathBinding.hostId !== candidate.hostId
1254
- || pathBinding.projectId !== candidate.projectId)) {
1255
- diagnostics.push({
1256
- projectId: candidate.projectId,
1257
- code: "path-identity-conflict",
1258
- message: "Canonical path is already bound to another native identity.",
1259
- });
1260
- continue;
1261
- }
1262
- const configuredMatches = projects.filter(
1263
- (project) => resolvedStoredPath(project.path) === candidate.path,
1264
- );
1265
- if (configuredMatches.length > 1) {
1266
- diagnostics.push({
1267
- projectId: candidate.projectId,
1268
- code: "ambiguous-configured-path",
1269
- message: "Multiple configured project paths resolve to this native project.",
1270
- });
1271
- continue;
1272
- }
1273
- let [configured] = configuredMatches;
1274
- if (!configured) {
1275
- try {
1276
- configured = await inspectProject({
1277
- path: candidate.path,
1278
- name: uniqueProjectName(candidate.label || path.basename(candidate.path), projects),
1279
- });
1280
- if (configured.isGitRepository !== candidate.isGitRepository) {
1281
- throw new Error("native Git repository metadata does not match the canonical path");
1282
- }
1283
- projects.push(configured);
1284
- added.push(configured);
1285
- } catch (error) {
1286
- diagnostics.push({
1287
- projectId: candidate.projectId,
1288
- code: "inspection-failed",
1289
- message: error.message,
1290
- });
1291
- continue;
1292
- }
1293
- }
1294
- if (!idBinding) {
1295
- const binding = {
1296
- hostId: candidate.hostId,
1297
- projectId: candidate.projectId,
1298
- path: configured.path,
1299
- };
1300
- bindings.push(binding);
1301
- bound.push(binding);
1302
- }
1303
- available.push({
1304
- hostId: candidate.hostId,
1305
- projectId: candidate.projectId,
1306
- path: candidate.path,
1307
- });
1308
- }
1309
- const indexChanged = JSON.stringify(bindings) !== JSON.stringify(currentIndex.bindings);
1310
- const updated = await validateConfig({
1311
- ...current,
1312
- projects,
1313
- ...(current.projectIndex || indexChanged
1314
- ? { projectIndex: { ...currentIndex, bindings } }
1315
- : {}),
1316
- }, { checkPaths: false });
1317
- const preview = mutationPreview(root, "project-reconcile", current, updated);
1318
- const changed = preview.beforeConfigHash !== preview.afterConfigHash;
1319
- const committed = changed
1320
- ? await commitConfigMutation(root, "project-reconcile", current, updated, preview, options)
1321
- : { transactionId: null, backupId: null, backupDeduplicated: false };
1322
- return {
1323
- schemaVersion: 1,
1324
- changed,
1325
- added,
1326
- bound,
1327
- available,
1328
- diagnostics,
1329
- projectCount: updated.projects.length,
1330
- ...preview,
1331
- ...committed,
1332
- };
1333
- });
1334
- }
1335
-
1336
- export async function includeProject(workspaceRoot, selector, options = {}) {
1337
- const root = await realpath(path.resolve(workspaceRoot));
1338
- return withWorkspaceLock(root, async () => {
1339
- const current = await readConfig(root, { checkPaths: false });
1340
- const index = current.projectIndex ?? emptyProjectIndex();
1341
- const selectedPaths = new Set();
1342
- if (selector.path) {
1343
- const requestedPath = path.resolve(requireString(selector.path, "project path").trim());
1344
- selectedPaths.add(requestedPath);
1345
- const canonicalPath = await canonicalDirectory(requestedPath).catch(() => null);
1346
- if (canonicalPath) selectedPaths.add(canonicalPath);
1347
- }
1348
- const exclusions = index.exclusions.filter((entry) => !(
1349
- selectedPaths.has(entry.path)
1350
- || (selector.projectId && entry.hostId === (selector.hostId ?? "local")
1351
- && entry.projectId === selector.projectId)
1352
- ));
1353
- if (exclusions.length === index.exclusions.length) {
1354
- return { changed: false, exclusions, configHash: configHash(current) };
1355
- }
1356
- const updated = await validateConfig({
1357
- ...current,
1358
- projectIndex: { ...index, exclusions },
1359
- }, { checkPaths: false });
1360
- const preview = mutationPreview(root, "project-include", current, updated);
1361
- const committed = await commitConfigMutation(root, "project-include", current, updated, preview, options);
1362
- return { changed: true, exclusions, ...preview, ...committed };
1363
- });
1364
- }
1365
-
1366
- function routingHintField(kind) {
1367
- if (kind === "alias") return "aliases";
1368
- if (kind === "githubRepo") return "githubRepos";
1369
- return "responsibilities";
1370
- }
1371
-
1372
- function emptyRoutingHint(projectPath) {
1373
- return {
1374
- path: projectPath,
1375
- aliases: [],
1376
- githubRepos: [],
1377
- responsibilities: [],
1378
- forgotten: [],
1379
- };
1380
- }
1381
-
1382
- export async function updateProjectHint(workspaceRoot, input, options = {}) {
1383
- requireExactFields(
1384
- input,
1385
- new Set(["action", "project", "kind", "value", "provenance"]),
1386
- "project hint input",
1387
- );
1388
- const action = requireEnum(
1389
- input.action,
1390
- new Set(["remember", "correct", "forget"]),
1391
- "project hint action",
1392
- );
1393
- const kind = requireEnum(input.kind, ROUTING_HINT_KINDS, "project hint kind");
1394
- let value = requireString(input.value, "project hint value").trim();
1395
- if (kind === "alias" && value.length > 80) throw new Error("project alias must be at most 80 characters");
1396
- if (kind === "responsibility" && value.length > 240) {
1397
- throw new Error("project responsibility must be at most 240 characters");
1398
- }
1399
- if (kind === "githubRepo") value = canonicalGithubRepository(value, "project hint value");
1400
- const root = await realpath(path.resolve(workspaceRoot));
1401
- return withWorkspaceLock(root, async () => {
1402
- const current = await readConfig(root, { checkPaths: false });
1403
- const configuredPath = path.resolve(requireString(input.project, "project hint project").trim());
1404
- const project = current.projects.find((candidate) => candidate.path === configuredPath);
1405
- if (!project) throw new Error(`configured project not found: ${configuredPath}`);
1406
- const normalizedFact = normalizeRoutingFacts([{
1407
- value,
1408
- provenance: input.provenance,
1409
- }], "project hint", {
1410
- kind,
1411
- maxCount: 1,
1412
- maxLength: kind === "alias" ? 80 : kind === "responsibility" ? 240 : 256,
1413
- })[0];
1414
- const provenance = normalizedFact.provenance;
1415
- if (action === "correct" && kind !== "alias") {
1416
- throw new Error("correct is only valid for an explicit alias correction");
1417
- }
1418
- if (action === "forget" && provenance.kind !== "explicit_user") {
1419
- throw new Error("forget requires explicit_user provenance");
1420
- }
1421
- if (action !== "forget" && kind === "alias" && provenance.kind !== "explicit_user") {
1422
- throw new Error("aliases require explicit_user provenance");
1423
- }
1424
- if (action !== "forget" && kind === "githubRepo"
1425
- && !new Set(["verified_repository", "accepted_terminal_report"]).has(provenance.kind)) {
1426
- throw new Error("repository ownership requires verified repository evidence");
1427
- }
1428
- if (action !== "forget" && kind === "responsibility"
1429
- && provenance.kind !== "accepted_terminal_report") {
1430
- throw new Error("responsibilities require accepted_terminal_report provenance");
1431
- }
1432
- if (action !== "forget" && kind === "githubRepo") {
1433
- if (provenance.repositoryPath === null) {
1434
- throw new Error("repository ownership requires an inspected repositoryPath");
1435
- }
1436
- const repositoryPath = await canonicalGitRoot(provenance.repositoryPath);
1437
- const relative = path.relative(project.path, repositoryPath);
1438
- if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
1439
- throw new Error("repositoryPath must be the selected project or a contained Git root");
1440
- }
1441
- const inspected = await inspectProject({ path: repositoryPath });
1442
- if (!inspected.githubRepos.some((repository) => repository.toLowerCase() === value.toLowerCase())) {
1443
- throw new Error("repository hint is not the inspected project's canonical origin");
1444
- }
1445
- }
1446
- if (provenance.kind === "accepted_terminal_report") {
1447
- const tasks = await readDispatchesUnlocked(root);
1448
- const task = tasks.find((candidate) => candidate.id === provenance.taskId);
1449
- const turn = task?.latestTurn;
1450
- if (!task || task.project.path !== project.path
1451
- || task.threadId !== provenance.threadId
1452
- || turn?.turnRef !== provenance.turnRef
1453
- || !RESULT_STATUSES.has(turn.result?.status)) {
1454
- throw new Error("project hint provenance is not the accepted current terminal report");
1455
- }
1456
- }
1457
- const projectIndex = current.projectIndex ?? emptyProjectIndex();
1458
- let hints = projectIndex.routingHints.map((hint) => ({
1459
- ...hint,
1460
- aliases: [...hint.aliases],
1461
- githubRepos: [...hint.githubRepos],
1462
- responsibilities: [...hint.responsibilities],
1463
- forgotten: [...hint.forgotten],
1464
- }));
1465
- let hint = hints.find((candidate) => candidate.path === project.path);
1466
- if (!hint) {
1467
- if (hints.length >= MAX_ROUTING_HINTS) throw new Error("project routing hint capacity is full");
1468
- hint = emptyRoutingHint(project.path);
1469
- hints.push(hint);
1470
- }
1471
- const field = routingHintField(kind);
1472
- const key = value.toLowerCase();
1473
- const forgottenIndex = hint.forgotten.findIndex((fact) => (
1474
- fact.kind === kind && fact.value.toLowerCase() === key
1475
- ));
1476
- let suppressed = false;
1477
- if (action !== "forget") {
1478
- if (forgottenIndex !== -1 && provenance.kind !== "explicit_user") {
1479
- suppressed = true;
1480
- } else {
1481
- if (forgottenIndex !== -1) hint.forgotten.splice(forgottenIndex, 1);
1482
- if (action === "correct" && kind === "alias") {
1483
- for (const other of hints.filter((candidate) => candidate.path !== project.path)) {
1484
- const removed = other.aliases.some((fact) => fact.value.toLowerCase() === key);
1485
- other.aliases = other.aliases.filter((fact) => fact.value.toLowerCase() !== key);
1486
- if (removed && !other.forgotten.some((fact) => (
1487
- fact.kind === "alias" && fact.value.toLowerCase() === key
1488
- ))) {
1489
- if (other.forgotten.length >= 32) throw new Error("project forgotten-hint capacity is full");
1490
- other.forgotten.push({ kind: "alias", value });
1491
- }
1492
- }
1493
- }
1494
- if (!hint[field].some((fact) => fact.value.toLowerCase() === key)) {
1495
- const limit = kind === "alias" ? 16 : kind === "githubRepo" ? 16 : 8;
1496
- if (hint[field].length >= limit) throw new Error(`project ${field} capacity is full`);
1497
- hint[field].push(normalizedFact);
1498
- }
1499
- }
1500
- } else {
1501
- hint[field] = hint[field].filter((fact) => fact.value.toLowerCase() !== key);
1502
- if (forgottenIndex === -1) {
1503
- if (hint.forgotten.length >= 32) throw new Error("project forgotten-hint capacity is full");
1504
- hint.forgotten.push({ kind, value });
1505
- }
1506
- }
1507
- hints = hints.filter((candidate) => (
1508
- candidate.aliases.length || candidate.githubRepos.length
1509
- || candidate.responsibilities.length || candidate.forgotten.length
1510
- ));
1511
- const updated = await validateConfig({
1512
- ...current,
1513
- projectIndex: { ...projectIndex, routingHints: hints },
1514
- }, { checkPaths: false });
1515
- const preview = mutationPreview(root, `project-hint-${action}`, current, updated);
1516
- const changed = preview.beforeConfigHash !== preview.afterConfigHash;
1517
- const committed = changed
1518
- ? await commitConfigMutation(root, `project-hint-${action}`, current, updated, preview, options)
1519
- : { transactionId: null, backupId: null, backupDeduplicated: false };
1520
- return { changed, suppressed, project: project.path, kind, value, ...preview, ...committed };
1521
- });
1522
- }
1523
-
1524
- export async function prepareDispatch(workspaceRoot, {
1525
- taskId = randomUUID(),
1526
- now = () => new Date().toISOString(),
1527
- } = {}) {
1528
- const workspace = await realpath(path.resolve(workspaceRoot));
1529
- const config = await readConfig(workspace, { checkPaths: false });
1530
- const projects = [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
1531
- const marker = taskChefMarker(taskId);
1532
- const preparedAt = requireTimestamp(now(), "preparedAt");
1533
- return {
1534
- schemaVersion: 1,
1535
- workspace,
1536
- taskId,
1537
- preparedAt,
1538
- marker,
1539
- projectCount: projects.length,
1540
- projects,
1541
- routingHints: routingView(config),
1542
- modelRoles: await resolveModelRoles(),
1543
- };
1544
- }
1545
-
1546
- export async function addProject(workspaceRoot, input, options = {}) {
1547
- const root = await realpath(path.resolve(workspaceRoot));
1548
- return withWorkspaceLock(root, async () => {
1549
- const config = await readConfig(root, { checkPaths: false });
1550
- const project = await inspectProject(input);
1551
- assertWorkspaceOutsideProject(root, project.path);
1552
- const currentIndex = config.projectIndex ?? emptyProjectIndex();
1553
- const exclusions = currentIndex.exclusions.filter((entry) => entry.path !== project.path);
1554
- const updated = await validateConfig({
1555
- ...config,
1556
- projects: [...config.projects, project],
1557
- ...(config.projectIndex || exclusions.length !== currentIndex.exclusions.length
1558
- ? { projectIndex: { ...currentIndex, exclusions } }
1559
- : {}),
1560
- }, { checkPaths: false });
1561
- const preview = mutationPreview(root, "project-add", config, updated);
1562
- if (options.dryRun) return { changed: true, project, ...preview };
1563
- const committed = await commitConfigMutation(root, "project-add", config, updated, preview, options);
1564
- Object.defineProperties(project, {
1565
- mutation: { value: { ...preview, ...committed }, enumerable: false },
1566
- });
1567
- return project;
1568
- });
1569
- }
1570
-
1571
- export async function importProjects(workspaceRoot, inputs, { replace = false, dryRun = false, ...options } = {}) {
1572
- if (!Array.isArray(inputs)) throw new Error("project import must be a JSON array");
1573
- const root = await realpath(path.resolve(workspaceRoot));
1574
- return withWorkspaceLock(root, async () => {
1575
- const current = await readConfig(root, { checkPaths: !replace });
1576
- const imported = [];
1577
- for (const [index, input] of inputs.entries()) {
1578
- const canonicalPath = await canonicalDirectory(input?.path);
1579
- assertWorkspaceOutsideProject(root, canonicalPath);
1580
- const existing = current.projects.find((project) => project.path === canonicalPath);
1581
- const mergedInput = { ...input, path: canonicalPath };
1582
- if (!("name" in mergedInput) && existing) mergedInput.name = existing.name;
1583
- if (!("description" in mergedInput) && existing?.description) {
1584
- mergedInput.description = existing.description;
1585
- }
1586
- if (existing && !replace) {
1587
- const importedRepositories = "githubRepos" in mergedInput
1588
- ? normalizeGithubRepositories(mergedInput.githubRepos, `projects[${index}].githubRepos`)
1589
- : [];
1590
- mergedInput.githubRepos = [...existing.githubRepos, ...importedRepositories];
1591
- }
1592
- imported.push(await inspectProject(mergedInput, index));
1593
- }
1594
- const projects = replace ? [] : [...current.projects];
1595
- for (const project of imported) {
1596
- const index = projects.findIndex((existing) => existing.path === project.path);
1597
- if (index === -1) projects.push(project);
1598
- else projects[index] = project;
1599
- }
1600
- const currentIndex = current.projectIndex ?? emptyProjectIndex();
1601
- const retainedPaths = new Set(projects.map((project) => project.path));
1602
- const removedProjects = replace
1603
- ? current.projects.filter((project) => !retainedPaths.has(project.path))
1604
- : [];
1605
- const exclusions = [...currentIndex.exclusions];
1606
- for (const project of removedProjects) {
1607
- const binding = currentIndex.bindings.find((entry) => entry.path === project.path);
1608
- const alreadyExcluded = exclusions.some((entry) => (
1609
- entry.path === project.path
1610
- || (binding && entry.hostId === binding.hostId && entry.projectId === binding.projectId)
1611
- ));
1612
- if (!alreadyExcluded) {
1613
- exclusions.push({
1614
- hostId: binding?.hostId ?? "local",
1615
- projectId: binding?.projectId ?? null,
1616
- path: project.path,
1617
- });
1618
- }
1619
- }
1620
- const nextProjectIndex = {
1621
- ...projectIndexForProjects(currentIndex, projects),
1622
- exclusions,
1623
- };
1624
- const config = await validateConfig({
1625
- ...current,
1626
- projects,
1627
- ...(current.projectIndex || removedProjects.length > 0
1628
- ? { projectIndex: nextProjectIndex }
1629
- : {}),
1630
- }, { checkPaths: false });
1631
- const preview = mutationPreview(root, replace ? "project-replace" : "project-import", current, config);
1632
- if (dryRun) {
1633
- return {
1634
- mode: replace ? "replace" : "merge",
1635
- importedCount: imported.length,
1636
- projectCount: config.projects.length,
1637
- projects: imported,
1638
- changed: preview.beforeConfigHash !== preview.afterConfigHash,
1639
- ...preview,
1640
- };
1641
- }
1642
- if (replace) requirePreviewAuthorization(preview, options, { replacement: true });
1643
- const changed = preview.beforeConfigHash !== preview.afterConfigHash;
1644
- const committed = changed
1645
- ? await commitConfigMutation(root, replace ? "project-replace" : "project-import", current, config, preview, options)
1646
- : { transactionId: null, backupId: null, backupDeduplicated: false };
1647
- return {
1648
- mode: replace ? "replace" : "merge",
1649
- importedCount: imported.length,
1650
- projectCount: config.projects.length,
1651
- projects: imported,
1652
- changed,
1653
- ...preview,
1654
- ...committed,
1655
- };
1656
- });
1657
- }
1658
-
1659
- export async function removeProject(workspaceRoot, name, { dryRun = false, ...options } = {}) {
1660
- const root = await realpath(path.resolve(workspaceRoot));
1661
- return withWorkspaceLock(root, async () => {
1662
- const config = await readConfig(root, { checkPaths: false });
1663
- const index = config.projects.findIndex(
1664
- (project) => project.name.toLowerCase() === requireString(name, "project name").toLowerCase(),
1665
- );
1666
- if (index === -1) throw new Error(`configured project not found: ${name}`);
1667
- const [project] = config.projects.slice(index, index + 1);
1668
- const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
1669
- const currentIndex = config.projectIndex ?? emptyProjectIndex();
1670
- const binding = currentIndex.bindings.find((entry) => entry.path === project.path);
1671
- const exclusion = {
1672
- hostId: binding?.hostId ?? "local",
1673
- projectId: binding?.projectId ?? null,
1674
- path: project.path,
1675
- };
1676
- const updated = await validateConfig({
1677
- ...config,
1678
- projects,
1679
- projectIndex: {
1680
- bindings: currentIndex.bindings.filter((entry) => entry.path !== project.path),
1681
- exclusions: [
1682
- ...currentIndex.exclusions.filter((entry) => entry.path !== project.path),
1683
- exclusion,
1684
- ],
1685
- routingHints: currentIndex.routingHints.filter((entry) => entry.path !== project.path),
1686
- },
1687
- }, { checkPaths: false });
1688
- const preview = mutationPreview(root, "project-remove", config, updated);
1689
- if (dryRun) return { project, changed: true, ...preview };
1690
- requirePreviewAuthorization(preview, options);
1691
- const committed = await commitConfigMutation(root, "project-remove", config, updated, preview, options);
1692
- return { project, changed: true, ...preview, ...committed };
1693
- });
1694
- }
1695
-
1696
- export async function updateProject(workspaceRoot, selector, input, {
1697
- dryRun = false,
1698
- ...options
1699
- } = {}) {
1700
- const root = await realpath(path.resolve(workspaceRoot));
1701
- return withWorkspaceLock(root, async () => {
1702
- const current = await readConfig(root, { checkPaths: false });
1703
- const index = current.projects.findIndex((project) => (
1704
- selector.path
1705
- ? project.path === path.resolve(selector.path)
1706
- : project.name.toLowerCase() === requireString(selector.name, "project name").toLowerCase()
1707
- ));
1708
- if (index === -1) throw taskChefError("INVALID_INPUT", "configured project not found");
1709
- if (!input || typeof input !== "object" || Array.isArray(input)) {
1710
- throw taskChefError("INVALID_INPUT", "project update fields must be an object");
1711
- }
1712
- const allowed = new Set(["name", "description", "clearDescription", "githubRepos", "refreshGit"]);
1713
- const unexpected = Object.keys(input).find((key) => !allowed.has(key));
1714
- if (unexpected) throw taskChefError("INVALID_INPUT", `unsupported project update field: ${unexpected}`);
1715
- if (Object.keys(input).length === 0) throw taskChefError("INVALID_INPUT", "project update requires a field");
1716
- if ("description" in input && input.clearDescription) {
1717
- throw taskChefError("INVALID_INPUT", "description and clearDescription cannot be combined");
1718
- }
1719
- const before = current.projects[index];
1720
- let project = { ...before };
1721
- if ("name" in input) project.name = requireString(input.name, "project name").trim();
1722
- if ("description" in input) project.description = requireString(input.description, "description").trim();
1723
- if (input.clearDescription) delete project.description;
1724
- if ("githubRepos" in input) {
1725
- project.githubRepos = normalizeGithubRepositories(input.githubRepos, "githubRepos");
1726
- }
1727
- if (input.refreshGit) {
1728
- const inspected = await inspectProject({ path: before.path });
1729
- project.isGitRepository = inspected.isGitRepository;
1730
- }
1731
- const projects = [...current.projects];
1732
- projects[index] = project;
1733
- const updated = await validateConfig({ ...current, projects }, { checkPaths: false });
1734
- const preview = mutationPreview(root, "project-update", current, updated);
1735
- const changed = preview.beforeConfigHash !== preview.afterConfigHash;
1736
- if (dryRun || !changed) return { project: updated.projects[index], changed, ...preview };
1737
- requirePreviewAuthorization(preview, options);
1738
- const committed = await commitConfigMutation(root, "project-update", current, updated, preview, options);
1739
- return { project: updated.projects[index], changed, ...preview, ...committed };
1740
- });
1741
- }
1742
-
1743
- export async function createWorkspaceBackup(workspaceRoot, options = {}) {
1744
- const root = await realpath(path.resolve(workspaceRoot));
1745
- return withWorkspaceLock(root, () => createStateBackupUnlocked(root, options));
1746
- }
1747
-
1748
- export async function listWorkspaceBackups(workspaceRoot) {
1749
- const root = await realpath(path.resolve(workspaceRoot));
1750
- return listBackups(root);
1751
- }
1752
-
1753
- export async function verifyWorkspaceBackup(workspaceRoot, id) {
1754
- const root = await realpath(path.resolve(workspaceRoot));
1755
- return verifyBackup(root, id);
1756
- }
1757
-
1758
- export async function pruneWorkspaceBackups(workspaceRoot, options = {}) {
1759
- const root = await realpath(path.resolve(workspaceRoot));
1760
- return withWorkspaceLock(root, () => pruneBackupsUnlocked(root, options));
1761
- }
1762
-
1763
- export async function restoreWorkspaceBackup(workspaceRoot, id, {
1764
- dryRun = false,
1765
- scope = "projects",
1766
- approveRestore = false,
1767
- confirmOffline = false,
1768
- confirmUnreadableCurrent = false,
1769
- maintenanceTransaction = null,
1770
- writeStateFile = writeDurableAtomic,
1771
- ...options
1772
- } = {}) {
1773
- if (!new Set(["projects", "config", "state"]).has(scope)) {
1774
- throw taskChefError("INVALID_INPUT", "restore scope must be projects, config, or state");
1775
- }
1776
- const root = await realpath(path.resolve(workspaceRoot));
1777
- return withWorkspaceLock(root, async () => {
1778
- let maintenance = null;
1779
- if (maintenanceTransaction !== null) {
1780
- maintenance = JSON.parse(await readFile(path.join(root, ".taskchef-maintenance.json"), "utf8"));
1781
- if (maintenance.transactionId !== maintenanceTransaction) {
1782
- throw taskChefError("STATE_RECOVERY_REQUIRED", "maintenance transaction ID does not match");
1783
- }
1784
- if (!new Set([maintenance.backupId, maintenance.safetyBackupId]).has(id)) {
1785
- throw taskChefError("STATE_RECOVERY_REQUIRED", "recovery backup does not belong to the maintenance transaction");
1786
- }
1787
- }
1788
- const sourceVerification = await verifyBackup(root, id);
1789
- const maintenanceRollback = maintenance !== null
1790
- && id === maintenance.safetyBackupId
1791
- && sourceVerification.integrityValid;
1792
- if (!sourceVerification.usable && !maintenanceRollback) {
1793
- throw taskChefError("BACKUP_FAILED", sourceVerification.error);
1794
- }
1795
- let sourcePayloads = null;
1796
- if (scope === "state" || maintenanceRollback) {
1797
- sourcePayloads = (await readBackupPayloads(
1798
- root,
1799
- id,
1800
- ["taskchef.json", "tasks.jsonl", "AGENTS.md"],
1801
- { allowForensic: maintenanceRollback, allowMissing: maintenanceRollback },
1802
- )).payloads;
1803
- if (!maintenanceRollback) {
1804
- await parseTaskLogContent(root, sourcePayloads[DISPATCH_FILE_NAME].toString("utf8"), {
1805
- validateWorkspace: false,
1806
- });
1807
- }
1808
- }
1809
- let stored = null;
1810
- let storedError = null;
1811
- const storedConfigBytes = sourcePayloads !== null ? sourcePayloads["taskchef.json"] : (
1812
- sourceVerification.usable && sourceVerification.manifest.files["taskchef.json"].present
1813
- ? await readFile(path.join(sourceVerification.path, "taskchef.json"))
1814
- : null
1815
- );
1816
- if (storedConfigBytes !== null) {
1817
- try {
1818
- stored = await validateConfig(JSON.parse(storedConfigBytes.toString("utf8")), {
1819
- checkPaths: false,
1820
- });
1821
- } catch (error) {
1822
- storedError = error.message;
1823
- }
1824
- } else {
1825
- storedError = "backup configuration is absent";
1826
- }
1827
- if (!stored && !maintenanceRollback) {
1828
- throw taskChefError(
1829
- "BACKUP_FAILED",
1830
- `backup configuration cannot be restored: ${storedError}`,
1831
- );
1832
- }
1833
- let current = null;
1834
- let currentBytes = null;
1835
- let currentPresent = true;
1836
- let currentError = null;
1837
- try {
1838
- currentBytes = await readFile(path.join(root, "taskchef.json"));
1839
- current = await readConfig(root, { checkPaths: false });
1840
- } catch (error) {
1841
- currentError = error.message;
1842
- if (error.code === "ENOENT" || /configuration does not exist/.test(error.message)) {
1843
- currentPresent = false;
1844
- currentBytes = null;
1845
- }
1846
- }
1847
- if ((!current || !stored) && scope === "projects") {
1848
- throw taskChefError(
1849
- "INVALID_INPUT",
1850
- "projects-only restore requires readable current configuration; preview --scope config or state instead",
1851
- );
1852
- }
1853
- let updated = stored;
1854
- if (scope === "projects") {
1855
- const { projectIndex: _currentIndex, ...currentWithoutIndex } = current;
1856
- updated = await validateConfig({
1857
- ...currentWithoutIndex,
1858
- projects: stored.projects,
1859
- ...(stored.projectIndex ? { projectIndex: stored.projectIndex } : {}),
1860
- }, { checkPaths: false });
1861
- }
1862
- const beforeStateHash = current
1863
- ? configHash(current)
1864
- : currentPresent ? `unreadable:${sha256(currentBytes)}` : "missing";
1865
- const afterStateHash = updated
1866
- ? configHash(updated)
1867
- : storedConfigBytes === null ? "missing" : `unreadable:${sha256(storedConfigBytes)}`;
1868
- const preview = current && updated
1869
- ? mutationPreview(root, `backup-restore-${scope}`, current, updated)
1870
- : {
1871
- schemaVersion: 1,
1872
- operation: `backup-restore-${scope}`,
1873
- beforeCount: null,
1874
- afterCount: updated?.projects.length ?? null,
1875
- beforeConfigHash: beforeStateHash,
1876
- afterConfigHash: afterStateHash,
1877
- beforeProjectSetHash: current ? projectSetHash(current.projects) : null,
1878
- afterProjectSetHash: updated ? projectSetHash(updated.projects) : null,
1879
- beforeProjects: current?.projects ?? null,
1880
- afterProjects: updated?.projects ?? null,
1881
- diff: { added: [], changed: [], removed: [], unavailable: true },
1882
- currentError,
1883
- storedError,
1884
- };
1885
- let stateFiles = [];
1886
- if (scope === "state") {
1887
- const verified = sourceVerification;
1888
- for (const name of ["taskchef.json", "tasks.jsonl", "AGENTS.md"]) {
1889
- const liveBytes = await readFile(path.join(root, name)).catch((error) => {
1890
- if (error.code === "ENOENT") return null;
1891
- throw error;
1892
- });
1893
- stateFiles.push({
1894
- name,
1895
- beforePresent: liveBytes !== null,
1896
- beforeHash: liveBytes === null ? null : sha256(liveBytes),
1897
- afterPresent: sourcePayloads[name] !== null,
1898
- afterHash: verified.manifest.files[name].sha256,
1899
- changed: (liveBytes === null) !== (sourcePayloads[name] === null)
1900
- || (liveBytes !== null && sha256(liveBytes) !== verified.manifest.files[name].sha256),
1901
- });
1902
- }
1903
- }
1904
- const restorePlan = {
1905
- ...preview,
1906
- planHash: semanticHash({
1907
- schemaVersion: 1,
1908
- workspace: root,
1909
- backupId: id,
1910
- scope,
1911
- beforeConfigHash: preview.beforeConfigHash,
1912
- afterConfigHash: preview.afterConfigHash,
1913
- beforeProjectSetHash: preview.beforeProjectSetHash,
1914
- afterProjectSetHash: preview.afterProjectSetHash,
1915
- diff: preview.diff,
1916
- stateFiles,
1917
- }, "taskchef-restore-plan-v1"),
1918
- };
1919
- if (dryRun) return {
1920
- dryRun: true,
1921
- backupId: id,
1922
- scope,
1923
- changed: restorePlan.beforeConfigHash !== restorePlan.afterConfigHash
1924
- || stateFiles.some((file) => file.changed),
1925
- stateFiles,
1926
- ...restorePlan,
1927
- };
1928
- if (!approveRestore) {
1929
- throw taskChefError("CONFIRMATION_REQUIRED", "restore requires explicit --approve-restore", {
1930
- preview: restorePlan,
1931
- });
1932
- }
1933
- if (!current || !updated) {
1934
- if (!confirmUnreadableCurrent
1935
- || options.expectedConfigHash !== restorePlan.beforeConfigHash
1936
- || options.confirmPlan !== restorePlan.planHash) {
1937
- throw taskChefError(
1938
- "CONFIRMATION_REQUIRED",
1939
- "raw-state recovery requires its exact current hash, restore plan, and --confirm-unreadable-current",
1940
- { preview: restorePlan },
1941
- );
1942
- }
1943
- } else {
1944
- requirePreviewAuthorization(restorePlan, options, { replacement: true });
1945
- }
1946
- if (scope === "state" && !confirmOffline) {
1947
- throw taskChefError(
1948
- "CONFIRMATION_REQUIRED",
1949
- "full state restore requires --confirm-offline after stopping the dashboard and TaskChef writers",
1950
- { preview: restorePlan, stateFiles },
1951
- );
1952
- }
1953
- await validateAuditUnlocked(root);
1954
- const safety = maintenance
1955
- ? await verifyBackup(root, maintenance.safetyBackupId)
1956
- : await createStateBackupUnlocked(root, { reason: "pre-restore", now: options.now });
1957
- if (!safety.usable && !safety.integrityValid) {
1958
- throw taskChefError("BACKUP_FAILED", "restore safety backup is unavailable", {
1959
- backupId: maintenance?.safetyBackupId ?? safety.id,
1960
- });
1961
- }
1962
- if (scope === "state") {
1963
- const transactionId = maintenanceTransaction ?? randomUUID();
1964
- const payloads = sourcePayloads;
1965
- const fencePath = path.join(root, ".taskchef-maintenance.json");
1966
- await writeDurableAtomic(fencePath, `${JSON.stringify({
1967
- schemaVersion: 1,
1968
- transactionId,
1969
- operation: "state-restore",
1970
- backupId: maintenance?.backupId ?? id,
1971
- recoveryBackupId: id,
1972
- safetyBackupId: safety.id,
1973
- stateFiles,
1974
- }, null, 2)}\n`);
1975
- await appendAuditUnlocked(root, {
1976
- schemaVersion: 1,
1977
- transactionId,
1978
- timestamp: new Date().toISOString(),
1979
- operation: "backup-restore-state",
1980
- phase: "prepared",
1981
- beforeHash: restorePlan.beforeConfigHash,
1982
- afterHash: restorePlan.afterConfigHash,
1983
- beforeCount: restorePlan.beforeCount,
1984
- afterCount: restorePlan.afterCount,
1985
- added: restorePlan.diff.added.map((project) => project.name),
1986
- changed: restorePlan.diff.changed.map((project) => project.after.name),
1987
- removed: restorePlan.diff.removed.map((project) => project.name),
1988
- backupId: id,
1989
- });
1990
- try {
1991
- for (const name of ["tasks.jsonl", "AGENTS.md", "taskchef.json"]) {
1992
- const filePath = path.join(root, name);
1993
- if (payloads[name] === null) await removeDurable(filePath);
1994
- else await writeStateFile(filePath, payloads[name]);
1995
- }
1996
- for (const file of stateFiles) {
1997
- const restoredBytes = await readFile(path.join(root, file.name)).catch((error) => {
1998
- if (error.code === "ENOENT") return null;
1999
- throw error;
2000
- });
2001
- if ((restoredBytes !== null) !== file.afterPresent
2002
- || (restoredBytes !== null && sha256(restoredBytes) !== file.afterHash)) {
2003
- throw new Error(`restored payload failed verification: ${file.name}`);
2004
- }
2005
- }
2006
- await removeDurable(path.join(root, ".taskchef-usage.json"));
2007
- await supersedeAuditTransactionsUnlocked(root, transactionId);
2008
- await appendAuditUnlocked(root, {
2009
- transactionId,
2010
- timestamp: new Date().toISOString(),
2011
- operation: "backup-restore-state",
2012
- phase: "committed",
2013
- beforeHash: restorePlan.beforeConfigHash,
2014
- afterHash: restorePlan.afterConfigHash,
2015
- beforeCount: restorePlan.beforeCount,
2016
- afterCount: restorePlan.afterCount,
2017
- added: restorePlan.diff.added.map((project) => project.name),
2018
- changed: restorePlan.diff.changed.map((project) => project.after.name),
2019
- removed: restorePlan.diff.removed.map((project) => project.name),
2020
- backupId: id,
2021
- });
2022
- await removeDurable(fencePath);
2023
- } catch (error) {
2024
- throw taskChefError(
2025
- "STATE_RECOVERY_REQUIRED",
2026
- `state restore ${transactionId} is incomplete: ${error.message}`,
2027
- { transactionId, safetyBackupId: safety.id },
2028
- );
2029
- }
2030
- return {
2031
- backupId: id,
2032
- safetyBackupId: safety.id,
2033
- scope,
2034
- changed: true,
2035
- stateFiles,
2036
- transactionId,
2037
- ...restorePlan,
2038
- };
2039
- }
2040
- const committed = await commitConfigMutation(
2041
- root,
2042
- `backup-restore-${scope}`,
2043
- current,
2044
- updated,
2045
- restorePlan,
2046
- {
2047
- ...options,
2048
- existingBackup: safety,
2049
- allowForensicBackup: !current,
2050
- skipReconcile: true,
2051
- supersedePending: true,
2052
- },
2053
- );
2054
- return {
2055
- ...restorePlan,
2056
- ...committed,
2057
- backupId: id,
2058
- safetyBackupId: safety.id,
2059
- scope,
2060
- changed: true,
2061
- };
2062
- }, { allowMaintenance: maintenanceTransaction !== null });
2063
- }
2064
-
2065
- export async function readWorkspaceMaintenance(workspaceRoot) {
2066
- const root = await realpath(path.resolve(workspaceRoot));
2067
- const filePath = path.join(root, ".taskchef-maintenance.json");
2068
- try {
2069
- const value = JSON.parse(await readFile(filePath, "utf8"));
2070
- return { active: true, ...value };
2071
- } catch (error) {
2072
- if (error.code === "ENOENT") return { active: false };
2073
- throw taskChefError("STATE_RECOVERY_REQUIRED", `maintenance state is unreadable: ${error.message}`);
2074
- }
2075
- }
2076
-
2077
- async function validateDispatchShape(dispatch, name = "task") {
2078
- const supportedVersions = [
2079
- FIRST_SELF_LINKING_TASK_SCHEMA_VERSION,
2080
- 5,
2081
- LEGACY_RESULTS_TASK_SCHEMA_VERSION,
2082
- FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION,
2083
- INTERRUPTED_TURN_TASK_SCHEMA_VERSION,
2084
- PREVIOUS_TASK_SCHEMA_VERSION,
2085
- CURRENT_TASK_SCHEMA_VERSION,
2086
- ];
2087
- if (!supportedVersions.includes(dispatch?.schemaVersion)) {
2088
- throw new Error(`unsupported ${name} schemaVersion`);
2089
- }
2090
- requireExactFields(
2091
- dispatch,
2092
- dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
2093
- ? DISPATCH_FIELDS
2094
- : dispatch.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION
2095
- ? LEGACY_TURN_DISPATCH_FIELDS
2096
- : dispatch.schemaVersion === LEGACY_RESULTS_TASK_SCHEMA_VERSION
2097
- ? SCHEMA_6_DISPATCH_FIELDS
2098
- : dispatch.schemaVersion === 5
2099
- ? SCHEMA_5_DISPATCH_FIELDS
2100
- : STATEFUL_DISPATCH_FIELDS,
2101
- name,
2102
- );
2103
- const id = requireSafeId(dispatch.id, `${name}.id`);
2104
- const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
2105
- const status = requireEnum(dispatch.status, TASK_STATUSES, `${name}.status`);
2106
- const summary = optionalString(dispatch.summary, `${name}.summary`, {
2107
- maxLength: MAX_RESULT_SUMMARY_LENGTH,
2108
- });
2109
- const rawTurnId = optionalString(dispatch.turnId, `${name}.turnId`, { maxLength: 256 });
2110
- const turnId = rawTurnId === null ? null : normalizeTurnRef(rawTurnId, `${name}.turnId`);
2111
- const storedTurnRef = dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
2112
- ? (dispatch.turnRef === null ? null : normalizeTurnRef(dispatch.turnRef, `${name}.turnRef`))
2113
- : null;
2114
- const updatedAt = requireTimestamp(dispatch.updatedAt, `${name}.updatedAt`);
2115
- const normalizeResult = (result, resultName) => {
2116
- requireExactFields(result, RESULT_FIELDS, resultName);
2117
- const normalizedResult = {
2118
- status: requireEnum(
2119
- result.status,
2120
- RESULT_STATUSES,
2121
- `${resultName}.status`,
2122
- ),
2123
- summary: optionalString(
2124
- result.summary,
2125
- `${resultName}.summary`,
2126
- { maxLength: MAX_RESULT_SUMMARY_LENGTH },
2127
- ),
2128
- turnId: result.turnId === null
2129
- ? null
2130
- : normalizeTurnRef(optionalString(
2131
- result.turnId,
2132
- `${resultName}.turnId`,
2133
- { maxLength: 256 },
2134
- ), `${resultName}.turnId`),
2135
- updatedAt: requireTimestamp(
2136
- result.updatedAt,
2137
- `${resultName}.updatedAt`,
2138
- ),
2139
- };
2140
- if (normalizedResult.summary === null) {
2141
- throw new Error(`${resultName}.summary must be a non-empty string`);
2142
- }
2143
- return normalizedResult;
2144
- };
2145
- const normalizeTurnResult = (result, resultName) => {
2146
- if (result === null) return null;
2147
- requireExactFields(result, TURN_RESULT_FIELDS, resultName);
2148
- const normalizedResult = {
2149
- status: requireEnum(
2150
- result.status,
2151
- dispatch.schemaVersion >= INTERRUPTED_TURN_TASK_SCHEMA_VERSION
2152
- ? TURN_RESULT_STATUSES
2153
- : RESULT_STATUSES,
2154
- `${resultName}.status`,
2155
- ),
2156
- summary: optionalString(result.summary, `${resultName}.summary`, {
2157
- maxLength: MAX_RESULT_SUMMARY_LENGTH,
2158
- }),
2159
- updatedAt: requireTimestamp(result.updatedAt, `${resultName}.updatedAt`),
2160
- };
2161
- if (normalizedResult.summary === null) {
2162
- throw new Error(`${resultName}.summary must be a non-empty string`);
2163
- }
2164
- if (
2165
- normalizedResult.status === "interrupted"
2166
- && normalizedResult.summary !== INTERRUPTED_TURN_SUMMARY
2167
- ) {
2168
- throw new Error(`${resultName}.summary must use the TaskChef interrupted-turn summary`);
2169
- }
2170
- return normalizedResult;
2171
- };
2172
- const normalizeTurn = (turn, turnName) => {
2173
- requireExactFields(
2174
- turn,
2175
- dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
2176
- ? TURN_FIELDS
2177
- : dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
2178
- ? SCHEMA_9_TURN_FIELDS
2179
- : LEGACY_TURN_FIELDS,
2180
- turnName,
2181
- );
2182
- const rawTurnId = optionalString(turn.turnId, `${turnName}.turnId`, { maxLength: 256 });
2183
- const normalizedTurnId = rawTurnId === null
2184
- ? null
2185
- : normalizeTurnRef(rawTurnId, `${turnName}.turnId`);
2186
- return {
2187
- turnRef: dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
2188
- ? normalizeTurnRef(turn.turnRef, `${turnName}.turnRef`)
2189
- : normalizedTurnId,
2190
- turnId: normalizedTurnId,
2191
- requestSummary: optionalString(turn.requestSummary, `${turnName}.requestSummary`, {
2192
- maxLength: MAX_REQUEST_SUMMARY_LENGTH,
2193
- }),
2194
- startedAt: requireTimestamp(turn.startedAt, `${turnName}.startedAt`),
2195
- result: normalizeTurnResult(turn.result, `${turnName}.result`),
2196
- provenance: dispatch.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
2197
- ? normalizeTurnProvenance(turn.provenance, `${turnName}.provenance`)
2198
- : null,
2199
- };
2200
- };
2201
- let legacyResults = [];
2202
- let turns = [];
2203
- if (dispatch.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION) {
2204
- if (!Array.isArray(dispatch.turns)) throw new Error(`${name}.turns must be an array`);
2205
- turns = dispatch.turns.map((turn, index) => normalizeTurn(turn, `${name}.turns[${index}]`));
2206
- } else if (dispatch.schemaVersion === LEGACY_RESULTS_TASK_SCHEMA_VERSION) {
2207
- if (!Array.isArray(dispatch.results)) throw new Error(`${name}.results must be an array`);
2208
- legacyResults = dispatch.results.map((result, index) => (
2209
- normalizeResult(result, `${name}.results[${index}]`)
2210
- ));
2211
- } else if (dispatch.schemaVersion === 5) {
2212
- if (dispatch.lastResult !== null) {
2213
- legacyResults = [normalizeResult(dispatch.lastResult, `${name}.lastResult`)];
2214
- }
2215
- } else if (RESULT_STATUSES.has(status)) {
2216
- legacyResults = [{ status, summary, turnId, updatedAt }];
2217
- }
2218
- if (dispatch.schemaVersion < FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION) {
2219
- turns = legacyResults.map((result) => ({
2220
- turnRef: result.turnId,
2221
- turnId: result.turnId,
2222
- requestSummary: null,
2223
- startedAt: result.updatedAt,
2224
- result: {
2225
- status: result.status,
2226
- summary: result.summary,
2227
- updatedAt: result.updatedAt,
2228
- },
2229
- provenance: null,
2230
- }));
2231
- if (
2232
- status === "working"
2233
- && turnId !== null
2234
- && (
2235
- turns.at(-1)?.turnId !== turnId
2236
- || turns.at(-1)?.result !== null
2237
- )
2238
- ) {
2239
- turns.push({
2240
- turnRef: turnId,
2241
- turnId,
2242
- requestSummary: null,
2243
- startedAt: updatedAt,
2244
- result: null,
2245
- provenance: null,
2246
- });
2247
- }
2248
- }
2249
- const results = turns.flatMap((turn) => (
2250
- turn.result === null || !RESULT_STATUSES.has(turn.result.status)
2251
- ) ? [] : [{
2252
- ...turn.result,
2253
- turnRef: turn.turnRef,
2254
- turnId: turn.turnId,
2255
- ...(turn.provenance?.kind === "dashboard_manual"
2256
- ? { provenance: turn.provenance }
2257
- : {}),
2258
- }]);
2259
- const lastResult = results.at(-1) ?? null;
2260
- const latestTurn = turns.at(-1) ?? null;
2261
- const normalized = {
2262
- schemaVersion: dispatch.schemaVersion,
2263
- id,
2264
- project,
2265
- title: requireString(dispatch.title, `${name}.title`).trim(),
2266
- instruction: requireString(dispatch.instruction, `${name}.instruction`),
2267
- threadId: dispatch.threadId === null
2268
- ? null
2269
- : normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
2270
- createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
2271
- status,
2272
- summary,
2273
- turnRef: dispatch.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
2274
- ? storedTurnRef
2275
- : turns.at(-1)?.turnRef ?? null,
2276
- turnId,
2277
- updatedAt,
2278
- updatedBy: requireEnum(dispatch.updatedBy, TASK_UPDATE_SOURCES, `${name}.updatedBy`),
2279
- turns,
2280
- latestTurn,
2281
- results,
2282
- lastResult,
2283
- };
2284
- if (normalized.updatedBy === "dashboard" && normalized.schemaVersion < CURRENT_TASK_SCHEMA_VERSION) {
2285
- throw new Error(`${name}.updatedBy dashboard requires schema ${CURRENT_TASK_SCHEMA_VERSION}`);
2286
- }
2287
- if (normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION) {
2288
- if (normalized.turnId !== null && normalized.turnRef !== normalizeTurnRef(normalized.turnId)) {
2289
- throw new Error(`${name}.turnRef must equal turnId when turnId is present`);
2290
- }
2291
- if (normalized.latestTurn === null) {
2292
- if (normalized.turnRef !== null) {
2293
- throw new Error(`${name}.turnRef must be null before the first lifecycle turn`);
2294
- }
2295
- } else if (normalized.turnRef !== normalized.latestTurn.turnRef) {
2296
- throw new Error(`${name}.turnRef must match latestTurn.turnRef`);
2297
- }
2298
- for (const [index, turn] of normalized.turns.entries()) {
2299
- if (turn.turnId === null) {
2300
- turn.turnRef = normalizeExecutorTurnRef(
2301
- turn.turnRef,
2302
- `${name}.turns[${index}].turnRef`,
2303
- );
2304
- } else if (turn.turnRef !== normalizeTurnRef(turn.turnId)) {
2305
- throw new Error(
2306
- `${name}.turns[${index}].turnRef must equal turnId when turnId is present`,
2307
- );
2308
- }
2309
- }
2310
- }
2311
- if (normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION) {
2312
- const actionIds = new Set();
2313
- for (const [index, turn] of normalized.turns.entries()) {
2314
- const provenance = turn.provenance;
2315
- if (provenance.kind !== "dashboard_manual") continue;
2316
- const turnName = `${name}.turns[${index}]`;
2317
- const predecessor = normalized.turns[index - 1] ?? null;
2318
- if (actionIds.has(provenance.actionId)) {
2319
- throw new Error(`${name} has duplicate manual transition actionId: ${provenance.actionId}`);
2320
- }
2321
- actionIds.add(provenance.actionId);
2322
- if (turn.turnId !== null) {
2323
- throw new Error(`${turnName}.turnId must be null for a manual dashboard turn`);
2324
- }
2325
- if (provenance.expectedThreadId !== normalized.threadId) {
2326
- throw new Error(`${turnName}.provenance.expectedThreadId must match the task threadId`);
2327
- }
2328
- if (provenance.expectedTurnRef !== (predecessor?.turnRef ?? null)) {
2329
- throw new Error(`${turnName}.provenance.expectedTurnRef must match the prior turn`);
2330
- }
2331
- if (!canManuallyTransition(provenance.fromStatus, provenance.toStatus)) {
2332
- throw new Error(`${turnName}.provenance describes an invalid manual transition`);
2333
- }
2334
- const validPriorState = provenance.fromStatus === "needs_input"
2335
- ? predecessor?.result?.status === "needs_input"
2336
- : provenance.fromStatus === "working"
2337
- ? predecessor === null || predecessor.result?.status === "interrupted"
2338
- : predecessor?.result?.status === provenance.fromStatus;
2339
- if (!validPriorState) {
2340
- throw new Error(`${turnName}.provenance.fromStatus does not match the prior turn`);
2341
- }
2342
- if (
2343
- provenance.fromStatus === "working"
2344
- && predecessor !== null
2345
- && predecessor.result.updatedAt !== turn.startedAt
2346
- ) {
2347
- throw new Error(`${turnName} must share its timestamp with the interrupted prior turn`);
2348
- }
2349
- const expectedPriorTimestamp = provenance.fromStatus === "working"
2350
- ? predecessor?.startedAt ?? null
2351
- : predecessor.result.updatedAt;
2352
- if (
2353
- Date.parse(provenance.expectedUpdatedAt) < Date.parse(normalized.createdAt)
2354
- || (
2355
- expectedPriorTimestamp !== null
2356
- && provenance.expectedUpdatedAt !== expectedPriorTimestamp
2357
- )
2358
- ) {
2359
- throw new Error(`${turnName}.provenance.expectedUpdatedAt does not match prior state`);
2360
- }
2361
- if (turn.requestSummary !== manualTransitionRequestSummary(
2362
- provenance.fromStatus,
2363
- provenance.toStatus,
2364
- )) {
2365
- throw new Error(`${turnName}.requestSummary must use the manual transition summary`);
2366
- }
2367
- if (
2368
- turn.result?.status !== provenance.toStatus
2369
- || turn.result?.summary !== manualTransitionSummary(provenance.toStatus)
2370
- ) {
2371
- throw new Error(`${turnName}.result must match its manual transition provenance`);
2372
- }
2373
- if (
2374
- turn.startedAt !== turn.result.updatedAt
2375
- || Date.parse(turn.startedAt) < Date.parse(provenance.expectedUpdatedAt)
2376
- ) {
2377
- throw new Error(`${turnName} has invalid manual transition timestamps`);
2378
- }
2379
- }
2380
- if (normalized.updatedBy === "dashboard") {
2381
- if (normalized.latestTurn?.provenance?.kind !== "dashboard_manual") {
2382
- throw new Error(`${name}.updatedBy dashboard requires a latest manual dashboard turn`);
2383
- }
2384
- }
2385
- if (
2386
- normalized.latestTurn?.provenance?.kind === "dashboard_manual"
2387
- && normalized.updatedBy !== "dashboard"
2388
- ) {
2389
- throw new Error(`${name} latest manual dashboard turn requires updatedBy dashboard`);
2390
- }
2391
- }
2392
- const isSelfLinkingRecord = normalized.threadId !== null
2393
- && parseTaskChefMarker(normalized.instruction) === normalized.id;
2394
- if (isSelfLinkingRecord) {
2395
- if (normalized.turnId !== null) {
2396
- normalized.turnId = normalizeCodexThreadId(normalized.turnId, `${name}.turnId`);
2397
- }
2398
- for (const [index, turn] of normalized.turns.entries()) {
2399
- if (turn.turnId != null) {
2400
- turn.turnId = normalizeCodexThreadId(
2401
- turn.turnId,
2402
- `${name}.turns[${index}].turnId`,
2403
- );
2404
- }
2405
- if (turn.turnRef !== null) {
2406
- turn.turnRef = normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
2407
- ? normalizeExecutorTurnRef(turn.turnRef, `${name}.turns[${index}].turnRef`)
2408
- : normalizeTurnRef(turn.turnRef, `${name}.turns[${index}].turnRef`);
2409
- }
2410
- }
2411
- normalized.results = normalized.turns.flatMap((turn) => (
2412
- turn.result === null || !RESULT_STATUSES.has(turn.result.status)
2413
- ) ? [] : [{
2414
- ...turn.result,
2415
- turnRef: turn.turnRef,
2416
- turnId: turn.turnId,
2417
- ...(turn.provenance?.kind === "dashboard_manual"
2418
- ? { provenance: turn.provenance }
2419
- : {}),
2420
- }]);
2421
- normalized.lastResult = normalized.results.at(-1) ?? null;
2422
- normalized.latestTurn = normalized.turns.at(-1) ?? null;
2423
- }
2424
- if (normalized.schemaVersion >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION) {
2425
- if (normalized.threadId === null) {
2426
- const isLinkPending = normalized.status === "working"
2427
- && normalized.summary === null
2428
- && normalized.turnRef === null
2429
- && normalized.turnId === null
2430
- && normalized.lastResult === null
2431
- && normalized.updatedBy === "dispatcher";
2432
- const isCreationFailure = normalized.status === "failed"
2433
- && normalized.summary !== null
2434
- && (
2435
- normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
2436
- || normalized.turnRef !== null
2437
- )
2438
- && normalized.turnId === null
2439
- && normalized.lastResult?.status === "failed"
2440
- && normalized.lastResult.turnId === null
2441
- && normalized.updatedBy === "mcp";
2442
- const isManualTerminal = normalized.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
2443
- && RESULT_STATUSES.has(normalized.status)
2444
- && normalized.summary !== null
2445
- && normalized.turnId === null
2446
- && normalized.lastResult?.status === normalized.status
2447
- && normalized.lastResult.turnId === null
2448
- && normalized.updatedBy === "dashboard"
2449
- && normalized.latestTurn?.provenance?.kind === "dashboard_manual";
2450
- if (!isLinkPending && !isCreationFailure && !isManualTerminal) {
2451
- throw new Error(`${name} has an invalid unlinked lifecycle state`);
2452
- }
2453
- if ((isCreationFailure || isManualTerminal) && normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION) {
2454
- normalized.turnRef = normalizeExecutorTurnRef(normalized.turnRef, `${name}.turnRef`);
2455
- for (const [index, turn] of normalized.turns.entries()) {
2456
- turn.turnRef = normalizeExecutorTurnRef(
2457
- turn.turnRef,
2458
- `${name}.turns[${index}].turnRef`,
2459
- );
2460
- }
2461
- }
2462
- } else {
2463
- if (RESULT_STATUSES.has(normalized.status) && normalized.turnId === null) {
2464
- if (normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION) {
2465
- throw new Error(`${name}.turnId is required for a linked semantic state`);
2466
- }
2467
- }
2468
- const resultWithoutTurn = normalized.results.findIndex((result) => (
2469
- normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION && result.turnId === null
2470
- ));
2471
- if (resultWithoutTurn !== -1) {
2472
- throw new Error(`${name}.results[${resultWithoutTurn}].turnId is required for a linked result`);
2473
- }
2474
- }
2475
- }
2476
- if (normalized.status === "working" && normalized.summary !== null) {
2477
- throw new Error(`${name}.summary must be null while status is working`);
2478
- }
2479
- if (RESULT_STATUSES.has(normalized.status) && normalized.summary === null) {
2480
- throw new Error(`${name}.summary is required for status ${normalized.status}`);
2481
- }
2482
- if (normalized.schemaVersion >= 5) {
2483
- if (RESULT_STATUSES.has(normalized.status)) {
2484
- if (
2485
- normalized.lastResult === null
2486
- || normalized.lastResult.status !== normalized.status
2487
- || normalized.lastResult.summary !== normalized.summary
2488
- || normalized.lastResult.turnRef !== normalized.turnRef
2489
- || normalized.lastResult.turnId !== normalized.turnId
2490
- || normalized.lastResult.updatedAt !== normalized.updatedAt
2491
- ) {
2492
- throw new Error(`${name}.lastResult must match the current semantic state`);
2493
- }
2494
- }
2495
- if (
2496
- normalized.lastResult !== null
2497
- && Date.parse(normalized.lastResult.updatedAt) < Date.parse(normalized.createdAt)
2498
- ) {
2499
- throw new Error(`${name}.lastResult.updatedAt must not be earlier than createdAt`);
2500
- }
2501
- if (
2502
- normalized.lastResult !== null
2503
- && Date.parse(normalized.lastResult.updatedAt) > Date.parse(normalized.updatedAt)
2504
- ) {
2505
- throw new Error(`${name}.lastResult.updatedAt must not be later than updatedAt`);
2506
- }
2507
- if (
2508
- normalized.status === "working"
2509
- && isSelfLinkingRecord
2510
- && normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
2511
- && normalized.lastResult?.turnId != null
2512
- && (normalized.turnId === null || normalized.turnId <= normalized.lastResult.turnId)
2513
- ) {
2514
- throw new Error(`${name}.turnId must be newer than lastResult.turnId while working`);
2515
- }
2516
- }
2517
- if (
2518
- normalized.schemaVersion >= FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION
2519
- && normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
2520
- && normalized.turnId !== normalized.turnRef
2521
- ) {
2522
- throw new Error(`${name}.turnId must match the latest legacy turn identity`);
2523
- }
2524
- if (normalized.status === "working") {
2525
- if (normalized.turnRef === null) {
2526
- if (normalized.turns.length !== 0) {
2527
- throw new Error(`${name}.turns must be empty before the first working turn`);
2528
- }
2529
- } else if (
2530
- normalized.latestTurn === null
2531
- || normalized.latestTurn.turnRef !== normalized.turnRef
2532
- || normalized.latestTurn.turnId !== normalized.turnId
2533
- || normalized.latestTurn.result !== null
2534
- ) {
2535
- throw new Error(`${name}.latestTurn must match the current working turn`);
2536
- }
2537
- }
2538
- if (RESULT_STATUSES.has(normalized.status)) {
2539
- if (
2540
- normalized.latestTurn === null
2541
- || normalized.latestTurn.turnRef !== normalized.turnRef
2542
- || normalized.latestTurn.turnId !== normalized.turnId
2543
- || normalized.latestTurn.result === null
2544
- || normalized.latestTurn.result.status !== normalized.status
2545
- || normalized.latestTurn.result.summary !== normalized.summary
2546
- || normalized.latestTurn.result.updatedAt !== normalized.updatedAt
2547
- ) {
2548
- throw new Error(`${name}.latestTurn must match the current semantic state`);
2549
- }
2550
- }
2551
- const seenTurnRefs = new Set();
2552
- let lastNativeTurnRef = null;
2553
- for (const [index, turn] of normalized.turns.entries()) {
2554
- const turnKey = turn.turnRef;
2555
- const isLegacyOpaqueTurnReuse = (
2556
- !isSelfLinkingRecord
2557
- && index === normalized.turns.length - 1
2558
- && index > 0
2559
- && normalized.turns[index - 1].turnId === turn.turnId
2560
- && normalized.turns[index - 1].turnRef === turn.turnRef
2561
- && normalized.turns[index - 1].result !== null
2562
- );
2563
- if (seenTurnRefs.has(turnKey) && !isLegacyOpaqueTurnReuse) {
2564
- throw new Error(`${name}.turns contains duplicate turnRef: ${turn.turnRef ?? "null"}`);
2565
- }
2566
- seenTurnRefs.add(turnKey);
2567
- if (Date.parse(turn.startedAt) < Date.parse(normalized.createdAt)) {
2568
- throw new Error(`${name}.turns[${index}].startedAt must not be earlier than createdAt`);
2569
- }
2570
- if (Date.parse(turn.startedAt) > Date.parse(normalized.updatedAt)) {
2571
- throw new Error(`${name}.turns[${index}].startedAt must not be later than updatedAt`);
2572
- }
2573
- if (
2574
- index > 0
2575
- && Date.parse(turn.startedAt) < Date.parse(normalized.turns[index - 1].startedAt)
2576
- ) {
2577
- throw new Error(`${name}.turns must be ordered by startedAt`);
2578
- }
2579
- if (
2580
- normalized.schemaVersion < TURN_REF_TASK_SCHEMA_VERSION
2581
- && isSelfLinkingRecord
2582
- && index > 0
2583
- && turn.turnId <= normalized.turns[index - 1].turnId
2584
- ) {
2585
- throw new Error(`${name}.turns must be ordered by turnId`);
2586
- }
2587
- if (
2588
- normalized.schemaVersion >= TURN_REF_TASK_SCHEMA_VERSION
2589
- && isSelfLinkingRecord
2590
- && turn.turnId !== null
2591
- ) {
2592
- if (lastNativeTurnRef !== null && turn.turnRef <= lastNativeTurnRef) {
2593
- throw new Error(`${name}.native-backed turnRefs must be strictly increasing`);
2594
- }
2595
- lastNativeTurnRef = turn.turnRef;
2596
- }
2597
- if (turn.result !== null) {
2598
- if (Date.parse(turn.result.updatedAt) < Date.parse(turn.startedAt)) {
2599
- throw new Error(`${name}.turns[${index}].result.updatedAt must not be earlier than startedAt`);
2600
- }
2601
- if (Date.parse(turn.result.updatedAt) > Date.parse(normalized.updatedAt)) {
2602
- throw new Error(`${name}.turns[${index}].result.updatedAt must not be later than updatedAt`);
2603
- }
2604
- } else if (index !== normalized.turns.length - 1) {
2605
- throw new Error(`${name}.turns may contain an unfinished turn only at the end`);
2606
- }
2607
- }
2608
- if (
2609
- Date.parse(normalized.updatedAt) < Date.parse(normalized.createdAt)
2610
- ) {
2611
- throw new Error(`${name}.updatedAt must not be earlier than createdAt`);
2612
- }
2613
- if (
2614
- normalized.threadId === null &&
2615
- parseTaskChefMarker(normalized.instruction) !== normalized.id
2616
- ) {
2617
- throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
2618
- }
2619
- return normalized;
2620
- }
2621
-
2622
- async function parseDispatchRecordsUnlocked(root, content, { validateWorkspace = true } = {}) {
2623
- if (validateWorkspace) await readConfig(root, { checkPaths: false });
2624
- if (content.length > 0 && !content.endsWith("\n")) {
2625
- throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
2626
- }
2627
- const lines = content.length === 0 ? [] : content.slice(0, -1).split("\n");
2628
- const records = [];
2629
- for (const [index, line] of lines.entries()) {
2630
- if (line.trim().length === 0) {
2631
- throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is empty`);
2632
- }
2633
- let value;
2634
- try {
2635
- value = JSON.parse(line);
2636
- } catch (error) {
2637
- throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is invalid JSON: ${error.message}`);
2638
- }
2639
- records.push({
2640
- line,
2641
- raw: value,
2642
- normalized: await validateDispatchShape(value, `task line ${index + 1}`),
2643
- });
2644
- }
2645
- const ids = new Set();
2646
- const threadIds = new Set();
2647
- const manualActionIds = new Set();
2648
- for (const { normalized: dispatch } of records) {
2649
- if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
2650
- const threadKey = dispatch.threadId === null ? null : threadIdentityKey(dispatch.threadId);
2651
- if (threadKey !== null && threadIds.has(threadKey)) {
2652
- throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
2653
- }
2654
- ids.add(dispatch.id);
2655
- if (threadKey !== null) threadIds.add(threadKey);
2656
- for (const turn of dispatch.turns) {
2657
- if (turn.provenance?.kind !== "dashboard_manual") continue;
2658
- if (manualActionIds.has(turn.provenance.actionId)) {
2659
- throw new Error(`duplicate manual transition actionId: ${turn.provenance.actionId}`);
2660
- }
2661
- manualActionIds.add(turn.provenance.actionId);
2662
- }
2663
- }
2664
- return records;
2665
- }
2666
-
2667
- function threadIdentityKey(threadId) {
2668
- try {
2669
- return `codex:${normalizeCodexThreadId(threadId)}`;
2670
- } catch {
2671
- return `opaque:${threadId}`;
2672
- }
2673
- }
2674
-
2675
- async function readDispatchRecordsUnlocked(root) {
2676
- const filePath = path.join(root, DISPATCH_FILE_NAME);
2677
- if (!(await managedRegularFileExists(filePath))) {
2678
- throw new Error(`task log does not exist: ${filePath}`);
2679
- }
2680
- return parseDispatchRecordsUnlocked(root, await readFile(filePath, "utf8"));
2681
- }
2682
-
2683
- async function readDispatchesUnlocked(root) {
2684
- return (await readDispatchRecordsUnlocked(root)).map((record) => record.normalized);
2685
- }
2686
-
2687
- export async function listTasks(workspaceRoot) {
2688
- const root = await realpath(path.resolve(workspaceRoot));
2689
- return readDispatchesUnlocked(root);
2690
- }
2691
-
2692
- export async function parseTaskLogContent(workspaceRoot, content, {
2693
- validateWorkspace = true,
2694
- } = {}) {
2695
- const root = await realpath(path.resolve(workspaceRoot));
2696
- if (typeof content !== "string") throw new Error("task log content must be a string");
2697
- return (await parseDispatchRecordsUnlocked(root, content, { validateWorkspace }))
2698
- .map((record) => record.normalized);
2699
- }
2700
-
2701
- function currentSchemaTask(dispatch, patch = {}) {
2702
- const {
2703
- latestTurn: _latestTurn,
2704
- results: _results,
2705
- lastResult: _lastResult,
2706
- ...persisted
2707
- } = dispatch;
2708
- const sourceTurns = patch.turns ?? dispatch.turns ?? [];
2709
- const turns = sourceTurns.map((turn) => ({
2710
- ...turn,
2711
- provenance: turn.provenance ?? { kind: "legacy" },
2712
- turnRef: turn.turnRef === null || turn.turnRef === undefined
2713
- ? (turn.turnId === null || turn.turnId === undefined ? randomUUID() : turn.turnId)
2714
- : turn.turnRef,
2715
- }));
2716
- const latestTurn = turns.at(-1) ?? null;
2717
- return {
2718
- ...persisted,
2719
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
2720
- ...patch,
2721
- turnRef: "turnRef" in patch ? patch.turnRef : latestTurn?.turnRef ?? null,
2722
- turns,
2723
- };
2724
- }
2725
-
2726
- export async function migrateTaskLog(workspaceRoot, {
2727
- now = () => new Date().toISOString(),
2728
- writeTaskLog = writeTextAtomic,
2729
- } = {}) {
2730
- const root = await realpath(path.resolve(workspaceRoot));
2731
- return withWorkspaceLock(root, async () => {
2732
- const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
2733
- if (!(await managedRegularFileExists(dispatchPath))) {
2734
- throw new Error(`task log does not exist: ${dispatchPath}`);
2735
- }
2736
- const original = await readFile(dispatchPath, "utf8");
2737
- const records = await parseDispatchRecordsUnlocked(root, original);
2738
- const migratedCount = records.filter(
2739
- (record) => record.raw.schemaVersion !== CURRENT_TASK_SCHEMA_VERSION,
2740
- ).length;
2741
- if (migratedCount === 0) {
2742
- const turnCount = records.reduce((count, record) => count + record.normalized.turns.length, 0);
2743
- return {
2744
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
2745
- action: "unchanged",
2746
- taskCount: records.length,
2747
- turnCount,
2748
- migratedCount: 0,
2749
- nativeTurnRefCount: records.reduce((count, record) => count + record.normalized.turns.filter(
2750
- (turn) => turn.turnId !== null && turn.turnRef === turn.turnId,
2751
- ).length, 0),
2752
- fallbackTurnRefCount: records.reduce((count, record) => count + record.normalized.turns.filter(
2753
- (turn) => turn.turnId === null,
2754
- ).length, 0),
2755
- backupPath: null,
2756
- };
2757
- }
2758
- const stateBackup = await createStateBackupUnlocked(root, {
2759
- reason: `pre-task-log-migration-v${CURRENT_TASK_SCHEMA_VERSION}`,
2760
- now,
2761
- });
2762
- const beforeTurnCount = records.reduce((count, record) => count + record.normalized.turns.length, 0);
2763
- const migratedTasks = records.map((record) => currentSchemaTask(record.normalized));
2764
- const lines = migratedTasks.map((task) => JSON.stringify(task));
2765
- const migrated = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
2766
- const validated = await parseDispatchRecordsUnlocked(root, migrated);
2767
- const afterTurnCount = validated.reduce((count, record) => count + record.normalized.turns.length, 0);
2768
- if (validated.length !== records.length || afterTurnCount !== beforeTurnCount) {
2769
- throw new Error("task log migration changed task or turn counts before replacement");
2770
- }
2771
- const nativeTurnRefCount = migratedTasks.reduce((count, task) => count + task.turns.filter(
2772
- (turn) => turn.turnId !== null && turn.turnRef === turn.turnId,
2773
- ).length, 0);
2774
- const fallbackTurnRefCount = migratedTasks.reduce((count, task) => count + task.turns.filter(
2775
- (turn) => turn.turnId === null,
2776
- ).length, 0);
2777
- const timestamp = requireTimestamp(now(), "migration timestamp")
2778
- .replaceAll(":", "-")
2779
- .replaceAll(".", "-");
2780
- const backupPath = `${dispatchPath}.pre-v10-${timestamp}-${randomUUID()}.bak`;
2781
- await writeFile(backupPath, original, { encoding: "utf8", mode: 0o600, flag: "wx" });
2782
- if (await readFile(backupPath, "utf8") !== original) {
2783
- throw new Error(`task log backup validation failed: ${backupPath}`);
2784
- }
2785
- try {
2786
- await writeTaskLog(dispatchPath, migrated);
2787
- await parseDispatchRecordsUnlocked(root, await readFile(dispatchPath, "utf8"));
2788
- } catch (error) {
2789
- throw new Error(
2790
- `task log migration failed after recovery backup ${backupPath}: ${error.message}`,
2791
- { cause: error },
2792
- );
2793
- }
2794
- return {
2795
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
2796
- action: "migrated",
2797
- taskCount: records.length,
2798
- turnCount: afterTurnCount,
2799
- migratedCount,
2800
- nativeTurnRefCount,
2801
- fallbackTurnRefCount,
2802
- backupPath,
2803
- stateBackupId: stateBackup.id,
2804
- };
2805
- });
2806
- }
2807
-
2808
- export async function recordTask(workspaceRoot, input, { now } = {}) {
2809
- requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
2810
- const root = await realpath(path.resolve(workspaceRoot));
2811
- return withWorkspaceLock(root, async () => {
2812
- const config = await readConfig(root, { checkPaths: false });
2813
- const requested = requireString(input.project, "task input.project").trim();
2814
- const requestedPath = path.resolve(requested);
2815
- let configured = config.projects.find((candidate) => candidate.path === requestedPath);
2816
- if (!configured) {
2817
- const canonicalRequested = await canonicalDirectory(requested);
2818
- for (const candidate of config.projects) {
2819
- const canonicalCandidate = await realpath(candidate.path).catch(() => null);
2820
- if (canonicalCandidate === canonicalRequested) {
2821
- configured = candidate;
2822
- break;
2823
- }
2824
- }
2825
- }
2826
- if (!configured) throw new Error(`project is not configured in taskchef.json: ${requestedPath}`);
2827
- const projectPath = configured.isGitRepository
2828
- ? await canonicalGitRoot(requested)
2829
- : await canonicalDirectory(requested);
2830
- const project = { ...configured, path: projectPath };
2831
- const createdAt = now ?? new Date().toISOString();
2832
- const dispatch = await validateDispatchShape({
2833
- schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
2834
- id: input.id,
2835
- project,
2836
- title: input.title,
2837
- instruction: input.instruction,
2838
- threadId: input.threadId,
2839
- createdAt,
2840
- status: "working",
2841
- summary: null,
2842
- turnRef: null,
2843
- turnId: null,
2844
- updatedAt: createdAt,
2845
- updatedBy: "dispatcher",
2846
- turns: [],
2847
- });
2848
- const existing = await readDispatchesUnlocked(root);
2849
- if (existing.some((item) => item.id === dispatch.id)) {
2850
- throw new Error(`task already exists: ${dispatch.id}`);
2851
- }
2852
- if (
2853
- dispatch.threadId !== null &&
2854
- existing.some((item) => (
2855
- item.threadId !== null
2856
- && threadIdentityKey(item.threadId) === threadIdentityKey(dispatch.threadId)
2857
- ))
2858
- ) {
2859
- throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
2860
- }
2861
- if (
2862
- dispatch.threadId !== null
2863
- && parseTaskChefMarker(dispatch.instruction) === dispatch.id
2864
- ) {
2865
- throw new Error("a marked self-linking task must be recorded with threadId: null");
2866
- }
2867
- await appendDispatchesAtomic(root, [dispatch]);
2868
- return dispatch;
2869
- });
2870
- }
2871
-
2872
- export async function linkTask(workspaceRoot, taskId, threadId, { now } = {}) {
2873
- const id = requireSafeId(taskId, "taskId");
2874
- const durableThreadId = normalizeCodexThreadId(threadId);
2875
- const root = await realpath(path.resolve(workspaceRoot));
2876
- return withWorkspaceLock(root, async () => {
2877
- const records = await readDispatchRecordsUnlocked(root);
2878
- const dispatches = records.map((record) => record.normalized);
2879
- const index = dispatches.findIndex((dispatch) => dispatch.id === id);
2880
- if (index === -1) throw new Error(`task not found: ${id}`);
2881
- const dispatch = dispatches[index];
2882
- const sameIdentity = dispatch.threadId?.toLowerCase() === durableThreadId;
2883
- if (sameIdentity && dispatch.updatedBy === "mcp") {
2884
- if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
2885
- throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
2886
- }
2887
- if (dispatch.threadId === durableThreadId) return dispatch;
2888
- const canonical = await validateDispatchShape(currentSchemaTask(dispatch, {
2889
- threadId: durableThreadId,
2890
- updatedAt: transitionTimestamp(now, dispatch.updatedAt),
2891
- updatedBy: "mcp",
2892
- }));
2893
- const lines = records.map((record, recordIndex) => recordIndex === index
2894
- ? dispatchLineWithState(canonical, {})
2895
- : record.line);
2896
- await writeDispatchLinesAtomic(root, lines);
2897
- return canonical;
2898
- }
2899
- if (dispatch.threadId !== null && dispatch.updatedBy === "mcp") {
2900
- throw new Error(`task already has a different threadId: ${id}`);
2901
- }
2902
- if (
2903
- dispatch.threadId !== null
2904
- || dispatch.status !== "working"
2905
- || dispatch.updatedBy !== "dispatcher"
2906
- ) {
2907
- throw new Error(`task is not an eligible link-pending dispatcher record: ${id}`);
2908
- }
2909
- if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
2910
- throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
2911
- }
2912
- if (dispatches.some((item) => (
2913
- item.id !== id && item.threadId?.toLowerCase() === durableThreadId
2914
- ))) {
2915
- throw new Error(`threadId is already recorded: ${durableThreadId}`);
2916
- }
2917
- const linked = await validateDispatchShape(currentSchemaTask(dispatch, {
2918
- threadId: durableThreadId,
2919
- updatedAt: transitionTimestamp(now, dispatch.updatedAt),
2920
- updatedBy: "mcp",
2921
- }));
2922
- const lines = records.map((record, recordIndex) => recordIndex === index
2923
- ? dispatchLineWithState(linked, {})
2924
- : record.line);
2925
- await writeDispatchLinesAtomic(root, lines);
2926
- return linked;
2927
- });
2928
- }
2929
-
2930
- function dispatchLineWithState(dispatch, patch) {
2931
- return JSON.stringify(currentSchemaTask(dispatch, patch));
2932
- }
2933
-
2934
- function normalizeTaskStateInput(input, { allowWorking }) {
2935
- const fields = new Set([
2936
- "taskId", "threadId", "turnRef", "turnId", "status", "summary", "requestSummary",
2937
- ]);
2938
- if (!input || typeof input !== "object" || Array.isArray(input)) {
2939
- throw new Error("task state must be an object");
2940
- }
2941
- const unexpected = Object.keys(input).find((key) => !fields.has(key));
2942
- if (unexpected) throw new Error(`task state has unsupported field: ${unexpected}`);
2943
- for (const field of ["taskId", "threadId", "turnId", "status"]) {
2944
- if (!(field in input)) throw new Error(`task state is missing field: ${field}`);
2945
- }
2946
- const id = requireSafeId(input.taskId, "taskId");
2947
- const threadId = input.threadId === null
2948
- ? null
2949
- : normalizeDurableThreadId(input.threadId, "threadId");
2950
- const rawTurnId = optionalString(input.turnId, "turnId", { maxLength: 256 });
2951
- const turnId = rawTurnId === null ? null : normalizeTurnRef(rawTurnId, "turnId");
2952
- const turnRefInput = "turnRef" in input ? input.turnRef : turnId;
2953
- const turnRef = turnRefInput === null ? null : normalizeTurnRef(turnRefInput);
2954
- if (turnId !== null && turnRef !== normalizeTurnRef(turnId, "turnId as turnRef")) {
2955
- throw new Error("turnRef must equal turnId when turnId is present");
2956
- }
2957
- const status = requireEnum(
2958
- input.status,
2959
- allowWorking ? TASK_STATUSES : RESULT_STATUSES,
2960
- "status",
2961
- );
2962
- const summary = optionalString("summary" in input ? input.summary : null, "summary", {
2963
- maxLength: MAX_RESULT_SUMMARY_LENGTH,
2964
- });
2965
- const requestSummary = optionalString(
2966
- "requestSummary" in input ? input.requestSummary : null,
2967
- "requestSummary",
2968
- { maxLength: MAX_REQUEST_SUMMARY_LENGTH },
2969
- );
2970
- if (status === "working" && summary !== null) {
2971
- throw new Error("summary must be null while status is working");
2972
- }
2973
- if (status !== "working" && summary === null) {
2974
- throw new Error(`summary is required for status ${status}`);
2975
- }
2976
- if (status !== "working" && requestSummary !== null) {
2977
- throw new Error(`requestSummary is accepted only for status working`);
2978
- }
2979
- return { id, threadId, turnRef, turnId, status, summary, requestSummary };
2980
- }
2981
-
2982
- function manualTransitionSummary(status) {
2983
- return `Manually marked ${status} from the TaskChef dashboard.`;
2984
- }
2985
-
2986
- function manualTransitionRequestSummary(fromStatus, toStatus) {
2987
- return `Manual dashboard transition from ${fromStatus} to ${toStatus}.`;
2988
- }
2989
-
2990
- function canManuallyTransition(fromStatus, toStatus) {
2991
- if (!MANUAL_TRANSITION_STATUSES.has(fromStatus) || !MANUAL_TARGET_STATUSES.has(toStatus)) {
2992
- return false;
2993
- }
2994
- return ["working", "needs_input"].includes(fromStatus) || fromStatus !== toStatus;
2995
- }
2996
-
2997
- function taskOperationError(code, message, task = null) {
2998
- const error = new Error(message);
2999
- error.code = code;
3000
- error.task = task;
3001
- return error;
3002
- }
3003
-
3004
- function normalizeManualTransitionInput(input) {
3005
- requireExactFields(input, MANUAL_TRANSITION_FIELDS, "manual transition");
3006
- requireExactFields(input.expected, MANUAL_EXPECTED_FIELDS, "manual transition.expected");
3007
- const expectedTurnRef = input.expected.turnRef === null
3008
- ? null
3009
- : normalizeTurnRef(input.expected.turnRef, "manual transition.expected.turnRef");
3010
- const expectedThreadId = input.expected.threadId === null
3011
- ? null
3012
- : normalizeDurableThreadId(
3013
- input.expected.threadId,
3014
- "manual transition.expected.threadId",
3015
- );
3016
- return {
3017
- actionId: normalizeExecutorTurnRef(input.actionId, "manual transition.actionId"),
3018
- expected: {
3019
- status: requireEnum(
3020
- input.expected.status,
3021
- TASK_STATUSES,
3022
- "manual transition.expected.status",
3023
- ),
3024
- turnRef: expectedTurnRef,
3025
- threadId: expectedThreadId,
3026
- updatedAt: requireTimestamp(
3027
- input.expected.updatedAt,
3028
- "manual transition.expected.updatedAt",
3029
- ),
3030
- },
3031
- targetStatus: requireEnum(
3032
- input.targetStatus,
3033
- MANUAL_TARGET_STATUSES,
3034
- "manual transition.targetStatus",
3035
- ),
3036
- };
3037
- }
3038
-
3039
- function sameManualAction(provenance, input) {
3040
- return provenance.fromStatus === input.expected.status
3041
- && provenance.toStatus === input.targetStatus
3042
- && provenance.expectedTurnRef === input.expected.turnRef
3043
- && provenance.expectedThreadId === input.expected.threadId
3044
- && provenance.expectedUpdatedAt === input.expected.updatedAt;
3045
- }
3046
-
3047
- function sameLastResult(lastResult, { status, summary, turnRef, turnId }) {
3048
- return lastResult !== null
3049
- && lastResult.status === status
3050
- && lastResult.summary === summary
3051
- && lastResult.turnRef === turnRef
3052
- && lastResult.turnId === turnId;
3053
- }
3054
-
3055
- async function reportTaskStateInternal(
3056
- workspaceRoot,
3057
- input,
3058
- { now, compatibilityAlias = false } = {},
3059
- ) {
3060
- const turnRefWasProvided = input !== null
3061
- && typeof input === "object"
3062
- && Object.prototype.hasOwnProperty.call(input, "turnRef");
3063
- const { id, threadId, turnRef, turnId, status, summary, requestSummary } = normalizeTaskStateInput(input, {
3064
- allowWorking: !compatibilityAlias,
3065
- });
3066
- const root = await realpath(path.resolve(workspaceRoot));
3067
- return withWorkspaceLock(root, async () => {
3068
- const records = await readDispatchRecordsUnlocked(root);
3069
- const dispatches = records.map((record) => record.normalized);
3070
- const index = dispatches.findIndex((dispatch) => dispatch.id === id);
3071
- if (index === -1) throw new Error(`task not found: ${id}`);
3072
- const dispatch = dispatches[index];
3073
- const isSelfLinkingJourney = (
3074
- dispatch.threadId !== null
3075
- && parseTaskChefMarker(dispatch.instruction) === dispatch.id
3076
- );
3077
- if (
3078
- !compatibilityAlias
3079
- && dispatch.threadId !== null
3080
- && !isSelfLinkingJourney
3081
- ) {
3082
- throw new Error(`report_state accepts only self-linked task records: ${id}`);
3083
- }
3084
- let stateTurnRef = turnRef;
3085
- let stateTurnId = turnId;
3086
- if (isSelfLinkingJourney) {
3087
- if (dispatch.updatedBy === "dispatcher") {
3088
- throw new Error(`self-linking task must link before reporting a result: ${id}`);
3089
- }
3090
- stateTurnId = turnId === null ? null : normalizeCodexThreadId(turnId, "turnId");
3091
- stateTurnRef = normalizeExecutorTurnRef(turnRef, "turnRef");
3092
- }
3093
- if (dispatch.threadId === null) {
3094
- const rawSchemaVersion = records[index].raw.schemaVersion;
3095
- const hasCurrentMarker = rawSchemaVersion
3096
- >= FIRST_SELF_LINKING_TASK_SCHEMA_VERSION
3097
- && parseTaskChefMarker(dispatch.instruction) === dispatch.id;
3098
- const isIdenticalOmittedRefCreationFailureRetry = !turnRefWasProvided
3099
- && hasCurrentMarker
3100
- && threadId === null
3101
- && dispatch.status === "failed"
3102
- && dispatch.turnId === null
3103
- && dispatch.updatedBy === "mcp"
3104
- && dispatch.lastResult?.status === status
3105
- && dispatch.lastResult?.summary === summary
3106
- && dispatch.lastResult?.turnId === null;
3107
- if (isIdenticalOmittedRefCreationFailureRetry) return dispatch;
3108
- if (stateTurnRef !== null) {
3109
- stateTurnRef = normalizeExecutorTurnRef(stateTurnRef, "turnRef");
3110
- }
3111
- if (threadId !== null || stateTurnId !== null || stateTurnRef === null || status !== "failed") {
3112
- throw new Error(`task without a durable threadId accepts only failed with a turnRef and null thread/turn IDs: ${id}`);
3113
- }
3114
- const isFreshCreationFailure = hasCurrentMarker
3115
- && dispatch.status === "working"
3116
- && dispatch.turnRef === null
3117
- && dispatch.lastResult === null
3118
- && dispatch.updatedBy === "dispatcher";
3119
- const isIdenticalCreationFailureRetry = hasCurrentMarker
3120
- && dispatch.status === "failed"
3121
- && dispatch.turnRef === stateTurnRef
3122
- && dispatch.updatedBy === "mcp"
3123
- && sameLastResult(dispatch.lastResult, {
3124
- status,
3125
- summary,
3126
- turnRef: stateTurnRef,
3127
- turnId: stateTurnId,
3128
- });
3129
- if (!isFreshCreationFailure && !isIdenticalCreationFailureRetry) {
3130
- throw new Error(`report_state unlinked failure requires a fresh link-pending task: ${id}`);
3131
- }
3132
- } else {
3133
- if (threadIdentityKey(threadId) !== threadIdentityKey(dispatch.threadId)) {
3134
- throw new Error(`task result threadId does not match recorded threadId: ${id}`);
3135
- }
3136
- if (stateTurnRef === null) {
3137
- throw new Error(`task state turnRef is required for a linked task: ${id}`);
3138
- }
3139
- }
3140
- if (status === "working") {
3141
- const sameWorkingTurn = dispatch.status === "working" && stateTurnRef === dispatch.turnRef;
3142
- const recordedTurn = dispatch.turns.findLast((turn) => turn.turnRef === stateTurnRef);
3143
- if (!sameWorkingTurn && recordedTurn) {
3144
- throw new Error(`working turnRef is stale: ${id}`);
3145
- }
3146
- const latestNativeTurnRef = isSelfLinkingJourney && stateTurnId !== null
3147
- ? dispatch.turns.reduce((latest, turn) => (
3148
- turn.turnId !== null && (latest === null || turn.turnRef > latest)
3149
- ? turn.turnRef
3150
- : latest
3151
- ), null)
3152
- : null;
3153
- if (
3154
- !sameWorkingTurn
3155
- && latestNativeTurnRef !== null
3156
- && stateTurnRef <= latestNativeTurnRef
3157
- ) {
3158
- throw new Error(`working native turnRef is stale: ${id}`);
3159
- }
3160
- if (sameWorkingTurn) {
3161
- if (dispatch.turnId !== stateTurnId) {
3162
- throw new Error(`working turnRef already has different Codex turn metadata: ${id}`);
3163
- }
3164
- const storedRequest = dispatch.latestTurn?.requestSummary ?? null;
3165
- if (
3166
- requestSummary !== null
3167
- && storedRequest !== null
3168
- && requestSummary !== storedRequest
3169
- ) {
3170
- throw new Error(`working turn already has a different requestSummary: ${id}`);
3171
- }
3172
- if (
3173
- records[index].raw.schemaVersion === CURRENT_TASK_SCHEMA_VERSION
3174
- && (requestSummary === null || storedRequest === requestSummary)
3175
- ) {
3176
- return dispatch;
3177
- }
3178
- }
3179
- const updatedAt = sameWorkingTurn
3180
- ? dispatch.updatedAt
3181
- : transitionTimestamp(now, dispatch.updatedAt);
3182
- const recoveredTurns = (
3183
- !sameWorkingTurn
3184
- && dispatch.status === "working"
3185
- && dispatch.latestTurn?.result === null
3186
- )
3187
- ? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
3188
- ? {
3189
- ...turn,
3190
- result: {
3191
- status: "interrupted",
3192
- summary: INTERRUPTED_TURN_SUMMARY,
3193
- updatedAt,
3194
- },
3195
- }
3196
- : turn)
3197
- : dispatch.turns;
3198
- const turns = sameWorkingTurn
3199
- ? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
3200
- ? { ...turn, requestSummary: turn.requestSummary ?? requestSummary }
3201
- : turn)
3202
- : [...recoveredTurns, {
3203
- turnRef: stateTurnRef,
3204
- turnId: stateTurnId,
3205
- requestSummary,
3206
- startedAt: updatedAt,
3207
- result: null,
3208
- provenance: { kind: "mcp" },
3209
- }];
3210
- const updated = await validateDispatchShape(currentSchemaTask(dispatch, {
3211
- status,
3212
- summary: null,
3213
- turnRef: stateTurnRef,
3214
- turnId: stateTurnId,
3215
- updatedAt,
3216
- updatedBy: "mcp",
3217
- turns,
3218
- }));
3219
- const lines = records.map((record, recordIndex) => recordIndex === index
3220
- ? dispatchLineWithState(updated, {})
3221
- : record.line);
3222
- await writeDispatchLinesAtomic(root, lines);
3223
- return updated;
3224
- }
3225
- const priorTurn = dispatch.turns.findLast((turn) => turn.turnRef === stateTurnRef);
3226
- const priorTurnResult = priorTurn?.result === null || priorTurn === undefined
3227
- ? null
3228
- : { ...priorTurn.result, turnRef: priorTurn.turnRef, turnId: priorTurn.turnId };
3229
- if (priorTurnResult && sameLastResult(priorTurnResult, {
3230
- status, summary, turnRef: stateTurnRef, turnId: stateTurnId,
3231
- })) {
3232
- if (dispatch.turnRef === stateTurnRef) return dispatch;
3233
- throw new Error(`task result turnRef is stale: ${id}`);
3234
- }
3235
- if (priorTurnResult || (dispatch.turnRef === stateTurnRef && dispatch.status !== "working")) {
3236
- throw new Error(`task turn already has a different semantic result: ${id}`);
3237
- }
3238
- if (compatibilityAlias) {
3239
- const matchesWorkingTurn = dispatch.status === "working"
3240
- && dispatch.turnRef === stateTurnRef;
3241
- if (!matchesWorkingTurn && dispatch.turns.some((turn) => turn.turnRef === stateTurnRef)) {
3242
- throw new Error(`task result turnRef is stale: ${id}`);
3243
- }
3244
- } else if (
3245
- dispatch.threadId !== null
3246
- && (dispatch.status !== "working" || dispatch.turnRef !== stateTurnRef)
3247
- ) {
3248
- throw new Error(`task result must match the current working turnRef: ${id}`);
3249
- }
3250
- const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
3251
- const turnResult = { status, summary, updatedAt };
3252
- const currentTurnIndex = dispatch.turns.findLastIndex(
3253
- (turn) => turn.turnRef === stateTurnRef,
3254
- );
3255
- let turns;
3256
- if (currentTurnIndex === -1) {
3257
- turns = [...dispatch.turns, {
3258
- turnRef: stateTurnRef,
3259
- turnId: stateTurnId,
3260
- requestSummary: null,
3261
- startedAt: updatedAt,
3262
- result: turnResult,
3263
- provenance: { kind: "mcp" },
3264
- }];
3265
- } else {
3266
- turns = dispatch.turns.map((turn, turnIndex) => turnIndex === currentTurnIndex
3267
- ? { ...turn, result: turnResult }
3268
- : turn);
3269
- }
3270
- const candidate = currentSchemaTask(dispatch, {
3271
- status,
3272
- summary,
3273
- turnRef: stateTurnRef,
3274
- turnId: stateTurnId,
3275
- updatedAt,
3276
- updatedBy: "mcp",
3277
- turns,
3278
- });
3279
- const updated = await validateDispatchShape(candidate);
3280
- const lines = records.map((record, recordIndex) => recordIndex === index
3281
- ? dispatchLineWithState(updated, {})
3282
- : record.line);
3283
- await writeDispatchLinesAtomic(root, lines);
3284
- return updated;
3285
- });
3286
- }
3287
-
3288
- export async function reportTaskState(workspaceRoot, input, { now } = {}) {
3289
- return reportTaskStateInternal(workspaceRoot, input, { now });
3290
- }
3291
-
3292
- export async function reportTaskResult(workspaceRoot, input, options = {}) {
3293
- return reportTaskStateInternal(workspaceRoot, input, {
3294
- ...options,
3295
- compatibilityAlias: true,
3296
- });
3297
- }
3298
-
3299
- export async function manuallyTransitionTask(
3300
- workspaceRoot,
3301
- taskId,
3302
- input,
3303
- { now, writeTaskLines = writeDispatchLinesAtomic } = {},
3304
- ) {
3305
- const id = requireSafeId(taskId, "taskId");
3306
- let normalizedInput;
3307
- try {
3308
- normalizedInput = normalizeManualTransitionInput(input);
3309
- } catch {
3310
- throw taskOperationError("invalid_request", "manual transition request is invalid");
3311
- }
3312
- const root = await realpath(path.resolve(workspaceRoot));
3313
- return withWorkspaceLock(root, async () => {
3314
- const records = await readDispatchRecordsUnlocked(root);
3315
- const dispatches = records.map((record) => record.normalized);
3316
- const index = dispatches.findIndex((dispatch) => dispatch.id === id);
3317
- if (index === -1) {
3318
- throw taskOperationError("task_not_found", `task not found: ${id}`);
3319
- }
3320
- const actionOwner = dispatches.find((dispatch) => dispatch.turns.some((turn) => (
3321
- turn.provenance?.kind === "dashboard_manual"
3322
- && turn.provenance.actionId === normalizedInput.actionId
3323
- )));
3324
- if (actionOwner) {
3325
- const manualTurn = actionOwner.turns.find((turn) => (
3326
- turn.provenance?.kind === "dashboard_manual"
3327
- && turn.provenance.actionId === normalizedInput.actionId
3328
- ));
3329
- if (actionOwner.id !== id || !sameManualAction(manualTurn.provenance, normalizedInput)) {
3330
- throw taskOperationError(
3331
- "idempotency_conflict",
3332
- `manual transition actionId is already used: ${normalizedInput.actionId}`,
3333
- dispatches[index],
3334
- );
3335
- }
3336
- return { task: dispatches[index], idempotent: true };
3337
- }
3338
-
3339
- const dispatch = dispatches[index];
3340
- if (!canManuallyTransition(dispatch.status, normalizedInput.targetStatus)) {
3341
- throw taskOperationError(
3342
- "invalid_transition",
3343
- `task status cannot be changed manually from ${dispatch.status}: ${id}`,
3344
- dispatch,
3345
- );
3346
- }
3347
- const expected = normalizedInput.expected;
3348
- if (
3349
- dispatch.status !== expected.status
3350
- || dispatch.turnRef !== expected.turnRef
3351
- || dispatch.threadId !== expected.threadId
3352
- || dispatch.updatedAt !== expected.updatedAt
3353
- ) {
3354
- throw taskOperationError(
3355
- "stale_task",
3356
- `task changed after the dashboard loaded it: ${id}`,
3357
- dispatch,
3358
- );
3359
- }
3360
-
3361
- const updatedAt = transitionTimestamp(now, dispatch.updatedAt);
3362
- const interruptedTurns = (
3363
- dispatch.status === "working"
3364
- && dispatch.latestTurn?.result === null
3365
- )
3366
- ? dispatch.turns.map((turn, turnIndex) => turnIndex === dispatch.turns.length - 1
3367
- ? {
3368
- ...turn,
3369
- result: {
3370
- status: "interrupted",
3371
- summary: INTERRUPTED_TURN_SUMMARY,
3372
- updatedAt,
3373
- },
3374
- }
3375
- : turn)
3376
- : dispatch.turns;
3377
- const turnRef = randomUUID();
3378
- const provenance = {
3379
- kind: "dashboard_manual",
3380
- actionId: normalizedInput.actionId,
3381
- fromStatus: dispatch.status,
3382
- toStatus: normalizedInput.targetStatus,
3383
- expectedTurnRef: expected.turnRef,
3384
- expectedThreadId: expected.threadId,
3385
- expectedUpdatedAt: expected.updatedAt,
3386
- };
3387
- const summary = manualTransitionSummary(normalizedInput.targetStatus);
3388
- const turns = [...interruptedTurns, {
3389
- turnRef,
3390
- turnId: null,
3391
- requestSummary: manualTransitionRequestSummary(
3392
- dispatch.status,
3393
- normalizedInput.targetStatus,
3394
- ),
3395
- startedAt: updatedAt,
3396
- result: {
3397
- status: normalizedInput.targetStatus,
3398
- summary,
3399
- updatedAt,
3400
- },
3401
- provenance,
3402
- }];
3403
- const updated = await validateDispatchShape(currentSchemaTask(dispatch, {
3404
- status: normalizedInput.targetStatus,
3405
- summary,
3406
- turnRef,
3407
- turnId: null,
3408
- updatedAt,
3409
- updatedBy: "dashboard",
3410
- turns,
3411
- }));
3412
- const lines = records.map((record, recordIndex) => recordIndex === index
3413
- ? dispatchLineWithState(updated, {})
3414
- : record.line);
3415
- await writeTaskLines(root, lines);
3416
- return { task: updated, idempotent: false };
3417
- });
3418
- }
3419
-
3420
- export async function readTask(workspaceRoot, taskId) {
3421
- const id = requireSafeId(taskId, "taskId");
3422
- const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
3423
- if (!dispatch) throw new Error(`task not found: ${id}`);
3424
- return dispatch;
3425
- }
3426
-
3427
- export async function filterTasks(workspaceRoot, { project = null } = {}) {
3428
- const dispatches = await listTasks(workspaceRoot);
3429
- if (project === null) return dispatches;
3430
- const value = requireString(project, "project");
3431
- const filtered = dispatches.filter(
3432
- (dispatch) =>
3433
- dispatch.project.name.toLowerCase() === value.toLowerCase() ||
3434
- dispatch.project.path === value,
3435
- );
3436
- return filtered;
3437
- }
3438
-
3439
- export async function buildTaskSummary(workspaceRoot) {
3440
- const dispatches = await listTasks(workspaceRoot);
3441
- const projectCounts = new Map();
3442
- for (const dispatch of dispatches) {
3443
- projectCounts.set(
3444
- dispatch.project.name,
3445
- (projectCounts.get(dispatch.project.name) ?? 0) + 1,
3446
- );
3447
- }
3448
- return {
3449
- schemaVersion: 1,
3450
- taskCount: dispatches.length,
3451
- projectCounts: Object.fromEntries(
3452
- [...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right)),
3453
- ),
3454
- };
3455
- }
3456
-
3457
- const COPILOT_RECENT_TERMINAL_DAYS = 7;
3458
-
3459
- function sortTasksByNewestUpdate(tasks) {
3460
- return tasks
3461
- .map((task, index) => ({ task, index }))
3462
- .sort((left, right) => (
3463
- Date.parse(right.task.updatedAt) - Date.parse(left.task.updatedAt)
3464
- || left.index - right.index
3465
- ))
3466
- .map(({ task }) => task);
3467
- }
3468
-
3469
- function taskBriefEntry(task) {
3470
- const linkPending = task.threadId === null && task.status === "working";
3471
- const creationFailure = task.threadId === null && task.status === "failed";
3472
- const interruptedTurnCount = task.turns.filter(
3473
- (turn) => turn.result?.status === "interrupted",
3474
- ).length;
3475
- let attention = null;
3476
- let nextAction;
3477
- if (linkPending) {
3478
- attention = {
3479
- kind: "link_pending",
3480
- reason: "The executor has not linked its durable Codex task identity yet.",
3481
- };
3482
- nextAction = {
3483
- kind: "wait",
3484
- instruction: "Wait for the existing executor to self-link; recover its exact identity before proposing any continuation.",
3485
- requiresExplicitAuthorization: false,
3486
- };
3487
- } else if (task.status === "needs_input") {
3488
- attention = {
3489
- kind: "needs_input",
3490
- reason: task.summary,
3491
- };
3492
- nextAction = {
3493
- kind: "continue_existing_task",
3494
- instruction: "Provide the missing decision or fact in the existing executor task.",
3495
- requiresExplicitAuthorization: true,
3496
- };
3497
- } else if (task.status === "failed") {
3498
- attention = {
3499
- kind: creationFailure ? "creation_failed" : "failed",
3500
- reason: task.summary,
3501
- };
3502
- nextAction = creationFailure ? {
3503
- kind: "inspect_creation_failure",
3504
- instruction: "Review the creation failure before deciding whether to dispatch new work.",
3505
- requiresExplicitAuthorization: true,
3506
- } : {
3507
- kind: "continue_existing_task",
3508
- instruction: "Review the failure and authorize a focused follow-up in the existing executor task if appropriate.",
3509
- requiresExplicitAuthorization: true,
3510
- };
3511
- } else if (task.status === "working") {
3512
- nextAction = {
3513
- kind: "wait",
3514
- instruction: "No action is recommended while the existing executor is working.",
3515
- requiresExplicitAuthorization: false,
3516
- };
3517
- } else {
3518
- nextAction = {
3519
- kind: "none",
3520
- instruction: "No follow-up is required unless the user has a new request.",
3521
- requiresExplicitAuthorization: false,
3522
- };
3523
- }
3524
- return {
3525
- id: task.id,
3526
- title: task.title,
3527
- project: {
3528
- name: task.project.name,
3529
- path: task.project.path,
3530
- },
3531
- threadId: task.threadId,
3532
- state: task.status,
3533
- summary: task.summary,
3534
- lastOutcome: task.lastResult === null ? null : {
3535
- status: task.lastResult.status,
3536
- summary: task.lastResult.summary,
3537
- turnRef: task.lastResult.turnRef,
3538
- turnId: task.lastResult.turnId,
3539
- updatedAt: task.lastResult.updatedAt,
3540
- },
3541
- requestSummary: task.latestTurn?.requestSummary ?? null,
3542
- updatedAt: task.updatedAt,
3543
- attention,
3544
- nextAction: {
3545
- ...nextAction,
3546
- taskId: task.id,
3547
- threadId: task.threadId,
3548
- },
3549
- liveCheck: {
3550
- recommended: false,
3551
- reason: null,
3552
- },
3553
- interruptedTurnCount,
3554
- };
3555
- }
3556
-
3557
- export async function buildCopilotBrief(workspaceRoot, {
3558
- project = null,
3559
- taskId = null,
3560
- includeOldTerminal = false,
3561
- now = () => new Date().toISOString(),
3562
- } = {}) {
3563
- if (project !== null && taskId !== null) {
3564
- throw new Error("copilot brief accepts either project or taskId, not both");
3565
- }
3566
- let tasks = project === null
3567
- ? await listTasks(workspaceRoot)
3568
- : await filterTasks(workspaceRoot, { project });
3569
- if (taskId !== null) {
3570
- const id = requireSafeId(taskId, "taskId");
3571
- tasks = tasks.filter((task) => task.id === id);
3572
- if (tasks.length === 0) throw new Error(`task not found: ${id}`);
3573
- }
3574
- const generatedAt = requireTimestamp(now(), "copilot brief timestamp");
3575
- const recentCutoff = Date.parse(generatedAt)
3576
- - COPILOT_RECENT_TERMINAL_DAYS * 24 * 60 * 60 * 1_000;
3577
- const focused = taskId !== null || project !== null;
3578
- const selected = [];
3579
- let omittedTerminalCount = 0;
3580
- for (const task of sortTasksByNewestUpdate(tasks)) {
3581
- const terminal = task.status === "completed" || task.status === "failed";
3582
- const oldTerminal = terminal && Date.parse(task.updatedAt) < recentCutoff;
3583
- if (!focused && !includeOldTerminal && oldTerminal) {
3584
- omittedTerminalCount += 1;
3585
- continue;
3586
- }
3587
- selected.push(taskBriefEntry(task));
3588
- }
3589
- return {
3590
- schemaVersion: 1,
3591
- mode: "cached",
3592
- scope: taskId !== null ? "task" : project !== null ? "project" : "overview",
3593
- generatedAt,
3594
- taskCount: selected.length,
3595
- omittedTerminalCount,
3596
- tasks: selected,
3597
- };
3598
- }
3599
-
3600
- export async function doctorWorkspace(workspaceRoot) {
3601
- const root = path.resolve(workspaceRoot);
3602
- const checks = [];
3603
- const check = async (name, operation) => {
3604
- try {
3605
- const message = await operation();
3606
- checks.push({ name, status: "pass", message });
3607
- } catch (error) {
3608
- checks.push({ name, status: "fail", message: error.message });
3609
- }
3610
- };
3611
- await check("configuration", async () => {
3612
- const config = await readConfig(root);
3613
- return `${config.projects.length} configured project(s) valid`;
3614
- });
3615
- await check("task-log", async () => {
3616
- const dispatches = await listTasks(root);
3617
- return `${dispatches.length} task record(s) valid`;
3618
- });
3619
- await check("instructions", async () => {
3620
- const filePath = path.join(root, "AGENTS.md");
3621
- const existing = await readFile(filePath, "utf8");
3622
- const managed = await readDispatcherInstructions();
3623
- if (mergeDispatcherInstructions(existing, managed) !== existing) {
3624
- throw new Error("managed AGENTS.md instructions are missing or stale");
3625
- }
3626
- return "managed AGENTS.md instructions current";
3627
- });
3628
- return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
3629
- }