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

@@ -0,0 +1,130 @@
# Task: Marketplace MLM-06 R6 fixed-range remediation
## Identity
- Task ID: 20260830-marketplace-mlm06-r6-remediation-3c8e7a52
- Mode: Feature
- Branch: codex/20260830-marketplace-mlm06-r6-remediation-3c8e7a52-marketplace-mlm06-r6-remediation
- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r6-remediation-3c8e7a52
- Base commit: 8925f37b16de7fcbb43d6b8e6b76a9eb72b4a764
- Owner: marketplace-client-coordinator-r6-remediator
- Status: Ready for Integration
## Scope
- Remediate the three deduplicated R6 client review findings from exact clean
coordinator frontier `8925f37b16de7fcbb43d6b8e6b76a9eb72b4a764`.
- Own only Package Store channel/deferred-cleanup behavior, the Main-to-Pi active
Release disposal seam, the My Plugins system-included projection, focused tests,
and this task record.
## Intent And Constraints
- Preserve immutable Release bytes and frozen active workers while making explicit
uninstall complete automatically after the last protecting worker exits; any local
account snapshot reference must continue to block deletion.
- Persist the explicitly requested Stable/Beta channel when both channels resolve to
the same immutable Release, without another download or hot-swapping a worker.
- Present `system_included` as supplied with MakeLore and suppress device package
actions, while preserving project enablement and Agent assignment actions.
- Test first. Keep one source commit and one writer. Do not touch Server, user root
worktrees, Release B/Provider/hosted runtime/billing, production trust, XMA-01,
deployment, publication, push, or PR state.
## Project Context Loaded
Task context:
- Task ID: `20260830-marketplace-mlm06-r6-remediation-3c8e7a52`
- Mode: Feature
- Branch: `codex/20260830-marketplace-mlm06-r6-remediation-3c8e7a52-marketplace-mlm06-r6-remediation`
- Worktree: `D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r6-remediation-3c8e7a52`
- Base commit: `8925f37b16de7fcbb43d6b8e6b76a9eb72b4a764`
- Other active local tasks: the clean client coordinator and read-only R6 Standards/
Spec reviewers are the only relevant same-topic owners; historical owners are
completed/ready-for-integration.
- Overlap or semantic-conflict assessment: review overlap is intentional and read-only;
both axes independently report the same three roots. No concurrent product writer or
contradictory decision exists.
Read:
- `AGENTS.md`; `implement-spec`, `maintain-project-docs`, and `code-review` skills.
- Project entry/planning memory, positioning, current state, decisions, architecture,
domain/evidence/reflection/commitment/stale indexes, coordinator record, R6 review records.
- Canonical Marketplace implementation spec, ticket graph MLM-02/MLM-06, and accepted
design Package Store/channel/system-included sections.
Relevant understanding:
- Project goal: Release A has one safe Package Store/effective projection chain while
acquisition, installation, enablement, assignment, runtime, and billing remain separate.
- Current integrated focus: post-R5 client branch is clean; Server R7 is frozen/pass.
- Active task scope: three surgical client corrections only.
- Active constraints: active worker and account references are authorities; Beta is
explicit; bundled Data Service never enters Library/admission/download.
- Decisions affecting this task: Package Store cleanup only removes unreferenced Releases;
current workers never hot-swap; stable/Beta remain distinct user intent.
- Evidence/commitments: existing R5 full unit/typecheck/lint/build evidence is the baseline;
official production public key and XMA-01 remain holds.
- Files likely involved: `electron/coding-plugins/package-store.ts`,
`electron/api/coding-composition.ts`, `electron/coding-runtime/pi/runtime.ts`,
`src/pages/MyPlugins/index.tsx`, and their focused tests.
- Unknowns/stale docs/conflicts: shared canonical memory predates Marketplace but the exact
coordinator/spec/review records are authoritative; no semantic conflict remains.
Gate result:
- Passed.
## Plan
1. Add red Package Store/lifecycle tests for deferred explicit cleanup and same-Release
channel switching, plus a red My Plugins system-included projection test.
2. Implement the smallest Package Store, awaited worker-disposal, and Renderer changes.
3. Run focused/adjacent tests, typecheck, lint, Vite build, then one full unit run and
pressure case; update documentation gates and create one source commit.
## Outcome
- Explicit device-package removal now records a bounded pending cleanup only when an
active worker protects the Release. The final worker disposal is awaited through the
Main-to-Pi lifecycle and retries the explicit cleanup; another local account snapshot
still protects the immutable package, and an explicit reinstall cancels the pending
removal intent.
- Stable/Beta selection now persists the explicitly requested channel even when resolve
returns `keep` or reuses the same cached immutable Release. It does not redownload or
hot-swap a running worker.
- My Plugins treats `system_included` Data Service as supplied with MakeLore: it exposes
neither download/update/device-delete nor package-failure copy, while retaining the
separate project-enable and Agent-assignment navigation.
- No Server, Release B, provider/hosted/billing, production trust, XMA-01, deployment,
publication, push, or PR state was changed.
## Verification
- TDD red/green boundaries:
- active-worker explicit uninstall initially retained the index after release; green
after deferred cleanup and awaited worker disposal;
- Stable-to-Beta same-Release `keep` and cached reuse initially returned/persisted
Stable; green with one download and requested-channel persistence;
- system-included Data Service initially rendered "尚未下载到设备" and a download
action; green with supplied-with-MakeLore projection and no device action.
- Added protections: another account snapshot still blocks deferred deletion; an
explicit reinstall cancels it; Pi worker stop does not settle before async release
cleanup finishes.
- Focused owned tests: 3 files, 51 passed.
- Adjacent Package Store/Main/Pi/Renderer regression: 10 files, 116 passed.
- Full unit suite: 208 files, 1812 passed, 2 skipped; pressure suite: 1 file, 1 passed.
- `pnpm run typecheck`: passed.
- Scoped ESLint: passed. Full `pnpm run lint:check`: 0 errors and the unchanged 5
out-of-scope warnings in Home/Makelore.
- `pnpm run build:vite`: passed for Renderer, Main, Preload, and utility bundles; only
existing dynamic-import/chunk-size warnings were reported.
- `git diff --check`: passed.
## Follow-ups
- Integrate the sole source commit into the clean client coordinator, then run fresh
fixed-range Standards and Spec reviews before opening XMA-01.
- Official Ed25519 production public key remains an external production-activation hold.
## Promotion Candidates
- None recorded.

