From c56ddb9d3b14990f5dcd4f12eaa58d925e569292 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 30 Aug 2026 09:19:39 +0800 Subject: [PATCH] fix(marketplace): close release lifecycle gaps --- ...rketplace-mlm06-r6-remediation-3c8e7a52.md | 130 +++++++++++++++ electron/api/coding-composition.ts | 8 +- electron/coding-plugins/package-store.ts | 59 ++++++- electron/coding-runtime/pi/runtime.ts | 4 +- src/pages/MyPlugins/index.tsx | 10 +- .../coding-plugin-marketplace-client.test.ts | 154 +++++++++++++++++- tests/unit/pi-managed-worker-opener.test.ts | 22 ++- tests/unit/plugin-marketplace-pages.test.tsx | 17 ++ 8 files changed, 382 insertions(+), 22 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260830-marketplace-mlm06-r6-remediation-3c8e7a52.md diff --git a/.project-docs/30-worklog/tasks/20260830-marketplace-mlm06-r6-remediation-3c8e7a52.md b/.project-docs/30-worklog/tasks/20260830-marketplace-mlm06-r6-remediation-3c8e7a52.md new file mode 100644 index 0000000..0e08a1e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260830-marketplace-mlm06-r6-remediation-3c8e7a52.md @@ -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. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index a6d57ed..762c9b3 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -135,7 +135,7 @@ export function createCodingComposition( clientVersion: options.clientVersion ?? '2.0.0', }); const activePluginReleaseCounts = new Map(); - const registerActivePluginReleases = (releaseIds: readonly string[]): (() => void) => { + const registerActivePluginReleases = (releaseIds: readonly string[]): (() => Promise) => { const uniqueReleaseIds = [...new Set(releaseIds)]; for (const releaseId of uniqueReleaseIds) { const count = activePluginReleaseCounts.get(releaseId) ?? 0; @@ -143,18 +143,20 @@ export function createCodingComposition( activePluginReleaseCounts.set(releaseId, count + 1); } let released = false; - return () => { + return async () => { if (released) return; released = true; + const cleanup: Array> = []; for (const releaseId of uniqueReleaseIds) { const count = activePluginReleaseCounts.get(releaseId) ?? 0; if (count <= 1) { activePluginReleaseCounts.delete(releaseId); - packageStore.releaseActiveWorker(releaseId); + cleanup.push(packageStore.releaseActiveWorker(releaseId)); } else { activePluginReleaseCounts.set(releaseId, count - 1); } } + await Promise.all(cleanup); }; }; let effectiveResolver: EffectivePluginResolver | undefined; diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts index c365573..c00e2c9 100644 --- a/electron/coding-plugins/package-store.ts +++ b/electron/coding-plugins/package-store.ts @@ -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; private readonly activeWorkerReleaseIdsImpl: (() => readonly string[]) | null; private readonly activeWorkers = new Set(); + private readonly pendingExplicitCleanupPluginIds = new Set(); private readonly unsubscribeSession: (() => void) | null; private operation: Promise = 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 { @@ -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 { 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 { @@ -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 { + 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, diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 4fa417d..ec413a7 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -142,7 +142,7 @@ export interface PiManagedWorkerOpenerOptions { userDataDir: string; bundledSkillsDir: string; getSkillRoots?(): readonly string[] | Promise; - registerActivePluginReleases?(releaseIds: readonly string[]): () => void; + registerActivePluginReleases?(releaseIds: readonly string[]): () => void | Promise; loadProviderInput(): Promise; resolveCredential(account: ProviderAccount): Promise; getLocalProxyCredential?(): Promise; @@ -335,7 +335,7 @@ export function createPiManagedWorkerOpener( try { await extension.dispose(); } finally { - releaseActivePluginReleases?.(); + await releaseActivePluginReleases?.(); } }; let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => { diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx index 486d601..34f35af 100644 --- a/src/pages/MyPlugins/index.tsx +++ b/src/pages/MyPlugins/index.tsx @@ -64,7 +64,8 @@ export function MyPluginsView(props: MyPluginsViewProps) {
{props.library?.items.map((plugin) => { 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 suspended = plugin.runtimeStatus === 'suspended'; const retired = plugin.catalogStatus === 'retired'; @@ -73,12 +74,13 @@ export function MyPluginsView(props: MyPluginsViewProps) { const updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion); const betaChannelUnavailable = Boolean(hasDevicePackage && installedChannel === 'beta' && !plugin.betaVersion); 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
-

{plugin.title}

{plugin.acquisition === 'system_included' ? 系统内置 : removed ? 已移除 : 已获取}

{plugin.summary}

稳定版 {plugin.stableVersion ?? '不可用'}

{plugin.betaVersion ?

Beta 版 {plugin.betaVersion}

: null}{hasDevicePackage ? <>

设备版本 {installed?.version}

当前频道:{installedChannel === 'beta' ? 'Beta' : '稳定'}

:

尚未下载到设备

}
+

{plugin.title}

{systemIncluded ? 系统内置 : removed ? 已移除 : 已获取}

{plugin.summary}

稳定版 {plugin.stableVersion ?? '不可用'}

{plugin.betaVersion ?

Beta 版 {plugin.betaVersion}

: null}{systemIncluded ?

随 MakeLore 提供

: hasDevicePackage ? <>

设备版本 {installed?.version}

当前频道:{installedChannel === 'beta' ? 'Beta' : '稳定'}

:

尚未下载到设备

}
{suspended ?

运行已暂停

: null}{retired ?

{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}

: null}{betaChannelUnavailable ?

当前 Beta 频道暂无可用版本;不会静默切回稳定版。

: null}{failure ?

{failure}

: null}
- {removed ? + {systemIncluded ? null + : removed ? : !hasDevicePackage ? : updateAvailable ? : 设备版本已是最新} diff --git a/tests/unit/coding-plugin-marketplace-client.test.ts b/tests/unit/coding-plugin-marketplace-client.test.ts index 4050956..e506290 100644 --- a/tests/unit/coding-plugin-marketplace-client.test.ts +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -796,6 +796,78 @@ describe('PluginPackageStore', () => { 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 () => { temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); const archive = buildSkillOnlyArchive(); @@ -907,7 +979,7 @@ describe('PluginPackageStore', () => { 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-')); const archive = buildSkillOnlyArchive(); const active = signedGrant(archive, { releaseId: 'release-active' }); @@ -937,11 +1009,83 @@ describe('PluginPackageStore', () => { await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); await expect(store.readInstalledIndex()).resolves.toHaveLength(1); - store.releaseActiveWorker('release-active'); - await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({ - status: 'removed', pluginId: PLUGIN_ID, reason: 'none', - }); + await store.releaseActiveWorker('release-active'); 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 () => { diff --git a/tests/unit/pi-managed-worker-opener.test.ts b/tests/unit/pi-managed-worker-opener.test.ts index 242c48b..75cfe53 100644 --- a/tests/unit/pi-managed-worker-opener.test.ts +++ b/tests/unit/pi-managed-worker-opener.test.ts @@ -35,6 +35,12 @@ import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; const roots: string[] = []; const NOW = '2026-08-22T16:00:00.000Z'; +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} + class OpenerFakeProcess implements PiWorkerProcessAdapter { readonly generation = 1; readonly proofResponseDelays: Array<{ commandType: string; delayMs: number }> = []; @@ -151,9 +157,13 @@ describe('managed Pi worker opener', () => { effectiveSnapshot, })), } as unknown as CodingCapabilityRegistry; + const firstReleaseCleanup = deferred(); const releaseCleanups: Array> = []; 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); return cleanup; }); @@ -252,7 +262,15 @@ describe('managed Pi worker opener', () => { expect(processes[0]?.proofResponseDelays).toEqual([{ commandType: 'prompt', delayMs: 12_000 }]); expect(registerActivePluginReleases).toHaveBeenCalledTimes(3); 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 restarted.worker.stop('test_injection'); expect(releaseCleanups).toHaveLength(3); diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index ed50dbb..02313d3 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -112,6 +112,23 @@ describe('My Plugins', () => { 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(); + + 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', () => { const onUpdate = vi.fn(); const onInstallBeta = vi.fn();