fix(install): merge state across selective installs

This commit is contained in:
dajiaohuang
2026-08-24 20:08:58 -04:00
committed by haelyra
parent 6e66dfbae8
commit 64d7dc5da0
2 changed files with 90 additions and 22 deletions
+24 -22
View File
@@ -133,19 +133,13 @@ function isManagedOperation(operation) {
}
function uniqueOperations(operations) {
const seen = new Set();
return operations.filter(operation => {
const key = [
operation.kind,
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
comparablePath(operation.destinationPath),
].join('\0');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
const byDestination = new Map();
for (const operation of operations) {
// A target path has one current owner. Later operations come from the
// newest plan and replace stale metadata for the same destination.
byDestination.set(comparablePath(operation.destinationPath), operation);
}
return [...byDestination.values()];
}
function buildState(statePreview, operations) {
@@ -236,14 +230,18 @@ function createFileConflictWarning(destinationPath, retainsLegacy) {
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
}
function createDisabledMigration(plan) {
function createDisabledMigration(plan, previousState) {
const finalState = buildState(plan.statePreview, [
...((previousState && previousState.operations) || []),
...plan.statePreview.operations,
]);
return {
enabled: false,
appliedOperations: [...plan.operations],
skippedOperations: [],
warnings: [],
bridgeState: plan.statePreview,
finalState: plan.statePreview,
bridgeState: finalState,
finalState,
legacyOperationsToRemove: [],
requiresBridgeState: false,
};
@@ -331,11 +329,16 @@ function buildMigrationStates(plan, previousState, previous, classification) {
const legacyOperationsToRemove = legacyOperations.filter(operation => (
!retainedLegacyOperations.has(operation)
));
const removedLegacyDestinations = new Set(
legacyOperationsToRemove.map(operation => comparablePath(operation.destinationPath))
);
const finalOperations = [
...((previousState && previousState.operations) || []).filter(operation => (
!removedLegacyDestinations.has(comparablePath(operation.destinationPath))
)),
...plan.statePreview.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
)),
...retainedLegacyOperations,
];
const bridgeOperations = [
...((previousState && previousState.operations) || []),
@@ -352,14 +355,13 @@ function buildMigrationStates(plan, previousState, previous, classification) {
}
function prepareClaudeSkillMigration(plan) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan);
}
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan, previousState);
}
const currentGroups = groupCurrentSkillOperations(plan);
const previous = classifyPreviousOperations(plan, previousState);
const classification = classifySkillConflicts(currentGroups, previous);
@@ -433,6 +433,72 @@ function runTests() {
}
})) passed++; else failed++;
if (test('merges managed operations across selective installs for the same target', () => {
const fixture = createFixture();
try {
applyInstallPlan(fixture.plan);
const extraSourceRelativePath = path.join('skills', 'extra-skill', 'SKILL.md');
const extraSourcePath = path.join(fixture.sourceRoot, extraSourceRelativePath);
const extraDestinationPath = path.join(
fixture.targetRoot,
'skills',
'extra-skill',
'SKILL.md'
);
fs.mkdirSync(path.dirname(extraSourcePath), { recursive: true });
fs.writeFileSync(extraSourcePath, '# Extra ECC skill\n');
const extraOperation = createOperation(
'skill-extra',
fixture.sourceRoot,
extraSourceRelativePath,
extraDestinationPath
);
const extraPlan = {
...fixture.plan,
operations: [extraOperation],
statePreview: {
...fixture.plan.statePreview,
request: {
...fixture.plan.statePreview.request,
modules: [],
includeComponents: ['skill-extra'],
},
resolution: {
selectedModules: [],
skippedModules: [],
},
operations: [extraOperation],
},
};
applyInstallPlan(extraPlan);
const stateAfterExtraInstall = readInstallState(fixture.installStatePath);
assert.ok(fixture.operations.every(operation => (
stateAfterExtraInstall.operations.some(recorded => (
recorded.destinationPath === operation.destinationPath
))
)));
assert.ok(stateAfterExtraInstall.operations.some(operation => (
operation.destinationPath === extraDestinationPath
)));
const retry = applyInstallPlan(fixture.plan);
assert.deepStrictEqual(retry.skippedOperations, []);
const stateAfterRetry = readInstallState(fixture.installStatePath);
assert.ok(stateAfterRetry.operations.some(operation => (
operation.destinationPath === extraDestinationPath
)));
const uninstall = runUninstall(fixture);
assert.strictEqual(uninstall.summary.errorCount, 0);
assert.ok(fixture.operations.every(operation => !fs.existsSync(operation.destinationPath)));
assert.ok(!fs.existsSync(extraDestinationPath));
} finally {
cleanup(fixture.tempDir);
}
})) passed++; else failed++;
if (test('tracks a partial migration so retry and uninstall remain safe', () => {
const fixture = createFixture();
try {