@kontextmind/kxm 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.kxm/README.md +14 -0
- package/.kxm/assets/README.md +5 -0
- package/.kxm/assets/retrospectives/README.md +5 -0
- package/.kxm/config/README.md +5 -0
- package/.kxm/config/agents.json +43 -0
- package/.kxm/config/env.example +56 -0
- package/.kxm/config/update.example.yaml +9 -0
- package/.kxm/config/workflows/fix.json +160 -0
- package/.kxm/config/workflows/jira-development.json +116 -0
- package/.kxm/config/workflows/provenance-quorum.json +150 -0
- package/.kxm/config/workflows/v04-dogfood.json +72 -0
- package/CHANGELOG.md +465 -0
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/SECURITY.md +72 -0
- package/docs/README.md +48 -0
- package/docs/agent-communication-envelopes-and-gates.md +553 -0
- package/docs/architecture.md +242 -0
- package/docs/assignment-runner.md +241 -0
- package/docs/configuration.md +361 -0
- package/docs/continuous-improvement.md +114 -0
- package/docs/getting-started.md +253 -0
- package/docs/kxm-handbook.md +1090 -0
- package/docs/operations.md +205 -0
- package/docs/provenance-gates.md +291 -0
- package/docs/skills.md +45 -0
- package/docs/templates/README.md +95 -0
- package/docs/templates/adr.md +88 -0
- package/docs/templates/architecture.md +120 -0
- package/docs/templates/bug-fix.md +109 -0
- package/docs/templates/feature.md +108 -0
- package/docs/templates/handoff.md +72 -0
- package/docs/templates/postmortem.md +77 -0
- package/docs/templates/research.md +100 -0
- package/docs/templates/review.md +85 -0
- package/docs/templates/runbook.md +73 -0
- package/docs/templates/test-plan.md +87 -0
- package/docs/templates/test-report.md +72 -0
- package/docs/test-matrix.md +121 -0
- package/docs/troubleshooting.md +249 -0
- package/docs/vnext/README.md +62 -0
- package/docs/vnext/architecture.md +185 -0
- package/docs/vnext/effects-and-recovery.md +172 -0
- package/docs/vnext/lifecycles.md +235 -0
- package/docs/vnext/migration.md +220 -0
- package/docs/vnext/routing.md +184 -0
- package/docs/vnext/synchronization.md +172 -0
- package/docs/vnext/terminology.md +240 -0
- package/docs/vnext/validation.md +335 -0
- package/docs/webhook-workflows.md +240 -0
- package/docs/workflow-guide.md +1150 -0
- package/examples/README.md +102 -0
- package/examples/provenance-workflow.json +40 -0
- package/examples/requester.ts +30 -0
- package/examples/reviewer-agent.ts +29 -0
- package/examples/roundtrip.ts +46 -0
- package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
- package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
- package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
- package/examples/vnext/.kxm/agents/planner.yaml +13 -0
- package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
- package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
- package/examples/vnext/.kxm/gates.yaml +8 -0
- package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
- package/examples/vnext/.kxm/models/implementation.yaml +14 -0
- package/examples/vnext/.kxm/models/primary.yaml +17 -0
- package/examples/vnext/.kxm/prices.yaml +111 -0
- package/examples/vnext/.kxm/project/env.yaml +7 -0
- package/examples/vnext/.kxm/project.yaml +32 -0
- package/examples/vnext/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/.kxm/workflows/default.yaml +92 -0
- package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
- package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
- package/examples/vnext/README.md +53 -0
- package/examples/vnext/records/assignment-result-recorded.json +63 -0
- package/examples/vnext/records/assignment-result.json +46 -0
- package/examples/vnext/records/context-candidate.json +42 -0
- package/examples/vnext/records/delivery-manifest.json +66 -0
- package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
- package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
- package/examples/vnext/records/run-created.json +54 -0
- package/examples/vnext/records/sync-event.json +65 -0
- package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
- package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
- package/examples/workflow-signal.ts +63 -0
- package/package.json +129 -0
- package/plugins/kxm/.claude-plugin/plugin.json +73 -0
- package/plugins/kxm/.mcp.json +19 -0
- package/plugins/kxm/README.md +93 -0
- package/plugins/kxm/dist/cli.js +42853 -0
- package/plugins/kxm/dist/client.js +416 -0
- package/plugins/kxm/dist/core.js +1823 -0
- package/plugins/kxm/dist/extension.js +3797 -0
- package/plugins/kxm/dist/mcp-server.js +17104 -0
- package/plugins/kxm/dist/runtime.js +23361 -0
- package/plugins/kxm/dist/server.js +13640 -0
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
- package/plugins/kxm/package.json +12 -0
- package/plugins/kxm/skills/kxm/SKILL.md +97 -0
- package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
- package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
- package/plugins/kxm/src/arbiter.ts +355 -0
- package/plugins/kxm/src/artifacts-exist.ts +62 -0
- package/plugins/kxm/src/autocomplete.ts +236 -0
- package/plugins/kxm/src/cli.ts +3707 -0
- package/plugins/kxm/src/client.ts +614 -0
- package/plugins/kxm/src/commands.ts +1063 -0
- package/plugins/kxm/src/config.ts +290 -0
- package/plugins/kxm/src/context/providers.ts +101 -0
- package/plugins/kxm/src/context-packet.ts +332 -0
- package/plugins/kxm/src/context.ts +499 -0
- package/plugins/kxm/src/core.ts +6 -0
- package/plugins/kxm/src/database.ts +563 -0
- package/plugins/kxm/src/diagnostics.ts +184 -0
- package/plugins/kxm/src/envelope.ts +118 -0
- package/plugins/kxm/src/extension.ts +895 -0
- package/plugins/kxm/src/external-effects.ts +299 -0
- package/plugins/kxm/src/github-watch.ts +255 -0
- package/plugins/kxm/src/hub-binding.ts +160 -0
- package/plugins/kxm/src/hub.ts +2502 -0
- package/plugins/kxm/src/improve.ts +383 -0
- package/plugins/kxm/src/inbox.ts +10 -0
- package/plugins/kxm/src/kxm-install-kind.ts +113 -0
- package/plugins/kxm/src/kxm-update-config.ts +39 -0
- package/plugins/kxm/src/kxm-update.ts +238 -0
- package/plugins/kxm/src/local-snapshot.ts +406 -0
- package/plugins/kxm/src/logger.ts +198 -0
- package/plugins/kxm/src/mcp-server.ts +143 -0
- package/plugins/kxm/src/memory.ts +385 -0
- package/plugins/kxm/src/nous-pi.ts +287 -0
- package/plugins/kxm/src/nous-provider.ts +729 -0
- package/plugins/kxm/src/price-calc.ts +87 -0
- package/plugins/kxm/src/prices.ts +121 -0
- package/plugins/kxm/src/protocol.ts +172 -0
- package/plugins/kxm/src/recovery.ts +211 -0
- package/plugins/kxm/src/redact.ts +26 -0
- package/plugins/kxm/src/retrospective.ts +400 -0
- package/plugins/kxm/src/routing.ts +830 -0
- package/plugins/kxm/src/runtime.ts +9 -0
- package/plugins/kxm/src/server.ts +117 -0
- package/plugins/kxm/src/session-work.ts +571 -0
- package/plugins/kxm/src/session.ts +184 -0
- package/plugins/kxm/src/skills.ts +535 -0
- package/plugins/kxm/src/state.ts +326 -0
- package/plugins/kxm/src/store.ts +637 -0
- package/plugins/kxm/src/studio-layout.ts +268 -0
- package/plugins/kxm/src/suggest.ts +162 -0
- package/plugins/kxm/src/task-manager.ts +244 -0
- package/plugins/kxm/src/telemetry.ts +116 -0
- package/plugins/kxm/src/tui.ts +1046 -0
- package/plugins/kxm/src/vnext-bindings.ts +403 -0
- package/plugins/kxm/src/vnext-config.ts +1646 -0
- package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
- package/plugins/kxm/src/vnext-engine-command.ts +533 -0
- package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
- package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
- package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
- package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
- package/plugins/kxm/src/vnext-engine.ts +2458 -0
- package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
- package/plugins/kxm/src/vnext-harness.ts +1142 -0
- package/plugins/kxm/src/vnext-init.ts +430 -0
- package/plugins/kxm/src/vnext-migrate.ts +1848 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
- package/plugins/kxm/src/vnext-permission.ts +936 -0
- package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
- package/plugins/kxm/src/vnext-repair.ts +1094 -0
- package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
- package/plugins/kxm/src/vnext-runtime.ts +663 -0
- package/plugins/kxm/src/vnext-template.ts +247 -0
- package/plugins/kxm/src/wiki.ts +313 -0
- package/plugins/kxm/src/workflow.ts +1548 -0
- package/schemas/vnext/README.md +46 -0
- package/schemas/vnext/agent.schema.json +40 -0
- package/schemas/vnext/assignment-result.schema.json +66 -0
- package/schemas/vnext/backup-manifest.schema.json +89 -0
- package/schemas/vnext/candidate.schema.json +109 -0
- package/schemas/vnext/common.schema.json +422 -0
- package/schemas/vnext/context-candidate.schema.json +76 -0
- package/schemas/vnext/context-packet.schema.json +192 -0
- package/schemas/vnext/delivery-manifest.schema.json +159 -0
- package/schemas/vnext/environment.schema.json +66 -0
- package/schemas/vnext/gate-registry.schema.json +109 -0
- package/schemas/vnext/handoff-manifest.schema.json +146 -0
- package/schemas/vnext/init-operation.schema.json +61 -0
- package/schemas/vnext/local-repository-bindings.schema.json +30 -0
- package/schemas/vnext/memory-record.schema.json +45 -0
- package/schemas/vnext/migration-decision.schema.json +26 -0
- package/schemas/vnext/migration-plan.schema.json +123 -0
- package/schemas/vnext/migration-receipt.schema.json +52 -0
- package/schemas/vnext/model.schema.json +42 -0
- package/schemas/vnext/permission-diff.schema.json +57 -0
- package/schemas/vnext/prices.schema.json +115 -0
- package/schemas/vnext/project.schema.json +85 -0
- package/schemas/vnext/repository.schema.json +24 -0
- package/schemas/vnext/run-event.schema.json +460 -0
- package/schemas/vnext/session-brief.schema.json +153 -0
- package/schemas/vnext/sync-event.schema.json +234 -0
- package/schemas/vnext/template-provenance.schema.json +38 -0
- package/schemas/vnext/workflow.schema.json +248 -0
- package/scripts/assignment-run.d.mts +354 -0
- package/scripts/assignment-run.mjs +4451 -0
- package/scripts/build-runtime.mjs +56 -0
- package/scripts/check-generated.mjs +77 -0
- package/scripts/check-versions.mjs +34 -0
- package/scripts/emit-codex-artifacts.d.mts +9 -0
- package/scripts/emit-codex-artifacts.mjs +91 -0
- package/scripts/harness-run.d.mts +83 -0
- package/scripts/harness-run.mjs +2095 -0
- package/scripts/kxm-hub.mjs +105 -0
- package/scripts/kxm-publish-npm.mjs +327 -0
- package/scripts/kxm-release-github.mjs +472 -0
- package/scripts/kxm-runtime-supervisor.mjs +7 -0
- package/scripts/kxm-worker.mjs +1127 -0
- package/scripts/kxm.mjs +27 -0
- package/scripts/roster-policy.d.mts +20 -0
- package/scripts/roster-policy.mjs +161 -0
- package/scripts/smoke-multi-pi.mjs +479 -0
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
linkSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
openSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
VnextConfigError,
|
|
20
|
+
VnextSchemaRegistry,
|
|
21
|
+
loadVnextProject,
|
|
22
|
+
assertNoRegisteredGates,
|
|
23
|
+
parseRestrictedYaml,
|
|
24
|
+
type JsonObject,
|
|
25
|
+
type VnextConfigIssue,
|
|
26
|
+
type VnextConfigOptions,
|
|
27
|
+
type VnextProjectBundle,
|
|
28
|
+
} from "./vnext-config.ts";
|
|
29
|
+
import {
|
|
30
|
+
CURRENT_VNEXT_TEMPLATE_VARIANT,
|
|
31
|
+
VNEXT_TEMPLATE_ID,
|
|
32
|
+
VNEXT_TEMPLATE_PROVENANCE_PATH,
|
|
33
|
+
renderVnextTemplate,
|
|
34
|
+
resolveVnextTemplateBaseline,
|
|
35
|
+
SUPPORTED_VNEXT_TEMPLATE_VARIANTS,
|
|
36
|
+
vnextContentSha256,
|
|
37
|
+
type VnextRenderedTemplate,
|
|
38
|
+
type VnextTemplateProvenance,
|
|
39
|
+
type VnextTemplateVariant,
|
|
40
|
+
} from "./vnext-template.ts";
|
|
41
|
+
import { computeVnextResourcePermissionDiff } from "./vnext-permission.ts";
|
|
42
|
+
import type { VnextResourceKind } from "./vnext-config.ts";
|
|
43
|
+
|
|
44
|
+
/** Map a managed template path to its resource kind for permission diffing. */
|
|
45
|
+
function resourceKindForTemplatePath(path: string): VnextResourceKind {
|
|
46
|
+
if (path === ".kxm/project.yaml") return "project";
|
|
47
|
+
if (path === ".kxm/gates.yaml") return "gate-registry";
|
|
48
|
+
if (path.endsWith("repo.yaml")) return "repository";
|
|
49
|
+
if (path.startsWith(".kxm/agents/")) return "agent";
|
|
50
|
+
if (path.startsWith(".kxm/models/")) return "model";
|
|
51
|
+
if (path.startsWith(".kxm/workflows/")) return "workflow";
|
|
52
|
+
return "environment";
|
|
53
|
+
}
|
|
54
|
+
const TRANSACTION_NAME = ".kxm-init-transaction";
|
|
55
|
+
const OPERATION_FILE = "operation.json";
|
|
56
|
+
const MAX_MANAGED_FILES = 128;
|
|
57
|
+
const MAX_MANAGED_FILE_BYTES = 256 * 1024;
|
|
58
|
+
const MAX_MANAGED_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
59
|
+
const PORTABLE_PATH = /^(?:\.|(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*[. ](?:\/|$))[^/\\\u0000-\u001F]+(?:\/[^/\\\u0000-\u001F]+)*)$/;
|
|
60
|
+
|
|
61
|
+
export type VnextTemplateClassification = "unchanged" | "user-only" | "template-only" | "converged" | "conflict";
|
|
62
|
+
|
|
63
|
+
export interface VnextTemplateChange {
|
|
64
|
+
path: string;
|
|
65
|
+
classification: VnextTemplateClassification;
|
|
66
|
+
baseSha256?: string;
|
|
67
|
+
localSha256?: string;
|
|
68
|
+
targetSha256?: string;
|
|
69
|
+
action: "none" | "create" | "replace";
|
|
70
|
+
policyReviewRequired: boolean;
|
|
71
|
+
reason?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface VnextTemplateRepairPlan {
|
|
75
|
+
projectRoot: string;
|
|
76
|
+
projectId: string;
|
|
77
|
+
projectName: string;
|
|
78
|
+
sourceTemplateRevision: string;
|
|
79
|
+
targetTemplateRevision: string;
|
|
80
|
+
changesRequired: boolean;
|
|
81
|
+
canApply: boolean;
|
|
82
|
+
changes: readonly VnextTemplateChange[];
|
|
83
|
+
issues: readonly VnextConfigIssue[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface VnextInitOperationFile {
|
|
87
|
+
path: string;
|
|
88
|
+
observedSha256: string | null;
|
|
89
|
+
targetSha256: string;
|
|
90
|
+
classification: "template-only" | "converged";
|
|
91
|
+
action: "create" | "replace" | "none";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface VnextInitOperation {
|
|
95
|
+
schema: "kxm.init-operation.v1";
|
|
96
|
+
operationId: string;
|
|
97
|
+
kind: "create" | "repair";
|
|
98
|
+
phase: "preparing" | "prepared" | "applying" | "verified";
|
|
99
|
+
projectRoot: string;
|
|
100
|
+
projectId: string;
|
|
101
|
+
projectName: string;
|
|
102
|
+
templateId: typeof VNEXT_TEMPLATE_ID;
|
|
103
|
+
sourceTemplateRevision: string | null;
|
|
104
|
+
targetTemplateRevision: string;
|
|
105
|
+
planSha256: string;
|
|
106
|
+
files: readonly VnextInitOperationFile[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface VnextOperationResult {
|
|
110
|
+
kind: "create" | "repair";
|
|
111
|
+
resumed: boolean;
|
|
112
|
+
bundle: VnextProjectBundle;
|
|
113
|
+
files: readonly string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface VnextRepairRuntimeOptions {
|
|
117
|
+
schemasDir?: string;
|
|
118
|
+
repositoryBindings?: Readonly<Record<string, string>>;
|
|
119
|
+
registeredExecutors?: Iterable<string>;
|
|
120
|
+
registeredToolPresets?: Iterable<string>;
|
|
121
|
+
templateVariant?: VnextTemplateVariant;
|
|
122
|
+
testFaultAt?: "prepared" | "first-resource" | "provenance" | "verified";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
126
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function repairIssue(code: string, file: string, message: string, phase: VnextConfigIssue["phase"] = "semantic"): VnextConfigIssue {
|
|
130
|
+
return { phase, code, file, message };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fail(code: string, file: string, message: string, phase: VnextConfigIssue["phase"] = "semantic"): never {
|
|
134
|
+
throw new VnextConfigError([repairIssue(code, file, message, phase)]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function syncDirectory(path: string): void {
|
|
138
|
+
let descriptor: number | undefined;
|
|
139
|
+
try {
|
|
140
|
+
descriptor = openSync(path, "r");
|
|
141
|
+
fsyncSync(descriptor);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
144
|
+
if (process.platform !== "win32" && code !== "EINVAL" && code !== "ENOTSUP" && code !== "EISDIR") throw error;
|
|
145
|
+
} finally {
|
|
146
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeDurableNew(file: string, bytes: string | Uint8Array, mode: number): void {
|
|
151
|
+
let descriptor: number | undefined;
|
|
152
|
+
try {
|
|
153
|
+
descriptor = openSync(file, "wx", mode);
|
|
154
|
+
writeFileSync(descriptor, bytes);
|
|
155
|
+
fsyncSync(descriptor);
|
|
156
|
+
closeSync(descriptor);
|
|
157
|
+
descriptor = undefined;
|
|
158
|
+
try { chmodSync(file, mode); } catch { /* Windows may not expose POSIX mode changes. */ }
|
|
159
|
+
} finally {
|
|
160
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
161
|
+
}
|
|
162
|
+
syncDirectory(dirname(file));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeDurable(file: string, bytes: string | Uint8Array, mode: number): void {
|
|
166
|
+
let descriptor: number | undefined;
|
|
167
|
+
try {
|
|
168
|
+
descriptor = openSync(file, "w", mode);
|
|
169
|
+
writeFileSync(descriptor, bytes);
|
|
170
|
+
fsyncSync(descriptor);
|
|
171
|
+
closeSync(descriptor);
|
|
172
|
+
descriptor = undefined;
|
|
173
|
+
try { chmodSync(file, mode); } catch { /* Windows may not expose POSIX mode changes. */ }
|
|
174
|
+
} finally {
|
|
175
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
176
|
+
}
|
|
177
|
+
syncDirectory(dirname(file));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sameHostPath(left: string, right: string): boolean {
|
|
181
|
+
const first = resolve(left);
|
|
182
|
+
const second = resolve(right);
|
|
183
|
+
return process.platform === "win32"
|
|
184
|
+
? first.toLocaleLowerCase("en-US") === second.toLocaleLowerCase("en-US")
|
|
185
|
+
: first === second;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function transactionRoot(projectRoot: string): string {
|
|
189
|
+
return join(resolve(projectRoot), TRANSACTION_NAME);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function vnextInitTransactionPath(projectRoot: string): string {
|
|
193
|
+
return transactionRoot(projectRoot);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function operationFile(projectRoot: string): string {
|
|
197
|
+
return join(transactionRoot(projectRoot), OPERATION_FILE);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function targetFile(projectRoot: string, portablePath: string): string {
|
|
201
|
+
return join(transactionRoot(projectRoot), "targets", ...portablePath.split("/"));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function shadowRoot(projectRoot: string): string {
|
|
205
|
+
return join(transactionRoot(projectRoot), "shadow");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function backupFile(projectRoot: string, portablePath: string): string {
|
|
209
|
+
return join(transactionRoot(projectRoot), "backups", ...portablePath.split("/"));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function portableManagedPath(path: string): boolean {
|
|
213
|
+
return path.startsWith(".kxm/")
|
|
214
|
+
&& path !== VNEXT_TEMPLATE_PROVENANCE_PATH
|
|
215
|
+
&& path.length <= 1024
|
|
216
|
+
&& !path.includes("\\")
|
|
217
|
+
&& !isAbsolute(path)
|
|
218
|
+
&& PORTABLE_PATH.test(path)
|
|
219
|
+
&& !/[<>:"|?*]/.test(path);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readRegularBounded(file: string, label: string): Buffer {
|
|
223
|
+
const stat = lstatSync(file);
|
|
224
|
+
if (stat.isSymbolicLink() || !stat.isFile()) fail("repair_file_invalid", label, "managed file must be a regular file, not a link or directory", "path");
|
|
225
|
+
if (stat.size > MAX_MANAGED_FILE_BYTES) fail("repair_file_too_large", label, `managed file exceeds ${MAX_MANAGED_FILE_BYTES} bytes`, "parse");
|
|
226
|
+
return readFileSync(file);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function readOptionalManaged(projectRoot: string, path: string): Buffer | undefined {
|
|
230
|
+
const file = join(projectRoot, ...path.split("/"));
|
|
231
|
+
if (!existsSync(file)) return undefined;
|
|
232
|
+
let parent = dirname(file);
|
|
233
|
+
const boundary = resolve(projectRoot);
|
|
234
|
+
while (parent !== boundary) {
|
|
235
|
+
const stat = lstatSync(parent);
|
|
236
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fail("repair_parent_invalid", path, "managed file parents must be regular directories", "path");
|
|
237
|
+
parent = dirname(parent);
|
|
238
|
+
}
|
|
239
|
+
return readRegularBounded(file, path);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function readProjectAndProvenance(projectRoot: string, schemasDir?: string): { project: JsonObject; provenance: VnextTemplateProvenance; provenanceBytes: Buffer } | undefined {
|
|
243
|
+
const projectFile = join(projectRoot, ".kxm", "project.yaml");
|
|
244
|
+
const provenanceFile = join(projectRoot, ...VNEXT_TEMPLATE_PROVENANCE_PATH.split("/"));
|
|
245
|
+
if (!existsSync(projectFile) || !existsSync(provenanceFile)) return undefined;
|
|
246
|
+
const projectBytes = readOptionalManaged(projectRoot, ".kxm/project.yaml");
|
|
247
|
+
const provenanceBytes = readOptionalManaged(projectRoot, VNEXT_TEMPLATE_PROVENANCE_PATH);
|
|
248
|
+
if (!projectBytes || !provenanceBytes) return undefined;
|
|
249
|
+
const project = parseRestrictedYaml(projectBytes, ".kxm/project.yaml");
|
|
250
|
+
const provenanceValue = parseRestrictedYaml(provenanceBytes, VNEXT_TEMPLATE_PROVENANCE_PATH);
|
|
251
|
+
const registry = new VnextSchemaRegistry(schemasDir);
|
|
252
|
+
const issues = [
|
|
253
|
+
...registry.validate("project", project, ".kxm/project.yaml"),
|
|
254
|
+
...registry.validateTemplateProvenance(provenanceValue, VNEXT_TEMPLATE_PROVENANCE_PATH),
|
|
255
|
+
];
|
|
256
|
+
if (issues.length > 0) throw new VnextConfigError(issues);
|
|
257
|
+
const provenance = resolveVnextTemplateBaseline(provenanceValue);
|
|
258
|
+
if (!provenance) fail("template_provenance_revision_invalid", VNEXT_TEMPLATE_PROVENANCE_PATH, "template provenance does not exactly match a supported built-in baseline");
|
|
259
|
+
if (project.id !== provenance.inputs.projectId) {
|
|
260
|
+
fail("template_provenance_project_mismatch", VNEXT_TEMPLATE_PROVENANCE_PATH, "template provenance belongs to a different project identity");
|
|
261
|
+
}
|
|
262
|
+
const seen = new Set<string>();
|
|
263
|
+
let prior = "";
|
|
264
|
+
let totalBytes = 0;
|
|
265
|
+
for (const record of provenance.files) {
|
|
266
|
+
if (!portableManagedPath(record.path)) fail("template_provenance_path_invalid", VNEXT_TEMPLATE_PROVENANCE_PATH, `managed path ${record.path} is invalid`, "path");
|
|
267
|
+
const folded = record.path.toLocaleLowerCase("en-US");
|
|
268
|
+
if (seen.has(folded)) fail("template_provenance_path_collision", VNEXT_TEMPLATE_PROVENANCE_PATH, `managed path ${record.path} collides after case folding`, "path");
|
|
269
|
+
if (prior && compareCodeUnits(prior, record.path) >= 0) fail("template_provenance_order_invalid", VNEXT_TEMPLATE_PROVENANCE_PATH, "managed files are not in strict code-unit order");
|
|
270
|
+
totalBytes += record.bytes;
|
|
271
|
+
seen.add(folded);
|
|
272
|
+
prior = record.path;
|
|
273
|
+
}
|
|
274
|
+
if (provenance.files.length > MAX_MANAGED_FILES || totalBytes > MAX_MANAGED_TOTAL_BYTES) {
|
|
275
|
+
fail("template_provenance_bounds_exceeded", VNEXT_TEMPLATE_PROVENANCE_PATH, "managed template manifest exceeds bounded file or byte limits", "parse");
|
|
276
|
+
}
|
|
277
|
+
return { project, provenance, provenanceBytes };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Classify exact whole-file B/L/T states without writing or inferring a missing baseline. */
|
|
281
|
+
export function planVnextTemplateRepair(
|
|
282
|
+
projectRoot: string,
|
|
283
|
+
options: VnextRepairRuntimeOptions = {},
|
|
284
|
+
): VnextTemplateRepairPlan | undefined {
|
|
285
|
+
assertNoRegisteredGates(options);
|
|
286
|
+
const root = resolve(projectRoot);
|
|
287
|
+
const source = readProjectAndProvenance(root, options.schemasDir);
|
|
288
|
+
if (!source) return undefined;
|
|
289
|
+
const target = renderVnextTemplate(
|
|
290
|
+
source.provenance.inputs.projectId,
|
|
291
|
+
source.provenance.inputs.projectName,
|
|
292
|
+
options.templateVariant ?? CURRENT_VNEXT_TEMPLATE_VARIANT,
|
|
293
|
+
);
|
|
294
|
+
const baseByPath = new Map(source.provenance.files.map((record) => [record.path, record]));
|
|
295
|
+
const targetByPath = new Map(target.provenance.files.map((record) => [record.path, record]));
|
|
296
|
+
const paths = [...new Set([...baseByPath.keys(), ...targetByPath.keys()])].sort(compareCodeUnits);
|
|
297
|
+
const sourceRenderer = SUPPORTED_VNEXT_TEMPLATE_VARIANTS
|
|
298
|
+
.map((variant) => renderVnextTemplate(source.provenance.inputs.projectId, source.provenance.inputs.projectName, variant))
|
|
299
|
+
.find((candidate) => candidate.templateRevision === source.provenance.templateRevision);
|
|
300
|
+
const expectedProvenance = sourceRenderer?.files.get(VNEXT_TEMPLATE_PROVENANCE_PATH);
|
|
301
|
+
const issues: VnextConfigIssue[] = [];
|
|
302
|
+
if (!expectedProvenance || vnextContentSha256(source.provenanceBytes) !== vnextContentSha256(expectedProvenance)) {
|
|
303
|
+
issues.push(repairIssue(
|
|
304
|
+
"template_provenance_user_modified",
|
|
305
|
+
VNEXT_TEMPLATE_PROVENANCE_PATH,
|
|
306
|
+
"template provenance formatting or bytes differ from the supported baseline and require review",
|
|
307
|
+
));
|
|
308
|
+
}
|
|
309
|
+
const changes = paths.map((path): VnextTemplateChange => {
|
|
310
|
+
const base = baseByPath.get(path);
|
|
311
|
+
const targetRecord = targetByPath.get(path);
|
|
312
|
+
const local = readOptionalManaged(root, path);
|
|
313
|
+
const localSha256 = local && vnextContentSha256(local);
|
|
314
|
+
const baseSha256 = base?.sha256;
|
|
315
|
+
if (base && localSha256 === base.sha256 && local?.byteLength !== base.bytes) {
|
|
316
|
+
fail("template_provenance_bytes_invalid", VNEXT_TEMPLATE_PROVENANCE_PATH, `recorded byte count for ${path} does not match its baseline content`);
|
|
317
|
+
}
|
|
318
|
+
const targetSha256 = targetRecord?.sha256;
|
|
319
|
+
if (baseSha256 === localSha256 && localSha256 === targetSha256) {
|
|
320
|
+
return {
|
|
321
|
+
path,
|
|
322
|
+
classification: "unchanged",
|
|
323
|
+
...(baseSha256 ? { baseSha256 } : {}),
|
|
324
|
+
...(localSha256 ? { localSha256 } : {}),
|
|
325
|
+
...(targetSha256 ? { targetSha256 } : {}),
|
|
326
|
+
action: "none",
|
|
327
|
+
policyReviewRequired: false,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (localSha256 !== undefined && localSha256 === targetSha256) {
|
|
331
|
+
return { path, classification: "converged", ...(baseSha256 ? { baseSha256 } : {}), localSha256, targetSha256, action: "none", policyReviewRequired: false };
|
|
332
|
+
}
|
|
333
|
+
if (localSha256 === baseSha256 && targetRecord) {
|
|
334
|
+
const authorityChanged = base?.authoritySha256 !== targetRecord.authoritySha256;
|
|
335
|
+
if (authorityChanged || !base) {
|
|
336
|
+
const baseValue = base ? sourceRenderer?.values.get(path) : undefined;
|
|
337
|
+
const targetValue = target.values.get(path);
|
|
338
|
+
const detail = baseValue && targetValue
|
|
339
|
+
? computeVnextResourcePermissionDiff(resourceKindForTemplatePath(path), path, baseValue, targetValue)
|
|
340
|
+
.map((change) => `${change.path} ${change.field} ${change.direction}`)
|
|
341
|
+
.join("; ")
|
|
342
|
+
: "";
|
|
343
|
+
issues.push(repairIssue(
|
|
344
|
+
"template_policy_review_required",
|
|
345
|
+
path,
|
|
346
|
+
`template change alters or introduces an authority-bearing resource and requires reviewed permission-diff trust${detail ? `: ${detail}` : ""}`,
|
|
347
|
+
));
|
|
348
|
+
return {
|
|
349
|
+
path,
|
|
350
|
+
classification: "conflict",
|
|
351
|
+
...(baseSha256 ? { baseSha256 } : {}),
|
|
352
|
+
...(localSha256 ? { localSha256 } : {}),
|
|
353
|
+
...(targetSha256 ? { targetSha256 } : {}),
|
|
354
|
+
action: "none",
|
|
355
|
+
policyReviewRequired: true,
|
|
356
|
+
reason: "authority projection changed",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
path,
|
|
361
|
+
classification: "template-only",
|
|
362
|
+
baseSha256: baseSha256 as string,
|
|
363
|
+
...(localSha256 ? { localSha256 } : {}),
|
|
364
|
+
targetSha256: targetSha256 as string,
|
|
365
|
+
action: localSha256 === undefined ? "create" : "replace",
|
|
366
|
+
policyReviewRequired: false,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (targetSha256 === baseSha256) {
|
|
370
|
+
return {
|
|
371
|
+
path,
|
|
372
|
+
classification: "user-only",
|
|
373
|
+
...(baseSha256 ? { baseSha256 } : {}),
|
|
374
|
+
...(localSha256 ? { localSha256 } : {}),
|
|
375
|
+
...(targetSha256 ? { targetSha256 } : {}),
|
|
376
|
+
action: "none",
|
|
377
|
+
policyReviewRequired: false,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
issues.push(repairIssue("template_repair_conflict", path, "both the user and template changed this managed file; local bytes were preserved"));
|
|
381
|
+
return {
|
|
382
|
+
path,
|
|
383
|
+
classification: "conflict",
|
|
384
|
+
...(baseSha256 ? { baseSha256 } : {}),
|
|
385
|
+
...(localSha256 ? { localSha256 } : {}),
|
|
386
|
+
...(targetSha256 ? { targetSha256 } : {}),
|
|
387
|
+
action: "none",
|
|
388
|
+
policyReviewRequired: false,
|
|
389
|
+
reason: targetRecord ? "user and template changes overlap" : "template deletion requires explicit review",
|
|
390
|
+
};
|
|
391
|
+
});
|
|
392
|
+
const changesRequired = source.provenance.templateRevision !== target.templateRevision;
|
|
393
|
+
return {
|
|
394
|
+
projectRoot: root,
|
|
395
|
+
projectId: source.provenance.inputs.projectId,
|
|
396
|
+
projectName: source.provenance.inputs.projectName,
|
|
397
|
+
sourceTemplateRevision: source.provenance.templateRevision,
|
|
398
|
+
targetTemplateRevision: target.templateRevision,
|
|
399
|
+
changesRequired,
|
|
400
|
+
canApply: changesRequired && issues.length === 0,
|
|
401
|
+
changes,
|
|
402
|
+
issues,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function operationPlanSha(operation: Omit<VnextInitOperation, "planSha256" | "phase">): string {
|
|
407
|
+
return vnextContentSha256(JSON.stringify({
|
|
408
|
+
kind: operation.kind,
|
|
409
|
+
projectRoot: operation.projectRoot,
|
|
410
|
+
projectId: operation.projectId,
|
|
411
|
+
projectName: operation.projectName,
|
|
412
|
+
templateId: operation.templateId,
|
|
413
|
+
sourceTemplateRevision: operation.sourceTemplateRevision,
|
|
414
|
+
targetTemplateRevision: operation.targetTemplateRevision,
|
|
415
|
+
files: operation.files,
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function writeOperation(projectRoot: string, operation: VnextInitOperation): void {
|
|
420
|
+
const root = transactionRoot(projectRoot);
|
|
421
|
+
const file = operationFile(projectRoot);
|
|
422
|
+
const temporary = join(root, `.operation-${operation.operationId}-${randomUUID()}.tmp`);
|
|
423
|
+
try {
|
|
424
|
+
writeDurableNew(temporary, Buffer.from(`${JSON.stringify(operation, null, 2)}\n`, "utf8"), 0o600);
|
|
425
|
+
renameSync(temporary, file);
|
|
426
|
+
syncDirectory(root);
|
|
427
|
+
} finally {
|
|
428
|
+
rmSync(temporary, { force: true });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function updatePhase(projectRoot: string, operation: VnextInitOperation, phase: VnextInitOperation["phase"]): VnextInitOperation {
|
|
433
|
+
const updated = { ...operation, phase };
|
|
434
|
+
writeOperation(projectRoot, updated);
|
|
435
|
+
return updated;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function readOperation(projectRoot: string, schemasDir?: string): VnextInitOperation | undefined {
|
|
439
|
+
const root = transactionRoot(projectRoot);
|
|
440
|
+
if (!existsSync(root)) return undefined;
|
|
441
|
+
const stat = lstatSync(root);
|
|
442
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fail("init_transaction_invalid", TRANSACTION_NAME, "initialization transaction must be a regular directory", "path");
|
|
443
|
+
const file = operationFile(projectRoot);
|
|
444
|
+
if (!existsSync(file)) {
|
|
445
|
+
const entries = readdirSync(root, { withFileTypes: true });
|
|
446
|
+
const recoverable = entries.every((entry) => entry.isFile() && /^\.operation-op_[a-f0-9]{32}-[a-f0-9-]{36}\.tmp$/.test(entry.name));
|
|
447
|
+
if (recoverable) return undefined;
|
|
448
|
+
fail("init_transaction_record_missing", TRANSACTION_NAME, "initialization transaction without an operation record contains unrecognized state");
|
|
449
|
+
}
|
|
450
|
+
const value = parseRestrictedYaml(readRegularBounded(file, `${TRANSACTION_NAME}/${OPERATION_FILE}`), `${TRANSACTION_NAME}/${OPERATION_FILE}`);
|
|
451
|
+
const registry = new VnextSchemaRegistry(schemasDir);
|
|
452
|
+
const issues = registry.validateInitOperation(value, `${TRANSACTION_NAME}/${OPERATION_FILE}`);
|
|
453
|
+
if (issues.length > 0) throw new VnextConfigError(issues);
|
|
454
|
+
const operation = value as unknown as VnextInitOperation;
|
|
455
|
+
if (!sameHostPath(operation.projectRoot, projectRoot)) fail("init_transaction_project_mismatch", TRANSACTION_NAME, "operation belongs to a different project root");
|
|
456
|
+
const expectedPlanSha = operationPlanSha(operation);
|
|
457
|
+
if (operation.planSha256 !== expectedPlanSha) fail("init_transaction_plan_hash_invalid", TRANSACTION_NAME, "operation plan hash does not match its immutable intent");
|
|
458
|
+
let prior = "";
|
|
459
|
+
const seen = new Set<string>();
|
|
460
|
+
for (const entry of operation.files) {
|
|
461
|
+
if (!entry.path.startsWith(".kxm/") || !PORTABLE_PATH.test(entry.path)) fail("init_transaction_path_invalid", TRANSACTION_NAME, `operation path ${entry.path} is invalid`, "path");
|
|
462
|
+
const folded = entry.path.toLocaleLowerCase("en-US");
|
|
463
|
+
if (seen.has(folded) || (prior && compareCodeUnits(prior, entry.path) >= 0)) fail("init_transaction_order_invalid", TRANSACTION_NAME, "operation paths must be unique and use strict code-unit order", "path");
|
|
464
|
+
seen.add(folded);
|
|
465
|
+
prior = entry.path;
|
|
466
|
+
}
|
|
467
|
+
if (!isAbsolute(operation.projectRoot)) fail("init_transaction_project_root_invalid", TRANSACTION_NAME, "operation project root must be absolute", "path");
|
|
468
|
+
validateOperationIntent(projectRoot, operation);
|
|
469
|
+
return operation;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function hasVnextInitTransaction(projectRoot: string): boolean {
|
|
473
|
+
return existsSync(transactionRoot(projectRoot));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function readTarget(projectRoot: string, path: string): Buffer | undefined {
|
|
477
|
+
return readOptionalManaged(join(transactionRoot(projectRoot), "targets"), path);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function writeTarget(projectRoot: string, path: string, bytes: Buffer): void {
|
|
481
|
+
if (bytes.byteLength > MAX_MANAGED_FILE_BYTES) fail("template_target_too_large", path, `target exceeds ${MAX_MANAGED_FILE_BYTES} bytes`);
|
|
482
|
+
const file = targetFile(projectRoot, path);
|
|
483
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
484
|
+
writeDurableNew(file, bytes, 0o600);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function writeAndVerifyBackups(projectRoot: string, operation: VnextInitOperation): void {
|
|
488
|
+
if (operation.kind !== "repair") return;
|
|
489
|
+
for (const entry of operation.files) {
|
|
490
|
+
if (entry.observedSha256 === null) continue;
|
|
491
|
+
const current = readOptionalManaged(projectRoot, entry.path);
|
|
492
|
+
if (!current || vnextContentSha256(current) !== entry.observedSha256) {
|
|
493
|
+
fail("repair_preimage_changed", entry.path, "destination changed before transaction preparation; local bytes were preserved");
|
|
494
|
+
}
|
|
495
|
+
const file = backupFile(projectRoot, entry.path);
|
|
496
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
497
|
+
writeDurableNew(file, current, 0o600);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function writeKnownBackups(projectRoot: string, operation: VnextInitOperation): void {
|
|
502
|
+
if (operation.kind !== "repair" || !operation.sourceTemplateRevision) return;
|
|
503
|
+
const source = rendererForRevision(operation, operation.sourceTemplateRevision);
|
|
504
|
+
if (!source) fail("init_transaction_source_unknown", TRANSACTION_NAME, "operation source is not a supported built-in template");
|
|
505
|
+
for (const entry of operation.files) {
|
|
506
|
+
if (entry.observedSha256 === null) continue;
|
|
507
|
+
const bytes = source.files.get(entry.path);
|
|
508
|
+
if (!bytes || vnextContentSha256(bytes) !== entry.observedSha256) {
|
|
509
|
+
fail("init_transaction_backup_source_invalid", entry.path, "supported source template cannot reproduce the pinned preimage");
|
|
510
|
+
}
|
|
511
|
+
const file = backupFile(projectRoot, entry.path);
|
|
512
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
513
|
+
writeDurableNew(file, bytes, 0o600);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function verifyBackups(projectRoot: string, operation: VnextInitOperation): void {
|
|
518
|
+
if (operation.kind !== "repair") return;
|
|
519
|
+
for (const entry of operation.files) {
|
|
520
|
+
if (entry.observedSha256 === null) continue;
|
|
521
|
+
const bytes = readOptionalManaged(join(transactionRoot(projectRoot), "backups"), entry.path);
|
|
522
|
+
if (!bytes || vnextContentSha256(bytes) !== entry.observedSha256) {
|
|
523
|
+
fail("init_transaction_backup_invalid", entry.path, "pinned preimage backup is missing or corrupt");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function verifyTargetArtifacts(projectRoot: string, operation: VnextInitOperation): void {
|
|
529
|
+
let total = 0;
|
|
530
|
+
for (const entry of operation.files) {
|
|
531
|
+
const bytes = readTarget(projectRoot, entry.path);
|
|
532
|
+
if (!bytes) fail("init_transaction_target_missing", entry.path, "pinned target artifact is missing");
|
|
533
|
+
total += bytes.byteLength;
|
|
534
|
+
if (vnextContentSha256(bytes) !== entry.targetSha256) fail("init_transaction_target_mismatch", entry.path, "pinned target artifact hash does not match the operation");
|
|
535
|
+
}
|
|
536
|
+
if (operation.files.length > MAX_MANAGED_FILES || total > MAX_MANAGED_TOTAL_BYTES) fail("init_transaction_bounds_exceeded", TRANSACTION_NAME, "operation exceeds bounded file or byte limits", "parse");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function loaderOptions(options: VnextRepairRuntimeOptions): VnextConfigOptions {
|
|
540
|
+
assertNoRegisteredGates(options);
|
|
541
|
+
return {
|
|
542
|
+
...(options.schemasDir === undefined ? {} : { schemasDir: options.schemasDir }),
|
|
543
|
+
...(options.repositoryBindings === undefined ? {} : { repositoryBindings: options.repositoryBindings }),
|
|
544
|
+
...(options.registeredExecutors === undefined ? {} : { registeredExecutors: options.registeredExecutors }),
|
|
545
|
+
...(options.registeredToolPresets === undefined ? {} : { registeredToolPresets: options.registeredToolPresets }),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function sourceConfigFiles(projectRoot: string): string[] {
|
|
550
|
+
const fixed = [
|
|
551
|
+
".kxm/project.yaml",
|
|
552
|
+
".kxm/gates.yaml",
|
|
553
|
+
VNEXT_TEMPLATE_PROVENANCE_PATH,
|
|
554
|
+
".kxm/project/env.yaml",
|
|
555
|
+
".kxm/repo/repo.yaml",
|
|
556
|
+
".kxm/repo/env.yaml",
|
|
557
|
+
];
|
|
558
|
+
const files = fixed.filter((path) => existsSync(join(projectRoot, ...path.split("/"))));
|
|
559
|
+
for (const directory of ["agents", "models", "workflows"]) {
|
|
560
|
+
const absolute = join(projectRoot, ".kxm", directory);
|
|
561
|
+
if (!existsSync(absolute)) continue;
|
|
562
|
+
const stat = lstatSync(absolute);
|
|
563
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fail("repair_directory_invalid", `.kxm/${directory}`, "configuration directory must be regular", "path");
|
|
564
|
+
for (const entry of readdirSync(absolute, { withFileTypes: true })) {
|
|
565
|
+
if (entry.isDirectory()) fail("repair_nested_directory", `.kxm/${directory}/${entry.name}`, "nested configuration directories are not repairable", "path");
|
|
566
|
+
const lower = entry.name.toLocaleLowerCase("en-US");
|
|
567
|
+
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) files.push(`.kxm/${directory}/${entry.name}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const ordered = [...new Set(files)].sort(compareCodeUnits);
|
|
571
|
+
if (ordered.length > MAX_MANAGED_FILES) fail("repair_snapshot_too_many_files", ".kxm", `configuration snapshot exceeds ${MAX_MANAGED_FILES} files`, "parse");
|
|
572
|
+
return ordered;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function absoluteMemberBindings(projectRoot: string, project: JsonObject, explicit: Readonly<Record<string, string>> = {}): Readonly<Record<string, string>> {
|
|
576
|
+
const bindings: Record<string, string> = { ...explicit };
|
|
577
|
+
const repositories = Array.isArray(project.repositories) ? project.repositories : [];
|
|
578
|
+
for (const candidate of repositories) {
|
|
579
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
|
580
|
+
const repository = candidate as JsonObject;
|
|
581
|
+
if (repository.role !== "member" || typeof repository.id !== "string" || Object.hasOwn(bindings, repository.id)) continue;
|
|
582
|
+
if (typeof repository.pathHint === "string") {
|
|
583
|
+
const candidate = resolve(projectRoot, ...repository.pathHint.split("/"));
|
|
584
|
+
if (existsSync(candidate)) bindings[repository.id] = candidate;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return bindings;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function prepareShadow(projectRoot: string, operation: VnextInitOperation, options: VnextRepairRuntimeOptions): void {
|
|
591
|
+
const shadow = shadowRoot(projectRoot);
|
|
592
|
+
mkdirSync(shadow, { recursive: true, mode: 0o700 });
|
|
593
|
+
let total = 0;
|
|
594
|
+
if (operation.kind === "repair") {
|
|
595
|
+
for (const path of sourceConfigFiles(projectRoot)) {
|
|
596
|
+
const bytes = readOptionalManaged(projectRoot, path);
|
|
597
|
+
if (!bytes) fail("repair_snapshot_file_missing", path, "configuration changed during shadow preparation");
|
|
598
|
+
total += bytes.byteLength;
|
|
599
|
+
const source = join(projectRoot, ...path.split("/"));
|
|
600
|
+
const destination = join(shadow, ...path.split("/"));
|
|
601
|
+
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
|
602
|
+
writeDurableNew(destination, bytes, lstatSync(source).mode & 0o777);
|
|
603
|
+
if (total > MAX_MANAGED_TOTAL_BYTES) fail("repair_snapshot_too_large", ".kxm", `configuration snapshot exceeds ${MAX_MANAGED_TOTAL_BYTES} bytes`, "parse");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
for (const entry of operation.files) {
|
|
607
|
+
const bytes = readTarget(projectRoot, entry.path);
|
|
608
|
+
if (!bytes) fail("init_transaction_target_missing", entry.path, "pinned target artifact is missing");
|
|
609
|
+
const destination = join(shadow, ...entry.path.split("/"));
|
|
610
|
+
mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
|
|
611
|
+
const mode = existsSync(destination) ? lstatSync(destination).mode & 0o777 : 0o644;
|
|
612
|
+
writeDurable(destination, bytes, mode);
|
|
613
|
+
}
|
|
614
|
+
const project = parseRestrictedYaml(readFileSync(join(shadow, ".kxm", "project.yaml")), ".kxm/project.yaml");
|
|
615
|
+
const bound = absoluteMemberBindings(projectRoot, project, options.repositoryBindings);
|
|
616
|
+
loadVnextProject(shadow, { ...loaderOptions(options), repositoryBindings: bound });
|
|
617
|
+
const provenance = readProjectAndProvenance(shadow, options.schemasDir)?.provenance;
|
|
618
|
+
if (!provenance || provenance.templateRevision !== operation.targetTemplateRevision) {
|
|
619
|
+
fail("repair_shadow_provenance_invalid", VNEXT_TEMPLATE_PROVENANCE_PATH, "prospective template provenance does not match the pinned target");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function createOperation(
|
|
624
|
+
projectRoot: string,
|
|
625
|
+
kind: "create" | "repair",
|
|
626
|
+
rendered: VnextRenderedTemplate,
|
|
627
|
+
files: readonly VnextInitOperationFile[],
|
|
628
|
+
sourceTemplateRevision: string | null,
|
|
629
|
+
): VnextInitOperation {
|
|
630
|
+
const root = resolve(projectRoot);
|
|
631
|
+
const operationWithoutHash = {
|
|
632
|
+
schema: "kxm.init-operation.v1" as const,
|
|
633
|
+
operationId: `op_${randomUUID().replaceAll("-", "")}`,
|
|
634
|
+
kind,
|
|
635
|
+
projectRoot: root,
|
|
636
|
+
projectId: rendered.projectId,
|
|
637
|
+
projectName: rendered.projectName,
|
|
638
|
+
templateId: VNEXT_TEMPLATE_ID as typeof VNEXT_TEMPLATE_ID,
|
|
639
|
+
sourceTemplateRevision,
|
|
640
|
+
targetTemplateRevision: rendered.templateRevision,
|
|
641
|
+
files: [...files].sort((left, right) => compareCodeUnits(left.path, right.path)),
|
|
642
|
+
};
|
|
643
|
+
return {
|
|
644
|
+
...operationWithoutHash,
|
|
645
|
+
phase: "preparing",
|
|
646
|
+
planSha256: operationPlanSha(operationWithoutHash),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function validateOperationRecord(operation: VnextInitOperation, schemasDir?: string): void {
|
|
651
|
+
const label = `${TRANSACTION_NAME}/${OPERATION_FILE}`;
|
|
652
|
+
const value = parseRestrictedYaml(Buffer.from(JSON.stringify(operation), "utf8"), label);
|
|
653
|
+
const issues = new VnextSchemaRegistry(schemasDir).validateInitOperation(value, label);
|
|
654
|
+
if (issues.length > 0) throw new VnextConfigError(issues);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function prepareOperation(
|
|
658
|
+
projectRoot: string,
|
|
659
|
+
operation: VnextInitOperation,
|
|
660
|
+
rendered: VnextRenderedTemplate,
|
|
661
|
+
options: VnextRepairRuntimeOptions,
|
|
662
|
+
): VnextInitOperation {
|
|
663
|
+
const transaction = transactionRoot(projectRoot);
|
|
664
|
+
validateOperationRecord(operation, options.schemasDir);
|
|
665
|
+
validateOperationIntent(projectRoot, operation);
|
|
666
|
+
if (existsSync(transaction)) fail("init_transaction_exists", TRANSACTION_NAME, "an initialization transaction already exists and must be resumed");
|
|
667
|
+
mkdirSync(transaction, { mode: 0o700 });
|
|
668
|
+
syncDirectory(projectRoot);
|
|
669
|
+
writeOperation(projectRoot, operation);
|
|
670
|
+
try {
|
|
671
|
+
for (const entry of operation.files) {
|
|
672
|
+
const bytes = rendered.files.get(entry.path);
|
|
673
|
+
if (!bytes || vnextContentSha256(bytes) !== entry.targetSha256) fail("template_target_unavailable", entry.path, "renderer cannot reproduce the pinned target bytes");
|
|
674
|
+
writeTarget(projectRoot, entry.path, bytes);
|
|
675
|
+
}
|
|
676
|
+
writeAndVerifyBackups(projectRoot, operation);
|
|
677
|
+
verifyTargetArtifacts(projectRoot, operation);
|
|
678
|
+
verifyBackups(projectRoot, operation);
|
|
679
|
+
prepareShadow(projectRoot, operation, options);
|
|
680
|
+
syncTreeDirectories(transaction);
|
|
681
|
+
return updatePhase(projectRoot, operation, "prepared");
|
|
682
|
+
} catch (error) {
|
|
683
|
+
rmSync(transaction, { recursive: true, force: true });
|
|
684
|
+
throw error;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function ensureDestinationParents(projectRoot: string, path: string): void {
|
|
689
|
+
const boundary = resolve(projectRoot);
|
|
690
|
+
const segments = path.split("/").slice(0, -1);
|
|
691
|
+
let current = boundary;
|
|
692
|
+
for (const segment of segments) {
|
|
693
|
+
current = join(current, segment);
|
|
694
|
+
if (!existsSync(current)) mkdirSync(current, { mode: 0o700 });
|
|
695
|
+
const stat = lstatSync(current);
|
|
696
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fail("repair_destination_parent_invalid", path, "destination parent must be a regular directory", "path");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function destinationSha(projectRoot: string, path: string): string | undefined {
|
|
701
|
+
const file = join(projectRoot, ...path.split("/"));
|
|
702
|
+
if (!existsSync(file)) return undefined;
|
|
703
|
+
return vnextContentSha256(readRegularBounded(file, path));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function requireConditionalLinkSupport(directory: string, operationId: string, path: string): void {
|
|
707
|
+
const nonce = randomUUID();
|
|
708
|
+
const source = join(directory, `.kxm-repair-${operationId}-${nonce}.link-source`);
|
|
709
|
+
const target = join(directory, `.kxm-repair-${operationId}-${nonce}.link-target`);
|
|
710
|
+
try {
|
|
711
|
+
writeDurableNew(source, Buffer.from("kxm-link-probe\n", "utf8"), 0o600);
|
|
712
|
+
try { linkSync(source, target); }
|
|
713
|
+
catch { fail("repair_hard_link_unsupported", path, "filesystem does not support the conditional hard-link install required for safe repair"); }
|
|
714
|
+
} finally {
|
|
715
|
+
rmSync(target, { force: true });
|
|
716
|
+
rmSync(source, { force: true });
|
|
717
|
+
syncDirectory(directory);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function atomicInstallTarget(projectRoot: string, operation: VnextInitOperation, entry: VnextInitOperationFile): void {
|
|
722
|
+
const destination = join(projectRoot, ...entry.path.split("/"));
|
|
723
|
+
ensureDestinationParents(projectRoot, entry.path);
|
|
724
|
+
const directory = dirname(destination);
|
|
725
|
+
const temporaryPrefix = `.kxm-repair-${operation.operationId}-`;
|
|
726
|
+
const pathKey = vnextContentSha256(entry.path).slice("sha256:".length, "sha256:".length + 16);
|
|
727
|
+
const displaced = join(directory, `${temporaryPrefix}${pathKey}.preimage`);
|
|
728
|
+
if (existsSync(displaced)) {
|
|
729
|
+
const stat = lstatSync(displaced);
|
|
730
|
+
if (stat.isSymbolicLink() || !stat.isFile()) fail("repair_preimage_temporary_invalid", entry.path, "stale displaced preimage is not a regular file", "path");
|
|
731
|
+
if (entry.observedSha256 === null || vnextContentSha256(readFileSync(displaced)) !== entry.observedSha256) {
|
|
732
|
+
fail("repair_preimage_temporary_mismatch", entry.path, "stale displaced preimage does not match the pinned operation");
|
|
733
|
+
}
|
|
734
|
+
if (!existsSync(destination)) {
|
|
735
|
+
try { linkSync(displaced, destination); }
|
|
736
|
+
catch { fail("repair_preimage_restore_blocked", entry.path, "could not conditionally restore a displaced preimage"); }
|
|
737
|
+
syncDirectory(directory);
|
|
738
|
+
}
|
|
739
|
+
rmSync(displaced, { force: true });
|
|
740
|
+
syncDirectory(directory);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
for (const candidate of readdirSync(directory, { withFileTypes: true })) {
|
|
744
|
+
const owned = candidate.name.startsWith(temporaryPrefix)
|
|
745
|
+
&& (candidate.name.endsWith(".tmp") || candidate.name.endsWith(".link-source") || candidate.name.endsWith(".link-target"));
|
|
746
|
+
if (!owned) continue;
|
|
747
|
+
if (!candidate.isFile()) fail("repair_temporary_invalid", entry.path, "stale operation temporary is not a regular file", "path");
|
|
748
|
+
rmSync(join(directory, candidate.name), { force: true });
|
|
749
|
+
}
|
|
750
|
+
const current = destinationSha(projectRoot, entry.path);
|
|
751
|
+
if (current === entry.targetSha256) return;
|
|
752
|
+
if (current !== (entry.observedSha256 ?? undefined)) {
|
|
753
|
+
fail("repair_preimage_changed", entry.path, "destination changed after planning; pinned target was not installed");
|
|
754
|
+
}
|
|
755
|
+
requireConditionalLinkSupport(directory, operation.operationId, entry.path);
|
|
756
|
+
const bytes = readTarget(projectRoot, entry.path);
|
|
757
|
+
if (!bytes) fail("init_transaction_target_missing", entry.path, "pinned target artifact is missing");
|
|
758
|
+
const temporary = join(directory, `${temporaryPrefix}${randomUUID()}.tmp`);
|
|
759
|
+
const mode = existsSync(destination) ? lstatSync(destination).mode & 0o777 : 0o644;
|
|
760
|
+
try {
|
|
761
|
+
writeDurableNew(temporary, bytes, mode);
|
|
762
|
+
if (entry.observedSha256 !== null) {
|
|
763
|
+
renameSync(destination, displaced);
|
|
764
|
+
syncDirectory(directory);
|
|
765
|
+
if (vnextContentSha256(readFileSync(displaced)) !== entry.observedSha256) {
|
|
766
|
+
if (!existsSync(destination)) {
|
|
767
|
+
try { linkSync(displaced, destination); }
|
|
768
|
+
catch { fail("repair_preimage_restore_blocked", entry.path, "changed destination was displaced but could not be conditionally restored; its bytes remain in the operation preimage file"); }
|
|
769
|
+
syncDirectory(directory);
|
|
770
|
+
rmSync(displaced, { force: true });
|
|
771
|
+
syncDirectory(directory);
|
|
772
|
+
}
|
|
773
|
+
fail("repair_preimage_changed", entry.path, "destination changed at replacement time; concurrent bytes were preserved at the destination or in the operation preimage file");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
linkSync(temporary, destination);
|
|
778
|
+
} catch {
|
|
779
|
+
if (!existsSync(destination) && existsSync(displaced)) {
|
|
780
|
+
try { linkSync(displaced, destination); }
|
|
781
|
+
catch { fail("repair_preimage_restore_blocked", entry.path, "target install failed and the displaced preimage could not be conditionally restored"); }
|
|
782
|
+
syncDirectory(directory);
|
|
783
|
+
}
|
|
784
|
+
rmSync(displaced, { force: true });
|
|
785
|
+
syncDirectory(directory);
|
|
786
|
+
fail("repair_destination_recreated", entry.path, "destination was recreated concurrently or conditional install is unsupported; pinned target was not installed");
|
|
787
|
+
}
|
|
788
|
+
syncDirectory(directory);
|
|
789
|
+
rmSync(temporary, { force: true });
|
|
790
|
+
rmSync(displaced, { force: true });
|
|
791
|
+
syncDirectory(directory);
|
|
792
|
+
} finally {
|
|
793
|
+
rmSync(temporary, { force: true });
|
|
794
|
+
}
|
|
795
|
+
if (destinationSha(projectRoot, entry.path) !== entry.targetSha256) fail("repair_install_verification_failed", entry.path, "installed target hash does not match the pinned operation");
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function installedCreateMatches(projectRoot: string, operation: VnextInitOperation): boolean {
|
|
799
|
+
const configRoot = join(projectRoot, ".kxm");
|
|
800
|
+
if (!existsSync(configRoot)) return false;
|
|
801
|
+
const actual: string[] = [];
|
|
802
|
+
const visit = (directory: string): void => {
|
|
803
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
804
|
+
const absolute = join(directory, entry.name);
|
|
805
|
+
if (entry.isSymbolicLink()) fail("create_resume_link", relativeConfigPath(projectRoot, absolute), "created configuration must not contain links", "path");
|
|
806
|
+
if (entry.isDirectory()) visit(absolute);
|
|
807
|
+
else if (entry.isFile()) actual.push(relativeConfigPath(projectRoot, absolute));
|
|
808
|
+
else fail("create_resume_file_invalid", relativeConfigPath(projectRoot, absolute), "created configuration contains a non-regular entry", "path");
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
visit(configRoot);
|
|
812
|
+
actual.sort(compareCodeUnits);
|
|
813
|
+
const expected = operation.files.map((entry) => entry.path).sort(compareCodeUnits);
|
|
814
|
+
return actual.length === expected.length
|
|
815
|
+
&& actual.every((path, index) => path === expected[index])
|
|
816
|
+
&& operation.files.every((entry) => destinationSha(projectRoot, entry.path) === entry.targetSha256);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function relativeConfigPath(projectRoot: string, file: string): string {
|
|
820
|
+
return relative(resolve(projectRoot), file).replaceAll("\\", "/");
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function syncTreeDirectories(root: string): void {
|
|
824
|
+
if (!existsSync(root)) return;
|
|
825
|
+
const directories: string[] = [];
|
|
826
|
+
const visit = (directory: string): void => {
|
|
827
|
+
directories.push(directory);
|
|
828
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
829
|
+
if (entry.isDirectory()) visit(join(directory, entry.name));
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
visit(root);
|
|
833
|
+
for (const directory of directories.reverse()) syncDirectory(directory);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function rebuildCreateShadow(projectRoot: string, operation: VnextInitOperation, options: VnextRepairRuntimeOptions): void {
|
|
837
|
+
const shadow = shadowRoot(projectRoot);
|
|
838
|
+
rmSync(shadow, { recursive: true, force: true });
|
|
839
|
+
prepareShadow(projectRoot, operation, options);
|
|
840
|
+
const stat = lstatSync(shadow);
|
|
841
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || !installedCreateMatches(shadow, operation)) {
|
|
842
|
+
fail("create_shadow_invalid", TRANSACTION_NAME, "reconstructed create shadow does not exactly match pinned target files", "path");
|
|
843
|
+
}
|
|
844
|
+
syncTreeDirectories(join(shadow, ".kxm"));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function applyOperation(
|
|
848
|
+
projectRoot: string,
|
|
849
|
+
original: VnextInitOperation,
|
|
850
|
+
options: VnextRepairRuntimeOptions,
|
|
851
|
+
resumed: boolean,
|
|
852
|
+
): VnextOperationResult {
|
|
853
|
+
assertNoRegisteredGates(options);
|
|
854
|
+
let operation = original;
|
|
855
|
+
const effectiveOptions = options;
|
|
856
|
+
verifyTargetArtifacts(projectRoot, operation);
|
|
857
|
+
verifyBackups(projectRoot, operation);
|
|
858
|
+
operation = updatePhase(projectRoot, operation, "applying");
|
|
859
|
+
if (operation.kind === "create") {
|
|
860
|
+
const destination = join(projectRoot, ".kxm");
|
|
861
|
+
if (!existsSync(destination)) {
|
|
862
|
+
rebuildCreateShadow(projectRoot, operation, effectiveOptions);
|
|
863
|
+
renameSync(join(shadowRoot(projectRoot), ".kxm"), destination);
|
|
864
|
+
syncDirectory(projectRoot);
|
|
865
|
+
} else if (!installedCreateMatches(projectRoot, operation)) {
|
|
866
|
+
fail("create_resume_conflict", ".kxm", "existing project configuration does not match the pinned create operation");
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
const resources = operation.files.filter((entry) => entry.path !== VNEXT_TEMPLATE_PROVENANCE_PATH && entry.action !== "none");
|
|
870
|
+
let installed = 0;
|
|
871
|
+
for (const entry of resources) {
|
|
872
|
+
atomicInstallTarget(projectRoot, operation, entry);
|
|
873
|
+
installed += 1;
|
|
874
|
+
if (installed === 1 && options.testFaultAt === "first-resource") throw new Error("injected init fault after first resource");
|
|
875
|
+
}
|
|
876
|
+
loadVnextProject(projectRoot, loaderOptions(effectiveOptions));
|
|
877
|
+
const provenance = operation.files.find((entry) => entry.path === VNEXT_TEMPLATE_PROVENANCE_PATH);
|
|
878
|
+
if (!provenance) fail("repair_provenance_target_missing", TRANSACTION_NAME, "repair operation has no final provenance target");
|
|
879
|
+
atomicInstallTarget(projectRoot, operation, provenance);
|
|
880
|
+
if (options.testFaultAt === "provenance") throw new Error("injected init fault after provenance");
|
|
881
|
+
}
|
|
882
|
+
const bundle = loadVnextProject(projectRoot, loaderOptions(effectiveOptions));
|
|
883
|
+
const source = readProjectAndProvenance(projectRoot, effectiveOptions.schemasDir);
|
|
884
|
+
if (!source || source.provenance.templateRevision !== operation.targetTemplateRevision) {
|
|
885
|
+
fail("init_operation_verification_failed", VNEXT_TEMPLATE_PROVENANCE_PATH, "installed provenance does not match the pinned transaction");
|
|
886
|
+
}
|
|
887
|
+
operation = updatePhase(projectRoot, operation, "verified");
|
|
888
|
+
if (options.testFaultAt === "verified") throw new Error("injected init fault after verification");
|
|
889
|
+
const files = operation.files.map((entry) => entry.path);
|
|
890
|
+
syncTreeDirectories(join(projectRoot, ".kxm"));
|
|
891
|
+
return { kind: operation.kind, resumed, bundle, files };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export function prepareAndApplyVnextCreate(
|
|
895
|
+
projectRoot: string,
|
|
896
|
+
rendered: VnextRenderedTemplate,
|
|
897
|
+
options: VnextRepairRuntimeOptions = {},
|
|
898
|
+
): VnextOperationResult {
|
|
899
|
+
assertNoRegisteredGates(options);
|
|
900
|
+
const files = [...rendered.files.entries()].map(([path, bytes]): VnextInitOperationFile => ({
|
|
901
|
+
path,
|
|
902
|
+
observedSha256: null,
|
|
903
|
+
targetSha256: vnextContentSha256(bytes),
|
|
904
|
+
classification: "template-only",
|
|
905
|
+
action: "create",
|
|
906
|
+
}));
|
|
907
|
+
const operation = createOperation(projectRoot, "create", rendered, files, null);
|
|
908
|
+
const prepared = prepareOperation(projectRoot, operation, rendered, options);
|
|
909
|
+
if (options.testFaultAt === "prepared") throw new Error("injected init fault after prepare");
|
|
910
|
+
return applyOperation(projectRoot, prepared, options, false);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export function prepareAndApplyVnextRepair(
|
|
914
|
+
plan: VnextTemplateRepairPlan,
|
|
915
|
+
options: VnextRepairRuntimeOptions = {},
|
|
916
|
+
): VnextOperationResult {
|
|
917
|
+
assertNoRegisteredGates(options);
|
|
918
|
+
if (!plan.canApply) fail("template_repair_not_applicable", ".kxm", "template repair has conflicts, policy changes, or no safe template-only update");
|
|
919
|
+
const rendered = renderVnextTemplate(plan.projectId, plan.projectName, options.templateVariant ?? CURRENT_VNEXT_TEMPLATE_VARIANT);
|
|
920
|
+
if (rendered.templateRevision !== plan.targetTemplateRevision) fail("template_repair_target_changed", ".kxm", "template target changed after planning");
|
|
921
|
+
const actionable = plan.changes.filter((change) => change.classification === "template-only");
|
|
922
|
+
const provenanceBytes = rendered.files.get(VNEXT_TEMPLATE_PROVENANCE_PATH);
|
|
923
|
+
if (!provenanceBytes) fail("template_provenance_target_missing", VNEXT_TEMPLATE_PROVENANCE_PATH, "renderer did not produce provenance");
|
|
924
|
+
const sourceProvenance = readOptionalManaged(plan.projectRoot, VNEXT_TEMPLATE_PROVENANCE_PATH);
|
|
925
|
+
if (!sourceProvenance) fail("template_provenance_missing", VNEXT_TEMPLATE_PROVENANCE_PATH, "repair requires recorded provenance");
|
|
926
|
+
const files: VnextInitOperationFile[] = actionable.map((change) => ({
|
|
927
|
+
path: change.path,
|
|
928
|
+
observedSha256: change.localSha256 ?? null,
|
|
929
|
+
targetSha256: change.targetSha256 as string,
|
|
930
|
+
classification: "template-only",
|
|
931
|
+
action: change.action,
|
|
932
|
+
}));
|
|
933
|
+
files.push({
|
|
934
|
+
path: VNEXT_TEMPLATE_PROVENANCE_PATH,
|
|
935
|
+
observedSha256: vnextContentSha256(sourceProvenance),
|
|
936
|
+
targetSha256: vnextContentSha256(provenanceBytes),
|
|
937
|
+
classification: "template-only",
|
|
938
|
+
action: "replace",
|
|
939
|
+
});
|
|
940
|
+
const operation = createOperation(plan.projectRoot, "repair", rendered, files, plan.sourceTemplateRevision);
|
|
941
|
+
const prepared = prepareOperation(plan.projectRoot, operation, rendered, options);
|
|
942
|
+
if (options.testFaultAt === "prepared") throw new Error("injected init fault after prepare");
|
|
943
|
+
return applyOperation(plan.projectRoot, prepared, options, false);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function rendererForRevision(operation: VnextInitOperation, revision: string): VnextRenderedTemplate | undefined {
|
|
947
|
+
for (const variant of SUPPORTED_VNEXT_TEMPLATE_VARIANTS) {
|
|
948
|
+
const rendered = renderVnextTemplate(operation.projectId, operation.projectName, variant);
|
|
949
|
+
if (rendered.templateRevision === revision) return rendered;
|
|
950
|
+
}
|
|
951
|
+
return undefined;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function rendererForOperation(operation: VnextInitOperation): VnextRenderedTemplate | undefined {
|
|
955
|
+
return rendererForRevision(operation, operation.targetTemplateRevision);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Re-derive every executable operation field from immutable built-in templates. */
|
|
959
|
+
function validateOperationIntent(projectRoot: string, operation: VnextInitOperation): void {
|
|
960
|
+
const target = rendererForOperation(operation);
|
|
961
|
+
if (!target) fail("init_transaction_target_unknown", TRANSACTION_NAME, "operation target is not a supported built-in template");
|
|
962
|
+
const entries = new Map(operation.files.map((entry) => [entry.path, entry]));
|
|
963
|
+
if (operation.kind === "create") {
|
|
964
|
+
if (operation.sourceTemplateRevision !== null) {
|
|
965
|
+
fail("init_transaction_create_invalid", TRANSACTION_NAME, "create operation has a source revision");
|
|
966
|
+
}
|
|
967
|
+
if (entries.size !== target.files.size) fail("init_transaction_create_files_invalid", TRANSACTION_NAME, "create operation does not contain the exact built-in target file set");
|
|
968
|
+
for (const [path, bytes] of target.files) {
|
|
969
|
+
const entry = entries.get(path);
|
|
970
|
+
if (!entry
|
|
971
|
+
|| entry.observedSha256 !== null
|
|
972
|
+
|| entry.targetSha256 !== vnextContentSha256(bytes)
|
|
973
|
+
|| entry.classification !== "template-only"
|
|
974
|
+
|| entry.action !== "create") {
|
|
975
|
+
fail("init_transaction_create_file_invalid", path, "create operation file does not match its built-in target");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (!operation.sourceTemplateRevision || operation.sourceTemplateRevision === operation.targetTemplateRevision) {
|
|
982
|
+
fail("init_transaction_repair_revision_invalid", TRANSACTION_NAME, "repair operation requires distinct supported source and target revisions");
|
|
983
|
+
}
|
|
984
|
+
const source = rendererForRevision(operation, operation.sourceTemplateRevision);
|
|
985
|
+
if (!source) fail("init_transaction_source_unknown", TRANSACTION_NAME, "operation source is not a supported built-in template");
|
|
986
|
+
const sourceRecords = new Map(source.provenance.files.map((record) => [record.path, record]));
|
|
987
|
+
const targetRecords = new Map(target.provenance.files.map((record) => [record.path, record]));
|
|
988
|
+
if (sourceRecords.size !== targetRecords.size
|
|
989
|
+
|| [...sourceRecords.keys()].some((path) => !targetRecords.has(path))) {
|
|
990
|
+
fail("init_transaction_template_shape_changed", TRANSACTION_NAME, "automatic repair cannot add or delete managed template paths");
|
|
991
|
+
}
|
|
992
|
+
const safeChangedPaths: string[] = [];
|
|
993
|
+
for (const [path, base] of sourceRecords) {
|
|
994
|
+
const next = targetRecords.get(path) as typeof base;
|
|
995
|
+
if (base.authoritySha256 !== next.authoritySha256) {
|
|
996
|
+
fail("init_transaction_authority_change", path, "automatic repair operation changes an authority projection");
|
|
997
|
+
}
|
|
998
|
+
if (base.sha256 !== next.sha256) safeChangedPaths.push(path);
|
|
999
|
+
}
|
|
1000
|
+
const provenanceEntry = entries.get(VNEXT_TEMPLATE_PROVENANCE_PATH);
|
|
1001
|
+
const sourceProvenanceBytes = source.files.get(VNEXT_TEMPLATE_PROVENANCE_PATH) as Buffer;
|
|
1002
|
+
const targetProvenanceBytes = target.files.get(VNEXT_TEMPLATE_PROVENANCE_PATH) as Buffer;
|
|
1003
|
+
if (!provenanceEntry
|
|
1004
|
+
|| provenanceEntry.observedSha256 !== vnextContentSha256(sourceProvenanceBytes)
|
|
1005
|
+
|| provenanceEntry.targetSha256 !== vnextContentSha256(targetProvenanceBytes)
|
|
1006
|
+
|| provenanceEntry.classification !== "template-only"
|
|
1007
|
+
|| provenanceEntry.action !== "replace") {
|
|
1008
|
+
fail("init_transaction_provenance_intent_invalid", VNEXT_TEMPLATE_PROVENANCE_PATH, "repair provenance action does not match supported source and target templates");
|
|
1009
|
+
}
|
|
1010
|
+
for (const [path, entry] of entries) {
|
|
1011
|
+
if (path === VNEXT_TEMPLATE_PROVENANCE_PATH) continue;
|
|
1012
|
+
const base = sourceRecords.get(path);
|
|
1013
|
+
const next = targetRecords.get(path);
|
|
1014
|
+
if (!base || !next || base.sha256 === next.sha256
|
|
1015
|
+
|| entry.observedSha256 !== base.sha256
|
|
1016
|
+
|| entry.targetSha256 !== next.sha256
|
|
1017
|
+
|| entry.classification !== "template-only"
|
|
1018
|
+
|| entry.action !== "replace") {
|
|
1019
|
+
fail("init_transaction_repair_file_invalid", path, "repair operation file is not a safe built-in template-only replacement");
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
for (const path of safeChangedPaths) {
|
|
1023
|
+
if (!entries.has(path) && destinationSha(projectRoot, path) !== targetRecords.get(path)?.sha256) {
|
|
1024
|
+
fail("init_transaction_repair_file_missing", path, "repair operation omitted a changed file that has not converged to the target");
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function finishPreparingOperation(
|
|
1030
|
+
projectRoot: string,
|
|
1031
|
+
operation: VnextInitOperation,
|
|
1032
|
+
options: VnextRepairRuntimeOptions,
|
|
1033
|
+
): VnextInitOperation {
|
|
1034
|
+
const rendered = rendererForOperation(operation);
|
|
1035
|
+
if (!rendered) fail("init_transaction_renderer_unavailable", TRANSACTION_NAME, "this binary cannot reproduce the pinned pre-application target");
|
|
1036
|
+
for (const directory of ["targets", "backups", "shadow"]) {
|
|
1037
|
+
rmSync(join(transactionRoot(projectRoot), directory), { recursive: true, force: true });
|
|
1038
|
+
}
|
|
1039
|
+
for (const entry of operation.files) {
|
|
1040
|
+
const bytes = rendered.files.get(entry.path);
|
|
1041
|
+
if (!bytes || vnextContentSha256(bytes) !== entry.targetSha256) fail("template_target_unavailable", entry.path, "renderer cannot reproduce the pinned target bytes");
|
|
1042
|
+
writeTarget(projectRoot, entry.path, bytes);
|
|
1043
|
+
}
|
|
1044
|
+
writeKnownBackups(projectRoot, operation);
|
|
1045
|
+
verifyTargetArtifacts(projectRoot, operation);
|
|
1046
|
+
verifyBackups(projectRoot, operation);
|
|
1047
|
+
prepareShadow(projectRoot, operation, options);
|
|
1048
|
+
syncTreeDirectories(transactionRoot(projectRoot));
|
|
1049
|
+
return updatePhase(projectRoot, operation, "prepared");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Resume only a validated fixed transaction; filesystem truth wins over its phase hint. */
|
|
1053
|
+
export function resumeVnextInitTransaction(
|
|
1054
|
+
projectRoot: string,
|
|
1055
|
+
options: VnextRepairRuntimeOptions = {},
|
|
1056
|
+
): VnextOperationResult | undefined {
|
|
1057
|
+
assertNoRegisteredGates(options);
|
|
1058
|
+
let operation = readOperation(projectRoot, options.schemasDir);
|
|
1059
|
+
if (!operation) {
|
|
1060
|
+
const root = transactionRoot(projectRoot);
|
|
1061
|
+
if (existsSync(root)) {
|
|
1062
|
+
rmSync(root, { recursive: true, force: true });
|
|
1063
|
+
syncDirectory(projectRoot);
|
|
1064
|
+
}
|
|
1065
|
+
return undefined;
|
|
1066
|
+
}
|
|
1067
|
+
if (operation.phase === "preparing") operation = finishPreparingOperation(projectRoot, operation, options);
|
|
1068
|
+
verifyTargetArtifacts(projectRoot, operation);
|
|
1069
|
+
return applyOperation(projectRoot, operation, options, true);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** Remove a verified transaction only after associated local binding intent is durable. */
|
|
1073
|
+
export function commitVnextInitTransaction(projectRoot: string, schemasDir?: string): void {
|
|
1074
|
+
const operation = readOperation(projectRoot, schemasDir);
|
|
1075
|
+
if (!operation || operation.phase !== "verified") {
|
|
1076
|
+
fail("init_transaction_not_verified", TRANSACTION_NAME, "initialization transaction cannot commit before installed state is verified");
|
|
1077
|
+
}
|
|
1078
|
+
const transaction = transactionRoot(projectRoot);
|
|
1079
|
+
const retired = join(projectRoot, `${TRANSACTION_NAME}-cleanup-${operation.operationId}-${randomUUID()}`);
|
|
1080
|
+
renameSync(transaction, retired);
|
|
1081
|
+
syncDirectory(projectRoot);
|
|
1082
|
+
rmSync(retired, { recursive: true, force: true });
|
|
1083
|
+
syncDirectory(projectRoot);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Validate a pending transaction for dry-run reporting without writing or cleanup. */
|
|
1087
|
+
export function inspectVnextInitTransaction(projectRoot: string, schemasDir?: string): VnextInitOperation | undefined {
|
|
1088
|
+
const operation = readOperation(projectRoot, schemasDir);
|
|
1089
|
+
if (operation && operation.phase !== "preparing") {
|
|
1090
|
+
verifyTargetArtifacts(projectRoot, operation);
|
|
1091
|
+
verifyBackups(projectRoot, operation);
|
|
1092
|
+
}
|
|
1093
|
+
return operation;
|
|
1094
|
+
}
|