mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(skills): share again under an existing skill bundle name as a new version (#29090)
* fix(skills): republish under an existing skill bundle name as a new version Look up the caller's package by bundle name before packaging so a re-share reuses its package id, and explain a name a teammate already holds instead of a generic failure. Also stop classifying cloud request failures as filesystem failures (same fix as #20848). Fixes #20770 * fix(skills): keep the cloud request error importable without Electron The runtime now classifies SkillCloudRequestError, and orcad has no Electron. * fix(skills): map a held name only at lookup; leave classification to #20848 A finalize 409 after a signed-out preview is the user's own name, so it falls back to the generic publish failure instead of blaming a teammate. * fix(skills): say the skill name is in use instead of blaming a teammate Orca has no team UI, so the copy states only what the server told us.
This commit is contained in:
@@ -55,6 +55,9 @@ ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <nam
|
||||
enable the switch in the desktop app if they want this action.
|
||||
- Orca stages one agent-published bundle at a time per host. If another publish is active,
|
||||
wait for it to finish before retrying `agent_skill_sharing_busy`.
|
||||
- Sharing again under a `--bundle-name` the account already published adds a new version to
|
||||
that package. `agent_skill_bundle_name_taken` means the name is already in use; do not
|
||||
retry, ask the user for a different name.
|
||||
- Run the command in an Orca terminal on the machine that stores the skills. Forwarded WSL,
|
||||
SSH, and paired-runtime invocations fail before discovery so Orca cannot read from the
|
||||
wrong filesystem.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -50,6 +50,7 @@ function registerSharingHandlers(
|
||||
const preparations = new SkillSharePreparationService(
|
||||
join(app.getPath('userData'), 'skill-share-preparations'),
|
||||
{
|
||||
findOwnedPackageId: (name, request) => runtime.findOwnedSkillPackageId(name, request),
|
||||
publishVersion: (request) => runtime.publishSkillPackageVersion(request),
|
||||
createShare: (packageId, request) => runtime.createSkillPackageShare(packageId, request)
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AgentSkillShareRequest } from '../../shared/agent-skill-sharing-co
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import type { DiscoveredSkill } from '../../shared/skills'
|
||||
import { extractSkillBundleArchive } from '../skills/skill-bundle-extraction'
|
||||
import { SkillCloudRequestError } from '../skills/skill-cloud-request-error'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ userDataPath: '/tmp' }))
|
||||
@@ -61,6 +62,7 @@ function runtimeWithCloud(options: {
|
||||
isEnabled: () => boolean
|
||||
failPublish?: boolean
|
||||
waitForAbort?: boolean
|
||||
nameTaken?: boolean
|
||||
}) {
|
||||
const manifests: string[][] = []
|
||||
let notifyStarted: (() => void) | null = null
|
||||
@@ -116,7 +118,14 @@ function runtimeWithCloud(options: {
|
||||
agentSkillSharingEnabled: options.isEnabled()
|
||||
})
|
||||
} as never)
|
||||
runtime.setSkillCloudService({ publishVersion, createShare } as never)
|
||||
const findOwnedPackageId = vi.fn(async () => {
|
||||
if (options.nameTaken) {
|
||||
throw new SkillCloudRequestError(409, 'skill_package_name_taken', 'taken')
|
||||
}
|
||||
return { status: 'ok' as const, value: null }
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: agent sharing calls only these three cloud methods.
|
||||
runtime.setSkillCloudService({ findOwnedPackageId, publishVersion, createShare } as never)
|
||||
return { runtime, publishVersion, createShare, manifests, started }
|
||||
}
|
||||
|
||||
@@ -203,6 +212,20 @@ describe('agent skill sharing runtime', () => {
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
|
||||
it('tells the agent to pick another bundle name when the name is in use', async () => {
|
||||
const alpha = await createSkill('alpha-id', 'alpha')
|
||||
const { runtime, publishVersion } = runtimeWithCloud({ isEnabled: () => true, nameTaken: true })
|
||||
|
||||
await expect(
|
||||
runtime.publishDiscoveredSkillsFromAgent(request(['alpha-id']), [alpha])
|
||||
).rejects.toMatchObject({
|
||||
code: 'agent_skill_bundle_name_taken',
|
||||
message: expect.stringContaining('"team-skills"')
|
||||
})
|
||||
expect(publishVersion).not.toHaveBeenCalled()
|
||||
expect(await operationDirectories()).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels an upload and cleans preparation files', async () => {
|
||||
const alpha = await createSkill('alpha-id', 'alpha')
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
AGENT_SKILL_SHARING_BUSY_CODE,
|
||||
AGENT_SKILL_BUNDLE_NAME_TAKEN_CODE,
|
||||
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE
|
||||
} from '../../../shared/agent-skill-sharing-contract'
|
||||
import {
|
||||
@@ -165,6 +166,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
|
||||
AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE,
|
||||
AGENT_SKILL_SELECTOR_NOT_FOUND_CODE,
|
||||
AGENT_SKILL_SHARING_BUSY_CODE,
|
||||
AGENT_SKILL_BUNDLE_NAME_TAKEN_CODE,
|
||||
AGENT_SKILL_SHARING_UNSUPPORTED_ENVIRONMENT_CODE,
|
||||
SKILL_INSTALL_RPC_ERROR_CODE,
|
||||
// Why: an owner conflict is a distinct client decision (reload the host, re-adopt,
|
||||
|
||||
@@ -48,6 +48,10 @@ export type RuntimeSkillCommandSurface = {
|
||||
publishSkillPackageVersion(
|
||||
request: SkillCloudPublishRequest
|
||||
): Promise<SkillCloudOperation<SkillCloudVersion>>
|
||||
findOwnedSkillPackageId(
|
||||
name: string,
|
||||
options: SkillCloudOptions
|
||||
): ReturnType<SkillCloudService['findOwnedPackageId']>
|
||||
createSkillPackageShare(
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & { pinnedVersionId?: string; idempotencyKey?: string }
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isAgentSkillSharingEnabled
|
||||
} from '../../shared/agent-skill-sharing-gate'
|
||||
import {
|
||||
AGENT_SKILL_BUNDLE_NAME_TAKEN_CODE,
|
||||
AGENT_SKILL_NOT_SHAREABLE_CODE,
|
||||
AGENT_SKILL_SHARING_BUSY_CODE,
|
||||
AgentSkillSharingError,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
type AgentSkillShareRequest
|
||||
} from '../../shared/agent-skill-sharing-contract'
|
||||
import type { DiscoveredSkill } from '../../shared/skills'
|
||||
import { SKILL_SHARE_NAME_TAKEN_ERROR } from '../../shared/skill-sharing-contract'
|
||||
import { selectDiscoveredSkills } from '../skills/agent-skill-selection'
|
||||
import { SkillSharePreparationService } from '../skills/skill-share-preparation-service'
|
||||
import { RuntimeSkillInstallQueries } from './runtime-skill-install-queries'
|
||||
@@ -71,6 +73,7 @@ export class RuntimeSkillCommands
|
||||
const preparations = new SkillSharePreparationService(
|
||||
join(this.userDataPath(), 'agent-skill-share-operations'),
|
||||
{
|
||||
findOwnedPackageId: (name, input) => cloud.findOwnedPackageId(name, input),
|
||||
publishVersion: (input) => cloud.publishVersion(input),
|
||||
createShare: (packageId, input) => cloud.createShare(packageId, input)
|
||||
},
|
||||
@@ -135,6 +138,14 @@ export class RuntimeSkillCommands
|
||||
}
|
||||
}
|
||||
: published
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === SKILL_SHARE_NAME_TAKEN_ERROR) {
|
||||
throw new AgentSkillSharingError(
|
||||
AGENT_SKILL_BUNDLE_NAME_TAKEN_CODE,
|
||||
`The skill bundle name "${request.bundleName}" is already in use. Choose a different --bundle-name.`
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', cancel)
|
||||
await preparations.dispose()
|
||||
@@ -151,6 +162,9 @@ export class RuntimeSkillCommands
|
||||
publishSkillPackageVersion(request: SkillCloudPublishRequest) {
|
||||
return this.requireCloud().publishVersion(request)
|
||||
}
|
||||
findOwnedSkillPackageId(name: string, options: SkillCloudOptions) {
|
||||
return this.requireCloud().findOwnedPackageId(name, options)
|
||||
}
|
||||
createSkillPackageShare(
|
||||
packageId: string,
|
||||
request: SkillCloudOptions & { pinnedVersionId?: string; idempotencyKey?: string }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Why: kept apart from the request so the headless runtime can classify it without Electron.
|
||||
export class SkillCloudRequestError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { skillCloudRequest, type SkillCloudRequestError } from './skill-cloud-request'
|
||||
import { skillCloudRequest } from './skill-cloud-request'
|
||||
import type { SkillCloudRequestError } from './skill-cloud-request-error'
|
||||
|
||||
vi.mock('electron', () => ({ app: { isPackaged: false } }))
|
||||
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config'
|
||||
import { createSkillCloudDeadline } from './skill-cloud-deadline'
|
||||
|
||||
export class SkillCloudRequestError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
import { SkillCloudRequestError } from './skill-cloud-request-error'
|
||||
|
||||
export async function skillCloudRequest<T>(input: {
|
||||
apiUrl?: string
|
||||
|
||||
@@ -15,6 +15,7 @@ import { resolveArtifactCloudApiUrl } from '../artifacts/artifact-cloud-config'
|
||||
import { runSkillCloudOperation } from './skill-cloud-auth'
|
||||
import { uploadSkillPackageToSignedPolicy } from './skill-cloud-direct-upload'
|
||||
import { skillCloudRequest } from './skill-cloud-request'
|
||||
import { SkillCloudRequestError } from './skill-cloud-request-error'
|
||||
import { startSkillPhaseOperation } from './skill-operation-observability'
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/
|
||||
@@ -229,6 +230,24 @@ export class SkillCloudService {
|
||||
})
|
||||
}
|
||||
|
||||
/** Id of the caller's live package with this name; null when the name is free. */
|
||||
findOwnedPackageId(name: string, options: SkillCloudOptions) {
|
||||
return this.withAuth(options, async (token, apiUrl) => {
|
||||
const found = await skillCloudRequest<{ package: { id: string } }>({
|
||||
apiUrl,
|
||||
authToken: token,
|
||||
path: `/v1/skill-packages/by-name/${encodeURIComponent(name)}`
|
||||
}).catch((error: unknown) => {
|
||||
// Why: any 404, so an API without this route still publishes the first share.
|
||||
if (error instanceof SkillCloudRequestError && error.statusCode === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
})
|
||||
return found?.package.id ?? null
|
||||
})
|
||||
}
|
||||
|
||||
listOwnedShares(
|
||||
options: SkillCloudOptions
|
||||
): Promise<SkillCloudOperation<SkillCloudOwnedShare[]>> {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SKILL_SHARE_NAME_TAKEN_ERROR } from '../../shared/skill-sharing-contract'
|
||||
import { SkillCloudService } from './skill-cloud-service'
|
||||
import { SkillSharePreparationService } from './skill-share-preparation-service'
|
||||
|
||||
vi.mock('electron', () => ({ app: { isPackaged: false } }))
|
||||
|
||||
const API_URL = 'http://127.0.0.1:8787'
|
||||
let root = ''
|
||||
let source = ''
|
||||
let lookups: string[] = []
|
||||
|
||||
function stubLookup(status: number, body: unknown): void {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: URL | RequestInfo) => {
|
||||
lookups.push(new URL(String(input)).pathname)
|
||||
return Response.json(body, { status })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function service(): SkillSharePreparationService {
|
||||
const cloud = new SkillCloudService(root)
|
||||
return new SkillSharePreparationService(join(root, 'preparations'), {
|
||||
findOwnedPackageId: (name) => cloud.findOwnedPackageId(name, { apiUrl: API_URL }),
|
||||
publishVersion: vi.fn(),
|
||||
createShare: vi.fn()
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv('ORCA_CLOUD_AUTH_TOKEN', 'desktop-e2e-token')
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-skill-share-name-'))
|
||||
source = join(root, 'source')
|
||||
await mkdir(source)
|
||||
await writeFile(join(source, 'SKILL.md'), '---\nname: lw-skill\ndescription: d\n---\n# d\n')
|
||||
lookups = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.unstubAllGlobals()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('skill share package id by name', () => {
|
||||
it('reuses the id of a package the caller already published under the name', async () => {
|
||||
stubLookup(200, { package: { id: 'pkg_existing' } })
|
||||
|
||||
const preview = await service().prepare({ sourceDirectory: source, bundleName: 'lw-test' })
|
||||
|
||||
expect(preview.packageId).toBe('pkg_existing')
|
||||
expect(lookups).toEqual(['/v1/skill-packages/by-name/lw-test'])
|
||||
})
|
||||
|
||||
it('mints a new id when the name is free', async () => {
|
||||
stubLookup(404, { code: 'skill_package_not_found' })
|
||||
|
||||
const preview = await service().prepare({ sourceDirectory: source, bundleName: 'lw-test' })
|
||||
|
||||
expect(preview.packageId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
it('mints a new id when signed out, leaving sign-in to publish', async () => {
|
||||
vi.unstubAllEnvs()
|
||||
stubLookup(200, { package: { id: 'pkg_existing' } })
|
||||
|
||||
const preview = await service().prepare({ sourceDirectory: source, bundleName: 'lw-test' })
|
||||
|
||||
expect(preview.packageId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(lookups).toEqual([])
|
||||
})
|
||||
|
||||
it('fails before packaging when the name is already in use', async () => {
|
||||
stubLookup(409, { code: 'skill_package_name_taken', message: 'taken' })
|
||||
|
||||
await expect(
|
||||
service().prepare({ sourceDirectory: source, bundleName: 'lw-test' })
|
||||
).rejects.toThrow(SKILL_SHARE_NAME_TAKEN_ERROR)
|
||||
})
|
||||
|
||||
it('fails the share on any other lookup failure instead of guessing a new id', async () => {
|
||||
stubLookup(503, { code: 'skill_service_unavailable' })
|
||||
|
||||
await expect(
|
||||
service().prepare({ sourceDirectory: source, bundleName: 'lw-test' })
|
||||
).rejects.toMatchObject({ statusCode: 503 })
|
||||
})
|
||||
|
||||
it('keeps an explicit package id without a lookup', async () => {
|
||||
stubLookup(200, { package: { id: 'pkg_existing' } })
|
||||
|
||||
const preview = await service().prepare({
|
||||
sourceDirectory: source,
|
||||
bundleName: 'lw-test',
|
||||
packageId: 'pkg_managed'
|
||||
})
|
||||
|
||||
expect(preview.packageId).toBe('pkg_managed')
|
||||
expect(lookups).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,10 @@ import { writeSkillInstallReceipt } from './skill-install-provenance'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
async function nameIsFree() {
|
||||
return { status: 'ok' as const, value: null }
|
||||
}
|
||||
|
||||
async function createSource(): Promise<{ root: string; source: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-skill-share-preparation-'))
|
||||
temporaryDirectories.push(root)
|
||||
@@ -74,7 +78,7 @@ describe('SkillSharePreparationService', () => {
|
||||
})
|
||||
const service = new SkillSharePreparationService(
|
||||
join(root, 'preparations'),
|
||||
{ publishVersion: vi.fn(), createShare: vi.fn() },
|
||||
{ findOwnedPackageId: nameIsFree, publishVersion: vi.fn(), createShare: vi.fn() },
|
||||
{ installStateDirectory: stateDirectory, platform: 'win32' }
|
||||
)
|
||||
|
||||
@@ -113,7 +117,7 @@ describe('SkillSharePreparationService', () => {
|
||||
})
|
||||
const service = new SkillSharePreparationService(
|
||||
join(root, 'preparations'),
|
||||
{ publishVersion: vi.fn(), createShare: vi.fn() },
|
||||
{ findOwnedPackageId: nameIsFree, publishVersion: vi.fn(), createShare: vi.fn() },
|
||||
{ installStateDirectory: stateDirectory, platform: 'win32' }
|
||||
)
|
||||
|
||||
@@ -133,7 +137,7 @@ describe('SkillSharePreparationService', () => {
|
||||
})
|
||||
const service = new SkillSharePreparationService(
|
||||
preparationRoot,
|
||||
{ publishVersion: vi.fn(), createShare: vi.fn() },
|
||||
{ findOwnedPackageId: nameIsFree, publishVersion: vi.fn(), createShare: vi.fn() },
|
||||
{ initializeRoot }
|
||||
)
|
||||
|
||||
@@ -152,6 +156,7 @@ describe('SkillSharePreparationService', () => {
|
||||
await mkdir(join(preparationRoot, 'abandoned'), { recursive: true })
|
||||
await writeFile(join(preparationRoot, 'abandoned', 'package.tar.gz'), 'private bytes')
|
||||
const service = new SkillSharePreparationService(preparationRoot, {
|
||||
findOwnedPackageId: nameIsFree,
|
||||
publishVersion: vi.fn(),
|
||||
createShare: vi.fn()
|
||||
})
|
||||
@@ -164,6 +169,7 @@ describe('SkillSharePreparationService', () => {
|
||||
it('enforces the preparation cap while archives are still being created', async () => {
|
||||
const { root, source } = await createSource()
|
||||
const service = new SkillSharePreparationService(join(root, 'preparations'), {
|
||||
findOwnedPackageId: nameIsFree,
|
||||
publishVersion: vi.fn(),
|
||||
createShare: vi.fn()
|
||||
})
|
||||
@@ -193,6 +199,7 @@ describe('SkillSharePreparationService', () => {
|
||||
value: { id: 'share_retry', url: 'https://share.test/skills/share/share_retry' }
|
||||
})
|
||||
const service = new SkillSharePreparationService(join(root, 'preparations'), {
|
||||
findOwnedPackageId: nameIsFree,
|
||||
publishVersion,
|
||||
createShare
|
||||
})
|
||||
@@ -234,6 +241,7 @@ describe('SkillSharePreparationService', () => {
|
||||
value: { id: 'share_retry', url: 'https://share.test/skills/share/share_retry' }
|
||||
})
|
||||
const service = new SkillSharePreparationService(join(root, 'preparations'), {
|
||||
findOwnedPackageId: nameIsFree,
|
||||
publishVersion,
|
||||
createShare
|
||||
})
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, realpath, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
SkillSharePreview,
|
||||
SkillShareProgress,
|
||||
SkillSharePublishInput,
|
||||
SkillSharePublishOperation
|
||||
import {
|
||||
SKILL_SHARE_NAME_TAKEN_ERROR,
|
||||
type SkillSharePreview,
|
||||
type SkillShareProgress,
|
||||
type SkillSharePublishInput,
|
||||
type SkillSharePublishOperation
|
||||
} from '../../shared/skill-sharing-contract'
|
||||
import type { SkillCloudVersion } from '../../shared/skill-cloud-contract'
|
||||
import { createSkillBundleArchive, type CreatedSkillBundle } from './skill-bundle-creation'
|
||||
import type { SkillCloudService } from './skill-cloud-service'
|
||||
import { SkillCloudRequestError } from './skill-cloud-request-error'
|
||||
import { readSkillInstallReceipt } from './skill-install-provenance'
|
||||
|
||||
const PREPARATION_TTL_MS = 30 * 60 * 1000
|
||||
@@ -66,7 +68,10 @@ export class SkillSharePreparationService {
|
||||
|
||||
constructor(
|
||||
private readonly root: string,
|
||||
private readonly cloud: Pick<SkillCloudService, 'createShare' | 'publishVersion'>,
|
||||
private readonly cloud: Pick<
|
||||
SkillCloudService,
|
||||
'createShare' | 'findOwnedPackageId' | 'publishVersion'
|
||||
>,
|
||||
private readonly options: {
|
||||
initializeRoot?: () => Promise<void>
|
||||
installStateDirectory?: string
|
||||
@@ -124,12 +129,13 @@ export class SkillSharePreparationService {
|
||||
: source
|
||||
})
|
||||
)
|
||||
const bundleName = input.bundleName ?? 'shared-skill'
|
||||
const created = await createSkillBundleArchive({
|
||||
sources,
|
||||
bundleName: input.bundleName ?? 'shared-skill',
|
||||
bundleName,
|
||||
description: input.description,
|
||||
archivePath: join(directory, 'package.tar.gz'),
|
||||
packageId: input.packageId ?? randomUUID(),
|
||||
packageId: input.packageId ?? (await this.packageIdForName(bundleName)),
|
||||
versionId: randomUUID()
|
||||
})
|
||||
const value: Preparation = {
|
||||
@@ -208,6 +214,17 @@ export class SkillSharePreparationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the cloud keeps one live package per name, and the id is baked into the archive.
|
||||
private async packageIdForName(bundleName: string): Promise<string> {
|
||||
const owned = await this.cloud.findOwnedPackageId(bundleName, {}).catch((error: unknown) => {
|
||||
throw error instanceof SkillCloudRequestError && error.code === 'skill_package_name_taken'
|
||||
? new Error(SKILL_SHARE_NAME_TAKEN_ERROR, { cause: error })
|
||||
: error
|
||||
})
|
||||
// Why: signed out, the preview still works without an account; publish asks for sign-in.
|
||||
return (owned.status === 'ok' && owned.value) || randomUUID()
|
||||
}
|
||||
|
||||
cancel(preparationId: string): void {
|
||||
this.preparations.get(preparationId)?.controller?.abort()
|
||||
}
|
||||
|
||||
@@ -152,6 +152,24 @@ describe('SkillShareDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('explains a skill name that is already in use', async () => {
|
||||
setup(
|
||||
[],
|
||||
false,
|
||||
[skill],
|
||||
{},
|
||||
Promise.reject(
|
||||
new Error(
|
||||
"Error invoking remote method 'skills:prepareShare': Error: skill-share-name-taken"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'The skill name “private-skill” is already in use. Rename your skill to share it.'
|
||||
)
|
||||
})
|
||||
|
||||
it('releases a prepared archive when the dialog unmounts', async () => {
|
||||
const { skills, unmount } = setup([])
|
||||
await screen.findByRole('heading', { name: 'Share skill' })
|
||||
|
||||
@@ -4,9 +4,10 @@ import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
SkillSharePreview,
|
||||
SkillShareProgress
|
||||
import {
|
||||
SKILL_SHARE_NAME_TAKEN_ERROR,
|
||||
type SkillSharePreview,
|
||||
type SkillShareProgress
|
||||
} from '../../../../shared/skill-sharing-contract'
|
||||
import type { ManagedSkillInstall } from '../../../../shared/skill-install-contract'
|
||||
import type { DiscoveredSkill } from '../../../../shared/skills'
|
||||
@@ -40,6 +41,17 @@ function operationError(status: string): string {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: IPC wraps the main-process message, so match it as a substring.
|
||||
function nameTakenError(cause: unknown, name: string): string | null {
|
||||
return String(cause).includes(SKILL_SHARE_NAME_TAKEN_ERROR)
|
||||
? translate(
|
||||
'auto.components.skills.SkillShareDialog.nameTaken',
|
||||
'The skill name “{{name}}” is already in use. Rename your skill to share it.',
|
||||
{ name }
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
export function SkillShareDialog({
|
||||
skills,
|
||||
skill,
|
||||
@@ -78,6 +90,7 @@ export function SkillShareDialog({
|
||||
setError(null)
|
||||
setPublishingNewVersion(false)
|
||||
setPreparing(true)
|
||||
const bundleName = derivedBundleName(selectedSkills)
|
||||
void (async () => {
|
||||
let managedInstall: ManagedSkillInstall | null = null
|
||||
try {
|
||||
@@ -90,7 +103,7 @@ export function SkillShareDialog({
|
||||
}
|
||||
const nextPreview = await window.api.skills.prepareShare({
|
||||
skillIds: selectedSkills.map((skill) => skill.id),
|
||||
bundleName: derivedBundleName(selectedSkills),
|
||||
bundleName,
|
||||
...(managedInstall ? { packageId: managedInstall.packageId } : {})
|
||||
})
|
||||
retainedPreparationId = nextPreview.preparationId
|
||||
@@ -119,10 +132,11 @@ export function SkillShareDialog({
|
||||
console.warn('[skills] share preparation failed:', cause)
|
||||
if (generation.current === current) {
|
||||
setError(
|
||||
translate(
|
||||
'auto.components.skills.SkillShareDialog.prepareFailed',
|
||||
'Could not prepare this skill for sharing.'
|
||||
)
|
||||
nameTakenError(cause, bundleName) ??
|
||||
translate(
|
||||
'auto.components.skills.SkillShareDialog.prepareFailed',
|
||||
'Could not prepare this skill for sharing.'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4925,6 +4925,7 @@
|
||||
"peopleRequired": "Select at least one teammate.",
|
||||
"publishCancelled": "Upload cancelled. The prepared copy is still available to retry.",
|
||||
"publishFailed": "Could not publish this skill. The prepared copy is still available to retry.",
|
||||
"nameTaken": "The skill name “{{name}}” is already in use. Rename your skill to share it.",
|
||||
"copied": "Share link copied",
|
||||
"preparing": "Preparing preview…",
|
||||
"ready": "Skill link ready",
|
||||
|
||||
@@ -4787,6 +4787,7 @@
|
||||
"peopleRequired": "Selecciona al menos un compañero de equipo.",
|
||||
"publishCancelled": "Subida cancelada. La copia preparada todavía está disponible para volver a intentarlo.",
|
||||
"publishFailed": "No se pudo publicar esta habilidad. La copia preparada todavía está disponible para volver a intentarlo.",
|
||||
"nameTaken": "El nombre de habilidad “{{name}}” ya está en uso. Cambia el nombre de tu habilidad para compartirla.",
|
||||
"copied": "Compartir enlace copiado",
|
||||
"preparing": "Preparando vista previa…",
|
||||
"ready": "Enlace de habilidad listo",
|
||||
|
||||
@@ -4806,6 +4806,7 @@
|
||||
"peopleRequired": "Sélectionnez au moins un membre de l'équipe.",
|
||||
"publishCancelled": "Téléversement annulé. La copie préparée reste disponible pour une nouvelle tentative.",
|
||||
"publishFailed": "Impossible de publier ce skill. La copie préparée reste disponible pour une nouvelle tentative.",
|
||||
"nameTaken": "Le nom de skill « {{name}} » est déjà utilisé. Renommez votre skill pour le partager.",
|
||||
"copied": "Lien de partage copié",
|
||||
"preparing": "Préparation de l'aperçu…",
|
||||
"ready": "Lien du skill prêt",
|
||||
|
||||
@@ -4787,6 +4787,7 @@
|
||||
"peopleRequired": "少なくとも 1 人のチームメイトを選択してください。",
|
||||
"publishCancelled": "アップロードがキャンセルされました。準備されたコピーは引き続き再試行できます。",
|
||||
"publishFailed": "このスキルを公開できませんでした。準備されたコピーは引き続き再試行できます。",
|
||||
"nameTaken": "スキル名「{{name}}」はすでに使用されています。共有するにはスキルの名前を変更してください。",
|
||||
"copied": "共有リンクがコピーされました",
|
||||
"preparing": "プレビューを準備しています…",
|
||||
"ready": "スキルリンク準備完了",
|
||||
|
||||
@@ -4787,6 +4787,7 @@
|
||||
"peopleRequired": "팀원을 한 명 이상 선택하세요.",
|
||||
"publishCancelled": "업로드가 취소되었습니다. 준비된 사본은 아직 재시도할 수 있습니다.",
|
||||
"publishFailed": "이 스킬을 게시할 수 없습니다. 준비된 사본은 아직 재시도할 수 있습니다.",
|
||||
"nameTaken": "스킬 이름 “{{name}}”은(는) 이미 사용 중입니다. 공유하려면 스킬 이름을 변경하세요.",
|
||||
"copied": "공유 링크가 복사되었습니다.",
|
||||
"preparing": "미리보기 준비 중…",
|
||||
"ready": "스킬링크 준비 완료",
|
||||
|
||||
@@ -4813,6 +4813,7 @@
|
||||
"peopleRequired": "选择至少一名队友。",
|
||||
"publishCancelled": "上传已取消。准备好的副本仍然可以重试。",
|
||||
"publishFailed": "无法发布该技能。准备好的副本仍然可以重试。",
|
||||
"nameTaken": "技能名称“{{name}}”已被使用。请重命名您的技能后再共享。",
|
||||
"copied": "分享链接已复制",
|
||||
"preparing": "正在准备预览...",
|
||||
"ready": "技能链接已准备好",
|
||||
|
||||
@@ -8,6 +8,7 @@ export const AGENT_SKILL_SELECTOR_NOT_FOUND_CODE = 'agent_skill_selector_not_fou
|
||||
export const AGENT_SKILL_SELECTOR_AMBIGUOUS_CODE = 'agent_skill_selector_ambiguous'
|
||||
export const AGENT_SKILL_SHARING_BUSY_CODE = 'agent_skill_sharing_busy'
|
||||
export const AGENT_SKILL_NOT_SHAREABLE_CODE = 'agent_skill_not_shareable'
|
||||
export const AGENT_SKILL_BUNDLE_NAME_TAKEN_CODE = 'agent_skill_bundle_name_taken'
|
||||
|
||||
export const AgentSkillShareRequestSchema = z
|
||||
.object({
|
||||
|
||||
@@ -17,6 +17,9 @@ import type {
|
||||
SkillBundleSelectedSkill
|
||||
} from './skill-bundle-install-contract'
|
||||
|
||||
// Why: a plain message survives Electron IPC, which drops error classes and fields.
|
||||
export const SKILL_SHARE_NAME_TAKEN_ERROR = 'skill-share-name-taken'
|
||||
|
||||
export type SkillSharePreview = {
|
||||
preparationId: string
|
||||
packageId: string
|
||||
|
||||
Reference in New Issue
Block a user