mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
feat(skills): report bundle install progress
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4342,6 +4342,9 @@
|
||||
},
|
||||
"SkillManagedInstallList": {
|
||||
"86c76cb262": "{{count}} skill bundle"
|
||||
},
|
||||
"skill-install-progress-state": {
|
||||
"currentSkill": "Installing {{value0}} of {{value1}}: {{value2}}…"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user