@kontextmind/kxm 0.7.52 → 0.7.54
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +34 -0
- package/docs/agent-skills.md +1 -1
- package/docs/configuration.md +0 -3
- package/docs/contracts/migration.md +50 -220
- package/docs/contracts/validation.md +15 -33
- package/docs/test-matrix.md +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +380 -2146
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +23 -178
- package/plugins/kxm/dist/runtime.js +23 -178
- package/plugins/kxm/dist/server.js +6 -105
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +4 -7
- package/plugins/kxm/src/autocomplete.ts +0 -3
- package/plugins/kxm/src/cli/project.ts +2 -133
- package/plugins/kxm/src/cli.ts +3 -24
- package/plugins/kxm/src/database.ts +9 -21
- package/plugins/kxm/src/external-effects.ts +37 -27
- package/plugins/kxm/src/init.ts +10 -6
- package/plugins/kxm/src/intake.ts +6 -11
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/project-config.ts +54 -173
- package/plugins/kxm/src/runtime-store.ts +0 -69
- package/plugins/kxm/src/store.ts +0 -92
- package/schemas/README.md +0 -3
- package/plugins/kxm/src/migrate.ts +0 -1848
- package/schemas/migration-decision.schema.json +0 -26
- package/schemas/migration-plan.schema.json +0 -123
- package/schemas/migration-receipt.schema.json +0 -52
|
@@ -1,1848 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
4
|
-
import { stringify } from "yaml";
|
|
5
|
-
import {
|
|
6
|
-
KxmConfigError,
|
|
7
|
-
KxmSchemaRegistry,
|
|
8
|
-
discoverGitRoot,
|
|
9
|
-
legacyConfigFilesAt,
|
|
10
|
-
loadKxmProject,
|
|
11
|
-
migrationReceiptSelfHash,
|
|
12
|
-
parseRestrictedYaml,
|
|
13
|
-
readKxmMigrationReceipt,
|
|
14
|
-
validateKxmResources,
|
|
15
|
-
verifyKxmMigrationReceiptTarget,
|
|
16
|
-
kxmCanonicalJson,
|
|
17
|
-
KXM_MIGRATION_RECEIPT_PATH,
|
|
18
|
-
type JsonObject,
|
|
19
|
-
type JsonValue,
|
|
20
|
-
type KxmConfigIssue,
|
|
21
|
-
type KxmConfigOptions,
|
|
22
|
-
type KxmResourceKind,
|
|
23
|
-
} from "./project-config.ts";
|
|
24
|
-
import { withKxmLocalBindingLock, type KxmLocalBindingLock, type KxmLocalBindingStoreOptions } from "./bindings.ts";
|
|
25
|
-
|
|
26
|
-
/* ------------------------------------------------------------------ *
|
|
27
|
-
* Bounded legacy JSON reading (duplicate keys rejected, depth bounded)
|
|
28
|
-
* ------------------------------------------------------------------ */
|
|
29
|
-
|
|
30
|
-
const MAX_LEGACY_JSON_BYTES = 1024 * 1024;
|
|
31
|
-
const MAX_LEGACY_JSON_DEPTH = 32;
|
|
32
|
-
const MAX_LEGACY_JSON_NODES = 32_768;
|
|
33
|
-
const MAX_LEGACY_SOURCES = 64;
|
|
34
|
-
|
|
35
|
-
function migrateIssue(phase: KxmConfigIssue["phase"], code: string, file: string, message: string): KxmConfigIssue {
|
|
36
|
-
return { phase, code, file, message };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function migrateFail(code: string, file: string, message: string): never {
|
|
40
|
-
throw new KxmConfigError([migrateIssue("parse", code, file, message)]);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** JSON.parse with duplicate-key rejection and structural bounds. */
|
|
44
|
-
export function parseLegacyJson(text: string, label: string): JsonValue {
|
|
45
|
-
const bytes = Buffer.byteLength(text, "utf8");
|
|
46
|
-
if (bytes > MAX_LEGACY_JSON_BYTES) migrateFail("document_too_large", label, `document exceeds ${MAX_LEGACY_JSON_BYTES} bytes`);
|
|
47
|
-
const state = { nodes: 0 };
|
|
48
|
-
const parseNode = (value: unknown, path: string, depth: number): JsonValue => {
|
|
49
|
-
state.nodes += 1;
|
|
50
|
-
if (state.nodes > MAX_LEGACY_JSON_NODES) migrateFail("node_limit", label, `document exceeds ${MAX_LEGACY_JSON_NODES} nodes`);
|
|
51
|
-
if (depth > MAX_LEGACY_JSON_DEPTH) migrateFail("depth_limit", label, `document exceeds nesting depth ${MAX_LEGACY_JSON_DEPTH}`);
|
|
52
|
-
if (value === null || typeof value === "boolean" || typeof value === "string") return value;
|
|
53
|
-
if (typeof value === "number") {
|
|
54
|
-
if (!Number.isFinite(value)) migrateFail("non_json_number", label, `${path} is not a finite number`);
|
|
55
|
-
return value;
|
|
56
|
-
}
|
|
57
|
-
if (Array.isArray(value)) {
|
|
58
|
-
return value.map((entry, index) => parseNode(entry, `${path}[${index}]`, depth + 1)) as JsonValue;
|
|
59
|
-
}
|
|
60
|
-
if (value && typeof value === "object") {
|
|
61
|
-
const record: JsonObject = {};
|
|
62
|
-
for (const [key, entry] of Object.entries(value)) record[key] = parseNode(entry, `${path}.${key}`, depth + 1);
|
|
63
|
-
return record;
|
|
64
|
-
}
|
|
65
|
-
migrateFail("non_json_value", label, `${path} is not JSON-compatible`);
|
|
66
|
-
};
|
|
67
|
-
let raw: unknown;
|
|
68
|
-
try {
|
|
69
|
-
raw = JSON.parse(text);
|
|
70
|
-
} catch (error) {
|
|
71
|
-
migrateFail("invalid_json", label, error instanceof Error ? error.message : String(error));
|
|
72
|
-
}
|
|
73
|
-
// JSON.parse silently keeps the last duplicate key; reject them with a
|
|
74
|
-
// token-level scan over the (now known-valid) document.
|
|
75
|
-
detectDuplicateKeys(text, label);
|
|
76
|
-
return parseNode(raw, "$", 0);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Token-level duplicate key scan: tracks object key sets per nesting level. */
|
|
80
|
-
function detectDuplicateKeys(text: string, label: string): void {
|
|
81
|
-
const stack: Array<Set<string> | "array"> = [];
|
|
82
|
-
let index = 0;
|
|
83
|
-
const length = text.length;
|
|
84
|
-
const skipWhitespace = (): void => {
|
|
85
|
-
while (index < length && /[\s]/.test(text[index] as string)) index += 1;
|
|
86
|
-
};
|
|
87
|
-
const parseString = (): string => {
|
|
88
|
-
let result = "";
|
|
89
|
-
index += 1; // opening quote
|
|
90
|
-
while (index < length) {
|
|
91
|
-
const char = text[index] as string;
|
|
92
|
-
if (char === '"') { index += 1; return result; }
|
|
93
|
-
if (char === "\\") {
|
|
94
|
-
const escape = text[index + 1] as string;
|
|
95
|
-
if (escape === "u") {
|
|
96
|
-
result += String.fromCharCode(Number.parseInt(text.slice(index + 2, index + 6), 16));
|
|
97
|
-
index += 6;
|
|
98
|
-
} else {
|
|
99
|
-
const escapes: Record<string, string> = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
|
100
|
-
result += escapes[escape] ?? escape;
|
|
101
|
-
index += 2;
|
|
102
|
-
}
|
|
103
|
-
} else {
|
|
104
|
-
result += char;
|
|
105
|
-
index += 1;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
migrateFail("invalid_json", label, "unterminated string");
|
|
109
|
-
};
|
|
110
|
-
const skipValue = (): void => {
|
|
111
|
-
skipWhitespace();
|
|
112
|
-
const char = text[index] as string;
|
|
113
|
-
if (char === '"') { parseString(); return; }
|
|
114
|
-
if (char === "{" || char === "[") { parseContainer(); return; }
|
|
115
|
-
while (index < length && !/[\s,\]}]/.test(text[index] as string)) index += 1;
|
|
116
|
-
};
|
|
117
|
-
const parseContainer = (): void => {
|
|
118
|
-
const char = text[index] as string;
|
|
119
|
-
if (stack.length > MAX_LEGACY_JSON_DEPTH) migrateFail("depth_limit", label, `document exceeds nesting depth ${MAX_LEGACY_JSON_DEPTH}`);
|
|
120
|
-
if (char === "{") {
|
|
121
|
-
const keys = new Set<string>();
|
|
122
|
-
stack.push(keys);
|
|
123
|
-
index += 1;
|
|
124
|
-
skipWhitespace();
|
|
125
|
-
if (text[index] === "}") { index += 1; stack.pop(); return; }
|
|
126
|
-
for (;;) {
|
|
127
|
-
skipWhitespace();
|
|
128
|
-
if (text[index] !== '"') migrateFail("invalid_json", label, `expected object key at byte ${index}`);
|
|
129
|
-
const key = parseString();
|
|
130
|
-
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
131
|
-
migrateFail("forbidden_key", label, `key ${JSON.stringify(key)} is forbidden`);
|
|
132
|
-
}
|
|
133
|
-
const frame = stack[stack.length - 1];
|
|
134
|
-
if (frame instanceof Set) {
|
|
135
|
-
if (frame.has(key)) migrateFail("duplicate_key", label, `duplicate key ${JSON.stringify(key)}`);
|
|
136
|
-
frame.add(key);
|
|
137
|
-
}
|
|
138
|
-
skipWhitespace();
|
|
139
|
-
if (text[index] !== ":") migrateFail("invalid_json", label, `expected ':' at byte ${index}`);
|
|
140
|
-
index += 1;
|
|
141
|
-
skipValue();
|
|
142
|
-
skipWhitespace();
|
|
143
|
-
if (text[index] === ",") { index += 1; continue; }
|
|
144
|
-
if (text[index] === "}") { index += 1; stack.pop(); return; }
|
|
145
|
-
migrateFail("invalid_json", label, `expected ',' or '}' at byte ${index}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (char === "[") {
|
|
149
|
-
stack.push("array");
|
|
150
|
-
index += 1;
|
|
151
|
-
skipWhitespace();
|
|
152
|
-
if (text[index] === "]") { index += 1; stack.pop(); return; }
|
|
153
|
-
for (;;) {
|
|
154
|
-
skipValue();
|
|
155
|
-
skipWhitespace();
|
|
156
|
-
if (text[index] === ",") { index += 1; continue; }
|
|
157
|
-
if (text[index] === "]") { index += 1; stack.pop(); return; }
|
|
158
|
-
migrateFail("invalid_json", label, `expected ',' or ']' at byte ${index}`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
skipValue();
|
|
163
|
-
skipWhitespace();
|
|
164
|
-
if (index !== length) migrateFail("invalid_json", label, `trailing content at byte ${index}`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/* ------------------------------------------------------------------ *
|
|
168
|
-
* Legacy source model
|
|
169
|
-
* ------------------------------------------------------------------ */
|
|
170
|
-
|
|
171
|
-
export interface KxmLegacySource {
|
|
172
|
-
path: string;
|
|
173
|
-
sha256: string;
|
|
174
|
-
bytes: number;
|
|
175
|
-
value: JsonValue;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function sha256Of(input: string | Uint8Array): string {
|
|
179
|
-
return `sha256:${createHash("sha256").update(input).digest("hex")}`;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Read every legacy configuration file with bounds and exact duplicate-key rejection. */
|
|
183
|
-
export function readKxmLegacySources(root: string): KxmLegacySource[] {
|
|
184
|
-
const files = legacyConfigFilesAt(root);
|
|
185
|
-
if (files.length > MAX_LEGACY_SOURCES) {
|
|
186
|
-
throw new KxmConfigError([migrateIssue("discovery", "legacy_source_limit", ".kxm/config", `legacy configuration exceeds ${MAX_LEGACY_SOURCES} files`)]);
|
|
187
|
-
}
|
|
188
|
-
return files.map((path) => {
|
|
189
|
-
const absolute = join(root, ...path.split("/"));
|
|
190
|
-
const stat = lstatSync(absolute);
|
|
191
|
-
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
192
|
-
throw new KxmConfigError([migrateIssue("path", "legacy_source_invalid", path, "legacy configuration must be a regular file, not a link")]);
|
|
193
|
-
}
|
|
194
|
-
const buffer = readFileSync(absolute);
|
|
195
|
-
let text: string;
|
|
196
|
-
try {
|
|
197
|
-
text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
198
|
-
} catch {
|
|
199
|
-
throw new KxmConfigError([migrateIssue("parse", "invalid_utf8", path, "document is not valid UTF-8")]);
|
|
200
|
-
}
|
|
201
|
-
return {
|
|
202
|
-
path,
|
|
203
|
-
sha256: sha256Of(buffer),
|
|
204
|
-
bytes: buffer.byteLength,
|
|
205
|
-
value: parseLegacyJson(text, path),
|
|
206
|
-
};
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export function legacySourceDigest(sources: readonly KxmLegacySource[]): string {
|
|
211
|
-
return sha256Of(kxmCanonicalJson(sources.map((source) => ({ path: source.path, sha256: source.sha256, bytes: source.bytes }))));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/* ------------------------------------------------------------------ *
|
|
215
|
-
* Conversion
|
|
216
|
-
* ------------------------------------------------------------------ */
|
|
217
|
-
|
|
218
|
-
const IDENTIFIER = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
|
|
219
|
-
const KNOWN_AGENT_FIELDS = new Set(["name", "kind", "driver", "model", "thinking", "purpose"]);
|
|
220
|
-
const KNOWN_GATE_FIELDS = new Set(["name", "kind", "driver", "purpose"]);
|
|
221
|
-
const KNOWN_WORKFLOW_FIELDS = new Set([
|
|
222
|
-
"id", "source", "project", "target", "secret", "secretEnv", "signalSecret", "signalSecretEnv",
|
|
223
|
-
"event", "filter", "delivery", "ttlMs", "promptTemplate", "stages", "maxTransitions",
|
|
224
|
-
"reproOracle", "planHash", "requirePlanHash",
|
|
225
|
-
]);
|
|
226
|
-
const KNOWN_STAGE_FIELDS = new Set(["id", "label", "instructions", "requiredEvidence", "maxAttempts", "area", "evidencePolicies", "on", "maxTransitions"]);
|
|
227
|
-
const KNOWN_POLICY_FIELDS = new Set(["kind", "minProducers", "eligibleAgents", "acceptedStatuses", "degradation"]);
|
|
228
|
-
const KNOWN_TRANSITION_FIELDS = new Set(["target", "maxTransitions"]);
|
|
229
|
-
|
|
230
|
-
export interface KxmMigrationAmbiguity {
|
|
231
|
-
key: string;
|
|
232
|
-
category: "unsupported" | "identity" | "step-kind" | "terminal-status" | "transition-budget" | "evidence-policy" | "permission" | "secret-reference";
|
|
233
|
-
sourcePath: string;
|
|
234
|
-
message: string;
|
|
235
|
-
allowedValues: readonly (string | number)[];
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export interface KxmMigrationUnmapped {
|
|
239
|
-
sourcePath: string;
|
|
240
|
-
jsonPointer: string;
|
|
241
|
-
valueSha256: string;
|
|
242
|
-
sensitive: boolean;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export interface KxmMigrationRename {
|
|
246
|
-
from: string;
|
|
247
|
-
to: string;
|
|
248
|
-
kind: "agent" | "model" | "workflow" | "step" | "evidence" | "outcome";
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export interface KxmMigrationPermissionChange {
|
|
252
|
-
sourcePath: string;
|
|
253
|
-
targetPath: string;
|
|
254
|
-
direction: "narrowing" | "expansion" | "unknown";
|
|
255
|
-
summary: string;
|
|
256
|
-
decisionKey: string;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
interface MutablePlan {
|
|
260
|
-
ambiguities: KxmMigrationAmbiguity[];
|
|
261
|
-
invalidDecisions: string[];
|
|
262
|
-
decisionKeys: Set<string>;
|
|
263
|
-
decisionAllowedValues: Map<string, readonly (string | number)[]>;
|
|
264
|
-
unmapped: KxmMigrationUnmapped[];
|
|
265
|
-
renames: KxmMigrationRename[];
|
|
266
|
-
permissionChanges: KxmMigrationPermissionChange[];
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function compareCodeUnits(left: string, right: string): number {
|
|
270
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
function asObject(value: JsonValue | undefined): JsonObject | undefined {
|
|
274
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function asString(value: JsonValue | undefined): string | undefined {
|
|
278
|
-
return typeof value === "string" ? value : undefined;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function normalizeIdentifier(raw: string): string {
|
|
282
|
-
return raw
|
|
283
|
-
.toLocaleLowerCase("en-US")
|
|
284
|
-
.replace(/[^a-z0-9_-]+/g, "-")
|
|
285
|
-
.replace(/[-_]{2,}/g, "-")
|
|
286
|
-
.replace(/^[-_]+/, "")
|
|
287
|
-
.replace(/[-_]+$/, "")
|
|
288
|
-
.replace(/^(?=[0-9])/, "id-");
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function pointerEscape(segment: string): string {
|
|
292
|
-
const escaped = segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
293
|
-
// jsonPointer is schema-bounded at 1024; digest oversized segments so a
|
|
294
|
-
// pathological legacy key can never hard-fail the plan record.
|
|
295
|
-
if (escaped.length <= 200) return escaped;
|
|
296
|
-
const digest = createHash("sha256").update(segment, "utf8").digest("hex").slice(0, 8);
|
|
297
|
-
return `${escaped.slice(0, 180)}~digest-${digest}`;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Truncate a legacy name for plan-record fields with schema length bounds. */
|
|
301
|
-
function elide(raw: string, max = 120): string {
|
|
302
|
-
return raw.length <= max ? raw : `${raw.slice(0, max - 15)}…[${raw.length}]`;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Deterministic, schema-pattern-safe decision-key fragment for a raw legacy name. */
|
|
306
|
-
function keyFragment(raw: string): string {
|
|
307
|
-
const full = normalizeIdentifier(raw) || "unnamed";
|
|
308
|
-
// The 8-hex digest disambiguates; cap the readable portion so composed
|
|
309
|
-
// decision keys always fit the 512-char schema bound.
|
|
310
|
-
const normalized = full.length > 96 ? `${full.slice(0, 96)}-${full.length}` : full;
|
|
311
|
-
const digest = createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 8);
|
|
312
|
-
return `${normalized}-${digest}`;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function recordUnmapped(plan: MutablePlan, sourcePath: string, pointer: string, value: JsonValue | undefined, sensitive: boolean): void {
|
|
316
|
-
// Sensitive fields are never hashed content-wise: a low-entropy secret must
|
|
317
|
-
// not be recoverable by offline guessing against a committed plan. Record
|
|
318
|
-
// only the serialized byte length as proof something was dropped.
|
|
319
|
-
const digestInput = sensitive
|
|
320
|
-
? { redacted: true, bytes: Buffer.byteLength(kxmCanonicalJson(value === undefined ? null : value), "utf8") }
|
|
321
|
-
: (value === undefined ? null : value);
|
|
322
|
-
plan.unmapped.push({
|
|
323
|
-
sourcePath,
|
|
324
|
-
jsonPointer: pointer,
|
|
325
|
-
valueSha256: sha256Of(kxmCanonicalJson(digestInput)),
|
|
326
|
-
sensitive,
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function recordUnknownFields(
|
|
331
|
-
plan: MutablePlan,
|
|
332
|
-
sourcePath: string,
|
|
333
|
-
pointer: string,
|
|
334
|
-
record: JsonObject,
|
|
335
|
-
known: ReadonlySet<string>,
|
|
336
|
-
): void {
|
|
337
|
-
for (const key of Object.keys(record).sort(compareCodeUnits)) {
|
|
338
|
-
if (!known.has(key)) recordUnmapped(plan, sourcePath, `${pointer}/${pointerEscape(key)}`, record[key], false);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
type IdentityOutcome =
|
|
343
|
-
| { status: "adopted"; id: string }
|
|
344
|
-
| { status: "pending" }
|
|
345
|
-
| { status: "dropped" };
|
|
346
|
-
|
|
347
|
-
function mapIdentity(
|
|
348
|
-
plan: MutablePlan,
|
|
349
|
-
sourcePath: string,
|
|
350
|
-
raw: string,
|
|
351
|
-
kind: KxmMigrationRename["kind"],
|
|
352
|
-
taken: Map<string, string>,
|
|
353
|
-
decisions: Readonly<Record<string, JsonValue>>,
|
|
354
|
-
dropPointer?: string,
|
|
355
|
-
dropValue?: JsonValue,
|
|
356
|
-
): IdentityOutcome {
|
|
357
|
-
const normalized = normalizeIdentifier(raw);
|
|
358
|
-
const usable = (candidate: string): boolean => IDENTIFIER.test(candidate) && candidate.length <= 64;
|
|
359
|
-
const adopt = (candidate: string): { outcome: "adopted"; id: string } | { outcome: "taken" } | { outcome: "duplicate" } => {
|
|
360
|
-
const foldedCandidate = candidate.toLocaleLowerCase("en-US");
|
|
361
|
-
const priorCandidate = taken.get(foldedCandidate);
|
|
362
|
-
if (priorCandidate !== undefined && priorCandidate !== raw) return { outcome: "taken" };
|
|
363
|
-
if (priorCandidate === raw) return { outcome: "duplicate" };
|
|
364
|
-
taken.set(foldedCandidate, raw);
|
|
365
|
-
plan.renames.push({ from: elide(raw), to: candidate, kind });
|
|
366
|
-
return { outcome: "adopted", id: candidate };
|
|
367
|
-
};
|
|
368
|
-
const reportDuplicate = (): IdentityOutcome => {
|
|
369
|
-
// Exact duplicate raw identity: never merge two legacy definitions
|
|
370
|
-
// last-wins. The only resolution is an explicit drop of the duplicate;
|
|
371
|
-
// its full content is preserved in the report either way.
|
|
372
|
-
const decided = resolveDecision(
|
|
373
|
-
plan,
|
|
374
|
-
`identity-duplicate:${kind}:${keyFragment(raw)}`,
|
|
375
|
-
"unsupported",
|
|
376
|
-
sourcePath,
|
|
377
|
-
`${kind} name ${JSON.stringify(elide(raw, 200))} is declared more than once; confirm the duplicate definition is dropped`,
|
|
378
|
-
["drop"],
|
|
379
|
-
decisions,
|
|
380
|
-
);
|
|
381
|
-
recordUnmapped(plan, sourcePath, dropPointer ?? `/duplicates/${pointerEscape(raw)}`, dropValue ?? raw, false);
|
|
382
|
-
return decided === "drop" ? { status: "dropped" } : { status: "pending" };
|
|
383
|
-
};
|
|
384
|
-
if (!normalized || !usable(normalized)) {
|
|
385
|
-
const supplied = resolveDecision(
|
|
386
|
-
plan,
|
|
387
|
-
`identity:${kind}:${keyFragment(raw)}`,
|
|
388
|
-
"identity",
|
|
389
|
-
sourcePath,
|
|
390
|
-
`${kind} name ${JSON.stringify(elide(raw, 200))} cannot be normalized to a KXM identifier; supply a decision with the target identifier`,
|
|
391
|
-
["<identifier>"],
|
|
392
|
-
decisions,
|
|
393
|
-
);
|
|
394
|
-
if (typeof supplied === "string" && usable(supplied)) {
|
|
395
|
-
const adopted = adopt(supplied);
|
|
396
|
-
if (adopted.outcome === "adopted") return { status: "adopted", id: adopted.id };
|
|
397
|
-
if (adopted.outcome === "duplicate") return reportDuplicate();
|
|
398
|
-
plan.ambiguities.push({
|
|
399
|
-
key: `identity:${kind}:${keyFragment(raw)}`,
|
|
400
|
-
category: "identity",
|
|
401
|
-
sourcePath,
|
|
402
|
-
message: `decision-supplied identifier ${JSON.stringify(elide(supplied, 200))} for ${kind} ${JSON.stringify(elide(raw, 200))} collides with another migrated identity`,
|
|
403
|
-
allowedValues: ["<identifier>"],
|
|
404
|
-
});
|
|
405
|
-
}
|
|
406
|
-
return { status: "pending" };
|
|
407
|
-
}
|
|
408
|
-
const folded = normalized.toLocaleLowerCase("en-US");
|
|
409
|
-
const prior = taken.get(folded);
|
|
410
|
-
if (prior && prior !== raw) {
|
|
411
|
-
const supplied = resolveDecision(
|
|
412
|
-
plan,
|
|
413
|
-
`identity-collision:${kind}:${folded}-${keyFragment(raw)}`,
|
|
414
|
-
"identity",
|
|
415
|
-
sourcePath,
|
|
416
|
-
`${kind} names ${JSON.stringify(elide(prior, 200))} and ${JSON.stringify(elide(raw, 200))} collide after normalization; supply a distinct identifier for ${JSON.stringify(elide(raw, 200))}`,
|
|
417
|
-
["<identifier>"],
|
|
418
|
-
decisions,
|
|
419
|
-
);
|
|
420
|
-
if (typeof supplied === "string" && usable(supplied)) {
|
|
421
|
-
const adopted = adopt(supplied);
|
|
422
|
-
if (adopted.outcome === "adopted") return { status: "adopted", id: adopted.id };
|
|
423
|
-
if (adopted.outcome === "duplicate") return reportDuplicate();
|
|
424
|
-
plan.ambiguities.push({
|
|
425
|
-
key: `identity-collision:${kind}:${folded}-${keyFragment(raw)}`,
|
|
426
|
-
category: "identity",
|
|
427
|
-
sourcePath,
|
|
428
|
-
message: `decision-supplied identifier ${JSON.stringify(elide(supplied, 200))} for ${kind} ${JSON.stringify(elide(raw, 200))} collides with another migrated identity`,
|
|
429
|
-
allowedValues: ["<identifier>"],
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
return { status: "pending" };
|
|
433
|
-
}
|
|
434
|
-
if (prior === raw) return reportDuplicate();
|
|
435
|
-
taken.set(folded, raw);
|
|
436
|
-
if (normalized !== raw && !plan.renames.some((rename) => rename.kind === kind && rename.from === raw)) {
|
|
437
|
-
plan.renames.push({ from: elide(raw), to: normalized, kind });
|
|
438
|
-
}
|
|
439
|
-
return { status: "adopted", id: normalized };
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** Validate one supplied decision value against the plan's allowed shape. */
|
|
443
|
-
function decisionValueAllowed(key: string, value: JsonValue | undefined, allowed: readonly (string | number)[]): boolean {
|
|
444
|
-
if (value === undefined) return false;
|
|
445
|
-
if (allowed.some((candidate) => candidate === "<identifier>")) {
|
|
446
|
-
return typeof value === "string" && IDENTIFIER.test(value) && value.length <= 64;
|
|
447
|
-
}
|
|
448
|
-
if (allowed.some((candidate) => candidate === "<provider/model>")) {
|
|
449
|
-
if (typeof value !== "string") return false;
|
|
450
|
-
const separator = value.indexOf("/");
|
|
451
|
-
const provider = separator > 0 ? normalizeIdentifier(value.slice(0, separator)) : "";
|
|
452
|
-
const modelId = separator > 0 ? value.slice(separator + 1) : "";
|
|
453
|
-
return IDENTIFIER.test(provider) && provider.length <= 64 && modelId.length >= 1 && modelId.length <= 200;
|
|
454
|
-
}
|
|
455
|
-
// Numeric budget proposals enumerate suggested values; any in-range integer
|
|
456
|
-
// is a valid reviewed choice.
|
|
457
|
-
if (key.startsWith("backedge:")) {
|
|
458
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100;
|
|
459
|
-
}
|
|
460
|
-
if (key.startsWith("budget:")) {
|
|
461
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1000;
|
|
462
|
-
}
|
|
463
|
-
// Roster-choice keys list the roster as a hint (capped at 64 entries for
|
|
464
|
-
// schema bounds); a capped list accepts any identifier-shaped value and the
|
|
465
|
-
// semantic validators (coordinator_unknown/producer_agent_unknown) remain
|
|
466
|
-
// the backstop, while an uncapped list requires exact membership.
|
|
467
|
-
if (key.startsWith("coordinator:") || key.startsWith("producer:")) {
|
|
468
|
-
if (allowed.some((candidate) => candidate === value)) return true;
|
|
469
|
-
return allowed.length >= 64 && typeof value === "string" && IDENTIFIER.test(value) && value.length <= 64;
|
|
470
|
-
}
|
|
471
|
-
return allowed.some((candidate) => candidate === value);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
function resolveDecision(
|
|
475
|
-
plan: MutablePlan,
|
|
476
|
-
key: string,
|
|
477
|
-
category: KxmMigrationAmbiguity["category"],
|
|
478
|
-
sourcePath: string,
|
|
479
|
-
message: string,
|
|
480
|
-
allowedValues: readonly (string | number)[],
|
|
481
|
-
decisions: Readonly<Record<string, JsonValue>>,
|
|
482
|
-
): JsonValue | undefined {
|
|
483
|
-
plan.decisionKeys.add(key);
|
|
484
|
-
plan.decisionAllowedValues.set(key, allowedValues);
|
|
485
|
-
if (Object.hasOwn(decisions, key)) {
|
|
486
|
-
const value = decisions[key];
|
|
487
|
-
if (decisionValueAllowed(key, value, allowedValues)) return value;
|
|
488
|
-
// An invalid supplied value never falls back to a default: the ambiguity
|
|
489
|
-
// persists and the decision is reported invalid at apply.
|
|
490
|
-
if (!plan.invalidDecisions.includes(key)) plan.invalidDecisions.push(key);
|
|
491
|
-
}
|
|
492
|
-
if (!plan.ambiguities.some((ambiguity) => ambiguity.key === key)) {
|
|
493
|
-
plan.ambiguities.push({ key, category, sourcePath, message, allowedValues });
|
|
494
|
-
}
|
|
495
|
-
return undefined;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/* ------------------------- agents roster -------------------------- */
|
|
499
|
-
|
|
500
|
-
interface ConvertedAgents {
|
|
501
|
-
agents: Map<string, JsonObject>;
|
|
502
|
-
models: Map<string, JsonObject>;
|
|
503
|
-
/** Exact legacy raw roster name -> migrated id (case-sensitive first match). */
|
|
504
|
-
rawNames: Map<string, string>;
|
|
505
|
-
/** Raw roster names whose identity decision is still pending (exact set). */
|
|
506
|
-
pendingRawNames: Set<string>;
|
|
507
|
-
/** True when at least one roster agent's identity decision is still pending. */
|
|
508
|
-
identityPending: boolean;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
function convertAgentsRoster(
|
|
512
|
-
plan: MutablePlan,
|
|
513
|
-
source: KxmLegacySource,
|
|
514
|
-
decisions: Readonly<Record<string, JsonValue>>,
|
|
515
|
-
agentNames: Map<string, string>,
|
|
516
|
-
): ConvertedAgents {
|
|
517
|
-
const agents = new Map<string, JsonObject>();
|
|
518
|
-
const models = new Map<string, JsonObject>();
|
|
519
|
-
const rawNames = new Map<string, string>();
|
|
520
|
-
const pendingRawNames = new Set<string>();
|
|
521
|
-
const modelNames = new Map<string, string>();
|
|
522
|
-
let identityPending = false;
|
|
523
|
-
const root = asObject(source.value);
|
|
524
|
-
if (!root) {
|
|
525
|
-
resolveDecision(
|
|
526
|
-
plan,
|
|
527
|
-
`unsupported:${source.path}:/`,
|
|
528
|
-
"unsupported",
|
|
529
|
-
source.path,
|
|
530
|
-
"agents.json must be a JSON object with an agents array; confirm it is dropped from conversion",
|
|
531
|
-
["drop"],
|
|
532
|
-
decisions,
|
|
533
|
-
);
|
|
534
|
-
return { agents, models, rawNames, pendingRawNames, identityPending };
|
|
535
|
-
}
|
|
536
|
-
recordUnknownFields(plan, source.path, "", root, new Set(["schema", "project", "host", "agents", "ownership"]));
|
|
537
|
-
if (root.ownership !== undefined) recordUnmapped(plan, source.path, "/ownership", root.ownership, false);
|
|
538
|
-
if (root.host !== undefined) recordUnmapped(plan, source.path, "/host", root.host, false);
|
|
539
|
-
if (root.project !== undefined) recordUnmapped(plan, source.path, "/project", root.project, false);
|
|
540
|
-
if (root.schema !== undefined && root.schema !== "kxm.agents.v1") recordUnmapped(plan, source.path, "/schema", root.schema, false);
|
|
541
|
-
const list = Array.isArray(root.agents) ? root.agents : [];
|
|
542
|
-
for (const [index, entry] of list.entries()) {
|
|
543
|
-
const pointer = `/agents/${index}`;
|
|
544
|
-
const record = asObject(entry);
|
|
545
|
-
if (!record) {
|
|
546
|
-
recordUnmapped(plan, source.path, pointer, entry, false);
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
recordUnknownFields(plan, source.path, pointer, record, KNOWN_AGENT_FIELDS);
|
|
550
|
-
const name = asString(record.name);
|
|
551
|
-
const kind = asString(record.kind) ?? "agent";
|
|
552
|
-
const driver = asString(record.driver) ?? "ai";
|
|
553
|
-
if (!name || kind !== "agent" || driver !== "ai") {
|
|
554
|
-
resolveDecision(
|
|
555
|
-
plan,
|
|
556
|
-
`unsupported:${source.path}:${pointer}`,
|
|
557
|
-
"unsupported",
|
|
558
|
-
source.path,
|
|
559
|
-
`roster entry ${index} (${name === undefined ? "unnamed" : elide(name, 200)}) has kind ${elide(kind, 64)}/driver ${elide(driver, 64)}; only agent/ai entries convert losslessly — confirm the entry is dropped`,
|
|
560
|
-
["drop"],
|
|
561
|
-
decisions,
|
|
562
|
-
);
|
|
563
|
-
recordUnmapped(plan, source.path, pointer, record, false);
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
// Register the permission decision before identity resolution: the key
|
|
567
|
-
// derives from the raw roster name, so the first plan already lists it
|
|
568
|
-
// even when an identity decision is still pending.
|
|
569
|
-
resolveDecision(
|
|
570
|
-
plan,
|
|
571
|
-
`permission:${keyFragment(name)}`,
|
|
572
|
-
"permission",
|
|
573
|
-
source.path,
|
|
574
|
-
`approve the narrowed tool/repository/network ceiling for agent ${elide(name, 200)}`,
|
|
575
|
-
["approve"],
|
|
576
|
-
decisions,
|
|
577
|
-
);
|
|
578
|
-
const agentIdentity = mapIdentity(plan, source.path, name, "agent", agentNames, decisions, pointer, record);
|
|
579
|
-
if (agentIdentity.status === "pending") {
|
|
580
|
-
identityPending = true;
|
|
581
|
-
pendingRawNames.add(name);
|
|
582
|
-
}
|
|
583
|
-
if (agentIdentity.status !== "adopted") continue;
|
|
584
|
-
const id = agentIdentity.id;
|
|
585
|
-
const agent: JsonObject = {
|
|
586
|
-
schema: "kxm.agent.v1",
|
|
587
|
-
purpose: asString(record.purpose) ?? `Migrated legacy agent ${elide(name, 200)}.`,
|
|
588
|
-
};
|
|
589
|
-
const model = asString(record.model);
|
|
590
|
-
if (model) {
|
|
591
|
-
const applyModel = (candidate: string): boolean => {
|
|
592
|
-
const separator = candidate.indexOf("/");
|
|
593
|
-
if (separator <= 0) return false;
|
|
594
|
-
const provider = normalizeIdentifier(candidate.slice(0, separator));
|
|
595
|
-
const modelId = candidate.slice(separator + 1);
|
|
596
|
-
if (IDENTIFIER.test(provider) && provider.length <= 64 && modelId.length >= 1 && modelId.length <= 200) {
|
|
597
|
-
agent.model = { provider, model: modelId };
|
|
598
|
-
return true;
|
|
599
|
-
}
|
|
600
|
-
return false;
|
|
601
|
-
};
|
|
602
|
-
if (!applyModel(model)) {
|
|
603
|
-
const supplied = resolveDecision(
|
|
604
|
-
plan,
|
|
605
|
-
`model:${keyFragment(name)}`,
|
|
606
|
-
"identity",
|
|
607
|
-
source.path,
|
|
608
|
-
`agent ${elide(name, 200)} model ${JSON.stringify(elide(model, 200))} does not map to a provider identifier + model id pair; supply <provider>/<model>`,
|
|
609
|
-
["<provider/model>"],
|
|
610
|
-
decisions,
|
|
611
|
-
);
|
|
612
|
-
if (typeof supplied === "string" && supplied !== model) {
|
|
613
|
-
applyModel(supplied);
|
|
614
|
-
} else if (typeof supplied !== "string") {
|
|
615
|
-
recordUnmapped(plan, source.path, `${pointer}/model`, model, false);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
const thinking = asString(record.thinking);
|
|
620
|
-
if (thinking) {
|
|
621
|
-
// KXM agents select models; thinking belongs to a model profile. Emit
|
|
622
|
-
// a per-agent profile when the model mapped cleanly.
|
|
623
|
-
const selector = asObject(agent.model as JsonValue | undefined);
|
|
624
|
-
if (selector && typeof selector.profile !== "string") {
|
|
625
|
-
const profileIdentity = mapIdentity(plan, source.path, `${id}-primary`, "model", modelNames, decisions);
|
|
626
|
-
const profileId = profileIdentity.status === "adopted" ? profileIdentity.id : undefined;
|
|
627
|
-
if (profileId) {
|
|
628
|
-
const profile: JsonObject = {
|
|
629
|
-
schema: "kxm.model.v1",
|
|
630
|
-
provider: selector.provider as string,
|
|
631
|
-
model: selector.model as string,
|
|
632
|
-
thinking,
|
|
633
|
-
};
|
|
634
|
-
models.set(profileId, profile);
|
|
635
|
-
agent.model = { profile: profileId };
|
|
636
|
-
}
|
|
637
|
-
} else {
|
|
638
|
-
recordUnmapped(plan, source.path, `${pointer}/thinking`, thinking, false);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
// Legacy workers ran with an interactive tool allowlist chosen at launch;
|
|
642
|
-
// the migrated definition declares the narrowest portable ceiling.
|
|
643
|
-
agent.tools = { preset: "workspace-writer" };
|
|
644
|
-
agent.defaultRepositoryAccess = "none";
|
|
645
|
-
agent.network = "provider-only";
|
|
646
|
-
plan.permissionChanges.push({
|
|
647
|
-
sourcePath: source.path,
|
|
648
|
-
targetPath: `.kxm/agents/${id}.yaml`,
|
|
649
|
-
direction: "unknown",
|
|
650
|
-
summary: `agent ${elide(name, 200)}: legacy workers used launch-time tool flags the converter cannot observe; migrated ceiling is workspace-writer tools, no repository access, provider-only network`,
|
|
651
|
-
decisionKey: `permission:${keyFragment(name)}`,
|
|
652
|
-
});
|
|
653
|
-
agents.set(id, agent);
|
|
654
|
-
if (!rawNames.has(name)) rawNames.set(name, id);
|
|
655
|
-
}
|
|
656
|
-
return { agents, models, rawNames, pendingRawNames, identityPending };
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/* ------------------------- gates roster --------------------------- */
|
|
660
|
-
|
|
661
|
-
const IMPLEMENTED_GATES = new Set(["test", "scm-delivery"]);
|
|
662
|
-
|
|
663
|
-
function convertGatesRoster(
|
|
664
|
-
plan: MutablePlan,
|
|
665
|
-
source: KxmLegacySource,
|
|
666
|
-
decisions: Readonly<Record<string, JsonValue>>,
|
|
667
|
-
): void {
|
|
668
|
-
const root = asObject(source.value);
|
|
669
|
-
if (!root) {
|
|
670
|
-
resolveDecision(
|
|
671
|
-
plan,
|
|
672
|
-
`unsupported:${source.path}:/`,
|
|
673
|
-
"unsupported",
|
|
674
|
-
source.path,
|
|
675
|
-
"gates.json must be a JSON object with a gates array; confirm it is dropped from conversion",
|
|
676
|
-
["drop"],
|
|
677
|
-
decisions,
|
|
678
|
-
);
|
|
679
|
-
return;
|
|
680
|
-
}
|
|
681
|
-
recordUnknownFields(plan, source.path, "", root, new Set(["schema", "gates"]));
|
|
682
|
-
if (root.schema !== undefined && root.schema !== "kxm.gates.v1") recordUnmapped(plan, source.path, "/schema", root.schema, false);
|
|
683
|
-
const list = Array.isArray(root.gates) ? root.gates : [];
|
|
684
|
-
for (const [index, entry] of list.entries()) {
|
|
685
|
-
const pointer = `/gates/${index}`;
|
|
686
|
-
const record = asObject(entry);
|
|
687
|
-
if (!record) {
|
|
688
|
-
recordUnmapped(plan, source.path, pointer, entry, false);
|
|
689
|
-
continue;
|
|
690
|
-
}
|
|
691
|
-
recordUnknownFields(plan, source.path, pointer, record, KNOWN_GATE_FIELDS);
|
|
692
|
-
const name = asString(record.name) ?? `gate-${index}`;
|
|
693
|
-
if (!IMPLEMENTED_GATES.has(name)) {
|
|
694
|
-
resolveDecision(
|
|
695
|
-
plan,
|
|
696
|
-
`gate:${keyFragment(name)}`,
|
|
697
|
-
"unsupported",
|
|
698
|
-
source.path,
|
|
699
|
-
`gate ${JSON.stringify(elide(name, 200))} has no registered deterministic runner in KXM; it is preserved in the migration report only`,
|
|
700
|
-
["report-only"],
|
|
701
|
-
decisions,
|
|
702
|
-
);
|
|
703
|
-
recordUnmapped(plan, source.path, pointer, entry as JsonValue, false);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/* ------------------------- workflows ------------------------------ */
|
|
709
|
-
|
|
710
|
-
interface StageContext {
|
|
711
|
-
workflowId: string;
|
|
712
|
-
/** Stable decision-key fragment derived from the raw legacy workflow id. */
|
|
713
|
-
workflowKey: string;
|
|
714
|
-
stageIds: string[];
|
|
715
|
-
renamesByKind: Map<string, string>;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function mapStageRef(context: StageContext, raw: string): string {
|
|
719
|
-
return context.renamesByKind.get(raw) ?? normalizeIdentifier(raw);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
function convertTransition(
|
|
723
|
-
plan: MutablePlan,
|
|
724
|
-
source: KxmLegacySource,
|
|
725
|
-
context: StageContext,
|
|
726
|
-
stageId: string,
|
|
727
|
-
stageKey: string,
|
|
728
|
-
outcome: string,
|
|
729
|
-
rawOutcome: string,
|
|
730
|
-
rawValue: JsonValue,
|
|
731
|
-
decisions: Readonly<Record<string, JsonValue>>,
|
|
732
|
-
stagePointer: string,
|
|
733
|
-
): JsonValue | undefined {
|
|
734
|
-
const pointer = `${stagePointer}/on/${pointerEscape(rawOutcome)}`;
|
|
735
|
-
const rule = typeof rawValue === "string" ? { target: rawValue } : asObject(rawValue);
|
|
736
|
-
if (!rule) {
|
|
737
|
-
recordUnmapped(plan, source.path, pointer, rawValue, false);
|
|
738
|
-
return undefined;
|
|
739
|
-
}
|
|
740
|
-
if (typeof rawValue !== "string") recordUnknownFields(plan, source.path, pointer, rule, KNOWN_TRANSITION_FIELDS);
|
|
741
|
-
const target = asString(rule.target);
|
|
742
|
-
if (!target) {
|
|
743
|
-
recordUnmapped(plan, source.path, pointer, rawValue, false);
|
|
744
|
-
return undefined;
|
|
745
|
-
}
|
|
746
|
-
const sourceIndex = context.stageIds.indexOf(stageId);
|
|
747
|
-
if (target === "$terminal") {
|
|
748
|
-
// Legacy $terminal always completes the run successfully regardless of
|
|
749
|
-
// outcome; KXM requires an explicit terminal status.
|
|
750
|
-
const chosen = resolveDecision(
|
|
751
|
-
plan,
|
|
752
|
-
`terminal:${context.workflowKey}:${stageKey}:${outcome}`,
|
|
753
|
-
"terminal-status",
|
|
754
|
-
source.path,
|
|
755
|
-
`legacy transition ${context.workflowId}/${stageId} outcome ${outcome} ends the run; choose the KXM terminal status (legacy semantics completed the run even on failure outcomes)`,
|
|
756
|
-
outcome === "passed" ? ["completed"] : ["completed", "failed", "cancelled"],
|
|
757
|
-
decisions,
|
|
758
|
-
);
|
|
759
|
-
const status = typeof chosen === "string" && ["completed", "failed", "cancelled"].includes(chosen)
|
|
760
|
-
? chosen
|
|
761
|
-
: "completed";
|
|
762
|
-
const objectRule = asObject(rawValue);
|
|
763
|
-
const result: JsonObject = { target: "$terminal", terminalStatus: status };
|
|
764
|
-
if (typeof objectRule?.maxTransitions === "number") {
|
|
765
|
-
if (Number.isInteger(objectRule.maxTransitions) && objectRule.maxTransitions >= 1 && objectRule.maxTransitions <= 100) {
|
|
766
|
-
result.maxTransitions = objectRule.maxTransitions;
|
|
767
|
-
} else {
|
|
768
|
-
recordUnmapped(plan, source.path, `${pointer}/maxTransitions`, objectRule.maxTransitions, false);
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
return result;
|
|
772
|
-
}
|
|
773
|
-
const targetId = mapStageRef(context, target);
|
|
774
|
-
const targetIndex = context.stageIds.indexOf(targetId);
|
|
775
|
-
if (targetIndex < 0) {
|
|
776
|
-
resolveDecision(
|
|
777
|
-
plan,
|
|
778
|
-
`transition-target:${context.workflowKey}:${stageKey}:${outcome}`,
|
|
779
|
-
"identity",
|
|
780
|
-
source.path,
|
|
781
|
-
`transition target ${JSON.stringify(elide(target, 200))} is not a stage of workflow ${context.workflowId}; confirm the edge is dropped`,
|
|
782
|
-
["drop"],
|
|
783
|
-
decisions,
|
|
784
|
-
);
|
|
785
|
-
recordUnmapped(plan, source.path, pointer, rawValue, false);
|
|
786
|
-
return undefined;
|
|
787
|
-
}
|
|
788
|
-
const objectRule = asObject(rawValue);
|
|
789
|
-
let explicitBudget: number | undefined;
|
|
790
|
-
if (typeof objectRule?.maxTransitions === "number") {
|
|
791
|
-
if (Number.isInteger(objectRule.maxTransitions) && objectRule.maxTransitions >= 1 && objectRule.maxTransitions <= 100) {
|
|
792
|
-
explicitBudget = objectRule.maxTransitions;
|
|
793
|
-
} else {
|
|
794
|
-
// Out-of-range per-edge budget: preserve it and treat the edge as
|
|
795
|
-
// unbounded (back-edges then require an explicit budget decision).
|
|
796
|
-
recordUnmapped(plan, source.path, `${pointer}/maxTransitions`, objectRule.maxTransitions, false);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
const isBackEdge = targetIndex <= sourceIndex;
|
|
800
|
-
if (isBackEdge && explicitBudget === undefined) {
|
|
801
|
-
const chosen = resolveDecision(
|
|
802
|
-
plan,
|
|
803
|
-
`backedge:${context.workflowKey}:${stageKey}:${outcome}`,
|
|
804
|
-
"transition-budget",
|
|
805
|
-
source.path,
|
|
806
|
-
`back-edge ${context.workflowId}/${stageId} -> ${targetId} (outcome ${outcome}) has no per-edge budget in legacy; KXM requires maxTransitions (1-100)`,
|
|
807
|
-
[2, 3, 4, 5],
|
|
808
|
-
decisions,
|
|
809
|
-
);
|
|
810
|
-
const budget = typeof chosen === "number" && Number.isInteger(chosen) && chosen >= 1 && chosen <= 100 ? chosen : 2;
|
|
811
|
-
return { target: targetId, maxTransitions: budget };
|
|
812
|
-
}
|
|
813
|
-
if (explicitBudget !== undefined) return { target: targetId, maxTransitions: explicitBudget };
|
|
814
|
-
return targetId;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
function convertWorkflowFile(
|
|
818
|
-
plan: MutablePlan,
|
|
819
|
-
source: KxmLegacySource,
|
|
820
|
-
decisions: Readonly<Record<string, JsonValue>>,
|
|
821
|
-
agentIds: ReadonlyMap<string, JsonObject>,
|
|
822
|
-
agentRawNames: ReadonlyMap<string, string>,
|
|
823
|
-
pendingAgentRawNames: ReadonlySet<string>,
|
|
824
|
-
rosterIdentityPending: boolean,
|
|
825
|
-
workflowNames: Map<string, string>,
|
|
826
|
-
): Map<string, JsonObject> {
|
|
827
|
-
const workflows = new Map<string, JsonObject>();
|
|
828
|
-
const list = Array.isArray(source.value) ? source.value : [source.value];
|
|
829
|
-
for (const [definitionIndex, entry] of list.entries()) {
|
|
830
|
-
const definition = asObject(entry);
|
|
831
|
-
const basePointer = Array.isArray(source.value) ? `/${definitionIndex}` : "";
|
|
832
|
-
if (!definition) {
|
|
833
|
-
recordUnmapped(plan, source.path, basePointer || "/", entry, false);
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
|
-
recordUnknownFields(plan, source.path, basePointer, definition, KNOWN_WORKFLOW_FIELDS);
|
|
837
|
-
const fileStem = source.path.slice(source.path.lastIndexOf("/") + 1).replace(/\.json$/u, "");
|
|
838
|
-
const rawId = asString(definition.id) ?? `${fileStem}-${definitionIndex}`;
|
|
839
|
-
const workflowIdentity = mapIdentity(plan, source.path, rawId, "workflow", workflowNames, decisions, basePointer || "/", definition);
|
|
840
|
-
// An approved drop removes the workflow entirely; a pending identity
|
|
841
|
-
// decision uses a reserved preview id so every downstream decision
|
|
842
|
-
// (secrets, budgets, terminals) is still listed in this plan. The
|
|
843
|
-
// placeholder can never be applied: its identity ambiguity blocks it.
|
|
844
|
-
if (workflowIdentity.status === "dropped") continue;
|
|
845
|
-
const workflowId = workflowIdentity.status === "adopted"
|
|
846
|
-
? workflowIdentity.id
|
|
847
|
-
: `preview-${(normalizeIdentifier(rawId) || "unnamed").slice(0, 46).replace(/[-_]+$/u, "")}-${createHash("sha256").update(`${rawId}#${source.path}#${definitionIndex}`, "utf8").digest("hex").slice(0, 8)}`;
|
|
848
|
-
const workflowKey = keyFragment(rawId);
|
|
849
|
-
|
|
850
|
-
// Hub/webhook-specific fields have no KXM workflow equivalent; the
|
|
851
|
-
// trigger surface is a later phase. Record them (secrets hashed).
|
|
852
|
-
for (const field of ["source", "project", "event", "filter", "delivery", "ttlMs", "promptTemplate"] as const) {
|
|
853
|
-
if (definition[field] !== undefined) recordUnmapped(plan, source.path, `${basePointer}/${field}`, definition[field], false);
|
|
854
|
-
}
|
|
855
|
-
for (const field of ["secret", "signalSecret", "secretEnv", "signalSecretEnv"] as const) {
|
|
856
|
-
if (definition[field] !== undefined) {
|
|
857
|
-
recordUnmapped(plan, source.path, `${basePointer}/${field}`, definition[field], true);
|
|
858
|
-
resolveDecision(
|
|
859
|
-
plan,
|
|
860
|
-
`secret:${workflowKey}:${field}`,
|
|
861
|
-
"secret-reference",
|
|
862
|
-
source.path,
|
|
863
|
-
`workflow ${workflowId} field ${field} configures a webhook secret; KXM workflows never embed secrets — confirm the field is dropped and re-granted through scoped secret references when the trigger surface lands`,
|
|
864
|
-
["drop"],
|
|
865
|
-
decisions,
|
|
866
|
-
);
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
const rawTarget = asString(definition.target);
|
|
871
|
-
const normalizedTarget = rawTarget ? normalizeIdentifier(rawTarget) : undefined;
|
|
872
|
-
// Exact raw roster match first, then case-insensitive raw match; a bare
|
|
873
|
-
// normalized lookup could silently bind a collision-renamed agent.
|
|
874
|
-
let stepAgent = rawTarget !== undefined ? agentRawNames.get(rawTarget) : undefined;
|
|
875
|
-
if (!stepAgent && rawTarget !== undefined && pendingAgentRawNames.has(rawTarget)) {
|
|
876
|
-
// Exact pending-raw match wins over any adopted case-fold sibling.
|
|
877
|
-
stepAgent = undefined;
|
|
878
|
-
} else if (!stepAgent && rawTarget !== undefined) {
|
|
879
|
-
const foldedTarget = rawTarget.toLocaleLowerCase("en-US");
|
|
880
|
-
for (const [rawName, id] of agentRawNames) {
|
|
881
|
-
if (rawName.toLocaleLowerCase("en-US") === foldedTarget) { stepAgent = id; break; }
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
if (!stepAgent && agentRawNames.size === 0 && normalizedTarget && agentIds.has(normalizedTarget)) {
|
|
885
|
-
// No roster: a normalized lookup cannot misbind a collision rename.
|
|
886
|
-
stepAgent = normalizedTarget;
|
|
887
|
-
}
|
|
888
|
-
const targetIsPendingAgent = rawTarget !== undefined
|
|
889
|
-
&& [...pendingAgentRawNames].some((pending) => pending === rawTarget || pending.toLocaleLowerCase("en-US") === rawTarget.toLocaleLowerCase("en-US"));
|
|
890
|
-
if (!stepAgent && targetIsPendingAgent) {
|
|
891
|
-
// The target is a roster agent whose identity decision is still
|
|
892
|
-
// pending: no separate coordinator decision (it would be unstable);
|
|
893
|
-
// the placeholder binds by raw name once identities resolve.
|
|
894
|
-
stepAgent = normalizedTarget && IDENTIFIER.test(normalizedTarget) && normalizedTarget.length <= 64 ? normalizedTarget : "coordinator";
|
|
895
|
-
}
|
|
896
|
-
if (!stepAgent) {
|
|
897
|
-
if (agentIds.size === 0 && rosterIdentityPending) {
|
|
898
|
-
// Every roster identity is still pending: the coordinator choice
|
|
899
|
-
// resolves itself once the identity decisions land, so no separate
|
|
900
|
-
// coordinator decision is offered (its ambiguity would be unstable).
|
|
901
|
-
stepAgent = normalizedTarget && IDENTIFIER.test(normalizedTarget) && normalizedTarget.length <= 64 ? normalizedTarget : "coordinator";
|
|
902
|
-
} else if (agentIds.size === 0) {
|
|
903
|
-
const dropped = resolveDecision(
|
|
904
|
-
plan,
|
|
905
|
-
`coordinator:${workflowKey}`,
|
|
906
|
-
"identity",
|
|
907
|
-
source.path,
|
|
908
|
-
`workflow ${workflowId} target ${JSON.stringify(rawTarget === undefined ? "" : elide(rawTarget, 200))} matches no migrated agent and the roster is empty; confirm the workflow is dropped`,
|
|
909
|
-
["drop"],
|
|
910
|
-
decisions,
|
|
911
|
-
);
|
|
912
|
-
if (dropped === "drop") continue;
|
|
913
|
-
stepAgent = normalizedTarget && IDENTIFIER.test(normalizedTarget) && normalizedTarget.length <= 64 ? normalizedTarget : "coordinator";
|
|
914
|
-
} else {
|
|
915
|
-
const chosen = resolveDecision(
|
|
916
|
-
plan,
|
|
917
|
-
`coordinator:${workflowKey}`,
|
|
918
|
-
"identity",
|
|
919
|
-
source.path,
|
|
920
|
-
`workflow ${workflowId} target ${JSON.stringify(rawTarget === undefined ? "" : elide(rawTarget, 200))} is not a migrated agent; choose the roster agent that executes its steps`,
|
|
921
|
-
[...agentIds.keys()].sort(compareCodeUnits).slice(0, 64),
|
|
922
|
-
decisions,
|
|
923
|
-
);
|
|
924
|
-
if (typeof chosen === "string" && agentIds.has(chosen)) {
|
|
925
|
-
stepAgent = chosen;
|
|
926
|
-
if (rawTarget) plan.renames.push({ from: elide(rawTarget), to: chosen, kind: "agent" });
|
|
927
|
-
} else {
|
|
928
|
-
if (chosen !== undefined && !plan.invalidDecisions.includes(`coordinator:${workflowKey}`)) {
|
|
929
|
-
plan.invalidDecisions.push(`coordinator:${workflowKey}`);
|
|
930
|
-
}
|
|
931
|
-
if (chosen !== undefined && !plan.ambiguities.some((ambiguity) => ambiguity.key === `coordinator:${workflowKey}`)) {
|
|
932
|
-
plan.ambiguities.push({
|
|
933
|
-
key: `coordinator:${workflowKey}`,
|
|
934
|
-
category: "identity",
|
|
935
|
-
sourcePath: source.path,
|
|
936
|
-
message: `decision value ${JSON.stringify(typeof chosen === "string" ? elide(chosen, 200) : chosen)} for workflow ${workflowId} is not a migrated agent`,
|
|
937
|
-
allowedValues: [...agentIds.keys()].sort(compareCodeUnits).slice(0, 64),
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
// Preview placeholder: keep converting so every downstream decision
|
|
941
|
-
// (terminal status, budgets, policies) is already listed in this
|
|
942
|
-
// plan. Semantic validation only gates the fully resolved plan.
|
|
943
|
-
stepAgent = normalizedTarget && IDENTIFIER.test(normalizedTarget) && normalizedTarget.length <= 64 ? normalizedTarget : "coordinator";
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
const rawStages = Array.isArray(definition.stages) ? definition.stages : [];
|
|
949
|
-
const stageIds: string[] = [];
|
|
950
|
-
const stageRename = new Map<string, string>();
|
|
951
|
-
const stepIdByIndex = new Map<number, string>();
|
|
952
|
-
const stageKeyByRaw = new Map<string, string>();
|
|
953
|
-
const droppedStageIndexes = new Set<number>();
|
|
954
|
-
const takenSteps = new Map<string, string>();
|
|
955
|
-
for (const [stageIndex, stageEntry] of rawStages.entries()) {
|
|
956
|
-
const stage = asObject(stageEntry);
|
|
957
|
-
if (!stage) {
|
|
958
|
-
recordUnmapped(plan, source.path, `${basePointer}/stages/${stageIndex}`, stageEntry, false);
|
|
959
|
-
droppedStageIndexes.add(stageIndex);
|
|
960
|
-
continue;
|
|
961
|
-
}
|
|
962
|
-
const rawStageId = asString(stage.id);
|
|
963
|
-
if (!rawStageId) {
|
|
964
|
-
recordUnmapped(plan, source.path, `${basePointer}/stages/${stageIndex}`, stage, false);
|
|
965
|
-
droppedStageIndexes.add(stageIndex);
|
|
966
|
-
continue;
|
|
967
|
-
}
|
|
968
|
-
// An approved drop removes the stage; a pending step identity decision
|
|
969
|
-
// uses a preview id (unique per stage index) so its transitions and
|
|
970
|
-
// evidence still register in this plan.
|
|
971
|
-
const stepIdentity = mapIdentity(plan, source.path, rawStageId, "step", takenSteps, decisions, `${basePointer}/stages/${stageIndex}`, stage);
|
|
972
|
-
if (stepIdentity.status === "dropped") {
|
|
973
|
-
droppedStageIndexes.add(stageIndex);
|
|
974
|
-
continue;
|
|
975
|
-
}
|
|
976
|
-
const stepId = stepIdentity.status === "adopted" ? stepIdentity.id : `preview-${(normalizeIdentifier(rawStageId) || "unnamed").slice(0, 46).replace(/[-_]+$/u, "")}-${createHash("sha256").update(`${rawStageId}#${stageIndex}`, "utf8").digest("hex").slice(0, 8)}`;
|
|
977
|
-
const stepKey = keyFragment(rawStageId);
|
|
978
|
-
stageKeyByRaw.set(rawStageId, stepKey);
|
|
979
|
-
// Instruction-less stages are excluded up front (with their drop
|
|
980
|
-
// decision registered here) so explicit edges and the default passed
|
|
981
|
-
// edge flow through the transition-target drop decision and skip to the
|
|
982
|
-
// next surviving stage.
|
|
983
|
-
if (!asString(stage.instructions)) {
|
|
984
|
-
resolveDecision(
|
|
985
|
-
plan,
|
|
986
|
-
`instructions:${workflowKey}:${stepKey}`,
|
|
987
|
-
"unsupported",
|
|
988
|
-
source.path,
|
|
989
|
-
`stage ${stepId} has no instructions; KXM agent steps require executable instructions — confirm the stage is dropped`,
|
|
990
|
-
["drop"],
|
|
991
|
-
decisions,
|
|
992
|
-
);
|
|
993
|
-
recordUnmapped(plan, source.path, `${basePointer}/stages/${stageIndex}`, stage, false);
|
|
994
|
-
droppedStageIndexes.add(stageIndex);
|
|
995
|
-
continue;
|
|
996
|
-
}
|
|
997
|
-
if (!stageRename.has(rawStageId)) stageRename.set(rawStageId, stepId);
|
|
998
|
-
stepIdByIndex.set(stageIndex, stepId);
|
|
999
|
-
stageIds.push(stepId);
|
|
1000
|
-
}
|
|
1001
|
-
const context: StageContext = { workflowId, workflowKey, stageIds, renamesByKind: stageRename };
|
|
1002
|
-
|
|
1003
|
-
const steps: JsonObject[] = [];
|
|
1004
|
-
for (const [stageIndex, stageEntry] of rawStages.entries()) {
|
|
1005
|
-
if (droppedStageIndexes.has(stageIndex)) continue;
|
|
1006
|
-
const stage = asObject(stageEntry);
|
|
1007
|
-
if (!stage) continue;
|
|
1008
|
-
const rawStageId = asString(stage.id);
|
|
1009
|
-
if (!rawStageId) continue;
|
|
1010
|
-
const stepId = stepIdByIndex.get(stageIndex);
|
|
1011
|
-
if (!stepId) continue;
|
|
1012
|
-
const stepKey = stageKeyByRaw.get(rawStageId) ?? keyFragment(rawStageId);
|
|
1013
|
-
const pointer = `${basePointer}/stages/${stageIndex}`;
|
|
1014
|
-
recordUnknownFields(plan, source.path, pointer, stage, KNOWN_STAGE_FIELDS);
|
|
1015
|
-
const label = asString(stage.label);
|
|
1016
|
-
const instructions = asString(stage.instructions);
|
|
1017
|
-
if (!instructions) continue; // excluded in the pre-pass above
|
|
1018
|
-
const step: JsonObject = {
|
|
1019
|
-
id: stepId,
|
|
1020
|
-
kind: "agent",
|
|
1021
|
-
agent: stepAgent,
|
|
1022
|
-
};
|
|
1023
|
-
if (label) step.description = label;
|
|
1024
|
-
let maxAttempts = 3;
|
|
1025
|
-
if (typeof stage.maxAttempts === "number" && Number.isInteger(stage.maxAttempts) && stage.maxAttempts >= 1 && stage.maxAttempts <= 20) {
|
|
1026
|
-
maxAttempts = stage.maxAttempts;
|
|
1027
|
-
} else if (stage.maxAttempts !== undefined) {
|
|
1028
|
-
// Out-of-range or non-numeric: preserve and use the legacy default.
|
|
1029
|
-
recordUnmapped(plan, source.path, `${pointer}/maxAttempts`, stage.maxAttempts, false);
|
|
1030
|
-
}
|
|
1031
|
-
step.maxAttempts = maxAttempts;
|
|
1032
|
-
step.instructions = instructions;
|
|
1033
|
-
|
|
1034
|
-
const requiredEvidence: JsonObject[] = [];
|
|
1035
|
-
const policyProducers = new Set<string>();
|
|
1036
|
-
let policyProducerMinimum = 0;
|
|
1037
|
-
const rawEvidence = Array.isArray(stage.requiredEvidence) ? stage.requiredEvidence : [];
|
|
1038
|
-
const seenKeys = new Set<string>();
|
|
1039
|
-
for (const [evidenceIndex, evidenceEntry] of rawEvidence.entries()) {
|
|
1040
|
-
const rawKey = asString(evidenceEntry);
|
|
1041
|
-
if (!rawKey) {
|
|
1042
|
-
recordUnmapped(plan, source.path, `${pointer}/requiredEvidence/${evidenceIndex}`, evidenceEntry, false);
|
|
1043
|
-
continue;
|
|
1044
|
-
}
|
|
1045
|
-
const key = normalizeIdentifier(rawKey);
|
|
1046
|
-
if (!IDENTIFIER.test(key) || key.length > 64) {
|
|
1047
|
-
recordUnmapped(plan, source.path, `${pointer}/requiredEvidence/${evidenceIndex}`, evidenceEntry, false);
|
|
1048
|
-
continue;
|
|
1049
|
-
}
|
|
1050
|
-
if (seenKeys.has(key)) {
|
|
1051
|
-
recordUnmapped(plan, source.path, `${pointer}/requiredEvidence/${evidenceIndex}`, evidenceEntry, false);
|
|
1052
|
-
continue;
|
|
1053
|
-
}
|
|
1054
|
-
if (key !== rawKey) plan.renames.push({ from: elide(rawKey), to: key, kind: "evidence" });
|
|
1055
|
-
seenKeys.add(key);
|
|
1056
|
-
requiredEvidence.push({ key, kind: "assignment-result" });
|
|
1057
|
-
}
|
|
1058
|
-
const policies = asObject(stage.evidencePolicies as JsonValue | undefined);
|
|
1059
|
-
if (policies) {
|
|
1060
|
-
const seenPolicies = new Set<string>();
|
|
1061
|
-
for (const [rawPolicyKey, policyEntry] of Object.entries(policies).sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1062
|
-
const policy = asObject(policyEntry);
|
|
1063
|
-
const policyKey = normalizeIdentifier(rawPolicyKey);
|
|
1064
|
-
if (seenPolicies.has(policyKey)) {
|
|
1065
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}`, policyEntry, false);
|
|
1066
|
-
continue;
|
|
1067
|
-
}
|
|
1068
|
-
seenPolicies.add(policyKey);
|
|
1069
|
-
const requirement = requiredEvidence.find((candidate) => candidate.key === policyKey);
|
|
1070
|
-
if (!policy || !requirement) {
|
|
1071
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}`, policyEntry, false);
|
|
1072
|
-
continue;
|
|
1073
|
-
}
|
|
1074
|
-
recordUnknownFields(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}`, policy, KNOWN_POLICY_FIELDS);
|
|
1075
|
-
let minProducers = 1;
|
|
1076
|
-
if (typeof policy.minProducers === "number" && Number.isInteger(policy.minProducers) && policy.minProducers >= 1 && policy.minProducers <= 16) {
|
|
1077
|
-
minProducers = policy.minProducers;
|
|
1078
|
-
} else if (policy.minProducers !== undefined) {
|
|
1079
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}/minProducers`, policy.minProducers, false);
|
|
1080
|
-
}
|
|
1081
|
-
if (policy.eligibleAgents !== undefined && !Array.isArray(policy.eligibleAgents)) {
|
|
1082
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}/eligibleAgents`, policy.eligibleAgents, false);
|
|
1083
|
-
}
|
|
1084
|
-
const eligible = Array.isArray(policy.eligibleAgents) ? policy.eligibleAgents : [];
|
|
1085
|
-
const eligibleIds: string[] = [];
|
|
1086
|
-
for (const [eligibleIndex, candidate] of eligible.entries()) {
|
|
1087
|
-
const rawName = asString(candidate);
|
|
1088
|
-
if (!rawName) {
|
|
1089
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}/eligibleAgents/${eligibleIndex}`, candidate, false);
|
|
1090
|
-
continue;
|
|
1091
|
-
}
|
|
1092
|
-
const rawBound = pendingAgentRawNames.has(rawName)
|
|
1093
|
-
? undefined
|
|
1094
|
-
: agentRawNames.get(rawName)
|
|
1095
|
-
?? [...agentRawNames].find(([candidateName]) => candidateName.toLocaleLowerCase("en-US") === rawName.toLocaleLowerCase("en-US"))?.[1];
|
|
1096
|
-
if (rawBound) {
|
|
1097
|
-
if (!eligibleIds.includes(rawBound)) eligibleIds.push(rawBound);
|
|
1098
|
-
continue;
|
|
1099
|
-
}
|
|
1100
|
-
if ([...pendingAgentRawNames].some((pending) => pending === rawName || pending.toLocaleLowerCase("en-US") === rawName.toLocaleLowerCase("en-US"))) {
|
|
1101
|
-
// The producer is a roster agent whose identity is pending; it
|
|
1102
|
-
// binds by raw name once identities resolve. Preview with the
|
|
1103
|
-
// normalized form so policy decisions still register.
|
|
1104
|
-
const previewId = normalizeIdentifier(rawName);
|
|
1105
|
-
if (IDENTIFIER.test(previewId) && previewId.length <= 64 && !eligibleIds.includes(previewId)) {
|
|
1106
|
-
eligibleIds.push(previewId);
|
|
1107
|
-
}
|
|
1108
|
-
continue;
|
|
1109
|
-
}
|
|
1110
|
-
const normalized = normalizeIdentifier(rawName);
|
|
1111
|
-
if (agentRawNames.size === 0 && agentIds.has(normalized)) {
|
|
1112
|
-
// Empty roster map means no agents.json: allow the plain
|
|
1113
|
-
// normalized lookup (no rename collisions are possible).
|
|
1114
|
-
if (!eligibleIds.includes(normalized)) eligibleIds.push(normalized);
|
|
1115
|
-
continue;
|
|
1116
|
-
}
|
|
1117
|
-
if (agentIds.size === 0) {
|
|
1118
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}/eligibleAgents/${eligibleIndex}`, candidate, false);
|
|
1119
|
-
continue;
|
|
1120
|
-
}
|
|
1121
|
-
const mapped = resolveDecision(
|
|
1122
|
-
plan,
|
|
1123
|
-
`producer:${workflowKey}:${stepKey}:${keyFragment(rawName)}`,
|
|
1124
|
-
"evidence-policy",
|
|
1125
|
-
source.path,
|
|
1126
|
-
`evidence policy on ${stepId}.${policyKey} names producer ${JSON.stringify(elide(rawName, 200))} which is not a migrated agent; choose the roster agent that takes this producer role`,
|
|
1127
|
-
[...agentIds.keys()].sort(compareCodeUnits).slice(0, 64),
|
|
1128
|
-
decisions,
|
|
1129
|
-
);
|
|
1130
|
-
if (typeof mapped === "string" && agentIds.has(mapped)) {
|
|
1131
|
-
plan.renames.push({ from: elide(rawName), to: mapped, kind: "agent" });
|
|
1132
|
-
if (!eligibleIds.includes(mapped)) eligibleIds.push(mapped);
|
|
1133
|
-
} else if (mapped !== undefined) {
|
|
1134
|
-
if (!plan.invalidDecisions.includes(`producer:${workflowKey}:${stepKey}:${keyFragment(rawName)}`)) {
|
|
1135
|
-
plan.invalidDecisions.push(`producer:${workflowKey}:${stepKey}:${keyFragment(rawName)}`);
|
|
1136
|
-
}
|
|
1137
|
-
if (!plan.ambiguities.some((ambiguity) => ambiguity.key === `producer:${workflowKey}:${stepKey}:${keyFragment(rawName)}`)) {
|
|
1138
|
-
plan.ambiguities.push({
|
|
1139
|
-
key: `producer:${workflowKey}:${stepKey}:${keyFragment(rawName)}`,
|
|
1140
|
-
category: "evidence-policy",
|
|
1141
|
-
sourcePath: source.path,
|
|
1142
|
-
message: `decision value ${JSON.stringify(typeof mapped === "string" ? elide(mapped, 200) : mapped)} for producer ${JSON.stringify(elide(rawName, 200))} is not a migrated agent`,
|
|
1143
|
-
allowedValues: [...agentIds.keys()].sort(compareCodeUnits).slice(0, 64),
|
|
1144
|
-
});
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
const acceptedStatusesMalformed = policy.acceptedStatuses !== undefined && !Array.isArray(policy.acceptedStatuses);
|
|
1149
|
-
if (acceptedStatusesMalformed) {
|
|
1150
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}/acceptedStatuses`, policy.acceptedStatuses, false);
|
|
1151
|
-
}
|
|
1152
|
-
const accepted = acceptedStatusesMalformed ? [] : (Array.isArray(policy.acceptedStatuses) ? policy.acceptedStatuses : ["replied"]);
|
|
1153
|
-
if (Array.isArray(policy.acceptedStatuses) && policy.acceptedStatuses.length === 0) {
|
|
1154
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}/acceptedStatuses`, policy.acceptedStatuses, false);
|
|
1155
|
-
}
|
|
1156
|
-
let acceptedStatuses: string[] | undefined = accepted.length > 0 && accepted.every((status) => status === "replied") ? ["passed"] : undefined;
|
|
1157
|
-
if (!acceptedStatuses) {
|
|
1158
|
-
const chosen = resolveDecision(
|
|
1159
|
-
plan,
|
|
1160
|
-
`statuses:${workflowKey}:${stepKey}:${policyKey}`,
|
|
1161
|
-
"evidence-policy",
|
|
1162
|
-
source.path,
|
|
1163
|
-
acceptedStatusesMalformed
|
|
1164
|
-
? `evidence policy ${policyKey} acceptedStatuses is malformed (not an array) and was preserved in the report; confirm mapping the policy to KXM 'passed'`
|
|
1165
|
-
: `evidence policy ${policyKey} accepted statuses ${JSON.stringify(elide(accepted.map((candidate) => String(candidate)).join(","), 200))} cannot map losslessly; confirm mapping to KXM 'passed'`,
|
|
1166
|
-
["passed"],
|
|
1167
|
-
decisions,
|
|
1168
|
-
);
|
|
1169
|
-
if (chosen === "passed") acceptedStatuses = ["passed"];
|
|
1170
|
-
}
|
|
1171
|
-
if (acceptedStatuses) {
|
|
1172
|
-
// Register the strengthening decision whenever the policy's
|
|
1173
|
-
// statuses map, even before producers resolve, so the first plan
|
|
1174
|
-
// already lists every required decision key.
|
|
1175
|
-
plan.permissionChanges.push({
|
|
1176
|
-
sourcePath: source.path,
|
|
1177
|
-
targetPath: `.kxm/workflows/${workflowId}.yaml`,
|
|
1178
|
-
direction: "narrowing",
|
|
1179
|
-
summary: `evidence ${policyKey} on ${stepId}: legacy peer-reply policy (status 'replied') migrates to assignment-result producer policy requiring 'passed'`,
|
|
1180
|
-
decisionKey: `evidence:${workflowKey}:${stepKey}:${policyKey}`,
|
|
1181
|
-
});
|
|
1182
|
-
plan.permissionChanges.push({
|
|
1183
|
-
sourcePath: source.path,
|
|
1184
|
-
targetPath: `.kxm/workflows/${workflowId}.yaml`,
|
|
1185
|
-
direction: "expansion",
|
|
1186
|
-
summary: `step ${stepId}: assignment pool widens from the single legacy coordinator to coordinator plus evidence producers so KXM producer evidence stays in scope`,
|
|
1187
|
-
decisionKey: `evidence:${workflowKey}:${stepKey}:${policyKey}`,
|
|
1188
|
-
});
|
|
1189
|
-
resolveDecision(
|
|
1190
|
-
plan,
|
|
1191
|
-
`evidence:${workflowKey}:${stepKey}:${policyKey}`,
|
|
1192
|
-
"evidence-policy",
|
|
1193
|
-
source.path,
|
|
1194
|
-
`approve strengthening ${stepId}.${policyKey} from 'peer replied' to 'peer assignment passed' and widening the step assignment pool to include the producers`,
|
|
1195
|
-
["approve"],
|
|
1196
|
-
decisions,
|
|
1197
|
-
);
|
|
1198
|
-
}
|
|
1199
|
-
if (eligibleIds.length > 0 && acceptedStatuses) {
|
|
1200
|
-
requirement.kind = "assignment-result";
|
|
1201
|
-
requirement.producerPolicy = {
|
|
1202
|
-
minimumProducers: minProducers,
|
|
1203
|
-
eligibleAgents: eligibleIds,
|
|
1204
|
-
acceptedStatuses,
|
|
1205
|
-
};
|
|
1206
|
-
for (const producer of eligibleIds) policyProducers.add(producer);
|
|
1207
|
-
policyProducerMinimum = Math.max(policyProducerMinimum, minProducers);
|
|
1208
|
-
const degradation = asObject(policy.degradation as JsonValue | undefined);
|
|
1209
|
-
if (degradation && degradation.minProducers !== undefined) {
|
|
1210
|
-
if (typeof degradation.minProducers === "number" && Number.isInteger(degradation.minProducers)
|
|
1211
|
-
&& degradation.minProducers >= 1 && degradation.minProducers <= 15 && degradation.minProducers < minProducers) {
|
|
1212
|
-
(requirement.producerPolicy as JsonObject).degradation = { minimumProducers: degradation.minProducers };
|
|
1213
|
-
} else {
|
|
1214
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}/degradation`, policy.degradation as JsonValue, false);
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
} else {
|
|
1218
|
-
// The policy could not be honored (unresolved producers or
|
|
1219
|
-
// unmappable statuses); preserve it in the report.
|
|
1220
|
-
recordUnmapped(plan, source.path, `${pointer}/evidencePolicies/${pointerEscape(rawPolicyKey)}`, policyEntry, false);
|
|
1221
|
-
}
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
if (requiredEvidence.length > 0) step.requiredEvidence = requiredEvidence;
|
|
1225
|
-
if (policyProducers.size > 0) {
|
|
1226
|
-
// KXM evidence producers must execute within the step assignment
|
|
1227
|
-
// pool: widen it beyond the legacy single-coordinator shape.
|
|
1228
|
-
const pool = [stepAgent, ...[...policyProducers].filter((candidate) => candidate !== stepAgent).sort(compareCodeUnits)];
|
|
1229
|
-
step.assignments = {
|
|
1230
|
-
allowedAgents: pool,
|
|
1231
|
-
minimum: 1,
|
|
1232
|
-
target: Math.max(1, policyProducerMinimum),
|
|
1233
|
-
maximum: pool.length,
|
|
1234
|
-
maxParallel: pool.length,
|
|
1235
|
-
};
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
const on = asObject(stage.on as JsonValue | undefined);
|
|
1239
|
-
const transitions: JsonObject = {};
|
|
1240
|
-
if (on) {
|
|
1241
|
-
const seenOutcomes = new Set<string>();
|
|
1242
|
-
for (const [rawOutcome, rawTransition] of Object.entries(on).sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1243
|
-
const outcome = normalizeIdentifier(rawOutcome);
|
|
1244
|
-
if (!IDENTIFIER.test(outcome) || outcome.length > 64) {
|
|
1245
|
-
recordUnmapped(plan, source.path, `${pointer}/on/${pointerEscape(rawOutcome)}`, rawTransition, false);
|
|
1246
|
-
continue;
|
|
1247
|
-
}
|
|
1248
|
-
if (seenOutcomes.has(outcome)) {
|
|
1249
|
-
// Two legacy outcome keys normalize to the same KXM outcome;
|
|
1250
|
-
// never merge their edges last-wins.
|
|
1251
|
-
recordUnmapped(plan, source.path, `${pointer}/on/${pointerEscape(rawOutcome)}`, rawTransition, false);
|
|
1252
|
-
continue;
|
|
1253
|
-
}
|
|
1254
|
-
seenOutcomes.add(outcome);
|
|
1255
|
-
if (outcome !== rawOutcome) plan.renames.push({ from: elide(rawOutcome), to: outcome, kind: "outcome" });
|
|
1256
|
-
const converted = convertTransition(plan, source, context, stepId, stepKey, outcome, rawOutcome, rawTransition, decisions, pointer);
|
|
1257
|
-
if (converted !== undefined) transitions[outcome] = converted;
|
|
1258
|
-
}
|
|
1259
|
-
// v0.4 semantics: an undeclared (or decision-dropped) passed outcome
|
|
1260
|
-
// keeps the default edge to the next surviving stage.
|
|
1261
|
-
if (transitions.passed === undefined) {
|
|
1262
|
-
const nextId = stageIds[stageIds.indexOf(stepId) + 1];
|
|
1263
|
-
transitions.passed = nextId ?? { target: "$terminal", terminalStatus: "completed" };
|
|
1264
|
-
}
|
|
1265
|
-
} else {
|
|
1266
|
-
// v0.4 default edges: passed -> next stage (or completed), failure
|
|
1267
|
-
// retries within maxAttempts then fails the run.
|
|
1268
|
-
const nextId = stageIds[stageIds.indexOf(stepId) + 1];
|
|
1269
|
-
transitions.passed = nextId ?? { target: "$terminal", terminalStatus: "completed" };
|
|
1270
|
-
}
|
|
1271
|
-
step.on = transitions;
|
|
1272
|
-
if (typeof stage.maxTransitions === "number") {
|
|
1273
|
-
// Legacy per-stage transition budgets have no KXM equivalent;
|
|
1274
|
-
// per-edge budgets and the global limit cover the same bound.
|
|
1275
|
-
recordUnmapped(plan, source.path, `${pointer}/maxTransitions`, stage.maxTransitions, false);
|
|
1276
|
-
}
|
|
1277
|
-
if (stage.area !== undefined) recordUnmapped(plan, source.path, `${pointer}/area`, stage.area, false);
|
|
1278
|
-
steps.push(step);
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
if (steps.length === 0) {
|
|
1282
|
-
resolveDecision(
|
|
1283
|
-
plan,
|
|
1284
|
-
`stages:${workflowKey}`,
|
|
1285
|
-
"unsupported",
|
|
1286
|
-
source.path,
|
|
1287
|
-
`workflow ${workflowId} produced no convertible steps; confirm it is dropped`,
|
|
1288
|
-
["drop"],
|
|
1289
|
-
decisions,
|
|
1290
|
-
);
|
|
1291
|
-
continue;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
const workflow: JsonObject = {
|
|
1295
|
-
schema: "kxm.workflow.v1",
|
|
1296
|
-
coordinator: stepAgent,
|
|
1297
|
-
steps,
|
|
1298
|
-
};
|
|
1299
|
-
if (typeof definition.maxTransitions === "number" && Number.isInteger(definition.maxTransitions) && definition.maxTransitions >= 1 && definition.maxTransitions <= 1000) {
|
|
1300
|
-
workflow.limits = { maxTransitions: definition.maxTransitions };
|
|
1301
|
-
} else if (definition.maxTransitions !== undefined) {
|
|
1302
|
-
recordUnmapped(plan, source.path, `${basePointer}/maxTransitions`, definition.maxTransitions, false);
|
|
1303
|
-
}
|
|
1304
|
-
if (workflow.limits === undefined) {
|
|
1305
|
-
// KXM requires a global budget whenever any back-edge exists.
|
|
1306
|
-
const hasBackEdge = steps.some((step) => Object.values(asObject(step.on as JsonValue) ?? {}).some((rule) => {
|
|
1307
|
-
const target = typeof rule === "string" ? rule : asObject(rule)?.target;
|
|
1308
|
-
return typeof target === "string" && target !== "$terminal" && stageIds.indexOf(target) <= stageIds.indexOf(String(step.id));
|
|
1309
|
-
}));
|
|
1310
|
-
if (hasBackEdge) {
|
|
1311
|
-
const chosen = resolveDecision(
|
|
1312
|
-
plan,
|
|
1313
|
-
`budget:${workflowKey}`,
|
|
1314
|
-
"transition-budget",
|
|
1315
|
-
source.path,
|
|
1316
|
-
`workflow ${workflowId} has back-edges but no legacy global maxTransitions; KXM requires limits.maxTransitions (1-1000)`,
|
|
1317
|
-
[28],
|
|
1318
|
-
decisions,
|
|
1319
|
-
);
|
|
1320
|
-
workflow.limits = { maxTransitions: typeof chosen === "number" && Number.isInteger(chosen) && chosen >= 1 && chosen <= 1000 ? chosen : 28 };
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
for (const oracleField of ["reproOracle", "planHash"] as const) {
|
|
1324
|
-
const oracle = asObject(definition[oracleField] as JsonValue | undefined);
|
|
1325
|
-
if (!oracle) continue;
|
|
1326
|
-
const oracleStage = asString(oracle.stageId);
|
|
1327
|
-
const oracleEvidence = asString(oracle.evidenceKey);
|
|
1328
|
-
if (!oracleStage || !oracleEvidence) {
|
|
1329
|
-
recordUnmapped(plan, source.path, `${basePointer}/${oracleField}`, definition[oracleField], false);
|
|
1330
|
-
continue;
|
|
1331
|
-
}
|
|
1332
|
-
const mappedStage = context.renamesByKind.get(oracleStage);
|
|
1333
|
-
if (!mappedStage) {
|
|
1334
|
-
// The referenced stage did not survive conversion (dropped by
|
|
1335
|
-
// decision or unconvertible); preserve the oracle and omit it rather
|
|
1336
|
-
// than hard-failing the fully-decided plan.
|
|
1337
|
-
if (!context.stageIds.includes(normalizeIdentifier(oracleStage))) {
|
|
1338
|
-
recordUnmapped(plan, source.path, `${basePointer}/${oracleField}`, definition[oracleField], false);
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
|
-
workflow[oracleField] = {
|
|
1343
|
-
stageId: mappedStage ?? normalizeIdentifier(oracleStage),
|
|
1344
|
-
evidenceKey: normalizeIdentifier(oracleEvidence),
|
|
1345
|
-
};
|
|
1346
|
-
}
|
|
1347
|
-
if (Array.isArray(definition.requirePlanHash)) {
|
|
1348
|
-
const mapped: string[] = [];
|
|
1349
|
-
for (const [requireIndex, candidate] of definition.requirePlanHash.entries()) {
|
|
1350
|
-
const rawStage = asString(candidate);
|
|
1351
|
-
if (!rawStage) {
|
|
1352
|
-
recordUnmapped(plan, source.path, `${basePointer}/requirePlanHash/${requireIndex}`, candidate, false);
|
|
1353
|
-
continue;
|
|
1354
|
-
}
|
|
1355
|
-
const mappedStage = context.renamesByKind.get(rawStage);
|
|
1356
|
-
if (!mappedStage && !context.stageIds.includes(normalizeIdentifier(rawStage))) {
|
|
1357
|
-
recordUnmapped(plan, source.path, `${basePointer}/requirePlanHash/${requireIndex}`, candidate, false);
|
|
1358
|
-
continue;
|
|
1359
|
-
}
|
|
1360
|
-
mapped.push(mappedStage ?? normalizeIdentifier(rawStage));
|
|
1361
|
-
}
|
|
1362
|
-
if (mapped.length > 0) workflow.requirePlanHash = [...new Set(mapped)].sort(compareCodeUnits);
|
|
1363
|
-
}
|
|
1364
|
-
workflows.set(workflowId, workflow);
|
|
1365
|
-
}
|
|
1366
|
-
return workflows;
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
/* ------------------------- plan assembly -------------------------- */
|
|
1370
|
-
|
|
1371
|
-
export interface KxmMigrationPlanResult {
|
|
1372
|
-
plan: JsonObject;
|
|
1373
|
-
resources: Map<string, JsonObject>;
|
|
1374
|
-
ambiguities: readonly KxmMigrationAmbiguity[];
|
|
1375
|
-
decisionKeys: ReadonlySet<string>;
|
|
1376
|
-
decisionAllowedValues: ReadonlyMap<string, readonly (string | number)[]>;
|
|
1377
|
-
invalidDecisions: readonly string[];
|
|
1378
|
-
decisions: Readonly<Record<string, JsonValue>>;
|
|
1379
|
-
sources: readonly KxmLegacySource[];
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
export interface KxmMigrationOptions extends KxmConfigOptions {
|
|
1383
|
-
decisions?: Readonly<Record<string, JsonValue>>;
|
|
1384
|
-
projectId?: string;
|
|
1385
|
-
projectName?: string;
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
const PROJECT_ID = /^prj_[A-Za-z0-9][A-Za-z0-9_-]{5,127}$/;
|
|
1389
|
-
|
|
1390
|
-
function sortedRecord<T>(map: ReadonlyMap<string, T>, prefix: string): Map<string, T> {
|
|
1391
|
-
return new Map([...map.entries()].sort(([left], [right]) => compareCodeUnits(left, right)).map(([key, value]) => [`${prefix}${key}.yaml`, value] as [string, T]));
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
/** Resolve the authoritative Git worktree root for migration commands. */
|
|
1395
|
-
function migrationGitRoot(start: string): string {
|
|
1396
|
-
const root = discoverGitRoot(start);
|
|
1397
|
-
if (!root) {
|
|
1398
|
-
throw new KxmConfigError([migrateIssue("discovery", "git_root_required", ".", "kxm migrate must run inside the authoritative Git worktree")]);
|
|
1399
|
-
}
|
|
1400
|
-
return root;
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
/** Convert legacy configuration into a deterministic, schema-shaped migration plan. */
|
|
1404
|
-
export function planKxmMigration(projectRoot: string, options: KxmMigrationOptions = {}): KxmMigrationPlanResult {
|
|
1405
|
-
const root = migrationGitRoot(projectRoot);
|
|
1406
|
-
const registry = new KxmSchemaRegistry(options.schemasDir);
|
|
1407
|
-
const sources = readKxmLegacySources(root);
|
|
1408
|
-
if (sources.length === 0) {
|
|
1409
|
-
throw new KxmConfigError([migrateIssue("discovery", "legacy_sources_missing", ".kxm/config", "no legacy configuration files found to migrate")]);
|
|
1410
|
-
}
|
|
1411
|
-
const decisions = options.decisions ?? {};
|
|
1412
|
-
const plan: MutablePlan = { ambiguities: [], invalidDecisions: [], decisionKeys: new Set(), decisionAllowedValues: new Map(), unmapped: [], renames: [], permissionChanges: [] };
|
|
1413
|
-
const agentNames = new Map<string, string>();
|
|
1414
|
-
const workflowNames = new Map<string, string>();
|
|
1415
|
-
|
|
1416
|
-
const agentsSource = sources.find((source) => source.path === ".kxm/config/agents.json");
|
|
1417
|
-
const gatesSource = sources.find((source) => source.path === ".kxm/config/gates.json");
|
|
1418
|
-
const converted = agentsSource
|
|
1419
|
-
? convertAgentsRoster(plan, agentsSource, decisions, agentNames)
|
|
1420
|
-
: { agents: new Map<string, JsonObject>(), models: new Map<string, JsonObject>(), rawNames: new Map<string, string>(), pendingRawNames: new Set<string>(), identityPending: false };
|
|
1421
|
-
if (!agentsSource) {
|
|
1422
|
-
resolveDecision(
|
|
1423
|
-
plan,
|
|
1424
|
-
"agents-missing",
|
|
1425
|
-
"unsupported",
|
|
1426
|
-
".kxm/config/agents.json",
|
|
1427
|
-
"no agents.json roster; workflows convert against an empty agent set — confirm conversion continues",
|
|
1428
|
-
["continue"],
|
|
1429
|
-
decisions,
|
|
1430
|
-
);
|
|
1431
|
-
}
|
|
1432
|
-
if (gatesSource) convertGatesRoster(plan, gatesSource, decisions);
|
|
1433
|
-
|
|
1434
|
-
const workflows = new Map<string, JsonObject>();
|
|
1435
|
-
for (const source of sources) {
|
|
1436
|
-
if (!source.path.startsWith(".kxm/config/workflows/")) continue;
|
|
1437
|
-
const convertedWorkflows = convertWorkflowFile(
|
|
1438
|
-
plan,
|
|
1439
|
-
source,
|
|
1440
|
-
decisions,
|
|
1441
|
-
converted.agents,
|
|
1442
|
-
converted.rawNames,
|
|
1443
|
-
converted.pendingRawNames,
|
|
1444
|
-
converted.identityPending,
|
|
1445
|
-
workflowNames,
|
|
1446
|
-
);
|
|
1447
|
-
for (const [id, workflow] of convertedWorkflows) workflows.set(id, workflow);
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
const projectId = options.projectId ?? `prj_mig${sha256Of(legacySourceDigest(sources)).slice(7, 39)}`;
|
|
1451
|
-
if (!PROJECT_ID.test(projectId)) {
|
|
1452
|
-
throw new KxmConfigError([migrateIssue("semantic", "project_id_invalid", ".kxm/project.yaml", "project ID must satisfy the kxm.project.v1 opaque ID grammar and use the prj_ prefix")]);
|
|
1453
|
-
}
|
|
1454
|
-
const projectName = options.projectName ?? "Migrated legacy project";
|
|
1455
|
-
if (projectName.length < 1 || projectName.length > 120 || /[\u0000\r\n]/.test(projectName)) {
|
|
1456
|
-
throw new KxmConfigError([migrateIssue("semantic", "project_name_invalid", ".kxm/project.yaml", "project name must contain 1-120 characters on one line")]);
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
const resources = new Map<string, JsonObject>();
|
|
1460
|
-
resources.set(".kxm/project.yaml", {
|
|
1461
|
-
schema: "kxm.project.v1",
|
|
1462
|
-
id: projectId,
|
|
1463
|
-
name: projectName,
|
|
1464
|
-
...(workflows.size > 0 ? { defaultWorkflow: [...workflows.keys()].sort(compareCodeUnits)[0] } : {}),
|
|
1465
|
-
defaultExecutor: "local",
|
|
1466
|
-
repositories: [{ id: "control", role: "control", required: true, pathHint: "." }],
|
|
1467
|
-
workspace: {
|
|
1468
|
-
dirtySnapshot: {
|
|
1469
|
-
untracked: "ask",
|
|
1470
|
-
dirtySubmodules: "fail",
|
|
1471
|
-
},
|
|
1472
|
-
},
|
|
1473
|
-
});
|
|
1474
|
-
resources.set(".kxm/repo/repo.yaml", {
|
|
1475
|
-
schema: "kxm.repository.v1",
|
|
1476
|
-
projectId,
|
|
1477
|
-
repositoryId: "control",
|
|
1478
|
-
description: "Authoritative project configuration and repository content.",
|
|
1479
|
-
defaultAccess: "write",
|
|
1480
|
-
});
|
|
1481
|
-
for (const [path, value] of sortedRecord(converted.agents, ".kxm/agents/")) resources.set(path, value);
|
|
1482
|
-
for (const [path, value] of sortedRecord(converted.models, ".kxm/models/")) resources.set(path, value);
|
|
1483
|
-
for (const [path, value] of sortedRecord(workflows, ".kxm/workflows/")) resources.set(path, value);
|
|
1484
|
-
|
|
1485
|
-
// Validate every converted resource against its exact schema before the
|
|
1486
|
-
// plan is considered applicable.
|
|
1487
|
-
const schemaIssues: KxmConfigIssue[] = [];
|
|
1488
|
-
const kindOf = (path: string): KxmResourceKind => path === ".kxm/project.yaml" ? "project"
|
|
1489
|
-
: path.endsWith("repo.yaml") ? "repository"
|
|
1490
|
-
: path.startsWith(".kxm/agents/") ? "agent"
|
|
1491
|
-
: path.startsWith(".kxm/models/") ? "model"
|
|
1492
|
-
: "workflow";
|
|
1493
|
-
const idOf = (path: string): string | undefined => path === ".kxm/project.yaml" || path.endsWith("repo.yaml")
|
|
1494
|
-
? undefined
|
|
1495
|
-
: path.slice(path.lastIndexOf("/") + 1, -".yaml".length);
|
|
1496
|
-
const semanticResources = new Map<string, { kind: KxmResourceKind; id?: string; value: JsonObject }>();
|
|
1497
|
-
for (const [path, value] of [...resources.entries()].sort(([left], [right]) => compareCodeUnits(left, right))) {
|
|
1498
|
-
const kind = kindOf(path);
|
|
1499
|
-
schemaIssues.push(...registry.validate(kind, value, path));
|
|
1500
|
-
const id = idOf(path);
|
|
1501
|
-
semanticResources.set(path, kind === "repository" ? { kind, id: "control", value } : { kind, ...(id === undefined ? {} : { id }), value });
|
|
1502
|
-
}
|
|
1503
|
-
if (schemaIssues.length > 0) throw new KxmConfigError(schemaIssues);
|
|
1504
|
-
// Full semantic validation gates a resolved plan: while ambiguities remain,
|
|
1505
|
-
// the intermediate resource set may legitimately be incomplete (for example
|
|
1506
|
-
// a workflow whose coordinator decision is still pending), and the operator
|
|
1507
|
-
// needs the plan listing those ambiguities rather than a validation error.
|
|
1508
|
-
// Apply always re-plans and requires zero ambiguities, so this check still
|
|
1509
|
-
// runs before any write.
|
|
1510
|
-
if (plan.ambiguities.length === 0) {
|
|
1511
|
-
const semanticIssues = validateKxmResources(semanticResources, options);
|
|
1512
|
-
if (semanticIssues.length > 0) throw new KxmConfigError(semanticIssues);
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
const planRecord: JsonObject = {
|
|
1516
|
-
schema: "kxm.migration-plan.v1",
|
|
1517
|
-
projectId,
|
|
1518
|
-
projectName,
|
|
1519
|
-
sourceDigest: legacySourceDigest(sources),
|
|
1520
|
-
sources: sources.map((source) => ({ path: source.path, sha256: source.sha256, bytes: source.bytes })),
|
|
1521
|
-
resources: [...resources.keys()].sort(compareCodeUnits).map((path) => {
|
|
1522
|
-
const bytes = Buffer.from(stringify(resources.get(path), { lineWidth: 0 }), "utf8");
|
|
1523
|
-
return { path, sha256: sha256Of(bytes), bytes: bytes.byteLength };
|
|
1524
|
-
}),
|
|
1525
|
-
ambiguities: [...plan.ambiguities].sort((left, right) => compareCodeUnits(left.key, right.key)).map((ambiguity) => ({
|
|
1526
|
-
key: ambiguity.key,
|
|
1527
|
-
category: ambiguity.category,
|
|
1528
|
-
sourcePath: ambiguity.sourcePath,
|
|
1529
|
-
message: ambiguity.message,
|
|
1530
|
-
allowedValues: [...ambiguity.allowedValues],
|
|
1531
|
-
})),
|
|
1532
|
-
unmapped: [...plan.unmapped].sort((left, right) => compareCodeUnits(left.sourcePath, right.sourcePath) || compareCodeUnits(left.jsonPointer, right.jsonPointer)).map((entry) => ({ ...entry })),
|
|
1533
|
-
renames: [...new Map(plan.renames.map((rename) => [JSON.stringify([rename.kind, rename.from, rename.to]), rename])).values()]
|
|
1534
|
-
.sort((left, right) => compareCodeUnits(left.kind, right.kind) || compareCodeUnits(left.from, right.from))
|
|
1535
|
-
.map((entry) => ({ ...entry })),
|
|
1536
|
-
permissionChanges: [...plan.permissionChanges].sort((left, right) => compareCodeUnits(left.decisionKey, right.decisionKey)).map((entry) => ({ ...entry })),
|
|
1537
|
-
canApply: plan.ambiguities.length === 0,
|
|
1538
|
-
};
|
|
1539
|
-
const planIssues = registry.validateMigrationPlan(planRecord, "kxm.migration-plan.v1");
|
|
1540
|
-
if (planIssues.length > 0) throw new KxmConfigError(planIssues);
|
|
1541
|
-
|
|
1542
|
-
return {
|
|
1543
|
-
plan: planRecord,
|
|
1544
|
-
resources,
|
|
1545
|
-
ambiguities: plan.ambiguities,
|
|
1546
|
-
decisionKeys: plan.decisionKeys,
|
|
1547
|
-
decisionAllowedValues: plan.decisionAllowedValues,
|
|
1548
|
-
invalidDecisions: plan.invalidDecisions,
|
|
1549
|
-
decisions,
|
|
1550
|
-
sources,
|
|
1551
|
-
};
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
/* ------------------------- decisions file -------------------------- */
|
|
1555
|
-
|
|
1556
|
-
export function readKxmMigrationDecisions(path: string, schemasDir?: string): JsonObject {
|
|
1557
|
-
if (!existsSync(path)) {
|
|
1558
|
-
throw new KxmConfigError([migrateIssue("discovery", "decisions_missing", path, "migration decisions file does not exist")]);
|
|
1559
|
-
}
|
|
1560
|
-
const registry = new KxmSchemaRegistry(schemasDir);
|
|
1561
|
-
const value = parseRestrictedYaml(readFileSync(path), path);
|
|
1562
|
-
const issues = registry.validateMigrationDecision(value, path);
|
|
1563
|
-
if (issues.length > 0) throw new KxmConfigError(issues);
|
|
1564
|
-
return value;
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
function resolutionsOf(decisions: JsonObject): Record<string, JsonValue> {
|
|
1568
|
-
const resolutions = decisions.resolutions;
|
|
1569
|
-
if (!resolutions || typeof resolutions !== "object" || Array.isArray(resolutions)) return {};
|
|
1570
|
-
return { ...(resolutions as JsonObject) };
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
/* ------------------------- apply / verify -------------------------- */
|
|
1574
|
-
|
|
1575
|
-
export interface KxmMigrationApplyResult {
|
|
1576
|
-
action: "applied" | "planned" | "already-migrated";
|
|
1577
|
-
projectRoot: string;
|
|
1578
|
-
receiptPath?: string;
|
|
1579
|
-
configRevision?: string;
|
|
1580
|
-
files: readonly string[];
|
|
1581
|
-
plan?: JsonObject;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
export interface KxmMigrationApplyOptions extends KxmMigrationOptions {
|
|
1585
|
-
dryRun?: boolean;
|
|
1586
|
-
decisionsFile?: string;
|
|
1587
|
-
localStateRoot?: string;
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
function syncDirectory(path: string): void {
|
|
1591
|
-
let descriptor: number | undefined;
|
|
1592
|
-
try {
|
|
1593
|
-
descriptor = openSync(path, "r");
|
|
1594
|
-
fsyncSync(descriptor);
|
|
1595
|
-
} catch (error) {
|
|
1596
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
1597
|
-
if (process.platform !== "win32" && code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") throw error;
|
|
1598
|
-
} finally {
|
|
1599
|
-
if (descriptor !== undefined) closeSync(descriptor);
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
function writeDurable(path: string, content: Buffer): void {
|
|
1604
|
-
const temp = `${path}.kxm-migration-tmp-${process.pid}-${createHash("sha256").update(`${path}${Date.now()}${Math.random()}`, "utf8").digest("hex").slice(0, 12)}`;
|
|
1605
|
-
let created = false;
|
|
1606
|
-
try {
|
|
1607
|
-
const descriptor = openSync(temp, "wx", 0o600);
|
|
1608
|
-
created = true;
|
|
1609
|
-
try {
|
|
1610
|
-
writeFileSync(descriptor, content);
|
|
1611
|
-
fsyncSync(descriptor);
|
|
1612
|
-
} finally {
|
|
1613
|
-
closeSync(descriptor);
|
|
1614
|
-
}
|
|
1615
|
-
renameSync(temp, path);
|
|
1616
|
-
try { chmodSync(path, 0o600); } catch { /* Windows may ignore modes. */ }
|
|
1617
|
-
syncDirectory(join(path, ".."));
|
|
1618
|
-
} finally {
|
|
1619
|
-
if (created && existsSync(temp)) rmSync(temp, { force: true });
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
|
|
1623
|
-
/** Apply a reviewed migration: stage, validate the complete target, install, and receipt. */
|
|
1624
|
-
export function applyKxmMigration(projectRoot: string, options: KxmMigrationApplyOptions = {}): KxmMigrationApplyResult {
|
|
1625
|
-
const root = migrationGitRoot(projectRoot);
|
|
1626
|
-
const storeOptions: KxmLocalBindingStoreOptions = {
|
|
1627
|
-
...(options.localStateRoot === undefined ? {} : { stateRoot: options.localStateRoot }),
|
|
1628
|
-
...(options.schemasDir === undefined ? {} : { schemasDir: options.schemasDir }),
|
|
1629
|
-
};
|
|
1630
|
-
|
|
1631
|
-
const execute = (lock: KxmLocalBindingLock | undefined): KxmMigrationApplyResult => {
|
|
1632
|
-
const receiptAbsolute = join(root, ...KXM_MIGRATION_RECEIPT_PATH.split("/"));
|
|
1633
|
-
// Already-migrated detection precedes planning and decision checks: a
|
|
1634
|
-
// validated receipt makes re-apply an idempotent no-op even when legacy
|
|
1635
|
-
// sources were later removed or no decisions are supplied.
|
|
1636
|
-
if (existsSync(receiptAbsolute)) {
|
|
1637
|
-
const stat = lstatSync(receiptAbsolute);
|
|
1638
|
-
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1639
|
-
throw new KxmConfigError([migrateIssue("path", "migration_receipt_invalid", KXM_MIGRATION_RECEIPT_PATH, "migration receipt must be a regular file, not a link")]);
|
|
1640
|
-
}
|
|
1641
|
-
const existing = loadKxmProject(root, options);
|
|
1642
|
-
return {
|
|
1643
|
-
action: "already-migrated",
|
|
1644
|
-
projectRoot: root,
|
|
1645
|
-
receiptPath: KXM_MIGRATION_RECEIPT_PATH,
|
|
1646
|
-
configRevision: existing.configRevision,
|
|
1647
|
-
files: [],
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
const decisionsRecord = options.decisionsFile ? readKxmMigrationDecisions(options.decisionsFile, options.schemasDir) : undefined;
|
|
1652
|
-
const decisions = decisionsRecord ? resolutionsOf(decisionsRecord) : (options.decisions ?? {});
|
|
1653
|
-
// Plan once with the real decisions: resolveDecision validates every
|
|
1654
|
-
// supplied value inline, so an invalid value can never masquerade as a
|
|
1655
|
-
// semantic plan error. A second decision-free plan enumerates the
|
|
1656
|
-
// universe the operator reviewed; decision keys are accepted from the
|
|
1657
|
-
// union of both plans because binding decisions (coordinator, identities)
|
|
1658
|
-
// can make plan-1 keys unnecessary without invalidating the review.
|
|
1659
|
-
const first = planKxmMigration(root, { ...options, decisions });
|
|
1660
|
-
if (decisionsRecord) {
|
|
1661
|
-
if (decisionsRecord.projectId !== first.plan.projectId) {
|
|
1662
|
-
throw new KxmConfigError([migrateIssue("semantic", "decision_project_mismatch", options.decisionsFile ?? "<decisions>", "decisions file projectId does not match the recomputed plan")]);
|
|
1663
|
-
}
|
|
1664
|
-
if (decisionsRecord.projectName !== first.plan.projectName) {
|
|
1665
|
-
throw new KxmConfigError([migrateIssue("semantic", "decision_project_mismatch", options.decisionsFile ?? "<decisions>", "decisions file projectName does not match the recomputed plan")]);
|
|
1666
|
-
}
|
|
1667
|
-
if (decisionsRecord.sourceDigest !== first.plan.sourceDigest) {
|
|
1668
|
-
throw new KxmConfigError([migrateIssue("semantic", "decision_source_changed", options.decisionsFile ?? "<decisions>", "legacy sources changed after the decisions file was reviewed")]);
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
if (Object.keys(decisions).length > 0) {
|
|
1672
|
-
// Keys decided-plan registered were validated inline by resolveDecision.
|
|
1673
|
-
// Keys only the undecided universe registered (reviewed but now no-ops,
|
|
1674
|
-
// e.g. a coordinator choice superseded by a raw-name binding) are
|
|
1675
|
-
// validated against the universe's allowed values here. Anything else
|
|
1676
|
-
// was never part of any review.
|
|
1677
|
-
const universe = planKxmMigration(root, { ...options, decisions: {} });
|
|
1678
|
-
const unknown: string[] = [];
|
|
1679
|
-
const invalid: string[] = [];
|
|
1680
|
-
for (const [key, value] of Object.entries(decisions)) {
|
|
1681
|
-
if (first.decisionKeys.has(key)) continue;
|
|
1682
|
-
const universeAllowed = universe.decisionAllowedValues.get(key);
|
|
1683
|
-
if (!universeAllowed) {
|
|
1684
|
-
unknown.push(key);
|
|
1685
|
-
} else if (!decisionValueAllowed(key, value, universeAllowed)) {
|
|
1686
|
-
invalid.push(key);
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
if (unknown.length > 0) {
|
|
1690
|
-
throw new KxmConfigError(unknown.sort(compareCodeUnits).map((key) => migrateIssue("semantic", "decision_unknown", key, "decision key does not match any current ambiguity")));
|
|
1691
|
-
}
|
|
1692
|
-
const allInvalid = [...new Set([...invalid, ...first.invalidDecisions])].sort(compareCodeUnits);
|
|
1693
|
-
if (allInvalid.length > 0) {
|
|
1694
|
-
throw new KxmConfigError(allInvalid.map((key) => migrateIssue("semantic", "decision_value_invalid", key, "decision value is not one of the plan's allowed values")));
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
if (first.ambiguities.length > 0) {
|
|
1698
|
-
return { action: "planned", projectRoot: root, files: [], plan: first.plan };
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
const files = [...first.resources.entries()].sort(([left], [right]) => compareCodeUnits(left, right))
|
|
1702
|
-
.map(([path, value]) => ({ path, bytes: Buffer.from(stringify(value, { lineWidth: 0 }), "utf8") }));
|
|
1703
|
-
// Refuse to overwrite any existing KXM state or legacy inputs.
|
|
1704
|
-
const collisions = files.filter((file) => existsSync(join(root, ...file.path.split("/"))));
|
|
1705
|
-
if (collisions.length > 0) {
|
|
1706
|
-
throw new KxmConfigError(collisions.map((file) => migrateIssue("semantic", "migration_target_exists", file.path, "target resource already exists; refusing to overwrite")));
|
|
1707
|
-
}
|
|
1708
|
-
// Never write through a linked or non-directory parent component: walk
|
|
1709
|
-
// every target parent from the project root before the first write.
|
|
1710
|
-
const parentIssues: KxmConfigIssue[] = [];
|
|
1711
|
-
for (const file of files) {
|
|
1712
|
-
const segments = file.path.split("/");
|
|
1713
|
-
let current = root;
|
|
1714
|
-
for (const segment of segments.slice(0, -1)) {
|
|
1715
|
-
current = join(current, segment);
|
|
1716
|
-
if (!existsSync(current)) continue;
|
|
1717
|
-
const stat = lstatSync(current);
|
|
1718
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
1719
|
-
parentIssues.push(migrateIssue("path", "migration_target_parent_invalid", file.path, `migration target parent ${segment} must be a regular directory, not a link`));
|
|
1720
|
-
break;
|
|
1721
|
-
}
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
if (parentIssues.length > 0) throw new KxmConfigError(parentIssues);
|
|
1725
|
-
|
|
1726
|
-
if (options.dryRun) {
|
|
1727
|
-
// Preflight is complete (decisions, collisions, linked parents); a
|
|
1728
|
-
// real apply would proceed past this point.
|
|
1729
|
-
return { action: "planned", projectRoot: root, files: files.map((file) => file.path), plan: first.plan };
|
|
1730
|
-
}
|
|
1731
|
-
if (!lock) throw new Error("project mutation lock is required to apply a migration");
|
|
1732
|
-
|
|
1733
|
-
const written: string[] = [];
|
|
1734
|
-
try {
|
|
1735
|
-
for (const file of files) {
|
|
1736
|
-
const absolute = join(root, ...file.path.split("/"));
|
|
1737
|
-
mkdirSync(join(absolute, ".."), { recursive: true });
|
|
1738
|
-
// Record before writing: the path was verified absent, so rollback may
|
|
1739
|
-
// remove it even when a post-rename directory sync fails mid-write.
|
|
1740
|
-
written.push(absolute);
|
|
1741
|
-
writeDurable(absolute, file.bytes);
|
|
1742
|
-
}
|
|
1743
|
-
// Validate the complete installed bundle before issuing the receipt.
|
|
1744
|
-
const bundle = loadKxmProject(root, { ...options, allowUnreceiptedLegacyConfig: true });
|
|
1745
|
-
const receipt: JsonObject = {
|
|
1746
|
-
schema: "kxm.migration-receipt.v1",
|
|
1747
|
-
migrationId: `mig_${sha256Of(`${first.plan.sourceDigest}\n${bundle.configRevision}`).slice(7, 39)}`,
|
|
1748
|
-
projectId: String(first.plan.projectId),
|
|
1749
|
-
sourceDigest: String(first.plan.sourceDigest),
|
|
1750
|
-
decisionDigest: sha256Of(kxmCanonicalJson((decisionsRecord ?? { schema: "kxm.migration-decision.v1", projectId: first.plan.projectId, projectName: first.plan.projectName, sourceDigest: first.plan.sourceDigest, resolutions: decisions }) as JsonObject)),
|
|
1751
|
-
configRevision: bundle.configRevision,
|
|
1752
|
-
sources: first.plan.sources as JsonValue,
|
|
1753
|
-
resources: files.map((file) => ({ path: file.path, sha256: sha256Of(file.bytes), bytes: file.bytes.byteLength })),
|
|
1754
|
-
};
|
|
1755
|
-
receipt.receiptSha256 = migrationReceiptSelfHash(receipt);
|
|
1756
|
-
const registry = new KxmSchemaRegistry(options.schemasDir);
|
|
1757
|
-
const receiptIssues = registry.validateMigrationReceipt(receipt, KXM_MIGRATION_RECEIPT_PATH);
|
|
1758
|
-
if (receiptIssues.length > 0) throw new KxmConfigError(receiptIssues);
|
|
1759
|
-
written.push(receiptAbsolute);
|
|
1760
|
-
writeDurable(receiptAbsolute, Buffer.from(stringify(receipt, { lineWidth: 0 }), "utf8"));
|
|
1761
|
-
// The receipt must make the mixed tree loadable.
|
|
1762
|
-
const verified = loadKxmProject(root, options);
|
|
1763
|
-
return {
|
|
1764
|
-
action: "applied",
|
|
1765
|
-
projectRoot: root,
|
|
1766
|
-
receiptPath: KXM_MIGRATION_RECEIPT_PATH,
|
|
1767
|
-
configRevision: verified.configRevision,
|
|
1768
|
-
files: files.map((file) => file.path),
|
|
1769
|
-
plan: first.plan,
|
|
1770
|
-
};
|
|
1771
|
-
} catch (error) {
|
|
1772
|
-
// Roll back this apply's writes so a later retry is not blocked by
|
|
1773
|
-
// migration_target_exists on state we created. Only files written by
|
|
1774
|
-
// this run (all verified absent before the first write) are removed.
|
|
1775
|
-
for (const absolute of written.reverse()) {
|
|
1776
|
-
try { rmSync(absolute, { force: true }); } catch { /* best effort rollback */ }
|
|
1777
|
-
}
|
|
1778
|
-
throw error;
|
|
1779
|
-
}
|
|
1780
|
-
};
|
|
1781
|
-
|
|
1782
|
-
if (options.dryRun) return execute(undefined);
|
|
1783
|
-
return withKxmLocalBindingLock(root, storeOptions, (lock) => execute(lock));
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/* ------------------------- verify ---------------------------------- */
|
|
1787
|
-
|
|
1788
|
-
export interface KxmMigrationVerifyResult {
|
|
1789
|
-
ok: boolean;
|
|
1790
|
-
projectRoot: string;
|
|
1791
|
-
receiptPath: string;
|
|
1792
|
-
configRevision?: string;
|
|
1793
|
-
issues: readonly KxmConfigIssue[];
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/** Re-check a migration receipt against current legacy sources and the target bundle. No writes. */
|
|
1797
|
-
export function verifyKxmMigration(projectRoot: string, options: KxmConfigOptions = {}): KxmMigrationVerifyResult {
|
|
1798
|
-
const root = migrationGitRoot(projectRoot);
|
|
1799
|
-
const receiptAbsolute = join(root, ...KXM_MIGRATION_RECEIPT_PATH.split("/"));
|
|
1800
|
-
if (!existsSync(receiptAbsolute)) {
|
|
1801
|
-
return {
|
|
1802
|
-
ok: false,
|
|
1803
|
-
projectRoot: root,
|
|
1804
|
-
receiptPath: KXM_MIGRATION_RECEIPT_PATH,
|
|
1805
|
-
issues: [migrateIssue("discovery", "migration_receipt_missing", KXM_MIGRATION_RECEIPT_PATH, "no migration receipt exists")],
|
|
1806
|
-
};
|
|
1807
|
-
}
|
|
1808
|
-
try {
|
|
1809
|
-
const bundle = loadKxmProject(root, options);
|
|
1810
|
-
const receipt = bundle.migrationReceipt;
|
|
1811
|
-
if (!receipt) {
|
|
1812
|
-
if (legacyConfigFilesAt(root).length === 0) {
|
|
1813
|
-
// Every legacy source was removed after migration: the receipt was
|
|
1814
|
-
// never consulted at load. Read it directly; readKxmMigrationReceipt
|
|
1815
|
-
// surfaces one migration_source_missing issue per declared source.
|
|
1816
|
-
const direct = readKxmMigrationReceipt(root, options);
|
|
1817
|
-
return {
|
|
1818
|
-
ok: false,
|
|
1819
|
-
projectRoot: root,
|
|
1820
|
-
receiptPath: KXM_MIGRATION_RECEIPT_PATH,
|
|
1821
|
-
configRevision: bundle.configRevision,
|
|
1822
|
-
issues: direct.issues.length > 0 ? direct.issues : [migrateIssue("semantic", "migration_receipt_invalid", KXM_MIGRATION_RECEIPT_PATH, "receipt did not validate")],
|
|
1823
|
-
};
|
|
1824
|
-
}
|
|
1825
|
-
return {
|
|
1826
|
-
ok: false,
|
|
1827
|
-
projectRoot: root,
|
|
1828
|
-
receiptPath: KXM_MIGRATION_RECEIPT_PATH,
|
|
1829
|
-
configRevision: bundle.configRevision,
|
|
1830
|
-
issues: [migrateIssue("semantic", "migration_receipt_invalid", KXM_MIGRATION_RECEIPT_PATH, "receipt did not validate during project load")],
|
|
1831
|
-
};
|
|
1832
|
-
}
|
|
1833
|
-
const targetIssues = verifyKxmMigrationReceiptTarget(root, receipt, bundle.resources, bundle.configRevision);
|
|
1834
|
-
return {
|
|
1835
|
-
ok: targetIssues.length === 0,
|
|
1836
|
-
projectRoot: root,
|
|
1837
|
-
receiptPath: KXM_MIGRATION_RECEIPT_PATH,
|
|
1838
|
-
configRevision: bundle.configRevision,
|
|
1839
|
-
issues: targetIssues,
|
|
1840
|
-
};
|
|
1841
|
-
} catch (error) {
|
|
1842
|
-
if (error instanceof KxmConfigError) {
|
|
1843
|
-
return { ok: false, projectRoot: root, receiptPath: KXM_MIGRATION_RECEIPT_PATH, issues: error.issues };
|
|
1844
|
-
}
|
|
1845
|
-
throw error;
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
|