feat(skills): report bundle install progress

This commit is contained in:
Jinwoo-H
2026-08-12 08:38:25 -04:00
parent a8ad09c803
commit a600fc1be0
26 changed files with 580 additions and 138 deletions
@@ -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
+39 -25
View File
@@ -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<typeof skillCloudBundleShareInstallSchema>
| z.infer<typeof skillCloudBundlePackageVersionInstallSchema>
| z.infer<typeof skillCloudBundlePackageVersionInstallSchema>,
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
@@ -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()
})
})
@@ -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
}
})
}
+21 -3
View File
@@ -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<string, AbortController>()
private readonly skillInstallProgress = new Map<string, SkillBundleInstallProgress>()
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<SkillBundleInstallResult> {
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()
@@ -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', () => {
+12 -1
View File
@@ -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,
@@ -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<readonly string[]>
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()
@@ -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')
@@ -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,
@@ -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) => {
@@ -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<SkillBundleInstallResult> {
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?.()
}
}
@@ -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) {
@@ -0,0 +1,40 @@
import type { SkillBundleInstallProgress } from '../../shared/skill-bundle-install-contract'
const POLL_INTERVAL_MS = 150
function waitForNextPoll(signal: AbortSignal): Promise<void> {
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<SkillBundleInstallProgress | null>
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()
}
@@ -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
+79 -39
View File
@@ -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<unknown>
}
async function readBundleInstallProgress(
userDataPath: string,
environmentId: string,
operationId: string
): Promise<SkillBundleInstallProgress | null> {
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<unknown>,
requireHttps: boolean
@@ -155,6 +178,7 @@ export async function installSkillBundleOnRemoteRuntime(input: {
capabilities: readonly RuntimeCapability[]
requireHttps: boolean
signal?: AbortSignal
onProgress?: (progress: SkillBundleInstallProgress) => void
}): Promise<SkillBundleInstallResult> {
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?.()
}
}
+1
View File
@@ -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'
])
+26 -11
View File
@@ -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<readonly string[]>
private readonly installProgress = new Map<string, SkillBundleInstallProgress>()
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)
@@ -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…')
})
})
@@ -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<string | null>(null)
const activeOperationIdRef = useRef<string | null>(null)
const [phase, setPhase] = useState<keyof typeof INSTALL_PHASE_LABELS | null>(null)
const [progress, setProgress] = useState<SkillInstallProgress | null>(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)
}
}
}
+3
View File
@@ -4342,6 +4342,9 @@
},
"SkillManagedInstallList": {
"86c76cb262": "{{count}} skill bundle"
},
"skill-install-progress-state": {
"currentSkill": "Installing {{value0}} of {{value1}}: {{value2}}…"
}
},
"sidebar": {
+2
View File
@@ -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
@@ -149,3 +149,15 @@ export type SkillBundleInstallResult = z.infer<typeof SkillBundleInstallResultSc
export type SkillBundleSkillResult = SkillBundleInstallResult['skills'][number]
export type SkillBundlePlacementResult = SkillBundleSkillResult['placements'][number]
export type SkillBundleInstallFailure = SkillInstallFailure
export const SkillBundleInstallProgressSchema = z
.object({
operationId: z.string().min(1).max(128),
skillId: z.string().min(1).max(128),
skillName: z.string().min(1).max(128),
skillIndex: z.number().int().min(1).max(512),
skillCount: z.number().int().min(1).max(512)
})
.strict()
export type SkillBundleInstallProgress = z.infer<typeof SkillBundleInstallProgressSchema>
+1
View File
@@ -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.'
+6
View File
@@ -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 = {
+5
View File
@@ -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,