ast-mcp-server 0.13.1 → 0.14.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/CHANGELOG.md +15 -4
- package/README.md +24 -10
- package/cordis.patch.yml +1 -1
- package/dist/batch/schema.js +1 -1
- package/dist/services/authenticated-publication.js +181 -0
- package/dist/services/call-spines.js +10 -5
- package/dist/services/context-builder.js +2 -1
- package/dist/services/diagnostics.js +293 -9
- package/dist/services/impact.js +46 -14
- package/dist/services/managed-file.js +3 -50
- package/dist/services/operation-plan-file.js +17 -18
- package/dist/services/operations.js +231 -87
- package/dist/services/relationships.js +403 -4
- package/dist/services/test-candidates.js +35 -2
- package/dist/tools/find_test_candidates.js +24 -3
- package/dist/tools/get_impact.js +12 -2
- package/dist/tools/relationship-schema.js +12 -1
- package/docs/support.md +4 -2
- package/package.json +9 -7
- package/skills/structural-code-editing/SKILL.md +8 -0
- package/skills/structural-code-editing/releases.json +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstatSync, realpathSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { constants, lstatSync, realpathSync } from "node:fs";
|
|
3
|
+
import { open, readFile, stat } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createTwoFilesPatch } from "diff";
|
|
6
6
|
import { LanguageVariant, Node, ScriptTarget, SyntaxKind, ts } from "ts-morph";
|
|
7
|
-
import {
|
|
7
|
+
import { compareObservedDiagnostics, observeDiagnostic, } from "./diagnostics.js";
|
|
8
|
+
import { probePublicationCapability, publishAuthenticated, removeOwnedPublicationEntry, rollbackOwnedCommit, snapshotHeldFile, } from "./authenticated-publication.js";
|
|
8
9
|
import { createFreshProject, findDeclarationByName, getSourceFileOrThrow, invalidateProjectIfIdle, recordProjectMutationHistory, withProjectOperation, } from "./project.js";
|
|
10
|
+
import { PublicOperationalError } from "./public-errors.js";
|
|
9
11
|
import { NO_REQUEST_CONTEXT } from "./request-context.js";
|
|
10
12
|
import { executableDeclaration } from "./symbols.js";
|
|
11
13
|
import { buildClassScaffold } from "./scaffold.js";
|
|
@@ -25,9 +27,9 @@ let operationNow = () => Date.now();
|
|
|
25
27
|
function hash(content) {
|
|
26
28
|
return createHash("sha256").update(content).digest("hex");
|
|
27
29
|
}
|
|
28
|
-
function planHashFor(operation) {
|
|
30
|
+
function planHashFor(operation, version = 2) {
|
|
29
31
|
return hash(JSON.stringify({
|
|
30
|
-
version
|
|
32
|
+
version,
|
|
31
33
|
operation_id: operation.operation_id,
|
|
32
34
|
kind: operation.kind,
|
|
33
35
|
project_root: operation.project_root,
|
|
@@ -62,8 +64,29 @@ function pruneOperations() {
|
|
|
62
64
|
operations.delete(oldest);
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
|
-
function
|
|
66
|
-
return [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()].map((diagnostic) =>
|
|
67
|
+
function observeProjectDiagnostics(project, projectRoot, requestContext) {
|
|
68
|
+
return [...project.getConfigFileParsingDiagnostics(), ...project.getPreEmitDiagnostics()].map((diagnostic, ordinal) => observeDiagnostic(diagnostic, projectRoot, ordinal, requestContext));
|
|
69
|
+
}
|
|
70
|
+
function collectPlannedTextChanges(project, projectRoot, originals, requestContext) {
|
|
71
|
+
const updated = new Map(project
|
|
72
|
+
.getSourceFiles()
|
|
73
|
+
.map((sourceFile) => [sourceFile.getFilePath(), sourceFile.getFullText()]));
|
|
74
|
+
const paths = new Set([...originals.keys(), ...updated.keys()]);
|
|
75
|
+
const changes = [];
|
|
76
|
+
for (const absolutePath of paths) {
|
|
77
|
+
requestContext.checkpoint();
|
|
78
|
+
const beforeText = originals.get(absolutePath) ?? null;
|
|
79
|
+
const afterText = updated.get(absolutePath) ?? null;
|
|
80
|
+
if (beforeText === afterText)
|
|
81
|
+
continue;
|
|
82
|
+
changes.push({
|
|
83
|
+
absolutePath,
|
|
84
|
+
file: path.relative(projectRoot, absolutePath),
|
|
85
|
+
beforeText,
|
|
86
|
+
afterText,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return changes.sort((left, right) => left.file < right.file ? -1 : left.file > right.file ? 1 : 0);
|
|
67
90
|
}
|
|
68
91
|
function decodeSource(bytes, filePath) {
|
|
69
92
|
if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) {
|
|
@@ -164,7 +187,7 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
|
|
|
164
187
|
const context = createFreshProject(projectRootInput);
|
|
165
188
|
const workspaceBefore = createWorkspaceSnapshot(context);
|
|
166
189
|
operationContext.checkpoint();
|
|
167
|
-
const
|
|
190
|
+
const beforeObservations = observeProjectDiagnostics(context.project, context.projectRoot, operationContext);
|
|
168
191
|
const originals = new Map(context.project
|
|
169
192
|
.getSourceFiles()
|
|
170
193
|
.map((sourceFile) => [sourceFile.getFilePath(), sourceFile.getFullText()]));
|
|
@@ -180,37 +203,45 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
|
|
|
180
203
|
operationContext.checkpoint();
|
|
181
204
|
const referenceCount = mutate(context.project, context.projectRoot);
|
|
182
205
|
operationContext.checkpoint();
|
|
183
|
-
const
|
|
184
|
-
const
|
|
206
|
+
const afterObservations = observeProjectDiagnostics(context.project, context.projectRoot, operationContext);
|
|
207
|
+
const textChanges = collectPlannedTextChanges(context.project, context.projectRoot, originals, operationContext);
|
|
208
|
+
if (textChanges.length === 0) {
|
|
209
|
+
throw new Error("The structural operation produced no file changes.");
|
|
210
|
+
}
|
|
211
|
+
const comparisonWorkspace = createWorkspaceSnapshot(createFreshProject(context.tsConfigFilePath));
|
|
212
|
+
operationContext.checkpoint();
|
|
213
|
+
if (comparisonWorkspace.digest !== workspaceBefore.digest) {
|
|
214
|
+
throw new Error("Workspace changed while the operation was being prepared. Retry preparation.");
|
|
215
|
+
}
|
|
216
|
+
const diagnosticDelta = compareObservedDiagnostics(beforeObservations, afterObservations, textChanges, operationContext);
|
|
185
217
|
const files = [];
|
|
186
|
-
for (const
|
|
218
|
+
for (const { absolutePath, file, beforeText, afterText } of textChanges) {
|
|
187
219
|
operationContext.checkpoint();
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (
|
|
192
|
-
const file = path.relative(context.projectRoot, absolutePath);
|
|
220
|
+
if (afterText === null) {
|
|
221
|
+
throw new Error(`Structural operation deletion is unsupported: ${file}`);
|
|
222
|
+
}
|
|
223
|
+
if (beforeText === null) {
|
|
193
224
|
assertSafeNewOperationFile(context.projectRoot, absolutePath, file);
|
|
194
225
|
const originalBytes = Buffer.alloc(0);
|
|
195
|
-
const updatedBytes = Buffer.from(
|
|
226
|
+
const updatedBytes = Buffer.from(afterText, "utf8");
|
|
196
227
|
files.push({
|
|
197
228
|
absolutePath,
|
|
198
229
|
file,
|
|
199
230
|
originalHash: ABSENT_FILE_HASH,
|
|
200
231
|
updatedHash: hashBytes(updatedBytes),
|
|
201
232
|
originalText: "",
|
|
202
|
-
updatedText,
|
|
233
|
+
updatedText: afterText,
|
|
203
234
|
originalBytes,
|
|
204
235
|
updatedBytes,
|
|
205
|
-
diff: createTwoFilesPatch("/dev/null", file, "",
|
|
236
|
+
diff: createTwoFilesPatch("/dev/null", file, "", afterText, "before", "after", {
|
|
206
237
|
context: 3,
|
|
207
238
|
}),
|
|
208
239
|
mode: DEFAULT_NEW_FILE_MODE,
|
|
209
240
|
});
|
|
210
241
|
continue;
|
|
211
242
|
}
|
|
212
|
-
|
|
213
|
-
|
|
243
|
+
const originalText = beforeText;
|
|
244
|
+
const updatedText = afterText;
|
|
214
245
|
const originalBytes = await readFile(absolutePath);
|
|
215
246
|
operationContext.checkpoint();
|
|
216
247
|
if (decodeSource(originalBytes, absolutePath) !== originalText) {
|
|
@@ -219,7 +250,6 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
|
|
|
219
250
|
const updatedBytes = encodeUpdatedSource(originalBytes, updatedText);
|
|
220
251
|
const fileStat = await stat(absolutePath);
|
|
221
252
|
operationContext.checkpoint();
|
|
222
|
-
const file = path.relative(context.projectRoot, absolutePath);
|
|
223
253
|
files.push({
|
|
224
254
|
absolutePath,
|
|
225
255
|
file,
|
|
@@ -235,10 +265,6 @@ async function createPlan(projectRootInput, kind, allowNewErrors, mutate, reques
|
|
|
235
265
|
mode: fileStat.mode,
|
|
236
266
|
});
|
|
237
267
|
}
|
|
238
|
-
files.sort((left, right) => left.file.localeCompare(right.file));
|
|
239
|
-
if (files.length === 0) {
|
|
240
|
-
throw new Error("The structural operation produced no file changes.");
|
|
241
|
-
}
|
|
242
268
|
const postWorkspaceFiles = new Map(workspaceBefore.files);
|
|
243
269
|
for (const file of files) {
|
|
244
270
|
operationContext.checkpoint();
|
|
@@ -413,40 +439,106 @@ async function withWriteLock(key, requestContext, callback) {
|
|
|
413
439
|
writeQueues.delete(key);
|
|
414
440
|
}
|
|
415
441
|
}
|
|
416
|
-
|
|
442
|
+
function matchesPrepared(identity, sha256, mode) {
|
|
443
|
+
return identity.sha256 === sha256 && identity.mode === (mode & 0o777);
|
|
444
|
+
}
|
|
445
|
+
async function stageFile(file, operationId) {
|
|
446
|
+
const parentPath = path.dirname(file.absolutePath);
|
|
447
|
+
const destinationBasename = path.basename(file.absolutePath);
|
|
448
|
+
const stageBasename = `${destinationBasename}.ast-mcp-${operationId}-${randomUUID()}.tmp`;
|
|
449
|
+
const temporaryPath = path.join(parentPath, stageBasename);
|
|
450
|
+
const handles = [];
|
|
451
|
+
let directory;
|
|
452
|
+
let stage;
|
|
417
453
|
try {
|
|
418
|
-
await
|
|
454
|
+
directory = await open(parentPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
455
|
+
handles.push(directory);
|
|
456
|
+
const heldParent = await directory.stat({ bigint: true });
|
|
457
|
+
const namedParent = lstatSync(parentPath, { bigint: true });
|
|
458
|
+
if (!heldParent.isDirectory() ||
|
|
459
|
+
!namedParent.isDirectory() ||
|
|
460
|
+
heldParent.dev !== namedParent.dev ||
|
|
461
|
+
heldParent.ino !== namedParent.ino) {
|
|
462
|
+
throw new Error("Publication parent identity changed during staging.");
|
|
463
|
+
}
|
|
464
|
+
stage = await open(temporaryPath, "wx", file.mode);
|
|
465
|
+
handles.push(stage);
|
|
466
|
+
await stage.writeFile(file.updatedBytes);
|
|
467
|
+
await stage.chmod(file.mode & 0o777);
|
|
468
|
+
await stage.sync();
|
|
469
|
+
const stageIdentity = await snapshotHeldFile(stage);
|
|
470
|
+
if (!matchesPrepared(stageIdentity, file.updatedHash, file.mode)) {
|
|
471
|
+
throw new Error("Staged publication does not match the reviewed postimage.");
|
|
472
|
+
}
|
|
473
|
+
const plan = {
|
|
474
|
+
kind: isCreatedFile(file) ? "creation" : "replacement",
|
|
475
|
+
directory,
|
|
476
|
+
destinationBasename,
|
|
477
|
+
stage,
|
|
478
|
+
stageBasename,
|
|
479
|
+
stageIdentity,
|
|
480
|
+
};
|
|
481
|
+
if (!isCreatedFile(file)) {
|
|
482
|
+
const preimage = await open(`/proc/self/fd/${directory.fd}/${destinationBasename}`, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
483
|
+
handles.push(preimage);
|
|
484
|
+
const preimageIdentity = await snapshotHeldFile(preimage);
|
|
485
|
+
if (!matchesPrepared(preimageIdentity, file.originalHash, file.mode)) {
|
|
486
|
+
throw new Error(`Conflict: ${file.file} changed while publication was staged.`);
|
|
487
|
+
}
|
|
488
|
+
plan.preimage = preimage;
|
|
489
|
+
plan.preimageIdentity = preimageIdentity;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
plan,
|
|
493
|
+
handles,
|
|
494
|
+
parentPath,
|
|
495
|
+
parentDev: heldParent.dev,
|
|
496
|
+
parentIno: heldParent.ino,
|
|
497
|
+
cleanupIdentity: plan.stageIdentity,
|
|
498
|
+
};
|
|
419
499
|
}
|
|
420
500
|
catch (error) {
|
|
421
|
-
if (
|
|
422
|
-
|
|
501
|
+
if (directory && stage) {
|
|
502
|
+
try {
|
|
503
|
+
const currentStage = await snapshotHeldFile(stage);
|
|
504
|
+
await removeOwnedPublicationEntry(directory, stageBasename, currentStage);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// Preserve the original staging error; cleanup remains best effort.
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
await Promise.allSettled(handles.map((handle) => handle.close()));
|
|
511
|
+
throw error;
|
|
423
512
|
}
|
|
424
513
|
}
|
|
425
|
-
async function
|
|
426
|
-
const
|
|
427
|
-
await
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
514
|
+
async function authenticateHeldParent(held) {
|
|
515
|
+
const current = lstatSync(held.parentPath, { bigint: true });
|
|
516
|
+
const opened = await held.plan.directory.stat({ bigint: true });
|
|
517
|
+
if (!current.isDirectory() ||
|
|
518
|
+
current.isSymbolicLink() ||
|
|
519
|
+
current.dev !== held.parentDev ||
|
|
520
|
+
current.ino !== held.parentIno ||
|
|
521
|
+
opened.dev !== held.parentDev ||
|
|
522
|
+
opened.ino !== held.parentIno) {
|
|
523
|
+
throw new Error("Conflict: publication parent identity changed before publication.");
|
|
435
524
|
}
|
|
436
|
-
return temporaryPath;
|
|
437
525
|
}
|
|
438
|
-
async function
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
await
|
|
444
|
-
await handle.sync();
|
|
526
|
+
async function closeHeldPublication(held) {
|
|
527
|
+
await Promise.allSettled(held.handles.map((handle) => handle.close()));
|
|
528
|
+
}
|
|
529
|
+
async function cleanupHeldPublication(held) {
|
|
530
|
+
if (held.cleanupIdentity !== null) {
|
|
531
|
+
await removeOwnedPublicationEntry(held.plan.directory, held.plan.stageBasename, held.cleanupIdentity).catch(() => false);
|
|
445
532
|
}
|
|
446
|
-
|
|
447
|
-
|
|
533
|
+
await closeHeldPublication(held);
|
|
534
|
+
}
|
|
535
|
+
async function settleHeldPublications(held, preserveEntries = false) {
|
|
536
|
+
const publications = [...held];
|
|
537
|
+
if (preserveEntries) {
|
|
538
|
+
for (const publication of publications)
|
|
539
|
+
publication.cleanupIdentity = null;
|
|
448
540
|
}
|
|
449
|
-
await
|
|
541
|
+
await Promise.all(publications.map(preserveEntries ? closeHeldPublication : cleanupHeldPublication));
|
|
450
542
|
}
|
|
451
543
|
async function syncDirectories(files) {
|
|
452
544
|
const directories = new Set(files.map((file) => path.dirname(file.absolutePath)));
|
|
@@ -598,8 +690,25 @@ export async function applyOperation(operationId, planHash, requestContext = NO_
|
|
|
598
690
|
staged.set(file, await stageFile(file, operationId));
|
|
599
691
|
operationContext.checkpoint();
|
|
600
692
|
}
|
|
601
|
-
|
|
693
|
+
const probedParents = new Set();
|
|
694
|
+
for (const [index, file] of operation.files.entries()) {
|
|
602
695
|
operationContext.checkpoint();
|
|
696
|
+
try {
|
|
697
|
+
await operationTestHooks.onFilePhase?.({
|
|
698
|
+
operationId,
|
|
699
|
+
file: file.file,
|
|
700
|
+
index,
|
|
701
|
+
phase: "capability-preflight",
|
|
702
|
+
});
|
|
703
|
+
const parent = path.dirname(file.absolutePath);
|
|
704
|
+
if (!probedParents.has(parent)) {
|
|
705
|
+
await probePublicationCapability(parent);
|
|
706
|
+
probedParents.add(parent);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch (cause) {
|
|
710
|
+
throw new PublicOperationalError("MUTATION_BLOCKED", "Authenticated publication capability is unavailable.", { cause });
|
|
711
|
+
}
|
|
603
712
|
if (isCreatedFile(file)) {
|
|
604
713
|
assertSafeNewOperationFile(operation.project_root, file.absolutePath, file.file);
|
|
605
714
|
continue;
|
|
@@ -612,63 +721,98 @@ export async function applyOperation(operationId, planHash, requestContext = NO_
|
|
|
612
721
|
}
|
|
613
722
|
for (const [index, file] of operation.files.entries()) {
|
|
614
723
|
operationContext.checkpoint();
|
|
615
|
-
await operationTestHooks.
|
|
724
|
+
await operationTestHooks.onFilePhase?.({
|
|
725
|
+
operationId,
|
|
726
|
+
file: file.file,
|
|
727
|
+
index,
|
|
728
|
+
phase: "before-publish",
|
|
729
|
+
});
|
|
730
|
+
const held = staged.get(file);
|
|
731
|
+
await authenticateHeldParent(held);
|
|
616
732
|
if (!completionCritical) {
|
|
617
733
|
operationContext.enterCompletionCritical();
|
|
618
734
|
completionCritical = true;
|
|
619
735
|
invalidateAfterCompletion = true;
|
|
620
736
|
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
737
|
+
const publication = await publishAuthenticated(held.plan);
|
|
738
|
+
if (publication.state === "pre_effect") {
|
|
739
|
+
if (publication.reason === "unsupported") {
|
|
740
|
+
throw new PublicOperationalError("MUTATION_BLOCKED", "Authenticated publication capability is unavailable.");
|
|
741
|
+
}
|
|
742
|
+
throw new PublicOperationalError("CONFLICT", "Authenticated publication preserved a competing entry.");
|
|
626
743
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
applied.push(file);
|
|
744
|
+
if (publication.state === "rolled_back") {
|
|
745
|
+
held.cleanupIdentity = held.plan.stageIdentity;
|
|
746
|
+
throw new PublicOperationalError("CONFLICT", "Authenticated publication preserved a competing entry.");
|
|
631
747
|
}
|
|
632
|
-
|
|
748
|
+
if (publication.state === "ambiguous") {
|
|
749
|
+
held.cleanupIdentity = null;
|
|
750
|
+
throw new PublicOperationalError("AMBIGUOUS_APPLY", "Authenticated publication ownership is ambiguous.");
|
|
751
|
+
}
|
|
752
|
+
held.committed = publication.token;
|
|
753
|
+
held.cleanupIdentity =
|
|
754
|
+
publication.token.kind === "replacement"
|
|
755
|
+
? publication.token.displacedIdentity
|
|
756
|
+
: held.plan.stageIdentity;
|
|
757
|
+
applied.push(file);
|
|
758
|
+
await operationTestHooks.onFilePhase?.({
|
|
759
|
+
operationId,
|
|
760
|
+
file: file.file,
|
|
761
|
+
index,
|
|
762
|
+
phase: "after-commit",
|
|
763
|
+
});
|
|
633
764
|
const writtenBytes = await readFile(file.absolutePath);
|
|
634
765
|
if (hashBytes(writtenBytes) !== file.updatedHash) {
|
|
635
766
|
throw new Error(`Post-write verification failed for ${file.file}.`);
|
|
636
767
|
}
|
|
637
768
|
}
|
|
638
769
|
await syncDirectories(operation.files);
|
|
770
|
+
await settleHeldPublications(staged.values());
|
|
771
|
+
staged.clear();
|
|
639
772
|
}
|
|
640
773
|
catch (error) {
|
|
641
|
-
|
|
774
|
+
if (PublicOperationalError.is(error) && error.code === "AMBIGUOUS_APPLY") {
|
|
775
|
+
await settleHeldPublications(staged.values(), true);
|
|
776
|
+
staged.clear();
|
|
777
|
+
throw error;
|
|
778
|
+
}
|
|
779
|
+
let rollbackAmbiguous = false;
|
|
642
780
|
for (const file of [...applied].reverse()) {
|
|
781
|
+
const held = staged.get(file);
|
|
643
782
|
try {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
783
|
+
await operationTestHooks.onFilePhase?.({
|
|
784
|
+
operationId,
|
|
785
|
+
file: file.file,
|
|
786
|
+
index: operation.files.indexOf(file),
|
|
787
|
+
phase: "before-rollback",
|
|
788
|
+
});
|
|
789
|
+
const rollback = await rollbackOwnedCommit(held.committed);
|
|
790
|
+
if (rollback.state !== "rolled_back") {
|
|
791
|
+
held.cleanupIdentity = null;
|
|
792
|
+
rollbackAmbiguous = true;
|
|
793
|
+
break;
|
|
655
794
|
}
|
|
795
|
+
held.cleanupIdentity = held.committed.destinationIdentity;
|
|
656
796
|
}
|
|
657
|
-
catch
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
797
|
+
catch {
|
|
798
|
+
held.cleanupIdentity = null;
|
|
799
|
+
rollbackAmbiguous = true;
|
|
800
|
+
break;
|
|
661
801
|
}
|
|
662
802
|
}
|
|
663
|
-
|
|
664
|
-
await
|
|
665
|
-
|
|
803
|
+
if (rollbackAmbiguous) {
|
|
804
|
+
await settleHeldPublications(staged.values(), true);
|
|
805
|
+
staged.clear();
|
|
806
|
+
throw new PublicOperationalError("AMBIGUOUS_APPLY", "Authenticated rollback ownership is ambiguous.", { cause: error });
|
|
807
|
+
}
|
|
808
|
+
await settleHeldPublications(staged.values());
|
|
809
|
+
staged.clear();
|
|
810
|
+
if (applied.length > 0) {
|
|
666
811
|
await syncDirectories(applied);
|
|
812
|
+
if (PublicOperationalError.is(error))
|
|
813
|
+
throw error;
|
|
667
814
|
throw new Error(`Operation failed after replacing ${applied.length} file(s); rollback succeeded and original bytes were restored. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
668
815
|
}
|
|
669
|
-
if (rollbackErrors.length > 0) {
|
|
670
|
-
throw new Error(`Operation failed and rollback was incomplete: ${rollbackErrors.join("; ")}. Original error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
671
|
-
}
|
|
672
816
|
throw error;
|
|
673
817
|
}
|
|
674
818
|
operation.status = "applied";
|
|
@@ -756,7 +900,7 @@ export function exportOperationRecord(operationId) {
|
|
|
756
900
|
})),
|
|
757
901
|
};
|
|
758
902
|
}
|
|
759
|
-
export function importOperationRecord(persisted, expectedPlanHash) {
|
|
903
|
+
export function importOperationRecord(persisted, expectedPlanHash, planVersion = 2) {
|
|
760
904
|
if (persisted.plan_hash !== expectedPlanHash) {
|
|
761
905
|
throw new Error(`Plan hash mismatch for operation "${persisted.operation_id}". No files were written.`);
|
|
762
906
|
}
|
|
@@ -838,7 +982,7 @@ export function importOperationRecord(persisted, expectedPlanHash) {
|
|
|
838
982
|
tsConfigFilePath: context.tsConfigFilePath,
|
|
839
983
|
files,
|
|
840
984
|
};
|
|
841
|
-
if (planHashFor(record) !== expectedPlanHash) {
|
|
985
|
+
if (planHashFor(record, planVersion) !== expectedPlanHash) {
|
|
842
986
|
throw new Error(`Persisted plan integrity mismatch for operation "${persisted.operation_id}". No files were written.`);
|
|
843
987
|
}
|
|
844
988
|
const existing = operations.get(record.operation_id);
|