taskchef 1.0.2 → 3.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.
package/src/workspace.js CHANGED
@@ -4,13 +4,12 @@ import {
4
4
  lstat,
5
5
  mkdir,
6
6
  readFile,
7
- readlink,
8
7
  readdir,
9
8
  realpath,
10
9
  link,
11
10
  rename,
11
+ rmdir,
12
12
  stat,
13
- symlink,
14
13
  unlink,
15
14
  writeFile,
16
15
  } from "node:fs/promises";
@@ -18,6 +17,7 @@ import { randomUUID } from "node:crypto";
18
17
  import { fileURLToPath } from "node:url";
19
18
  import path from "node:path";
20
19
  import { promisify } from "node:util";
20
+ import lockfile from "proper-lockfile";
21
21
 
22
22
  const execFile = promisify(execFileCallback);
23
23
  const DISPATCHER_INSTRUCTIONS_URL = new URL(
@@ -29,9 +29,12 @@ const DISPATCHER_INSTRUCTIONS_END = "<!-- taskchef:dispatcher-instructions:end -
29
29
  const TASKCHEF_SKILL_NAMES = [
30
30
  "taskchef-bootstrap",
31
31
  "taskchef-delegate",
32
- "taskchef-reconcile",
32
+ "taskchef-report",
33
33
  ];
34
+ const LEGACY_TASKCHEF_SKILL_NAMES = [...TASKCHEF_SKILL_NAMES, "taskchef-reconcile"];
34
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";
35
38
  const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
36
39
  const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
37
40
  const PROJECT_FIELDS = new Set([
@@ -42,27 +45,22 @@ const PROJECT_FIELDS = new Set([
42
45
  "description",
43
46
  ]);
44
47
  const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepo", "description"]);
45
- const TASK_FIELDS = new Set([
48
+ const DISPATCH_FIELDS = new Set([
46
49
  "schemaVersion",
47
50
  "id",
48
51
  "project",
49
52
  "title",
50
53
  "instruction",
51
- "status",
52
54
  "threadId",
53
- "result",
54
55
  "createdAt",
55
- "updatedAt",
56
56
  ]);
57
- const RESULT_FIELDS = new Set(["message", "githubPRs", "githubIssues"]);
58
- const CREATE_TASK_FIELDS = new Set(["id", "project", "title", "instruction"]);
59
- const TASK_STATUSES = new Set(["pending", "running", "blocked", "finished"]);
60
- const STATUS_TRANSITIONS = {
61
- pending: new Set(["pending", "running"]),
62
- running: new Set(["running", "blocked", "finished"]),
63
- blocked: new Set(["blocked", "running", "finished"]),
64
- finished: new Set(["finished", "running", "blocked"]),
65
- };
57
+ const RECORD_DISPATCH_FIELDS = new Set([
58
+ "id",
59
+ "project",
60
+ "title",
61
+ "instruction",
62
+ "threadId",
63
+ ]);
66
64
 
67
65
  function requireExactFields(value, fields, name) {
68
66
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -141,6 +139,31 @@ async function managedRegularFileExists(filePath) {
141
139
  return true;
142
140
  }
143
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
+
144
167
  async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
145
168
  await mkdir(path.dirname(filePath), { recursive: true });
146
169
  if (exclusive && (await pathExists(filePath))) {
@@ -244,38 +267,73 @@ export async function ensureWorkspaceInstructions(workspaceRoot) {
244
267
  return { path: filePath, action };
245
268
  }
246
269
 
247
- async function ensureSkillLink(skillsDirectory, skillName) {
248
- const source = path.join(SKILLS_SOURCE_ROOT, skillName);
249
- const destination = path.join(skillsDirectory, skillName);
250
- await canonicalDirectory(source);
251
- const details = await lstat(destination).catch((error) => {
270
+ async function findLegacySkillLinks(workspaceRoot) {
271
+ const agentsDirectory = path.join(workspaceRoot, ".agents");
272
+ const agentsDetails = await lstat(agentsDirectory).catch((error) => {
252
273
  if (error.code === "ENOENT") return null;
253
274
  throw error;
254
275
  });
255
- if (details === null) {
256
- await symlink(source, destination, "dir");
257
- return { name: skillName, path: destination, action: "created" };
276
+ if (agentsDetails === null) return { agentsDirectory: null, skillsDirectory: null, links: [] };
277
+ if (agentsDetails.isSymbolicLink() || !agentsDetails.isDirectory()) {
278
+ return { agentsDirectory: null, skillsDirectory: null, links: [] };
258
279
  }
259
- if (!details.isSymbolicLink()) {
260
- throw new Error(`TaskChef skill path exists and is not a symlink: ${destination}`);
280
+
281
+ const skillsDirectory = path.join(agentsDirectory, "skills");
282
+ const skillsDetails = await lstat(skillsDirectory).catch((error) => {
283
+ if (error.code === "ENOENT") return null;
284
+ throw error;
285
+ });
286
+ if (skillsDetails === null) return { agentsDirectory, skillsDirectory, links: [] };
287
+ if (skillsDetails.isSymbolicLink() || !skillsDetails.isDirectory()) {
288
+ return { agentsDirectory, skillsDirectory: null, links: [] };
261
289
  }
262
- const linked = path.resolve(path.dirname(destination), await readlink(destination));
263
- if (linked !== source) {
264
- await unlink(destination);
265
- await symlink(source, destination, "dir");
266
- return { name: skillName, path: destination, action: "updated" };
290
+
291
+ const links = [];
292
+ for (const skillName of LEGACY_TASKCHEF_SKILL_NAMES) {
293
+ const skillPath = path.join(skillsDirectory, skillName);
294
+ const details = await lstat(skillPath).catch((error) => {
295
+ if (error.code === "ENOENT") return null;
296
+ throw error;
297
+ });
298
+ if (details === null) continue;
299
+ if (!details.isSymbolicLink()) {
300
+ throw new Error(`legacy TaskChef skill path is not a symlink: ${skillPath}`);
301
+ }
302
+ links.push({ name: skillName, path: skillPath });
267
303
  }
268
- return { name: skillName, path: destination, action: "unchanged" };
304
+ return { agentsDirectory, skillsDirectory, links };
305
+ }
306
+
307
+ async function removeLegacySkillLinks(workspaceRoot) {
308
+ const legacy = await findLegacySkillLinks(workspaceRoot);
309
+ for (const link of legacy.links) await unlink(link.path);
310
+ const removedDirectories = [];
311
+ if (legacy.skillsDirectory && (await readdir(legacy.skillsDirectory)).length === 0) {
312
+ await rmdir(legacy.skillsDirectory);
313
+ removedDirectories.push(legacy.skillsDirectory);
314
+ }
315
+ if (legacy.agentsDirectory && (await readdir(legacy.agentsDirectory).catch((error) => {
316
+ if (error.code === "ENOENT") return null;
317
+ throw error;
318
+ }))?.length === 0) {
319
+ await rmdir(legacy.agentsDirectory);
320
+ removedDirectories.push(legacy.agentsDirectory);
321
+ }
322
+ return { removed: legacy.links, removedDirectories };
269
323
  }
270
324
 
271
325
  export async function ensureWorkspaceSkills(workspaceRoot) {
272
326
  const root = await realpath(path.resolve(workspaceRoot));
273
- const skillsDirectory = await ensureManagedDirectory(root, ".agents", "skills");
274
- const skills = [];
275
- for (const skillName of TASKCHEF_SKILL_NAMES) {
276
- skills.push(await ensureSkillLink(skillsDirectory, skillName));
277
- }
278
- return { directory: skillsDirectory, skills };
327
+ const legacySkills = await removeLegacySkillLinks(root);
328
+ return {
329
+ directory: null,
330
+ skills: TASKCHEF_SKILL_NAMES.map((name) => ({
331
+ name,
332
+ path: path.join(SKILLS_SOURCE_ROOT, name),
333
+ action: "provided-by-plugin",
334
+ })),
335
+ legacySkills,
336
+ };
279
337
  }
280
338
 
281
339
  export async function canonicalGitRoot(projectPath) {
@@ -481,31 +539,44 @@ export async function validateConfig(config, { checkPaths = true } = {}) {
481
539
  };
482
540
  }
483
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
+
484
551
  export async function initializeWorkspace(workspaceRoot) {
485
552
  const requestedRoot = path.resolve(workspaceRoot);
486
553
  await mkdir(requestedRoot, { recursive: true });
487
554
  const root = await realpath(requestedRoot);
488
- await ensureManagedDirectory(root, "tasks");
489
555
  const configPath = path.join(root, "taskchef.json");
490
556
  const configExists = await managedRegularFileExists(configPath);
491
557
  const config = configExists
492
558
  ? await readConfig(root, { checkPaths: false })
493
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);
494
566
  if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
567
+ const tasks = await ensureDispatchFile(root);
568
+ const legacyTasks = await migrateLegacyTaskRecords(root, legacyTaskRecords);
495
569
  const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
496
570
  if (!configExists) await unlink(configPath).catch(() => {});
497
571
  throw error;
498
572
  });
499
- const skills = await ensureWorkspaceSkills(root).catch(async (error) => {
500
- if (!configExists) await unlink(configPath).catch(() => {});
501
- throw error;
502
- });
503
573
  return {
504
574
  workspace: root,
505
575
  config: { path: configPath, action: configExists ? "unchanged" : "created", value: config },
506
- tasks: { path: path.join(root, "tasks"), action: "ready" },
576
+ tasks,
507
577
  instructions,
508
- skills,
578
+ legacySkills,
579
+ legacyTasks,
509
580
  };
510
581
  }
511
582
 
@@ -558,21 +629,6 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
558
629
  else projects[index] = project;
559
630
  }
560
631
  const config = await validateConfig({ schemaVersion: 1, projects });
561
- if (replace) {
562
- const currentPaths = new Set(current.projects.map((project) => project.path));
563
- const configuredPaths = new Set(config.projects.map((project) => project.path));
564
- const removedPaths = new Set(
565
- [...currentPaths].filter((projectPath) => !configuredPaths.has(projectPath)),
566
- );
567
- const newlyOrphaned = (await listTasks(root, { checkProjects: false })).filter(
568
- (task) => removedPaths.has(task.project),
569
- );
570
- if (newlyOrphaned.length > 0) {
571
- throw new Error(
572
- `replacement would orphan ${newlyOrphaned.length} task record(s); remove referenced projects with --force first`,
573
- );
574
- }
575
- }
576
632
  await writeJsonAtomic(path.join(root, "taskchef.json"), config);
577
633
  return {
578
634
  mode: replace ? "replace" : "merge",
@@ -582,7 +638,7 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
582
638
  };
583
639
  }
584
640
 
585
- export async function removeProject(workspaceRoot, name, { force = false } = {}) {
641
+ export async function removeProject(workspaceRoot, name) {
586
642
  const root = path.resolve(workspaceRoot);
587
643
  const config = await readConfig(root, { checkPaths: false });
588
644
  const index = config.projects.findIndex(
@@ -590,215 +646,287 @@ export async function removeProject(workspaceRoot, name, { force = false } = {})
590
646
  );
591
647
  if (index === -1) throw new Error(`configured project not found: ${name}`);
592
648
  const [project] = config.projects.slice(index, index + 1);
593
- const referenced = (await listTasks(root, { checkProjects: false })).filter(
594
- (task) => task.project === project.path,
595
- );
596
- if (referenced.length > 0 && !force) {
597
- throw new Error(
598
- `project is referenced by ${referenced.length} task record(s); pass --force to remove it`,
599
- );
600
- }
601
649
  const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
602
650
  await writeJsonAtomic(path.join(root, "taskchef.json"), { schemaVersion: 1, projects });
603
- return { project, referencedTaskCount: referenced.length };
651
+ return { project };
604
652
  }
605
653
 
606
- function validateGithubUrl(value, kind, name) {
607
- requireString(value, name);
608
- let url;
609
- try {
610
- url = new URL(value);
611
- } catch {
612
- throw new Error(`${name} must be a canonical GitHub URL`);
613
- }
614
- const segment = kind === "pull" ? "pull" : "issues";
615
- const pattern = new RegExp(`^/[^/]+/[^/]+/${segment}/[1-9]\\d*$`);
616
- if (
617
- url.protocol !== "https:" ||
618
- url.hostname !== "github.com" ||
619
- url.username ||
620
- url.password ||
621
- url.port ||
622
- url.search ||
623
- url.hash ||
624
- !pattern.test(url.pathname)
625
- ) {
626
- throw new Error(`${name} must be a canonical GitHub ${kind} URL`);
627
- }
628
- return value;
629
- }
630
-
631
- export function validateResult(result) {
632
- if (result === null) return null;
633
- requireExactFields(result, RESULT_FIELDS, "result");
634
- requireString(result.message, "result.message");
635
- for (const field of ["githubPRs", "githubIssues"]) {
636
- if (!Array.isArray(result[field])) throw new Error(`result.${field} must be an array`);
637
- }
638
- result.githubPRs.forEach((value, index) =>
639
- validateGithubUrl(value, "pull", `result.githubPRs[${index}]`));
640
- result.githubIssues.forEach((value, index) =>
641
- 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 });
642
659
  return {
643
- message: result.message,
644
- githubPRs: [...result.githubPRs],
645
- githubIssues: [...result.githubIssues],
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`),
646
667
  };
647
668
  }
648
669
 
649
- function validateTaskShape(task) {
650
- requireExactFields(task, TASK_FIELDS, "task");
651
- if (task.schemaVersion !== 1) throw new Error("unsupported task schemaVersion");
652
- requireSafeId(task.id);
653
- requireString(task.project, "project");
654
- requireString(task.title, "title");
655
- requireString(task.instruction, "instruction");
656
- if (!TASK_STATUSES.has(task.status)) throw new Error(`unsupported task status: ${task.status}`);
657
- if (task.threadId !== null) requireString(task.threadId, "threadId");
658
- if (task.status === "pending" && task.threadId !== null) {
659
- 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}`);
660
675
  }
661
- if (task.status !== "pending" && task.threadId === null) {
662
- throw new Error(`a ${task.status} task requires a threadId`);
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`);
663
679
  }
664
- validateResult(task.result);
665
- requireTimestamp(task.createdAt, "createdAt");
666
- requireTimestamp(task.updatedAt, "updatedAt");
667
- if (Date.parse(task.updatedAt) < Date.parse(task.createdAt)) {
668
- throw new Error("updatedAt must not be earlier than createdAt");
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);
669
703
  }
670
- return task;
704
+ return dispatches;
705
+ }
706
+
707
+ export async function listTasks(workspaceRoot) {
708
+ const root = await realpath(path.resolve(workspaceRoot));
709
+ return readDispatchesUnlocked(root);
671
710
  }
672
711
 
673
- export async function createTask(workspaceRoot, input, { now } = {}) {
712
+ export async function recordTask(workspaceRoot, input, { now } = {}) {
713
+ requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
674
714
  const root = await realpath(path.resolve(workspaceRoot));
675
- requireExactFields(input, CREATE_TASK_FIELDS, "task creation input");
676
715
  const config = await readConfig(root);
677
- const id = requireSafeId(input.id);
678
- const project = await canonicalDirectory(input.project);
679
- if (!config.projects.some((configuredProject) => configuredProject.path === project)) {
680
- throw new Error(`project is not configured in taskchef.json: ${project}`);
681
- }
682
- const createdAt = now ?? new Date().toISOString();
683
- requireTimestamp(createdAt, "createdAt");
684
- 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({
685
720
  schemaVersion: 1,
686
- id,
721
+ id: input.id,
687
722
  project,
688
- title: requireString(input.title, "title"),
689
- instruction: requireString(input.instruction, "instruction"),
690
- status: "pending",
691
- threadId: null,
692
- result: null,
693
- createdAt,
694
- updatedAt: createdAt,
695
- };
696
- const tasksDirectory = await ensureManagedDirectory(root, "tasks");
697
- const taskDirectory = path.join(tasksDirectory, id);
698
- if (await lstat(taskDirectory).catch((error) => {
699
- if (error.code === "ENOENT") return null;
700
- throw error;
701
- })) throw new Error(`task already exists: ${id}`);
702
- await mkdir(taskDirectory, { recursive: false });
703
- await writeJsonAtomic(path.join(taskDirectory, "task.json"), task, { exclusive: true });
704
- 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;
705
739
  }
706
740
 
707
741
  export async function readTask(workspaceRoot, taskId) {
708
742
  const id = requireSafeId(taskId, "taskId");
709
- const root = await realpath(path.resolve(workspaceRoot));
710
- const tasksDirectory = await ensureManagedDirectory(root, "tasks");
711
- const taskDirectory = path.join(tasksDirectory, id);
712
- const taskDirectoryDetails = await lstat(taskDirectory);
713
- if (taskDirectoryDetails.isSymbolicLink() || !taskDirectoryDetails.isDirectory()) {
714
- throw new Error(`task path is not a real directory: ${taskDirectory}`);
715
- }
716
- const filePath = path.join(taskDirectory, "task.json");
717
- const fileDetails = await lstat(filePath);
718
- if (fileDetails.isSymbolicLink() || !fileDetails.isFile()) {
719
- throw new Error(`task record is not a regular file: ${filePath}`);
720
- }
721
- const task = validateTaskShape(JSON.parse(await readFile(filePath, "utf8")));
722
- if (task.id !== id) throw new Error(`task ID does not match directory: ${id}`);
723
- return task;
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;
724
758
  }
725
759
 
726
- export async function updateTask(workspaceRoot, taskId, patch, { now } = {}) {
727
- const allowed = new Set(["status", "threadId", "result"]);
728
- const unexpected = Object.keys(patch).find((key) => !allowed.has(key));
729
- if (unexpected) throw new Error(`unsupported task update field: ${unexpected}`);
730
- if (Object.keys(patch).length === 0) throw new Error("task update must not be empty");
731
- const current = await readTask(workspaceRoot, taskId);
732
- const status = patch.status ?? current.status;
733
- if (!TASK_STATUSES.has(status)) throw new Error(`unsupported task status: ${status}`);
734
- if (!STATUS_TRANSITIONS[current.status].has(status)) {
735
- throw new Error(`unsupported task transition: ${current.status} -> ${status}`);
736
- }
737
- const threadId = patch.threadId === undefined ? current.threadId : patch.threadId;
738
- if (threadId !== null) requireString(threadId, "threadId");
739
- if (current.threadId && threadId !== current.threadId) {
740
- throw new Error("threadId cannot be replaced once recorded");
741
- }
742
- const updated = {
743
- ...current,
744
- status,
745
- threadId,
746
- result: patch.result === undefined ? current.result : validateResult(patch.result),
747
- updatedAt: now ?? new Date().toISOString(),
748
- };
749
- requireTimestamp(updated.updatedAt, "updatedAt");
750
- if (Date.parse(updated.updatedAt) < Date.parse(current.updatedAt)) {
751
- 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
+ );
752
768
  }
753
- validateTaskShape(updated);
754
- const filePath = path.join(path.resolve(workspaceRoot), "tasks", current.id, "task.json");
755
- await writeJsonAtomic(filePath, updated);
756
- return updated;
769
+ return {
770
+ schemaVersion: 1,
771
+ taskCount: dispatches.length,
772
+ projectCounts: Object.fromEntries(
773
+ [...projectCounts.entries()].sort(([left], [right]) => left.localeCompare(right)),
774
+ ),
775
+ };
757
776
  }
758
777
 
759
- export async function listTasks(workspaceRoot, { checkProjects = true } = {}) {
760
- const root = await realpath(path.resolve(workspaceRoot));
761
- await readConfig(root, { checkPaths: checkProjects });
762
- const tasksDirectory = await ensureManagedDirectory(root, "tasks");
763
- const entries = await readdir(tasksDirectory, { withFileTypes: true });
764
- const tasks = [];
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();
765
820
  for (const entry of entries) {
766
- if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
767
- if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
768
- tasks.push(await readTask(root, entry.name));
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 });
769
877
  }
770
- return tasks.sort((left, right) => left.id.localeCompare(right.id));
878
+ return {
879
+ tasksPath,
880
+ entries,
881
+ records,
882
+ action: entries.length === 0 ? "removed-empty" : "migrated",
883
+ };
771
884
  }
772
885
 
773
- export async function filterTasks(workspaceRoot, { statuses = [], project = null } = {}) {
774
- const config = await readConfig(workspaceRoot);
775
- const statusSet = new Set(statuses);
776
- for (const status of statusSet) {
777
- if (!TASK_STATUSES.has(status)) throw new Error(`unsupported task status: ${status}`);
778
- }
779
- let projectPath = null;
780
- if (project !== null) {
781
- const configured = config.projects.find(
782
- (candidate) => candidate.name.toLowerCase() === project.toLowerCase() || candidate.path === project,
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),
783
919
  );
784
- if (!configured) throw new Error(`configured project not found: ${project}`);
785
- projectPath = configured.path;
920
+ return pending;
921
+ });
922
+ for (const migration of migrations) {
923
+ if (migration.taskPath !== null) await unlink(migration.taskPath);
924
+ await rmdir(migration.taskDirectory);
786
925
  }
787
- return (await listTasks(workspaceRoot)).filter(
788
- (task) =>
789
- (statusSet.size === 0 || statusSet.has(task.status)) &&
790
- (projectPath === null || task.project === projectPath),
791
- );
792
- }
793
-
794
- export async function buildTaskSummary(workspaceRoot) {
795
- const tasks = await listTasks(workspaceRoot);
926
+ await rmdir(inspection.tasksPath);
796
927
  return {
797
- schemaVersion: 1,
798
- taskCount: tasks.length,
799
- statusCounts: Object.fromEntries(
800
- [...TASK_STATUSES].map((status) => [status, tasks.filter((task) => task.status === status).length]),
801
- ),
928
+ action: inspection.action,
929
+ migratedCount: migrations.filter((item) => item.dispatch).length,
802
930
  };
803
931
  }
804
932
 
@@ -813,17 +941,13 @@ export async function doctorWorkspace(workspaceRoot) {
813
941
  checks.push({ name, status: "fail", message: error.message });
814
942
  }
815
943
  };
816
- let config = null;
817
944
  await check("configuration", async () => {
818
- config = await readConfig(root);
945
+ const config = await readConfig(root);
819
946
  return `${config.projects.length} configured project(s) valid`;
820
947
  });
821
- await check("tasks-directory", async () => {
822
- const details = await lstat(path.join(root, "tasks"));
823
- if (details.isSymbolicLink() || !details.isDirectory()) {
824
- throw new Error("tasks path is not a real directory");
825
- }
826
- return "tasks directory ready";
948
+ await check("task-log", async () => {
949
+ const dispatches = await listTasks(root);
950
+ return `${dispatches.length} task record(s) valid`;
827
951
  });
828
952
  await check("instructions", async () => {
829
953
  const filePath = path.join(root, "AGENTS.md");
@@ -834,52 +958,18 @@ export async function doctorWorkspace(workspaceRoot) {
834
958
  }
835
959
  return "managed AGENTS.md instructions current";
836
960
  });
837
- for (const skillName of TASKCHEF_SKILL_NAMES) {
838
- await check(`skill:${skillName}`, async () => {
839
- const destination = path.join(root, ".agents", "skills", skillName);
840
- const details = await lstat(destination);
841
- if (!details.isSymbolicLink()) throw new Error("skill path is not a symlink");
842
- const linked = path.resolve(path.dirname(destination), await readlink(destination));
843
- const expected = path.join(SKILLS_SOURCE_ROOT, skillName);
844
- if (linked !== expected) throw new Error(`unexpected target: ${linked}`);
845
- await canonicalDirectory(expected);
846
- return "skill link valid";
847
- });
848
- }
849
- await check("task-records", async () => {
850
- const taskRoot = path.join(root, "tasks");
851
- const entries = await readdir(taskRoot, { withFileTypes: true });
852
- let count = 0;
853
- for (const entry of entries) {
854
- if (!entry.isDirectory()) throw new Error(`unexpected task entry: ${entry.name}`);
855
- if (!SAFE_ID.test(entry.name)) throw new Error(`invalid task directory name: ${entry.name}`);
856
- const task = await readTask(root, entry.name);
857
- if (task.id !== entry.name) throw new Error(`task ID does not match directory: ${entry.name}`);
858
- if (config && !config.projects.some((project) => project.path === task.project)) {
859
- throw new Error(`task ${task.id} references an unconfigured project`);
860
- }
861
- count += 1;
961
+ await check("legacy-skill-links", async () => {
962
+ const legacy = await findLegacySkillLinks(root);
963
+ if (legacy.links.length > 0) {
964
+ throw new Error("legacy TaskChef skill links remain; run workspace init to remove them");
862
965
  }
863
- return `${count} task record(s) valid`;
966
+ return "no legacy TaskChef skill links";
967
+ });
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");
971
+ }
972
+ return "no legacy task records";
864
973
  });
865
974
  return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
866
975
  }
867
-
868
- export async function buildReconciliationCandidates(
869
- workspaceRoot,
870
- { includeFinished = false } = {},
871
- ) {
872
- const includedStatuses = includeFinished
873
- ? ["running", "blocked", "finished"]
874
- : ["running", "blocked"];
875
- const includedStatusSet = new Set(includedStatuses);
876
- const tasks = (await listTasks(workspaceRoot)).filter(
877
- (task) => task.threadId !== null && includedStatusSet.has(task.status),
878
- );
879
- return {
880
- schemaVersion: 1,
881
- candidateCount: tasks.length,
882
- includedStatuses,
883
- tasks,
884
- };
885
- }