fix(marketplace): close release lifecycle gaps

This commit is contained in:
2026-08-30 09:19:39 +08:00
parent 8925f37b16
commit c56ddb9d3b
8 changed files with 382 additions and 22 deletions

View File

@@ -100,7 +100,7 @@ export interface InstalledReleaseRecord {
readonly sha256: string;
readonly sizeBytes: number;
readonly installedAt: string;
/** Channel and verified client range are immutable facts of this install. */
/** Channel is the latest explicit selection intent; verified client range is immutable. */
readonly channel?: 'stable' | 'beta';
readonly minMakeloreVersion?: string;
readonly maxMakeloreVersion?: string | null;
@@ -628,6 +628,7 @@ export class PluginPackageStore {
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
private readonly activeWorkers = new Set<string>();
private readonly pendingExplicitCleanupPluginIds = new Set<string>();
private readonly unsubscribeSession: (() => void) | null;
private operation: Promise<void> = Promise.resolve();
@@ -691,7 +692,11 @@ export class PluginPackageStore {
fail('plugin_beta_selection_required');
}
const binding = this.requireBinding();
return this.withOperation(() => this.resolveAndInstallLocked(input, binding));
return this.withOperation(async () => {
const result = await this.resolveAndInstallLocked(input, binding);
this.pendingExplicitCleanupPluginIds.delete(input.pluginId);
return result;
});
}
async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
@@ -714,7 +719,13 @@ export class PluginPackageStore {
this.assertBinding(binding);
this.accountCache.invalidatePlugin(binding, validated);
this.assertBinding(binding);
return this.removeUnusedLocked(validated, binding, 'explicit');
const result = await this.removeUnusedLocked(validated, binding, 'explicit');
if (result.status === 'kept' && result.reason === 'active_worker_reference') {
this.pendingExplicitCleanupPluginIds.add(validated);
} else {
this.pendingExplicitCleanupPluginIds.delete(validated);
}
return result;
});
}
@@ -722,8 +733,15 @@ export class PluginPackageStore {
this.activeWorkers.add(validReleaseId(releaseId));
}
releaseActiveWorker(releaseId: string): void {
async releaseActiveWorker(releaseId: string): Promise<void> {
this.activeWorkers.delete(validReleaseId(releaseId));
if (this.pendingExplicitCleanupPluginIds.size === 0) return;
await this.withOperation(async () => {
for (const pluginId of [...this.pendingExplicitCleanupPluginIds]) {
const result = await this.removeUnusedLocked(pluginId, undefined, 'explicit');
if (result.status === 'removed') this.pendingExplicitCleanupPluginIds.delete(pluginId);
}
});
}
async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
@@ -788,12 +806,13 @@ export class PluginPackageStore {
if (item.action === 'keep') {
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
this.assertBinding(binding);
await this.persistSelectedChannel(index, pluginId, current.releaseId, channel, binding);
return {
status: 'kept',
pluginId,
releaseId: current.releaseId,
version: current.version,
...(current.channel === undefined ? {} : { channel: current.channel }),
channel,
packageRoot: current.packageRoot,
definition: current.definition,
};
@@ -814,6 +833,7 @@ export class PluginPackageStore {
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
if (existing && !existing.unavailableReason) {
this.assertBinding(binding);
await this.persistSelectedChannel(index, pluginId, existing.releaseId, channel, binding);
await this.setCurrentSelection(
await this.readCurrentSelection(),
pluginId,
@@ -824,7 +844,7 @@ export class PluginPackageStore {
pluginId,
releaseId: existing.releaseId,
version: existing.version,
...(existing.channel === undefined ? {} : { channel: existing.channel }),
channel,
packageRoot: existing.packageRoot,
definition: existing.definition,
};
@@ -1016,6 +1036,33 @@ export class PluginPackageStore {
});
}
private async persistSelectedChannel(
index: IndexDocument,
pluginId: string,
releaseId: string,
channel: 'stable' | 'beta',
binding: AccountBinding,
): Promise<void> {
const record = index.releases.find((candidate) => (
candidate.pluginId === pluginId && candidate.releaseId === releaseId
));
if (!record) fail('plugin_store_index_invalid', 'selected Release is missing from the package index');
if (record.channel === channel) return;
this.assertBinding(binding);
const releases = index.releases.map((candidate) => (
candidate === record ? Object.freeze({ ...candidate, channel }) : candidate
));
try {
await this.writeIndex(this.indexPath, serializeIndex({
schema_version: INDEX_SCHEMA_VERSION,
releases,
}));
} catch {
throw new PluginPackageStoreError('plugin_install_failed', 'package channel update failed');
}
this.assertBinding(binding);
}
private async removeUnusedLocked(
validated: string,
binding?: AccountBinding,