taskchef 8.4.2 → 8.5.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 +1 -1
- package/README.md +18 -4
- package/assets/taskchef-dispatcher-instructions.md +2 -0
- package/docs/spec.md +50 -4
- package/docs/workflows.md +14 -6
- package/index.js +3 -0
- package/package.json +1 -1
- package/skills/taskchef-bootstrap/SKILL.md +11 -3
- package/skills/taskchef-bootstrap/references/project-index.md +22 -5
- package/skills/taskchef-delegate/SKILL.md +20 -9
- package/skills/taskchef-executor/SKILL.md +10 -0
- package/src/config-mutations.js +8 -0
- package/src/dashboard/dist/app.js +30 -30
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.test.tsx +8 -0
- package/src/dashboard/react/App.tsx +3 -2
- package/src/dashboard/react/components/TaskDetail.tsx +1 -1
- package/src/dashboard/react/styles.css +1 -0
- package/src/github.js +8 -5
- package/src/mcp.js +166 -0
- package/src/workspace.js +718 -8
package/src/workspace.js
CHANGED
|
@@ -70,7 +70,7 @@ const FIRST_TURN_HISTORY_TASK_SCHEMA_VERSION = 7;
|
|
|
70
70
|
const INTERRUPTED_TURN_TASK_SCHEMA_VERSION = 8;
|
|
71
71
|
const LEGACY_RESULTS_TASK_SCHEMA_VERSION = 6;
|
|
72
72
|
const FIRST_SELF_LINKING_TASK_SCHEMA_VERSION = 4;
|
|
73
|
-
const CONFIG_FIELDS = new Set(["schemaVersion", "projects", "dashboard"]);
|
|
73
|
+
const CONFIG_FIELDS = new Set(["schemaVersion", "projects", "dashboard", "projectIndex"]);
|
|
74
74
|
const DASHBOARD_CONFIG_FIELDS = new Set(["autostart"]);
|
|
75
75
|
const PROJECT_FIELDS = new Set([
|
|
76
76
|
"name",
|
|
@@ -80,6 +80,17 @@ const PROJECT_FIELDS = new Set([
|
|
|
80
80
|
"description",
|
|
81
81
|
]);
|
|
82
82
|
const PROJECT_INPUT_FIELDS = new Set(["name", "path", "githubRepos", "description"]);
|
|
83
|
+
const PROJECT_INDEX_FIELDS = new Set(["bindings", "exclusions", "routingHints"]);
|
|
84
|
+
const PROJECT_BINDING_FIELDS = new Set(["hostId", "projectId", "path"]);
|
|
85
|
+
const ROUTING_HINT_FIELDS = new Set(["path", "aliases", "githubRepos", "responsibilities", "forgotten"]);
|
|
86
|
+
const ROUTING_FACT_FIELDS = new Set(["value", "provenance"]);
|
|
87
|
+
const FORGOTTEN_FACT_FIELDS = new Set(["kind", "value"]);
|
|
88
|
+
const ROUTING_PROVENANCE = new Set(["explicit_user", "verified_repository", "accepted_terminal_report"]);
|
|
89
|
+
const ROUTING_PROVENANCE_FIELDS = new Set([
|
|
90
|
+
"kind", "evidence", "taskId", "threadId", "turnRef", "repositoryPath",
|
|
91
|
+
]);
|
|
92
|
+
const ROUTING_HINT_KINDS = new Set(["alias", "githubRepo", "responsibility"]);
|
|
93
|
+
const MAX_ROUTING_HINTS = 128;
|
|
83
94
|
const STATEFUL_DISPATCH_FIELDS = new Set([
|
|
84
95
|
"schemaVersion",
|
|
85
96
|
"id",
|
|
@@ -521,6 +532,150 @@ async function normalizeProjects(
|
|
|
521
532
|
return normalized;
|
|
522
533
|
}
|
|
523
534
|
|
|
535
|
+
function normalizedAbsolutePath(value, name) {
|
|
536
|
+
const normalized = requireString(value, name).trim();
|
|
537
|
+
if (!path.isAbsolute(normalized) || path.normalize(normalized) !== normalized) {
|
|
538
|
+
throw new Error(`${name} must be a normalized absolute path`);
|
|
539
|
+
}
|
|
540
|
+
return normalized;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeRoutingFacts(value, name, { kind, maxCount, maxLength }) {
|
|
544
|
+
if (!Array.isArray(value) || value.length > maxCount) {
|
|
545
|
+
throw new Error(`${name} must be an array with at most ${maxCount} entries`);
|
|
546
|
+
}
|
|
547
|
+
const seen = new Set();
|
|
548
|
+
return value.map((fact, index) => {
|
|
549
|
+
requireExactFields(fact, ROUTING_FACT_FIELDS, `${name}[${index}]`);
|
|
550
|
+
let normalized = requireString(fact.value, `${name}[${index}].value`).trim();
|
|
551
|
+
if (normalized.length > maxLength) throw new Error(`${name}[${index}].value is too long`);
|
|
552
|
+
if (kind === "githubRepo") normalized = canonicalGithubRepository(normalized, `${name}[${index}].value`);
|
|
553
|
+
requireExactFields(fact.provenance, ROUTING_PROVENANCE_FIELDS, `${name}[${index}].provenance`);
|
|
554
|
+
const provenanceKind = requireEnum(
|
|
555
|
+
fact.provenance.kind,
|
|
556
|
+
ROUTING_PROVENANCE,
|
|
557
|
+
`${name}[${index}].provenance.kind`,
|
|
558
|
+
);
|
|
559
|
+
const provenance = {
|
|
560
|
+
kind: provenanceKind,
|
|
561
|
+
evidence: requireString(fact.provenance.evidence, `${name}[${index}].provenance.evidence`).trim(),
|
|
562
|
+
taskId: fact.provenance.taskId === null
|
|
563
|
+
? null : requireSafeId(fact.provenance.taskId, `${name}[${index}].provenance.taskId`),
|
|
564
|
+
threadId: fact.provenance.threadId === null
|
|
565
|
+
? null : normalizeDurableThreadId(fact.provenance.threadId, `${name}[${index}].provenance.threadId`),
|
|
566
|
+
turnRef: fact.provenance.turnRef === null
|
|
567
|
+
? null : normalizeTurnRef(fact.provenance.turnRef, `${name}[${index}].provenance.turnRef`),
|
|
568
|
+
repositoryPath: fact.provenance.repositoryPath === null
|
|
569
|
+
? null : normalizedAbsolutePath(
|
|
570
|
+
fact.provenance.repositoryPath,
|
|
571
|
+
`${name}[${index}].provenance.repositoryPath`,
|
|
572
|
+
),
|
|
573
|
+
};
|
|
574
|
+
if (provenance.evidence.length > 240) throw new Error(`${name}[${index}].provenance.evidence is too long`);
|
|
575
|
+
if (provenanceKind === "accepted_terminal_report"
|
|
576
|
+
&& [provenance.taskId, provenance.threadId, provenance.turnRef].some((part) => part === null)) {
|
|
577
|
+
throw new Error(`${name}[${index}].provenance requires taskId, threadId, and turnRef`);
|
|
578
|
+
}
|
|
579
|
+
if (provenanceKind !== "accepted_terminal_report"
|
|
580
|
+
&& [provenance.taskId, provenance.threadId, provenance.turnRef].some((part) => part !== null)) {
|
|
581
|
+
throw new Error(`${name}[${index}].provenance task identity is only valid for accepted reports`);
|
|
582
|
+
}
|
|
583
|
+
const item = {
|
|
584
|
+
value: normalized,
|
|
585
|
+
provenance,
|
|
586
|
+
};
|
|
587
|
+
const key = item.value.toLowerCase();
|
|
588
|
+
if (seen.has(key)) throw new Error(`${name} must not contain duplicate values`);
|
|
589
|
+
seen.add(key);
|
|
590
|
+
return item;
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function normalizeProjectIndex(value) {
|
|
595
|
+
requireExactFields(value, PROJECT_INDEX_FIELDS, "taskchef.json.projectIndex");
|
|
596
|
+
if (!Array.isArray(value.bindings) || !Array.isArray(value.exclusions)
|
|
597
|
+
|| !Array.isArray(value.routingHints)) {
|
|
598
|
+
throw new Error("taskchef.json.projectIndex lists must be arrays");
|
|
599
|
+
}
|
|
600
|
+
if (value.routingHints.length > MAX_ROUTING_HINTS) {
|
|
601
|
+
throw new Error(`taskchef.json.projectIndex.routingHints must have at most ${MAX_ROUTING_HINTS} entries`);
|
|
602
|
+
}
|
|
603
|
+
const bindings = value.bindings.map((binding, index) => {
|
|
604
|
+
const name = `taskchef.json.projectIndex.bindings[${index}]`;
|
|
605
|
+
requireExactFields(binding, PROJECT_BINDING_FIELDS, name);
|
|
606
|
+
return {
|
|
607
|
+
hostId: requireEnum(binding.hostId, new Set(["local"]), `${name}.hostId`),
|
|
608
|
+
projectId: requireString(binding.projectId, `${name}.projectId`).trim(),
|
|
609
|
+
path: normalizedAbsolutePath(binding.path, `${name}.path`),
|
|
610
|
+
};
|
|
611
|
+
});
|
|
612
|
+
const exclusions = value.exclusions.map((entry, index) => {
|
|
613
|
+
const name = `taskchef.json.projectIndex.exclusions[${index}]`;
|
|
614
|
+
requireExactFields(entry, PROJECT_BINDING_FIELDS, name);
|
|
615
|
+
return {
|
|
616
|
+
hostId: requireEnum(entry.hostId, new Set(["local"]), `${name}.hostId`),
|
|
617
|
+
projectId: entry.projectId === null
|
|
618
|
+
? null : requireString(entry.projectId, `${name}.projectId`).trim(),
|
|
619
|
+
path: normalizedAbsolutePath(entry.path, `${name}.path`),
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
const routingHints = value.routingHints.map((hint, index) => {
|
|
623
|
+
const name = `taskchef.json.projectIndex.routingHints[${index}]`;
|
|
624
|
+
requireExactFields(hint, ROUTING_HINT_FIELDS, name);
|
|
625
|
+
if (!Array.isArray(hint.forgotten) || hint.forgotten.length > 32) {
|
|
626
|
+
throw new Error(`${name}.forgotten must be an array with at most 32 entries`);
|
|
627
|
+
}
|
|
628
|
+
const forgotten = hint.forgotten.map((fact, factIndex) => {
|
|
629
|
+
const factName = `${name}.forgotten[${factIndex}]`;
|
|
630
|
+
requireExactFields(fact, FORGOTTEN_FACT_FIELDS, factName);
|
|
631
|
+
const kind = requireEnum(fact.kind, ROUTING_HINT_KINDS, `${factName}.kind`);
|
|
632
|
+
let normalized = requireString(fact.value, `${factName}.value`).trim();
|
|
633
|
+
if (kind === "githubRepo") normalized = canonicalGithubRepository(normalized, `${factName}.value`);
|
|
634
|
+
return { kind, value: normalized };
|
|
635
|
+
});
|
|
636
|
+
return {
|
|
637
|
+
path: normalizedAbsolutePath(hint.path, `${name}.path`),
|
|
638
|
+
aliases: normalizeRoutingFacts(hint.aliases, `${name}.aliases`, {
|
|
639
|
+
kind: "alias", maxCount: 16, maxLength: 80,
|
|
640
|
+
}),
|
|
641
|
+
githubRepos: normalizeRoutingFacts(hint.githubRepos, `${name}.githubRepos`, {
|
|
642
|
+
kind: "githubRepo", maxCount: 16, maxLength: 256,
|
|
643
|
+
}),
|
|
644
|
+
responsibilities: normalizeRoutingFacts(hint.responsibilities, `${name}.responsibilities`, {
|
|
645
|
+
kind: "responsibility", maxCount: 8, maxLength: 240,
|
|
646
|
+
}),
|
|
647
|
+
forgotten,
|
|
648
|
+
};
|
|
649
|
+
});
|
|
650
|
+
for (const [name, entries] of [["bindings", bindings], ["exclusions", exclusions]]) {
|
|
651
|
+
const ids = entries.map((entry) => `${entry.hostId}\0${entry.projectId ?? `path:${entry.path}`}`);
|
|
652
|
+
if (new Set(ids).size !== ids.length) throw new Error(`projectIndex ${name} must not contain duplicate native IDs`);
|
|
653
|
+
}
|
|
654
|
+
if (new Set(bindings.map((entry) => entry.path)).size !== bindings.length) {
|
|
655
|
+
throw new Error("projectIndex bindings must not contain duplicate paths");
|
|
656
|
+
}
|
|
657
|
+
if (new Set(exclusions.map((entry) => entry.path)).size !== exclusions.length) {
|
|
658
|
+
throw new Error("projectIndex exclusions must not contain duplicate paths");
|
|
659
|
+
}
|
|
660
|
+
if (new Set(routingHints.map((entry) => entry.path)).size !== routingHints.length) {
|
|
661
|
+
throw new Error("projectIndex routingHints must not contain duplicate paths");
|
|
662
|
+
}
|
|
663
|
+
return { bindings, exclusions, routingHints };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function emptyProjectIndex() {
|
|
667
|
+
return { bindings: [], exclusions: [], routingHints: [] };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function projectIndexForProjects(projectIndex, projects) {
|
|
671
|
+
const paths = new Set(projects.map((project) => project.path));
|
|
672
|
+
return {
|
|
673
|
+
bindings: projectIndex.bindings.filter((entry) => paths.has(entry.path)),
|
|
674
|
+
exclusions: projectIndex.exclusions,
|
|
675
|
+
routingHints: projectIndex.routingHints.filter((entry) => paths.has(entry.path)),
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
524
679
|
function normalizeGithubRemote(remote) {
|
|
525
680
|
if (typeof remote !== "string" || remote.trim().length === 0) return null;
|
|
526
681
|
try {
|
|
@@ -615,10 +770,24 @@ export async function validateConfig(config, { checkPaths = true } = {}) {
|
|
|
615
770
|
}
|
|
616
771
|
dashboard = { autostart: config.dashboard.autostart };
|
|
617
772
|
}
|
|
773
|
+
const projectIndex = "projectIndex" in config
|
|
774
|
+
? normalizeProjectIndex(config.projectIndex)
|
|
775
|
+
: null;
|
|
776
|
+
const projects = await normalizeProjects(config.projects, { checkPaths });
|
|
777
|
+
if (projectIndex) {
|
|
778
|
+
const projectPaths = new Set(projects.map((project) => project.path));
|
|
779
|
+
if (projectIndex.bindings.some((entry) => !projectPaths.has(entry.path))) {
|
|
780
|
+
throw new Error("projectIndex binding path must identify a configured project");
|
|
781
|
+
}
|
|
782
|
+
if (projectIndex.routingHints.some((entry) => !projectPaths.has(entry.path))) {
|
|
783
|
+
throw new Error("projectIndex routing hint path must identify a configured project");
|
|
784
|
+
}
|
|
785
|
+
}
|
|
618
786
|
return {
|
|
619
787
|
schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION,
|
|
620
|
-
projects
|
|
788
|
+
projects,
|
|
621
789
|
...(dashboard ? { dashboard } : {}),
|
|
790
|
+
...(projectIndex ? { projectIndex } : {}),
|
|
622
791
|
};
|
|
623
792
|
}
|
|
624
793
|
|
|
@@ -724,11 +893,17 @@ export async function readProjectIndex(workspaceRoot, { checkPaths = true } = {}
|
|
|
724
893
|
configHash: configHash(config),
|
|
725
894
|
projectSetHash: projectSetHash(config.projects),
|
|
726
895
|
projects,
|
|
896
|
+
...(config.projectIndex ? { projectIndex: config.projectIndex } : {}),
|
|
727
897
|
};
|
|
728
898
|
}
|
|
729
899
|
|
|
730
900
|
function mutationPreview(root, operation, current, updated) {
|
|
731
901
|
const diff = projectDiff(current.projects, updated.projects);
|
|
902
|
+
const beforeIndex = current.projectIndex ?? emptyProjectIndex();
|
|
903
|
+
const afterIndex = updated.projectIndex ?? emptyProjectIndex();
|
|
904
|
+
const projectIndexDiff = JSON.stringify(beforeIndex) === JSON.stringify(afterIndex)
|
|
905
|
+
? null
|
|
906
|
+
: { before: beforeIndex, after: afterIndex };
|
|
732
907
|
const preview = {
|
|
733
908
|
schemaVersion: 1,
|
|
734
909
|
operation,
|
|
@@ -741,6 +916,7 @@ function mutationPreview(root, operation, current, updated) {
|
|
|
741
916
|
beforeProjects: current.projects,
|
|
742
917
|
afterProjects: updated.projects,
|
|
743
918
|
diff,
|
|
919
|
+
projectIndexDiff,
|
|
744
920
|
};
|
|
745
921
|
return {
|
|
746
922
|
...preview,
|
|
@@ -883,12 +1059,475 @@ async function commitConfigMutation(root, operation, current, updated, preview,
|
|
|
883
1059
|
return { transactionId, backupId: backup.id, backupDeduplicated: backup.deduplicated };
|
|
884
1060
|
}
|
|
885
1061
|
|
|
1062
|
+
function validateNativeProjectSnapshot(snapshot) {
|
|
1063
|
+
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)
|
|
1064
|
+
|| snapshot.schemaVersion !== 2 || !Array.isArray(snapshot.projects)) {
|
|
1065
|
+
throw new Error("native project snapshot must be a schemaVersion 2 object with a projects array");
|
|
1066
|
+
}
|
|
1067
|
+
return snapshot.projects.map((project, index) => {
|
|
1068
|
+
const name = `native projects[${index}]`;
|
|
1069
|
+
if (!project || typeof project !== "object" || Array.isArray(project)) {
|
|
1070
|
+
throw new Error(`${name} must be an object`);
|
|
1071
|
+
}
|
|
1072
|
+
for (const field of ["projectId", "projectKind", "label"]) {
|
|
1073
|
+
if (!(field in project)) throw new Error(`${name} is missing field: ${field}`);
|
|
1074
|
+
}
|
|
1075
|
+
const normalized = {
|
|
1076
|
+
projectId: requireString(project.projectId, `${name}.projectId`).trim(),
|
|
1077
|
+
projectKind: requireString(project.projectKind, `${name}.projectKind`).trim(),
|
|
1078
|
+
label: requireString(project.label, `${name}.label`).trim(),
|
|
1079
|
+
path: typeof project.path === "string" ? project.path.trim() : null,
|
|
1080
|
+
hostId: typeof project.hostId === "string" ? project.hostId.trim() : null,
|
|
1081
|
+
hostDisplayName: typeof project.hostDisplayName === "string"
|
|
1082
|
+
? project.hostDisplayName.trim() : null,
|
|
1083
|
+
isGitRepository: typeof project.isGitRepository === "boolean"
|
|
1084
|
+
? project.isGitRepository : null,
|
|
1085
|
+
};
|
|
1086
|
+
if (normalized.projectKind === "local"
|
|
1087
|
+
&& (normalized.path === null || normalized.path.length === 0
|
|
1088
|
+
|| normalized.hostId === null || normalized.hostId.length === 0
|
|
1089
|
+
|| normalized.isGitRepository === null)) {
|
|
1090
|
+
throw new Error(`${name} local project requires path, hostId, and isGitRepository`);
|
|
1091
|
+
}
|
|
1092
|
+
return normalized;
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function uniqueProjectName(preferred, projects) {
|
|
1097
|
+
const base = preferred.trim();
|
|
1098
|
+
const names = new Set(projects.map((project) => project.name.toLowerCase()));
|
|
1099
|
+
if (!names.has(base.toLowerCase())) return base;
|
|
1100
|
+
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
|
1101
|
+
const candidate = `${base} (${suffix})`;
|
|
1102
|
+
if (!names.has(candidate.toLowerCase())) return candidate;
|
|
1103
|
+
}
|
|
1104
|
+
throw new Error(`could not allocate a unique project name for ${base}`);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function routingView(config) {
|
|
1108
|
+
return (config.projectIndex?.routingHints ?? []).map((hint) => ({
|
|
1109
|
+
path: hint.path,
|
|
1110
|
+
aliases: hint.aliases.map((fact) => fact.value),
|
|
1111
|
+
githubRepos: hint.githubRepos.map((fact) => fact.value),
|
|
1112
|
+
responsibilities: hint.responsibilities.map((fact) => fact.value),
|
|
1113
|
+
}));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export async function reconcileProjects(workspaceRoot, nativeSnapshot, options = {}) {
|
|
1117
|
+
const nativeProjects = validateNativeProjectSnapshot(nativeSnapshot);
|
|
1118
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
1119
|
+
return withWorkspaceLock(root, async () => {
|
|
1120
|
+
const current = await readConfig(root, { checkPaths: false });
|
|
1121
|
+
const storedIndex = current.projectIndex ?? emptyProjectIndex();
|
|
1122
|
+
const diagnostics = [];
|
|
1123
|
+
const candidates = [];
|
|
1124
|
+
for (const native of nativeProjects) {
|
|
1125
|
+
if (native.projectKind !== "local" || native.hostId !== "local") {
|
|
1126
|
+
diagnostics.push({
|
|
1127
|
+
projectId: native.projectId,
|
|
1128
|
+
code: "ineligible-host",
|
|
1129
|
+
message: "Only local projects on the active local execution host are eligible.",
|
|
1130
|
+
});
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
try {
|
|
1134
|
+
const canonicalPath = await canonicalDirectory(native.path);
|
|
1135
|
+
assertWorkspaceOutsideProject(root, canonicalPath);
|
|
1136
|
+
candidates.push({ ...native, path: canonicalPath });
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
diagnostics.push({
|
|
1139
|
+
projectId: native.projectId,
|
|
1140
|
+
code: /own delegation project|inside a delegation project/.test(error.message)
|
|
1141
|
+
? "workspace-excluded" : "unavailable-path",
|
|
1142
|
+
message: error.message,
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const storedPaths = new Set([
|
|
1147
|
+
...current.projects.map((project) => project.path),
|
|
1148
|
+
...storedIndex.bindings.map((entry) => entry.path),
|
|
1149
|
+
...storedIndex.exclusions.map((entry) => entry.path),
|
|
1150
|
+
...storedIndex.routingHints.map((entry) => entry.path),
|
|
1151
|
+
]);
|
|
1152
|
+
const resolvedPaths = new Map();
|
|
1153
|
+
for (const storedPath of storedPaths) {
|
|
1154
|
+
resolvedPaths.set(storedPath, await canonicalDirectory(storedPath).catch(() => null));
|
|
1155
|
+
}
|
|
1156
|
+
const configuredCanonicalCounts = new Map();
|
|
1157
|
+
const candidatePaths = new Set(candidates.map((candidate) => candidate.path));
|
|
1158
|
+
for (const project of current.projects) {
|
|
1159
|
+
const canonicalPath = resolvedPaths.get(project.path);
|
|
1160
|
+
if (canonicalPath) {
|
|
1161
|
+
configuredCanonicalCounts.set(
|
|
1162
|
+
canonicalPath,
|
|
1163
|
+
(configuredCanonicalCounts.get(canonicalPath) ?? 0) + 1,
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const canCanonicalize = (storedPath, canonicalPath) => (
|
|
1168
|
+
storedPath === canonicalPath
|
|
1169
|
+
|| [storedIndex.bindings, storedIndex.exclusions, storedIndex.routingHints].every((entries) => (
|
|
1170
|
+
!entries.some((entry) => entry.path === canonicalPath && entry.path !== storedPath)
|
|
1171
|
+
))
|
|
1172
|
+
);
|
|
1173
|
+
const canonicalizedPaths = new Map();
|
|
1174
|
+
for (const project of current.projects) {
|
|
1175
|
+
const canonicalPath = resolvedPaths.get(project.path);
|
|
1176
|
+
if (canonicalPath && candidatePaths.has(canonicalPath)
|
|
1177
|
+
&& configuredCanonicalCounts.get(canonicalPath) === 1
|
|
1178
|
+
&& canCanonicalize(project.path, canonicalPath)) {
|
|
1179
|
+
canonicalizedPaths.set(project.path, canonicalPath);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
const canonicalizeIndexedPath = (entry) => ({
|
|
1183
|
+
...entry,
|
|
1184
|
+
path: canonicalizedPaths.get(entry.path) ?? entry.path,
|
|
1185
|
+
});
|
|
1186
|
+
const projects = current.projects.map((project) => ({
|
|
1187
|
+
...project,
|
|
1188
|
+
path: canonicalizedPaths.get(project.path) ?? project.path,
|
|
1189
|
+
}));
|
|
1190
|
+
const currentIndex = {
|
|
1191
|
+
bindings: storedIndex.bindings.map(canonicalizeIndexedPath),
|
|
1192
|
+
exclusions: storedIndex.exclusions.map(canonicalizeIndexedPath),
|
|
1193
|
+
routingHints: storedIndex.routingHints.map(canonicalizeIndexedPath),
|
|
1194
|
+
};
|
|
1195
|
+
const resolvedStoredPath = (storedPath) => resolvedPaths.get(storedPath) ?? storedPath;
|
|
1196
|
+
const idCounts = new Map();
|
|
1197
|
+
const pathCounts = new Map();
|
|
1198
|
+
for (const candidate of candidates) {
|
|
1199
|
+
const id = `${candidate.hostId}\0${candidate.projectId}`;
|
|
1200
|
+
idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
|
|
1201
|
+
pathCounts.set(candidate.path, (pathCounts.get(candidate.path) ?? 0) + 1);
|
|
1202
|
+
}
|
|
1203
|
+
const bindings = [...currentIndex.bindings];
|
|
1204
|
+
const added = [];
|
|
1205
|
+
const bound = [];
|
|
1206
|
+
const available = [];
|
|
1207
|
+
for (const candidate of candidates) {
|
|
1208
|
+
const id = `${candidate.hostId}\0${candidate.projectId}`;
|
|
1209
|
+
if (idCounts.get(id) > 1 || pathCounts.get(candidate.path) > 1) {
|
|
1210
|
+
diagnostics.push({
|
|
1211
|
+
projectId: candidate.projectId,
|
|
1212
|
+
code: idCounts.get(id) > 1 ? "duplicate-native-id" : "duplicate-canonical-path",
|
|
1213
|
+
message: "Ambiguous native project identity was ignored.",
|
|
1214
|
+
});
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
const excluded = currentIndex.exclusions.some((entry) => (
|
|
1218
|
+
entry.hostId === candidate.hostId
|
|
1219
|
+
&& (entry.projectId === candidate.projectId
|
|
1220
|
+
|| resolvedStoredPath(entry.path) === candidate.path)
|
|
1221
|
+
));
|
|
1222
|
+
if (excluded) {
|
|
1223
|
+
diagnostics.push({
|
|
1224
|
+
projectId: candidate.projectId,
|
|
1225
|
+
code: "explicitly-excluded",
|
|
1226
|
+
message: "Project remains excluded until explicitly included.",
|
|
1227
|
+
});
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
const idBinding = bindings.find((entry) => (
|
|
1231
|
+
entry.hostId === candidate.hostId && entry.projectId === candidate.projectId
|
|
1232
|
+
));
|
|
1233
|
+
if (idBinding && resolvedStoredPath(idBinding.path) !== candidate.path) {
|
|
1234
|
+
diagnostics.push({
|
|
1235
|
+
projectId: candidate.projectId,
|
|
1236
|
+
code: "identity-path-conflict",
|
|
1237
|
+
message: `Native identity is still bound to ${idBinding.path}; review the move explicitly.`,
|
|
1238
|
+
});
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
const pathBindings = bindings.filter(
|
|
1242
|
+
(entry) => resolvedStoredPath(entry.path) === candidate.path,
|
|
1243
|
+
);
|
|
1244
|
+
if (pathBindings.length > 1) {
|
|
1245
|
+
diagnostics.push({
|
|
1246
|
+
projectId: candidate.projectId,
|
|
1247
|
+
code: "ambiguous-configured-path",
|
|
1248
|
+
message: "Multiple configured index paths resolve to this native project.",
|
|
1249
|
+
});
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
const [pathBinding] = pathBindings;
|
|
1253
|
+
if (pathBinding && (pathBinding.hostId !== candidate.hostId
|
|
1254
|
+
|| pathBinding.projectId !== candidate.projectId)) {
|
|
1255
|
+
diagnostics.push({
|
|
1256
|
+
projectId: candidate.projectId,
|
|
1257
|
+
code: "path-identity-conflict",
|
|
1258
|
+
message: "Canonical path is already bound to another native identity.",
|
|
1259
|
+
});
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const configuredMatches = projects.filter(
|
|
1263
|
+
(project) => resolvedStoredPath(project.path) === candidate.path,
|
|
1264
|
+
);
|
|
1265
|
+
if (configuredMatches.length > 1) {
|
|
1266
|
+
diagnostics.push({
|
|
1267
|
+
projectId: candidate.projectId,
|
|
1268
|
+
code: "ambiguous-configured-path",
|
|
1269
|
+
message: "Multiple configured project paths resolve to this native project.",
|
|
1270
|
+
});
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
let [configured] = configuredMatches;
|
|
1274
|
+
if (!configured) {
|
|
1275
|
+
try {
|
|
1276
|
+
configured = await inspectProject({
|
|
1277
|
+
path: candidate.path,
|
|
1278
|
+
name: uniqueProjectName(candidate.label || path.basename(candidate.path), projects),
|
|
1279
|
+
});
|
|
1280
|
+
if (configured.isGitRepository !== candidate.isGitRepository) {
|
|
1281
|
+
throw new Error("native Git repository metadata does not match the canonical path");
|
|
1282
|
+
}
|
|
1283
|
+
projects.push(configured);
|
|
1284
|
+
added.push(configured);
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
diagnostics.push({
|
|
1287
|
+
projectId: candidate.projectId,
|
|
1288
|
+
code: "inspection-failed",
|
|
1289
|
+
message: error.message,
|
|
1290
|
+
});
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (!idBinding) {
|
|
1295
|
+
const binding = {
|
|
1296
|
+
hostId: candidate.hostId,
|
|
1297
|
+
projectId: candidate.projectId,
|
|
1298
|
+
path: configured.path,
|
|
1299
|
+
};
|
|
1300
|
+
bindings.push(binding);
|
|
1301
|
+
bound.push(binding);
|
|
1302
|
+
}
|
|
1303
|
+
available.push({
|
|
1304
|
+
hostId: candidate.hostId,
|
|
1305
|
+
projectId: candidate.projectId,
|
|
1306
|
+
path: candidate.path,
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
const indexChanged = JSON.stringify(bindings) !== JSON.stringify(currentIndex.bindings);
|
|
1310
|
+
const updated = await validateConfig({
|
|
1311
|
+
...current,
|
|
1312
|
+
projects,
|
|
1313
|
+
...(current.projectIndex || indexChanged
|
|
1314
|
+
? { projectIndex: { ...currentIndex, bindings } }
|
|
1315
|
+
: {}),
|
|
1316
|
+
}, { checkPaths: false });
|
|
1317
|
+
const preview = mutationPreview(root, "project-reconcile", current, updated);
|
|
1318
|
+
const changed = preview.beforeConfigHash !== preview.afterConfigHash;
|
|
1319
|
+
const committed = changed
|
|
1320
|
+
? await commitConfigMutation(root, "project-reconcile", current, updated, preview, options)
|
|
1321
|
+
: { transactionId: null, backupId: null, backupDeduplicated: false };
|
|
1322
|
+
return {
|
|
1323
|
+
schemaVersion: 1,
|
|
1324
|
+
changed,
|
|
1325
|
+
added,
|
|
1326
|
+
bound,
|
|
1327
|
+
available,
|
|
1328
|
+
diagnostics,
|
|
1329
|
+
projectCount: updated.projects.length,
|
|
1330
|
+
...preview,
|
|
1331
|
+
...committed,
|
|
1332
|
+
};
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
export async function includeProject(workspaceRoot, selector, options = {}) {
|
|
1337
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
1338
|
+
return withWorkspaceLock(root, async () => {
|
|
1339
|
+
const current = await readConfig(root, { checkPaths: false });
|
|
1340
|
+
const index = current.projectIndex ?? emptyProjectIndex();
|
|
1341
|
+
const selectedPaths = new Set();
|
|
1342
|
+
if (selector.path) {
|
|
1343
|
+
const requestedPath = path.resolve(requireString(selector.path, "project path").trim());
|
|
1344
|
+
selectedPaths.add(requestedPath);
|
|
1345
|
+
const canonicalPath = await canonicalDirectory(requestedPath).catch(() => null);
|
|
1346
|
+
if (canonicalPath) selectedPaths.add(canonicalPath);
|
|
1347
|
+
}
|
|
1348
|
+
const exclusions = index.exclusions.filter((entry) => !(
|
|
1349
|
+
selectedPaths.has(entry.path)
|
|
1350
|
+
|| (selector.projectId && entry.hostId === (selector.hostId ?? "local")
|
|
1351
|
+
&& entry.projectId === selector.projectId)
|
|
1352
|
+
));
|
|
1353
|
+
if (exclusions.length === index.exclusions.length) {
|
|
1354
|
+
return { changed: false, exclusions, configHash: configHash(current) };
|
|
1355
|
+
}
|
|
1356
|
+
const updated = await validateConfig({
|
|
1357
|
+
...current,
|
|
1358
|
+
projectIndex: { ...index, exclusions },
|
|
1359
|
+
}, { checkPaths: false });
|
|
1360
|
+
const preview = mutationPreview(root, "project-include", current, updated);
|
|
1361
|
+
const committed = await commitConfigMutation(root, "project-include", current, updated, preview, options);
|
|
1362
|
+
return { changed: true, exclusions, ...preview, ...committed };
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function routingHintField(kind) {
|
|
1367
|
+
if (kind === "alias") return "aliases";
|
|
1368
|
+
if (kind === "githubRepo") return "githubRepos";
|
|
1369
|
+
return "responsibilities";
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function emptyRoutingHint(projectPath) {
|
|
1373
|
+
return {
|
|
1374
|
+
path: projectPath,
|
|
1375
|
+
aliases: [],
|
|
1376
|
+
githubRepos: [],
|
|
1377
|
+
responsibilities: [],
|
|
1378
|
+
forgotten: [],
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
export async function updateProjectHint(workspaceRoot, input, options = {}) {
|
|
1383
|
+
requireExactFields(
|
|
1384
|
+
input,
|
|
1385
|
+
new Set(["action", "project", "kind", "value", "provenance"]),
|
|
1386
|
+
"project hint input",
|
|
1387
|
+
);
|
|
1388
|
+
const action = requireEnum(
|
|
1389
|
+
input.action,
|
|
1390
|
+
new Set(["remember", "correct", "forget"]),
|
|
1391
|
+
"project hint action",
|
|
1392
|
+
);
|
|
1393
|
+
const kind = requireEnum(input.kind, ROUTING_HINT_KINDS, "project hint kind");
|
|
1394
|
+
let value = requireString(input.value, "project hint value").trim();
|
|
1395
|
+
if (kind === "alias" && value.length > 80) throw new Error("project alias must be at most 80 characters");
|
|
1396
|
+
if (kind === "responsibility" && value.length > 240) {
|
|
1397
|
+
throw new Error("project responsibility must be at most 240 characters");
|
|
1398
|
+
}
|
|
1399
|
+
if (kind === "githubRepo") value = canonicalGithubRepository(value, "project hint value");
|
|
1400
|
+
const root = await realpath(path.resolve(workspaceRoot));
|
|
1401
|
+
return withWorkspaceLock(root, async () => {
|
|
1402
|
+
const current = await readConfig(root, { checkPaths: false });
|
|
1403
|
+
const configuredPath = path.resolve(requireString(input.project, "project hint project").trim());
|
|
1404
|
+
const project = current.projects.find((candidate) => candidate.path === configuredPath);
|
|
1405
|
+
if (!project) throw new Error(`configured project not found: ${configuredPath}`);
|
|
1406
|
+
const normalizedFact = normalizeRoutingFacts([{
|
|
1407
|
+
value,
|
|
1408
|
+
provenance: input.provenance,
|
|
1409
|
+
}], "project hint", {
|
|
1410
|
+
kind,
|
|
1411
|
+
maxCount: 1,
|
|
1412
|
+
maxLength: kind === "alias" ? 80 : kind === "responsibility" ? 240 : 256,
|
|
1413
|
+
})[0];
|
|
1414
|
+
const provenance = normalizedFact.provenance;
|
|
1415
|
+
if (action === "correct" && kind !== "alias") {
|
|
1416
|
+
throw new Error("correct is only valid for an explicit alias correction");
|
|
1417
|
+
}
|
|
1418
|
+
if (action === "forget" && provenance.kind !== "explicit_user") {
|
|
1419
|
+
throw new Error("forget requires explicit_user provenance");
|
|
1420
|
+
}
|
|
1421
|
+
if (action !== "forget" && kind === "alias" && provenance.kind !== "explicit_user") {
|
|
1422
|
+
throw new Error("aliases require explicit_user provenance");
|
|
1423
|
+
}
|
|
1424
|
+
if (action !== "forget" && kind === "githubRepo"
|
|
1425
|
+
&& !new Set(["verified_repository", "accepted_terminal_report"]).has(provenance.kind)) {
|
|
1426
|
+
throw new Error("repository ownership requires verified repository evidence");
|
|
1427
|
+
}
|
|
1428
|
+
if (action !== "forget" && kind === "responsibility"
|
|
1429
|
+
&& provenance.kind !== "accepted_terminal_report") {
|
|
1430
|
+
throw new Error("responsibilities require accepted_terminal_report provenance");
|
|
1431
|
+
}
|
|
1432
|
+
if (action !== "forget" && kind === "githubRepo") {
|
|
1433
|
+
if (provenance.repositoryPath === null) {
|
|
1434
|
+
throw new Error("repository ownership requires an inspected repositoryPath");
|
|
1435
|
+
}
|
|
1436
|
+
const repositoryPath = await canonicalGitRoot(provenance.repositoryPath);
|
|
1437
|
+
const relative = path.relative(project.path, repositoryPath);
|
|
1438
|
+
if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) {
|
|
1439
|
+
throw new Error("repositoryPath must be the selected project or a contained Git root");
|
|
1440
|
+
}
|
|
1441
|
+
const inspected = await inspectProject({ path: repositoryPath });
|
|
1442
|
+
if (!inspected.githubRepos.some((repository) => repository.toLowerCase() === value.toLowerCase())) {
|
|
1443
|
+
throw new Error("repository hint is not the inspected project's canonical origin");
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (provenance.kind === "accepted_terminal_report") {
|
|
1447
|
+
const tasks = await readDispatchesUnlocked(root);
|
|
1448
|
+
const task = tasks.find((candidate) => candidate.id === provenance.taskId);
|
|
1449
|
+
const turn = task?.latestTurn;
|
|
1450
|
+
if (!task || task.project.path !== project.path
|
|
1451
|
+
|| task.threadId !== provenance.threadId
|
|
1452
|
+
|| turn?.turnRef !== provenance.turnRef
|
|
1453
|
+
|| !RESULT_STATUSES.has(turn.result?.status)) {
|
|
1454
|
+
throw new Error("project hint provenance is not the accepted current terminal report");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const projectIndex = current.projectIndex ?? emptyProjectIndex();
|
|
1458
|
+
let hints = projectIndex.routingHints.map((hint) => ({
|
|
1459
|
+
...hint,
|
|
1460
|
+
aliases: [...hint.aliases],
|
|
1461
|
+
githubRepos: [...hint.githubRepos],
|
|
1462
|
+
responsibilities: [...hint.responsibilities],
|
|
1463
|
+
forgotten: [...hint.forgotten],
|
|
1464
|
+
}));
|
|
1465
|
+
let hint = hints.find((candidate) => candidate.path === project.path);
|
|
1466
|
+
if (!hint) {
|
|
1467
|
+
if (hints.length >= MAX_ROUTING_HINTS) throw new Error("project routing hint capacity is full");
|
|
1468
|
+
hint = emptyRoutingHint(project.path);
|
|
1469
|
+
hints.push(hint);
|
|
1470
|
+
}
|
|
1471
|
+
const field = routingHintField(kind);
|
|
1472
|
+
const key = value.toLowerCase();
|
|
1473
|
+
const forgottenIndex = hint.forgotten.findIndex((fact) => (
|
|
1474
|
+
fact.kind === kind && fact.value.toLowerCase() === key
|
|
1475
|
+
));
|
|
1476
|
+
let suppressed = false;
|
|
1477
|
+
if (action !== "forget") {
|
|
1478
|
+
if (forgottenIndex !== -1 && provenance.kind !== "explicit_user") {
|
|
1479
|
+
suppressed = true;
|
|
1480
|
+
} else {
|
|
1481
|
+
if (forgottenIndex !== -1) hint.forgotten.splice(forgottenIndex, 1);
|
|
1482
|
+
if (action === "correct" && kind === "alias") {
|
|
1483
|
+
for (const other of hints.filter((candidate) => candidate.path !== project.path)) {
|
|
1484
|
+
const removed = other.aliases.some((fact) => fact.value.toLowerCase() === key);
|
|
1485
|
+
other.aliases = other.aliases.filter((fact) => fact.value.toLowerCase() !== key);
|
|
1486
|
+
if (removed && !other.forgotten.some((fact) => (
|
|
1487
|
+
fact.kind === "alias" && fact.value.toLowerCase() === key
|
|
1488
|
+
))) {
|
|
1489
|
+
if (other.forgotten.length >= 32) throw new Error("project forgotten-hint capacity is full");
|
|
1490
|
+
other.forgotten.push({ kind: "alias", value });
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
if (!hint[field].some((fact) => fact.value.toLowerCase() === key)) {
|
|
1495
|
+
const limit = kind === "alias" ? 16 : kind === "githubRepo" ? 16 : 8;
|
|
1496
|
+
if (hint[field].length >= limit) throw new Error(`project ${field} capacity is full`);
|
|
1497
|
+
hint[field].push(normalizedFact);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
} else {
|
|
1501
|
+
hint[field] = hint[field].filter((fact) => fact.value.toLowerCase() !== key);
|
|
1502
|
+
if (forgottenIndex === -1) {
|
|
1503
|
+
if (hint.forgotten.length >= 32) throw new Error("project forgotten-hint capacity is full");
|
|
1504
|
+
hint.forgotten.push({ kind, value });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
hints = hints.filter((candidate) => (
|
|
1508
|
+
candidate.aliases.length || candidate.githubRepos.length
|
|
1509
|
+
|| candidate.responsibilities.length || candidate.forgotten.length
|
|
1510
|
+
));
|
|
1511
|
+
const updated = await validateConfig({
|
|
1512
|
+
...current,
|
|
1513
|
+
projectIndex: { ...projectIndex, routingHints: hints },
|
|
1514
|
+
}, { checkPaths: false });
|
|
1515
|
+
const preview = mutationPreview(root, `project-hint-${action}`, current, updated);
|
|
1516
|
+
const changed = preview.beforeConfigHash !== preview.afterConfigHash;
|
|
1517
|
+
const committed = changed
|
|
1518
|
+
? await commitConfigMutation(root, `project-hint-${action}`, current, updated, preview, options)
|
|
1519
|
+
: { transactionId: null, backupId: null, backupDeduplicated: false };
|
|
1520
|
+
return { changed, suppressed, project: project.path, kind, value, ...preview, ...committed };
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
|
|
886
1524
|
export async function prepareDispatch(workspaceRoot, {
|
|
887
1525
|
taskId = randomUUID(),
|
|
888
1526
|
now = () => new Date().toISOString(),
|
|
889
1527
|
} = {}) {
|
|
890
1528
|
const workspace = await realpath(path.resolve(workspaceRoot));
|
|
891
|
-
const
|
|
1529
|
+
const config = await readConfig(workspace, { checkPaths: false });
|
|
1530
|
+
const projects = [...config.projects].sort((left, right) => left.name.localeCompare(right.name));
|
|
892
1531
|
const marker = taskChefMarker(taskId);
|
|
893
1532
|
const preparedAt = requireTimestamp(now(), "preparedAt");
|
|
894
1533
|
return {
|
|
@@ -899,6 +1538,7 @@ export async function prepareDispatch(workspaceRoot, {
|
|
|
899
1538
|
marker,
|
|
900
1539
|
projectCount: projects.length,
|
|
901
1540
|
projects,
|
|
1541
|
+
routingHints: routingView(config),
|
|
902
1542
|
modelRoles: await resolveModelRoles(),
|
|
903
1543
|
};
|
|
904
1544
|
}
|
|
@@ -909,9 +1549,14 @@ export async function addProject(workspaceRoot, input, options = {}) {
|
|
|
909
1549
|
const config = await readConfig(root, { checkPaths: false });
|
|
910
1550
|
const project = await inspectProject(input);
|
|
911
1551
|
assertWorkspaceOutsideProject(root, project.path);
|
|
1552
|
+
const currentIndex = config.projectIndex ?? emptyProjectIndex();
|
|
1553
|
+
const exclusions = currentIndex.exclusions.filter((entry) => entry.path !== project.path);
|
|
912
1554
|
const updated = await validateConfig({
|
|
913
1555
|
...config,
|
|
914
1556
|
projects: [...config.projects, project],
|
|
1557
|
+
...(config.projectIndex || exclusions.length !== currentIndex.exclusions.length
|
|
1558
|
+
? { projectIndex: { ...currentIndex, exclusions } }
|
|
1559
|
+
: {}),
|
|
915
1560
|
}, { checkPaths: false });
|
|
916
1561
|
const preview = mutationPreview(root, "project-add", config, updated);
|
|
917
1562
|
if (options.dryRun) return { changed: true, project, ...preview };
|
|
@@ -952,9 +1597,36 @@ export async function importProjects(workspaceRoot, inputs, { replace = false, d
|
|
|
952
1597
|
if (index === -1) projects.push(project);
|
|
953
1598
|
else projects[index] = project;
|
|
954
1599
|
}
|
|
1600
|
+
const currentIndex = current.projectIndex ?? emptyProjectIndex();
|
|
1601
|
+
const retainedPaths = new Set(projects.map((project) => project.path));
|
|
1602
|
+
const removedProjects = replace
|
|
1603
|
+
? current.projects.filter((project) => !retainedPaths.has(project.path))
|
|
1604
|
+
: [];
|
|
1605
|
+
const exclusions = [...currentIndex.exclusions];
|
|
1606
|
+
for (const project of removedProjects) {
|
|
1607
|
+
const binding = currentIndex.bindings.find((entry) => entry.path === project.path);
|
|
1608
|
+
const alreadyExcluded = exclusions.some((entry) => (
|
|
1609
|
+
entry.path === project.path
|
|
1610
|
+
|| (binding && entry.hostId === binding.hostId && entry.projectId === binding.projectId)
|
|
1611
|
+
));
|
|
1612
|
+
if (!alreadyExcluded) {
|
|
1613
|
+
exclusions.push({
|
|
1614
|
+
hostId: binding?.hostId ?? "local",
|
|
1615
|
+
projectId: binding?.projectId ?? null,
|
|
1616
|
+
path: project.path,
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
const nextProjectIndex = {
|
|
1621
|
+
...projectIndexForProjects(currentIndex, projects),
|
|
1622
|
+
exclusions,
|
|
1623
|
+
};
|
|
955
1624
|
const config = await validateConfig({
|
|
956
1625
|
...current,
|
|
957
1626
|
projects,
|
|
1627
|
+
...(current.projectIndex || removedProjects.length > 0
|
|
1628
|
+
? { projectIndex: nextProjectIndex }
|
|
1629
|
+
: {}),
|
|
958
1630
|
}, { checkPaths: false });
|
|
959
1631
|
const preview = mutationPreview(root, replace ? "project-replace" : "project-import", current, config);
|
|
960
1632
|
if (dryRun) {
|
|
@@ -994,9 +1666,24 @@ export async function removeProject(workspaceRoot, name, { dryRun = false, ...op
|
|
|
994
1666
|
if (index === -1) throw new Error(`configured project not found: ${name}`);
|
|
995
1667
|
const [project] = config.projects.slice(index, index + 1);
|
|
996
1668
|
const projects = config.projects.filter((_, projectIndex) => projectIndex !== index);
|
|
1669
|
+
const currentIndex = config.projectIndex ?? emptyProjectIndex();
|
|
1670
|
+
const binding = currentIndex.bindings.find((entry) => entry.path === project.path);
|
|
1671
|
+
const exclusion = {
|
|
1672
|
+
hostId: binding?.hostId ?? "local",
|
|
1673
|
+
projectId: binding?.projectId ?? null,
|
|
1674
|
+
path: project.path,
|
|
1675
|
+
};
|
|
997
1676
|
const updated = await validateConfig({
|
|
998
1677
|
...config,
|
|
999
1678
|
projects,
|
|
1679
|
+
projectIndex: {
|
|
1680
|
+
bindings: currentIndex.bindings.filter((entry) => entry.path !== project.path),
|
|
1681
|
+
exclusions: [
|
|
1682
|
+
...currentIndex.exclusions.filter((entry) => entry.path !== project.path),
|
|
1683
|
+
exclusion,
|
|
1684
|
+
],
|
|
1685
|
+
routingHints: currentIndex.routingHints.filter((entry) => entry.path !== project.path),
|
|
1686
|
+
},
|
|
1000
1687
|
}, { checkPaths: false });
|
|
1001
1688
|
const preview = mutationPreview(root, "project-remove", config, updated);
|
|
1002
1689
|
if (dryRun) return { project, changed: true, ...preview };
|
|
@@ -1163,7 +1850,15 @@ export async function restoreWorkspaceBackup(workspaceRoot, id, {
|
|
|
1163
1850
|
"projects-only restore requires readable current configuration; preview --scope config or state instead",
|
|
1164
1851
|
);
|
|
1165
1852
|
}
|
|
1166
|
-
|
|
1853
|
+
let updated = stored;
|
|
1854
|
+
if (scope === "projects") {
|
|
1855
|
+
const { projectIndex: _currentIndex, ...currentWithoutIndex } = current;
|
|
1856
|
+
updated = await validateConfig({
|
|
1857
|
+
...currentWithoutIndex,
|
|
1858
|
+
projects: stored.projects,
|
|
1859
|
+
...(stored.projectIndex ? { projectIndex: stored.projectIndex } : {}),
|
|
1860
|
+
}, { checkPaths: false });
|
|
1861
|
+
}
|
|
1167
1862
|
const beforeStateHash = current
|
|
1168
1863
|
? configHash(current)
|
|
1169
1864
|
: currentPresent ? `unreadable:${sha256(currentBytes)}` : "missing";
|
|
@@ -2114,10 +2809,25 @@ export async function recordTask(workspaceRoot, input, { now } = {}) {
|
|
|
2114
2809
|
requireExactFields(input, RECORD_DISPATCH_FIELDS, "task input");
|
|
2115
2810
|
const root = await realpath(path.resolve(workspaceRoot));
|
|
2116
2811
|
return withWorkspaceLock(root, async () => {
|
|
2117
|
-
const config = await readConfig(root);
|
|
2118
|
-
const
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2812
|
+
const config = await readConfig(root, { checkPaths: false });
|
|
2813
|
+
const requested = requireString(input.project, "task input.project").trim();
|
|
2814
|
+
const requestedPath = path.resolve(requested);
|
|
2815
|
+
let configured = config.projects.find((candidate) => candidate.path === requestedPath);
|
|
2816
|
+
if (!configured) {
|
|
2817
|
+
const canonicalRequested = await canonicalDirectory(requested);
|
|
2818
|
+
for (const candidate of config.projects) {
|
|
2819
|
+
const canonicalCandidate = await realpath(candidate.path).catch(() => null);
|
|
2820
|
+
if (canonicalCandidate === canonicalRequested) {
|
|
2821
|
+
configured = candidate;
|
|
2822
|
+
break;
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
if (!configured) throw new Error(`project is not configured in taskchef.json: ${requestedPath}`);
|
|
2827
|
+
const projectPath = configured.isGitRepository
|
|
2828
|
+
? await canonicalGitRoot(requested)
|
|
2829
|
+
: await canonicalDirectory(requested);
|
|
2830
|
+
const project = { ...configured, path: projectPath };
|
|
2121
2831
|
const createdAt = now ?? new Date().toISOString();
|
|
2122
2832
|
const dispatch = await validateDispatchShape({
|
|
2123
2833
|
schemaVersion: CURRENT_TASK_SCHEMA_VERSION,
|