diff --git a/docs/agent-skill-sharing-implementation-checklist.md b/docs/agent-skill-sharing-implementation-checklist.md index b5c3534193a..32c5768847c 100644 --- a/docs/agent-skill-sharing-implementation-checklist.md +++ b/docs/agent-skill-sharing-implementation-checklist.md @@ -881,8 +881,10 @@ does not mean the surrounding phase is complete. global or workspace scope. - [x] Preview new, unchanged, update, and conflict counts and resolve all per-skill conflicts in one surface with **Keep local** as the default. -- [ ] Label the primary action **Install N skills** and show aggregate progress plus current skill. - The label and aggregate phases are implemented; current-skill progress is not yet emitted. +- [x] Label the primary action **Install N skills** and show aggregate progress plus current skill. + The destination-owned installer emits the current name and one-based bundle position. Local + installs forward it directly; paired and SSH hosts expose a capability-gated read-only + progress method, with phase-only fallback for mixed-version older hosts. - [x] Group results into installed, unchanged, kept local, and failed; retry failed items only. ### Skills page lifecycle diff --git a/src/main/ipc/skill-cloud-ipc-handlers.ts b/src/main/ipc/skill-cloud-ipc-handlers.ts index c8bbed66f64..40fc374da6c 100644 --- a/src/main/ipc/skill-cloud-ipc-handlers.ts +++ b/src/main/ipc/skill-cloud-ipc-handlers.ts @@ -3,9 +3,9 @@ import { join } from 'node:path' import { app, BrowserWindow, ipcMain } from 'electron' import { z } from 'zod' import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability' -import type { SkillInstallProgress } from '../../shared/skill-sharing-contract' import { SkillDiscoveryTargetSchema, type SkillDiscoveryResult } from '../../shared/skills' import type { SkillCloudDownloadGrant } from '../../shared/skill-cloud-contract' +import type { SkillBundleInstallProgress } from '../../shared/skill-bundle-install-contract' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { installSkillBundleCloudGrant, @@ -21,6 +21,7 @@ import { } from '../skills/skill-runtime-capability' import { callRuntimeEnvironment } from './runtime-environment-transport-routing' import { registerSkillInstallManagementIpcHandlers } from './skill-install-management-ipc-handlers' +import { sendBundleInstallProgress, sendSkillInstallProgress } from './skill-install-progress-ipc' import { skillCloudBundlePackageVersionInstallSchema, skillCloudBundleShareInstallSchema, @@ -123,18 +124,31 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { grant: SkillCloudDownloadGrant, input: | z.infer - | z.infer + | z.infer, + onProgress: (progress: SkillBundleInstallProgress) => void ) => { if (!input.environmentId || input.environmentId.startsWith('ssh:')) { - return installSkillBundleCloudGrant(runtime, grant, { - ...input, - operationId: input.operationId ?? randomUUID() - }) + return installSkillBundleCloudGrant( + runtime, + grant, + { + ...input, + operationId: input.operationId ?? randomUUID() + }, + undefined, + onProgress + ) } const operationId = input.operationId ?? randomUUID() const signal = remoteInstallCancellation.begin(operationId) try { - return await installSkillBundleCloudGrant(runtime, grant, { ...input, operationId }, signal) + return await installSkillBundleCloudGrant( + runtime, + grant, + { ...input, operationId }, + signal, + onProgress + ) } finally { remoteInstallCancellation.finish(operationId, signal) } @@ -145,18 +159,10 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle('skills:createDownloadGrant', (_event, shareId: unknown) => runtime.createSkillDownloadGrant(z.string().min(1).max(128).parse(shareId), {}) ) - const sendProgress = ( - event: Electron.IpcMainInvokeEvent, - progress: SkillInstallProgress - ): void => { - if (!event.sender.isDestroyed()) { - event.sender.send('skills:installProgress', progress) - } - } ipcMain.handle('skills:installShare', async (event, value: unknown) => { const parsed = skillCloudShareInstallSchema.parse(value) const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } - sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'authorizing' }) if ( input.environmentId && !(await supportsSkillRuntimeInstall(app.getPath('userData'), input.environmentId)) @@ -169,14 +175,14 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { installTarget }) if (grant.status === 'ok') { - sendProgress(event, { operationId: input.operationId, phase: 'installing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' }) } return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant }) ipcMain.handle('skills:installBundleShare', async (event, value: unknown) => { const parsed = skillCloudBundleShareInstallSchema.parse(value) const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } - sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'authorizing' }) if ( input.environmentId && !input.environmentId.startsWith('ssh:') && @@ -190,14 +196,18 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { installTarget }) if (grant.status === 'ok') { - sendProgress(event, { operationId: input.operationId, phase: 'installing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' }) } - return grant.status === 'ok' ? installAuthorizedBundleGrant(grant.value, input) : grant + return grant.status === 'ok' + ? installAuthorizedBundleGrant(grant.value, input, (progress) => + sendBundleInstallProgress(event, progress) + ) + : grant }) ipcMain.handle('skills:installPackageVersion', async (event, value: unknown) => { const parsed = skillCloudPackageVersionInstallSchema.parse(value) const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } - sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'authorizing' }) if ( input.environmentId && !(await supportsSkillRuntimeInstall(app.getPath('userData'), input.environmentId)) @@ -211,14 +221,14 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { { installTarget } ) if (grant.status === 'ok') { - sendProgress(event, { operationId: input.operationId, phase: 'installing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' }) } return grant.status === 'ok' ? installAuthorizedGrant(grant.value, input) : grant }) ipcMain.handle('skills:installBundlePackageVersion', async (event, value: unknown) => { const parsed = skillCloudBundlePackageVersionInstallSchema.parse(value) const input = { ...parsed, operationId: parsed.operationId ?? randomUUID() } - sendProgress(event, { operationId: input.operationId, phase: 'authorizing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'authorizing' }) if ( input.environmentId && !input.environmentId.startsWith('ssh:') && @@ -233,9 +243,13 @@ function registerCloudInstallHandlers(runtime: OrcaRuntimeService): void { { installTarget } ) if (grant.status === 'ok') { - sendProgress(event, { operationId: input.operationId, phase: 'installing' }) + sendSkillInstallProgress(event, { operationId: input.operationId, phase: 'installing' }) } - return grant.status === 'ok' ? installAuthorizedBundleGrant(grant.value, input) : grant + return grant.status === 'ok' + ? installAuthorizedBundleGrant(grant.value, input, (progress) => + sendBundleInstallProgress(event, progress) + ) + : grant }) ipcMain.handle('skills:cancelInstall', async (_event, value: unknown) => { const input = z diff --git a/src/main/ipc/skill-install-progress-ipc.test.ts b/src/main/ipc/skill-install-progress-ipc.test.ts new file mode 100644 index 00000000000..f9ac04a5e11 --- /dev/null +++ b/src/main/ipc/skill-install-progress-ipc.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' +import type { IpcMainInvokeEvent } from 'electron' +import { sendBundleInstallProgress, sendSkillInstallProgress } from './skill-install-progress-ipc' + +function event(destroyed = false): IpcMainInvokeEvent { + return { + sender: { isDestroyed: () => destroyed, send: vi.fn() } + } as unknown as IpcMainInvokeEvent +} + +describe('skill install progress IPC', () => { + it('projects destination-owned bundle progress without paths or grants', () => { + const target = event() + sendBundleInstallProgress(target, { + operationId: 'operation_1', + skillId: 'alpha', + skillName: 'alpha', + skillIndex: 2, + skillCount: 30 + }) + + expect(target.sender.send).toHaveBeenCalledWith('skills:installProgress', { + operationId: 'operation_1', + phase: 'installing', + currentSkill: { id: 'alpha', name: 'alpha', index: 2, total: 30 } + }) + }) + + it('does not publish after the invoking renderer is destroyed', () => { + const target = event(true) + sendSkillInstallProgress(target, { operationId: 'operation_1', phase: 'authorizing' }) + expect(target.sender.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/skill-install-progress-ipc.ts b/src/main/ipc/skill-install-progress-ipc.ts new file mode 100644 index 00000000000..cba25217f59 --- /dev/null +++ b/src/main/ipc/skill-install-progress-ipc.ts @@ -0,0 +1,28 @@ +import type { IpcMainInvokeEvent } from 'electron' +import type { SkillBundleInstallProgress } from '../../shared/skill-bundle-install-contract' +import type { SkillInstallProgress } from '../../shared/skill-sharing-contract' + +export function sendSkillInstallProgress( + event: IpcMainInvokeEvent, + progress: SkillInstallProgress +): void { + if (!event.sender.isDestroyed()) { + event.sender.send('skills:installProgress', progress) + } +} + +export function sendBundleInstallProgress( + event: IpcMainInvokeEvent, + progress: SkillBundleInstallProgress +): void { + sendSkillInstallProgress(event, { + operationId: progress.operationId, + phase: 'installing', + currentSkill: { + id: progress.skillId, + name: progress.skillName, + index: progress.skillIndex, + total: progress.skillCount + } + }) +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 67650b0ba98..dc2aec76b9f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -153,6 +153,7 @@ import type { SkillRemoveRequest } from '../../shared/skill-install-contract' import type { + SkillBundleInstallProgress, SkillBundleInstallRequest, SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract' @@ -3298,6 +3299,7 @@ export class OrcaRuntimeService { private skillCloudService: SkillCloudService | null = null private skillUploadSessions: SkillUploadSessionService | null = null private readonly skillInstallOperations = new Map() + private readonly skillInstallProgress = new Map() private readonly claudeAgentTeams = new ClaudeAgentTeamsService() private mobileDictation: { id: string @@ -4692,7 +4694,8 @@ export class OrcaRuntimeService { async installSharedSkillBundleRequest( request: SkillBundleInstallRequest, - signal?: AbortSignal + signal?: AbortSignal, + onProgress?: (progress: SkillBundleInstallProgress) => void ): Promise { if (this.skillInstallOperations.has(request.operationId)) { throw new Error('skill-install-operation-in-progress') @@ -4705,6 +4708,14 @@ export class OrcaRuntimeService { signal?.addEventListener('abort', abort, { once: true }) } this.skillInstallOperations.set(request.operationId, controller) + const reportProgress = (progress: SkillBundleInstallProgress): void => { + this.skillInstallProgress.set(request.operationId, progress) + try { + onProgress?.(progress) + } catch { + // Why: renderer teardown must not change the host-owned install outcome. + } + } try { const runtimeId = this.getStatus().runtimeId const sshTarget = await this.resolveSkillSshTarget(request.destination) @@ -4721,7 +4732,8 @@ export class OrcaRuntimeService { }, workspace: sshTarget.workspace, requireHttps: app.isPackaged, - signal: controller.signal + signal: controller.signal, + onProgress: reportProgress }) } const allowedDownloadOrigins = ['https://storage.googleapis.com'] @@ -4740,16 +4752,22 @@ export class OrcaRuntimeService { resolveStagedUpload: (uploadId, identity) => this.requireSkillUploadSessions().take(uploadId, identity), detectProviders: detectInstalledAgentsWithShellPathHydration, - signal: controller.signal + signal: controller.signal, + onProgress: reportProgress }) } finally { signal?.removeEventListener('abort', abort) if (this.skillInstallOperations.get(request.operationId) === controller) { this.skillInstallOperations.delete(request.operationId) } + this.skillInstallProgress.delete(request.operationId) } } + getSharedSkillInstallProgress(operationId: string): SkillBundleInstallProgress | null { + return this.skillInstallProgress.get(operationId) ?? null + } + cancelSharedSkillInstall(operationId: string): boolean { const operation = this.skillInstallOperations.get(operationId) operation?.abort() diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts index e7dadbe9c07..a9197663e9f 100644 --- a/src/main/runtime/rpc/methods/skills.test.ts +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -190,6 +190,22 @@ describe('skills.install RPC', () => { expect(result).toEqual({ cancelled: true }) expect(cancelSharedSkillInstall).toHaveBeenCalledWith('operation_1') }) + + it('returns active bundle progress without requiring it from older clients', async () => { + const progress = { + operationId: 'operation_1', + skillId: 'alpha', + skillName: 'alpha', + skillIndex: 1, + skillCount: 2 + } + const getSharedSkillInstallProgress = vi.fn(() => progress) + expect( + method('skills.getInstallProgress').handler({ operationId: 'operation_1' }, { + runtime: { getSharedSkillInstallProgress } + } as unknown as RpcContext) + ).toEqual(progress) + }) }) describe('skill management RPC', () => { diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index e4427205fbc..01cedf0a24a 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -6,7 +6,10 @@ import { SkillInstallRequestSchema, SkillRemoveRequestSchema } from '../../../../shared/skill-install-contract' -import { SkillBundleInstallRequestSchema } from '../../../../shared/skill-bundle-install-contract' +import { + SkillBundleInstallProgressSchema, + SkillBundleInstallRequestSchema +} from '../../../../shared/skill-bundle-install-contract' import { SkillUploadBeginRequestSchema, SkillUploadChunkRequestSchema, @@ -67,6 +70,14 @@ export const SKILL_METHODS: RpcMethod[] = [ cancelled: runtime.cancelSharedSkillInstall(params.operationId) }) }), + defineMethod({ + name: 'skills.getInstallProgress', + params: z.object({ operationId: z.string().min(1).max(128) }).strict(), + handler: (params, { runtime }) => { + const progress = runtime.getSharedSkillInstallProgress(params.operationId) + return progress ? SkillBundleInstallProgressSchema.parse(progress) : null + } + }), defineMethod({ name: 'skills.previewInstall', params: SkillInstallPreviewRequestSchema, diff --git a/src/main/skills/skill-bundle-install-request-service.ts b/src/main/skills/skill-bundle-install-request-service.ts index 23996080c0e..d40e897886e 100644 --- a/src/main/skills/skill-bundle-install-request-service.ts +++ b/src/main/skills/skill-bundle-install-request-service.ts @@ -1,5 +1,6 @@ import { isAbsolute, join } from 'node:path' import { + type SkillBundleInstallProgress, SkillBundleInstallRequestSchema, type SkillBundleInstallRequest, type SkillBundleInstallResult, @@ -21,6 +22,7 @@ export type SkillBundleInstallRequestDependencies = { requireHttps: boolean allowTrustedLocalFile?: boolean signal?: AbortSignal + onProgress?: (progress: SkillBundleInstallProgress) => void fetcher?: typeof fetch detectProviders: () => Promise resolveStagedUpload?: ( @@ -99,7 +101,8 @@ export async function executeSkillBundleInstallRequest( expectedArchiveSha256: request.package.archiveSha256, filesystem, wslDistro: destination.wslDistro, - signal: dependencies.signal + signal: dependencies.signal, + onProgress: dependencies.onProgress }) } finally { await ingress.cleanup() diff --git a/src/main/skills/skill-bundle-install-service.test.ts b/src/main/skills/skill-bundle-install-service.test.ts index 809019a9056..11c5dd82039 100644 --- a/src/main/skills/skill-bundle-install-service.test.ts +++ b/src/main/skills/skill-bundle-install-service.test.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createSkillBundleArchive } from './skill-bundle-creation' import { installSkillBundle } from './skill-bundle-install-service' import { listManagedSkillInstalls } from './skill-install-provenance' @@ -51,6 +51,11 @@ describe('skill bundle installation', () => { createdAt: '2026-08-11T12:00:00.000Z' }) + const onProgress = vi.fn((progress: { skillIndex: number }) => { + if (progress.skillIndex === 1) { + throw new Error('renderer closed') + } + }) const result = await installSkillBundle({ operationId: 'operation_1', archivePath: bundle.archivePath, @@ -64,7 +69,8 @@ describe('skill bundle installation', () => { orcaStateDirectory: join(root, 'state'), detectedProviders: [], destinationIdentity: 'local-global', - hostIdentity: 'host_1' + hostIdentity: 'host_1', + onProgress }) expect(result.status).toBe('partial') @@ -72,6 +78,22 @@ describe('skill bundle installation', () => { ['alpha-skill', 'installed'], ['beta-skill', 'kept-local'] ]) + expect(onProgress.mock.calls.map(([progress]) => progress)).toEqual([ + { + operationId: 'operation_1', + skillId: 'alpha-skill', + skillName: 'alpha-skill', + skillIndex: 1, + skillCount: 2 + }, + { + operationId: 'operation_1', + skillId: 'beta-skill', + skillName: 'beta-skill', + skillIndex: 2, + skillCount: 2 + } + ]) expect( await readFile(join(homeDirectory, '.agents', 'skills', 'alpha-skill', 'SKILL.md'), 'utf8') ).toContain('# alpha-skill') diff --git a/src/main/skills/skill-bundle-install-service.ts b/src/main/skills/skill-bundle-install-service.ts index 274086ade73..db6d5926bd2 100644 --- a/src/main/skills/skill-bundle-install-service.ts +++ b/src/main/skills/skill-bundle-install-service.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, rm } from 'node:fs/promises' import { join } from 'node:path' import type { + SkillBundleInstallProgress, SkillBundleInstallResult, SkillBundleSkillResult } from '../../shared/skill-bundle-install-contract' @@ -33,6 +34,7 @@ export type SkillBundleInstallServiceInput = Omit< string, 'keep-local' | 'replace-unmodified' | 'replace-and-discard-local' > + onProgress?: (progress: SkillBundleInstallProgress) => void } function skillManifest(input: { @@ -157,7 +159,18 @@ export async function installSkillBundle( throw new Error('skill-bundle-selection-invalid') } const results: SkillBundleSkillResult[] = [] - for (const skill of skills) { + for (const [index, skill] of skills.entries()) { + try { + input.onProgress?.({ + operationId: input.operationId, + skillId: skill.id, + skillName: skill.name, + skillIndex: index + 1, + skillCount: skills.length + }) + } catch { + // Why: progress observers cannot participate in the install transaction. + } if (input.signal?.aborted) { results.push({ skillId: skill.id, diff --git a/src/main/skills/skill-bundle-ssh-relay-service.test.ts b/src/main/skills/skill-bundle-ssh-relay-service.test.ts index b832b44db2d..2b0be4f1cbc 100644 --- a/src/main/skills/skill-bundle-ssh-relay-service.test.ts +++ b/src/main/skills/skill-bundle-ssh-relay-service.test.ts @@ -89,6 +89,48 @@ describe('installSkillBundleOnSshHost', () => { expect(requestHostRpc).toHaveBeenCalledOnce() }) + it('polls current-skill progress only when the SSH host advertises it', async () => { + const bytes = Buffer.from('private bundle archive') + const onProgress = vi.fn() + const progress = { + operationId: 'bundle-operation', + skillId: 'skill-1', + skillName: 'alpha', + skillIndex: 1, + skillCount: 30 + } + const requestHostRpc = vi.fn(async (method: string) => { + if (method === 'relay.status') { + return { + capabilities: ['skills.install.bundle.v1', 'skills.install-progress.v1'] + } + } + if (method === 'skills.getInstallProgress') { + return progress + } + if (method === 'skills.installBundle') { + await new Promise((resolve) => setTimeout(resolve, 0)) + return result() + } + throw new Error(`unexpected method ${method}`) + }) + + await installSkillBundleOnSshHost({ + provider: { requestHostRpc } as unknown as IPtyProvider, + userDataPath: await userDataPath(), + request: request(bytes), + requireHttps: true, + onProgress + }) + + expect(onProgress).toHaveBeenCalledWith(progress) + expect(requestHostRpc.mock.calls.map(([method]) => method)).toEqual([ + 'relay.status', + 'skills.getInstallProgress', + 'skills.installBundle' + ]) + }) + it('falls back to client-mediated transfer after direct download fails', async () => { const bytes = Buffer.from('private bundle archive') const requestHostRpc = vi.fn(async (method: string, params: unknown) => { diff --git a/src/main/skills/skill-bundle-ssh-relay-service.ts b/src/main/skills/skill-bundle-ssh-relay-service.ts index 4e70f53ba20..0fcbb713f15 100644 --- a/src/main/skills/skill-bundle-ssh-relay-service.ts +++ b/src/main/skills/skill-bundle-ssh-relay-service.ts @@ -1,14 +1,18 @@ import { + SkillBundleInstallProgressSchema, SkillBundleInstallResultSchema, + type SkillBundleInstallProgress, type SkillBundleInstallRequest, type SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract' import { SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability' import { SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, + SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, type SkillSshWorkspaceAuthority } from '../../shared/skill-ssh-relay-contract' @@ -22,6 +26,7 @@ import { } from './skill-ssh-relay-client' import { transferSkillPackageToSshHost } from './skill-ssh-package-transfer' import { retrySkillTransferRpc } from './skill-transfer-rpc-retry' +import { startSkillInstallProgressPolling } from './skill-install-progress-polling' export async function installSkillBundleOnSshHost(input: { provider: IPtyProvider @@ -30,6 +35,7 @@ export async function installSkillBundleOnSshHost(input: { workspace?: SkillSshWorkspaceAuthority requireHttps: boolean signal?: AbortSignal + onProgress?: (progress: SkillBundleInstallProgress) => void fetcher?: typeof fetch }): Promise { const client = requireSkillSshRelayClient(input.provider) @@ -37,52 +43,74 @@ export async function installSkillBundleOnSshHost(input: { if (!supported.includes(SKILL_BUNDLE_INSTALL_CAPABILITY)) { throw new Error('skill-bundle-ssh-update-required') } + const stopProgress = + input.onProgress && supported.includes(SKILL_INSTALL_PROGRESS_CAPABILITY) + ? startSkillInstallProgressPolling({ + read: async () => { + const value = await client( + SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, + { operationId: input.request.operationId }, + { timeoutMs: 2_000, signal: input.signal } + ) + if (value === null) { + return null + } + const parsed = SkillBundleInstallProgressSchema.safeParse(value) + return parsed.success ? parsed.data : null + }, + onProgress: input.onProgress + }) + : null try { - return SkillBundleInstallResultSchema.parse( - await retrySkillTransferRpc({ - signal: input.signal, - retryable: retryableSkillSshTransportError, - call: () => - client( - SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, - { request: input.request, workspace: input.workspace }, - { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } - ) - }) - ) - } catch (error) { - if ( - input.request.ingress.kind !== 'download-grant' || - !shouldUseSkillSshClientTransfer(error, input.requireHttps) - ) { - throw error - } - } - if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) { - throw new Error('skill-bundle-ssh-download-unavailable') - } - return retrySkillTransferRpc({ - signal: input.signal, - retryable: retryableSkillSshTransportError, - call: async () => { - const uploadId = await transferSkillPackageToSshHost(client, input) - try { - return SkillBundleInstallResultSchema.parse( - await client( - SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, - { - request: { - ...input.request, - ingress: { kind: 'staged-upload', uploadId } - }, - workspace: input.workspace - }, - { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } - ) - ) - } finally { - await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined) + try { + return SkillBundleInstallResultSchema.parse( + await retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableSkillSshTransportError, + call: () => + client( + SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, + { request: input.request, workspace: input.workspace }, + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } + ) + }) + ) + } catch (error) { + if ( + input.request.ingress.kind !== 'download-grant' || + !shouldUseSkillSshClientTransfer(error, input.requireHttps) + ) { + throw error } } - }) + if (!supported.includes(SKILL_UPLOAD_CAPABILITY)) { + throw new Error('skill-bundle-ssh-download-unavailable') + } + return await retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableSkillSshTransportError, + call: async () => { + const uploadId = await transferSkillPackageToSshHost(client, input) + try { + return SkillBundleInstallResultSchema.parse( + await client( + SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, + { + request: { + ...input.request, + ingress: { kind: 'staged-upload', uploadId } + }, + workspace: input.workspace + }, + { timeoutMs: SKILL_SSH_REQUEST_TIMEOUT_MS, signal: input.signal } + ) + ) + } finally { + await client(SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, { uploadId }).catch(() => undefined) + } + } + }) + } finally { + stopProgress?.() + } } diff --git a/src/main/skills/skill-cloud-grant-installation.ts b/src/main/skills/skill-cloud-grant-installation.ts index c2e9d0988ab..f0a886cd897 100644 --- a/src/main/skills/skill-cloud-grant-installation.ts +++ b/src/main/skills/skill-cloud-grant-installation.ts @@ -5,6 +5,7 @@ import { SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE } from '../../shared/skill-install-capability' import type { + SkillBundleInstallProgress, SkillBundleInstallRequest, SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract' @@ -69,7 +70,8 @@ export async function installSkillBundleCloudGrant( runtime: OrcaRuntimeService, grant: SkillCloudDownloadGrant, input: SkillBundleCloudGrantInstallInput, - signal?: AbortSignal + signal?: AbortSignal, + onProgress?: (progress: SkillBundleInstallProgress) => void ) { const manifest = grant.version.manifest if (!('skills' in manifest)) { @@ -93,7 +95,7 @@ export async function installSkillBundleCloudGrant( if (!input.environmentId) { return { status: 'ok' as const, - value: await runtime.installSharedSkillBundleRequest(request) + value: await runtime.installSharedSkillBundleRequest(request, undefined, onProgress) } } const userDataPath = app.getPath('userData') @@ -112,7 +114,8 @@ export async function installSkillBundleCloudGrant( request, capabilities: status.result.capabilities ?? [], requireHttps: app.isPackaged, - signal + signal, + onProgress }) } } catch (error) { diff --git a/src/main/skills/skill-install-progress-polling.ts b/src/main/skills/skill-install-progress-polling.ts new file mode 100644 index 00000000000..68b8f86fcd2 --- /dev/null +++ b/src/main/skills/skill-install-progress-polling.ts @@ -0,0 +1,40 @@ +import type { SkillBundleInstallProgress } from '../../shared/skill-bundle-install-contract' + +const POLL_INTERVAL_MS = 150 + +function waitForNextPoll(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(finish, POLL_INTERVAL_MS) + function finish(): void { + clearTimeout(timeout) + signal.removeEventListener('abort', finish) + resolve() + } + signal.addEventListener('abort', finish, { once: true }) + }) +} + +export function startSkillInstallProgressPolling(input: { + read: () => Promise + onProgress: (progress: SkillBundleInstallProgress) => void +}): () => void { + const controller = new AbortController() + let lastKey = '' + void (async () => { + while (!controller.signal.aborted) { + const progress = await input.read().catch(() => null) + if (controller.signal.aborted) { + break + } + const key = progress + ? `${progress.skillId}:${progress.skillIndex}:${progress.skillCount}` + : '' + if (progress && key !== lastKey) { + lastKey = key + input.onProgress(progress) + } + await waitForNextPoll(controller.signal) + } + })().catch(() => undefined) + return () => controller.abort() +} diff --git a/src/main/skills/skill-remote-install-service.test.ts b/src/main/skills/skill-remote-install-service.test.ts index 23f7ce0d362..dbe952b685e 100644 --- a/src/main/skills/skill-remote-install-service.test.ts +++ b/src/main/skills/skill-remote-install-service.test.ts @@ -295,6 +295,35 @@ describe('installSkillBundleOnRemoteRuntime', () => { ) }) + it('polls current-skill progress only when the destination advertises it', async () => { + const onProgress = vi.fn() + const progress = { + operationId: bundleRequest.operationId, + skillId: 'skill-1', + skillName: 'alpha', + skillIndex: 1, + skillCount: 30 + } + mocks.callRuntimeEnvironment.mockImplementation(async (_state, _environment, method: string) => + method === 'skills.getInstallProgress' ? success(progress) : success(bundleResult) + ) + + await installSkillBundleOnRemoteRuntime({ + userDataPath: '/state', + environmentId: 'environment-1', + request: bundleRequest, + capabilities: ['skills.install.bundle.v1', 'skills.install-progress.v1'], + requireHttps: true, + onProgress + }) + + expect(onProgress).toHaveBeenCalledWith(progress) + expect(mocks.callRuntimeEnvironment.mock.calls.map((call) => call[2])).toEqual([ + 'skills.getInstallProgress', + 'skills.installBundle' + ]) + }) + it('falls back to staged upload while preserving the bundle selection', async () => { const cleanup = vi.fn(async () => undefined) mocks.callRuntimeEnvironment diff --git a/src/main/skills/skill-remote-install-service.ts b/src/main/skills/skill-remote-install-service.ts index 8ef157f0841..2a329c1dfb0 100644 --- a/src/main/skills/skill-remote-install-service.ts +++ b/src/main/skills/skill-remote-install-service.ts @@ -1,7 +1,9 @@ import type { RuntimeCapability } from '../../shared/protocol-version' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { + SkillBundleInstallProgressSchema, SkillBundleInstallResultSchema, + type SkillBundleInstallProgress, type SkillBundleInstallRequest, type SkillBundleInstallResult } from '../../shared/skill-bundle-install-contract' @@ -12,12 +14,14 @@ import { } from '../../shared/skill-install-contract' import { SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_UPLOAD_CAPABILITY } from '../../shared/skill-install-capability' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import { transferSkillPackageToRuntime } from './skill-client-mediated-transfer' import { SkillInstallFailureSchema } from '../../shared/skill-install-failure' import { SkillInstallOperationError } from './skill-install-operation-error' +import { startSkillInstallProgressPolling } from './skill-install-progress-polling' import { retrySkillTransferRpc } from './skill-transfer-rpc-retry' import { isRecoverableRemoteRuntimeConnectionError, @@ -58,6 +62,25 @@ async function installBundle( )) as RuntimeRpcResponse } +async function readBundleInstallProgress( + userDataPath: string, + environmentId: string, + operationId: string +): Promise { + const response = await callRuntimeEnvironment( + userDataPath, + environmentId, + 'skills.getInstallProgress', + { operationId }, + 2_000 + ) + if (response.ok !== true || response.result === null) { + return null + } + const parsed = SkillBundleInstallProgressSchema.safeParse(response.result) + return parsed.success ? parsed.data : null +} + function isDirectDownloadUnavailable( response: RuntimeRpcResponse, requireHttps: boolean @@ -155,6 +178,7 @@ export async function installSkillBundleOnRemoteRuntime(input: { capabilities: readonly RuntimeCapability[] requireHttps: boolean signal?: AbortSignal + onProgress?: (progress: SkillBundleInstallProgress) => void }): Promise { if ( input.request.ingress.kind !== 'download-grant' || @@ -162,46 +186,62 @@ export async function installSkillBundleOnRemoteRuntime(input: { ) { throw new Error('skill-bundle-remote-ingress-invalid') } - const grant = input.request.ingress - const direct = await retrySkillTransferRpc({ - signal: input.signal, - retryable: retryableRemoteInstallTransportError, - call: () => installBundle(input.userDataPath, input.environmentId, input.request) - }) - if (!isDirectDownloadUnavailable(direct, input.requireHttps)) { - if (direct.ok !== true) { - throw remoteFailure(direct) - } - return SkillBundleInstallResultSchema.parse(direct.result) - } - if (!input.capabilities.includes(SKILL_UPLOAD_CAPABILITY)) { - throw new Error('skill-bundle-remote-download-unavailable') - } - return retrySkillTransferRpc({ - signal: input.signal, - retryable: retryableRemoteInstallTransportError, - call: async () => { - const transfer = await transferSkillPackageToRuntime({ - userDataPath: input.userDataPath, - environmentId: input.environmentId, - transferId: input.request.operationId, - package: input.request.package, - grant, - requireHttps: input.requireHttps, - signal: input.signal - }) - try { - const staged = await installBundle(input.userDataPath, input.environmentId, { - ...input.request, - ingress: { kind: 'staged-upload', uploadId: transfer.uploadId } + const stopProgress = + input.onProgress && input.capabilities.includes(SKILL_INSTALL_PROGRESS_CAPABILITY) + ? startSkillInstallProgressPolling({ + read: () => + readBundleInstallProgress( + input.userDataPath, + input.environmentId, + input.request.operationId + ), + onProgress: input.onProgress }) - if (staged.ok !== true) { - throw remoteFailure(staged) - } - return SkillBundleInstallResultSchema.parse(staged.result) - } finally { - await transfer.cleanup().catch(() => undefined) + : null + try { + const grant = input.request.ingress + const direct = await retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableRemoteInstallTransportError, + call: () => installBundle(input.userDataPath, input.environmentId, input.request) + }) + if (!isDirectDownloadUnavailable(direct, input.requireHttps)) { + if (direct.ok !== true) { + throw remoteFailure(direct) } + return SkillBundleInstallResultSchema.parse(direct.result) } - }) + if (!input.capabilities.includes(SKILL_UPLOAD_CAPABILITY)) { + throw new Error('skill-bundle-remote-download-unavailable') + } + return retrySkillTransferRpc({ + signal: input.signal, + retryable: retryableRemoteInstallTransportError, + call: async () => { + const transfer = await transferSkillPackageToRuntime({ + userDataPath: input.userDataPath, + environmentId: input.environmentId, + transferId: input.request.operationId, + package: input.request.package, + grant, + requireHttps: input.requireHttps, + signal: input.signal + }) + try { + const staged = await installBundle(input.userDataPath, input.environmentId, { + ...input.request, + ingress: { kind: 'staged-upload', uploadId: transfer.uploadId } + }) + if (staged.ok !== true) { + throw remoteFailure(staged) + } + return SkillBundleInstallResultSchema.parse(staged.result) + } finally { + await transfer.cleanup().catch(() => undefined) + } + } + }) + } finally { + stopProgress?.() + } } diff --git a/src/relay/skill-install-handler.test.ts b/src/relay/skill-install-handler.test.ts index 24a810708d9..f5547c3e103 100644 --- a/src/relay/skill-install-handler.test.ts +++ b/src/relay/skill-install-handler.test.ts @@ -96,6 +96,7 @@ describe('SkillInstallHandler', () => { expect(SKILL_RELAY_CAPABILITIES).toEqual([ 'skills.install.v1', 'skills.install.bundle.v1', + 'skills.install-progress.v1', 'skills.upload.v1', 'skills.manage.v1' ]) diff --git a/src/relay/skill-install-handler.ts b/src/relay/skill-install-handler.ts index ffb04ca993d..0a8f32abfd9 100644 --- a/src/relay/skill-install-handler.ts +++ b/src/relay/skill-install-handler.ts @@ -3,21 +3,25 @@ import { join } from 'node:path' import { SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CAPABILITY, + SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, SKILL_UPLOAD_CAPABILITY } from '../shared/skill-install-capability' import type { ManagedSkillInstall } from '../shared/skill-install-contract' +import type { SkillBundleInstallProgress } from '../shared/skill-bundle-install-contract' import { SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD, SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD, SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD, SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, SKILL_SSH_RELAY_INSTALL_METHOD, + SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, SKILL_SSH_RELAY_LIST_METHOD, SKILL_SSH_RELAY_PREVIEW_METHOD, SKILL_SSH_RELAY_REMOVE_METHOD, SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD, SkillSshInstallBundleParamsSchema, + SkillSshInstallProgressParamsSchema, SkillSshInstallParamsSchema, SkillSshListParamsSchema, SkillSshPreviewParamsSchema, @@ -55,6 +59,7 @@ const SSH_SKILL_ENVIRONMENT_ID = 'ssh-host' export const SKILL_RELAY_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, + SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY ] as const @@ -64,6 +69,7 @@ export class SkillInstallHandler { private readonly stateDirectory: string private readonly uploads: SkillUploadSessionService private readonly detectProviders: () => Promise + private readonly installProgress = new Map() constructor( private readonly dispatcher: RelayDispatcher, @@ -99,17 +105,26 @@ export class SkillInstallHandler { }) this.dispatcher.onRequest(SKILL_SSH_RELAY_INSTALL_BUNDLE_METHOD, async (params, context) => { const input = SkillSshInstallBundleParamsSchema.parse(params) - return this.executeSkillOperation(() => - executeSkillBundleInstallRequest(input.request, { - authority: this.authority(input.workspace), - stateDirectory: this.stateDirectory, - allowedDownloadOrigins: ['https://storage.googleapis.com'], - requireHttps: true, - resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity), - detectProviders: this.detectProviders, - signal: context.signal - }) - ) + return this.executeSkillOperation(async () => { + try { + return await executeSkillBundleInstallRequest(input.request, { + authority: this.authority(input.workspace), + stateDirectory: this.stateDirectory, + allowedDownloadOrigins: ['https://storage.googleapis.com'], + requireHttps: true, + resolveStagedUpload: (uploadId, identity) => this.uploads.take(uploadId, identity), + detectProviders: this.detectProviders, + signal: context.signal, + onProgress: (progress) => this.installProgress.set(input.request.operationId, progress) + }) + } finally { + this.installProgress.delete(input.request.operationId) + } + }) + }) + this.dispatcher.onRequest(SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD, async (params) => { + const input = SkillSshInstallProgressParamsSchema.parse(params) + return this.installProgress.get(input.operationId) ?? null }) this.dispatcher.onRequest(SKILL_SSH_RELAY_PREVIEW_METHOD, async (params) => { const input = SkillSshPreviewParamsSchema.parse(params) diff --git a/src/renderer/src/components/skills/skill-install-progress-state.test.tsx b/src/renderer/src/components/skills/skill-install-progress-state.test.tsx new file mode 100644 index 00000000000..c97a7ccd963 --- /dev/null +++ b/src/renderer/src/components/skills/skill-install-progress-state.test.tsx @@ -0,0 +1,40 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SkillInstallProgress } from '../../../../shared/skill-sharing-contract' +import { useSkillInstallProgress } from './skill-install-progress-state' + +afterEach(() => vi.restoreAllMocks()) + +describe('useSkillInstallProgress', () => { + it('announces aggregate bundle progress and the current skill', () => { + let listener: ((progress: SkillInstallProgress) => void) | null = null + Object.defineProperty(window, 'api', { + configurable: true, + value: { + skills: { + onInstallProgress: vi.fn((next) => { + listener = next + return () => { + listener = null + } + }) + } + } + }) + const hook = renderHook(() => useSkillInstallProgress()) + + act(() => hook.result.current.begin('operation_1')) + expect(hook.result.current.phaseLabel).toBe('Authorizing package access…') + + act(() => + listener?.({ + operationId: 'operation_1', + phase: 'installing', + currentSkill: { id: 'beta', name: 'beta', index: 2, total: 30 } + }) + ) + expect(hook.result.current.phaseLabel).toBe('Installing 2 of 30: beta…') + }) +}) diff --git a/src/renderer/src/components/skills/skill-install-progress-state.ts b/src/renderer/src/components/skills/skill-install-progress-state.ts index 7451320a77d..d88c523ea95 100644 --- a/src/renderer/src/components/skills/skill-install-progress-state.ts +++ b/src/renderer/src/components/skills/skill-install-progress-state.ts @@ -1,4 +1,6 @@ import { useEffect, useRef, useState } from 'react' +import type { SkillInstallProgress } from '../../../../shared/skill-sharing-contract' +import { translate } from '@/i18n/i18n' const INSTALL_PHASE_LABELS = { authorizing: 'Authorizing package access…', @@ -13,13 +15,13 @@ export function useSkillInstallProgress(): { } { const [activeOperationId, setActiveOperationId] = useState(null) const activeOperationIdRef = useRef(null) - const [phase, setPhase] = useState(null) + const [progress, setProgress] = useState(null) useEffect( () => window.api.skills.onInstallProgress((progress) => { if (progress.operationId === activeOperationIdRef.current) { - setPhase(progress.phase) + setProgress(progress) } }), [] @@ -27,16 +29,28 @@ export function useSkillInstallProgress(): { return { activeOperationId, - phaseLabel: phase ? INSTALL_PHASE_LABELS[phase] : null, + phaseLabel: progress?.currentSkill + ? translate( + 'auto.components.skills.skill-install-progress-state.currentSkill', + 'Installing {{value0}} of {{value1}}: {{value2}}…', + { + value0: progress.currentSkill.index, + value1: progress.currentSkill.total, + value2: progress.currentSkill.name + } + ) + : progress + ? INSTALL_PHASE_LABELS[progress.phase] + : null, begin: (operationId) => { activeOperationIdRef.current = operationId setActiveOperationId(operationId) - setPhase('authorizing') + setProgress({ operationId, phase: 'authorizing' }) }, finish: () => { activeOperationIdRef.current = null setActiveOperationId(null) - setPhase(null) + setProgress(null) } } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c66af7b8a0e..12d743b763e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4342,6 +4342,9 @@ }, "SkillManagedInstallList": { "86c76cb262": "{{count}} skill bundle" + }, + "skill-install-progress-state": { + "currentSkill": "Installing {{value0}} of {{value1}}: {{value2}}…" } }, "sidebar": { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index dc8f5d2e6b1..3937b3ffa74 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -3,6 +3,7 @@ import { SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_INSTALL_CANCEL_CAPABILITY, + SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY, SKILL_UPLOAD_CAPABILITY @@ -130,6 +131,7 @@ export const RUNTIME_CAPABILITIES = [ SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, SKILL_INSTALL_CANCEL_CAPABILITY, + SKILL_INSTALL_PROGRESS_CAPABILITY, SKILL_INSTALL_RESULT_V2_CAPABILITY, SKILL_UPLOAD_CAPABILITY, SKILL_MANAGEMENT_CAPABILITY diff --git a/src/shared/skill-bundle-install-contract.ts b/src/shared/skill-bundle-install-contract.ts index c95ce09df65..ba8c9118ea9 100644 --- a/src/shared/skill-bundle-install-contract.ts +++ b/src/shared/skill-bundle-install-contract.ts @@ -149,3 +149,15 @@ export type SkillBundleInstallResult = z.infer diff --git a/src/shared/skill-install-capability.ts b/src/shared/skill-install-capability.ts index d76fd5d4f34..0e10478d1d3 100644 --- a/src/shared/skill-install-capability.ts +++ b/src/shared/skill-install-capability.ts @@ -4,6 +4,7 @@ export const SKILL_INSTALL_CANCEL_CAPABILITY = 'skills.install-cancel.v1' as con export const SKILL_UPLOAD_CAPABILITY = 'skills.upload.v1' as const export const SKILL_MANAGEMENT_CAPABILITY = 'skills.manage.v1' as const export const SKILL_BUNDLE_INSTALL_CAPABILITY = 'skills.install.bundle.v1' as const +export const SKILL_INSTALL_PROGRESS_CAPABILITY = 'skills.install-progress.v1' as const export const SKILL_INSTALL_UPDATE_REQUIRED_MESSAGE = 'Update Orca on the selected machine to install shared skills.' diff --git a/src/shared/skill-sharing-contract.ts b/src/shared/skill-sharing-contract.ts index 94bbefbc9ce..7f614708055 100644 --- a/src/shared/skill-sharing-contract.ts +++ b/src/shared/skill-sharing-contract.ts @@ -66,6 +66,12 @@ export type SkillShareProgress = { export type SkillInstallProgress = { operationId: string phase: 'authorizing' | 'installing' + currentSkill?: { + id: string + name: string + index: number + total: number + } } export type SkillShareInstallInput = { diff --git a/src/shared/skill-ssh-relay-contract.ts b/src/shared/skill-ssh-relay-contract.ts index 353f5c9f6b1..1b24b068ec8 100644 --- a/src/shared/skill-ssh-relay-contract.ts +++ b/src/shared/skill-ssh-relay-contract.ts @@ -15,6 +15,7 @@ export const SKILL_SSH_RELAY_BEGIN_UPLOAD_METHOD = 'skills.beginUpload' as const export const SKILL_SSH_RELAY_UPLOAD_CHUNK_METHOD = 'skills.uploadChunk' as const export const SKILL_SSH_RELAY_COMMIT_UPLOAD_METHOD = 'skills.commitUpload' as const export const SKILL_SSH_RELAY_CANCEL_UPLOAD_METHOD = 'skills.cancelUpload' as const +export const SKILL_SSH_RELAY_GET_INSTALL_PROGRESS_METHOD = 'skills.getInstallProgress' as const const SkillSshWorkspaceAuthoritySchema = z.discriminatedUnion('kind', [ z @@ -37,6 +38,10 @@ export const SkillSshInstallBundleParamsSchema = z }) .strict() +export const SkillSshInstallProgressParamsSchema = z + .object({ operationId: z.string().min(1).max(128) }) + .strict() + export const SkillSshPreviewParamsSchema = z .object({ request: SkillInstallPreviewRequestSchema,