diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c6b8c13d1c9..2efb1319918 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -13526,7 +13526,7 @@ describe('sessionLanguage multi-session propagation', () => { it('refreshes extension state without a duplicate direct skill refresh', async () => { const extensionManager = { - refreshCache: vi.fn().mockResolvedValue(undefined), + refreshCacheWithSnapshot: vi.fn().mockResolvedValue({ generation: 7 }), refreshTools: vi.fn().mockResolvedValue(undefined), }; const skillManager = { @@ -13565,7 +13565,7 @@ describe('sessionLanguage multi-session propagation', () => { ); const agentPromise = runAcpAgent( - makeConfig() as unknown as Config, + cfg as unknown as Config, { merged: { mcpServers: {} } } as unknown as LoadedSettings, mockArgv, ); @@ -13578,12 +13578,13 @@ describe('sessionLanguage multi-session propagation', () => { await agent.newSession({ cwd: '/ext', mcpServers: [] }); await expect( - agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceExtensionsRefresh, { - sessionId: 's-ext', - }), - ).resolves.toEqual({ ok: true }); + agent.extMethod( + SERVE_CONTROL_EXT_METHODS.workspaceRuntimeExtensionsRefresh, + {}, + ), + ).resolves.toEqual({ ok: true, refreshed: 1, generation: 7 }); - expect(extensionManager.refreshCache).toHaveBeenCalledOnce(); + expect(extensionManager.refreshCacheWithSnapshot).toHaveBeenCalledOnce(); expect(skillManager.refreshCache).not.toHaveBeenCalled(); expect(extensionManager.refreshTools).toHaveBeenCalledOnce(); expect(refreshHierarchicalMemory).not.toHaveBeenCalled(); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 25ee6fc2f67..f0ced9aeac1 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -8450,6 +8450,64 @@ class QwenAgent implements Agent { } return { ok: true }; } + case SERVE_CONTROL_EXT_METHODS.workspaceRuntimeExtensionsRefresh: { + const sessions = Array.from(this.sessions.values()); + const configs = new Set( + [ + this.config, + ...(this.workspaceMcpDiscoveryConfig + ? [this.workspaceMcpDiscoveryConfig] + : []), + ...sessions.map((session) => session.getConfig()), + ].filter( + (config) => typeof config.getExtensionManager === 'function', + ), + ); + const errors: unknown[] = []; + let generation: number | undefined; + const runRefresh = async (refresh: () => Promise) => { + try { + await refresh(); + } catch (error) { + errors.push(error); + } + }; + for (const config of configs) { + const extensionManager = config.getExtensionManager(); + await runRefresh(async () => { + const snapshot = await extensionManager.refreshCacheWithSnapshot(); + if (config === this.config) { + generation = snapshot.generation; + } + }); + await runRefresh(async () => await extensionManager.refreshTools()); + await runRefresh( + async () => + await config.getGeminiClient()?.refreshSystemInstruction(), + ); + } + for (const session of sessions) { + await runRefresh( + async () => await session.sendAvailableCommandsUpdate(), + ); + } + if (errors.length > 0) { + const details = errors + .map((error) => + error instanceof Error ? error.message : String(error), + ) + .join('; '); + throw new AggregateError( + errors, + `Extension runtime refresh failed: ${details}`, + ); + } + return { + ok: true, + refreshed: configs.size, + ...(generation === undefined ? {} : { generation }), + }; + } case 'deleteSession': { const sessionId = params['sessionId'] as string; if (!sessionId || !SESSION_ID_RE.test(sessionId)) { diff --git a/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts index 0a852fdcdbb..a89b8556c2e 100644 --- a/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.test.ts @@ -140,9 +140,10 @@ describe('createExtensionsController', () => { vi.useFakeTimers(); vi.setSystemTime(0); const refreshCache = vi - .spyOn(ExtensionManager.prototype, 'refreshCache') + .spyOn(ExtensionManager.prototype, 'refreshCacheWithSnapshot') .mockImplementation(async () => { vi.setSystemTime(3_000); + return { extensions: {} } as never; }); vi.spyOn(ExtensionManager.prototype, 'getLoadedExtensions').mockReturnValue( [], @@ -159,6 +160,53 @@ describe('createExtensionsController', () => { expect(refreshCache).toHaveBeenCalledOnce(); }); + it('reports user and workspace activation from the existing store rules', async () => { + vi.spyOn( + ExtensionManager.prototype, + 'refreshCacheWithSnapshot', + ).mockResolvedValue({ extensions: {} } as never); + vi.spyOn(ExtensionManager.prototype, 'getLoadedExtensions').mockReturnValue( + [ + { + id: 'extension-id', + name: 'demo', + version: '1.0.0', + isActive: false, + path: '/extensions/demo', + config: {}, + contextFiles: [], + } as never, + ], + ); + vi.spyOn( + ExtensionManager.prototype, + 'getExtensionActivationFromSnapshot', + ).mockImplementation((_id, _snapshot, path) => ({ + default: 'enabled', + workspace: 'inherit', + effective: path === '/work/bound' ? 'disabled' : 'enabled', + source: 'legacy_path_rule', + })); + const controller = createExtensionsController({ + boundWorkspace: '/work/bound', + bridge: {} as AcpSessionBridge, + workspace: {} as DaemonWorkspaceService, + }); + + await expect( + controller.buildLocalExtensionsStatus(), + ).resolves.toMatchObject({ + extensions: [ + { + name: 'demo', + isActive: false, + defaultActivation: 'enabled', + workspaceActivation: 'disabled', + }, + ], + }); + }); + it('reports an accepted operation as running while its cache refreshes', async () => { let finishRefresh!: () => void; const refreshPending = new Promise((resolve) => { @@ -383,7 +431,15 @@ describe('createExtensionsController', () => { { manager, deadlineMs: 100 }, ); await vi.advanceTimersByTimeAsync(0); - const operationId = responseBody.mock.calls[0]?.[0].operationId as string; + const accepted = responseBody.mock.calls[0]?.[0] as { + operationId: string; + deadlineAt: number; + }; + const operationId = accepted.operationId; + expect(accepted.deadlineAt).toBe(Date.now() + 100); + expect(controller.getOperation(operationId)?.deadlineAt).toBe( + accepted.deadlineAt, + ); let probeStarted = false; const probe = controller.preparationQueue.run(async () => { probeStarted = true; diff --git a/packages/cli/src/serve/routes/workspace-extensions-controller.ts b/packages/cli/src/serve/routes/workspace-extensions-controller.ts index f0d62126591..fd65b9644b4 100644 --- a/packages/cli/src/serve/routes/workspace-extensions-controller.ts +++ b/packages/cli/src/serve/routes/workspace-extensions-controller.ts @@ -26,6 +26,11 @@ import { } from '@qwen-code/acp-bridge/status'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; import type { WorkspaceRuntime } from '../workspace-registry.js'; +import { + getWorkspaceRuntimeCoordinator, + isWorkspaceRuntimeDrainingError, + type ExtensionsReconciliationAttempt, +} from '../workspace-runtime-coordinator.js'; import { createFifoTaskQueue, type FifoTaskQueue, @@ -119,12 +124,14 @@ export type ExtensionOperationStatus = { phase?: 'preparing' | 'committing' | 'reconciling'; createdAt: number; updatedAt: number; + deadlineAt?: number; source?: string; name?: string; result?: ExtensionMutationEvent & { refreshed?: number; failed?: number; error?: string; + activation?: 'applied' | 'deferred' | 'partial'; }; interaction?: ExtensionPendingInteraction; error?: string; @@ -161,10 +168,29 @@ export interface CreateExtensionsControllerDeps { boundWorkspace: string; bridge: AcpSessionBridge; workspace: DaemonWorkspaceService; + acquireManagementOperation?: () => () => void; + isWorkspaceTrusted?: boolean; maxExtensionOperationHistory?: number; + coordination?: ExtensionsControllerCoordination; +} + +export interface ExtensionsControllerCoordination { + preparationQueue: FifoTaskQueue; + commitQueue: FifoTaskQueue; + extensionOperations: Map; + operationAdmission: { unfinishedCount: number }; +} + +export function createExtensionsControllerCoordination(): ExtensionsControllerCoordination { + return { + preparationQueue: createFifoTaskQueue(EXTENSION_PREPARATION_CONCURRENCY), + commitQueue: createFifoTaskQueue(1), + extensionOperations: new Map(), + operationAdmission: { unfinishedCount: 0 }, + }; } -/** Shared coordinator for the legacy adapter and V2 global operations. */ +/** Owner-scoped controller with injectable scheduler and admission state. */ export interface ExtensionsController { readonly boundWorkspace: string; readonly workspace: DaemonWorkspaceService; @@ -178,6 +204,10 @@ export interface ExtensionsController { refreshed: number; failed: number; }>; + refreshWorkspaceExtensions(): Promise<{ + refreshed: number; + failed: number; + }>; getOperation(operationId: string): ExtensionOperationStatus | undefined; getActiveOperations(): ExtensionOperationStatus[]; updateOperation( @@ -207,6 +237,7 @@ export interface ExtensionsController { options?: { manager?: ExtensionManager; createManager?: (operationId: string) => ExtensionManager; + acquireManagementOperation?: () => () => void; onSettled?: (operationId: string) => void; refreshRuntimes?: | readonly WorkspaceRuntime[] @@ -214,11 +245,24 @@ export interface ExtensionsController { reserveRuntimeReconciliation?: ReserveRuntimeReconciliation; operationBasePath?: string; skipRefresh?: boolean; + refreshWorkspaceRuntime?: boolean; deadlineMs?: number; onRuntimeReconciled?: ( runtime: WorkspaceRuntime, generation: number, + attempt: ExtensionsReconciliationAttempt | undefined, ) => void; + onRuntimeReconciliationStarted?: ( + runtime: WorkspaceRuntime, + generation: number, + ) => ExtensionsReconciliationAttempt | undefined; + onRuntimeReconciliationFailed?: ( + runtime: WorkspaceRuntime, + generation: number, + attempt: ExtensionsReconciliationAttempt | undefined, + error: unknown, + ) => void; + onGenerationCommitted?: (generation: number) => void; }, ): void; } @@ -229,26 +273,27 @@ export function createExtensionsController( const { boundWorkspace, bridge, workspace } = deps; const maxExtensionOperationHistory = deps.maxExtensionOperationHistory ?? 100; - const preparationQueue = createFifoTaskQueue( - EXTENSION_PREPARATION_CONCURRENCY, - ); - const commitQueue = createFifoTaskQueue(1); - let unfinishedOperationCount = 0; + const coordination = + deps.coordination ?? createExtensionsControllerCoordination(); + const { preparationQueue, commitQueue, extensionOperations } = coordination; const acquireOperationSlot = (res: Response): (() => void) | undefined => { - if (unfinishedOperationCount >= MAX_UNFINISHED_EXTENSION_OPERATIONS) { + if ( + coordination.operationAdmission.unfinishedCount >= + MAX_UNFINISHED_EXTENSION_OPERATIONS + ) { res.status(429).json({ error: EXTENSION_QUEUE_FULL_MESSAGE, code: 'extension_queue_full', }); return undefined; } - unfinishedOperationCount += 1; + coordination.operationAdmission.unfinishedCount += 1; let released = false; return () => { if (released) return; released = true; - unfinishedOperationCount -= 1; + coordination.operationAdmission.unfinishedCount -= 1; }; }; @@ -261,6 +306,7 @@ export function createExtensionsController( workspaceDir, isWorkspaceTrusted: trustedOverride ?? + deps.isWorkspaceTrusted ?? getWorkspaceTrustStatus(loadSettings(workspaceDir).merged, workspaceDir) .effective.state === 'trusted', requestConsent: () => Promise.resolve(), @@ -305,7 +351,6 @@ export function createExtensionsController( return true; }; - const extensionOperations = new Map(); const isTerminalExtensionOperation = ( operation: ExtensionOperationStatus, ): boolean => @@ -367,20 +412,26 @@ export function createExtensionsController( | { expiresAt: number; value: ServeWorkspaceExtensionsStatus } | undefined; - const refreshExtensionsForAllSessions = async (): Promise<{ + const refreshExtensions = async ( + run: () => Promise<{ refreshed: number; failed: number }>, + ): Promise<{ refreshed: number; failed: number; }> => { + const releaseManagementOperation = + deps.acquireManagementOperation?.() ?? (() => undefined); const queueAbort = new AbortController(); let releaseCommitLane: (() => void) | undefined; - const refresh = commitQueue.runUntilReleased( - async (release) => { - releaseCommitLane = release; - extensionsStatusCache = undefined; - return await workspace.refreshExtensionsForAllSessions(); - }, - { signal: queueAbort.signal }, - ); + const refresh = commitQueue + .runUntilReleased( + async (release) => { + releaseCommitLane = release; + extensionsStatusCache = undefined; + return await run(); + }, + { signal: queueAbort.signal }, + ) + .finally(releaseManagementOperation); let timer: ReturnType | undefined; try { return await Promise.race([ @@ -401,6 +452,16 @@ export function createExtensionsController( if (timer) clearTimeout(timer); } }; + const refreshExtensionsForAllSessions = () => + refreshExtensions( + async () => await workspace.refreshExtensionsForAllSessions(), + ); + const refreshWorkspaceExtensions = () => + refreshExtensions(async () => + bridge.refreshWorkspaceExtensions + ? await bridge.refreshWorkspaceExtensions() + : await bridge.refreshExtensionsForAllSessions(), + ); const runQueuedExtensionMutation = ( operation: string, @@ -415,6 +476,7 @@ export function createExtensionsController( options: { manager?: ExtensionManager; createManager?: (operationId: string) => ExtensionManager; + acquireManagementOperation?: () => () => void; onSettled?: (operationId: string) => void; refreshRuntimes?: | readonly WorkspaceRuntime[] @@ -422,17 +484,42 @@ export function createExtensionsController( reserveRuntimeReconciliation?: ReserveRuntimeReconciliation; operationBasePath?: string; skipRefresh?: boolean; + refreshWorkspaceRuntime?: boolean; deadlineMs?: number; onRuntimeReconciled?: ( runtime: WorkspaceRuntime, generation: number, + attempt: ExtensionsReconciliationAttempt | undefined, ) => void; + onRuntimeReconciliationStarted?: ( + runtime: WorkspaceRuntime, + generation: number, + ) => ExtensionsReconciliationAttempt | undefined; + onRuntimeReconciliationFailed?: ( + runtime: WorkspaceRuntime, + generation: number, + attempt: ExtensionsReconciliationAttempt | undefined, + error: unknown, + ) => void; + onGenerationCommitted?: (generation: number) => void; } = {}, ): void => { const releaseOperationSlot = acquireOperationSlot(res); if (!releaseOperationSlot) return; + let releaseManagementOperation: (() => void) | undefined; + try { + releaseManagementOperation = + options.acquireManagementOperation?.() ?? + deps.acquireManagementOperation?.(); + } catch (error) { + releaseOperationSlot(); + throw error; + } const operationId = crypto.randomUUID(); const now = Date.now(); + const deadlineAt = options.deadlineMs + ? now + options.deadlineMs + : undefined; rememberExtensionOperation({ v: 1, operationId, @@ -440,6 +527,7 @@ export function createExtensionsController( status: 'queued', createdAt: now, updatedAt: now, + ...(deadlineAt === undefined ? {} : { deadlineAt }), ...(failureContext.source ? { source: redactExtensionDisplaySource(failureContext.source) } : {}), @@ -452,9 +540,14 @@ export function createExtensionsController( .status(202) .location(`${operationBasePath}/${operationId}`) .set('Retry-After', '1') - .json({ accepted: true, operationId }); + .json({ + accepted: true, + operationId, + ...(deadlineAt === undefined ? {} : { deadlineAt }), + }); } catch { extensionOperations.delete(operationId); + releaseManagementOperation?.(); releaseOperationSlot(); return; } @@ -484,21 +577,22 @@ export function createExtensionsController( options.createManager?.(operationId) ?? createExtensionManager(); const deadlineController = new AbortController(); - let deadlineStarted = false; - const startDeadline = () => { - if (deadlineStarted) return; - deadlineStarted = true; - if (options.deadlineMs) { - deadline = setTimeout(() => { - const error = new Error( - `Extension ${operation} exceeded its ${options.deadlineMs}ms preparation deadline.`, - ) as Error & { code: string }; - error.code = 'extension_prepare_timeout'; - deadlineController.abort(error); - }, options.deadlineMs); + const abortForDeadline = () => { + const error = new Error( + `Extension ${operation} exceeded its ${options.deadlineMs}ms operation deadline.`, + ) as Error & { code: string }; + error.code = 'extension_prepare_timeout'; + deadlineController.abort(error); + }; + if (deadlineAt !== undefined) { + const remainingMs = deadlineAt - Date.now(); + if (remainingMs <= 0) { + abortForDeadline(); + } else { + deadline = setTimeout(abortForDeadline, remainingMs); deadline.unref?.(); } - }; + } let pendingPreparations = 0; let activePreparations = 0; const updatePreparationState = () => { @@ -534,7 +628,6 @@ export function createExtensionsController( { signal: deadlineController.signal, onStart: () => { - startDeadline(); started = true; pendingPreparations -= 1; activePreparations += 1; @@ -573,6 +666,7 @@ export function createExtensionsController( reconciliationReservation ??= options.reserveRuntimeReconciliation?.(); committedGeneration = generation; + options.onGenerationCommitted?.(generation); release(); }), ); @@ -580,6 +674,7 @@ export function createExtensionsController( reconciliationReservation ??= options.reserveRuntimeReconciliation?.(); committedGeneration = result.generation; + options.onGenerationCommitted?.(result.generation); } for (const warning of result.warnings ?? []) { commitWarnings.push({ @@ -599,7 +694,6 @@ export function createExtensionsController( operationId, ); mutationEvent = event; - if (deadline) clearTimeout(deadline); extensionsStatusCache = undefined; if (options.skipRefresh || event.updated === false) { reconciliationReservation?.release(); @@ -619,6 +713,7 @@ export function createExtensionsController( committedGeneration = ( await extensionManager.getExtensionStoreSnapshot() ).generation; + options.onGenerationCommitted?.(committedGeneration); reconciliationReservation ??= options.reserveRuntimeReconciliation?.(); } @@ -631,53 +726,189 @@ export function createExtensionsController( ? options.refreshRuntimes() : options.refreshRuntimes; if (refreshTargets) { + const generation = committedGeneration; const results = await runReconciliation( async () => await Promise.all( refreshTargets.map(async (runtime) => { const startedAt = Date.now(); + let attempt: ExtensionsReconciliationAttempt | undefined; try { - runtime.workspaceService.invalidateWorkspaceSkillsStatus(); - return { - status: 'fulfilled' as const, - result: - await runtime.bridge.refreshExtensionsForAllSessions( - bridgeMutationEvent(event), - ), - elapsedMs: Date.now() - startedAt, - }; + return await getWorkspaceRuntimeCoordinator( + runtime, + ).runExtensionsPhysicalReconciliation(async () => { + attempt = options.onRuntimeReconciliationStarted?.( + runtime, + generation, + ); + if (options.onRuntimeReconciliationStarted && !attempt) { + return { + status: 'superseded' as const, + attempt, + elapsedMs: Date.now() - startedAt, + }; + } + try { + runtime.workspaceService.invalidateWorkspaceSkillsStatus(); + const refresh = options.refreshWorkspaceRuntime + ? (runtime.bridge.refreshWorkspaceExtensions?.bind( + runtime.bridge, + ) ?? + runtime.bridge.refreshExtensionsForAllSessions.bind( + runtime.bridge, + )) + : runtime.bridge.refreshExtensionsForAllSessions.bind( + runtime.bridge, + ); + return { + status: 'fulfilled' as const, + result: await refresh(bridgeMutationEvent(event)), + attempt, + elapsedMs: Date.now() - startedAt, + }; + } catch (reason) { + return { + status: 'rejected' as const, + reason, + attempt, + elapsedMs: Date.now() - startedAt, + }; + } + }); } catch (reason) { - return { - status: 'rejected' as const, - reason, - elapsedMs: Date.now() - startedAt, - }; + return isWorkspaceRuntimeDrainingError(reason) + ? { + status: 'superseded' as const, + attempt, + elapsedMs: Date.now() - startedAt, + } + : { + status: 'rejected' as const, + reason, + attempt, + elapsedMs: Date.now() - startedAt, + }; } }), ), ); let refreshed = 0; let failed = 0; + let superseded = 0; const warnings: NonNullable = [ ...commitWarnings, ]; for (let index = 0; index < results.length; index += 1) { const settled = results[index]!; const runtime = refreshTargets[index]!; - if (settled.status === 'fulfilled') { - refreshed += settled.result.refreshed; - failed += settled.result.failed; - if (settled.result.failed > 0) { + if (settled.status === 'superseded') { + superseded += 1; + } else if (settled.status === 'fulfilled') { + const runtimeGeneration = + 'generation' in settled.result && + typeof settled.result.generation === 'number' + ? settled.result.generation + : undefined; + const runtimeEpoch = + 'runtimeEpoch' in settled.result && + typeof settled.result.runtimeEpoch === 'number' + ? settled.result.runtimeEpoch + : undefined; + if ( + options.refreshWorkspaceRuntime && + settled.attempt && + runtimeEpoch !== settled.attempt.runtimeEpoch + ) { + failed += 1; + const error = new Error( + `Workspace runtime epoch changed during extension reconciliation (expected ${settled.attempt.runtimeEpoch}, received ${runtimeEpoch ?? 'none'}).`, + ); + options.onRuntimeReconciliationFailed?.( + runtime, + generation, + settled.attempt, + error, + ); + warnings.push({ + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + code: 'runtime_epoch_changed', + error: error.message, + }); + } else if (settled.result.failed > 0) { + refreshed += settled.result.refreshed; + failed += settled.result.failed; + const error = new Error( + `${settled.result.failed} extension runtime refresh(es) failed`, + ); + options.onRuntimeReconciliationFailed?.( + runtime, + generation, + settled.attempt, + error, + ); warnings.push({ workspaceId: runtime.workspaceId, workspaceCwd: runtime.workspaceCwd, error: `${settled.result.failed} session refresh(es) failed`, }); + } else if ( + options.refreshWorkspaceRuntime && + runtimeGeneration === undefined + ) { + failed += 1; + const error = new Error( + 'Runtime refresh succeeded without an applied extension generation.', + ); + options.onRuntimeReconciliationFailed?.( + runtime, + generation, + settled.attempt, + error, + ); + warnings.push({ + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + code: 'runtime_generation_missing', + error: + 'Runtime refresh succeeded without an applied extension generation.', + }); + } else if ( + runtimeGeneration !== undefined && + runtimeGeneration < generation + ) { + failed += 1; + const error = new Error( + `Runtime applied extension generation ${runtimeGeneration}, expected at least ${generation}.`, + ); + options.onRuntimeReconciliationFailed?.( + runtime, + generation, + settled.attempt, + error, + ); + warnings.push({ + workspaceId: runtime.workspaceId, + workspaceCwd: runtime.workspaceCwd, + code: 'runtime_generation_stale', + error: `Runtime applied extension generation ${runtimeGeneration}, expected at least ${generation}.`, + }); } else { - options.onRuntimeReconciled?.(runtime, committedGeneration); + refreshed += settled.result.refreshed; + options.onRuntimeReconciled?.( + runtime, + runtimeGeneration ?? generation, + settled.attempt, + ); } } else { failed += 1; + options.onRuntimeReconciliationFailed?.( + runtime, + generation, + settled.attempt, + settled.reason, + ); const message = sanitizeDaemonMessage( settled.reason instanceof Error ? settled.reason.message @@ -718,6 +949,13 @@ export function createExtensionsController( ...redactExtensionOperationResult(event), refreshed, failed, + activation: + refreshTargets.length === 0 || + superseded === refreshTargets.length + ? 'deferred' + : failed > 0 || superseded > 0 + ? 'partial' + : 'applied', }, ...(warnings.length > 0 ? { warnings } : {}), }); @@ -726,9 +964,11 @@ export function createExtensionsController( const { result, elapsedMs } = await runReconciliation(async () => { workspace.invalidateWorkspaceSkillsStatus(); const startedAt = Date.now(); - const result = await bridge.refreshExtensionsForAllSessions( - bridgeMutationEvent(event), - ); + const refresh = options.refreshWorkspaceRuntime + ? (bridge.refreshWorkspaceExtensions?.bind(bridge) ?? + bridge.refreshExtensionsForAllSessions.bind(bridge)) + : bridge.refreshExtensionsForAllSessions.bind(bridge); + const result = await refresh(bridgeMutationEvent(event)); return { result, elapsedMs: Date.now() - startedAt }; }); const warnings: NonNullable = @@ -754,6 +994,7 @@ export function createExtensionsController( ...redactExtensionOperationResult(event), refreshed: result.refreshed, failed: result.failed, + activation: result.failed > 0 ? 'partial' : 'applied', }, ...(warnings.length > 0 ? { warnings } : {}), }); @@ -919,8 +1160,12 @@ export function createExtensionsController( } finally { if (deadline) clearTimeout(deadline); reconciliationReservation?.release(); - options.onSettled?.(operationId); - releaseOperationSlot(); + try { + options.onSettled?.(operationId); + } finally { + releaseManagementOperation?.(); + releaseOperationSlot(); + } } })(); }; @@ -932,10 +1177,16 @@ export function createExtensionsController( return extensionsStatusCache.value; } const extensionManager = createExtensionManager(); - await extensionManager.refreshCache(); + const snapshot = await extensionManager.refreshCacheWithSnapshot(); const entries: ServeExtensionEntry[] = extensionManager .getLoadedExtensions() .map((ext): ServeExtensionEntry => { + const activation = + extensionManager.getExtensionActivationFromSnapshot( + ext.id, + snapshot, + boundWorkspace, + ); const capabilities: ServeExtensionCapabilities = { mcpServerCount: ext.mcpServers ? Object.keys(ext.mcpServers).length @@ -963,6 +1214,12 @@ export function createExtensionsController( : {}), version: ext.version, isActive: ext.isActive, + defaultActivation: activation.default, + workspaceActivation: + activation.workspace === 'inherit' && + activation.source === 'legacy_path_rule' + ? activation.effective + : activation.workspace, path: ext.path, ...(ext.installMetadata?.source ? { @@ -1015,6 +1272,7 @@ export function createExtensionsController( createExtensionManager, buildLocalExtensionsStatus, refreshExtensionsForAllSessions, + refreshWorkspaceExtensions, getOperation: (operationId) => extensionOperations.get(operationId), getActiveOperations: () => [...extensionOperations.values()].filter( diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 35dbd0eab03..ba9e251e49c 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -30,9 +30,14 @@ import type { WorkspaceRegistry, WorkspaceRuntime, } from '../workspace-registry.js'; +import { + getWorkspaceRuntimeCoordinator, + type ExtensionsReconciliationAttempt, +} from '../workspace-runtime-coordinator.js'; import type { DaemonWorkspaceService } from '../workspace-service/index.js'; import { createExtensionsController, + createExtensionsControllerCoordination, redactExtensionDisplaySource, type ExtensionPendingInteraction, type ExtensionOperationContext, @@ -198,6 +203,13 @@ export function registerWorkspaceExtensionRoutes( workspaceRegistry, } = deps; const maxExtensionOperationHistory = deps.maxExtensionOperationHistory; + const controllerCoordination = createExtensionsControllerCoordination(); + const isolatedOperationCoordination = () => ({ + preparationQueue: controllerCoordination.preparationQueue, + commitQueue: controllerCoordination.commitQueue, + extensionOperations: new Map(), + operationAdmission: controllerCoordination.operationAdmission, + }); const controllerDeps = ( ws: string, wsBridge: AcpSessionBridge, @@ -206,14 +218,43 @@ export function registerWorkspaceExtensionRoutes( boundWorkspace: ws, bridge: wsBridge, workspace: wsService, + coordination: controllerCoordination, ...(maxExtensionOperationHistory === undefined ? {} : { maxExtensionOperationHistory }), }); - const primaryController = createExtensionsController( - controllerDeps(boundWorkspace, bridge, workspace), - ); + const globalController = createExtensionsController({ + ...controllerDeps(boundWorkspace, bridge, workspace), + isWorkspaceTrusted: true, + }); + const legacyPrimaryController = createExtensionsController({ + ...controllerDeps(boundWorkspace, bridge, workspace), + coordination: isolatedOperationCoordination(), + }); + const controllersByRuntime = new WeakMap< + WorkspaceRuntime, + ExtensionsController + >(); + const controllerForRuntime = ( + runtime: WorkspaceRuntime, + ): ExtensionsController => { + let controller = controllersByRuntime.get(runtime); + if (!controller) { + controller = createExtensionsController({ + ...controllerDeps( + runtime.workspaceCwd, + runtime.bridge, + runtime.workspaceService, + ), + coordination: isolatedOperationCoordination(), + acquireManagementOperation: () => + getWorkspaceRuntimeCoordinator(runtime).acquireManagementOperation(), + }); + controllersByRuntime.set(runtime, controller); + } + return controller; + }; type ExtensionInteractionRequest = | Omit< Extract, @@ -371,29 +412,98 @@ export function registerWorkspaceExtensionRoutes( }, }; }; - const appliedGenerationByWorkspaceId = new Map(); const onRuntimeReconciled = ( runtime: WorkspaceRuntime, generation: number, + attempt: ExtensionsReconciliationAttempt | undefined, ): void => { - appliedGenerationByWorkspaceId.set(runtime.workspaceId, generation); + getWorkspaceRuntimeCoordinator(runtime).setExtensionsAppliedGeneration( + generation, + attempt, + ); }; - const globalReconciliationOptions = () => + const onRuntimeReconciliationStarted = ( + runtime: WorkspaceRuntime, + generation: number, + ): ExtensionsReconciliationAttempt | undefined => + getWorkspaceRuntimeCoordinator(runtime).beginExtensionsReconciliation( + generation, + ); + const onRuntimeReconciliationFailed = ( + runtime: WorkspaceRuntime, + generation: number, + attempt: ExtensionsReconciliationAttempt | undefined, + error: unknown, + ): void => { + getWorkspaceRuntimeCoordinator(runtime).failExtensionsReconciliation( + attempt, + error, + ); + }; + const markDesiredGeneration = ( + runtimes: readonly WorkspaceRuntime[], + generation: number, + ): void => { + for (const runtime of runtimes) { + getWorkspaceRuntimeCoordinator(runtime).setExtensionsDesiredGeneration( + generation, + ); + } + }; + const acquireRuntimeManagementOperations = ( + runtimes: readonly WorkspaceRuntime[], + ): (() => void) => { + const releases: Array<() => void> = []; + try { + for (const runtime of runtimes) { + releases.push( + getWorkspaceRuntimeCoordinator(runtime).acquireManagementOperation(), + ); + } + } catch (error) { + for (const release of releases) release(); + throw error; + } + return () => { + for (const release of releases) release(); + }; + }; + const globalReconciliationOptions = (refreshWorkspaceRuntime: boolean) => workspaceRegistry ? { - refreshRuntimes: () => workspaceRegistry.list(), + acquireManagementOperation: () => + acquireRuntimeManagementOperations(workspaceRegistry.list()), + refreshRuntimes: () => + workspaceRegistry + .list() + .filter((runtime) => runtime.bridge.isChannelLive()), reserveRuntimeReconciliation, onRuntimeReconciled, + onRuntimeReconciliationStarted, + onRuntimeReconciliationFailed, + onGenerationCommitted: (generation: number) => + markDesiredGeneration(workspaceRegistry.listManaged(), generation), + refreshWorkspaceRuntime, } - : {}; - const workspaceReconciliationOptions = () => - workspaceRegistry + : { refreshWorkspaceRuntime }; + const workspaceReconciliationOptions = ( + refreshWorkspaceRuntime: boolean, + workspaceCwd: string, + ) => { + const runtime = workspaceRegistry?.getByWorkspaceCwd(workspaceCwd); + return runtime ? { - refreshRuntimes: [workspaceRegistry.primary], + refreshRuntimes: runtime.bridge.isChannelLive() ? [runtime] : [], reserveRuntimeReconciliation, onRuntimeReconciled, + onRuntimeReconciliationStarted, + onRuntimeReconciliationFailed, + onGenerationCommitted: (generation: number) => + markDesiredGeneration([runtime], generation), + refreshWorkspaceRuntime, } - : {}; + : { refreshWorkspaceRuntime }; + }; const mutationClientBridges = ( runtimes?: | readonly WorkspaceRuntime[] @@ -411,45 +521,106 @@ export function registerWorkspaceExtensionRoutes( if (reconciling) return; reconciling = true; try { - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( boundWorkspace, true, ); const generation = (await manager.getExtensionStoreSnapshot()) .generation; - const pendingRuntimes = workspaceRegistry - .list() - .filter( - (runtime) => - (appliedGenerationByWorkspaceId.get(runtime.workspaceId) ?? 0) !== - generation, + markDesiredGeneration(workspaceRegistry.listManaged(), generation); + const pendingRuntimes = workspaceRegistry.list().filter((runtime) => { + if (!runtime.bridge.isChannelLive()) return false; + const capability = + getWorkspaceRuntimeCoordinator(runtime).status().capabilities + .extensions; + return ( + capability?.state !== 'ready' || + capability.appliedGeneration !== generation ); + }); if (generation === observedGeneration && pendingRuntimes.length === 0) return; const runtimes = pendingRuntimes; if (runtimes.length === 0) return; + const attempts = new Map< + WorkspaceRuntime, + ExtensionsReconciliationAttempt | undefined + >(); const results = await runtimeReconciliationQueue.run( async () => await Promise.allSettled( runtimes.map(async (runtime) => { - runtime.workspaceService.invalidateWorkspaceSkillsStatus(); - const result = - await runtime.bridge.refreshExtensionsForAllSessions(); + attempts.set( + runtime, + onRuntimeReconciliationStarted(runtime, generation), + ); + if (!attempts.get(runtime)) { + throw new Error( + 'Workspace runtime changed before extension reconciliation started', + ); + } + const result = await getWorkspaceRuntimeCoordinator( + runtime, + ).runExtensionsPhysicalReconciliation(async () => { + runtime.workspaceService.invalidateWorkspaceSkillsStatus(); + const refresh = + runtime.bridge.refreshWorkspaceExtensions?.bind( + runtime.bridge, + ) ?? + runtime.bridge.refreshExtensionsForAllSessions.bind( + runtime.bridge, + ); + return await refresh(); + }); + const attempt = attempts.get(runtime); + const resultEpoch = + 'runtimeEpoch' in result && + typeof result.runtimeEpoch === 'number' + ? result.runtimeEpoch + : undefined; + if (!attempt || resultEpoch !== attempt.runtimeEpoch) { + throw new Error( + `Workspace runtime epoch changed during extension reconciliation (expected ${attempt?.runtimeEpoch ?? 'none'}, received ${resultEpoch ?? 'none'})`, + ); + } if (result.failed > 0) { throw new Error( `${result.failed} extension session refresh(es) failed`, ); } + const runtimeGeneration = + 'generation' in result && + typeof result.generation === 'number' + ? result.generation + : undefined; + if (runtimeGeneration === undefined) { + throw new Error( + 'Runtime refresh succeeded without an applied extension generation', + ); + } + if (runtimeGeneration < generation) { + throw new Error( + `Runtime applied extension generation ${runtimeGeneration}, expected at least ${generation}`, + ); + } + return runtimeGeneration; }), ), ); results.forEach((result, index) => { + const runtime = runtimes[index]!; + const attempt = attempts.get(runtime); if (result.status === 'fulfilled') { - const workspaceId = runtimes[index]!.workspaceId; - appliedGenerationByWorkspaceId.set(workspaceId, generation); + onRuntimeReconciled(runtime, result.value, attempt); } else { + onRuntimeReconciliationFailed( + runtime, + generation, + attempt, + result.reason, + ); writeStderrLine( - `qwen serve: extension generation reconciliation failed for workspace ${runtimes[index]!.workspaceId}: ${redactUrlCredentials( + `qwen serve: extension generation reconciliation failed for workspace ${runtime.workspaceId}: ${redactUrlCredentials( result.reason instanceof Error ? result.reason.message : String(result.reason), @@ -483,7 +654,39 @@ export function registerWorkspaceExtensionRoutes( ).stopExtensionGenerationReconciler = () => clearInterval(generationPoller); } - const registerFor = (base: string, resolve: ResolveController): void => { + const registerFor = ( + base: string, + resolve: ResolveController, + refreshWorkspaceRuntime: boolean, + configOwner: 'legacy-primary' | 'global' | 'workspace', + ): void => { + const requireGlobalConfigOwner = (res: Response): boolean => { + if (configOwner !== 'workspace') return true; + res.status(400).json({ + error: + 'Global extension configuration must use /workspace/config/extensions', + code: 'global_scope_requires_singular_owner', + }); + return false; + }; + const rejectWrongActivationOwner = ( + scope: SettingScope, + res: Response, + ): boolean => { + if (configOwner === 'workspace' && scope === SettingScope.User) { + requireGlobalConfigOwner(res); + return true; + } + if (configOwner === 'global' && scope === SettingScope.Workspace) { + res.status(400).json({ + error: + 'Workspace extension configuration must use a qualified workspace route', + code: 'workspace_scope_requires_qualified_workspace', + }); + return true; + } + return false; + }; // GET {base} — read-only installed extension status. app.get(base, async (req, res) => { const ctrl = resolve(req, res, false); @@ -554,7 +757,7 @@ export function registerWorkspaceExtensionRoutes( `${base}/operations/:operationId/interactions/:interactionId`, mutate({ strict: true }), async (req, res) => { - const ctrl = resolve(req, res, false); + const ctrl = resolve(req, res, true); if (!ctrl) return; try { if ( @@ -566,10 +769,13 @@ export function registerWorkspaceExtensionRoutes( } const operationId = req.params['operationId']; const interactionId = req.params['interactionId']; + const operation = operationId + ? ctrl.getOperation(operationId) + : undefined; const pending = operationId ? pendingExtensionInteractions.get(operationId) : undefined; - if (!operationId || !interactionId || !pending) { + if (!operationId || !interactionId || !operation || !pending) { res.status(404).json({ error: 'Extension interaction not found' }); return; } @@ -637,6 +843,7 @@ export function registerWorkspaceExtensionRoutes( // POST {base}/install — install an extension and refresh all active // sessions asynchronously. app.post(`${base}/install`, mutate({ strict: true }), async (req, res) => { + if (!requireGlobalConfigOwner(res)) return; const ctrl = resolve(req, res, true); if (!ctrl) return; try { @@ -845,7 +1052,7 @@ export function registerWorkspaceExtensionRoutes( ); }, deadlineMs: EXTENSION_INTERACTIVE_PREPARE_DEADLINE_MS, - ...globalReconciliationOptions(), + ...globalReconciliationOptions(refreshWorkspaceRuntime), }, ); } catch (err) { @@ -857,6 +1064,7 @@ export function registerWorkspaceExtensionRoutes( `${base}/check-updates`, mutate({ strict: true }), async (req, res) => { + if (!requireGlobalConfigOwner(res)) return; const ctrl = resolve(req, res, true); if (!ctrl) return; let timer: ReturnType | undefined; @@ -924,29 +1132,36 @@ export function registerWorkspaceExtensionRoutes( }, ); - app.post(`${base}/refresh`, mutate({ strict: true }), async (req, res) => { - const ctrl = resolve(req, res, true); - if (!ctrl) return; - try { - if ( - !ctrl.validateExtensionMutationClient(req, res, { - requireClientId: false, - }) - ) { - return; - } - const releaseOperationSlot = ctrl.acquireOperationSlot(res); - if (!releaseOperationSlot) return; - try { - const result = await ctrl.refreshExtensionsForAllSessions(); - res.status(200).json(result); - } finally { - releaseOperationSlot(); - } - } catch (err) { - sendBridgeError(res, err, { route: `POST ${base}/refresh` }); - } - }); + if (!refreshWorkspaceRuntime) { + app.post( + `${base}/refresh`, + mutate({ strict: true }), + async (req, res) => { + const ctrl = resolve(req, res, true); + if (!ctrl) return; + try { + if ( + !ctrl.validateExtensionMutationClient(req, res, { + requireClientId: false, + }) + ) { + return; + } + const releaseOperationSlot = ctrl.acquireOperationSlot(res); + if (!releaseOperationSlot) return; + try { + res + .status(200) + .json(await ctrl.refreshExtensionsForAllSessions()); + } finally { + releaseOperationSlot(); + } + } catch (err) { + sendBridgeError(res, err, { route: `POST ${base}/refresh` }); + } + }, + ); + } app.post( `${base}/:name/enable`, @@ -969,6 +1184,7 @@ export function registerWorkspaceExtensionRoutes( } const scope = parseExtensionScope(safeBody(req), res); if (scope === null) return; + if (rejectWrongActivationOwner(scope, res)) return; ctrl.runQueuedExtensionMutation( 'enable', { name }, @@ -991,8 +1207,11 @@ export function registerWorkspaceExtensionRoutes( }, { ...(scope === SettingScope.User - ? globalReconciliationOptions() - : workspaceReconciliationOptions()), + ? globalReconciliationOptions(refreshWorkspaceRuntime) + : workspaceReconciliationOptions( + refreshWorkspaceRuntime, + ctrl.boundWorkspace, + )), }, ); } catch (err) { @@ -1022,6 +1241,7 @@ export function registerWorkspaceExtensionRoutes( } const scope = parseExtensionScope(safeBody(req), res); if (scope === null) return; + if (rejectWrongActivationOwner(scope, res)) return; ctrl.runQueuedExtensionMutation( 'disable', { name }, @@ -1044,8 +1264,11 @@ export function registerWorkspaceExtensionRoutes( }, { ...(scope === SettingScope.User - ? globalReconciliationOptions() - : workspaceReconciliationOptions()), + ? globalReconciliationOptions(refreshWorkspaceRuntime) + : workspaceReconciliationOptions( + refreshWorkspaceRuntime, + ctrl.boundWorkspace, + )), }, ); } catch (err) { @@ -1058,6 +1281,7 @@ export function registerWorkspaceExtensionRoutes( `${base}/:name/update`, mutate({ strict: true }), async (req, res) => { + if (!requireGlobalConfigOwner(res)) return; const ctrl = resolve(req, res, true); if (!ctrl) return; try { @@ -1145,7 +1369,7 @@ export function registerWorkspaceExtensionRoutes( ); }, deadlineMs: EXTENSION_INTERACTIVE_PREPARE_DEADLINE_MS, - ...globalReconciliationOptions(), + ...globalReconciliationOptions(refreshWorkspaceRuntime), }, ); } catch (err) { @@ -1155,6 +1379,7 @@ export function registerWorkspaceExtensionRoutes( ); app.delete(`${base}/:name`, mutate({ strict: true }), async (req, res) => { + if (!requireGlobalConfigOwner(res)) return; const ctrl = resolve(req, res, true); if (!ctrl) return; try { @@ -1191,7 +1416,7 @@ export function registerWorkspaceExtensionRoutes( return { status: 'uninstalled', name: extension.name }; }, { - ...globalReconciliationOptions(), + ...globalReconciliationOptions(refreshWorkspaceRuntime), }, ); } catch (err) { @@ -1201,7 +1426,39 @@ export function registerWorkspaceExtensionRoutes( }; // Legacy singular routes bound to the primary workspace (behavior unchanged). - registerFor('/workspace/extensions', () => primaryController); + registerFor( + '/workspace/extensions', + () => legacyPrimaryController, + false, + 'legacy-primary', + ); + registerFor( + '/workspace/config/extensions', + () => globalController, + true, + 'global', + ); + if (workspaceRegistry) { + registerFor( + '/workspaces/:workspace/config/extensions', + (req, res, requireTrust) => { + const runtime = resolveWorkspaceRuntimeFromParam( + workspaceRegistry, + req, + res, + ); + if ( + !runtime || + (requireTrust && !requireTrustedWorkspaceRuntime(runtime, res)) + ) { + return null; + } + return controllerForRuntime(runtime); + }, + true, + 'workspace', + ); + } const extensionById = ( manager: ExtensionManager, @@ -1273,15 +1530,19 @@ export function registerWorkspaceExtensionRoutes( deadlineMs?: number; } = {}, ): void => { + const affectedRuntimes = (): readonly WorkspaceRuntime[] => + typeof options.refreshRuntimes === 'function' + ? options.refreshRuntimes() + : (options.refreshRuntimes ?? workspaceRegistry?.listManaged() ?? []); if ( - !primaryController.validateExtensionMutationClient(req, res, { + !globalController.validateExtensionMutationClient(req, res, { requireClientId: false, - bridges: mutationClientBridges(options.refreshRuntimes), + bridges: mutationClientBridges(affectedRuntimes), }) ) { return; } - primaryController.runQueuedExtensionMutation( + globalController.runQueuedExtensionMutation( operation, failureContext, res, @@ -1289,16 +1550,43 @@ export function registerWorkspaceExtensionRoutes( { manager, operationBasePath: '/extensions/operations', + acquireManagementOperation: () => + acquireRuntimeManagementOperations( + affectedRuntimes().filter( + (runtime) => + !workspaceRegistry || + workspaceRegistry.getByWorkspaceCwd(runtime.workspaceCwd) === + runtime, + ), + ), onRuntimeReconciled, + onRuntimeReconciliationStarted, + onRuntimeReconciliationFailed, reserveRuntimeReconciliation, - ...options, + refreshRuntimes: () => + affectedRuntimes().filter( + (runtime) => + runtime.bridge.isChannelLive() && + (!workspaceRegistry || + workspaceRegistry.getByWorkspaceCwd(runtime.workspaceCwd) === + runtime), + ), + onGenerationCommitted: (generation) => + markDesiredGeneration(affectedRuntimes(), generation), + refreshWorkspaceRuntime: true, + ...(options.skipRefresh !== undefined + ? { skipRefresh: options.skipRefresh } + : {}), + ...(options.deadlineMs !== undefined + ? { deadlineMs: options.deadlineMs } + : {}), }, ); }; app.get('/extensions', async (_req, res) => { try { - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( boundWorkspace, true, ); @@ -1333,7 +1621,7 @@ export function registerWorkspaceExtensionRoutes( res.status(400).json({ error: 'Missing extension operation id' }); return; } - const operation = primaryController.getOperation(operationId); + const operation = globalController.getOperation(operationId); if (!operation) { res.status(404).json({ error: `Extension operation "${operationId}" not found`, @@ -1352,7 +1640,7 @@ export function registerWorkspaceExtensionRoutes( if (!extensionId) return; const state = parseActivationState(req, res); if (!state) return; - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( boundWorkspace, true, ); @@ -1382,7 +1670,7 @@ export function registerWorkspaceExtensionRoutes( }, { ...(workspaceRegistry - ? { refreshRuntimes: () => workspaceRegistry.list() } + ? { refreshRuntimes: () => workspaceRegistry.listManaged() } : {}), }, ); @@ -1467,7 +1755,7 @@ export function registerWorkspaceExtensionRoutes( res.status(400).json({ error: 'Invalid initial activation' }); return; } - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( boundWorkspace, true, ); @@ -1542,7 +1830,7 @@ export function registerWorkspaceExtensionRoutes( { deadlineMs: EXTENSION_PREPARE_DEADLINE_MS, ...(workspaceRegistry - ? { refreshRuntimes: () => workspaceRegistry.list() } + ? { refreshRuntimes: () => workspaceRegistry.listManaged() } : {}), }, ); @@ -1552,7 +1840,7 @@ export function registerWorkspaceExtensionRoutes( '/extensions/check-updates', mutate({ strict: true }), (req, res) => { - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( boundWorkspace, true, ); @@ -1588,7 +1876,7 @@ export function registerWorkspaceExtensionRoutes( (req, res) => { const extensionId = parseExtensionId(req, res); if (!extensionId) return; - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( boundWorkspace, true, ); @@ -1651,7 +1939,7 @@ export function registerWorkspaceExtensionRoutes( { deadlineMs: EXTENSION_PREPARE_DEADLINE_MS, ...(workspaceRegistry - ? { refreshRuntimes: () => workspaceRegistry.list() } + ? { refreshRuntimes: () => workspaceRegistry.listManaged() } : {}), }, ); @@ -1666,7 +1954,7 @@ export function registerWorkspaceExtensionRoutes( if (!extensionId) return; const route = 'DELETE /extensions/:extensionId'; if ( - !primaryController.validateExtensionMutationClient(req, res, { + !globalController.validateExtensionMutationClient(req, res, { requireClientId: false, bridges: mutationClientBridges(), }) @@ -1674,7 +1962,7 @@ export function registerWorkspaceExtensionRoutes( return; } try { - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( boundWorkspace, true, ); @@ -1705,7 +1993,7 @@ export function registerWorkspaceExtensionRoutes( }, { ...(workspaceRegistry - ? { refreshRuntimes: () => workspaceRegistry.list() } + ? { refreshRuntimes: () => workspaceRegistry.listManaged() } : {}), }, ); @@ -1721,7 +2009,7 @@ export function registerWorkspaceExtensionRoutes( const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); if (!runtime) return; try { - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( runtime.workspaceCwd, runtime.trusted, ); @@ -1739,7 +2027,6 @@ export function registerWorkspaceExtensionRoutes( defaultActivation: activation.default, workspaceActivation: activation.workspace === 'inherit' ? null : activation.workspace, - effectiveActivation: activation.effective, activationSource: activation.source, }; }); @@ -1750,7 +2037,8 @@ export function registerWorkspaceExtensionRoutes( trusted: runtime.trusted, desiredGeneration: snapshot.generation, appliedGeneration: - appliedGenerationByWorkspaceId.get(runtime.workspaceId) ?? 0, + getWorkspaceRuntimeCoordinator(runtime).status().capabilities + .extensions?.appliedGeneration ?? 0, extensions, }); } catch (error) { @@ -1770,7 +2058,7 @@ export function registerWorkspaceExtensionRoutes( if (!extensionId) return; const state = parseActivationState(req, res); if (!state) return; - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( runtime.workspaceCwd, true, ); @@ -1813,7 +2101,7 @@ export function registerWorkspaceExtensionRoutes( if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; const extensionId = parseExtensionId(req, res); if (!extensionId) return; - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( runtime.workspaceCwd, true, ); @@ -1856,7 +2144,7 @@ export function registerWorkspaceExtensionRoutes( (req, res) => { const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); if (!runtime || !requireTrustedWorkspaceRuntime(runtime, res)) return; - const manager = primaryController.createExtensionManager( + const manager = globalController.createExtensionManager( runtime.workspaceCwd, true, ); diff --git a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts index 1f6776625fd..560467ca1ee 100644 --- a/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts +++ b/packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts @@ -26,6 +26,7 @@ import { createWorkspaceRegistry, type WorkspaceRuntime, } from '../workspace-registry.js'; +import { getWorkspaceRuntimeCoordinator } from '../workspace-runtime-coordinator.js'; import type { AcpSessionBridge } from '../acp-session-bridge.js'; import type { DaemonWorkspaceService } from '../workspace-service/types.js'; @@ -45,11 +46,19 @@ function makeBridge(): AcpSessionBridge { return { permissionPolicy: 'first-responder', knownClientIds: () => new Set(['client-1']), + isChannelLive: vi.fn(() => true), + getRuntimeEpoch: vi.fn(() => 1), publishWorkspaceEvent: vi.fn(), refreshExtensionsForAllSessions: vi.fn(async () => ({ refreshed: 1, failed: 0, })), + refreshWorkspaceExtensions: vi.fn(async () => ({ + refreshed: 1, + failed: 0, + generation: 7, + runtimeEpoch: 1, + })), broadcastExtensionsChanged: vi.fn(), getDaemonStatusSnapshot: vi.fn(() => ({ limits: { @@ -57,7 +66,7 @@ function makeBridge(): AcpSessionBridge { maxPendingPromptsPerSession: 5, eventRingSize: 8000, compactedReplayMaxBytes: 4 * 1024 * 1024, - channelIdleTimeoutMs: 0, + channelIdleTimeoutMs: null, sessionIdleTimeoutMs: 1_800_000, }, sessionCount: 0, @@ -109,6 +118,7 @@ function makeRuntime( } async function makeHarness(opts?: { + primaryTrusted?: boolean; secondaryTrusted?: boolean; singleWorkspace?: boolean; }) { @@ -123,7 +133,7 @@ async function makeHarness(opts?: { const canonicalSecondary = canonicalizeWorkspace(secondaryCwd); const primary = makeRuntime(canonicalPrimary, { primary: true, - trusted: true, + trusted: opts?.primaryTrusted ?? true, workspaceId: 'primary-id', }); const secondary = makeRuntime(canonicalSecondary, { @@ -256,6 +266,292 @@ async function pollOperation( } describe('extension management v2 REST', () => { + it('keeps runtime refresh out of config routes', async () => { + const h = await makeHarness({ singleWorkspace: true }); + + const legacy = await auth( + request(h.app).post('/workspace/extensions/refresh'), + ); + expect(legacy.status).toBe(200); + expect( + h.primary.workspaceService.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + expect(h.primary.bridge.refreshWorkspaceExtensions).not.toHaveBeenCalled(); + + const workspaceConfig = await auth( + request(h.app).post('/workspace/config/extensions/refresh'), + ); + expect(workspaceConfig.status).toBe(404); + expect(h.primary.bridge.refreshWorkspaceExtensions).not.toHaveBeenCalled(); + expect( + h.primary.workspaceService.refreshExtensionsForAllSessions, + ).toHaveBeenCalledOnce(); + + const qualifiedConfig = await auth( + request(h.app).post('/workspaces/primary-id/config/extensions/refresh'), + ); + expect(qualifiedConfig.status).toBe(404); + expect(h.primary.bridge.refreshWorkspaceExtensions).not.toHaveBeenCalled(); + }); + + it('does not start the selected runtime through a config route', async () => { + const h = await makeHarness(); + try { + const response = await auth( + request(h.app).post( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/config/extensions/refresh`, + ), + ); + + expect(response.status).toBe(404); + expect( + h.secondary.bridge.refreshWorkspaceExtensions, + ).not.toHaveBeenCalled(); + expect( + h.primary.bridge.refreshWorkspaceExtensions, + ).not.toHaveBeenCalled(); + expect( + h.primary.workspaceService.refreshExtensionsForAllSessions, + ).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('enforces extension activation config owners in daemon routes', async () => { + const h = await makeHarness(); + mockExtensionManager(); + const enableExtension = vi + .spyOn(ExtensionManager.prototype, 'enableExtension') + .mockResolvedValue({ generation: 7 } as never); + try { + const singularWorkspace = await auth( + request(h.app) + .post('/workspace/config/extensions/demo/enable') + .send({ scope: 'workspace' }), + ); + expect(singularWorkspace.status).toBe(400); + expect(singularWorkspace.body).toMatchObject({ + code: 'workspace_scope_requires_qualified_workspace', + }); + + const qualifiedUser = await auth( + request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/config/extensions/demo/enable`, + ) + .send({ scope: 'user' }), + ); + expect(qualifiedUser.status).toBe(400); + expect(qualifiedUser.body).toMatchObject({ + code: 'global_scope_requires_singular_owner', + }); + expect(enableExtension).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('keeps global config independent of primary trust and only gates qualified mutations', async () => { + const h = await makeHarness({ + primaryTrusted: false, + secondaryTrusted: false, + }); + mockExtensionManager(); + let globalManagerTrusted: boolean | undefined; + vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionInstall', + ).mockImplementation(async function (this: ExtensionManager) { + globalManagerTrusted = ( + this as unknown as { isWorkspaceTrusted: boolean } + ).isWorkspaceTrusted; + return {} as never; + }); + vi.spyOn( + ExtensionManager.prototype, + 'commitPreparedExtension', + ).mockResolvedValue({ + identity: { id: extensionId, name: 'demo' }, + version: '1.0.0', + generation: 7, + } as never); + vi.spyOn( + ExtensionManager.prototype, + 'disposePreparedExtension', + ).mockResolvedValue(); + const enableExtension = vi + .spyOn(ExtensionManager.prototype, 'enableExtension') + .mockResolvedValue({ generation: 7 } as never); + try { + const globalInventory = await auth( + request(h.app).get('/workspace/config/extensions'), + ); + expect(globalInventory.status).toBe(200); + + const globalMutation = await auth( + request(h.app) + .post('/workspace/config/extensions/install') + .send({ source: '@scope/demo', consent: true }), + ); + expect(globalMutation.status).toBe(202); + await pollOperation( + h.app, + globalMutation.body.operationId, + '/workspace/config/extensions/operations', + ); + expect(globalManagerTrusted).toBe(true); + + const qualifiedBase = `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/config/extensions`; + const qualifiedInventory = await auth(request(h.app).get(qualifiedBase)); + expect(qualifiedInventory.status).toBe(200); + + const qualifiedOperations = await auth( + request(h.app).get(`${qualifiedBase}/operations`), + ); + expect(qualifiedOperations.status).toBe(200); + + const qualifiedOperation = await auth( + request(h.app).get(`${qualifiedBase}/operations/missing`), + ); + expect(qualifiedOperation.status).toBe(404); + expect(qualifiedOperation.body).toMatchObject({ + code: 'extension_operation_not_found', + }); + + const qualifiedMutation = await auth( + request(h.app) + .post(`${qualifiedBase}/demo/enable`) + .send({ scope: 'workspace' }), + ); + expect(qualifiedMutation.status).toBe(403); + expect(qualifiedMutation.body).toMatchObject({ + code: 'untrusted_workspace', + }); + expect(enableExtension).not.toHaveBeenCalled(); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('isolates legacy-primary and global config operations', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn(ExtensionManager.prototype, 'enableExtension').mockResolvedValue({ + generation: 7, + } as never); + try { + const legacy = await auth( + request(h.app) + .post('/workspace/extensions/demo/enable') + .send({ scope: 'workspace' }), + ); + expect(legacy.status).toBe(202); + const globalMiss = await auth( + request(h.app).get( + `/workspace/config/extensions/operations/${legacy.body.operationId}`, + ), + ); + expect(globalMiss.status).toBe(404); + await pollOperation( + h.app, + legacy.body.operationId, + '/workspace/extensions/operations', + ); + + const global = await auth( + request(h.app) + .post('/workspace/config/extensions/demo/enable') + .send({ scope: 'user' }), + ); + expect(global.status).toBe(202); + const legacyMiss = await auth( + request(h.app).get( + `/workspace/extensions/operations/${global.body.operationId}`, + ), + ); + expect(legacyMiss.status).toBe(404); + await pollOperation( + h.app, + global.body.operationId, + '/workspace/config/extensions/operations', + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('shares unfinished operation admission across config owners', async () => { + const h = await makeHarness(); + mockExtensionManager(); + let releaseEnable = () => {}; + const enableGate = new Promise((resolve) => { + releaseEnable = resolve; + }); + vi.spyOn(ExtensionManager.prototype, 'enableExtension').mockImplementation( + async () => { + await enableGate; + return { generation: 7 } as never; + }, + ); + const qualifiedBase = `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/config/extensions`; + try { + const globalRequests = Array.from({ length: 5 }, () => + auth( + request(h.app) + .post('/workspace/config/extensions/demo/enable') + .send({ scope: 'user' }), + ), + ); + const qualifiedRequests = Array.from({ length: 5 }, () => + auth( + request(h.app) + .post(`${qualifiedBase}/demo/enable`) + .send({ scope: 'workspace' }), + ), + ); + const [globalOperations, qualifiedOperations] = await Promise.all([ + Promise.all(globalRequests), + Promise.all(qualifiedRequests), + ]); + + expect( + [...globalOperations, ...qualifiedOperations].every( + (response) => response.status === 202, + ), + ).toBe(true); + + const rejected = await auth( + request(h.app) + .post('/workspace/extensions/demo/enable') + .send({ scope: 'workspace' }), + ); + expect(rejected.status).toBe(429); + expect(rejected.body).toMatchObject({ code: 'extension_queue_full' }); + + releaseEnable(); + await Promise.all([ + ...globalOperations.map((operation) => + pollOperation( + h.app, + operation.body.operationId, + '/workspace/config/extensions/operations', + ), + ), + ...qualifiedOperations.map((operation) => + pollOperation( + h.app, + operation.body.operationId, + `${qualifiedBase}/operations`, + ), + ), + ]); + } finally { + releaseEnable(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + afterEach(() => { for (const app of activeApps) { ( @@ -368,7 +664,6 @@ describe('extension management v2 REST', () => { extensionId, defaultActivation: 'disabled', workspaceActivation: null, - effectiveActivation: 'disabled', activationSource: 'default', }, ], @@ -414,16 +709,60 @@ describe('extension management v2 REST', () => { expect.any(Function), ); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); expect( - h.primary.bridge.refreshExtensionsForAllSessions, + h.primary.bridge.refreshWorkspaceExtensions, ).not.toHaveBeenCalled(); } finally { await fsp.rm(h.scratch, { recursive: true, force: true }); } }); + it('keeps a workspace management lease until an extension operation settles', async () => { + const h = await makeHarness(); + mockExtensionManager(); + let finishCommit!: () => void; + vi.mocked( + ExtensionManager.prototype.setExtensionWorkspaceActivation, + ).mockImplementationOnce( + async (_extensionId, _workspaceCwd, _state, onCommitted) => + await new Promise((resolve) => { + finishCommit = () => { + onCommitted?.(7); + resolve({ + version: 2, + generation: 7, + legacyProjectionHash: 'hash', + extensions: {}, + }); + }; + }), + ); + try { + const started = await auth( + request(h.app) + .put( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions/${extensionId}/activation`, + ) + .send({ state: 'enabled' }), + ); + + expect(started.status).toBe(202); + expect(getWorkspaceRuntimeCoordinator(h.secondary).hasActiveWork()).toBe( + true, + ); + await vi.waitFor(() => expect(finishCommit).toBeTypeOf('function')); + finishCommit(); + await pollOperation(h.app, started.body.operationId); + expect(getWorkspaceRuntimeCoordinator(h.secondary).hasActiveWork()).toBe( + false, + ); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('returns the effective activation after clearing a workspace override', async () => { const h = await makeHarness(); mockExtensionManager(); @@ -537,32 +876,37 @@ describe('extension management v2 REST', () => { const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; }); - vi.mocked(h.secondary.bridge.refreshExtensionsForAllSessions) + vi.mocked(h.secondary.bridge.refreshWorkspaceExtensions!) .mockImplementationOnce(async () => { await refreshGate; - return { refreshed: 1, failed: 0 }; + return { refreshed: 1, failed: 0, generation: 7, runtimeEpoch: 1 }; }) - .mockResolvedValue({ refreshed: 1, failed: 0 }); + .mockResolvedValue({ + refreshed: 1, + failed: 0, + generation: 7, + runtimeEpoch: 1, + } as never); try { await vi.advanceTimersByTimeAsync(30_000); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(90_000); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); expect( - h.primary.bridge.refreshExtensionsForAllSessions, + h.primary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); releaseRefresh(); await vi.advanceTimersByTimeAsync(30_000); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); } finally { releaseRefresh(); @@ -571,24 +915,86 @@ describe('extension management v2 REST', () => { } }); + it('runs generation polling through the workspace extension lane', async () => { + vi.useFakeTimers(); + const h = await makeHarness(); + mockExtensionManager(); + let releaseLane = () => {}; + const laneGate = new Promise((resolve) => { + releaseLane = resolve; + }); + const coordinator = getWorkspaceRuntimeCoordinator(h.secondary); + const blocker = coordinator.runExtensionsPhysicalReconciliation( + async () => await laneGate, + ); + try { + await vi.advanceTimersByTimeAsync(30_000); + + expect( + h.secondary.bridge.refreshWorkspaceExtensions, + ).not.toHaveBeenCalled(); + + releaseLane(); + await blocker; + await vi.waitFor(() => + expect( + h.secondary.bridge.refreshWorkspaceExtensions, + ).toHaveBeenCalledOnce(), + ); + } finally { + releaseLane(); + await blocker; + vi.useRealTimers(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + + it('does not wake a cold runtime during generation reconciliation', async () => { + vi.useFakeTimers(); + const h = await makeHarness(); + mockExtensionManager(); + const secondaryLive = vi.mocked(h.secondary.bridge.isChannelLive); + secondaryLive.mockReturnValue(false); + try { + await vi.advanceTimersByTimeAsync(30_000); + expect( + h.secondary.bridge.refreshWorkspaceExtensions, + ).not.toHaveBeenCalled(); + + secondaryLive.mockReturnValue(true); + await vi.advanceTimersByTimeAsync(30_000); + expect( + h.secondary.bridge.refreshWorkspaceExtensions, + ).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('retries generation reconciliation after a runtime refresh fails', async () => { vi.useFakeTimers(); const h = await makeHarness(); mockExtensionManager(); vi.spyOn(process.stderr, 'write').mockReturnValue(true); - vi.mocked(h.secondary.bridge.refreshExtensionsForAllSessions) + vi.mocked(h.secondary.bridge.refreshWorkspaceExtensions!) .mockRejectedValueOnce(new Error('refresh failed')) - .mockResolvedValue({ refreshed: 1, failed: 0 }); + .mockResolvedValue({ + refreshed: 1, + failed: 0, + generation: 7, + runtimeEpoch: 1, + } as never); try { await vi.advanceTimersByTimeAsync(30_000); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); await vi.advanceTimersByTimeAsync(30_000); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); @@ -615,7 +1021,7 @@ describe('extension management v2 REST', () => { try { await vi.advanceTimersByTimeAsync(30_000); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); vi.mocked( @@ -624,10 +1030,18 @@ describe('extension management v2 REST', () => { vi.mocked( ExtensionManager.prototype.refreshCacheWithSnapshot, ).mockResolvedValue(rolledBackSnapshot); + vi.mocked( + h.secondary.bridge.refreshWorkspaceExtensions!, + ).mockResolvedValue({ + refreshed: 1, + failed: 0, + generation: 6, + runtimeEpoch: 1, + } as never); await vi.advanceTimersByTimeAsync(30_000); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledTimes(2); const projection = await auth( request(h.app).get( @@ -662,9 +1076,7 @@ describe('extension management v2 REST', () => { await vi.advanceTimersByTimeAsync(30_000); - expect( - late.bridge.refreshExtensionsForAllSessions, - ).toHaveBeenCalledOnce(); + expect(late.bridge.refreshWorkspaceExtensions).toHaveBeenCalledOnce(); const projection = await auth( request(h.app).get('/workspaces/late-stable-id/extensions'), ); @@ -681,9 +1093,18 @@ describe('extension management v2 REST', () => { it('advances applied generation only after the workspace reconciles', async () => { const h = await makeHarness(); mockExtensionManager(); - vi.mocked(h.secondary.bridge.refreshExtensionsForAllSessions) - .mockResolvedValueOnce({ refreshed: 0, failed: 1 }) - .mockResolvedValue({ refreshed: 1, failed: 0 }); + vi.mocked(h.secondary.bridge.refreshWorkspaceExtensions!) + .mockResolvedValueOnce({ + refreshed: 0, + failed: 1, + runtimeEpoch: 1, + } as never) + .mockResolvedValue({ + refreshed: 1, + failed: 0, + generation: 7, + runtimeEpoch: 1, + } as never); try { const activation = await auth( request(h.app) @@ -766,9 +1187,14 @@ describe('extension management v2 REST', () => { vi.mocked( ExtensionManager.prototype.getExtensionStoreSnapshot, ).mockResolvedValue(snapshot(9)); - vi.mocked( - h.secondary.bridge.refreshExtensionsForAllSessions, - ).mockResolvedValue({ refreshed: 1, failed: 0 }); + vi.mocked(h.secondary.bridge.refreshWorkspaceExtensions!).mockResolvedValue( + { + refreshed: 1, + failed: 0, + generation: 9, + runtimeEpoch: 1, + } as never, + ); try { const first = await auth( request(h.app) @@ -798,7 +1224,7 @@ describe('extension management v2 REST', () => { }); }); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).not.toHaveBeenCalled(); releaseFirstCommit?.(); @@ -807,10 +1233,13 @@ describe('extension management v2 REST', () => { ).resolves.toMatchObject({ status: 'succeeded' }); await expect( pollOperation(h.app, first.body.operationId), - ).resolves.toMatchObject({ status: 'succeeded' }); + ).resolves.toMatchObject({ + status: 'succeeded', + result: { activation: 'deferred' }, + }); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, - ).toHaveBeenCalledTimes(2); + h.secondary.bridge.refreshWorkspaceExtensions, + ).toHaveBeenCalledOnce(); const projection = await auth( request(h.app).get( `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/extensions`, @@ -836,16 +1265,48 @@ describe('extension management v2 REST', () => { const operation = await pollOperation(h.app, started.body.operationId); expect(operation.status).toBe('succeeded'); expect( - h.primary.bridge.refreshExtensionsForAllSessions, + h.primary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); expect( - h.secondary.bridge.refreshExtensionsForAllSessions, + h.secondary.bridge.refreshWorkspaceExtensions, ).toHaveBeenCalledOnce(); } finally { await fsp.rm(h.scratch, { recursive: true, force: true }); } }); + it('defers physical refresh for a draining runtime while advancing its desired generation', async () => { + const h = await makeHarness(); + mockExtensionManager(); + expect(h.registry.beginDrain(h.secondary)).toBe(true); + try { + const started = await auth( + request(h.app) + .put(`/extensions/${extensionId}/activation`) + .send({ state: 'disabled' }), + ); + expect(started.status).toBe(202); + await expect( + pollOperation(h.app, started.body.operationId), + ).resolves.toMatchObject({ status: 'succeeded' }); + + expect( + h.primary.bridge.refreshWorkspaceExtensions, + ).toHaveBeenCalledOnce(); + expect( + h.secondary.bridge.refreshWorkspaceExtensions, + ).not.toHaveBeenCalled(); + expect( + getWorkspaceRuntimeCoordinator(h.secondary).status().capabilities + .extensions, + ).toMatchObject({ + desiredGeneration: 7, + }); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('includes runtimes registered while a global mutation is committing', async () => { const h = await makeHarness(); mockExtensionManager(); @@ -894,9 +1355,7 @@ describe('extension management v2 REST', () => { await expect( pollOperation(h.app, started.body.operationId), ).resolves.toMatchObject({ status: 'succeeded' }); - expect( - late.bridge.refreshExtensionsForAllSessions, - ).toHaveBeenCalledOnce(); + expect(late.bridge.refreshWorkspaceExtensions).toHaveBeenCalledOnce(); const projection = await auth( request(h.app).get('/workspaces/late-id/extensions'), ); @@ -1012,6 +1471,75 @@ describe('extension management v2 REST', () => { } }); + it('does not accept a primary extension interaction through another workspace', async () => { + const h = await makeHarness(); + mockExtensionManager(); + vi.spyOn( + ExtensionManager.prototype, + 'prepareExtensionInstall', + ).mockImplementation(async function (this: ExtensionManager) { + const manager = this as unknown as { + requestSetting?: (setting: { + name: string; + description: string; + envVar: string; + }) => Promise; + }; + await manager.requestSetting?.({ + name: 'API key', + description: 'API key used by this extension', + envVar: 'API_KEY', + }); + return {} as never; + }); + try { + const started = await auth( + request(h.app) + .post('/workspace/config/extensions/install') + .send({ source: '@scope/demo', consent: true }), + ); + expect(started.status).toBe(202); + + let interactionId = ''; + await vi.waitFor(async () => { + const operation = await auth( + request(h.app).get( + `/workspace/config/extensions/operations/${started.body.operationId}`, + ), + ); + expect(operation.body.status).toBe('waiting_for_input'); + interactionId = operation.body.interaction.id as string; + }); + + const crossWorkspace = await auth( + request(h.app) + .post( + `/workspaces/${encodeURIComponent(h.secondary.workspaceId)}/config/extensions/operations/${started.body.operationId}/interactions/${interactionId}`, + ) + .send({ value: 'secret' }), + ); + expect(crossWorkspace.status).toBe(404); + + const stillWaiting = await auth( + request(h.app).get( + `/workspace/config/extensions/operations/${started.body.operationId}`, + ), + ); + expect(stillWaiting.body.status).toBe('waiting_for_input'); + + const cancel = await auth( + request(h.app) + .post( + `/workspace/config/extensions/operations/${started.body.operationId}/interactions/${interactionId}`, + ) + .send({ cancelled: true }), + ); + expect(cancel.status).toBe(200); + } finally { + await fsp.rm(h.scratch, { recursive: true, force: true }); + } + }); + it('preserves prototype-named extension update states', async () => { const h = await makeHarness(); mockExtensionManager(); @@ -1218,6 +1746,20 @@ describe('extension management v2 REST', () => { const disposePrepared = vi .spyOn(ExtensionManager.prototype, 'disposePreparedExtension') .mockResolvedValue(); + vi.mocked(h.primary.bridge.refreshWorkspaceExtensions!).mockResolvedValue({ + refreshed: 1, + failed: 0, + generation: 8, + runtimeEpoch: 1, + } as never); + vi.mocked(h.secondary.bridge.refreshWorkspaceExtensions!).mockResolvedValue( + { + refreshed: 1, + failed: 0, + generation: 8, + runtimeEpoch: 1, + } as never, + ); try { const started = await auth( request(h.app).post(`/extensions/${extensionId}/update`), @@ -1308,7 +1850,11 @@ describe('extension management v2 REST', () => { expect(started.status).toBe(202); await expect( - pollOperation(h.app, started.body.operationId), + pollOperation( + h.app, + started.body.operationId, + '/workspace/extensions/operations', + ), ).resolves.toMatchObject({ status: 'failed', code: 'extension_prepare_timeout', diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index c44f4093e78..708bcca623e 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -1215,6 +1215,97 @@ export class DaemonClient { ); } + async installWorkspaceConfigExtension( + params: ExtensionInstallRequest, + ): Promise { + return await this.jsonRequest( + '/workspace/config/extensions/install', + 'POST /workspace/config/extensions/install', + { method: 'POST', body: params, mode: 'rest' }, + ); + } + + async workspaceConfigExtensionOperationStatus( + operationId: string, + timeoutMs?: number, + ): Promise { + return await this.jsonRequest( + `/workspace/config/extensions/operations/${urlEncode(operationId)}`, + 'GET /workspace/config/extensions/operations/:operationId', + { mode: 'rest', timeoutMs }, + ); + } + + async activeWorkspaceConfigExtensionOperations(): Promise { + return await this.jsonRequest( + '/workspace/config/extensions/operations', + 'GET /workspace/config/extensions/operations', + { mode: 'rest' }, + ); + } + + async respondToWorkspaceConfigExtensionInteraction( + operationId: string, + interactionId: string, + response: ExtensionInteractionResponse, + ): Promise { + return await this.jsonRequest( + `/workspace/config/extensions/operations/${urlEncode(operationId)}/interactions/${urlEncode(interactionId)}`, + 'POST /workspace/config/extensions/operations/:operationId/interactions/:interactionId', + { method: 'POST', body: response, mode: 'rest' }, + ); + } + + async checkWorkspaceConfigExtensionUpdates(): Promise { + return await this.jsonRequest( + '/workspace/config/extensions/check-updates', + 'POST /workspace/config/extensions/check-updates', + { method: 'POST', body: {}, mode: 'rest' }, + ); + } + + async enableWorkspaceConfigExtension( + name: string, + params: ExtensionScopeRequest & { scope: 'user' }, + ): Promise { + return await this.jsonRequest( + `/workspace/config/extensions/${urlEncode(name)}/enable`, + 'POST /workspace/config/extensions/:name/enable', + { method: 'POST', body: params, mode: 'rest' }, + ); + } + + async disableWorkspaceConfigExtension( + name: string, + params: ExtensionScopeRequest & { scope: 'user' }, + ): Promise { + return await this.jsonRequest( + `/workspace/config/extensions/${urlEncode(name)}/disable`, + 'POST /workspace/config/extensions/:name/disable', + { method: 'POST', body: params, mode: 'rest' }, + ); + } + + async updateWorkspaceConfigExtension( + name: string, + ): Promise { + return await this.jsonRequest( + `/workspace/config/extensions/${urlEncode(name)}/update`, + 'POST /workspace/config/extensions/:name/update', + { method: 'POST', body: {}, mode: 'rest' }, + ); + } + + async uninstallWorkspaceConfigExtension( + name: string, + ): Promise { + return await this.jsonRequest( + `/workspace/config/extensions/${urlEncode(name)}`, + 'DELETE /workspace/config/extensions/:name', + { method: 'DELETE', mode: 'rest' }, + ); + } + async installExtension( params: ExtensionInstallRequest, clientId?: string, @@ -1420,9 +1511,16 @@ export class DaemonClient { } = {}, ): Promise { const pollIntervalMs = options.pollIntervalMs ?? 1_000; - const timeoutMs = options.timeoutMs ?? 10 * 60_000; - const hasDeadline = timeoutMs !== Number.POSITIVE_INFINITY; - const deadline = Date.now() + timeoutMs; + const now = Date.now(); + const clientDeadline = + options.timeoutMs === undefined ? undefined : now + options.timeoutMs; + const deadline = + handle.deadlineAt === undefined + ? now + (options.timeoutMs ?? 10 * 60_000) + : clientDeadline === undefined + ? handle.deadlineAt + : Math.min(handle.deadlineAt, clientDeadline); + const hasDeadline = deadline !== Number.POSITIVE_INFINITY; const timeoutError = () => new Error( `Timed out waiting for extension operation ${handle.operationId}. The server operation was not cancelled.`, @@ -5033,6 +5131,66 @@ export class WorkspaceDaemonClient { ); } + workspaceConfigExtensions(): Promise { + return this.restGet( + '/config/extensions', + 'GET /workspaces/:workspace/config/extensions', + ); + } + + workspaceConfigExtensionOperationStatus( + operationId: string, + timeoutMs?: number, + ): Promise { + return this.client.workspaceJsonRequest( + this.workspaceSelector, + `/config/extensions/operations/${urlEncode(operationId)}`, + 'GET /workspaces/:workspace/config/extensions/operations/:operationId', + { mode: 'rest', timeoutMs }, + ); + } + + activeWorkspaceConfigExtensionOperations(): Promise { + return this.restGet( + '/config/extensions/operations', + 'GET /workspaces/:workspace/config/extensions/operations', + ); + } + + respondToWorkspaceConfigExtensionInteraction( + operationId: string, + interactionId: string, + response: ExtensionInteractionResponse, + ): Promise { + return this.restPost( + `/config/extensions/operations/${urlEncode(operationId)}/interactions/${urlEncode(interactionId)}`, + 'POST /workspaces/:workspace/config/extensions/operations/:operationId/interactions/:interactionId', + response, + ); + } + + enableWorkspaceConfigExtension( + name: string, + params: ExtensionScopeRequest & { scope: 'workspace' }, + ): Promise { + return this.restPost( + `/config/extensions/${urlEncode(name)}/enable`, + 'POST /workspaces/:workspace/config/extensions/:name/enable', + params, + ); + } + + disableWorkspaceConfigExtension( + name: string, + params: ExtensionScopeRequest & { scope: 'workspace' }, + ): Promise { + return this.restPost( + `/config/extensions/${urlEncode(name)}/disable`, + 'POST /workspaces/:workspace/config/extensions/:name/disable', + params, + ); + } + setExtensionActivation( extensionId: string, state: ExtensionActivationState, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 19c9368cac5..641f1e2b813 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -3248,6 +3248,8 @@ export interface DaemonExtensionEntry { description?: string; version: string; isActive: boolean; + defaultActivation?: ExtensionActivationState; + workspaceActivation?: 'inherit' | ExtensionActivationState; path: string; source?: string; installType?: DaemonExtensionInstallType; @@ -3263,6 +3265,9 @@ export interface DaemonWorkspaceExtensionsStatus { v: 1; workspaceCwd: string; initialized: boolean; + runtimeEpoch?: number; + desiredGeneration?: number; + appliedGeneration?: number; extensions: DaemonExtensionEntry[]; errors?: DaemonStatusCell[]; } @@ -3310,7 +3315,6 @@ export interface WorkspaceExtensionProjectionEntry { version: string; defaultActivation: ExtensionActivationState; workspaceActivation: ExtensionWorkspaceActivation; - effectiveActivation: ExtensionActivationState; activationSource: | 'cli_override' | 'workspace_override' @@ -3331,6 +3335,7 @@ export interface WorkspaceExtensionProjection { export interface ExtensionInstallResponse { accepted: true; operationId: string; + deadlineAt?: number; } export type ExtensionMutationResponse = ExtensionInstallResponse; @@ -3362,6 +3367,7 @@ export interface ExtensionOperationResult { updated?: boolean; reason?: string; states?: Record; + activation?: 'applied' | 'deferred' | 'partial'; } export interface ExtensionOperationStatus { @@ -3372,6 +3378,7 @@ export interface ExtensionOperationStatus { phase?: 'preparing' | 'committing' | 'reconciling'; createdAt: number; updatedAt: number; + deadlineAt?: number; source?: string; name?: string; result?: ExtensionOperationResult; diff --git a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.test.tsx b/packages/web-shell/client/components/extensions/ExtensionsManagerPage.test.tsx deleted file mode 100644 index 76bd64bbebf..00000000000 --- a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.test.tsx +++ /dev/null @@ -1,914 +0,0 @@ -// @vitest-environment jsdom -import { act } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - DaemonHttpError, - type DaemonExtensionEntry, - type ExtensionOperationStatus, -} from '@qwen-code/sdk/daemon'; -import { I18nProvider } from '../../i18n'; - -Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - -const { actions, connection, signals } = vi.hoisted(() => ({ - actions: { - loadExtensionsStatus: vi.fn(), - installExtension: vi.fn(), - activeExtensionOperations: vi.fn(), - extensionOperationStatus: vi.fn(), - respondToExtensionInteraction: vi.fn(), - checkExtensionUpdates: vi.fn(), - refreshExtensions: vi.fn(), - enableExtension: vi.fn(), - disableExtension: vi.fn(), - updateExtension: vi.fn(), - uninstallExtension: vi.fn(), - }, - connection: { clientId: 'client-1' as string | undefined }, - signals: { extensionsVersion: 0 }, -})); - -vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ - useConnection: () => connection, - useWorkspaceActions: () => actions, - useWorkspaceEventSignals: () => signals, -})); - -const { ExtensionsManagerPage } = await import('./ExtensionsManagerPage'); - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -async function flush() { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - }); -} - -function extension( - updateState?: DaemonExtensionEntry['updateState'], -): DaemonExtensionEntry { - return { - kind: 'extension', - id: 'demo', - name: 'demo', - displayName: 'Demo', - version: '1.0.0', - isActive: true, - path: '/tmp/demo', - updateState, - capabilities: { - mcpServerCount: 0, - skillCount: 0, - agentCount: 0, - hookCount: 0, - commandCount: 0, - contextFileCount: 0, - channelCount: 0, - hasSettings: false, - }, - }; -} - -function renderPage() { - root?.render( - - - , - ); -} - -async function mount( - extensions: DaemonExtensionEntry[] = [], - activeOperations: ExtensionOperationStatus[] = [], -) { - actions.activeExtensionOperations.mockResolvedValue({ - v: 1, - operations: activeOperations, - }); - if (!actions.checkExtensionUpdates.getMockImplementation()) { - actions.checkExtensionUpdates.mockResolvedValue({ states: {} }); - } - actions.loadExtensionsStatus.mockResolvedValue({ - v: 1, - workspaceCwd: '/workspace', - initialized: true, - extensions, - }); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - renderPage(); - }); - await flush(); -} - -function buttonIncluding(text: string): HTMLButtonElement | undefined { - return Array.from(document.querySelectorAll('button')).find((button) => - button.textContent?.includes(text), - ); -} - -function elementIncluding(selector: string, text: string): Element | undefined { - return Array.from(document.querySelectorAll(selector)).find((element) => - element.textContent?.includes(text), - ); -} - -function click(element: Element | undefined) { - if (!element) throw new Error('click target not found'); - act(() => { - element.dispatchEvent( - new MouseEvent('click', { bubbles: true, cancelable: true }), - ); - }); -} - -function pointerDown(element: Element | undefined) { - if (!element) throw new Error('pointer target not found'); - act(() => { - element.dispatchEvent( - new MouseEvent('pointerdown', { - bubbles: true, - cancelable: true, - button: 0, - }), - ); - }); -} - -function changeInput(input: HTMLInputElement | null, value: string) { - if (!input) throw new Error('input not found'); - act(() => { - const setter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - 'value', - )?.set; - setter?.call(input, value); - input.dispatchEvent(new Event('input', { bubbles: true })); - }); -} - -async function startInstall() { - click(buttonIncluding('Add Extension')); - changeInput(document.querySelector('#extension-source'), 'owner/repo'); - click(buttonIncluding('Install')); - await flush(); -} - -afterEach(() => { - act(() => root?.unmount()); - container?.remove(); - root = null; - container = null; - connection.clientId = 'client-1'; - signals.extensionsVersion = 0; - vi.resetAllMocks(); -}); - -describe('ExtensionsManagerPage', () => { - it('reports recovery failures and clears the error after retry', async () => { - vi.useFakeTimers(); - actions.activeExtensionOperations - .mockRejectedValueOnce(new Error('Could not recover operations')) - .mockResolvedValue({ v: 1, operations: [] }); - - try { - await mount(); - expect(document.body.textContent).toContain( - 'Could not recover operations', - ); - expect(buttonIncluding('Add Extension')?.disabled).toBe(true); - - await act(async () => { - await vi.advanceTimersByTimeAsync(2000); - }); - await flush(); - - expect(actions.activeExtensionOperations).toHaveBeenCalledTimes(2); - expect(document.body.textContent).not.toContain( - 'Could not recover operations', - ); - expect(buttonIncluding('Add Extension')?.disabled).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it('recovers an active extension operation when reopened', async () => { - actions.extensionOperationStatus.mockResolvedValue({ - v: 1, - operationId: 'op-active', - operation: 'install', - status: 'succeeded', - createdAt: 1, - updatedAt: 2, - source: 'owner/repo', - result: { status: 'installed', name: 'demo' }, - }); - - await mount( - [], - [ - { - v: 1, - operationId: 'op-active', - operation: 'install', - status: 'running', - createdAt: 1, - updatedAt: 1, - source: 'owner/repo', - }, - ], - ); - - expect(actions.extensionOperationStatus).toHaveBeenCalledWith('op-active'); - expect(document.body.textContent).toContain('installed'); - }); - - it('recovers the newest install when multiple operations are active', async () => { - actions.extensionOperationStatus.mockResolvedValue({ - v: 1, - operationId: 'op-new', - operation: 'install', - status: 'succeeded', - createdAt: 2, - updatedAt: 3, - source: 'owner/new', - result: { status: 'installed', name: 'new' }, - }); - - await mount( - [], - [ - { - v: 1, - operationId: 'op-old', - operation: 'install', - status: 'running', - createdAt: 1, - updatedAt: 2, - source: 'owner/old', - }, - { - v: 1, - operationId: 'op-new', - operation: 'install', - status: 'queued', - createdAt: 2, - updatedAt: 2, - source: 'owner/new', - }, - ], - ); - - expect(actions.extensionOperationStatus).toHaveBeenCalledWith('op-new'); - }); - - it('recovers and completes an active extension mutation', async () => { - vi.useFakeTimers(); - actions.extensionOperationStatus - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-update', - operation: 'update', - status: 'running', - createdAt: 1, - updatedAt: 2, - name: 'demo', - }) - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-update', - operation: 'update', - status: 'succeeded', - createdAt: 1, - updatedAt: 3, - name: 'demo', - result: { status: 'updated', name: 'demo' }, - }); - - try { - await mount( - [extension()], - [ - { - v: 1, - operationId: 'op-update', - operation: 'update', - status: 'running', - createdAt: 1, - updatedAt: 1, - name: 'demo', - }, - ], - ); - expect(buttonIncluding('Add Extension')?.disabled).toBe(true); - - await act(async () => { - await vi.advanceTimersByTimeAsync(5000); - }); - await flush(); - - expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2); - expect(buttonIncluding('Add Extension')?.disabled).toBe(false); - expect(actions.loadExtensionsStatus).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } - }); - - it('reloads the extension list without refreshing daemon sessions', async () => { - await mount(); - - actions.loadExtensionsStatus.mockClear(); - click(buttonIncluding('refresh')); - await flush(); - - expect(actions.loadExtensionsStatus).toHaveBeenCalledOnce(); - expect(actions.refreshExtensions).not.toHaveBeenCalled(); - }); - - it('disables adding another extension while an install is pending', async () => { - actions.installExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-1', - }); - actions.extensionOperationStatus.mockResolvedValue({ - v: 1, - operationId: 'op-1', - operation: 'install', - status: 'running', - createdAt: 1, - updatedAt: 1, - }); - await mount(); - - await startInstall(); - - expect(actions.installExtension).toHaveBeenCalledOnce(); - expect(buttonIncluding('Add Extension')?.disabled).toBe(true); - }); - - it('keeps the add dialog open until the install request is accepted', async () => { - let acceptInstall: - | ((value: { accepted: true; operationId: string }) => void) - | undefined; - actions.installExtension.mockImplementation( - () => - new Promise((resolve) => { - acceptInstall = resolve; - }), - ); - actions.extensionOperationStatus.mockResolvedValue({ - v: 1, - operationId: 'op-1', - operation: 'install', - status: 'running', - createdAt: 1, - updatedAt: 1, - }); - await mount(); - - click(buttonIncluding('Add Extension')); - changeInput(document.querySelector('#extension-source'), 'owner/repo'); - click(buttonIncluding('Install')); - await flush(); - - expect(document.querySelector('#extension-source')).not.toBeNull(); - expect(buttonIncluding('Install')?.disabled).toBe(true); - - await act(async () => { - acceptInstall?.({ accepted: true, operationId: 'op-1' }); - }); - await flush(); - - expect(document.querySelector('#extension-source')).toBeNull(); - }); - - it('closes a failed interaction and resumes polling the install', async () => { - actions.installExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-1', - }); - actions.extensionOperationStatus - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-1', - operation: 'install', - status: 'waiting_for_input', - createdAt: 1, - updatedAt: 2, - interaction: { - id: 'interaction-1', - kind: 'setting', - setting: { - name: 'API key', - description: 'Enter an API key', - sensitive: true, - }, - }, - }) - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-1', - operation: 'install', - status: 'waiting_for_input', - createdAt: 1, - updatedAt: 3, - interaction: { - id: 'interaction-2', - kind: 'setting', - setting: { - name: 'Second API key', - description: 'Enter another API key', - sensitive: true, - }, - }, - }); - actions.respondToExtensionInteraction.mockRejectedValue( - new Error('Interaction expired'), - ); - await mount(); - - await startInstall(); - changeInput( - document.querySelector('input[aria-label="API key"]'), - 'secret', - ); - act(() => { - document - .querySelector('input[aria-label="API key"]') - ?.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), - ); - }); - await flush(); - - expect(actions.respondToExtensionInteraction).toHaveBeenCalledWith( - 'op-1', - 'interaction-1', - { value: 'secret' }, - 'client-1', - ); - expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2); - expect(document.body.textContent).toContain('Interaction expired'); - expect( - ( - document.querySelector( - 'input[aria-label="Second API key"]', - ) as HTMLInputElement | null - )?.value, - ).toBe(''); - }); - - it('submits a marketplace plugin selection while installing', async () => { - actions.installExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-marketplace', - }); - actions.extensionOperationStatus - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-marketplace', - operation: 'install', - status: 'waiting_for_input', - createdAt: 1, - updatedAt: 2, - interaction: { - id: 'interaction-marketplace', - kind: 'marketplace_plugin', - marketplace: { name: 'Example Marketplace' }, - plugins: [ - { - name: 'example-plugin', - description: 'Example plugin description', - }, - ], - }, - }) - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-marketplace', - operation: 'install', - status: 'succeeded', - createdAt: 1, - updatedAt: 3, - }); - actions.respondToExtensionInteraction.mockResolvedValue({ accepted: true }); - await mount(); - - await startInstall(); - expect(document.body.textContent).toContain('Example plugin description'); - click(document.querySelector('[role="radio"]') ?? undefined); - click(buttonIncluding('Install')); - await flush(); - - expect(actions.respondToExtensionInteraction).toHaveBeenCalledWith( - 'op-marketplace', - 'interaction-marketplace', - { pluginName: 'example-plugin' }, - 'client-1', - ); - expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2); - }); - - it('keeps polling while an interaction is waiting', async () => { - vi.useFakeTimers(); - actions.installExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-waiting', - }); - actions.extensionOperationStatus - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-waiting', - operation: 'install', - status: 'waiting_for_input', - createdAt: 1, - updatedAt: 2, - interaction: { - id: 'interaction-waiting', - kind: 'setting', - setting: { - name: 'API key', - description: 'Enter an API key', - sensitive: true, - }, - }, - }) - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-waiting', - operation: 'install', - status: 'failed', - createdAt: 1, - updatedAt: 3, - error: 'Extension interaction timed out', - }); - - try { - await mount(); - await startInstall(); - expect(document.body.textContent).toContain('API key'); - - await act(async () => { - await vi.advanceTimersByTimeAsync(5000); - }); - await flush(); - - expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2); - expect(document.body.textContent).toContain( - 'Extension interaction timed out', - ); - expect(buttonIncluding('Add Extension')?.disabled).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it('retains operation tracking after a transient status error', async () => { - vi.useFakeTimers(); - actions.installExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-retry', - }); - actions.extensionOperationStatus - .mockRejectedValueOnce(new Error('Temporary network error')) - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-retry', - operation: 'install', - status: 'succeeded', - createdAt: 1, - updatedAt: 2, - result: { status: 'installed', name: 'demo' }, - }); - - try { - await mount(); - await startInstall(); - expect(document.body.textContent).toContain('Temporary network error'); - expect(buttonIncluding('Add Extension')?.disabled).toBe(true); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1000); - }); - await flush(); - - expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2); - expect(document.body.textContent).toContain('installed'); - expect(buttonIncluding('Add Extension')?.disabled).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it('stops tracking an operation that is no longer on the daemon', async () => { - actions.installExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-missing', - }); - actions.extensionOperationStatus.mockRejectedValue( - new DaemonHttpError(404, {}, 'Operation not found'), - ); - - await mount(); - await startInstall(); - - expect(actions.extensionOperationStatus).toHaveBeenCalledOnce(); - expect(buttonIncluding('Add Extension')?.disabled).toBe(false); - expect(document.body.textContent).toContain('Operation not found'); - }); - - it('checks for updates automatically after loading extensions', async () => { - actions.checkExtensionUpdates.mockResolvedValue({ - states: { demo: 'update available' }, - }); - - await mount([extension()]); - - expect(actions.checkExtensionUpdates).toHaveBeenCalledWith('client-1'); - expect(document.body.textContent).toContain('update available'); - }); - - it('updates an extension without a session client id', async () => { - connection.clientId = undefined; - actions.checkExtensionUpdates.mockResolvedValue({ - states: { demo: 'update available' }, - }); - actions.updateExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-update', - }); - actions.extensionOperationStatus - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-update', - operation: 'update', - status: 'waiting_for_input', - createdAt: 1, - updatedAt: 2, - interaction: { - id: 'interaction-update', - kind: 'setting', - setting: { - name: 'Optional setting', - description: 'May be left empty', - sensitive: false, - }, - }, - }) - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-update', - operation: 'update', - status: 'succeeded', - createdAt: 1, - updatedAt: 3, - result: { status: 'updated', name: 'demo' }, - }); - actions.respondToExtensionInteraction.mockResolvedValue({ accepted: true }); - await mount([extension()]); - - click(document.querySelector('[data-slot="card"]') ?? undefined); - await flush(); - pointerDown( - document.querySelector('button[aria-label="Extension actions"]') ?? - undefined, - ); - await flush(); - click( - elementIncluding('[data-slot="dropdown-menu-item"]', 'Update Extension'), - ); - await flush(); - - expect(actions.updateExtension).toHaveBeenCalledWith('demo', undefined); - expect(actions.extensionOperationStatus).toHaveBeenCalledWith('op-update'); - expect(document.body.textContent).toContain('Optional setting'); - click(buttonIncluding('Update')); - await flush(); - expect(actions.respondToExtensionInteraction).toHaveBeenCalledWith( - 'op-update', - 'interaction-update', - { value: '' }, - undefined, - ); - expect(actions.extensionOperationStatus).toHaveBeenCalledTimes(2); - expect(document.body.textContent).not.toContain( - 'Wait for the session to connect', - ); - }); - - it('keeps uninstall progress on the detail page and returns silently to the list', async () => { - vi.useFakeTimers(); - let acceptUninstall: - | ((value: { accepted: true; operationId: string }) => void) - | undefined; - actions.uninstallExtension.mockImplementation( - () => - new Promise((resolve) => { - acceptUninstall = resolve; - }), - ); - actions.extensionOperationStatus - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-uninstall', - operation: 'uninstall', - status: 'running', - createdAt: 1, - updatedAt: 2, - name: 'demo', - }) - .mockResolvedValueOnce({ - v: 1, - operationId: 'op-uninstall', - operation: 'uninstall', - status: 'succeeded', - createdAt: 1, - updatedAt: 3, - name: 'demo', - result: { status: 'uninstalled', name: 'demo' }, - }); - - try { - await mount([extension()]); - click(document.querySelector('[data-slot="card"]') ?? undefined); - await flush(); - pointerDown( - document.querySelector('button[aria-label="Extension actions"]') ?? - undefined, - ); - await flush(); - click( - elementIncluding( - '[data-slot="dropdown-menu-item"]', - 'Uninstall Extension', - ), - ); - await flush(); - click(buttonIncluding('Uninstall Extension')); - await flush(); - - expect(document.querySelector('h1')?.textContent).toContain('Demo'); - expect(document.body.textContent).toContain( - 'Uninstalling extension "demo"', - ); - expect( - document.querySelector( - 'button[aria-label="Extension actions"]', - )?.disabled, - ).toBe(true); - expect(actions.extensionOperationStatus).not.toHaveBeenCalled(); - - await act(async () => { - acceptUninstall?.({ - accepted: true, - operationId: 'op-uninstall', - }); - }); - await flush(); - - actions.loadExtensionsStatus.mockResolvedValue({ - v: 1, - workspaceCwd: '/workspace', - initialized: true, - extensions: [], - }); - signals.extensionsVersion += 1; - await act(async () => renderPage()); - await flush(); - - expect(document.querySelector('h1')?.textContent).toContain('Demo'); - expect(document.body.textContent).toContain( - 'Uninstalling extension "demo"', - ); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1000); - }); - await flush(); - - expect(document.querySelector('h1')?.textContent).toContain( - 'Manage Extensions', - ); - expect(document.body.textContent).not.toContain( - 'Extension "demo" uninstalled.', - ); - } finally { - vi.useRealTimers(); - } - }); - - it('shows disable progress and disables detail actions', async () => { - actions.disableExtension.mockResolvedValue({ - accepted: true, - operationId: 'op-disable', - }); - actions.extensionOperationStatus.mockResolvedValue({ - v: 1, - operationId: 'op-disable', - operation: 'disable', - status: 'running', - createdAt: 1, - updatedAt: 2, - name: 'demo', - }); - await mount([extension()]); - - click(document.querySelector('[data-slot="card"]') ?? undefined); - await flush(); - pointerDown( - document.querySelector('button[aria-label="Extension actions"]') ?? - undefined, - ); - await flush(); - click( - elementIncluding('[data-slot="dropdown-menu-item"]', 'Disable Extension'), - ); - await flush(); - - expect(document.body.textContent).toContain('Disabling extension "demo"'); - expect( - document.querySelector( - 'button[aria-label="Extension actions"]', - )?.disabled, - ).toBe(true); - }); - - it('opens extension details with the keyboard', async () => { - await mount([extension()]); - - const card = document.querySelector('[data-slot="card"]'); - expect(card?.getAttribute('role')).toBe('button'); - expect(card?.getAttribute('aria-label')).toBe('Demo'); - act(() => { - card?.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), - ); - }); - await flush(); - - expect(document.querySelector('h1')?.textContent).toContain('Demo'); - }); - - it('shows the extension description only once on the detail page', async () => { - await mount([ - { ...extension(), description: 'A single extension description' }, - ]); - - click(document.querySelector('[data-slot="card"]') ?? undefined); - await flush(); - - expect( - document.body.textContent?.match(/A single extension description/g), - ).toHaveLength(1); - }); - - it('keeps only the status beside the card title without a footer', async () => { - await mount([extension()]); - - const card = document.querySelector('[data-slot="card"]'); - const titleRow = card?.querySelector( - '[data-slot="card-title"]', - )?.parentElement; - - expect(titleRow?.textContent).toContain('Demo'); - expect(titleRow?.textContent).toContain('enabled'); - expect(card?.textContent).not.toContain('v1.0.0'); - expect(card?.querySelector('[data-slot="card-footer"]')).toBeNull(); - expect( - card - ?.querySelector('[data-slot="card-description"]') - ?.classList.contains('truncate'), - ).toBe(true); - }); - - it('clears stale update states when the extensions signal changes', async () => { - actions.checkExtensionUpdates - .mockResolvedValueOnce({ states: { demo: 'update available' } }) - .mockResolvedValueOnce({ states: { demo: 'up to date' } }); - await mount([extension('up to date')]); - expect(document.body.textContent).toContain('update available'); - - actions.loadExtensionsStatus.mockResolvedValue({ - v: 1, - workspaceCwd: '/workspace', - initialized: true, - extensions: [{ ...extension('up to date') }], - }); - signals.extensionsVersion = 1; - await act(async () => { - renderPage(); - }); - await flush(); - - expect(actions.checkExtensionUpdates).toHaveBeenCalledTimes(2); - expect(document.body.textContent).not.toContain('update available'); - }); -}); diff --git a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx b/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx index 4e06a00a611..0e69c4fc376 100644 --- a/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx +++ b/packages/web-shell/client/components/extensions/ExtensionsManagerPage.tsx @@ -26,14 +26,20 @@ import { type DaemonExtensionEntry, type DaemonExtensionUpdateState, type ExtensionInteractionResponse, + type ExtensionMutationResponse, + type ExtensionOperationStatus, type ExtensionPendingInteraction, } from '@qwen-code/sdk/daemon'; import { - useConnection, + type DaemonWorkspaceExtensionViewEntry, useWorkspaceActions, useWorkspaceEventSignals, } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../../i18n'; +import { + nextPollingDelay, + remainingPollingTimeout, +} from '../../utils/polling-deadline'; import { trimDialogLabel } from '../../utils/dialogLabels'; import styles from './ExtensionsManagerPage.module.css'; import { @@ -41,6 +47,10 @@ import { preserveSelectedExtensionName, } from './extensions-manager-logic'; import { Alert, AlertDescription } from '../ui/alert'; +import { + ManagementNotice, + type ManagementNoticeTone, +} from '../ui/management-notice'; import { AlertDialog, AlertDialogAction, @@ -93,12 +103,19 @@ import { } from '../ui/empty'; import { Input } from '../ui/input'; import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; import { Separator } from '../ui/separator'; import { Spinner } from '../ui/spinner'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import type { EmbeddedManagerPage } from '../plugins/manager-page'; type Scope = 'user' | 'workspace'; -type Mutation = 'enable' | 'disable'; +type ToggleMutation = 'enable' | 'disable' | 'inherit'; type T = ReturnType['t']; type PendingInteractionState = { operationId: string; @@ -110,6 +127,7 @@ const UPDATE_AVAILABLE: DaemonExtensionUpdateState = 'update available'; interface ExtensionsManagerPageProps { onClose: () => void; + workspaceCwd?: string; initialFocusRef?: Ref; embedded?: EmbeddedManagerPage; } @@ -118,8 +136,20 @@ function extensionTitle(extension: DaemonExtensionEntry): string { return extension.displayName || extension.name; } +function extensionIsActive(extension: DaemonExtensionEntry): boolean { + if ( + extension.workspaceActivation && + extension.workspaceActivation !== 'inherit' + ) { + return extension.workspaceActivation === 'enabled'; + } + return extension.defaultActivation + ? extension.defaultActivation === 'enabled' + : extension.isActive; +} + function statusLabel(extension: DaemonExtensionEntry, t: T): string { - return extension.isActive + return extensionIsActive(extension) ? t('extensions.manage.status.enabled') : t('extensions.manage.status.disabled'); } @@ -159,6 +189,8 @@ function mutationMessage(operation: string, name: string, t: T): string { return t('extensions.manage.enabling', { name }); case 'disable': return t('extensions.manage.disabling', { name }); + case 'inherit': + return t('extensions.manage.inheriting', { name }); case 'uninstall': return t('extensions.manage.uninstalling', { name }); case 'update': @@ -174,6 +206,8 @@ function mutationSuccessMessage(operation: string, name: string, t: T): string { return t('extensions.manage.enabled', { name }); case 'disable': return t('extensions.manage.disabled', { name }); + case 'inherit': + return t('extensions.manage.inherited', { name }); case 'uninstall': return t('extensions.manage.uninstalled', { name }); case 'update': @@ -183,6 +217,37 @@ function mutationSuccessMessage(operation: string, name: string, t: T): string { } } +function operationProgressMessage( + operation: ExtensionOperationStatus, + name: string, + t: T, +): string | undefined { + switch (operation.phase) { + case 'preparing': + return t('extensions.manage.phase.preparing', { name }); + case 'committing': + return t('extensions.manage.phase.committing', { name }); + case 'reconciling': + return t('extensions.manage.phase.reconciling', { name }); + case undefined: + return undefined; + } +} + +function withActivationMessage( + value: string | null, + activation: 'applied' | 'deferred' | 'partial' | undefined, + t: T, +): string | null { + const activationMessage = + activation === 'deferred' + ? t('extensions.manage.activationDeferred') + : activation === 'partial' + ? t('extensions.manage.activationPartial') + : null; + return [value, activationMessage].filter(Boolean).join(' ') || null; +} + function DetailField({ label, value }: { label: string; value: string }) { return (
@@ -385,14 +450,16 @@ function ExtensionInteractionDialog({ export function ExtensionsManagerPage({ onClose, + workspaceCwd, initialFocusRef, embedded, }: ExtensionsManagerPageProps) { const { t } = useI18n(); - const connection = useConnection(); - const actions = useWorkspaceActions(); + const actions = useWorkspaceActions(workspaceCwd); const signals = useWorkspaceEventSignals(); - const [extensions, setExtensions] = useState([]); + const [extensions, setExtensions] = useState< + DaemonWorkspaceExtensionViewEntry[] + >([]); const [selectedName, setSelectedName] = useState(null); const [query, setQuery] = useState(''); const [updateStates, setUpdateStates] = useState< @@ -402,6 +469,8 @@ export function ExtensionsManagerPage({ const [checkingName, setCheckingName] = useState(null); const [busyName, setBusyName] = useState(null); const [message, setMessage] = useState(null); + const [messageTone, setMessageTone] = useState('info'); + const [messageOwner, setMessageOwner] = useState(null); const [recoveryError, setRecoveryError] = useState(null); const [actionsOpen, setActionsOpen] = useState(false); const [uninstallName, setUninstallName] = useState(null); @@ -411,6 +480,7 @@ export function ExtensionsManagerPage({ const [pendingInstall, setPendingInstall] = useState<{ operationId: string; source: string; + deadlineAt: number; } | null>(null); const [pendingInteraction, setPendingInteraction] = useState(null); @@ -428,8 +498,8 @@ export function ExtensionsManagerPage({ operationId: string; name: string; operation?: string; + deadlineAt: number; } | null>(null); - const clearInteraction = useCallback((operationId?: string) => { if (operationId && interactionOperationIdRef.current !== operationId) { return; @@ -483,6 +553,8 @@ export function ExtensionsManagerPage({ : nextExtensions; }); if (!preserveMessage) { + setMessageOwner(null); + setMessageTone(status.errors?.[0] ? 'error' : 'info'); setMessage(status.errors?.[0]?.error ?? null); } setSelectedName((name) => @@ -492,26 +564,51 @@ export function ExtensionsManagerPage({ ); }) .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); + if (!preserveMessage) setMessageOwner(null); + setMessageTone('error'); + const errorMessage = + error instanceof Error ? error.message : String(error); + setMessage((current) => + preserveMessage && current + ? `${current}\n${t('extensions.manage.followupRefreshFailed', { + error: errorMessage, + })}` + : errorMessage, + ); }) .finally(() => setLoading(false)); }, - [actions], + [actions, t], ); - const checkAllUpdates = useCallback(() => { - return actions - .checkExtensionUpdates(connection.clientId) - .then((result) => setUpdateStates(result.states)) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }); - }, [actions, connection.clientId]); - useEffect(() => { void load(); }, [load]); + const prepareRuntime = useCallback( + (preserveMessage: boolean) => { + setLoading(true); + return actions + .ensureRuntime() + .then(() => load(preserveMessage)) + .catch((error: unknown) => { + if (!preserveMessage) setMessageOwner(null); + setMessageTone('error'); + const errorMessage = + error instanceof Error ? error.message : String(error); + setMessage((current) => + preserveMessage && current + ? `${current}\n${t('extensions.manage.followupRefreshFailed', { + error: errorMessage, + })}` + : errorMessage, + ); + setLoading(false); + }); + }, + [actions, load, t], + ); + useEffect(() => { let cancelled = false; let timer: ReturnType | undefined; @@ -530,6 +627,9 @@ export function ExtensionsManagerPage({ operationId: activeInstall.operationId, source: activeInstall.source ?? activeInstall.name ?? 'extension', + deadlineAt: + activeInstall.deadlineAt ?? + activeInstall.createdAt + 21 * 60_000, }, ); } @@ -548,6 +648,9 @@ export function ExtensionsManagerPage({ operationId: activeMutation.operationId, name: activeMutation.name ?? 'extension', operation: activeMutation.operation, + deadlineAt: + activeMutation.deadlineAt ?? + activeMutation.createdAt + 21 * 60_000, }, ); setBusyName((current) => current ?? activeMutation.name ?? null); @@ -571,20 +674,16 @@ export function ExtensionsManagerPage({ }; }, [actions]); - const extensionsVersionRef = useRef(signals?.extensionsVersion ?? 0); + const extensionsVersion = signals?.extensionsVersion ?? 0; + const extensionsVersionRef = useRef(extensionsVersion); useEffect(() => { - const version = signals?.extensionsVersion ?? 0; + const version = extensionsVersion; if (version !== extensionsVersionRef.current) { extensionsVersionRef.current = version; setUpdateStates({}); void load(true); } - }, [load, signals?.extensionsVersion]); - - useEffect(() => { - if (extensions.length === 0) return; - void checkAllUpdates(); - }, [checkAllUpdates, extensions]); + }, [extensionsVersion, load]); useEffect(() => { if (!pendingInstall) return; @@ -596,6 +695,7 @@ export function ExtensionsManagerPage({ try { const operation = await actions.extensionOperationStatus( pendingInstall.operationId, + remainingPollingTimeout(pendingInstall.deadlineAt), ); if (cancelled) return; retryDelay = 1000; @@ -606,8 +706,12 @@ export function ExtensionsManagerPage({ operation.interaction, 'install', ); - timer = setTimeout(() => void poll(), 5000); + timer = setTimeout( + () => void poll(), + nextPollingDelay(pendingInstall.deadlineAt, 5000), + ); } else { + setMessageTone('error'); setMessage(t('extensions.manage.operationFailed')); clearInteraction(pendingInstall.operationId); setPendingInstall(null); @@ -615,6 +719,7 @@ export function ExtensionsManagerPage({ return; } if (operation.status === 'failed') { + setMessageTone('error'); setMessage( t('extensions.install.failed', { source: pendingInstall.source, @@ -627,37 +732,72 @@ export function ExtensionsManagerPage({ } if ( operation.status === 'succeeded' || - operation.status === 'succeeded_with_refresh_error' + operation.status === 'succeeded_with_refresh_error' || + operation.status === 'succeeded_with_warnings' ) { - setMessage( - operation.status === 'succeeded_with_refresh_error' - ? t('extensions.manage.refreshFailed', { - error: operation.result?.error ?? '', + const warning = + operation.warnings?.map((item) => item.error).join('\n') || + operation.result?.error; + const resultMessage = + operation.status === 'succeeded_with_warnings' + ? t('extensions.manage.completedWithWarnings', { + warning: warning ?? '', }) - : t('extensions.install.installed', { - name: operation.result?.name ?? pendingInstall.source, - }), + : operation.status === 'succeeded_with_refresh_error' + ? t('extensions.manage.refreshFailed', { + error: warning ?? '', + }) + : t('extensions.install.installed', { + name: operation.result?.name ?? pendingInstall.source, + }); + setMessageTone( + operation.status === 'succeeded_with_refresh_error' || + operation.result?.activation === 'partial' + ? 'error' + : operation.status === 'succeeded' && + operation.result?.activation !== 'deferred' + ? 'success' + : 'info', + ); + setMessage( + withActivationMessage( + resultMessage, + operation.result?.activation, + t, + ), ); clearInteraction(pendingInstall.operationId); setPendingInstall(null); void load(true); return; } + setMessageTone('progress'); setMessage( - t('extensions.install.started', { - source: pendingInstall.source, - }), + operationProgressMessage(operation, pendingInstall.source, t) ?? + t('extensions.install.started', { + source: pendingInstall.source, + }), + ); + timer = setTimeout( + () => void poll(), + nextPollingDelay(pendingInstall.deadlineAt, 1000), ); - timer = setTimeout(() => void poll(), 1000); } catch (error) { if (cancelled) return; + setMessageTone('error'); setMessage(error instanceof Error ? error.message : String(error)); - if (error instanceof DaemonHttpError && error.status === 404) { + if ( + Date.now() >= pendingInstall.deadlineAt || + (error instanceof DaemonHttpError && error.status === 404) + ) { clearInteraction(pendingInstall.operationId); setPendingInstall(null); return; } - timer = setTimeout(() => void poll(), retryDelay); + timer = setTimeout( + () => void poll(), + nextPollingDelay(pendingInstall.deadlineAt, retryDelay), + ); retryDelay = Math.min(retryDelay * 2, 30_000); } }; @@ -686,20 +826,20 @@ export function ExtensionsManagerPage({ pendingInteraction.operationId, pendingInteraction.interaction.id, response, - connection.clientId, ) .then(() => { clearInteraction(pendingInteraction.operationId); restartPolling(); }) .catch((error: unknown) => { + setMessageTone('error'); setMessage(error instanceof Error ? error.message : String(error)); clearInteraction(pendingInteraction.operationId); restartPolling(); }) .finally(() => setSubmittingInteraction(false)); }, - [actions, clearInteraction, connection.clientId, pendingInteraction], + [actions, clearInteraction, pendingInteraction], ); useEffect(() => { @@ -712,8 +852,10 @@ export function ExtensionsManagerPage({ try { const operation = await actions.extensionOperationStatus( pendingMutation.operationId, + remainingPollingTimeout(pendingMutation.deadlineAt), ); if (cancelled) return; + const operationName = pendingMutation.operation ?? operation.operation; retryDelay = 1000; if (operation.status === 'waiting_for_input') { if (operation.interaction) { @@ -722,8 +864,12 @@ export function ExtensionsManagerPage({ operation.interaction, 'mutation', ); - timer = setTimeout(() => void poll(), 5000); + timer = setTimeout( + () => void poll(), + nextPollingDelay(pendingMutation.deadlineAt, 5000), + ); } else { + setMessageTone('error'); setMessage(t('extensions.manage.operationFailed')); clearInteraction(pendingMutation.operationId); setPendingMutation(null); @@ -737,6 +883,7 @@ export function ExtensionsManagerPage({ return; } if (operation.status === 'failed') { + setMessageTone('error'); setMessage(operation.error ?? t('extensions.manage.operationFailed')); clearInteraction(pendingMutation.operationId); setPendingMutation(null); @@ -750,31 +897,56 @@ export function ExtensionsManagerPage({ } if ( operation.status === 'succeeded' || - operation.status === 'succeeded_with_refresh_error' + operation.status === 'succeeded_with_refresh_error' || + operation.status === 'succeeded_with_warnings' ) { - if (operation.status === 'succeeded_with_refresh_error') { - setMessage( - t('extensions.manage.refreshFailed', { - error: operation.result?.error ?? '', - }), - ); + let resultMessage: string | null; + if (operation.status === 'succeeded_with_warnings') { + resultMessage = t('extensions.manage.completedWithWarnings', { + warning: + operation.warnings?.map((item) => item.error).join('\n') || + operation.result?.error || + '', + }); + } else if (operation.status === 'succeeded_with_refresh_error') { + resultMessage = t('extensions.manage.refreshFailed', { + error: + operation.warnings?.map((item) => item.error).join('\n') || + operation.result?.error || + '', + }); } else if (operation.operation === 'uninstall') { - setMessage(null); + resultMessage = null; } else { - setMessage( - mutationSuccessMessage( - operation.operation, - pendingMutation.name, - t, - ), + resultMessage = mutationSuccessMessage( + operationName, + pendingMutation.name, + t, ); } + setMessageTone( + operation.status === 'succeeded_with_refresh_error' || + operation.result?.activation === 'partial' + ? 'error' + : operation.status === 'succeeded' && + operation.result?.activation !== 'deferred' + ? 'success' + : 'info', + ); + setMessage( + withActivationMessage( + resultMessage, + operation.result?.activation, + t, + ), + ); clearInteraction(pendingMutation.operationId); setPendingMutation(null); setBusyName(null); mutationInFlightRef.current = false; if (operation.operation === 'uninstall') { uninstallInFlightNameRef.current = null; + setMessageOwner(null); setSelectedName(null); } if (operation.operation === 'update') { @@ -787,14 +959,23 @@ export function ExtensionsManagerPage({ void load(true); return; } + setMessageTone('progress'); setMessage( - mutationMessage(operation.operation, pendingMutation.name, t), + operationProgressMessage(operation, pendingMutation.name, t) ?? + mutationMessage(operationName, pendingMutation.name, t), + ); + timer = setTimeout( + () => void poll(), + nextPollingDelay(pendingMutation.deadlineAt, 1000), ); - timer = setTimeout(() => void poll(), 1000); } catch (error) { if (cancelled) return; + setMessageTone('error'); setMessage(error instanceof Error ? error.message : String(error)); - if (error instanceof DaemonHttpError && error.status === 404) { + if ( + Date.now() >= pendingMutation.deadlineAt || + (error instanceof DaemonHttpError && error.status === 404) + ) { clearInteraction(pendingMutation.operationId); setPendingMutation(null); setBusyName(null); @@ -805,7 +986,10 @@ export function ExtensionsManagerPage({ } return; } - timer = setTimeout(() => void poll(), retryDelay); + timer = setTimeout( + () => void poll(), + nextPollingDelay(pendingMutation.deadlineAt, retryDelay), + ); retryDelay = Math.min(retryDelay * 2, 30_000); } }; @@ -818,36 +1002,40 @@ export function ExtensionsManagerPage({ }, [actions, clearInteraction, load, pendingMutation, showInteraction, t]); const refreshList = useCallback(() => { + setMessageOwner(null); + setMessageTone('info'); setMessage(null); - void load(); - }, [load]); + void prepareRuntime(false); + }, [prepareRuntime]); const checkUpdates = useCallback( (name: string) => { setCheckingName(name); + setMessageOwner(selectedName === name ? name : null); + setMessageTone('info'); setMessage(null); setUpdateStates((current) => ({ ...current, [name]: 'checking for updates', })); actions - .checkExtensionUpdates(connection.clientId) + .checkExtensionUpdates() .then((result) => { setUpdateStates(result.states); setMessage(updateLabel(result.states[name], t)); }) .catch((error: unknown) => { setUpdateStates((current) => ({ ...current, [name]: 'error' })); + setMessageTone('error'); setMessage(error instanceof Error ? error.message : String(error)); }) .finally(() => setCheckingName(null)); }, - [actions, connection.clientId, t], + [actions, selectedName, t], ); const installExtension = useCallback(() => { const source = installSource.trim(); - const clientId = connection.clientId; if ( !source || !operationsRecovered || @@ -857,21 +1045,27 @@ export function ExtensionsManagerPage({ ) return; setInstalling(true); + setMessageOwner(null); + setMessageTone('info'); setMessage(null); actions - .installExtension({ source, consent: true }, clientId) + .installExtension({ source, consent: true }) .then((result) => { - setPendingInstall({ operationId: result.operationId, source }); + setPendingInstall({ + operationId: result.operationId, + source, + deadlineAt: result.deadlineAt ?? Date.now() + 21 * 60_000, + }); setInstallSource(''); setInstallOpen(false); }) .catch((error: unknown) => { + setMessageTone('error'); setMessage(error instanceof Error ? error.message : String(error)); }) .finally(() => setInstalling(false)); }, [ actions, - connection.clientId, installSource, operationsRecovered, pendingInstall, @@ -881,10 +1075,9 @@ export function ExtensionsManagerPage({ const runMutation = useCallback( ( name: string, - run: (clientId?: string) => Promise, + run: () => Promise, options: { operation?: string; startMessage?: string } = {}, ): boolean => { - const clientId = connection.clientId; if ( !operationsRecovered || pendingInstall || @@ -899,9 +1092,11 @@ export function ExtensionsManagerPage({ uninstallInFlightNameRef.current = name; } setBusyName(name); + setMessageOwner(selectedName === name ? name : null); + setMessageTone('progress'); setMessage(options.startMessage ?? null); let startedPolling = false; - run(clientId) + run() .then((result) => { const operationId = result && @@ -916,12 +1111,14 @@ export function ExtensionsManagerPage({ operationId, name, operation: options.operation, + deadlineAt: result.deadlineAt ?? Date.now() + 21 * 60_000, }); return; } setMessage(t('extensions.manage.queued', { name })); }) .catch((error: unknown) => { + setMessageTone('error'); setMessage(error instanceof Error ? error.message : String(error)); }) .finally(() => { @@ -937,12 +1134,12 @@ export function ExtensionsManagerPage({ return true; }, [ - connection.clientId, checkingName, load, operationsRecovered, pendingInstall, pendingMutation, + selectedName, t, ], ); @@ -1037,29 +1234,36 @@ export function ExtensionsManagerPage({ busyName !== null || pendingMutation !== null; const checking = checkingName === selectedExtension.name; - const mutation: Mutation = selectedExtension.isActive - ? 'disable' - : 'enable'; - const toggleScope = (scope: Scope) => + const setScopeActivation = ( + scope: Scope, + activation: 'inherit' | 'enabled' | 'disabled', + ) => { + if (scope === 'user' && activation === 'inherit') return; + const mutation: ToggleMutation = + activation === 'enabled' + ? 'enable' + : activation === 'disabled' + ? 'disable' + : 'inherit'; runMutation( selectedExtension.name, - (clientId) => - mutation === 'enable' - ? actions.enableExtension( - selectedExtension.name, - { scope }, - clientId, - ) - : actions.disableExtension( - selectedExtension.name, - { scope }, - clientId, - ), + () => + actions.setExtensionActivation( + selectedExtension.id, + scope === 'user' + ? { scope, state: activation as 'enabled' | 'disabled' } + : { scope, state: activation }, + ), { operation: mutation, startMessage: mutationMessage(mutation, selectedExtension.name, t), }, ); + }; + const userActivation = selectedExtension.defaultActivation; + const workspaceActivation = selectedExtension.workspaceActivation; + const activationUnavailable = + userActivation === undefined || workspaceActivation === undefined; const commands = details?.commands ?? []; const skills = details?.skills ?? []; const agents = details?.agents ?? []; @@ -1083,7 +1287,7 @@ export function ExtensionsManagerPage({ runMutation( selectedExtension.name, - (clientId) => - actions.updateExtension( - selectedExtension.name, - clientId, - ), + () => actions.updateExtension(selectedExtension.name), { operation: 'update', startMessage: mutationMessage( @@ -1136,24 +1336,6 @@ export function ExtensionsManagerPage({ > {t('extensions.manage.update')} - toggleScope('user')} - > - {mutation === 'enable' - ? t('extensions.manage.enable') - : t('extensions.manage.disable')} - · {t('settings.scope.user')} - - toggleScope('workspace')} - > - {mutation === 'enable' - ? t('extensions.manage.enable') - : t('extensions.manage.disable')} - · {t('settings.scope.workspace')} - @@ -1173,15 +1355,101 @@ export function ExtensionsManagerPage({
- {message || recoveryError ? ( - + {messageOwner === selectedExtension.name && message ? ( + setMessage(null)} + className="break-words" + > + {message} + + ) : null} + + {activationUnavailable ? ( + - - {message ?? recoveryError} + + {t('extensions.manage.setting.unavailableDescription')} ) : null} + + +
+
+

+ {t('extensions.manage.userSetting')} +

+

+ {t('extensions.manage.userSettingDescription')} +

+
+ +
+ +
+
+

+ {t('extensions.manage.workspaceSetting')} +

+

+ {t('extensions.manage.workspaceSettingDescription')} +

+
+ +
+
+
+ @@ -1320,8 +1588,7 @@ export function ExtensionsManagerPage({ if ( runMutation( uninstallName, - (clientId) => - actions.uninstallExtension(uninstallName, clientId), + () => actions.uninstallExtension(uninstallName), { operation: 'uninstall', startMessage: mutationMessage( @@ -1394,13 +1661,21 @@ export function ExtensionsManagerPage({ - {message || recoveryError ? ( - - - - {message ?? recoveryError} - - + {(messageOwner === null && message) || recoveryError ? ( + { + setMessage(null); + setRecoveryError(null); + }} + className="break-words" + > + {(messageOwner === null ? message : null) ?? recoveryError} + ) : null}
@@ -1458,7 +1733,7 @@ export function ExtensionsManagerPage({ ( + extensions: readonly T[], query: string, -): DaemonExtensionEntry[] { +): T[] { const normalized = query.trim().toLowerCase(); if (!normalized) return [...extensions]; return extensions.filter((extension) => diff --git a/packages/webui/src/daemon/workspace/actions.ts b/packages/webui/src/daemon/workspace/actions.ts index 9c098648592..da57b64baae 100644 --- a/packages/webui/src/daemon/workspace/actions.ts +++ b/packages/webui/src/daemon/workspace/actions.ts @@ -11,6 +11,7 @@ import type { DaemonWorkspaceRuntimeStatus, WorkspaceDaemonClient, } from '@qwen-code/sdk/daemon'; +import { DaemonHttpError } from '@qwen-code/sdk/daemon'; import { withActionTimeout } from '../timing.js'; import type { DaemonDirectoryListing, @@ -41,6 +42,17 @@ export function createDaemonWorkspaceActions({ baseUrl, token, }: CreateDaemonWorkspaceActionsArgs): DaemonWorkspaceActions { + type ExtensionOperationRoute = 'primary' | 'workspace' | 'v2'; + const extensionOperationRoutes = new Map(); + + const rememberExtensionOperation = ( + result: T, + route: ExtensionOperationRoute, + ): T => { + extensionOperationRoutes.set(result.operationId, route); + return result; + }; + return { async listSessions(options) { const client = requireClient(getClient, 'List sessions failed'); @@ -417,9 +429,13 @@ export function createDaemonWorkspaceActions({ }, async loadExtensionsStatus() { - const client = requireClient(getClient, 'Load extensions failed'); + const client = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Load extensions failed', + ); return withActionTimeout( - client.workspaceExtensions(), + client.workspaceConfigExtensions(), 'Load extensions timed out', ); }, @@ -809,22 +825,61 @@ export function createDaemonWorkspaceActions({ ); }, - async installExtension(params, clientId) { + async installExtension(params) { const client = requireClient(getClient, 'Install extension failed'); return withActionTimeout( - client.installExtension(params, clientId), + client + .installWorkspaceConfigExtension(params) + .then((result) => rememberExtensionOperation(result, 'primary')), 'Install extension timed out', ); }, - async extensionOperationStatus(operationId) { + async extensionOperationStatus(operationId, timeoutMs) { const client = requireClient( getClient, 'Load extension operation failed', ); - return withActionTimeout( - client.extensionOperationStatus(operationId), + const workspaceClient = client.workspaceByCwd( + requireWorkspaceCwd(getWorkspaceCwd), + ); + const route = extensionOperationRoutes.get(operationId); + const load = (target: ExtensionOperationRoute) => + target === 'v2' + ? client.extensionOperation(operationId) + : target === 'primary' + ? client.workspaceConfigExtensionOperationStatus( + operationId, + timeoutMs, + ) + : workspaceClient.workspaceConfigExtensionOperationStatus( + operationId, + timeoutMs, + ); + let resolvedRoute = route; + const operation = route + ? load(route) + : load('workspace') + .then((result) => { + resolvedRoute = 'workspace'; + return result; + }) + .catch((error: unknown) => { + if (!(error instanceof DaemonHttpError) || error.status !== 404) { + throw error; + } + resolvedRoute = 'primary'; + return load('primary'); + }); + return withActionTimeout( + operation.then((result) => { + if (resolvedRoute) { + extensionOperationRoutes.set(operationId, resolvedRoute); + } + return result; + }), 'Load extension operation timed out', + timeoutMs, ); }, @@ -833,77 +888,144 @@ export function createDaemonWorkspaceActions({ getClient, 'Load active extension operations failed', ); + const workspaceClient = client.workspaceByCwd( + requireWorkspaceCwd(getWorkspaceCwd), + ); return withActionTimeout( - client.activeExtensionOperations(), + Promise.all([ + client.activeWorkspaceConfigExtensionOperations(), + workspaceClient.activeWorkspaceConfigExtensionOperations(), + ]).then(([primary, workspace]) => { + for (const operation of primary.operations) { + extensionOperationRoutes.set(operation.operationId, 'primary'); + } + for (const operation of workspace.operations) { + extensionOperationRoutes.set(operation.operationId, 'workspace'); + } + return { + v: 1 as const, + operations: [...primary.operations, ...workspace.operations], + }; + }), 'Load active extension operations timed out', ); }, - async respondToExtensionInteraction( - operationId, - interactionId, - response, - clientId, - ) { + async respondToExtensionInteraction(operationId, interactionId, response) { const client = requireClient( getClient, 'Respond to extension interaction failed', ); + const workspaceClient = client.workspaceByCwd( + requireWorkspaceCwd(getWorkspaceCwd), + ); + const route = extensionOperationRoutes.get(operationId) ?? 'primary'; return withActionTimeout( - client.respondToExtensionInteraction( - operationId, - interactionId, - response, - clientId, - ), + route === 'workspace' + ? workspaceClient.respondToWorkspaceConfigExtensionInteraction( + operationId, + interactionId, + response, + ) + : client.respondToWorkspaceConfigExtensionInteraction( + operationId, + interactionId, + response, + ), 'Respond to extension interaction timed out', ); }, - async checkExtensionUpdates(clientId) { + async checkExtensionUpdates() { const client = requireClient(getClient, 'Check extension updates failed'); return withActionTimeout( - client.checkExtensionUpdates(clientId), + client.checkWorkspaceConfigExtensionUpdates(), 'Check extension updates timed out', ); }, - async refreshExtensions(clientId) { - const client = requireClient(getClient, 'Refresh extensions failed'); + async refreshExtensions() { + const client = requireWorkspaceClient( + getClient, + getWorkspaceCwd, + 'Refresh extensions failed', + ); return withActionTimeout( - client.refreshExtensions(clientId), + ensureRuntimeCapability(client, 'extensions'), 'Refresh extensions timed out', + WORKSPACE_RUNTIME_ACTION_TIMEOUT_MS, ); }, - async enableExtension(name, params, clientId) { + async setExtensionActivation(extensionId, params) { + const client = requireClient( + getClient, + 'Set extension activation failed', + ); + const operation = + params.scope === 'user' + ? client.setExtensionDefaultActivation(extensionId, params.state) + : params.state === 'inherit' + ? client + .workspaceByCwd(requireWorkspaceCwd(getWorkspaceCwd)) + .clearExtensionActivation(extensionId) + : client + .workspaceByCwd(requireWorkspaceCwd(getWorkspaceCwd)) + .setExtensionActivation(extensionId, params.state); + return withActionTimeout( + operation.then((result) => rememberExtensionOperation(result, 'v2')), + 'Set extension activation timed out', + ); + }, + + async enableExtension(name, params) { const client = requireClient(getClient, 'Enable extension failed'); + const route: ExtensionOperationRoute = + params.scope === 'user' ? 'primary' : 'workspace'; + const operation = + route === 'primary' + ? client.enableWorkspaceConfigExtension(name, { scope: 'user' }) + : client + .workspaceByCwd(requireWorkspaceCwd(getWorkspaceCwd)) + .enableWorkspaceConfigExtension(name, { scope: 'workspace' }); return withActionTimeout( - client.enableExtension(name, params, clientId), + operation.then((result) => rememberExtensionOperation(result, route)), 'Enable extension timed out', ); }, - async disableExtension(name, params, clientId) { + async disableExtension(name, params) { const client = requireClient(getClient, 'Disable extension failed'); + const route: ExtensionOperationRoute = + params.scope === 'user' ? 'primary' : 'workspace'; + const operation = + route === 'primary' + ? client.disableWorkspaceConfigExtension(name, { scope: 'user' }) + : client + .workspaceByCwd(requireWorkspaceCwd(getWorkspaceCwd)) + .disableWorkspaceConfigExtension(name, { scope: 'workspace' }); return withActionTimeout( - client.disableExtension(name, params, clientId), + operation.then((result) => rememberExtensionOperation(result, route)), 'Disable extension timed out', ); }, - async updateExtension(name, clientId) { + async updateExtension(name) { const client = requireClient(getClient, 'Update extension failed'); return withActionTimeout( - client.updateExtension(name, clientId), + client + .updateWorkspaceConfigExtension(name) + .then((result) => rememberExtensionOperation(result, 'primary')), 'Update extension timed out', ); }, - async uninstallExtension(name, clientId) { + async uninstallExtension(name) { const client = requireClient(getClient, 'Uninstall extension failed'); return withActionTimeout( - client.uninstallExtension(name, clientId), + client + .uninstallWorkspaceConfigExtension(name) + .then((result) => rememberExtensionOperation(result, 'primary')), 'Uninstall extension timed out', ); }, diff --git a/packages/webui/src/daemon/workspace/types.ts b/packages/webui/src/daemon/workspace/types.ts index c087ba18c9b..d0c65162448 100644 --- a/packages/webui/src/daemon/workspace/types.ts +++ b/packages/webui/src/daemon/workspace/types.ts @@ -23,7 +23,6 @@ import type { ExtensionInteractionResponseResult, ExtensionOperationStatus, ExtensionActiveOperations, - ExtensionRefreshResponse, ExtensionScopeRequest, ExtensionInstallRequest, ExtensionInstallResponse, @@ -409,7 +408,7 @@ export interface DaemonWorkspaceActions { ): Promise; // Extensions - loadExtensionsStatus(): Promise; + loadExtensionsStatus(): Promise; // Tools loadToolsStatus(): Promise; @@ -521,7 +520,17 @@ export interface DaemonWorkspaceActions { checkExtensionUpdates( clientId?: string, ): Promise; - refreshExtensions(clientId?: string): Promise; + refreshExtensions(clientId?: string): Promise; + setExtensionActivation( + extensionId: string, + params: + | { scope: 'user'; state: 'enabled' | 'disabled' } + | { + scope: 'workspace'; + state: 'inherit' | 'enabled' | 'disabled'; + }, + clientId?: string, + ): Promise; enableExtension( name: string, params: ExtensionScopeRequest,