taskchef 3.0.2 → 4.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/.codex-plugin/plugin.json +1 -1
- package/BACKLOG.md +15 -0
- package/README.md +48 -16
- package/SPEC.md +111 -44
- package/assets/taskchef-dispatcher-instructions.md +2 -0
- package/index.js +24 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +29 -8
- package/skills/taskchef-delegate/SKILL.md +85 -14
- package/skills/taskchef-report/SKILL.md +20 -10
- package/src/cli.js +33 -5
- package/src/delegation.js +555 -0
- package/src/github.js +108 -0
- package/src/workspace.js +152 -240
package/src/workspace.js
CHANGED
|
@@ -18,6 +18,11 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { promisify } from "node:util";
|
|
20
20
|
import lockfile from "proper-lockfile";
|
|
21
|
+
import { normalizeDurableThreadId, parseTaskChefMarker } from "./delegation.js";
|
|
22
|
+
import {
|
|
23
|
+
canonicalGithubRepository,
|
|
24
|
+
normalizeGithubRepositories,
|
|
25
|
+
} from "./github.js";
|
|
21
26
|
|
|
22
27
|
const execFile = promisify(execFileCallback);
|
|
23
28
|
const DISPATCHER_INSTRUCTIONS_URL = new URL(
|
|
@@ -36,15 +41,18 @@ const SKILLS_SOURCE_ROOT = fileURLToPath(new URL("../skills/", import.meta.url))
|
|
|
36
41
|
const DISPATCH_FILE_NAME = "tasks.jsonl";
|
|
37
42
|
const DISPATCH_LOCK_NAME = ".taskchef-dispatch.lock";
|
|
38
43
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
44
|
+
const CURRENT_SCHEMA_VERSION = 2;
|
|
45
|
+
const LEGACY_SCHEMA_VERSION = 1;
|
|
39
46
|
const CONFIG_FIELDS = new Set(["schemaVersion", "projects"]);
|
|
40
47
|
const PROJECT_FIELDS = new Set([
|
|
41
48
|
"name",
|
|
42
49
|
"path",
|
|
43
50
|
"isGitRepository",
|
|
44
51
|
"githubRepo",
|
|
52
|
+
"githubRepos",
|
|
45
53
|
"description",
|
|
46
54
|
]);
|
|
47
|
-
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "
|
|
55
|
+
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
|
|
48
56
|
const DISPATCH_FIELDS = new Set([
|
|
49
57
|
"schemaVersion",
|
|
50
58
|
"id",
|
|
@@ -164,6 +172,12 @@ async function appendDispatchesAtomic(workspaceRoot, dispatches) {
|
|
|
164
172
|
await writeTextAtomic(dispatchPath, `${content}${appended}`);
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
async function writeDispatchLinesAtomic(workspaceRoot, lines) {
|
|
176
|
+
const dispatchPath = path.join(workspaceRoot, DISPATCH_FILE_NAME);
|
|
177
|
+
const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`;
|
|
178
|
+
await writeTextAtomic(dispatchPath, content);
|
|
179
|
+
}
|
|
180
|
+
|
|
167
181
|
async function writeJsonAtomic(filePath, value, { exclusive = false } = {}) {
|
|
168
182
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
169
183
|
if (exclusive && (await pathExists(filePath))) {
|
|
@@ -362,39 +376,23 @@ export async function canonicalDirectory(projectPath) {
|
|
|
362
376
|
return requested;
|
|
363
377
|
}
|
|
364
378
|
|
|
365
|
-
function
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
url = new URL(value);
|
|
371
|
-
} catch {
|
|
372
|
-
throw new Error(`${name} must be a canonical GitHub repository URL or null`);
|
|
373
|
-
}
|
|
374
|
-
if (
|
|
375
|
-
url.protocol !== "https:" ||
|
|
376
|
-
url.hostname !== "github.com" ||
|
|
377
|
-
url.username ||
|
|
378
|
-
url.password ||
|
|
379
|
-
url.port ||
|
|
380
|
-
url.search ||
|
|
381
|
-
url.hash ||
|
|
382
|
-
!/^\/[^/]+\/[^/]+$/.test(url.pathname) ||
|
|
383
|
-
url.pathname.endsWith(".git")
|
|
384
|
-
) {
|
|
385
|
-
throw new Error(`${name} must be a canonical GitHub repository URL or null`);
|
|
386
|
-
}
|
|
387
|
-
return value;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
async function normalizeProject(project, index, { checkPath = true } = {}) {
|
|
379
|
+
async function normalizeProject(
|
|
380
|
+
project,
|
|
381
|
+
index,
|
|
382
|
+
{ checkPath = true, allowLegacyGithubRepo = false } = {},
|
|
383
|
+
) {
|
|
391
384
|
const field = `projects[${index}]`;
|
|
392
385
|
if (!project || typeof project !== "object" || Array.isArray(project)) {
|
|
393
386
|
throw new Error(`${field} must be an object`);
|
|
394
387
|
}
|
|
395
388
|
const unexpected = Object.keys(project).find((key) => !PROJECT_FIELDS.has(key));
|
|
396
389
|
if (unexpected) throw new Error(`${field} has unsupported field: ${unexpected}`);
|
|
397
|
-
|
|
390
|
+
const repositoryField = allowLegacyGithubRepo ? "githubRepo" : "githubRepos";
|
|
391
|
+
const unsupportedRepositoryField = allowLegacyGithubRepo ? "githubRepos" : "githubRepo";
|
|
392
|
+
if (unsupportedRepositoryField in project) {
|
|
393
|
+
throw new Error(`${field} has unsupported field: ${unsupportedRepositoryField}`);
|
|
394
|
+
}
|
|
395
|
+
for (const required of ["name", "path", "isGitRepository", repositoryField]) {
|
|
398
396
|
if (!(required in project)) throw new Error(`${field} is missing field: ${required}`);
|
|
399
397
|
}
|
|
400
398
|
const name = requireString(project.name, `${field}.name`).trim();
|
|
@@ -412,15 +410,14 @@ async function normalizeProject(project, index, { checkPath = true } = {}) {
|
|
|
412
410
|
throw new Error(`${field}.path must be a normalized absolute path`);
|
|
413
411
|
}
|
|
414
412
|
}
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
413
|
+
const githubRepos = normalizeGithubRepositories(project[repositoryField], `${field}.${repositoryField}`, {
|
|
414
|
+
allowLegacyScalar: allowLegacyGithubRepo,
|
|
415
|
+
});
|
|
419
416
|
const normalized = {
|
|
420
417
|
name,
|
|
421
418
|
path: projectPath,
|
|
422
419
|
isGitRepository: project.isGitRepository,
|
|
423
|
-
|
|
420
|
+
githubRepos,
|
|
424
421
|
};
|
|
425
422
|
if ("description" in project) {
|
|
426
423
|
normalized.description = requireString(
|
|
@@ -431,11 +428,17 @@ async function normalizeProject(project, index, { checkPath = true } = {}) {
|
|
|
431
428
|
return normalized;
|
|
432
429
|
}
|
|
433
430
|
|
|
434
|
-
async function normalizeProjects(
|
|
431
|
+
async function normalizeProjects(
|
|
432
|
+
projects,
|
|
433
|
+
{ checkPaths = true, allowLegacyGithubRepo = false } = {},
|
|
434
|
+
) {
|
|
435
435
|
if (!Array.isArray(projects)) throw new Error("projects must be an array");
|
|
436
436
|
const normalized = [];
|
|
437
437
|
for (const [index, project] of projects.entries()) {
|
|
438
|
-
normalized.push(await normalizeProject(project, index, {
|
|
438
|
+
normalized.push(await normalizeProject(project, index, {
|
|
439
|
+
checkPath: checkPaths,
|
|
440
|
+
allowLegacyGithubRepo,
|
|
441
|
+
}));
|
|
439
442
|
}
|
|
440
443
|
if (new Set(normalized.map((project) => project.path)).size !== normalized.length) {
|
|
441
444
|
throw new Error("project paths must not contain duplicates");
|
|
@@ -451,16 +454,8 @@ async function normalizeProjects(projects, { checkPaths = true } = {}) {
|
|
|
451
454
|
|
|
452
455
|
function normalizeGithubRemote(remote) {
|
|
453
456
|
if (typeof remote !== "string" || remote.trim().length === 0) return null;
|
|
454
|
-
const value = remote.trim();
|
|
455
|
-
const scpMatch = value.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
|
|
456
|
-
if (scpMatch) return `https://github.com/${scpMatch[1]}/${scpMatch[2]}`;
|
|
457
|
-
const sshMatch = value.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
458
|
-
if (sshMatch) return `https://github.com/${sshMatch[1]}/${sshMatch[2]}`;
|
|
459
457
|
try {
|
|
460
|
-
|
|
461
|
-
if (url.hostname !== "github.com") return null;
|
|
462
|
-
const match = url.pathname.match(/^\/([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
463
|
-
return match ? `https://github.com/${match[1]}/${match[2]}` : null;
|
|
458
|
+
return canonicalGithubRepository(remote, "GitHub origin");
|
|
464
459
|
} catch {
|
|
465
460
|
return null;
|
|
466
461
|
}
|
|
@@ -500,10 +495,10 @@ async function inspectProject(input, index = 0) {
|
|
|
500
495
|
if (isGitRepository && gitRoot !== projectPath) {
|
|
501
496
|
throw new Error(`project must be the Git repository root: ${projectPath}`);
|
|
502
497
|
}
|
|
503
|
-
let
|
|
498
|
+
let githubRepos = [];
|
|
504
499
|
if (isGitRepository) {
|
|
505
|
-
if ("
|
|
506
|
-
|
|
500
|
+
if ("githubRepos" in input) {
|
|
501
|
+
githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
|
|
507
502
|
} else {
|
|
508
503
|
const remote = await execFile("git", ["remote", "get-url", "origin"], {
|
|
509
504
|
cwd: projectPath,
|
|
@@ -511,10 +506,11 @@ async function inspectProject(input, index = 0) {
|
|
|
511
506
|
if (error.code === 2 && /No such remote/i.test(error.stderr ?? "")) return null;
|
|
512
507
|
throw gitInspectionError("failed to inspect GitHub origin", error);
|
|
513
508
|
});
|
|
514
|
-
|
|
509
|
+
const detected = normalizeGithubRemote(remote);
|
|
510
|
+
githubRepos = detected === null ? [] : [detected];
|
|
515
511
|
}
|
|
516
|
-
} else if ("
|
|
517
|
-
|
|
512
|
+
} else if ("githubRepos" in input) {
|
|
513
|
+
githubRepos = normalizeGithubRepositories(input.githubRepos, `${field}.githubRepos`);
|
|
518
514
|
}
|
|
519
515
|
const project = {
|
|
520
516
|
name: "name" in input
|
|
@@ -522,7 +518,7 @@ async function inspectProject(input, index = 0) {
|
|
|
522
518
|
: path.basename(projectPath),
|
|
523
519
|
path: projectPath,
|
|
524
520
|
isGitRepository,
|
|
525
|
-
|
|
521
|
+
githubRepos,
|
|
526
522
|
};
|
|
527
523
|
if ("description" in input) {
|
|
528
524
|
project.description = requireString(input.description, `${field}.description`).trim();
|
|
@@ -532,10 +528,15 @@ async function inspectProject(input, index = 0) {
|
|
|
532
528
|
|
|
533
529
|
export async function validateConfig(config, { checkPaths = true } = {}) {
|
|
534
530
|
requireExactFields(config, CONFIG_FIELDS, "taskchef.json");
|
|
535
|
-
if (config.schemaVersion
|
|
531
|
+
if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(config.schemaVersion)) {
|
|
532
|
+
throw new Error("unsupported configuration schemaVersion");
|
|
533
|
+
}
|
|
536
534
|
return {
|
|
537
|
-
schemaVersion:
|
|
538
|
-
projects: await normalizeProjects(config.projects, {
|
|
535
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
536
|
+
projects: await normalizeProjects(config.projects, {
|
|
537
|
+
checkPaths,
|
|
538
|
+
allowLegacyGithubRepo: config.schemaVersion === LEGACY_SCHEMA_VERSION,
|
|
539
|
+
}),
|
|
539
540
|
};
|
|
540
541
|
}
|
|
541
542
|
|
|
@@ -554,29 +555,38 @@ export async function initializeWorkspace(workspaceRoot) {
|
|
|
554
555
|
const root = await realpath(requestedRoot);
|
|
555
556
|
const configPath = path.join(root, "taskchef.json");
|
|
556
557
|
const configExists = await managedRegularFileExists(configPath);
|
|
558
|
+
const storedConfigVersion = configExists
|
|
559
|
+
? JSON.parse(await readFile(configPath, "utf8")).schemaVersion
|
|
560
|
+
: null;
|
|
557
561
|
const config = configExists
|
|
558
562
|
? await readConfig(root, { checkPaths: false })
|
|
559
|
-
: { schemaVersion:
|
|
563
|
+
: { schemaVersion: CURRENT_SCHEMA_VERSION, projects: [] };
|
|
560
564
|
const dispatchPath = path.join(root, DISPATCH_FILE_NAME);
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
const legacyTaskRecords = await inspectLegacyTaskRecords(root, config, existingDispatches);
|
|
565
|
+
if (configExists && (await managedRegularFileExists(dispatchPath))) {
|
|
566
|
+
await readDispatchesUnlocked(root);
|
|
567
|
+
}
|
|
565
568
|
const { legacySkills } = await ensureWorkspaceSkills(root);
|
|
566
569
|
if (!configExists) await writeJsonAtomic(configPath, config, { exclusive: true });
|
|
570
|
+
else if (storedConfigVersion !== CURRENT_SCHEMA_VERSION) {
|
|
571
|
+
await writeJsonAtomic(configPath, config);
|
|
572
|
+
}
|
|
567
573
|
const tasks = await ensureDispatchFile(root);
|
|
568
|
-
const legacyTasks = await migrateLegacyTaskRecords(root, legacyTaskRecords);
|
|
569
574
|
const instructions = await ensureWorkspaceInstructions(root).catch(async (error) => {
|
|
570
575
|
if (!configExists) await unlink(configPath).catch(() => {});
|
|
571
576
|
throw error;
|
|
572
577
|
});
|
|
573
578
|
return {
|
|
574
579
|
workspace: root,
|
|
575
|
-
config: {
|
|
580
|
+
config: {
|
|
581
|
+
path: configPath,
|
|
582
|
+
action: !configExists
|
|
583
|
+
? "created"
|
|
584
|
+
: storedConfigVersion === CURRENT_SCHEMA_VERSION ? "unchanged" : "migrated",
|
|
585
|
+
value: config,
|
|
586
|
+
},
|
|
576
587
|
tasks,
|
|
577
588
|
instructions,
|
|
578
589
|
legacySkills,
|
|
579
|
-
legacyTasks,
|
|
580
590
|
};
|
|
581
591
|
}
|
|
582
592
|
|
|
@@ -600,7 +610,7 @@ export async function addProject(workspaceRoot, input) {
|
|
|
600
610
|
const config = await readConfig(root);
|
|
601
611
|
const project = await inspectProject(input);
|
|
602
612
|
const updated = await validateConfig({
|
|
603
|
-
schemaVersion:
|
|
613
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
604
614
|
projects: [...config.projects, project],
|
|
605
615
|
});
|
|
606
616
|
await writeJsonAtomic(path.join(root, "taskchef.json"), updated);
|
|
@@ -620,6 +630,12 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
620
630
|
if (!("description" in mergedInput) && existing?.description) {
|
|
621
631
|
mergedInput.description = existing.description;
|
|
622
632
|
}
|
|
633
|
+
if (existing && !replace) {
|
|
634
|
+
const importedRepositories = "githubRepos" in mergedInput
|
|
635
|
+
? normalizeGithubRepositories(mergedInput.githubRepos, `projects[${index}].githubRepos`)
|
|
636
|
+
: [];
|
|
637
|
+
mergedInput.githubRepos = [...existing.githubRepos, ...importedRepositories];
|
|
638
|
+
}
|
|
623
639
|
imported.push(await inspectProject(mergedInput, index));
|
|
624
640
|
}
|
|
625
641
|
const projects = replace ? [] : [...current.projects];
|
|
@@ -628,7 +644,7 @@ export async function importProjects(workspaceRoot, inputs, { replace = false }
|
|
|
628
644
|
if (index === -1) projects.push(project);
|
|
629
645
|
else projects[index] = project;
|
|
630
646
|
}
|
|
631
|
-
const config = await validateConfig({ schemaVersion:
|
|
647
|
+
const config = await validateConfig({ schemaVersion: CURRENT_SCHEMA_VERSION, projects });
|
|
632
648
|
await writeJsonAtomic(path.join(root, "taskchef.json"), config);
|
|
633
649
|
return {
|
|
634
650
|
mode: replace ? "replace" : "merge",
|
|
@@ -647,27 +663,44 @@ export async function removeProject(workspaceRoot, name) {
|
|
|
647
663
|
if (index === -1) throw new Error(`configured project not found: ${name}`);
|
|
648
664
|
const [project] = config.projects.slice(index, index + 1);
|
|
649
665
|
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
650
|
-
await writeJsonAtomic(path.join(root, "taskchef.json"), {
|
|
666
|
+
await writeJsonAtomic(path.join(root, "taskchef.json"), {
|
|
667
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
668
|
+
projects,
|
|
669
|
+
});
|
|
651
670
|
return { project };
|
|
652
671
|
}
|
|
653
672
|
|
|
654
673
|
async function validateDispatchShape(dispatch, name = "task") {
|
|
655
674
|
requireExactFields(dispatch, DISPATCH_FIELDS, name);
|
|
656
|
-
if (dispatch.schemaVersion
|
|
675
|
+
if (![LEGACY_SCHEMA_VERSION, CURRENT_SCHEMA_VERSION].includes(dispatch.schemaVersion)) {
|
|
676
|
+
throw new Error(`unsupported ${name} schemaVersion`);
|
|
677
|
+
}
|
|
657
678
|
const id = requireSafeId(dispatch.id, `${name}.id`);
|
|
658
|
-
const project = await normalizeProject(dispatch.project, 0, {
|
|
659
|
-
|
|
660
|
-
|
|
679
|
+
const project = await normalizeProject(dispatch.project, 0, {
|
|
680
|
+
checkPath: false,
|
|
681
|
+
allowLegacyGithubRepo: dispatch.schemaVersion === LEGACY_SCHEMA_VERSION,
|
|
682
|
+
});
|
|
683
|
+
const normalized = {
|
|
684
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
661
685
|
id,
|
|
662
686
|
project,
|
|
663
687
|
title: requireString(dispatch.title, `${name}.title`).trim(),
|
|
664
688
|
instruction: requireString(dispatch.instruction, `${name}.instruction`).trim(),
|
|
665
|
-
threadId:
|
|
689
|
+
threadId: dispatch.threadId === null
|
|
690
|
+
? null
|
|
691
|
+
: normalizeDurableThreadId(dispatch.threadId, `${name}.threadId`),
|
|
666
692
|
createdAt: requireTimestamp(dispatch.createdAt, `${name}.createdAt`),
|
|
667
693
|
};
|
|
694
|
+
if (
|
|
695
|
+
normalized.threadId === null &&
|
|
696
|
+
parseTaskChefMarker(normalized.instruction) !== normalized.id
|
|
697
|
+
) {
|
|
698
|
+
throw new Error(`${name} with a null threadId must contain its exact TaskChef marker`);
|
|
699
|
+
}
|
|
700
|
+
return normalized;
|
|
668
701
|
}
|
|
669
702
|
|
|
670
|
-
async function
|
|
703
|
+
async function readDispatchRecordsUnlocked(root) {
|
|
671
704
|
await readConfig(root, { checkPaths: false });
|
|
672
705
|
const filePath = path.join(root, DISPATCH_FILE_NAME);
|
|
673
706
|
if (!(await managedRegularFileExists(filePath))) {
|
|
@@ -678,7 +711,7 @@ async function readDispatchesUnlocked(root) {
|
|
|
678
711
|
throw new Error(`${DISPATCH_FILE_NAME} must end with a newline`);
|
|
679
712
|
}
|
|
680
713
|
const lines = content.length === 0 ? [] : content.slice(0, -1).split("\n");
|
|
681
|
-
const
|
|
714
|
+
const records = [];
|
|
682
715
|
for (const [index, line] of lines.entries()) {
|
|
683
716
|
if (line.trim().length === 0) {
|
|
684
717
|
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is empty`);
|
|
@@ -689,19 +722,27 @@ async function readDispatchesUnlocked(root) {
|
|
|
689
722
|
} catch (error) {
|
|
690
723
|
throw new Error(`${DISPATCH_FILE_NAME} line ${index + 1} is invalid JSON: ${error.message}`);
|
|
691
724
|
}
|
|
692
|
-
|
|
725
|
+
records.push({
|
|
726
|
+
line,
|
|
727
|
+
raw: value,
|
|
728
|
+
normalized: await validateDispatchShape(value, `task line ${index + 1}`),
|
|
729
|
+
});
|
|
693
730
|
}
|
|
694
731
|
const ids = new Set();
|
|
695
732
|
const threadIds = new Set();
|
|
696
|
-
for (const dispatch of
|
|
733
|
+
for (const { normalized: dispatch } of records) {
|
|
697
734
|
if (ids.has(dispatch.id)) throw new Error(`duplicate task ID: ${dispatch.id}`);
|
|
698
|
-
if (threadIds.has(dispatch.threadId)) {
|
|
735
|
+
if (dispatch.threadId !== null && threadIds.has(dispatch.threadId)) {
|
|
699
736
|
throw new Error(`duplicate task threadId: ${dispatch.threadId}`);
|
|
700
737
|
}
|
|
701
738
|
ids.add(dispatch.id);
|
|
702
|
-
threadIds.add(dispatch.threadId);
|
|
739
|
+
if (dispatch.threadId !== null) threadIds.add(dispatch.threadId);
|
|
703
740
|
}
|
|
704
|
-
return
|
|
741
|
+
return records;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function readDispatchesUnlocked(root) {
|
|
745
|
+
return (await readDispatchRecordsUnlocked(root)).map((record) => record.normalized);
|
|
705
746
|
}
|
|
706
747
|
|
|
707
748
|
export async function listTasks(workspaceRoot) {
|
|
@@ -717,7 +758,7 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
717
758
|
const project = config.projects.find((candidate) => candidate.path === projectPath);
|
|
718
759
|
if (!project) throw new Error(`project is not configured in taskchef.json: ${projectPath}`);
|
|
719
760
|
const dispatch = await validateDispatchShape({
|
|
720
|
-
schemaVersion:
|
|
761
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
721
762
|
id: input.id,
|
|
722
763
|
project,
|
|
723
764
|
title: input.title,
|
|
@@ -730,7 +771,10 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
730
771
|
if (existing.some((item) => item.id === dispatch.id)) {
|
|
731
772
|
throw new Error(`task already exists: ${dispatch.id}`);
|
|
732
773
|
}
|
|
733
|
-
if (
|
|
774
|
+
if (
|
|
775
|
+
dispatch.threadId !== null &&
|
|
776
|
+
existing.some((item) => item.threadId === dispatch.threadId)
|
|
777
|
+
) {
|
|
734
778
|
throw new Error(`threadId is already recorded: ${dispatch.threadId}`);
|
|
735
779
|
}
|
|
736
780
|
await appendDispatchesAtomic(root, [dispatch]);
|
|
@@ -738,6 +782,35 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
738
782
|
return dispatch;
|
|
739
783
|
}
|
|
740
784
|
|
|
785
|
+
export async function resolveTask(workspaceRoot, taskId, threadId) {
|
|
786
|
+
const id = requireSafeId(taskId, "taskId");
|
|
787
|
+
const durableThreadId = normalizeDurableThreadId(threadId);
|
|
788
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
789
|
+
return withDispatchLock(root, async () => {
|
|
790
|
+
const records = await readDispatchRecordsUnlocked(root);
|
|
791
|
+
const dispatches = records.map((record) => record.normalized);
|
|
792
|
+
const index = dispatches.findIndex((dispatch) => dispatch.id === id);
|
|
793
|
+
if (index === -1) throw new Error(`task not found: ${id}`);
|
|
794
|
+
const dispatch = dispatches[index];
|
|
795
|
+
if (parseTaskChefMarker(dispatch.instruction) !== dispatch.id) {
|
|
796
|
+
throw new Error(`task instruction does not contain its exact TaskChef marker: ${id}`);
|
|
797
|
+
}
|
|
798
|
+
if (dispatch.threadId === durableThreadId) return dispatch;
|
|
799
|
+
if (dispatch.threadId !== null) {
|
|
800
|
+
throw new Error(`task already has a different threadId: ${id}`);
|
|
801
|
+
}
|
|
802
|
+
if (dispatches.some((item) => item.threadId === durableThreadId)) {
|
|
803
|
+
throw new Error(`threadId is already recorded: ${durableThreadId}`);
|
|
804
|
+
}
|
|
805
|
+
const resolved = { ...dispatch, threadId: durableThreadId };
|
|
806
|
+
const lines = records.map((record, recordIndex) => recordIndex === index
|
|
807
|
+
? JSON.stringify({ ...record.raw, threadId: durableThreadId })
|
|
808
|
+
: record.line);
|
|
809
|
+
await writeDispatchLinesAtomic(root, lines);
|
|
810
|
+
return resolved;
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
741
814
|
export async function readTask(workspaceRoot, taskId) {
|
|
742
815
|
const id = requireSafeId(taskId, "taskId");
|
|
743
816
|
const dispatch = (await listTasks(workspaceRoot)).find((item) => item.id === id);
|
|
@@ -775,161 +848,6 @@ export async function buildTaskSummary(workspaceRoot) {
|
|
|
775
848
|
};
|
|
776
849
|
}
|
|
777
850
|
|
|
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();
|
|
820
|
-
for (const entry of entries) {
|
|
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 });
|
|
877
|
-
}
|
|
878
|
-
return {
|
|
879
|
-
tasksPath,
|
|
880
|
-
entries,
|
|
881
|
-
records,
|
|
882
|
-
action: entries.length === 0 ? "removed-empty" : "migrated",
|
|
883
|
-
};
|
|
884
|
-
}
|
|
885
|
-
|
|
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),
|
|
919
|
-
);
|
|
920
|
-
return pending;
|
|
921
|
-
});
|
|
922
|
-
for (const migration of migrations) {
|
|
923
|
-
if (migration.taskPath !== null) await unlink(migration.taskPath);
|
|
924
|
-
await rmdir(migration.taskDirectory);
|
|
925
|
-
}
|
|
926
|
-
await rmdir(inspection.tasksPath);
|
|
927
|
-
return {
|
|
928
|
-
action: inspection.action,
|
|
929
|
-
migratedCount: migrations.filter((item) => item.dispatch).length,
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
|
|
933
851
|
export async function doctorWorkspace(workspaceRoot) {
|
|
934
852
|
const root = path.resolve(workspaceRoot);
|
|
935
853
|
const checks = [];
|
|
@@ -965,11 +883,5 @@ export async function doctorWorkspace(workspaceRoot) {
|
|
|
965
883
|
}
|
|
966
884
|
return "no legacy TaskChef skill links";
|
|
967
885
|
});
|
|
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";
|
|
973
|
-
});
|
|
974
886
|
return { workspace: root, ok: checks.every((item) => item.status === "pass"), checks };
|
|
975
887
|
}
|