taskchef 2.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +3 -3
- package/BACKLOG.md +35 -82
- package/README.md +211 -108
- package/SPEC.md +114 -246
- package/assets/taskchef-dispatcher-instructions.md +3 -2
- package/index.js +2 -5
- package/package.json +6 -2
- package/skills/taskchef-bootstrap/SKILL.md +11 -11
- package/skills/taskchef-delegate/SKILL.md +16 -18
- package/skills/taskchef-report/SKILL.md +38 -0
- package/skills/taskchef-report/agents/openai.yaml +4 -0
- package/src/cli.js +26 -70
- package/src/workspace.js +315 -251
- package/skills/taskchef-reconcile/SKILL.md +0 -35
- package/skills/taskchef-reconcile/agents/openai.yaml +0 -4
package/src/workspace.js
CHANGED
|
@@ -17,6 +17,7 @@ import { randomUUID } from "node:crypto";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { promisify } from "node:util";
|
|
20
|
+
import lockfile from "proper-lockfile";
|
|
20
21
|
|
|
21
22
|
const execFile = promisify(execFileCallback);
|
|
22
23
|
const DISPATCHER_INSTRUCTIONS_URL = new URL(
|
|
@@ -28,9 +29,12 @@ const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -
|
|
|
28
29
|
const TASKCHEF_SKILL_NAMES = [
|
|
29
30
|
"taskchef-bootstrap",
|
|
30
31
|
"taskchef-delegate",
|
|
31
|
-
"taskchef-
|
|
32
|
+
"taskchef-report",
|
|
32
33
|
];
|
|
34
|
+
const LEGACY_TASKCHEF_SKILL_NAMES = [...TASKCHEF_SKILL_NAMES, "taskchef-reconcile"];
|
|
33
35
|
const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url));
|
|
36
|
+
const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
37
|
+
const DISPATCH_LOCK_NAME = ".taskchef-dispatch.lock";
|
|
34
38
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
35
39
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
36
40
|
const PROJECT_FIELDS = new Set([
|
|
@@ -41,27 +45,22 @@ const PROJECT_FIELDS = new Set([
|
|
|
41
45
|
"description",
|
|
42
46
|
]);
|
|
43
47
|
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepo", "description"]);
|
|
44
|
-
const
|
|
48
|
+
const DISPATCH_FIELDS = new Set([
|
|
45
49
|
"schemaVersion",
|
|
46
50
|
"id",
|
|
47
51
|
"project",
|
|
48
52
|
"title",
|
|
49
53
|
"instruction",
|
|
50
|
-
"status",
|
|
51
54
|
"threadId",
|
|
52
|
-
"result",
|
|
53
55
|
"createdAt",
|
|
54
|
-
"updatedAt",
|
|
55
56
|
]);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
finished: new Set(["finished", "running", "blocked"]),
|
|
64
|
-
};
|
|
57
|
+
const RECORD_DISPATCH_FIELDS = new Set([
|
|
58
|
+
"id",
|
|
59
|
+
"project",
|
|
60
|
+
"title",
|
|
61
|
+
"instruction",
|
|
62
|
+
"threadId",
|
|
63
|
+
]);
|
|
65
64
|
|
|
66
65
|
function requireExactFields(value, fields, name) {
|
|
67
66
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
@@ -140,6 +139,31 @@ async function managedRegularFileExists(filePath) {
|
|
|
140
139
|
return true;
|
|
141
140
|
}
|
|
142
141
|
|
|
142
|
+
async function withDispatchLock(workspaceRoot, operation) {
|
|
143
|
+
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
144
|
+
const lockPath = path.join(workspaceRoot, DISPATCH_LOCK_NAME);
|
|
145
|
+
const release = await lockfile.lock(dispatchPath, {
|
|
146
|
+
realpath: false,
|
|
147
|
+
lockfilePath: lockPath,
|
|
148
|
+
stale: 5_000,
|
|
149
|
+
update: 1_000,
|
|
150
|
+
retries: { retries: 70, factor: 1, minTimeout: 100, maxTimeout: 100 },
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
return await operation();
|
|
154
|
+
} finally {
|
|
155
|
+
await release();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function appendDispatchesAtomic(workspaceRoot, dispatches) {
|
|
160
|
+
if (dispatches.length === 0) return;
|
|
161
|
+
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
162
|
+
const content = await readFile(dispatchPath, "utf8");
|
|
163
|
+
const appended = dispatches.map((dispatch) => `${JSON.stringify(dispatch)}\n`).join("");
|
|
164
|
+
await writeTextAtomic(dispatchPath, `${content}${appended}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
143
167
|
async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
|
|
144
168
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
145
169
|
if (exclusive && (await pathExists(filePath))) {
|
|
@@ -265,7 +289,7 @@ async function findLegacySkillLinks(workspaceRoot) {
|
|
|
265
289
|
}
|
|
266
290
|
|
|
267
291
|
const links = [];
|
|
268
|
-
for (const skillName of
|
|
292
|
+
for (const skillName of LEGACY_TASKCHEF_SKILL_NAMES) {
|
|
269
293
|
const skillPath = path.join(skillsDirectory, skillName);
|
|
270
294
|
const details = await lstat(skillPath).catch((error) => {
|
|
271
295
|
if (error.code === "ENOENT") return null;
|
|
@@ -515,18 +539,33 @@ export async function validateConfig(config, { checkPaths = true } = {}) {
|
|
|
515
539
|
};
|
|
516
540
|
}
|
|
517
541
|
|
|
542
|
+
async function ensureDispatchFile(workspaceRoot) {
|
|
543
|
+
const filePath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
544
|
+
const exists = await managedRegularFileExists(filePath);
|
|
545
|
+
if (!exists) {
|
|
546
|
+
await writeFile(filePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
547
|
+
}
|
|
548
|
+
return { path: filePath, action: exists ? "unchanged" : "created" };
|
|
549
|
+
}
|
|
550
|
+
|
|
518
551
|
export async function initializeWorkspace(workspaceRoot) {
|
|
519
552
|
const requestedRoot = path.resolve(workspaceRoot);
|
|
520
553
|
await mkdir(requestedRoot, { recursive: true });
|
|
521
554
|
const root = await realpath(requestedRoot);
|
|
522
|
-
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
523
|
-
await ensureManagedDirectory(root, "tasks");
|
|
524
555
|
const configPath = path.join(root, "taskchef.json");
|
|
525
556
|
const configExists = await managedRegularFileExists(configPath);
|
|
526
557
|
const config = configExists
|
|
527
558
|
? await readConfig(root, { checkPaths: false })
|
|
528
559
|
: { schemaVersion: 1, projects: [] };
|
|
560
|
+
const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
|
|
561
|
+
const existingDispatches = configExists && (await managedRegularFileExists(dispatchPath))
|
|
562
|
+
? await readDispatchesUnlocked(root)
|
|
563
|
+
: [];
|
|
564
|
+
const legacyTaskRecords = await inspectLegacyTaskRecords(root, config, existingDispatches);
|
|
565
|
+
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
529
566
|
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
567
|
+
const tasks = await ensureDispatchFile(root);
|
|
568
|
+
const legacyTasks = await migrateLegacyTaskRecords(root, legacyTaskRecords);
|
|
530
569
|
const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
|
|
531
570
|
if (!configExists) await unlink(configPath).catch(() => {});
|
|
532
571
|
throw error;
|
|
@@ -534,9 +573,10 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
534
573
|
return {
|
|
535
574
|
workspace: root,
|
|
536
575
|
config: { path: configPath, action: configExists ? "unchanged" : "created", value: config },
|
|
537
|
-
tasks
|
|
576
|
+
tasks,
|
|
538
577
|
instructions,
|
|
539
578
|
legacySkills,
|
|
579
|
+
legacyTasks,
|
|
540
580
|
};
|
|
541
581
|
}
|
|
542
582
|
|
|
@@ -589,21 +629,6 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
589
629
|
else projects[index] = project;
|
|
590
630
|
}
|
|
591
631
|
const config = await validateConfig({ schemaVersion: 1, projects });
|
|
592
|
-
if (replace) {
|
|
593
|
-
const currentPaths = new Set(current.projects.map((project) => project.path));
|
|
594
|
-
const configuredPaths = new Set(config.projects.map((project) => project.path));
|
|
595
|
-
const removedPaths = new Set(
|
|
596
|
-
[...currentPaths].filter((projectPath) => !configuredPaths.has(projectPath)),
|
|
597
|
-
);
|
|
598
|
-
const newlyOrphaned = (await listTasks(root, { checkProjects: false })).filter(
|
|
599
|
-
(task) => removedPaths.has(task.project),
|
|
600
|
-
);
|
|
601
|
-
if (newlyOrphaned.length > 0) {
|
|
602
|
-
throw new Error(
|
|
603
|
-
`replacement would orphan ${newlyOrphaned.length} task record(s); remove referenced projects with --force first`,
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
632
|
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
608
633
|
return {
|
|
609
634
|
mode: replace ? "replace" : "merge",
|
|
@@ -613,7 +638,7 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
613
638
|
};
|
|
614
639
|
}
|
|
615
640
|
|
|
616
|
-
export async function removeProject(workspaceRoot, name
|
|
641
|
+
export async function removeProject(workspaceRoot, name) {
|
|
617
642
|
const root = path.resolve(workspaceRoot);
|
|
618
643
|
const config = await readConfig(root, { checkPaths: false });
|
|
619
644
|
const index = config.projects.findIndex(
|
|
@@ -621,215 +646,287 @@ export async function removeProject(workspaceRoot, name, { force = false } = {})
|
|
|
621
646
|
);
|
|
622
647
|
if (index === -1) throw new Error(`configured project not found: ${name}`);
|
|
623
648
|
const [project] = config.projects.slice(index, index + 1);
|
|
624
|
-
const referenced = (await listTasks(root, { checkProjects: false })).filter(
|
|
625
|
-
(task) => task.project === project.path,
|
|
626
|
-
);
|
|
627
|
-
if (referenced.length > 0 && !force) {
|
|
628
|
-
throw new Error(
|
|
629
|
-
`project is referenced by ${referenced.length} task record(s); pass --force to remove it`,
|
|
630
|
-
);
|
|
631
|
-
}
|
|
632
649
|
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
633
650
|
await writeJsonAtomic(path.join(root, "taskchef.json"), { schemaVersion: 1, projects });
|
|
634
|
-
return { project
|
|
651
|
+
return { project };
|
|
635
652
|
}
|
|
636
653
|
|
|
637
|
-
function
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
} catch {
|
|
643
|
-
throw new Error(`${name} must be a canonical GitHub URL`);
|
|
644
|
-
}
|
|
645
|
-
const segment = kind === "pull" ? "pull" : "issues";
|
|
646
|
-
const pattern = new RegExp(`^/[^/]+/[^/]+/${segment}/[1-9]\\d*$`);
|
|
647
|
-
if (
|
|
648
|
-
url.protocol !== "https:" ||
|
|
649
|
-
url.hostname !== "github.com" ||
|
|
650
|
-
url.username ||
|
|
651
|
-
url.password ||
|
|
652
|
-
url.port ||
|
|
653
|
-
url.search ||
|
|
654
|
-
url.hash ||
|
|
655
|
-
!pattern.test(url.pathname)
|
|
656
|
-
) {
|
|
657
|
-
throw new Error(`${name} must be a canonical GitHub ${kind} URL`);
|
|
658
|
-
}
|
|
659
|
-
return value;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
export function validateResult(result) {
|
|
663
|
-
if (result === null) return null;
|
|
664
|
-
requireExactFields(result, RESULT_FIELDS, "result");
|
|
665
|
-
requireString(result.message, "result.message");
|
|
666
|
-
for (const field of ["githubPRs", "githubIssues"]) {
|
|
667
|
-
if (!Array.isArray(result[field])) throw new Error(`result.${field} must be an array`);
|
|
668
|
-
}
|
|
669
|
-
result.githubPRs.forEach((value, index) =>
|
|
670
|
-
validateGithubUrl(value, "pull", `result.githubPRs[${index}]`));
|
|
671
|
-
result.githubIssues.forEach((value, index) =>
|
|
672
|
-
validateGithubUrl(value, "issue", `result.githubIssues[${index}]`));
|
|
654
|
+
async function validateDispatchShape(dispatch, name = "task") {
|
|
655
|
+
requireExactFields(dispatch, DISPATCH_FIELDS, name);
|
|
656
|
+
if (dispatch.schemaVersion !== 1) throw new Error(`unsupported ${name} schemaVersion`);
|
|
657
|
+
const id = requireSafeId(dispatch.id, `${name}.id`);
|
|
658
|
+
const project = await normalizeProject(dispatch.project, 0, { checkPath: false });
|
|
673
659
|
return {
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
660
|
+
schemaVersion: 1,
|
|
661
|
+
id,
|
|
662
|
+
project,
|
|
663
|
+
title: requireString(dispatch.title, `${name}.title`).trim(),
|
|
664
|
+
instruction: requireString(dispatch.instruction, `${name}.instruction`).trim(),
|
|
665
|
+
threadId: requireString(dispatch.threadId, `${name}.threadId`).trim(),
|
|
666
|
+
createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
|
|
677
667
|
};
|
|
678
668
|
}
|
|
679
669
|
|
|
680
|
-
function
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
requireString(task.title, "title");
|
|
686
|
-
requireString(task.instruction, "instruction");
|
|
687
|
-
if (!TASK_STATUSES.has(task.status)) throw new Error(`unsupported task status: ${task.status}`);
|
|
688
|
-
if (task.threadId !== null) requireString(task.threadId, "threadId");
|
|
689
|
-
if (task.status === "pending" && task.threadId !== null) {
|
|
690
|
-
throw new Error("a pending task must not have a threadId");
|
|
670
|
+
async function readDispatchesUnlocked(root) {
|
|
671
|
+
await readConfig(root, { checkPaths: false });
|
|
672
|
+
const filePath = path.join(root, DISPATCH_FILE_NAME);
|
|
673
|
+
if (!(await managedRegularFileExists(filePath))) {
|
|
674
|
+
throw new Error(`task log does not exist: ${filePath}`);
|
|
691
675
|
}
|
|
692
|
-
|
|
693
|
-
|
|
676
|
+
const content = await readFile(filePath, "utf8");
|
|
677
|
+
if (content.length > 0 && !content.endsWith("\n")) {
|
|
678
|
+
throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
|
|
694
679
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
680
|
+
const lines = content.length === 0 ? [] : content.slice(0, -1).split("\n");
|
|
681
|
+
const dispatches = [];
|
|
682
|
+
for (const [index, line] of lines.entries()) {
|
|
683
|
+
if (line.trim().length === 0) {
|
|
684
|
+
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is empty`);
|
|
685
|
+
}
|
|
686
|
+
let value;
|
|
687
|
+
try {
|
|
688
|
+
value = JSON.parse(line);
|
|
689
|
+
} catch (error) {
|
|
690
|
+
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is invalid JSON: ${error.message}`);
|
|
691
|
+
}
|
|
692
|
+
dispatches.push(await validateDispatchShape(value, `task line ${index + 1}`));
|
|
693
|
+
}
|
|
694
|
+
const ids = new Set();
|
|
695
|
+
const threadIds = new Set();
|
|
696
|
+
for (const dispatch of dispatches) {
|
|
697
|
+
if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
|
|
698
|
+
if (threadIds.has(dispatch.threadId)) {
|
|
699
|
+
throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
|
|
700
|
+
}
|
|
701
|
+
ids.add(dispatch.id);
|
|
702
|
+
threadIds.add(dispatch.threadId);
|
|
700
703
|
}
|
|
701
|
-
return
|
|
704
|
+
return dispatches;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export async function listTasks(workspaceRoot) {
|
|
708
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
709
|
+
return readDispatchesUnlocked(root);
|
|
702
710
|
}
|
|
703
711
|
|
|
704
|
-
export async function
|
|
712
|
+
export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
713
|
+
requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
|
|
705
714
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
706
|
-
requireExactFields(input, CREATE_TASK_FIELDS, "task creation input");
|
|
707
715
|
const config = await readConfig(root);
|
|
708
|
-
const
|
|
709
|
-
const project =
|
|
710
|
-
if (!
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
const createdAt = now ?? new Date().toISOString();
|
|
714
|
-
requireTimestamp(createdAt, "createdAt");
|
|
715
|
-
const task = {
|
|
716
|
+
const projectPath = await canonicalDirectory(input.project);
|
|
717
|
+
const project = config.projects.find((candidate) => candidate.path === projectPath);
|
|
718
|
+
if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
|
|
719
|
+
const dispatch = await validateDispatchShape({
|
|
716
720
|
schemaVersion: 1,
|
|
717
|
-
id,
|
|
721
|
+
id: input.id,
|
|
718
722
|
project,
|
|
719
|
-
title:
|
|
720
|
-
instruction:
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
return task;
|
|
723
|
+
title: input.title,
|
|
724
|
+
instruction: input.instruction,
|
|
725
|
+
threadId: input.threadId,
|
|
726
|
+
createdAt: now ?? new Date().toISOString(),
|
|
727
|
+
});
|
|
728
|
+
await withDispatchLock(root, async () => {
|
|
729
|
+
const existing = await readDispatchesUnlocked(root);
|
|
730
|
+
if (existing.some((item) => item.id === dispatch.id)) {
|
|
731
|
+
throw new Error(`task already exists: ${dispatch.id}`);
|
|
732
|
+
}
|
|
733
|
+
if (existing.some((item) => item.threadId === dispatch.threadId)) {
|
|
734
|
+
throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
|
|
735
|
+
}
|
|
736
|
+
await appendDispatchesAtomic(root, [dispatch]);
|
|
737
|
+
});
|
|
738
|
+
return dispatch;
|
|
736
739
|
}
|
|
737
740
|
|
|
738
741
|
export async function readTask(workspaceRoot, taskId) {
|
|
739
742
|
const id = requireSafeId(taskId, "taskId");
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
return
|
|
743
|
+
const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
|
|
744
|
+
if (!dispatch) throw new Error(`task not found: ${id}`);
|
|
745
|
+
return dispatch;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function filterTasks(workspaceRoot, { project = null } = {}) {
|
|
749
|
+
const dispatches = await listTasks(workspaceRoot);
|
|
750
|
+
if (project === null) return dispatches;
|
|
751
|
+
const value = requireString(project, "project");
|
|
752
|
+
const filtered = dispatches.filter(
|
|
753
|
+
(dispatch) =>
|
|
754
|
+
dispatch.project.name.toLowerCase() === value.toLowerCase() ||
|
|
755
|
+
dispatch.project.path === value,
|
|
756
|
+
);
|
|
757
|
+
return filtered;
|
|
755
758
|
}
|
|
756
759
|
|
|
757
|
-
export async function
|
|
758
|
-
const
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (!STATUS_TRANSITIONS[current.status].has(status)) {
|
|
766
|
-
throw new Error(`unsupported task transition: ${current.status} -> ${status}`);
|
|
767
|
-
}
|
|
768
|
-
const threadId = patch.threadId === undefined ? current.threadId : patch.threadId;
|
|
769
|
-
if (threadId !== null) requireString(threadId, "threadId");
|
|
770
|
-
if (current.threadId && threadId !== current.threadId) {
|
|
771
|
-
throw new Error("threadId cannot be replaced once recorded");
|
|
772
|
-
}
|
|
773
|
-
const updated = {
|
|
774
|
-
...current,
|
|
775
|
-
status,
|
|
776
|
-
threadId,
|
|
777
|
-
result: patch.result === undefined ? current.result : validateResult(patch.result),
|
|
778
|
-
updatedAt: now ?? new Date().toISOString(),
|
|
779
|
-
};
|
|
780
|
-
requireTimestamp(updated.updatedAt, "updatedAt");
|
|
781
|
-
if (Date.parse(updated.updatedAt) < Date.parse(current.updatedAt)) {
|
|
782
|
-
throw new Error("updatedAt must not move backwards");
|
|
760
|
+
export async function buildTaskSummary(workspaceRoot) {
|
|
761
|
+
const dispatches = await listTasks(workspaceRoot);
|
|
762
|
+
const projectCounts = new Map();
|
|
763
|
+
for (const dispatch of dispatches) {
|
|
764
|
+
projectCounts.set(
|
|
765
|
+
dispatch.project.name,
|
|
766
|
+
(projectCounts.get(dispatch.project.name) ?? 0) + 1,
|
|
767
|
+
);
|
|
783
768
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
769
|
+
return {
|
|
770
|
+
schemaVersion: 1,
|
|
771
|
+
taskCount: dispatches.length,
|
|
772
|
+
projectCounts: Object.fromEntries(
|
|
773
|
+
[...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right)),
|
|
774
|
+
),
|
|
775
|
+
};
|
|
788
776
|
}
|
|
789
777
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
778
|
+
const LEGACY_TASK_FIELDS = new Set([
|
|
779
|
+
"schemaVersion",
|
|
780
|
+
"id",
|
|
781
|
+
"project",
|
|
782
|
+
"title",
|
|
783
|
+
"instruction",
|
|
784
|
+
"status",
|
|
785
|
+
"threadId",
|
|
786
|
+
"result",
|
|
787
|
+
"createdAt",
|
|
788
|
+
"updatedAt",
|
|
789
|
+
]);
|
|
790
|
+
|
|
791
|
+
function validateLegacyTask(task, taskId) {
|
|
792
|
+
requireExactFields(task, LEGACY_TASK_FIELDS, `legacy task ${taskId}`);
|
|
793
|
+
if (task.schemaVersion !== 1) throw new Error(`legacy task ${taskId} has unsupported schemaVersion`);
|
|
794
|
+
if (requireSafeId(task.id) !== taskId) throw new Error(`legacy task ID does not match directory: ${taskId}`);
|
|
795
|
+
requireString(task.project, `legacy task ${taskId}.project`);
|
|
796
|
+
requireString(task.title, `legacy task ${taskId}.title`);
|
|
797
|
+
requireString(task.instruction, `legacy task ${taskId}.instruction`);
|
|
798
|
+
requireTimestamp(task.createdAt, `legacy task ${taskId}.createdAt`);
|
|
799
|
+
if (task.threadId === null) {
|
|
800
|
+
throw new Error(`legacy pending task ${taskId} has no executor thread and cannot be migrated`);
|
|
801
|
+
}
|
|
802
|
+
requireString(task.threadId, `legacy task ${taskId}.threadId`);
|
|
803
|
+
return task;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function inspectLegacyTaskRecords(workspaceRoot, config, existingDispatches = []) {
|
|
807
|
+
const tasksPath = path.join(workspaceRoot, "tasks");
|
|
808
|
+
const details = await lstat(tasksPath).catch((error) => {
|
|
809
|
+
if (error.code === "ENOENT") return null;
|
|
810
|
+
throw error;
|
|
811
|
+
});
|
|
812
|
+
if (details === null) return { tasksPath, entries: [], records: [], action: "not-found" };
|
|
813
|
+
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
814
|
+
throw new Error(`legacy tasks path is not a real directory: ${tasksPath}`);
|
|
815
|
+
}
|
|
816
|
+
const entries = await readdir(tasksPath, { withFileTypes: true });
|
|
817
|
+
const records = [];
|
|
818
|
+
const ids = new Set();
|
|
819
|
+
const threadIds = new Set();
|
|
796
820
|
for (const entry of entries) {
|
|
797
|
-
if (!entry.isDirectory()
|
|
798
|
-
|
|
799
|
-
|
|
821
|
+
if (!entry.isDirectory() || !SAFE_ID.test(entry.name)) {
|
|
822
|
+
throw new Error(`unexpected legacy task entry: ${entry.name}`);
|
|
823
|
+
}
|
|
824
|
+
const taskDirectory = path.join(tasksPath, entry.name);
|
|
825
|
+
const taskEntries = await readdir(taskDirectory, { withFileTypes: true });
|
|
826
|
+
if (taskEntries.length === 0) {
|
|
827
|
+
records.push({ dispatch: null, taskPath: null, taskDirectory, taskId: entry.name });
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
if (
|
|
831
|
+
taskEntries.length !== 1 ||
|
|
832
|
+
taskEntries[0].name !== "task.json" ||
|
|
833
|
+
!taskEntries[0].isFile()
|
|
834
|
+
) {
|
|
835
|
+
throw new Error(`legacy task directory must contain only task.json: ${entry.name}`);
|
|
836
|
+
}
|
|
837
|
+
const taskPath = path.join(taskDirectory, "task.json");
|
|
838
|
+
const task = validateLegacyTask(JSON.parse(await readFile(taskPath, "utf8")), entry.name);
|
|
839
|
+
const existing = existingDispatches.find((dispatch) => dispatch.id === task.id);
|
|
840
|
+
let dispatch;
|
|
841
|
+
if (existing) {
|
|
842
|
+
if (task.project.trim() !== existing.project.path) {
|
|
843
|
+
throw new Error(`legacy task conflicts with task history: ${task.id}`);
|
|
844
|
+
}
|
|
845
|
+
const comparable = {
|
|
846
|
+
title: task.title.trim(),
|
|
847
|
+
instruction: task.instruction.trim(),
|
|
848
|
+
threadId: task.threadId.trim(),
|
|
849
|
+
createdAt: task.createdAt,
|
|
850
|
+
};
|
|
851
|
+
for (const [field, value] of Object.entries(comparable)) {
|
|
852
|
+
if (existing[field] !== value) {
|
|
853
|
+
throw new Error(`legacy task conflicts with task history: ${task.id}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
dispatch = existing;
|
|
857
|
+
} else {
|
|
858
|
+
const project = config.projects.find((candidate) => candidate.path === task.project) ??
|
|
859
|
+
await inspectProject({ path: task.project });
|
|
860
|
+
dispatch = await validateDispatchShape({
|
|
861
|
+
schemaVersion: 1,
|
|
862
|
+
id: task.id,
|
|
863
|
+
project,
|
|
864
|
+
title: task.title,
|
|
865
|
+
instruction: task.instruction,
|
|
866
|
+
threadId: task.threadId,
|
|
867
|
+
createdAt: task.createdAt,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
if (ids.has(dispatch.id)) throw new Error(`duplicate legacy task ID: ${dispatch.id}`);
|
|
871
|
+
if (threadIds.has(dispatch.threadId)) {
|
|
872
|
+
throw new Error(`duplicate legacy task threadId: ${dispatch.threadId}`);
|
|
873
|
+
}
|
|
874
|
+
ids.add(dispatch.id);
|
|
875
|
+
threadIds.add(dispatch.threadId);
|
|
876
|
+
records.push({ dispatch, taskPath, taskDirectory, taskId: task.id });
|
|
800
877
|
}
|
|
801
|
-
return
|
|
878
|
+
return {
|
|
879
|
+
tasksPath,
|
|
880
|
+
entries,
|
|
881
|
+
records,
|
|
882
|
+
action: entries.length === 0 ? "removed-empty" : "migrated",
|
|
883
|
+
};
|
|
802
884
|
}
|
|
803
885
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
886
|
+
async function migrateLegacyTaskRecords(workspaceRoot, inspection) {
|
|
887
|
+
if (inspection.action === "not-found") return { action: "not-found", migratedCount: 0 };
|
|
888
|
+
const migrations = await withDispatchLock(workspaceRoot, async () => {
|
|
889
|
+
const existing = await readDispatchesUnlocked(workspaceRoot);
|
|
890
|
+
const existingById = new Map(existing.map((dispatch) => [dispatch.id, dispatch]));
|
|
891
|
+
const threadIds = new Set(existing.map((dispatch) => dispatch.threadId));
|
|
892
|
+
const pending = [];
|
|
893
|
+
for (const { dispatch, taskPath, taskDirectory, taskId } of inspection.records) {
|
|
894
|
+
if (dispatch === null) {
|
|
895
|
+
if (!existingById.has(taskId)) {
|
|
896
|
+
throw new Error(`empty legacy task directory has no matching dispatch: ${taskId}`);
|
|
897
|
+
}
|
|
898
|
+
pending.push({ dispatch: null, taskPath, taskDirectory });
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const previous = existingById.get(dispatch.id);
|
|
902
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(dispatch)) {
|
|
903
|
+
throw new Error(`legacy task conflicts with dispatch: ${dispatch.id}`);
|
|
904
|
+
}
|
|
905
|
+
if (!previous && threadIds.has(dispatch.threadId)) {
|
|
906
|
+
throw new Error(`legacy task threadId is already recorded: ${dispatch.threadId}`);
|
|
907
|
+
}
|
|
908
|
+
if (!previous) {
|
|
909
|
+
pending.push({ dispatch, taskPath, taskDirectory });
|
|
910
|
+
existingById.set(dispatch.id, dispatch);
|
|
911
|
+
threadIds.add(dispatch.threadId);
|
|
912
|
+
} else {
|
|
913
|
+
pending.push({ dispatch: null, taskPath, taskDirectory });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
await appendDispatchesAtomic(
|
|
917
|
+
workspaceRoot,
|
|
918
|
+
pending.filter((item) => item.dispatch).map((item) => item.dispatch),
|
|
814
919
|
);
|
|
815
|
-
|
|
816
|
-
|
|
920
|
+
return pending;
|
|
921
|
+
});
|
|
922
|
+
for (const migration of migrations) {
|
|
923
|
+
if (migration.taskPath !== null) await unlink(migration.taskPath);
|
|
924
|
+
await rmdir(migration.taskDirectory);
|
|
817
925
|
}
|
|
818
|
-
|
|
819
|
-
(task) =>
|
|
820
|
-
(statusSet.size === 0 || statusSet.has(task.status)) &&
|
|
821
|
-
(projectPath === null || task.project === projectPath),
|
|
822
|
-
);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
export async function buildTaskSummary(workspaceRoot) {
|
|
826
|
-
const tasks = await listTasks(workspaceRoot);
|
|
926
|
+
await rmdir(inspection.tasksPath);
|
|
827
927
|
return {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
statusCounts: Object.fromEntries(
|
|
831
|
-
[...TASK_STATUSES].map((status) => [status, tasks.filter((task) => task.status === status).length]),
|
|
832
|
-
),
|
|
928
|
+
action: inspection.action,
|
|
929
|
+
migratedCount: migrations.filter((item) => item.dispatch).length,
|
|
833
930
|
};
|
|
834
931
|
}
|
|
835
932
|
|
|
@@ -844,17 +941,13 @@ export async function doctorWorkspace(workspaceRoot) {
|
|
|
844
941
|
checks.push({ name, status: "fail", message: error.message });
|
|
845
942
|
}
|
|
846
943
|
};
|
|
847
|
-
let config = null;
|
|
848
944
|
await check("configuration", async () => {
|
|
849
|
-
config = await readConfig(root);
|
|
945
|
+
const config = await readConfig(root);
|
|
850
946
|
return `${config.projects.length} configured project(s) valid`;
|
|
851
947
|
});
|
|
852
|
-
await check("
|
|
853
|
-
const
|
|
854
|
-
|
|
855
|
-
throw new Error("tasks path is not a real directory");
|
|
856
|
-
}
|
|
857
|
-
return "tasks directory ready";
|
|
948
|
+
await check("task-log", async () => {
|
|
949
|
+
const dispatches = await listTasks(root);
|
|
950
|
+
return `${dispatches.length} task record(s) valid`;
|
|
858
951
|
});
|
|
859
952
|
await check("instructions", async () => {
|
|
860
953
|
const filePath = path.join(root, "AGENTS.md");
|
|
@@ -872,40 +965,11 @@ export async function doctorWorkspace(workspaceRoot) {
|
|
|
872
965
|
}
|
|
873
966
|
return "no legacy TaskChef skill links";
|
|
874
967
|
});
|
|
875
|
-
await check("
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
let count = 0;
|
|
879
|
-
for (const entry of entries) {
|
|
880
|
-
if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
|
|
881
|
-
if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
|
|
882
|
-
const task = await readTask(root, entry.name);
|
|
883
|
-
if (task.id !== entry.name) throw new Error(`task ID does not match directory: ${entry.name}`);
|
|
884
|
-
if (config && !config.projects.some((project) => project.path === task.project)) {
|
|
885
|
-
throw new Error(`task ${task.id} references an unconfigured project`);
|
|
886
|
-
}
|
|
887
|
-
count += 1;
|
|
968
|
+
await check("legacy-tasks", async () => {
|
|
969
|
+
if (await pathExists(path.join(root, "tasks"))) {
|
|
970
|
+
throw new Error("legacy tasks directory remains; run workspace init to migrate it");
|
|
888
971
|
}
|
|
889
|
-
return
|
|
972
|
+
return "no legacy task records";
|
|
890
973
|
});
|
|
891
974
|
return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
|
|
892
975
|
}
|
|
893
|
-
|
|
894
|
-
export async function buildReconciliationCandidates(
|
|
895
|
-
workspaceRoot,
|
|
896
|
-
{ includeFinished = false } = {},
|
|
897
|
-
) {
|
|
898
|
-
const includedStatuses = includeFinished
|
|
899
|
-
? ["running", "blocked", "finished"]
|
|
900
|
-
: ["running", "blocked"];
|
|
901
|
-
const includedStatusSet = new Set(includedStatuses);
|
|
902
|
-
const tasks = (await listTasks(workspaceRoot)).filter(
|
|
903
|
-
(task) => task.threadId !== null && includedStatusSet.has(task.status),
|
|
904
|
-
);
|
|
905
|
-
return {
|
|
906
|
-
schemaVersion: 1,
|
|
907
|
-
candidateCount: tasks.length,
|
|
908
|
-
includedStatuses,
|
|
909
|
-
tasks,
|
|
910
|
-
};
|
|
911
|
-
}
|