View File

@@ -135,7 +135,7 @@ export function createCodingComposition(
clientVersion: options.clientVersion ?? '2.0.0', clientVersion: options.clientVersion ?? '2.0.0',
}); });
const activePluginReleaseCounts = new Map<string, number>(); const activePluginReleaseCounts = new Map<string, number>();
const registerActivePluginReleases = (releaseIds: readonly string[]): (() => void) => { const registerActivePluginReleases = (releaseIds: readonly string[]): (() => Promise<void>) => {
const uniqueReleaseIds = [...new Set(releaseIds)]; const uniqueReleaseIds = [...new Set(releaseIds)];
for (const releaseId of uniqueReleaseIds) { for (const releaseId of uniqueReleaseIds) {
const count = activePluginReleaseCounts.get(releaseId) ?? 0; const count = activePluginReleaseCounts.get(releaseId) ?? 0;
@@ -143,18 +143,20 @@ export function createCodingComposition(
activePluginReleaseCounts.set(releaseId, count + 1); activePluginReleaseCounts.set(releaseId, count + 1);
} }
let released = false; let released = false;
return () => { return async () => {
if (released) return; if (released) return;
released = true; released = true;
const cleanup: Array<Promise<void>> = [];
for (const releaseId of uniqueReleaseIds) { for (const releaseId of uniqueReleaseIds) {
const count = activePluginReleaseCounts.get(releaseId) ?? 0; const count = activePluginReleaseCounts.get(releaseId) ?? 0;
if (count <= 1) { if (count <= 1) {
activePluginReleaseCounts.delete(releaseId); activePluginReleaseCounts.delete(releaseId);
packageStore.releaseActiveWorker(releaseId); cleanup.push(packageStore.releaseActiveWorker(releaseId));
} else { } else {
activePluginReleaseCounts.set(releaseId, count - 1); activePluginReleaseCounts.set(releaseId, count - 1);
} }
} }
await Promise.all(cleanup);
}; };
}; };
let effectiveResolver: EffectivePluginResolver | undefined; let effectiveResolver: EffectivePluginResolver | undefined;

View File

@@ -100,7 +100,7 @@ export interface InstalledReleaseRecord {
readonly sha256: string; readonly sha256: string;
readonly sizeBytes: number; readonly sizeBytes: number;
readonly installedAt: string; 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 channel?: 'stable' | 'beta';
readonly minMakeloreVersion?: string; readonly minMakeloreVersion?: string;
readonly maxMakeloreVersion?: string | null; readonly maxMakeloreVersion?: string | null;
@@ -628,6 +628,7 @@ export class PluginPackageStore {
private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>; private readonly writeIndex: (filePath: string, bytes: Uint8Array) => Promise<void>;
private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null; private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null;
private readonly activeWorkers = new Set<string>(); private readonly activeWorkers = new Set<string>();
private readonly pendingExplicitCleanupPluginIds = new Set<string>();
private readonly unsubscribeSession: (() => void) | null; private readonly unsubscribeSession: (() => void) | null;
private operation: Promise<void> = Promise.resolve(); private operation: Promise<void> = Promise.resolve();
@@ -691,7 +692,11 @@ export class PluginPackageStore {
fail('plugin_beta_selection_required'); fail('plugin_beta_selection_required');
} }
const binding = this.requireBinding(); 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> { async getInstalled(pluginId: string): Promise<InstalledRelease | null> {
@@ -714,7 +719,13 @@ export class PluginPackageStore {
this.assertBinding(binding); this.assertBinding(binding);
this.accountCache.invalidatePlugin(binding, validated); this.accountCache.invalidatePlugin(binding, validated);
this.assertBinding(binding); 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)); this.activeWorkers.add(validReleaseId(releaseId));
} }
releaseActiveWorker(releaseId: string): void { async releaseActiveWorker(releaseId: string): Promise<void> {
this.activeWorkers.delete(validReleaseId(releaseId)); 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[]> { async readInstalledIndex(): Promise<readonly InstalledReleaseRecord[]> {
@@ -788,12 +806,13 @@ export class PluginPackageStore {
if (item.action === 'keep') { if (item.action === 'keep') {
if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release'); if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release');
this.assertBinding(binding); this.assertBinding(binding);
await this.persistSelectedChannel(index, pluginId, current.releaseId, channel, binding);
return { return {
status: 'kept', status: 'kept',
pluginId, pluginId,
releaseId: current.releaseId, releaseId: current.releaseId,
version: current.version, version: current.version,
...(current.channel === undefined ? {} : { channel: current.channel }), channel,
packageRoot: current.packageRoot, packageRoot: current.packageRoot,
definition: current.definition, definition: current.definition,
}; };
@@ -814,6 +833,7 @@ export class PluginPackageStore {
const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId); const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId);
if (existing && !existing.unavailableReason) { if (existing && !existing.unavailableReason) {
this.assertBinding(binding); this.assertBinding(binding);
await this.persistSelectedChannel(index, pluginId, existing.releaseId, channel, binding);
await this.setCurrentSelection( await this.setCurrentSelection(
await this.readCurrentSelection(), await this.readCurrentSelection(),
pluginId, pluginId,
@@ -824,7 +844,7 @@ export class PluginPackageStore {
pluginId, pluginId,
releaseId: existing.releaseId, releaseId: existing.releaseId,
version: existing.version, version: existing.version,
...(existing.channel === undefined ? {} : { channel: existing.channel }), channel,
packageRoot: existing.packageRoot, packageRoot: existing.packageRoot,
definition: existing.definition, 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( private async removeUnusedLocked(
validated: string, validated: string,
binding?: AccountBinding, binding?: AccountBinding,

View File

@@ -142,7 +142,7 @@ export interface PiManagedWorkerOpenerOptions {
userDataDir: string; userDataDir: string;
bundledSkillsDir: string; bundledSkillsDir: string;
getSkillRoots?(): readonly string[] | Promise<readonly string[]>; getSkillRoots?(): readonly string[] | Promise<readonly string[]>;
registerActivePluginReleases?(releaseIds: readonly string[]): () => void; registerActivePluginReleases?(releaseIds: readonly string[]): () => void | Promise<void>;
loadProviderInput(): Promise<PiManagedProviderInput>; loadProviderInput(): Promise<PiManagedProviderInput>;
resolveCredential(account: ProviderAccount): Promise<string | null>; resolveCredential(account: ProviderAccount): Promise<string | null>;
getLocalProxyCredential?(): Promise<string | undefined>; getLocalProxyCredential?(): Promise<string | undefined>;
@@ -335,7 +335,7 @@ export function createPiManagedWorkerOpener(
try { try {
await extension.dispose(); await extension.dispose();
} finally { } finally {
releaseActivePluginReleases?.(); await releaseActivePluginReleases?.();
} }
}; };
let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => { let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => {

View File

@@ -64,7 +64,8 @@ export function MyPluginsView(props: MyPluginsViewProps) {
<section aria-label="账号插件库" className="mt-6 space-y-4"> <section aria-label="账号插件库" className="mt-6 space-y-4">
{props.library?.items.map((plugin) => { {props.library?.items.map((plugin) => {
const installed = props.installations[plugin.pluginId]; const installed = props.installations[plugin.pluginId];
const hasDevicePackage = Boolean(installed?.version); const systemIncluded = plugin.acquisition === 'system_included';
const hasDevicePackage = !systemIncluded && Boolean(installed?.version);
const removed = plugin.removedAt !== null; const removed = plugin.removedAt !== null;
const suspended = plugin.runtimeStatus === 'suspended'; const suspended = plugin.runtimeStatus === 'suspended';
const retired = plugin.catalogStatus === 'retired'; const retired = plugin.catalogStatus === 'retired';
@@ -73,12 +74,13 @@ export function MyPluginsView(props: MyPluginsViewProps) {
const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion); const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion);
const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion); const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion);
const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`)); const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`));
const failure = failureText(installed?.reason); const failure = systemIncluded ? null : failureText(installed?.reason);
return <article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70"> return <article key={plugin.pluginId} className="rounded-2xl bg-background p-5 shadow-soft ring-1 ring-border/70">
<div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{plugin.acquisition === 'system_included' ? <Badge variant="outline"></Badge> : removed ? <Badge variant="outline"></Badge> : <Badge variant="secondary"></Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums"> {plugin.stableVersion ?? '不可用'}</p>{plugin.betaVersion ? <p className="mt-1 tabular-nums text-muted-foreground">Beta {plugin.betaVersion}</p> : null}{hasDevicePackage ? <><p className="mt-1 tabular-nums text-muted-foreground"> {installed?.version}</p><p className="mt-1 text-muted-foreground">{installedChannel === 'beta' ? 'Beta' : '稳定'}</p></> : <p className="mt-1 text-muted-foreground"></p>}</div></div> <div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-2"><h2 className="text-balance text-xl font-semibold">{plugin.title}</h2>{systemIncluded ? <Badge variant="outline"></Badge> : removed ? <Badge variant="outline"></Badge> : <Badge variant="secondary"></Badge>}</div><p className="mt-2 text-pretty text-sm text-muted-foreground">{plugin.summary}</p></div><div className="text-right text-sm"><p className="tabular-nums"> {plugin.stableVersion ?? '不可用'}</p>{plugin.betaVersion ? <p className="mt-1 tabular-nums text-muted-foreground">Beta {plugin.betaVersion}</p> : null}{systemIncluded ? <p className="mt-1 text-muted-foreground"> MakeLore </p> : hasDevicePackage ? <><p className="mt-1 tabular-nums text-muted-foreground"> {installed?.version}</p><p className="mt-1 text-muted-foreground">{installedChannel === 'beta' ? 'Beta' : '稳定'}</p></> : <p className="mt-1 text-muted-foreground"></p>}</div></div>
<div className="mt-4 space-y-1 text-pretty text-sm">{suspended ? <p className="text-destructive"></p> : null}{retired ? <p>{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}</p> : null}{betaChannelUnavailable ? <p> Beta </p> : null}{failure ? <p className="text-destructive"><AlertCircle className="mr-1 inline h-4 w-4" />{failure}</p> : null}</div> <div className="mt-4 space-y-1 text-pretty text-sm">{suspended ? <p className="text-destructive"></p> : null}{retired ? <p>{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}</p> : null}{betaChannelUnavailable ? <p> Beta </p> : null}{failure ? <p className="text-destructive"><AlertCircle className="mr-1 inline h-4 w-4" />{failure}</p> : null}</div>
<div className="mt-5 flex flex-wrap gap-2"> <div className="mt-5 flex flex-wrap gap-2">
{removed ? <Button type="button" className="min-h-10" disabled={busy || retired || suspended} aria-label={`重新免费获取${plugin.title}`} onClick={() => void props.onReacquire(plugin.pluginId)}></Button> {systemIncluded ? null
: removed ? <Button type="button" className="min-h-10" disabled={busy || retired || suspended} aria-label={`重新免费获取${plugin.title}`} onClick={() => void props.onReacquire(plugin.pluginId)}></Button>
: !hasDevicePackage ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`下载${plugin.title}`} onClick={() => void props.onInstall(plugin.pluginId)}><Download className="mr-2 h-4 w-4" /></Button> : !hasDevicePackage ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`下载${plugin.title}`} onClick={() => void props.onInstall(plugin.pluginId)}><Download className="mr-2 h-4 w-4" /></Button>
: updateAvailable ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`${installedChannel === 'beta' ? '更新 Beta' : '更新'}${plugin.title}`} onClick={() => void (installedChannel === 'beta' ? props.onInstallBeta(plugin.pluginId) : props.onUpdate(plugin.pluginId))}><RefreshCw className="mr-2 h-4 w-4" />{installedChannel === 'beta' ? '更新 Beta' : '更新'}</Button> : updateAvailable ? <Button type="button" className="min-h-10" disabled={busy || suspended} aria-label={`${installedChannel === 'beta' ? '更新 Beta' : '更新'}${plugin.title}`} onClick={() => void (installedChannel === 'beta' ? props.onInstallBeta(plugin.pluginId) : props.onUpdate(plugin.pluginId))}><RefreshCw className="mr-2 h-4 w-4" />{installedChannel === 'beta' ? '更新 Beta' : '更新'}</Button>
: <span className="inline-flex min-h-10 items-center px-2 text-sm font-medium"><PackageCheck className="mr-2 h-4 w-4 text-brand" /></span>} : <span className="inline-flex min-h-10 items-center px-2 text-sm font-medium"><PackageCheck className="mr-2 h-4 w-4 text-brand" /></span>}

View File

@@ -796,6 +796,78 @@ describe('PluginPackageStore', () => {
await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1); await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1);
}); });
it('persists explicit Stable and Beta intent when resolve keeps the same Release', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
let action: 'install' | 'keep' = 'install';
const issueDownload = vi.fn(async () => grant);
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
action, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
})),
issueDownload,
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
getAccountBinding: () => ACCOUNT_A,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
action = 'keep';
await expect(store.resolveAndInstall({
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'beta', explicitBeta: true,
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'beta' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'beta' });
await expect(store.resolveAndInstall({
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'stable' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'stable' });
expect(issueDownload).toHaveBeenCalledOnce();
});
it('persists explicit channel intent when resolve reuses a cached immutable Release', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
let action: 'install' | 'update' = 'install';
const issueDownload = vi.fn(async () => grant);
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
action, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
})),
issueDownload,
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
getAccountBinding: () => ACCOUNT_A,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
action = 'update';
await expect(store.resolveAndInstall({
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'beta', explicitBeta: true,
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'beta' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'beta' });
await expect(store.resolveAndInstall({
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'stable' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'stable' });
expect(issueDownload).toHaveBeenCalledOnce();
});
it('removes only old releases and never guesses a new current selection from installedAt', async () => { it('removes only old releases and never guesses a new current selection from installedAt', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive(); const archive = buildSkillOnlyArchive();
@@ -907,7 +979,7 @@ describe('PluginPackageStore', () => {
await expect(store.readInstalledIndex()).resolves.toEqual([]); await expect(store.readInstalledIndex()).resolves.toEqual([]);
}); });
it('makes an active current Release unavailable to new workers before deferred cleanup', async () => { it('finishes an explicit uninstall when the last active worker releases its frozen Release', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive(); const archive = buildSkillOnlyArchive();
const active = signedGrant(archive, { releaseId: 'release-active' }); const active = signedGrant(archive, { releaseId: 'release-active' });
@@ -937,11 +1009,83 @@ describe('PluginPackageStore', () => {
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
await expect(store.readInstalledIndex()).resolves.toHaveLength(1); await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
store.releaseActiveWorker('release-active'); await store.releaseActiveWorker('release-active');
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'removed', pluginId: PLUGIN_ID, reason: 'none',
});
await expect(store.readInstalledIndex()).resolves.toEqual([]); await expect(store.readInstalledIndex()).resolves.toEqual([]);
await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-active'))).rejects.toThrow();
});
it('keeps deferred explicit cleanup blocked by another local account snapshot', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive, { releaseId: 'release-active' });
const accountCache = new AccountPluginCache();
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: grant.releaseId, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
accountCache,
getAccountBinding: () => ACCOUNT_A,
keyStore: new Map([['test-key', publicKey]]),
clientVersion: '1.0.0',
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
accountCache.setResolve(ACCOUNT_B, 'account-b-reference', makeResolveResult({
makeloreVersion: '1.0.0', resolveRequestId: 'account-b-reference',
}, { releaseId: grant.releaseId, sha256: grant.sha256, sizeBytes: grant.sizeBytes }));
store.registerActiveWorker('release-active');
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
});
await store.releaseActiveWorker('release-active');
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
await expect(store.readInstalledIndex()).resolves.toEqual([
expect.objectContaining({ pluginId: PLUGIN_ID, releaseId: 'release-active' }),
]);
await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-active')))
.resolves.toMatchObject({ isDirectory: expect.any(Function) });
});
it('cancels deferred cleanup when the user explicitly installs the same Release again', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive, { releaseId: 'release-active' });
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: grant.releaseId, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
getAccountBinding: () => ACCOUNT_A,
keyStore: new Map([['test-key', publicKey]]),
clientVersion: '1.0.0',
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
store.registerActiveWorker('release-active');
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
status: 'kept', reason: 'active_worker_reference',
});
await expect(store.resolveAndInstall({
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
})).resolves.toMatchObject({ status: 'kept', releaseId: 'release-active' });
await store.releaseActiveWorker('release-active');
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-active' });
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
}); });
it('preserves the old release across download, signature, and extraction failures', async () => { it('preserves the old release across download, signature, and extraction failures', async () => {

View File

@@ -35,6 +35,12 @@ import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
const roots: string[] = []; const roots: string[] = [];
const NOW = '2026-08-22T16:00:00.000Z'; const NOW = '2026-08-22T16:00:00.000Z';
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((yes) => { resolve = yes; });
return { promise, resolve };
}
class OpenerFakeProcess implements PiWorkerProcessAdapter { class OpenerFakeProcess implements PiWorkerProcessAdapter {
readonly generation = 1; readonly generation = 1;
readonly proofResponseDelays: Array<{ commandType: string; delayMs: number }> = []; readonly proofResponseDelays: Array<{ commandType: string; delayMs: number }> = [];
@@ -151,9 +157,13 @@ describe('managed Pi worker opener', () => {
effectiveSnapshot, effectiveSnapshot,
})), })),
} as unknown as CodingCapabilityRegistry; } as unknown as CodingCapabilityRegistry;
const firstReleaseCleanup = deferred<void>();
const releaseCleanups: Array<ReturnType<typeof vi.fn>> = []; const releaseCleanups: Array<ReturnType<typeof vi.fn>> = [];
const registerActivePluginReleases = vi.fn((_releaseIds: readonly string[]) => { const registerActivePluginReleases = vi.fn((_releaseIds: readonly string[]) => {
const cleanup = vi.fn(); const waitForFirst = releaseCleanups.length === 0;
const cleanup = vi.fn(async () => {
if (waitForFirst) await firstReleaseCleanup.promise;
});
releaseCleanups.push(cleanup); releaseCleanups.push(cleanup);
return cleanup; return cleanup;
}); });
@@ -252,7 +262,15 @@ describe('managed Pi worker opener', () => {
expect(processes[0]?.proofResponseDelays).toEqual([{ commandType: 'prompt', delayMs: 12_000 }]); expect(processes[0]?.proofResponseDelays).toEqual([{ commandType: 'prompt', delayMs: 12_000 }]);
expect(registerActivePluginReleases).toHaveBeenCalledTimes(3); expect(registerActivePluginReleases).toHaveBeenCalledTimes(3);
expect(registerActivePluginReleases).toHaveBeenNthCalledWith(1, ['plugin-release-a']); expect(registerActivePluginReleases).toHaveBeenNthCalledWith(1, ['plugin-release-a']);
await first.worker.stop('test_injection'); let firstStopSettled = false;
const firstStop = first.worker.stop('test_injection').then((result) => {
firstStopSettled = true;
return result;
});
await vi.waitFor(() => expect(releaseCleanups[0]).toHaveBeenCalledOnce());
expect(firstStopSettled).toBe(false);
firstReleaseCleanup.resolve();
await firstStop;
await reopened.worker.stop('test_injection'); await reopened.worker.stop('test_injection');
await restarted.worker.stop('test_injection'); await restarted.worker.stop('test_injection');
expect(releaseCleanups).toHaveLength(3); expect(releaseCleanups).toHaveLength(3);

View File

@@ -112,6 +112,23 @@ describe('My Plugins', () => {
expect(screen.getByText('稳定版 2.0.0')).toBeVisible(); expect(screen.getByText('稳定版 2.0.0')).toBeVisible();
}); });
it('shows system-included plugins as supplied with MakeLore without device package actions', () => {
const systemIncluded = library.items[3]!;
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [systemIncluded] }} installations={{}} state="ready"
pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
/></MemoryRouter>);
expect(screen.getByText('随 MakeLore 提供')).toBeVisible();
expect(screen.queryByText('尚未下载到设备')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '下载开发数据服务' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '更新开发数据服务' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '删除设备上的开发数据服务' })).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: '启用到项目' })).toBeVisible();
expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible();
});
it('updates an installed Beta package only through the explicit Beta action', () => { it('updates an installed Beta package only through the explicit Beta action', () => {
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const onInstallBeta = vi.fn(); const onInstallBeta = vi.fn();