mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Safely auto-update managed Orca skills
This commit is contained in:
@@ -27,7 +27,7 @@ Automatic managed-skill updates are enabled by default. Users can turn them off
|
||||
|
||||
Initial support is limited to Orca-owned feature skills:
|
||||
|
||||
- `linear-tickets`
|
||||
- `orca-linear`
|
||||
- `orchestration`
|
||||
- `computer-use`
|
||||
- `orca-cli`
|
||||
@@ -52,7 +52,7 @@ The automatic-updates setting does not create a new trigger. It only controls wh
|
||||
|
||||
| Skill | Trigger |
|
||||
| --- | --- |
|
||||
| `linear-tickets` | User starts a new worktree from a Linear task, or launches/continues an agent workflow attached to a Linear issue where agents are expected to read or update the issue. |
|
||||
| `orca-linear` | User starts a new worktree from a Linear task, or launches/continues an agent workflow attached to a Linear issue where agents are expected to read or update the issue. |
|
||||
| `orchestration` | User starts an orchestration workflow, such as `/orchestration`, handoff, child-agent coordination, or an explicit orchestration enable/use action. |
|
||||
| `computer-use` | User starts a Computer Use workflow, or an agent first attempts to invoke computer-use for the active task. |
|
||||
| `orca-cli` | A workflow that depends on the Orca CLI skill starts, such as Browser Use, mobile emulator agent workflows, or another feature that explicitly needs Orca CLI affordances. |
|
||||
@@ -63,15 +63,19 @@ The Agents settings pane exposes the default-on preference as "Allow verified Or
|
||||
|
||||
## Modal Copy Requirements
|
||||
|
||||
When the fallback modal is shown because an agent attempted a skill, the modal must say that.
|
||||
When the fallback modal appears from a feature trigger, the modal must name the feature context
|
||||
and workspace. Do not claim that an agent attempted the skill unless the triggering surface can
|
||||
prove that; runtime and workflow entry points can also be initiated directly by the user.
|
||||
|
||||
Examples:
|
||||
|
||||
- "An agent just tried to use Orca orchestration. Orca needs to update the orchestration skill before agents can coordinate reliably."
|
||||
- "An agent tried to use Orca's CLI skill for this workflow. Orca can update it now, or you can run the command manually."
|
||||
- "This Linear task workflow needs the Linear agent skill. Orca could not update it automatically."
|
||||
- "Orca Orchestration was used in octopus. Update the orchestration skill to enable agents to coordinate reliably."
|
||||
- "The Orca CLI skill is needed in octopus. Update the CLI skill to enable this workflow to continue reliably."
|
||||
- "A worktree was started from a Linear task in octopus. Install the Linear agent skill to enable agents to read and update Linear issues."
|
||||
|
||||
The modal should show the exact command Orca would run or wanted the user to run.
|
||||
When a safe command is available, the modal should show the exact command Orca would run or
|
||||
wanted the user to run. When no safe command is available, it should explain why Orca cannot
|
||||
update automatically and offer a re-check after the user fixes the install/runtime state.
|
||||
|
||||
## Safety Model
|
||||
|
||||
@@ -92,7 +96,7 @@ Auto-update is eligible only when all checks pass:
|
||||
9. The active project runtime is not repair-required.
|
||||
10. The coordinator has not recorded a recent failed attempt for the same skill/runtime/scope.
|
||||
|
||||
For the first version, auto-update should be limited to tracked global installs. Project-scoped installs should use the same trigger and return a non-emitted fallback until project-scope behavior and a safe manual command are proven.
|
||||
For the first version, auto-update should be limited to tracked global installs. Project-scoped installs should use the same trigger and show a manual-review fallback until project-scope behavior and a safe explicit update command are proven.
|
||||
|
||||
## Scope Resolution
|
||||
|
||||
@@ -189,13 +193,13 @@ Optional future invalidation:
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| Wrong scope gets updated | Never use cwd inference; require discovery scope plus matching lockfile; use explicit `--global` or `--project`. |
|
||||
| User has both global and project installs | Treat as ambiguous unless the active workflow has a single proven scope; return non-emitted fallback until there is an actionable command. |
|
||||
| User has custom/local/hand-copied skill | Require lock metadata; return fallback if missing or legacy and only emit UI when there is a safe manual command. |
|
||||
| User has both global and project installs | Treat as ambiguous unless the active workflow has a single proven scope; show a manual-review fallback instead of guessing. |
|
||||
| User has custom/local/hand-copied skill | Require lock metadata; show a manual-review fallback if metadata is missing, malformed, or not Orca-managed. |
|
||||
| Copy-mode installs are recreated as symlinks by upstream CLI | Avoid auto-update unless install metadata is trusted; call this out only when the fallback/manual path is actionable. |
|
||||
| Private repo or auth-gated source needs credentials | Do not auto-update when source/auth cannot be proven non-interactive; return non-emitted fallback unless Orca has a safe manual command. |
|
||||
| WSL distro unavailable or project runtime repair-required | Return fallback state without opening a setup modal until WSL-specific guidance or commands are actionable. |
|
||||
| Private repo or auth-gated source needs credentials | Do not auto-update when source/auth cannot be proven non-interactive; show a manual-review fallback rather than running a command. |
|
||||
| WSL distro unavailable or project runtime repair-required | Do not run host update commands; show runtime-context fallback guidance and allow re-check after repair. |
|
||||
| SSH/remote filesystem update writes somewhere unexpected | Do not auto-update remote/SSH skills in v1. |
|
||||
| Modal appears unexpectedly because an agent triggered it | Modal copy must name the triggering context: "An agent just tried to..." |
|
||||
| Modal appears unexpectedly because a workflow triggered it | Modal copy must name the feature context and workspace without guessing whether an agent or user initiated it. |
|
||||
| 50 agents cause 50 checks | Central coordinator with in-flight dedupe, success cache, and failure cooldown. |
|
||||
| User turns automatic updates off | Keep the same triggers, but return the manual update modal for stale global installs without cooling that disabled-path modal away. |
|
||||
| Update command hangs or npm/network is slow | Add timeout, cancellation on app shutdown where possible, and emit a modal only when a manual command is available. |
|
||||
@@ -209,10 +213,11 @@ Optional future invalidation:
|
||||
2. Renderer calls the coordinator with skill name and workflow context.
|
||||
3. Coordinator returns one of:
|
||||
- `ready`: installed and recently checked.
|
||||
- `updated`: reserved for a verified background update path once the CLI contract ships.
|
||||
- `updated`: Orca verified and ran the single-skill global update command successfully.
|
||||
- `fallback`: return reason and optional command.
|
||||
4. Workflow continues for `ready` and `updated`.
|
||||
5. For `fallback`, show the contextual modal only when the fallback includes a manual command; otherwise avoid an interrupting dead-end prompt.
|
||||
5. For `fallback`, show the contextual modal for actionable setup/manual-review states and keep
|
||||
cooldown/unsupported internal states silent.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
@@ -238,7 +243,7 @@ Optional future invalidation:
|
||||
|
||||
## Tests
|
||||
|
||||
- Global tracked skill update eligibility without running blind `npx`.
|
||||
- Global tracked skill update eligibility and verified single-skill `npx` execution.
|
||||
- Project tracked skill falls back in v1.
|
||||
- Same skill in global and project scope falls back as ambiguous.
|
||||
- Symlinked visible global install remains global after `realpath`.
|
||||
@@ -253,6 +258,10 @@ Optional future invalidation:
|
||||
|
||||
## Recommended First Version
|
||||
|
||||
Ship the coordinator and intent-boundary triggers, but do not run background `npx` until the verified CLI/ref/hash contract is available. Tracked global Orca-managed installs are the only future auto-update target for this version line. Everything else uses the same trigger and returns fallback state; only fallback state with a safe manual command opens a contextual modal.
|
||||
Ship the coordinator and intent-boundary triggers with automatic updates for tracked global
|
||||
Orca-managed installs only. The coordinator runs the explicit single-skill global update command
|
||||
after discovery and lockfile validation, then verifies the post-update install before reporting
|
||||
`updated`. Everything else uses the same trigger and returns fallback state; actionable fallback
|
||||
states open a contextual modal.
|
||||
|
||||
This covers the common setup path from Orca's own install buttons while protecting custom, project, remote, and ambiguous installs.
|
||||
|
||||
+6
-1
@@ -36,7 +36,8 @@ import { resolveConsent } from './telemetry/consent'
|
||||
import { triggerStartupNotificationRegistration } from './ipc/notifications'
|
||||
import { OrcaRuntimeService } from './runtime/orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
|
||||
import { getManagedSkillUpdateCoordinator } from './skills/managed-skill-updates'
|
||||
import { abortManagedSkillUpdateProcesses } from './skills/managed-skill-updates'
|
||||
import { getManagedSkillUpdateCoordinator } from './skills/managed-skill-update-coordinator-registry'
|
||||
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
|
||||
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
|
||||
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
|
||||
@@ -737,6 +738,9 @@ function openMainWindow(): BrowserWindow {
|
||||
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
|
||||
onBeforeRelaunch: async () => {
|
||||
isQuitting = true
|
||||
// Why: app:relaunch exits directly and can skip will-quit, so cancel
|
||||
// managed skill updater subprocesses before scheduling that exit.
|
||||
abortManagedSkillUpdateProcesses()
|
||||
await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store })
|
||||
}
|
||||
}
|
||||
@@ -1741,6 +1745,7 @@ app.on('before-quit', () => {
|
||||
// async work and let Electron exit.
|
||||
let daemonDisconnectDone = false
|
||||
app.on('will-quit', (e) => {
|
||||
abortManagedSkillUpdateProcesses()
|
||||
// Why: before-quit can still be aborted by renderer beforeunload; wait until
|
||||
// the committed quit path before removing the Windows notification icon.
|
||||
destroySystemTray()
|
||||
|
||||
@@ -65,7 +65,7 @@ vi.mock('./runtime-environment-request-connections', () => ({
|
||||
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
|
||||
}))
|
||||
|
||||
vi.mock('../skills/managed-skill-updates', () => ({
|
||||
vi.mock('../skills/managed-skill-update-coordinator-registry', () => ({
|
||||
getManagedSkillUpdateCoordinator: () => ({
|
||||
ensureManagedReady: ensureManagedReadyMock
|
||||
})
|
||||
@@ -400,7 +400,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('nudges managed skill readiness after remote runtime feature RPCs without opening setup', async () => {
|
||||
it('nudges managed skill readiness after remote runtime feature RPCs and emits fallback setup', async () => {
|
||||
registerRuntimeEnvironmentHandlers({} as never)
|
||||
ensureManagedReadyMock.mockResolvedValue({
|
||||
status: 'fallback',
|
||||
@@ -442,7 +442,50 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
remoteRuntime: true
|
||||
})
|
||||
})
|
||||
expect(sendManagedSkillFallbackMock).not.toHaveBeenCalled()
|
||||
expect(sendManagedSkillFallbackMock).toHaveBeenCalledWith({
|
||||
status: 'fallback',
|
||||
skillName: 'orchestration',
|
||||
context: 'agent-orchestration',
|
||||
runtime: 'host',
|
||||
scope: 'missing',
|
||||
reason: 'remote-runtime'
|
||||
})
|
||||
})
|
||||
|
||||
it('nudges orca-cli readiness after remote emulator runtime RPCs', async () => {
|
||||
registerRuntimeEnvironmentHandlers({} as never)
|
||||
sendRemoteRuntimeRequestMock.mockResolvedValue({
|
||||
id: 'rpc-emulator',
|
||||
ok: true,
|
||||
result: { tapped: true },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
|
||||
const add = handler<
|
||||
{ name: string; pairingCode: string },
|
||||
{ environment: { id: string; name: string } }
|
||||
>('runtimeEnvironments:addFromPairingCode')
|
||||
await add(null, { name: 'desk', pairingCode: pairingCode() })
|
||||
|
||||
const call = handler<
|
||||
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
|
||||
{ ok: true; result: unknown }
|
||||
>('runtimeEnvironments:call')
|
||||
await expect(
|
||||
call(null, {
|
||||
selector: 'desk',
|
||||
method: 'emulator.tap',
|
||||
params: { x: 10, y: 20 }
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ensureManagedReadyMock).toHaveBeenCalledWith({
|
||||
skillName: 'orca-cli',
|
||||
context: 'agent-orca-cli',
|
||||
remoteRuntime: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
|
||||
import type { RuntimeManagedSkillNudgeHandler } from '../runtime/rpc/managed-skill-nudge'
|
||||
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-updates'
|
||||
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-update-coordinator-registry'
|
||||
import { sendManagedSkillFallback, sendManagedSkillUpdated } from './skills'
|
||||
|
||||
export function createRuntimeManagedSkillNudge(store: Store): RuntimeManagedSkillNudgeHandler {
|
||||
|
||||
+16
-16
@@ -150,14 +150,14 @@ describe('registerSkillsHandlers', () => {
|
||||
expect(discoverSkillsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('registers managed ensure and returns repair-required fallback without emitting setup', async () => {
|
||||
it('registers managed ensure and emits repair-required fallback setup', async () => {
|
||||
const handler = getEnsureHandler()
|
||||
const send = vi.fn()
|
||||
|
||||
const result = await handler(
|
||||
{ sender: { send } },
|
||||
{
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: {
|
||||
projectRuntime: {
|
||||
@@ -177,9 +177,9 @@ describe('registerSkillsHandlers', () => {
|
||||
expect(result).toMatchObject({
|
||||
status: 'fallback',
|
||||
reason: 'repair-required-runtime',
|
||||
skillName: 'linear-tickets'
|
||||
skillName: 'orca-linear'
|
||||
})
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
|
||||
expect(discoverSkillsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('registerSkillsHandlers', () => {
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not emit managed fallback events for remote runtime results', async () => {
|
||||
it('emits managed fallback events for remote runtime results', async () => {
|
||||
const handler = getEnsureHandler()
|
||||
const send = vi.fn()
|
||||
const result = await handler(
|
||||
@@ -219,16 +219,16 @@ describe('registerSkillsHandlers', () => {
|
||||
status: 'fallback',
|
||||
reason: 'remote-runtime'
|
||||
})
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
|
||||
})
|
||||
|
||||
it('does not emit managed fallback events for WSL runtime results', async () => {
|
||||
it('emits managed fallback events for WSL runtime results', async () => {
|
||||
const handler = getEnsureHandler()
|
||||
const send = vi.fn()
|
||||
const result = await handler(
|
||||
{ sender: { send } },
|
||||
{
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
}
|
||||
@@ -238,24 +238,24 @@ describe('registerSkillsHandlers', () => {
|
||||
status: 'fallback',
|
||||
reason: 'wsl-runtime'
|
||||
})
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
|
||||
})
|
||||
|
||||
it('does not emit managed fallback events without a manual command', async () => {
|
||||
it('emits managed fallback events for unsafe installs without a manual command', async () => {
|
||||
const handler = getEnsureHandler()
|
||||
const send = vi.fn()
|
||||
discoverSkillsMock.mockResolvedValue({
|
||||
skills: [
|
||||
{
|
||||
id: 'repo-linear-tickets',
|
||||
name: 'linear-tickets',
|
||||
id: 'repo-orca-linear',
|
||||
name: 'orca-linear',
|
||||
description: null,
|
||||
providers: ['agent-skills'],
|
||||
sourceKind: 'repo',
|
||||
sourceLabel: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/linear-tickets',
|
||||
skillFilePath: '/workspace/current/.agents/skills/linear-tickets/SKILL.md',
|
||||
directoryPath: '/workspace/current/.agents/skills/orca-linear',
|
||||
skillFilePath: '/workspace/current/.agents/skills/orca-linear/SKILL.md',
|
||||
installed: true,
|
||||
fileCount: 1,
|
||||
updatedAt: 1
|
||||
@@ -267,7 +267,7 @@ describe('registerSkillsHandlers', () => {
|
||||
const result = await handler(
|
||||
{ sender: { send } },
|
||||
{
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
}
|
||||
@@ -277,6 +277,6 @@ describe('registerSkillsHandlers', () => {
|
||||
status: 'fallback',
|
||||
reason: 'project-install'
|
||||
})
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
expect(send).toHaveBeenCalledWith('skills:managedFallback', result)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,11 @@ import type {
|
||||
} from '../../shared/skills'
|
||||
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
|
||||
import { getDefaultWslDistro, getWslHome } from '../wsl'
|
||||
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-updates'
|
||||
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-update-coordinator-registry'
|
||||
import {
|
||||
publishManagedSkillFallback,
|
||||
publishManagedSkillUpdated
|
||||
} from '../skills/managed-skill-events'
|
||||
|
||||
type SkillDiscoveryRuntimeTarget =
|
||||
| { runtime: 'host' }
|
||||
@@ -70,6 +74,7 @@ export function registerSkillsHandlers(store: Store): void {
|
||||
): Promise<ManagedAgentSkillEnsureResult> => {
|
||||
const result = await getManagedSkillUpdateCoordinator(store).ensureManagedReady(request)
|
||||
if (shouldEmitManagedAgentSkillFallback(result)) {
|
||||
publishManagedSkillFallback(result)
|
||||
event.sender.send('skills:managedFallback', result)
|
||||
}
|
||||
if (result.status === 'updated') {
|
||||
@@ -84,6 +89,7 @@ export function sendManagedSkillFallback(result: ManagedAgentSkillEnsureResult):
|
||||
if (!shouldEmitManagedAgentSkillFallback(result)) {
|
||||
return
|
||||
}
|
||||
publishManagedSkillFallback(result)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('skills:managedFallback', result)
|
||||
}
|
||||
@@ -93,6 +99,7 @@ export function sendManagedSkillUpdated(result: ManagedAgentSkillEnsureResult):
|
||||
if (result.status !== 'updated') {
|
||||
return
|
||||
}
|
||||
publishManagedSkillUpdated(result)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
window.webContents.send('skills:managedUpdated', result)
|
||||
}
|
||||
|
||||
@@ -120,6 +120,11 @@ const METHODS = [
|
||||
params: z.object({}),
|
||||
handler: () => ({ id: 'msg-1' })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'emulator.tap',
|
||||
params: z.object({}),
|
||||
handler: () => ({ tapped: true })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'browser.fail',
|
||||
params: z.object({}),
|
||||
@@ -137,10 +142,12 @@ describe('RpcDispatcher feature interactions', () => {
|
||||
await dispatcher.dispatch(makeRequest('browser.click'))
|
||||
await dispatcher.dispatch(makeRequest('computer.click'))
|
||||
await dispatcher.dispatch(makeRequest('orchestration.send'))
|
||||
await dispatcher.dispatch(makeRequest('emulator.tap'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-browser-use')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('computer-use')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-orchestration')
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('mobile-emulator-agent-use')
|
||||
})
|
||||
|
||||
it('nudges managed skills for runtime feature use without blocking responses', async () => {
|
||||
@@ -180,6 +187,20 @@ describe('RpcDispatcher feature interactions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('nudges orca-cli when mobile emulator runtime methods are used', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const nudgeManagedSkill = vi.fn()
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS, nudgeManagedSkill })
|
||||
|
||||
await dispatcher.dispatch(makeRequest('emulator.tap'))
|
||||
await Promise.resolve()
|
||||
|
||||
expect(nudgeManagedSkill).toHaveBeenCalledWith({
|
||||
skillName: 'orca-cli',
|
||||
context: 'agent-orca-cli'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks runtime managed-skill nudges remote without passing host discovery targets', async () => {
|
||||
const runtime = makeRuntime()
|
||||
const nudgeManagedSkill = vi.fn()
|
||||
@@ -273,6 +294,7 @@ describe('RpcDispatcher feature interactions', () => {
|
||||
makeRequest('browser.screencast', browserPaneUiParams),
|
||||
() => {}
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('emulator.tap', browserPaneUiParams))
|
||||
await dispatcher.dispatch(makeRequest('browser.click'))
|
||||
|
||||
expect(runtime.recordFeatureInteraction).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -30,7 +30,7 @@ export function getManagedSkillNudgeForFeatureInteraction(
|
||||
if (id === 'computer-use') {
|
||||
return { skillName: COMPUTER_USE_SKILL_NAME, context: 'agent-computer-use' }
|
||||
}
|
||||
if (id === 'agent-browser-use') {
|
||||
if (id === 'agent-browser-use' || id === 'mobile-emulator-agent-use') {
|
||||
return { skillName: ORCA_CLI_SKILL_NAME, context: 'agent-orca-cli' }
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest, RpcResponse } from '../core'
|
||||
import { SKILL_METHODS } from './skills'
|
||||
import {
|
||||
publishManagedSkillFallback,
|
||||
publishManagedSkillUpdated
|
||||
} from '../../../skills/managed-skill-events'
|
||||
import type { ManagedAgentSkillFallback, ManagedAgentSkillUpdated } from '../../../../shared/skills'
|
||||
|
||||
function makeRequest(method: string): RpcRequest {
|
||||
return {
|
||||
id: 'req-1',
|
||||
authToken: 'token',
|
||||
method
|
||||
}
|
||||
}
|
||||
|
||||
function makeRuntime(): {
|
||||
runtime: {
|
||||
getRuntimeId: () => string
|
||||
listRepos: () => unknown[]
|
||||
registerSubscriptionCleanup: (
|
||||
subscriptionId: string,
|
||||
cleanup: () => void,
|
||||
connectionId?: string
|
||||
) => void
|
||||
cleanupSubscription: (subscriptionId: string) => void
|
||||
}
|
||||
cleanups: Map<string, () => void>
|
||||
} {
|
||||
const cleanups = new Map<string, () => void>()
|
||||
return {
|
||||
cleanups,
|
||||
runtime: {
|
||||
getRuntimeId: () => 'runtime-1',
|
||||
listRepos: () => [],
|
||||
registerSubscriptionCleanup: vi.fn((subscriptionId, cleanup) => {
|
||||
cleanups.set(subscriptionId, cleanup)
|
||||
}),
|
||||
cleanupSubscription: (subscriptionId) => {
|
||||
const cleanup = cleanups.get(subscriptionId)
|
||||
if (!cleanup) {
|
||||
return
|
||||
}
|
||||
cleanups.delete(subscriptionId)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseResponse(raw: string): RpcResponse {
|
||||
return JSON.parse(raw) as RpcResponse
|
||||
}
|
||||
|
||||
describe('skills runtime RPC methods', () => {
|
||||
it('streams managed skill events from the main-process event bus', async () => {
|
||||
const { runtime, cleanups } = makeRuntime()
|
||||
const dispatcher = new RpcDispatcher({ runtime: runtime as never, methods: SKILL_METHODS })
|
||||
const responses: RpcResponse[] = []
|
||||
|
||||
const dispatch = dispatcher.dispatchStreaming(
|
||||
makeRequest('skills.managedEvents'),
|
||||
(response) => {
|
||||
responses.push(parseResponse(response))
|
||||
},
|
||||
{ connectionId: 'conn-1' }
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(responses[0]).toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'ready', subscriptionId: expect.stringContaining('conn-1') }
|
||||
})
|
||||
})
|
||||
|
||||
const fallbackEvent = {
|
||||
status: 'fallback',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
runtime: 'host',
|
||||
scope: 'missing',
|
||||
reason: 'missing-install',
|
||||
uiKey: 'host::orca-linear:linear-worktree',
|
||||
message: 'Install orca-linear.',
|
||||
request: { skillName: 'orca-linear', context: 'linear-worktree' }
|
||||
} satisfies ManagedAgentSkillFallback
|
||||
const updatedEvent = {
|
||||
status: 'updated',
|
||||
skillName: 'orchestration',
|
||||
context: 'agent-orchestration',
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
} satisfies ManagedAgentSkillUpdated
|
||||
|
||||
publishManagedSkillFallback(fallbackEvent)
|
||||
publishManagedSkillUpdated(updatedEvent)
|
||||
|
||||
expect(responses).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'fallback', event: fallbackEvent }
|
||||
}),
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'updated', event: updatedEvent }
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
const ready = responses[0]
|
||||
const subscriptionId =
|
||||
ready.ok && typeof ready.result === 'object' && ready.result
|
||||
? (ready.result as { subscriptionId?: string }).subscriptionId
|
||||
: undefined
|
||||
expect(subscriptionId).toBeTruthy()
|
||||
runtime.cleanupSubscription(subscriptionId!)
|
||||
await dispatch
|
||||
|
||||
expect(cleanups.size).toBe(0)
|
||||
expect(responses.at(-1)).toMatchObject({
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'end' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,38 @@
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
|
||||
import { discoverSkills } from '../../../skills/discovery'
|
||||
import { onManagedSkillEvent } from '../../../skills/managed-skill-events'
|
||||
|
||||
export const SKILL_METHODS: RpcMethod[] = [
|
||||
let managedSkillEventsSubscriptionSeq = 0
|
||||
|
||||
export const SKILL_METHODS: RpcAnyMethod[] = [
|
||||
defineMethod({
|
||||
name: 'skills.discover',
|
||||
params: null,
|
||||
handler: async (_params, { runtime }) => discoverSkills({ repos: runtime.listRepos() })
|
||||
}),
|
||||
defineStreamingMethod({
|
||||
name: 'skills.managedEvents',
|
||||
params: null,
|
||||
handler: async (_params, { runtime, connectionId }, emit) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const unsubscribe = onManagedSkillEvent((event) => {
|
||||
emit(event)
|
||||
})
|
||||
|
||||
const seq = ++managedSkillEventsSubscriptionSeq
|
||||
const subscriptionId = `skills-managed-events-${connectionId ?? 'inproc'}-${seq}`
|
||||
runtime.registerSubscriptionCleanup(
|
||||
subscriptionId,
|
||||
() => {
|
||||
unsubscribe()
|
||||
emit({ type: 'end' })
|
||||
resolve()
|
||||
},
|
||||
connectionId
|
||||
)
|
||||
|
||||
emit({ type: 'ready', subscriptionId })
|
||||
})
|
||||
}
|
||||
})
|
||||
]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
|
||||
import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
import {
|
||||
isBrowserPaneUiRuntimeRpcParams,
|
||||
isUiOwnedRuntimeRpcParams
|
||||
} from '../../../shared/runtime-rpc-feature-interaction-source'
|
||||
|
||||
export function getRuntimeFeatureInteractionId(
|
||||
method: string,
|
||||
@@ -21,9 +24,12 @@ export function getRuntimeFeatureInteractionId(
|
||||
if (method.startsWith('browser.') && !method.startsWith('browser.profile')) {
|
||||
return 'agent-browser-use'
|
||||
}
|
||||
if (method.startsWith('emulator.')) {
|
||||
if (method.startsWith('emulator.') && isUiOwnedRuntimeRpcParams(rawParams)) {
|
||||
return null
|
||||
}
|
||||
if (method.startsWith('emulator.')) {
|
||||
return 'mobile-emulator-agent-use'
|
||||
}
|
||||
if (method === 'computer.permissions') {
|
||||
return 'computer-use-setup'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ManagedAgentSkillScope
|
||||
} from '../../shared/skills'
|
||||
import { getManagedSkillFallbackMessage } from './managed-skill-fallback-message'
|
||||
import { buildManagedSkillManualCommand } from './managed-skill-update-contract'
|
||||
|
||||
export function buildManagedSkillReadyResult(
|
||||
request: ManagedAgentSkillEnsureRequest
|
||||
@@ -21,6 +22,31 @@ export function buildManagedSkillReadyResult(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildManagedSkillUpdatedResult(
|
||||
request: ManagedAgentSkillEnsureRequest
|
||||
): ManagedAgentSkillEnsureResult {
|
||||
return {
|
||||
status: 'updated',
|
||||
skillName: request.skillName,
|
||||
context: request.context,
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildManagedSkillGlobalUpdateFallback(
|
||||
request: ManagedAgentSkillEnsureRequest,
|
||||
reason: Extract<ManagedAgentSkillFallbackReason, 'update-failed' | 'update-timeout'>
|
||||
): ManagedAgentSkillFallback {
|
||||
return buildManagedSkillFallback({
|
||||
request,
|
||||
reason,
|
||||
runtime: 'host',
|
||||
scope: 'global',
|
||||
manualCommand: buildManagedSkillManualCommand('update', request.skillName)
|
||||
})
|
||||
}
|
||||
|
||||
export function buildManagedSkillFallback(args: {
|
||||
request: ManagedAgentSkillEnsureRequest
|
||||
reason: ManagedAgentSkillFallbackReason
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type {
|
||||
ManagedAgentSkillEnsureResult,
|
||||
ManagedAgentSkillFallback,
|
||||
ManagedAgentSkillUpdated
|
||||
} from '../../shared/skills'
|
||||
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
|
||||
|
||||
export type ManagedSkillEvent =
|
||||
| { type: 'fallback'; event: ManagedAgentSkillFallback }
|
||||
| { type: 'updated'; event: ManagedAgentSkillUpdated }
|
||||
|
||||
const managedSkillEventListeners = new Set<(event: ManagedSkillEvent) => void>()
|
||||
|
||||
export function onManagedSkillEvent(listener: (event: ManagedSkillEvent) => void): () => void {
|
||||
managedSkillEventListeners.add(listener)
|
||||
return () => {
|
||||
managedSkillEventListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function publishManagedSkillFallback(result: ManagedAgentSkillEnsureResult): void {
|
||||
if (!shouldEmitManagedAgentSkillFallback(result)) {
|
||||
return
|
||||
}
|
||||
publishManagedSkillEvent({ type: 'fallback', event: result })
|
||||
}
|
||||
|
||||
export function publishManagedSkillUpdated(result: ManagedAgentSkillEnsureResult): void {
|
||||
if (result.status !== 'updated') {
|
||||
return
|
||||
}
|
||||
publishManagedSkillEvent({ type: 'updated', event: result })
|
||||
}
|
||||
|
||||
function publishManagedSkillEvent(event: ManagedSkillEvent): void {
|
||||
for (const listener of managedSkillEventListeners) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
ManagedAgentSkillManualCommand,
|
||||
ManagedAgentSkillName,
|
||||
ManagedAgentSkillScope,
|
||||
SkillDiscoveryResult
|
||||
} from '../../shared/skills'
|
||||
import { selectManagedSkillDiscoveryCandidates } from './managed-skill-discovery-selection'
|
||||
import { normalizePathForManagedSkillKey } from './managed-skill-update-cache-key'
|
||||
import { buildManagedSkillManualCommand } from './managed-skill-update-contract'
|
||||
|
||||
type ManagedSkillGlobalCandidate = SkillDiscoveryResult['skills'][number]
|
||||
@@ -90,7 +92,7 @@ export function selectSingleGlobalManagedSkillCandidate(args: {
|
||||
}
|
||||
|
||||
const candidate = homeCandidates[0]
|
||||
if (candidate.directoryIsSymlink || candidate.skillFileIsSymlink) {
|
||||
if (hasSymlinkedSkillPath(candidate)) {
|
||||
return {
|
||||
status: 'fallback',
|
||||
fallback: { reason: 'symlinked-global-install', scope: 'global' }
|
||||
@@ -98,3 +100,19 @@ export function selectSingleGlobalManagedSkillCandidate(args: {
|
||||
}
|
||||
return { status: 'candidate', candidate }
|
||||
}
|
||||
|
||||
function hasSymlinkedSkillPath(candidate: DiscoveredSkill): boolean {
|
||||
return (
|
||||
candidate.directoryIsSymlink === true ||
|
||||
candidate.skillFileIsSymlink === true ||
|
||||
!isCanonicalPath(candidate.directoryPath, candidate.realDirectoryPath) ||
|
||||
!isCanonicalPath(candidate.skillFilePath, candidate.realSkillFilePath)
|
||||
)
|
||||
}
|
||||
|
||||
function isCanonicalPath(path: string, realPath: string | null | undefined): boolean {
|
||||
return (
|
||||
typeof realPath === 'string' &&
|
||||
normalizePathForManagedSkillKey(realPath) === normalizePathForManagedSkillKey(path)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ManagedAgentSkillEnsureRequest, SkillDiscoveryResult } from '../../shared/skills'
|
||||
import { selectSingleGlobalManagedSkillCandidate } from './managed-skill-global-candidate'
|
||||
import { readManagedSkillLockEntry } from './managed-skill-lockfile'
|
||||
|
||||
export async function verifyManagedSkillPostUpdate(args: {
|
||||
discoverHostSkills: (projectRootPath?: string | null) => Promise<SkillDiscoveryResult>
|
||||
homeDir: string
|
||||
readTextFile: (path: string) => Promise<string>
|
||||
request: ManagedAgentSkillEnsureRequest
|
||||
}): Promise<{ ok: true; lockHash: string } | { ok: false }> {
|
||||
const postDiscovery = await args.discoverHostSkills(args.request.discoveryTarget?.projectRootPath)
|
||||
const postCandidateDecision = selectSingleGlobalManagedSkillCandidate({
|
||||
discovery: postDiscovery,
|
||||
homeDir: args.homeDir,
|
||||
projectRootPath: args.request.discoveryTarget?.projectRootPath,
|
||||
skillName: args.request.skillName
|
||||
})
|
||||
if (postCandidateDecision.status === 'fallback') {
|
||||
return { ok: false }
|
||||
}
|
||||
|
||||
const postLockEntryResult = await readManagedSkillLockEntry({
|
||||
homeDir: args.homeDir,
|
||||
readTextFile: args.readTextFile,
|
||||
skillName: args.request.skillName
|
||||
})
|
||||
return postLockEntryResult.ok
|
||||
? { ok: true, lockHash: postLockEntryResult.entry.skillFolderHash }
|
||||
: { ok: false }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '../../shared/agent-feature-install-commands'
|
||||
import type {
|
||||
DiscoveredSkill,
|
||||
ManagedAgentSkillEnsureRequest,
|
||||
ManagedAgentSkillName,
|
||||
SkillDiscoveryResult
|
||||
} from '../../shared/skills'
|
||||
|
||||
export const TEST_MANAGED_HOME_ROOT = join(homedir(), '.agents', 'skills')
|
||||
|
||||
export const orchestrationRequest: ManagedAgentSkillEnsureRequest = {
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
}
|
||||
|
||||
export function discoveredSkill(
|
||||
patch: Partial<DiscoveredSkill> & Pick<DiscoveredSkill, 'name' | 'sourceKind'>
|
||||
): DiscoveredSkill {
|
||||
const rootPath = patch.rootPath ?? TEST_MANAGED_HOME_ROOT
|
||||
const directoryPath = patch.directoryPath ?? join(rootPath, patch.name)
|
||||
return {
|
||||
id: patch.id ?? `${patch.sourceKind}-${patch.name}`,
|
||||
name: patch.name,
|
||||
description: patch.description ?? null,
|
||||
providers: patch.providers ?? ['agent-skills'],
|
||||
sourceKind: patch.sourceKind,
|
||||
sourceLabel: patch.sourceLabel ?? patch.sourceKind,
|
||||
rootPath,
|
||||
directoryPath,
|
||||
realDirectoryPath: patch.realDirectoryPath ?? directoryPath,
|
||||
directoryIsSymlink: patch.directoryIsSymlink ?? false,
|
||||
skillFilePath: patch.skillFilePath ?? join(directoryPath, 'SKILL.md'),
|
||||
realSkillFilePath: patch.realSkillFilePath ?? join(directoryPath, 'SKILL.md'),
|
||||
skillFileIsSymlink: patch.skillFileIsSymlink ?? false,
|
||||
installed: patch.installed ?? true,
|
||||
fileCount: patch.fileCount ?? 1,
|
||||
updatedAt: patch.updatedAt ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
export function discovery(skills: DiscoveredSkill[]): SkillDiscoveryResult {
|
||||
return { skills, sources: [], scannedAt: 1 }
|
||||
}
|
||||
|
||||
export function homeDiscovery(
|
||||
skillName: ManagedAgentSkillName = ORCHESTRATION_SKILL_NAME
|
||||
): SkillDiscoveryResult {
|
||||
return discovery([discoveredSkill({ name: skillName, sourceKind: 'home' })])
|
||||
}
|
||||
|
||||
export function lockfile(skillName: ManagedAgentSkillName, skillFolderHash: string): string {
|
||||
return JSON.stringify({
|
||||
version: 3,
|
||||
skills: {
|
||||
[skillName]: {
|
||||
source: 'stablyai/orca',
|
||||
sourceType: 'github',
|
||||
sourceUrl: 'https://github.com/stablyai/orca.git',
|
||||
skillPath: `skills/${skillName}/SKILL.md`,
|
||||
skillFolderHash
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isAbsolute, normalize, relative, sep } from 'node:path'
|
||||
import type {
|
||||
ManagedAgentSkillEnsureRequest,
|
||||
ManagedAgentSkillEnsureResult
|
||||
ManagedAgentSkillEnsureResult,
|
||||
ManagedAgentSkillFallbackReason,
|
||||
ManagedAgentSkillRuntime
|
||||
} from '../../shared/skills'
|
||||
import type { ExpectedManagedSkillRevision } from './managed-skill-update-contract'
|
||||
|
||||
export function shouldCooldownFallback(result: ManagedAgentSkillEnsureResult): boolean {
|
||||
return (
|
||||
@@ -16,18 +17,48 @@ export function shouldCooldownFallback(result: ManagedAgentSkillEnsureResult): b
|
||||
export function makeManagedSkillSuccessCacheKey(args: {
|
||||
appVersion: string
|
||||
request: ManagedAgentSkillEnsureRequest
|
||||
expected: ExpectedManagedSkillRevision | undefined
|
||||
currentLockHash: string
|
||||
}): string {
|
||||
return [args.appVersion, 'host', '', 'global', args.request.skillName, args.currentLockHash].join(
|
||||
':'
|
||||
)
|
||||
}
|
||||
|
||||
export function makeManagedSkillTargetFallbackCacheKey(args: {
|
||||
appVersion: string
|
||||
distro?: string | null
|
||||
reason: ManagedAgentSkillFallbackReason
|
||||
request: ManagedAgentSkillEnsureRequest
|
||||
runtime: ManagedAgentSkillRuntime
|
||||
}): string {
|
||||
return [
|
||||
args.appVersion,
|
||||
'host',
|
||||
'',
|
||||
'global',
|
||||
args.runtime,
|
||||
args.distro ?? '',
|
||||
'target-fallback',
|
||||
normalizeManagedSkillKeyPart(args.request.context),
|
||||
normalizeManagedSkillKeyPart(args.request.discoveryTarget?.projectRootPath),
|
||||
args.request.skillName,
|
||||
args.expected?.expectedHash ?? 'missing-expected-hash',
|
||||
args.expected?.expectedSourceRef ?? 'missing-source-ref'
|
||||
args.reason
|
||||
].join(':')
|
||||
}
|
||||
|
||||
export function makeManagedSkillPreDiscoveryCacheKey(args: {
|
||||
appVersion: string
|
||||
backgroundUpdatesEnabled: boolean
|
||||
distro?: string | null
|
||||
request: ManagedAgentSkillEnsureRequest
|
||||
runtime: Extract<ManagedAgentSkillRuntime, 'host' | 'wsl'>
|
||||
}): string {
|
||||
return [
|
||||
args.appVersion,
|
||||
args.runtime,
|
||||
args.distro ?? '',
|
||||
'pre-discovery',
|
||||
args.backgroundUpdatesEnabled ? 'background-updates-on' : 'background-updates-off',
|
||||
normalizeManagedSkillKeyPart(args.request.context),
|
||||
normalizeManagedSkillKeyPart(args.request.discoveryTarget?.projectRootPath),
|
||||
args.request.skillName
|
||||
].join(':')
|
||||
}
|
||||
|
||||
|
||||
@@ -2,25 +2,15 @@ import {
|
||||
buildAgentFeatureSkillInstallCommand,
|
||||
buildAgentFeatureSkillUpdateCommand,
|
||||
COMPUTER_USE_SKILL_NAME,
|
||||
LINEAR_TICKETS_SKILL_NAME,
|
||||
ORCA_LINEAR_SKILL_NAME,
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME
|
||||
} from '../../shared/agent-feature-install-commands'
|
||||
import type { ManagedAgentSkillManualCommand, ManagedAgentSkillName } from '../../shared/skills'
|
||||
|
||||
export type ExpectedManagedSkillRevision = {
|
||||
expectedHash: string
|
||||
expectedSourceRef: string
|
||||
skillsPackageVersion: string
|
||||
}
|
||||
|
||||
export const EXPECTED_MANAGED_SKILL_REVISIONS: Partial<
|
||||
Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>
|
||||
> = {}
|
||||
|
||||
const MANAGED_SKILL_NAMES = new Set<string>([
|
||||
COMPUTER_USE_SKILL_NAME,
|
||||
LINEAR_TICKETS_SKILL_NAME,
|
||||
ORCA_LINEAR_SKILL_NAME,
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME
|
||||
])
|
||||
@@ -29,10 +19,6 @@ export function isManagedAgentSkillName(value: string): value is ManagedAgentSki
|
||||
return MANAGED_SKILL_NAMES.has(value)
|
||||
}
|
||||
|
||||
export function isImmutableSourceRef(value: string): boolean {
|
||||
return /^[a-f0-9]{40}$/i.test(value)
|
||||
}
|
||||
|
||||
export function buildManagedSkillManualCommand(
|
||||
kind: ManagedAgentSkillManualCommand['kind'],
|
||||
skillName: ManagedAgentSkillName
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Store } from '../persistence'
|
||||
import { discoverSkills } from './discovery'
|
||||
import { ManagedSkillUpdateCoordinator } from './managed-skill-updates'
|
||||
|
||||
const coordinatorByStore = new WeakMap<Store, ManagedSkillUpdateCoordinator>()
|
||||
|
||||
export function getManagedSkillUpdateCoordinator(store: Store): ManagedSkillUpdateCoordinator {
|
||||
const existing = coordinatorByStore.get(store)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
appVersion: process.env.npm_package_version,
|
||||
backgroundUpdatesEnabled: () =>
|
||||
store.getSettings().managedAgentSkillBackgroundUpdatesEnabled !== false,
|
||||
discoverHostSkills: (projectRootPath) =>
|
||||
discoverSkills({
|
||||
repos: store.getRepos(),
|
||||
...(projectRootPath ? { cwd: projectRootPath } : {})
|
||||
})
|
||||
})
|
||||
coordinatorByStore.set(store, coordinator)
|
||||
return coordinator
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '../../shared/agent-feature-install-commands'
|
||||
import { createManagedSkillUpdateRunner } from './managed-skill-update-runner'
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn()
|
||||
}))
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
readonly kill = vi.fn()
|
||||
readonly unref = vi.fn()
|
||||
|
||||
constructor(readonly pid?: number) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
describe('createManagedSkillUpdateRunner', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.mocked(spawn).mockReset()
|
||||
})
|
||||
|
||||
it('resolves timeout without waiting for the child close event', async () => {
|
||||
vi.useFakeTimers()
|
||||
const child = new FakeChildProcess()
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const runner = createManagedSkillUpdateRunner({ timeoutMs: 50 })
|
||||
|
||||
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ status: 'timeout' })
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('kills the process when the shutdown signal aborts', async () => {
|
||||
const child = new FakeChildProcess()
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const controller = new AbortController()
|
||||
const runner = createManagedSkillUpdateRunner({ signal: controller.signal, timeoutMs: 1_000 })
|
||||
|
||||
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
|
||||
controller.abort()
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ status: 'failure', error: 'aborted' })
|
||||
expect(child.kill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('spawns the single-skill global update command without a shell from a neutral cwd', async () => {
|
||||
const child = new FakeChildProcess()
|
||||
vi.mocked(spawn).mockReturnValue(child as never)
|
||||
const runner = createManagedSkillUpdateRunner({ cwd: '/home/alice', timeoutMs: 1_000 })
|
||||
|
||||
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
|
||||
child.emit('close', 0)
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ status: 'success' })
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
['--yes', 'skills', 'update', 'orchestration', '--global', '--yes'],
|
||||
{
|
||||
cwd: '/home/alice',
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('uses taskkill for Windows process-tree cleanup on timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const child = new FakeChildProcess(123)
|
||||
const killer = new FakeChildProcess()
|
||||
vi.mocked(spawn)
|
||||
.mockReturnValueOnce(child as never)
|
||||
.mockReturnValueOnce(killer as never)
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
const runner = createManagedSkillUpdateRunner({ timeoutMs: 50 })
|
||||
|
||||
const resultPromise = runner(ORCHESTRATION_SKILL_NAME)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ status: 'timeout' })
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'taskkill',
|
||||
['/pid', '123', '/t', '/f'],
|
||||
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
|
||||
)
|
||||
expect(killer.unref).toHaveBeenCalledTimes(1)
|
||||
platform.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import type { ManagedAgentSkillName } from '../../shared/skills'
|
||||
|
||||
export type ManagedSkillUpdateRunnerResult =
|
||||
| { status: 'success' }
|
||||
| { status: 'failure'; exitCode?: number | null; error?: string }
|
||||
| { status: 'timeout' }
|
||||
|
||||
export type ManagedSkillUpdateRunner = (
|
||||
skillName: ManagedAgentSkillName
|
||||
) => Promise<ManagedSkillUpdateRunnerResult>
|
||||
|
||||
const DEFAULT_UPDATE_TIMEOUT_MS = 120_000
|
||||
|
||||
export function buildManagedSkillUpdateCommand(skillName: ManagedAgentSkillName): {
|
||||
executable: string
|
||||
args: string[]
|
||||
} {
|
||||
return {
|
||||
executable: process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
args: ['--yes', 'skills', 'update', skillName, '--global', '--yes']
|
||||
}
|
||||
}
|
||||
|
||||
export function createManagedSkillUpdateRunner(
|
||||
args: {
|
||||
cwd?: string
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
} = {}
|
||||
): ManagedSkillUpdateRunner {
|
||||
const timeoutMs = args.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS
|
||||
const cwd = args.cwd ?? homedir()
|
||||
return (skillName) => runManagedSkillUpdate(skillName, timeoutMs, cwd, args.signal)
|
||||
}
|
||||
|
||||
async function runManagedSkillUpdate(
|
||||
skillName: ManagedAgentSkillName,
|
||||
timeoutMs: number,
|
||||
cwd: string,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<ManagedSkillUpdateRunnerResult> {
|
||||
const command = buildManagedSkillUpdateCommand(skillName)
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const child = spawn(command.executable, command.args, {
|
||||
cwd,
|
||||
shell: false,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
const killChildTree = (): void => {
|
||||
killSpawnedCommandTree(child)
|
||||
}
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeout)
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
const finish = (result: ManagedSkillUpdateRunnerResult): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(result)
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
killChildTree()
|
||||
finish({ status: 'failure', error: 'aborted' })
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
killChildTree()
|
||||
finish({ status: 'timeout' })
|
||||
}, timeoutMs)
|
||||
if (signal?.aborted) {
|
||||
onAbort()
|
||||
return
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
child.once('error', (error) => {
|
||||
finish({ status: 'failure', error: error.message })
|
||||
})
|
||||
child.once('close', (exitCode) => {
|
||||
finish(exitCode === 0 ? { status: 'success' } : { status: 'failure', exitCode })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function killSpawnedCommandTree(child: ChildProcess): void {
|
||||
const pid = child.pid
|
||||
if (!pid || process.platform !== 'win32') {
|
||||
child.kill()
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: `npx.cmd` can leave its node/npm child alive if only cmd.exe is killed.
|
||||
const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
killer.on('error', () => child.kill())
|
||||
killer.unref()
|
||||
} catch {
|
||||
child.kill()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ORCA_LINEAR_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME
|
||||
} from '../../shared/agent-feature-install-commands'
|
||||
import type { ManagedAgentSkillEnsureRequest, ManagedAgentSkillName } from '../../shared/skills'
|
||||
import {
|
||||
TEST_MANAGED_HOME_ROOT,
|
||||
discoveredSkill,
|
||||
discovery,
|
||||
homeDiscovery,
|
||||
lockfile,
|
||||
orchestrationRequest
|
||||
} from './managed-skill-test-fixtures'
|
||||
import { buildManagedSkillUpdateCommand } from './managed-skill-update-runner'
|
||||
import { ManagedSkillUpdateCoordinator } from './managed-skill-updates'
|
||||
|
||||
describe('ManagedSkillUpdateCoordinator safety cases', () => {
|
||||
it('does not auto-update project, ambiguous, bundled, plugin, or symlinked installs', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const cases = [
|
||||
{
|
||||
skills: [
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
],
|
||||
reason: 'project-install'
|
||||
},
|
||||
{
|
||||
skills: [
|
||||
discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' }),
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
],
|
||||
reason: 'ambiguous-install'
|
||||
},
|
||||
{
|
||||
skills: [discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'bundled' })],
|
||||
reason: 'bundled-or-plugin-install'
|
||||
},
|
||||
{
|
||||
skills: [discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'plugin' })],
|
||||
reason: 'bundled-or-plugin-install'
|
||||
},
|
||||
{
|
||||
skills: [
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
directoryIsSymlink: true
|
||||
})
|
||||
],
|
||||
reason: 'symlinked-global-install'
|
||||
},
|
||||
{
|
||||
skills: [
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
directoryPath: '/home/alice/.agents/skills/orchestration',
|
||||
realDirectoryPath: '/mnt/central/skills/orchestration',
|
||||
skillFilePath: '/home/alice/.agents/skills/orchestration/SKILL.md',
|
||||
realSkillFilePath: '/mnt/central/skills/orchestration/SKILL.md'
|
||||
})
|
||||
],
|
||||
reason: 'symlinked-global-install'
|
||||
}
|
||||
] as const
|
||||
|
||||
for (const testCase of cases) {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () => discovery([...testCase.skills]),
|
||||
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
|
||||
updateRunner
|
||||
})
|
||||
const result = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe(testCase.reason)
|
||||
}
|
||||
expect(updateRunner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not treat Codex or Claude home skills as managed global installs', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
homeDir: () => '/home/alice',
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
providers: ['codex'],
|
||||
rootPath: '/home/alice/.codex/skills',
|
||||
directoryPath: '/home/alice/.codex/skills/orchestration'
|
||||
})
|
||||
]),
|
||||
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('missing-install')
|
||||
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('install')
|
||||
expect(updateRunner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps concurrent checks for different project roots independent', async () => {
|
||||
const discoverHostSkills = vi.fn(async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
rootPath: TEST_MANAGED_HOME_ROOT
|
||||
}),
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
)
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills,
|
||||
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'hash-1'),
|
||||
updateRunner: async () => ({ status: 'success' })
|
||||
})
|
||||
|
||||
const [currentProject, otherProject] = await Promise.all([
|
||||
coordinator.ensureManagedReady(orchestrationRequest),
|
||||
coordinator.ensureManagedReady({
|
||||
...orchestrationRequest,
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
|
||||
})
|
||||
])
|
||||
|
||||
expect(currentProject.status === 'fallback' ? currentProject.reason : null).toBe(
|
||||
'ambiguous-install'
|
||||
)
|
||||
expect(otherProject.status).toBe('ready')
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('returns missing install with an install command without auto-installing', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () => discovery([]),
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCA_LINEAR_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('missing-install')
|
||||
expect(result.status === 'fallback' ? result.manualCommand?.command : null).toBe(
|
||||
'npx --yes skills add https://github.com/stablyai/orca --skill orca-linear --global --yes'
|
||||
)
|
||||
expect(updateRunner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dedupes concurrent missing-install checks before discovery', async () => {
|
||||
const discoverHostSkills = vi.fn(async () => discovery([]))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
coordinator.ensureManagedReady({
|
||||
skillName: ORCA_LINEAR_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
}),
|
||||
coordinator.ensureManagedReady({
|
||||
skillName: ORCA_LINEAR_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
])
|
||||
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
|
||||
expect(second.status === 'fallback' ? second.reason : null).toBe('missing-install')
|
||||
})
|
||||
|
||||
it('cooldowns repeated missing-install checks and lets force bypass that cooldown', async () => {
|
||||
let now = 100
|
||||
const discoverHostSkills = vi.fn(async () => discovery([]))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
cooldownMs: 1_000,
|
||||
now: () => now,
|
||||
discoverHostSkills
|
||||
})
|
||||
const request: ManagedAgentSkillEnsureRequest = {
|
||||
skillName: ORCA_LINEAR_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
}
|
||||
|
||||
const first = await coordinator.ensureManagedReady(request)
|
||||
now = 200
|
||||
const second = await coordinator.ensureManagedReady(request)
|
||||
const forced = await coordinator.ensureManagedReady({ ...request, force: true })
|
||||
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
|
||||
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
|
||||
expect(forced.status === 'fallback' ? forced.reason : null).toBe('missing-install')
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('falls back for WSL, remote, and target-required runtime cases without local commands', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator()
|
||||
|
||||
const wsl = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
|
||||
})
|
||||
const remote = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
remoteRuntime: true
|
||||
})
|
||||
const unknown = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration'
|
||||
})
|
||||
|
||||
expect(wsl.status === 'fallback' ? wsl.reason : null).toBe('wsl-runtime')
|
||||
expect(wsl.status === 'fallback' ? wsl.manualCommand : undefined).toBeUndefined()
|
||||
expect(remote.status === 'fallback' ? remote.reason : null).toBe('remote-runtime')
|
||||
expect(remote.status === 'fallback' ? remote.manualCommand : undefined).toBeUndefined()
|
||||
expect(unknown.status === 'fallback' ? unknown.reason : null).toBe('target-required')
|
||||
})
|
||||
|
||||
it('uses canonical orca-linear as the managed Linear skill and leaves legacy unsupported', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () => homeDiscovery(ORCA_LINEAR_SKILL_NAME),
|
||||
readTextFile: async () => lockfile(ORCA_LINEAR_SKILL_NAME, 'hash-1'),
|
||||
updateRunner: async () => ({ status: 'success' })
|
||||
})
|
||||
|
||||
const canonical = await coordinator.ensureManagedReady({
|
||||
skillName: ORCA_LINEAR_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
const legacy = await coordinator.ensureManagedReady({
|
||||
skillName: 'linear-tickets' as ManagedAgentSkillName,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(canonical.status).toBe('ready')
|
||||
expect(legacy.status === 'fallback' ? legacy.reason : null).toBe('unsupported-skill')
|
||||
})
|
||||
|
||||
it('builds the exact single-skill global update command and Windows executable seam', () => {
|
||||
expect(buildManagedSkillUpdateCommand(ORCHESTRATION_SKILL_NAME)).toEqual({
|
||||
executable: 'npx',
|
||||
args: ['--yes', 'skills', 'update', 'orchestration', '--global', '--yes']
|
||||
})
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
|
||||
expect(buildManagedSkillUpdateCommand(ORCHESTRATION_SKILL_NAME)).toEqual({
|
||||
executable: 'npx.cmd',
|
||||
args: ['--yes', 'skills', 'update', 'orchestration', '--global', '--yes']
|
||||
})
|
||||
platform.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,638 +1,304 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
LINEAR_TICKETS_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME
|
||||
} from '../../shared/agent-feature-install-commands'
|
||||
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../shared/skills'
|
||||
import { ORCHESTRATION_SKILL_NAME } from '../../shared/agent-feature-install-commands'
|
||||
import { homeDiscovery, lockfile, orchestrationRequest } from './managed-skill-test-fixtures'
|
||||
import { ManagedSkillUpdateCoordinator } from './managed-skill-updates'
|
||||
|
||||
const TEST_MANAGED_HOME_ROOT = join(homedir(), '.agents', 'skills')
|
||||
|
||||
function discoveredSkill(
|
||||
patch: Partial<DiscoveredSkill> & Pick<DiscoveredSkill, 'name' | 'sourceKind'>
|
||||
): DiscoveredSkill {
|
||||
const rootPath = patch.rootPath ?? TEST_MANAGED_HOME_ROOT
|
||||
const directoryPath = patch.directoryPath ?? join(rootPath, patch.name)
|
||||
return {
|
||||
id: patch.id ?? `${patch.sourceKind}-${patch.name}`,
|
||||
name: patch.name,
|
||||
description: patch.description ?? null,
|
||||
providers: patch.providers ?? ['agent-skills'],
|
||||
sourceKind: patch.sourceKind,
|
||||
sourceLabel: patch.sourceLabel ?? patch.sourceKind,
|
||||
rootPath,
|
||||
directoryPath,
|
||||
realDirectoryPath: patch.realDirectoryPath ?? directoryPath,
|
||||
directoryIsSymlink: patch.directoryIsSymlink ?? false,
|
||||
skillFilePath: patch.skillFilePath ?? join(directoryPath, 'SKILL.md'),
|
||||
realSkillFilePath: patch.realSkillFilePath ?? join(directoryPath, 'SKILL.md'),
|
||||
skillFileIsSymlink: patch.skillFileIsSymlink ?? false,
|
||||
installed: patch.installed ?? true,
|
||||
fileCount: patch.fileCount ?? 1,
|
||||
updatedAt: patch.updatedAt ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
function discovery(skills: DiscoveredSkill[]): SkillDiscoveryResult {
|
||||
return { skills, sources: [], scannedAt: 1 }
|
||||
}
|
||||
|
||||
function lockfile(skillFolderHash: string): string {
|
||||
return JSON.stringify({
|
||||
version: 3,
|
||||
skills: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
source: 'stablyai/orca',
|
||||
sourceType: 'github',
|
||||
sourceUrl: 'https://github.com/stablyai/orca.git',
|
||||
skillPath: `skills/${ORCHESTRATION_SKILL_NAME}/SKILL.md`,
|
||||
skillFolderHash
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ManagedSkillUpdateCoordinator', () => {
|
||||
it('dedupes concurrent missing-install checks before discovery', async () => {
|
||||
const discoverHostSkills = vi.fn(async () => discovery([]))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
coordinator.ensureManagedReady({
|
||||
skillName: LINEAR_TICKETS_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
}),
|
||||
coordinator.ensureManagedReady({
|
||||
skillName: LINEAR_TICKETS_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
])
|
||||
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
|
||||
expect(first.status).toBe('fallback')
|
||||
expect(second.status).toBe('fallback')
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
|
||||
expect(first.status === 'fallback' ? first.manualCommand?.kind : null).toBe('install')
|
||||
})
|
||||
|
||||
it('cooldowns repeated missing-install checks after the first fallback', async () => {
|
||||
let now = 100
|
||||
const discoverHostSkills = vi.fn(async () => discovery([]))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
cooldownMs: 1_000,
|
||||
now: () => now,
|
||||
discoverHostSkills
|
||||
})
|
||||
|
||||
const first = await coordinator.ensureManagedReady({
|
||||
skillName: LINEAR_TICKETS_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
now = 200
|
||||
const second = await coordinator.ensureManagedReady({
|
||||
skillName: LINEAR_TICKETS_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
|
||||
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lets explicit re-checks bypass fallback cooldown', async () => {
|
||||
let now = 100
|
||||
const discoverHostSkills = vi.fn(async () => discovery([]))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
cooldownMs: 1_000,
|
||||
now: () => now,
|
||||
discoverHostSkills
|
||||
})
|
||||
|
||||
await coordinator.ensureManagedReady({
|
||||
skillName: LINEAR_TICKETS_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
now = 200
|
||||
const forced = await coordinator.ensureManagedReady({
|
||||
skillName: LINEAR_TICKETS_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: { runtime: 'host' },
|
||||
force: true
|
||||
})
|
||||
|
||||
expect(forced.status === 'fallback' ? forced.reason : null).toBe('missing-install')
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cooldowns repeated remote-runtime target fallbacks', async () => {
|
||||
let now = 100
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
cooldownMs: 1_000,
|
||||
now: () => now
|
||||
})
|
||||
const request = {
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration' as const,
|
||||
remoteRuntime: true
|
||||
} as const
|
||||
|
||||
const first = await coordinator.ensureManagedReady(request)
|
||||
now = 200
|
||||
const second = await coordinator.ensureManagedReady(request)
|
||||
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('remote-runtime')
|
||||
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
|
||||
})
|
||||
|
||||
it('cooldowns repeated target-required fallbacks', async () => {
|
||||
let now = 100
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
cooldownMs: 1_000,
|
||||
now: () => now
|
||||
})
|
||||
const request = {
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration' as const
|
||||
} as const
|
||||
|
||||
const first = await coordinator.ensureManagedReady(request)
|
||||
now = 200
|
||||
const second = await coordinator.ensureManagedReady(request)
|
||||
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('target-required')
|
||||
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
|
||||
})
|
||||
|
||||
it('falls back for project and symlinked global installs', async () => {
|
||||
const projectCoordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
})
|
||||
const symlinkCoordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
directoryIsSymlink: true
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
const project = await projectCoordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
})
|
||||
const symlink = await symlinkCoordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(project.status === 'fallback' ? project.reason : null).toBe('project-install')
|
||||
expect(symlink.status === 'fallback' ? symlink.reason : null).toBe('symlinked-global-install')
|
||||
})
|
||||
|
||||
it('ignores project installs from unrelated repo roots', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
rootPath: TEST_MANAGED_HOME_ROOT
|
||||
}),
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/unrelated/.agents/skills',
|
||||
directoryPath: '/workspace/unrelated/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('falls back when the current project root has a project-scoped install', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
rootPath: TEST_MANAGED_HOME_ROOT
|
||||
}),
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
})
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('ambiguous-install')
|
||||
})
|
||||
|
||||
it('does not emit a global install command when project scope exists but target is unknown', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('project-install')
|
||||
expect(result.status === 'fallback' ? result.manualCommand : undefined).toBeUndefined()
|
||||
})
|
||||
|
||||
it('passes the project root into host discovery', async () => {
|
||||
const discoverHostSkills = vi.fn(async () => discovery([]))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
|
||||
|
||||
await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
})
|
||||
|
||||
expect(discoverHostSkills).toHaveBeenCalledWith('/workspace/current')
|
||||
})
|
||||
|
||||
it('treats installed global skills as ready when the expected hash manifest is missing', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })])
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('does not treat Codex or Claude home skills as managed global installs', async () => {
|
||||
const readTextFile = vi.fn(async () => lockfile('hash-1'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
homeDir: () => '/home/alice',
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
providers: ['codex'],
|
||||
rootPath: '/home/alice/.codex/skills',
|
||||
directoryPath: '/home/alice/.codex/skills/orchestration'
|
||||
})
|
||||
]),
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-1',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('missing-install')
|
||||
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('install')
|
||||
expect(readTextFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns ready when a verified manifest and lock hash already match', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-1',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile: async () => lockfile('hash-1')
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('serves verified ready checks from the success cache without rediscovery', async () => {
|
||||
const discoverHostSkills = vi.fn(async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })])
|
||||
)
|
||||
const readTextFile = vi.fn(async () => lockfile('hash-1'))
|
||||
it('runs one verified update and returns updated when the post-update lock hash changes', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const readTextFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
|
||||
const discoverHostSkills = vi.fn(async () => homeDiscovery())
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
appVersion: '1.0.0',
|
||||
discoverHostSkills,
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-1',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
const request = {
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration' as const,
|
||||
discoveryTarget: { runtime: 'host' as const, projectRootPath: '/workspace/current' }
|
||||
} as const
|
||||
|
||||
const first = await coordinator.ensureManagedReady(request)
|
||||
const second = await coordinator.ensureManagedReady(request)
|
||||
const result = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
|
||||
expect(first.status).toBe('ready')
|
||||
expect(result.status).toBe('updated')
|
||||
expect(updateRunner).toHaveBeenCalledWith(ORCHESTRATION_SKILL_NAME)
|
||||
expect(updateRunner).toHaveBeenCalledTimes(1)
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns ready when the verified update succeeds without changing the lock hash', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () => homeDiscovery(),
|
||||
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'same-hash'),
|
||||
updateRunner: async () => ({ status: 'success' })
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
|
||||
expect(result.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('serves a successful post-update hash from cache without repeated subprocess or metadata work', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const discoverHostSkills = vi.fn(async () => homeDiscovery())
|
||||
const readTextFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
|
||||
.mockResolvedValue(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
appVersion: '1.0.0',
|
||||
discoverHostSkills,
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const first = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
const second = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
|
||||
expect(first.status).toBe('updated')
|
||||
expect(second.status).toBe('ready')
|
||||
expect(updateRunner).toHaveBeenCalledTimes(1)
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps repeated sequential ready checks on the pre-discovery fast path', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const discoverHostSkills = vi.fn(async () => homeDiscovery())
|
||||
const readTextFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
appVersion: '1.0.0',
|
||||
discoverHostSkills,
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const first = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 50 }, () => coordinator.ensureManagedReady(orchestrationRequest))
|
||||
)
|
||||
|
||||
expect(first.status).toBe('updated')
|
||||
expect(results.every((result) => result.status === 'ready')).toBe(true)
|
||||
expect(updateRunner).toHaveBeenCalledTimes(1)
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reuses a verified global lock hash across workspaces after discovery safety checks', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const discoverHostSkills = vi.fn(async () => homeDiscovery())
|
||||
const readTextFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
|
||||
.mockResolvedValue(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
appVersion: '1.0.0',
|
||||
discoverHostSkills,
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const first = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
const second = await coordinator.ensureManagedReady({
|
||||
...orchestrationRequest,
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
|
||||
})
|
||||
|
||||
expect(first.status).toBe('updated')
|
||||
expect(second.status).toBe('ready')
|
||||
expect(updateRunner).toHaveBeenCalledTimes(1)
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(3)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('dedupes concurrent automatic update attempts for the same trigger', async () => {
|
||||
let releaseRunner: (() => void) | undefined
|
||||
const updateRunner = vi.fn(
|
||||
() =>
|
||||
new Promise<{ status: 'success' }>((resolve) => {
|
||||
releaseRunner = () => resolve({ status: 'success' })
|
||||
})
|
||||
)
|
||||
const readTextFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'new-hash'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () => homeDiscovery(),
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const requests = Array.from({ length: 50 }, () =>
|
||||
coordinator.ensureManagedReady(orchestrationRequest)
|
||||
)
|
||||
await vi.waitFor(() => expect(updateRunner).toHaveBeenCalledTimes(1))
|
||||
if (!releaseRunner) {
|
||||
throw new Error('runner was not called')
|
||||
}
|
||||
releaseRunner()
|
||||
const results = await Promise.all(requests)
|
||||
|
||||
expect(results.every((result) => result.status === 'updated')).toBe(true)
|
||||
expect(updateRunner).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns update-failed with the manual update command and then cooldowns automatic retries', async () => {
|
||||
let now = 100
|
||||
const updateRunner = vi.fn(async () => ({ status: 'failure' as const, exitCode: 1 }))
|
||||
const discoverHostSkills = vi.fn(async () => homeDiscovery())
|
||||
const readTextFile = vi.fn(async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
cooldownMs: 1_000,
|
||||
now: () => now,
|
||||
discoverHostSkills,
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const first = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
now = 200
|
||||
const second = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
const repeated = await Promise.all(
|
||||
Array.from({ length: 50 }, () => coordinator.ensureManagedReady(orchestrationRequest))
|
||||
)
|
||||
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('update-failed')
|
||||
expect(first.status === 'fallback' ? first.manualCommand?.command : null).toBe(
|
||||
'npx --yes skills update orchestration --global --yes'
|
||||
)
|
||||
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
|
||||
expect(repeated.every((result) => result.status === 'fallback')).toBe(true)
|
||||
expect(
|
||||
repeated.every((result) =>
|
||||
result.status === 'fallback' ? result.reason === 'cooldown' : false
|
||||
)
|
||||
).toBe(true)
|
||||
expect(updateRunner).toHaveBeenCalledTimes(1)
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps concurrent checks for different project roots independent', async () => {
|
||||
const discoverHostSkills = vi.fn(async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'home',
|
||||
rootPath: TEST_MANAGED_HOME_ROOT
|
||||
}),
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
)
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
|
||||
|
||||
const [currentProject, otherProject] = await Promise.all([
|
||||
coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
}),
|
||||
coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
|
||||
})
|
||||
])
|
||||
|
||||
expect(currentProject.status === 'fallback' ? currentProject.reason : null).toBe(
|
||||
'ambiguous-install'
|
||||
)
|
||||
expect(otherProject.status).toBe('ready')
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keys project fallback UI identity by project root', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([
|
||||
discoveredSkill({
|
||||
name: ORCHESTRATION_SKILL_NAME,
|
||||
sourceKind: 'repo',
|
||||
rootPath: '/workspace/current/.agents/skills',
|
||||
directoryPath: '/workspace/current/.agents/skills/orchestration'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
const first = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
|
||||
})
|
||||
const second = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
|
||||
})
|
||||
|
||||
expect(first.status === 'fallback' ? first.uiKey : null).toContain('/workspace/current')
|
||||
expect(second.status === 'fallback' ? second.uiKey : null).toContain('/workspace/other')
|
||||
expect(
|
||||
first.status === 'fallback' && second.status === 'fallback' ? first.uiKey : null
|
||||
).not.toBe(second.status === 'fallback' ? second.uiKey : null)
|
||||
})
|
||||
|
||||
it('reads the current global lockfile version field', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-1',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile: async () =>
|
||||
JSON.stringify({
|
||||
version: 3,
|
||||
skills: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
source: 'stablyai/orca',
|
||||
sourceType: 'github',
|
||||
sourceUrl: 'https://github.com/stablyai/orca.git',
|
||||
skillPath: `skills/${ORCHESTRATION_SKILL_NAME}/SKILL.md`,
|
||||
skillFolderHash: 'hash-1'
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(result.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('returns a malformed fallback for non-object lockfile JSON', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-1',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile: async () => 'null'
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('lockfile-malformed')
|
||||
})
|
||||
|
||||
it('falls back without cooldown when automatic updates are off', async () => {
|
||||
it('lets explicit re-checks bypass automatic update cooldown', async () => {
|
||||
let now = 100
|
||||
const readTextFile = vi.fn(async () => lockfile('old-hash'))
|
||||
const updateRunner = vi.fn(async () => ({ status: 'failure' as const, exitCode: 1 }))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
cooldownMs: 1_000,
|
||||
now: () => now,
|
||||
discoverHostSkills: async () => homeDiscovery(),
|
||||
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
|
||||
updateRunner
|
||||
})
|
||||
|
||||
await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
now = 200
|
||||
const forced = await coordinator.ensureManagedReady({ ...orchestrationRequest, force: true })
|
||||
|
||||
expect(forced.status === 'fallback' ? forced.reason : null).toBe('update-failed')
|
||||
expect(updateRunner).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns update-timeout distinctly with the manual update command', async () => {
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () => homeDiscovery(),
|
||||
readTextFile: async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'),
|
||||
updateRunner: async () => ({ status: 'timeout' })
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('update-timeout')
|
||||
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('update')
|
||||
})
|
||||
|
||||
it('falls back to manual update every time when automatic updates are disabled', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const discoverHostSkills = vi.fn(async () => homeDiscovery())
|
||||
const readTextFile = vi.fn(async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
backgroundUpdatesEnabled: () => false,
|
||||
cooldownMs: 1_000,
|
||||
now: () => now,
|
||||
discoverHostSkills: async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-2',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile
|
||||
discoverHostSkills,
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const first = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
now = 200
|
||||
const second = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
const first = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
const second = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
const repeated = await Promise.all(
|
||||
Array.from({ length: 50 }, () => coordinator.ensureManagedReady(orchestrationRequest))
|
||||
)
|
||||
|
||||
expect(first.status === 'fallback' ? first.reason : null).toBe('background-update-disabled')
|
||||
expect(first.status === 'fallback' ? first.manualCommand?.kind : null).toBe('update')
|
||||
expect(second.status === 'fallback' ? second.reason : null).toBe('background-update-disabled')
|
||||
expect(second.status === 'fallback' ? second.manualCommand?.kind : null).toBe('update')
|
||||
expect(
|
||||
repeated.every((result) =>
|
||||
result.status === 'fallback' ? result.reason === 'background-update-disabled' : false
|
||||
)
|
||||
).toBe(true)
|
||||
expect(updateRunner).not.toHaveBeenCalled()
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lets explicit re-checks bypass the disabled automatic update fast path', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const discoverHostSkills = vi.fn(async () => homeDiscovery())
|
||||
const readTextFile = vi.fn(async () => lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
backgroundUpdatesEnabled: () => false,
|
||||
discoverHostSkills,
|
||||
readTextFile,
|
||||
updateRunner
|
||||
})
|
||||
|
||||
await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
const forced = await coordinator.ensureManagedReady({ ...orchestrationRequest, force: true })
|
||||
|
||||
expect(forced.status === 'fallback' ? forced.reason : null).toBe('background-update-disabled')
|
||||
expect(updateRunner).not.toHaveBeenCalled()
|
||||
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('shows manual update after automatic update cooldown when the setting is turned off', async () => {
|
||||
let now = 100
|
||||
let backgroundUpdatesEnabled = true
|
||||
const readTextFile = vi.fn(async () => lockfile('old-hash'))
|
||||
it('returns update-failed when post-update verification cannot prove the lock entry', async () => {
|
||||
const readTextFile = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(lockfile(ORCHESTRATION_SKILL_NAME, 'old-hash'))
|
||||
.mockResolvedValueOnce(JSON.stringify({ version: 3, skills: {} }))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
backgroundUpdatesEnabled: () => backgroundUpdatesEnabled,
|
||||
cooldownMs: 1_000,
|
||||
now: () => now,
|
||||
discoverHostSkills: async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-2',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile
|
||||
discoverHostSkills: async () => homeDiscovery(),
|
||||
readTextFile,
|
||||
updateRunner: async () => ({ status: 'success' })
|
||||
})
|
||||
|
||||
const automaticAttempt = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
backgroundUpdatesEnabled = false
|
||||
now = 200
|
||||
const manualAttempt = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
const result = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
|
||||
expect(automaticAttempt.status === 'fallback' ? automaticAttempt.reason : null).toBe(
|
||||
'unsupported-cli-contract'
|
||||
)
|
||||
expect(manualAttempt.status === 'fallback' ? manualAttempt.reason : null).toBe(
|
||||
'background-update-disabled'
|
||||
)
|
||||
expect(manualAttempt.status === 'fallback' ? manualAttempt.manualCommand?.kind : null).toBe(
|
||||
'update'
|
||||
)
|
||||
expect(readTextFile).toHaveBeenCalledTimes(2)
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('update-failed')
|
||||
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('update')
|
||||
})
|
||||
|
||||
it('does not show the manual-update modal while automatic updates are enabled', async () => {
|
||||
it('does not run the updater for missing or unmanaged lockfile cases', async () => {
|
||||
const updateRunner = vi.fn(async () => ({ status: 'success' as const }))
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
discoverHostSkills: async () =>
|
||||
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
|
||||
expectedRevisions: {
|
||||
[ORCHESTRATION_SKILL_NAME]: {
|
||||
expectedHash: 'hash-2',
|
||||
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
|
||||
skillsPackageVersion: '1.2.3'
|
||||
}
|
||||
},
|
||||
readTextFile: async () => lockfile('old-hash')
|
||||
discoverHostSkills: async () => homeDiscovery(),
|
||||
readTextFile: async () => JSON.stringify({ version: 3, skills: {} }),
|
||||
updateRunner
|
||||
})
|
||||
|
||||
const result = await coordinator.ensureManagedReady({
|
||||
skillName: ORCHESTRATION_SKILL_NAME,
|
||||
context: 'agent-orchestration',
|
||||
discoveryTarget: { runtime: 'host' }
|
||||
})
|
||||
const result = await coordinator.ensureManagedReady(orchestrationRequest)
|
||||
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('unsupported-cli-contract')
|
||||
expect(result.status === 'fallback' ? result.reason : null).toBe('lock-entry-missing')
|
||||
expect(result.status === 'fallback' ? result.manualCommand : undefined).toBeUndefined()
|
||||
expect(updateRunner).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,29 +3,32 @@ import { homedir } from 'node:os'
|
||||
import type {
|
||||
ManagedAgentSkillEnsureRequest,
|
||||
ManagedAgentSkillEnsureResult,
|
||||
ManagedAgentSkillName,
|
||||
SkillDiscoveryResult
|
||||
} from '../../shared/skills'
|
||||
import type { Store } from '../persistence'
|
||||
import { discoverSkills } from './discovery'
|
||||
import {
|
||||
buildManagedSkillFallback,
|
||||
buildManagedSkillReadyResult
|
||||
buildManagedSkillGlobalUpdateFallback,
|
||||
buildManagedSkillReadyResult,
|
||||
buildManagedSkillUpdatedResult
|
||||
} from './managed-skill-ensure-result'
|
||||
import { selectSingleGlobalManagedSkillCandidate } from './managed-skill-global-candidate'
|
||||
import { readManagedSkillLockEntry } from './managed-skill-lockfile'
|
||||
import {
|
||||
makeManagedSkillPreDiscoveryCacheKey,
|
||||
makeManagedSkillSuccessCacheKey,
|
||||
normalizeManagedSkillKeyPart,
|
||||
makeManagedSkillTargetFallbackCacheKey,
|
||||
shouldCooldownFallback
|
||||
} from './managed-skill-update-cache-key'
|
||||
import {
|
||||
buildManagedSkillManualCommand,
|
||||
EXPECTED_MANAGED_SKILL_REVISIONS,
|
||||
isImmutableSourceRef,
|
||||
isManagedAgentSkillName,
|
||||
type ExpectedManagedSkillRevision
|
||||
isManagedAgentSkillName
|
||||
} from './managed-skill-update-contract'
|
||||
import { verifyManagedSkillPostUpdate } from './managed-skill-post-update-verification'
|
||||
import {
|
||||
createManagedSkillUpdateRunner,
|
||||
type ManagedSkillUpdateRunner
|
||||
} from './managed-skill-update-runner'
|
||||
import { resolveManagedSkillTarget, type ResolvedManagedSkillTarget } from './managed-skill-target'
|
||||
|
||||
type ManagedSkillCoordinatorDeps = {
|
||||
@@ -36,30 +39,14 @@ type ManagedSkillCoordinatorDeps = {
|
||||
homeDir?: () => string
|
||||
now?: () => number
|
||||
readTextFile?: (path: string) => Promise<string>
|
||||
expectedRevisions?: Partial<Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>>
|
||||
updateRunner?: ManagedSkillUpdateRunner
|
||||
}
|
||||
|
||||
const DEFAULT_COOLDOWN_MS = 60_000
|
||||
const defaultUpdateAbortController = new AbortController()
|
||||
|
||||
const coordinatorByStore = new WeakMap<Store, ManagedSkillUpdateCoordinator>()
|
||||
|
||||
export function getManagedSkillUpdateCoordinator(store: Store): ManagedSkillUpdateCoordinator {
|
||||
const existing = coordinatorByStore.get(store)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const coordinator = new ManagedSkillUpdateCoordinator({
|
||||
appVersion: process.env.npm_package_version,
|
||||
backgroundUpdatesEnabled: () =>
|
||||
store.getSettings().managedAgentSkillBackgroundUpdatesEnabled !== false,
|
||||
discoverHostSkills: (projectRootPath) =>
|
||||
discoverSkills({
|
||||
repos: store.getRepos(),
|
||||
...(projectRootPath ? { cwd: projectRootPath } : {})
|
||||
})
|
||||
})
|
||||
coordinatorByStore.set(store, coordinator)
|
||||
return coordinator
|
||||
export function abortManagedSkillUpdateProcesses(): void {
|
||||
defaultUpdateAbortController.abort()
|
||||
}
|
||||
|
||||
export class ManagedSkillUpdateCoordinator {
|
||||
@@ -72,14 +59,17 @@ export class ManagedSkillUpdateCoordinator {
|
||||
private readonly homeDir: () => string
|
||||
private readonly now: () => number
|
||||
private readonly readTextFile: (path: string) => Promise<string>
|
||||
private readonly expectedRevisions: Partial<
|
||||
Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>
|
||||
>
|
||||
private readonly updateRunner: ManagedSkillUpdateRunner
|
||||
private readonly inFlightByPreDiscoveryKey = new Map<
|
||||
string,
|
||||
Promise<ManagedAgentSkillEnsureResult>
|
||||
>()
|
||||
private readonly disabledFallbackByPreDiscoveryKey = new Map<
|
||||
string,
|
||||
{ result: ManagedAgentSkillEnsureResult; until: number }
|
||||
>()
|
||||
private readonly cooldownUntilByKey = new Map<string, number>()
|
||||
private readonly readyUntilByPreDiscoveryKey = new Map<string, number>()
|
||||
private readonly successCache = new Set<string>()
|
||||
|
||||
constructor(deps: ManagedSkillCoordinatorDeps = {}) {
|
||||
@@ -89,8 +79,10 @@ export class ManagedSkillUpdateCoordinator {
|
||||
this.discoverHostSkills = deps.discoverHostSkills ?? (() => discoverSkills({ repos: [] }))
|
||||
this.homeDir = deps.homeDir ?? homedir
|
||||
this.now = deps.now ?? Date.now
|
||||
this.readTextFile = deps.readTextFile ?? readFileUtf8
|
||||
this.expectedRevisions = deps.expectedRevisions ?? EXPECTED_MANAGED_SKILL_REVISIONS
|
||||
this.readTextFile = deps.readTextFile ?? ((path) => readFile(path, 'utf8'))
|
||||
this.updateRunner =
|
||||
deps.updateRunner ??
|
||||
createManagedSkillUpdateRunner({ signal: defaultUpdateAbortController.signal })
|
||||
}
|
||||
|
||||
ensureManagedReady(
|
||||
@@ -109,16 +101,13 @@ export class ManagedSkillUpdateCoordinator {
|
||||
|
||||
const target = resolveManagedSkillTarget(request)
|
||||
if (!target.ok) {
|
||||
const targetFallbackKey = [
|
||||
this.appVersion,
|
||||
target.runtime,
|
||||
target.distro ?? '',
|
||||
'target-fallback',
|
||||
normalizeManagedSkillKeyPart(request.context),
|
||||
normalizeManagedSkillKeyPart(request.discoveryTarget?.projectRootPath),
|
||||
request.skillName,
|
||||
target.reason
|
||||
].join(':')
|
||||
const targetFallbackKey = makeManagedSkillTargetFallbackCacheKey({
|
||||
appVersion: this.appVersion,
|
||||
distro: target.distro,
|
||||
reason: target.reason,
|
||||
request,
|
||||
runtime: target.runtime
|
||||
})
|
||||
const cooldownUntil = this.cooldownUntilByKey.get(targetFallbackKey)
|
||||
if (!request.force && cooldownUntil && cooldownUntil > this.now()) {
|
||||
return Promise.resolve(
|
||||
@@ -143,28 +132,13 @@ export class ManagedSkillUpdateCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
const expected = this.expectedRevisions[request.skillName]
|
||||
const successLookupKey = makeManagedSkillSuccessCacheKey({
|
||||
const preDiscoveryKey = makeManagedSkillPreDiscoveryCacheKey({
|
||||
appVersion: this.appVersion,
|
||||
backgroundUpdatesEnabled: this.backgroundUpdatesEnabled(),
|
||||
distro: target.distro,
|
||||
request,
|
||||
expected
|
||||
runtime: target.runtime
|
||||
})
|
||||
if (target.runtime === 'host' && this.successCache.has(successLookupKey)) {
|
||||
return Promise.resolve(buildManagedSkillReadyResult(request))
|
||||
}
|
||||
|
||||
const preDiscoveryKey = [
|
||||
this.appVersion,
|
||||
target.runtime,
|
||||
target.distro ?? '',
|
||||
'pre-discovery',
|
||||
this.backgroundUpdatesEnabled() ? 'background-updates-on' : 'background-updates-off',
|
||||
normalizeManagedSkillKeyPart(request.context),
|
||||
normalizeManagedSkillKeyPart(request.discoveryTarget?.projectRootPath),
|
||||
request.skillName,
|
||||
expected?.expectedHash ?? 'missing-expected-hash',
|
||||
expected?.expectedSourceRef ?? 'missing-source-ref'
|
||||
].join(':')
|
||||
const existing = this.inFlightByPreDiscoveryKey.get(preDiscoveryKey)
|
||||
if (existing) {
|
||||
return existing
|
||||
@@ -181,12 +155,29 @@ export class ManagedSkillUpdateCoordinator {
|
||||
})
|
||||
)
|
||||
}
|
||||
const readyUntil = this.readyUntilByPreDiscoveryKey.get(preDiscoveryKey)
|
||||
if (!request.force && readyUntil && readyUntil > this.now()) {
|
||||
return Promise.resolve(buildManagedSkillReadyResult(request))
|
||||
}
|
||||
const disabledFallback = this.disabledFallbackByPreDiscoveryKey.get(preDiscoveryKey)
|
||||
if (!request.force && disabledFallback && disabledFallback.until > this.now()) {
|
||||
return Promise.resolve(disabledFallback.result)
|
||||
}
|
||||
|
||||
const promise = this.evaluate(request, target, expected)
|
||||
const promise = this.evaluate(request, target)
|
||||
.then((result) => {
|
||||
if (result.status === 'ready' || result.status === 'updated') {
|
||||
this.readyUntilByPreDiscoveryKey.set(preDiscoveryKey, this.now() + this.cooldownMs)
|
||||
}
|
||||
if (shouldCooldownFallback(result)) {
|
||||
this.cooldownUntilByKey.set(preDiscoveryKey, this.now() + this.cooldownMs)
|
||||
}
|
||||
if (isBackgroundUpdateDisabledFallback(result)) {
|
||||
this.disabledFallbackByPreDiscoveryKey.set(preDiscoveryKey, {
|
||||
result,
|
||||
until: this.now() + this.cooldownMs
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -200,8 +191,7 @@ export class ManagedSkillUpdateCoordinator {
|
||||
|
||||
private async evaluate(
|
||||
request: ManagedAgentSkillEnsureRequest,
|
||||
target: Extract<ResolvedManagedSkillTarget, { ok: true }>,
|
||||
expected: ExpectedManagedSkillRevision | undefined
|
||||
target: Extract<ResolvedManagedSkillTarget, { ok: true }>
|
||||
): Promise<ManagedAgentSkillEnsureResult> {
|
||||
if (target.runtime === 'wsl') {
|
||||
return buildManagedSkillFallback({
|
||||
@@ -229,45 +219,6 @@ export class ManagedSkillUpdateCoordinator {
|
||||
manualCommand: globalCandidateDecision.fallback.manualCommand
|
||||
})
|
||||
}
|
||||
if (!expected) {
|
||||
// Why: without a pinned/hash manifest, V1 cannot prove staleness or offer a
|
||||
// real update path, so installed global managed skills should not prompt.
|
||||
const cacheKey = makeManagedSkillSuccessCacheKey({
|
||||
appVersion: this.appVersion,
|
||||
request,
|
||||
expected
|
||||
})
|
||||
this.successCache.add(cacheKey)
|
||||
return buildManagedSkillReadyResult(request)
|
||||
}
|
||||
if (!isImmutableSourceRef(expected.expectedSourceRef)) {
|
||||
return buildManagedSkillFallback({
|
||||
request,
|
||||
reason: 'unsupported-cli-contract',
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
})
|
||||
}
|
||||
|
||||
const cacheKey = makeManagedSkillSuccessCacheKey({
|
||||
appVersion: this.appVersion,
|
||||
request,
|
||||
expected
|
||||
})
|
||||
if (this.successCache.has(cacheKey)) {
|
||||
return buildManagedSkillReadyResult(request)
|
||||
}
|
||||
|
||||
const backgroundUpdatesEnabled = this.backgroundUpdatesEnabled()
|
||||
const cooldownUntil = this.cooldownUntilByKey.get(cacheKey)
|
||||
if (backgroundUpdatesEnabled && !request.force && cooldownUntil && cooldownUntil > this.now()) {
|
||||
return buildManagedSkillFallback({
|
||||
request,
|
||||
reason: 'cooldown',
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
})
|
||||
}
|
||||
|
||||
const lockEntryResult = await readManagedSkillLockEntry({
|
||||
homeDir: this.homeDir(),
|
||||
@@ -282,10 +233,16 @@ export class ManagedSkillUpdateCoordinator {
|
||||
scope: 'global'
|
||||
})
|
||||
}
|
||||
if (lockEntryResult.entry.skillFolderHash === expected.expectedHash) {
|
||||
this.successCache.add(cacheKey)
|
||||
const currentLockHash = lockEntryResult.entry.skillFolderHash
|
||||
const cacheKey = makeManagedSkillSuccessCacheKey({
|
||||
appVersion: this.appVersion,
|
||||
request,
|
||||
currentLockHash
|
||||
})
|
||||
if (this.successCache.has(cacheKey)) {
|
||||
return buildManagedSkillReadyResult(request)
|
||||
}
|
||||
const backgroundUpdatesEnabled = this.backgroundUpdatesEnabled()
|
||||
if (!backgroundUpdatesEnabled) {
|
||||
return buildManagedSkillFallback({
|
||||
request,
|
||||
@@ -296,18 +253,50 @@ export class ManagedSkillUpdateCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
// Why: this tree does not ship the verified CLI hash/ref update contract
|
||||
// yet, so stale installs must fall back instead of running blind `npx`.
|
||||
this.cooldownUntilByKey.set(cacheKey, this.now() + this.cooldownMs)
|
||||
return buildManagedSkillFallback({
|
||||
request,
|
||||
reason: 'unsupported-cli-contract',
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
const cooldownUntil = this.cooldownUntilByKey.get(cacheKey)
|
||||
if (!request.force && cooldownUntil && cooldownUntil > this.now()) {
|
||||
return buildManagedSkillFallback({
|
||||
request,
|
||||
reason: 'cooldown',
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
})
|
||||
}
|
||||
|
||||
const updateResult = await this.updateRunner(request.skillName)
|
||||
if (updateResult.status !== 'success') {
|
||||
this.cooldownUntilByKey.set(cacheKey, this.now() + this.cooldownMs)
|
||||
return buildManagedSkillGlobalUpdateFallback(
|
||||
request,
|
||||
updateResult.status === 'timeout' ? 'update-timeout' : 'update-failed'
|
||||
)
|
||||
}
|
||||
|
||||
const postUpdate = await verifyManagedSkillPostUpdate({
|
||||
discoverHostSkills: this.discoverHostSkills,
|
||||
homeDir: this.homeDir(),
|
||||
readTextFile: this.readTextFile,
|
||||
request
|
||||
})
|
||||
if (!postUpdate.ok) {
|
||||
this.cooldownUntilByKey.set(cacheKey, this.now() + this.cooldownMs)
|
||||
return buildManagedSkillGlobalUpdateFallback(request, 'update-failed')
|
||||
}
|
||||
const postLockHash = postUpdate.lockHash
|
||||
this.successCache.add(
|
||||
makeManagedSkillSuccessCacheKey({
|
||||
appVersion: this.appVersion,
|
||||
request,
|
||||
currentLockHash: postLockHash
|
||||
})
|
||||
)
|
||||
if (postLockHash === currentLockHash) {
|
||||
return buildManagedSkillReadyResult(request)
|
||||
}
|
||||
return buildManagedSkillUpdatedResult(request)
|
||||
}
|
||||
}
|
||||
|
||||
function readFileUtf8(path: string): Promise<string> {
|
||||
return readFile(path, 'utf8')
|
||||
function isBackgroundUpdateDisabledFallback(result: ManagedAgentSkillEnsureResult): boolean {
|
||||
return result.status === 'fallback' && result.reason === 'background-update-disabled'
|
||||
}
|
||||
|
||||
@@ -7,31 +7,51 @@ export function useEmulatorPaneControls(worktreeId: string) {
|
||||
|
||||
const sendTap = useCallback(
|
||||
async (x: number, y: number) => {
|
||||
await callRuntimeRpc({ kind: 'local' }, 'emulator.tap', { x, y, worktree: worktreeId })
|
||||
await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.tap',
|
||||
{ x, y, worktree: worktreeId },
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const sendButton = useCallback(
|
||||
async (name: string) => {
|
||||
await callRuntimeRpc({ kind: 'local' }, 'emulator.button', { name, worktree: worktreeId })
|
||||
await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.button',
|
||||
{ name, worktree: worktreeId },
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const sendGesture = useCallback(
|
||||
async (points: EmulatorGesturePoint[]) => {
|
||||
await callRuntimeRpc({ kind: 'local' }, 'emulator.gesture', { points, worktree: worktreeId })
|
||||
await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.gesture',
|
||||
{ points, worktree: worktreeId },
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
},
|
||||
[worktreeId]
|
||||
)
|
||||
|
||||
const sendRotate = useCallback(async () => {
|
||||
const orientation = nextRotateOrientationRef.current
|
||||
await callRuntimeRpc({ kind: 'local' }, 'emulator.rotate', {
|
||||
orientation,
|
||||
worktree: worktreeId
|
||||
})
|
||||
await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.rotate',
|
||||
{
|
||||
orientation,
|
||||
worktree: worktreeId
|
||||
},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
nextRotateOrientationRef.current =
|
||||
orientation === 'landscape_left' ? 'portrait' : 'landscape_left'
|
||||
}, [worktreeId])
|
||||
|
||||
@@ -63,7 +63,8 @@ export function useEmulatorPaneSession({
|
||||
const list = (await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.listSimulators',
|
||||
{}
|
||||
{},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)) as SimulatorDeviceRow[]
|
||||
const next = markSimulatorDeviceBooted(list, bootedTarget)
|
||||
if (!mountedRef.current) {
|
||||
@@ -180,11 +181,16 @@ export function useEmulatorPaneSession({
|
||||
setStreamKey(null)
|
||||
liveTargetRef.current = null
|
||||
}
|
||||
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.attach', {
|
||||
device: target,
|
||||
worktree: worktreeId,
|
||||
focus: false
|
||||
})) as { attached?: boolean; info?: EmulatorPaneSession['info'] }
|
||||
const res = (await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.attach',
|
||||
{
|
||||
device: target,
|
||||
worktree: worktreeId,
|
||||
focus: false
|
||||
},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)) as { attached?: boolean; info?: EmulatorPaneSession['info'] }
|
||||
if (!mountedRef.current) {
|
||||
// Why: attach can finish after the tab closes, after the earlier
|
||||
// unmount shutdown already no-op'd because the session was not registered yet.
|
||||
|
||||
@@ -34,10 +34,15 @@ export function useEmulatorPaneShutdown({
|
||||
useAppStore.getState().setTabLabel(tabId, 'Shutting down…')
|
||||
}
|
||||
try {
|
||||
const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
|
||||
...(deviceTarget ? { device: deviceTarget } : {}),
|
||||
worktree: worktreeId
|
||||
})) as { deviceUdid?: string }
|
||||
const res = (await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.shutdown',
|
||||
{
|
||||
...(deviceTarget ? { device: deviceTarget } : {}),
|
||||
worktree: worktreeId
|
||||
},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)) as { deviceUdid?: string }
|
||||
const shutdownTarget = res?.deviceUdid || deviceTarget
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EMULATOR_LOCAL_SHUTDOWN_EVENT, {
|
||||
|
||||
@@ -103,7 +103,8 @@ export function MobileEmulatorSettingsPane({
|
||||
const result = (await callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.availability',
|
||||
{}
|
||||
{},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)) as EmulatorAvailability
|
||||
setAvailability(result)
|
||||
} catch (error) {
|
||||
|
||||
@@ -65,6 +65,12 @@ describe('ManagedAgentSkillSetupDialogHost copy', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('supports review copy for fallback cases without a runnable command', () => {
|
||||
expect(getManagedSkillContextCopy('agent-orchestration', 'Review')).toBe(
|
||||
'Orca Orchestration was used. Review the orchestration skill to enable agents to coordinate reliably.'
|
||||
)
|
||||
})
|
||||
|
||||
it('splits workspace-aware context copy so the workspace can expose its full path', () => {
|
||||
expect(getManagedSkillContextWorkspaceCopy('agent-computer-use', 'Install')).toEqual({
|
||||
beforeWorkspace: 'Computer Use was used in ',
|
||||
@@ -128,7 +134,7 @@ describe('ManagedAgentSkillSetupDialogHost queue state', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not replace the active modal with a non-actionable re-check fallback', () => {
|
||||
it('does not replace the active modal with a cooldown re-check fallback', () => {
|
||||
const active = fallback({
|
||||
context: 'agent-orchestration',
|
||||
uiKey: 'host::orchestration:agent-orchestration',
|
||||
@@ -141,7 +147,7 @@ describe('ManagedAgentSkillSetupDialogHost queue state', () => {
|
||||
})
|
||||
const deadEndFallback = fallback({
|
||||
context: 'agent-orchestration',
|
||||
reason: 'lockfile-malformed',
|
||||
reason: 'cooldown',
|
||||
uiKey: 'host::orchestration:agent-orchestration'
|
||||
})
|
||||
|
||||
@@ -152,4 +158,29 @@ describe('ManagedAgentSkillSetupDialogHost queue state', () => {
|
||||
queue: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps manual-review safety fallbacks visible after re-check', () => {
|
||||
const active = fallback({
|
||||
context: 'agent-orchestration',
|
||||
uiKey: 'host::orchestration:agent-orchestration',
|
||||
manualCommand: {
|
||||
kind: 'update',
|
||||
command: 'npx skills update orchestration',
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
}
|
||||
})
|
||||
const reviewFallback = fallback({
|
||||
context: 'agent-orchestration',
|
||||
reason: 'lockfile-malformed',
|
||||
uiKey: 'host::orchestration:agent-orchestration'
|
||||
})
|
||||
|
||||
expect(
|
||||
replaceActiveAfterManagedAgentSkillRecheck({ active, queue: [] }, reviewFallback)
|
||||
).toEqual({
|
||||
active: reviewFallback,
|
||||
queue: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,9 +87,11 @@ export function ManagedAgentSkillSetupDialogHost(): React.JSX.Element | null {
|
||||
}, [enqueueFallback])
|
||||
|
||||
const contextActionLabel =
|
||||
active?.manualCommand?.kind === 'update'
|
||||
? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.update', 'Update')
|
||||
: translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.install', 'Install')
|
||||
active?.manualCommand?.kind === 'install'
|
||||
? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.install', 'Install')
|
||||
: active?.manualCommand?.kind === 'update'
|
||||
? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.update', 'Update')
|
||||
: translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.review', 'Review')
|
||||
const contextCopy = active ? getManagedSkillContextCopy(active.context, contextActionLabel) : ''
|
||||
const installedCommand = useMemo(
|
||||
() => (active ? buildAgentFeatureSkillUpdateCommand(active.skillName) : ''),
|
||||
|
||||
@@ -35,7 +35,7 @@ export function getManagedSkillContextCopy(
|
||||
case 'agent-orca-cli':
|
||||
return translate(
|
||||
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrcaCliContext',
|
||||
'The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.',
|
||||
'The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.',
|
||||
{ actionLabel }
|
||||
)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function getManagedSkillContextWorkspaceCopy(
|
||||
return {
|
||||
beforeWorkspace: translate(
|
||||
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrcaCliContextBeforeWorkspace',
|
||||
'The Orca CLI skill was used in '
|
||||
'The Orca CLI skill is needed in '
|
||||
),
|
||||
afterWorkspace: translate(
|
||||
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrcaCliContextAfterWorkspace',
|
||||
|
||||
@@ -95,7 +95,9 @@ export async function discoverInstalledAgentSkills(
|
||||
|
||||
const inFlightDiscovery = pendingDiscoveryByTarget.get(key)
|
||||
if (inFlightDiscovery) {
|
||||
if (!force) {
|
||||
const inFlightSatisfiesForcedRefresh =
|
||||
pendingDiscoverySatisfiesForcedRefreshByTarget.get(key) === true
|
||||
if (!force || inFlightSatisfiesForcedRefresh) {
|
||||
return inFlightDiscovery
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -267,6 +267,29 @@ describe('discoverInstalledAgentSkills', () => {
|
||||
await expect(forcedRefresh).resolves.toBe(freshResult)
|
||||
})
|
||||
|
||||
it('reuses an in-flight forced refresh for the same target', async () => {
|
||||
const forcedScan = deferred<SkillDiscoveryResult>()
|
||||
const discover = vi.fn<() => Promise<SkillDiscoveryResult>>()
|
||||
discover.mockReturnValueOnce(forcedScan.promise)
|
||||
vi.stubGlobal('window', {
|
||||
api: { skills: { discover } }
|
||||
})
|
||||
|
||||
const firstForcedRefresh =
|
||||
_installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true)
|
||||
const secondForcedRefresh =
|
||||
_installedAgentSkillDiscoveryInternalsForTests.discoverInstalledAgentSkills(true)
|
||||
|
||||
expect(discover).toHaveBeenCalledTimes(1)
|
||||
|
||||
const freshResult = discoveryResult([skill({ name: 'orca-cli' })])
|
||||
forcedScan.resolve(freshResult)
|
||||
|
||||
await expect(firstForcedRefresh).resolves.toBe(freshResult)
|
||||
await expect(secondForcedRefresh).resolves.toBe(freshResult)
|
||||
expect(discover).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('caches host and WSL discovery results separately', async () => {
|
||||
const hostResult = discoveryResult([skill({ name: 'host-skill' })])
|
||||
const wslResult = discoveryResult([skill({ name: 'wsl-skill' })])
|
||||
|
||||
@@ -3312,20 +3312,21 @@
|
||||
"terminalAria": "Agent skill setup terminal",
|
||||
"update": "Update",
|
||||
"install": "Install",
|
||||
"review": "Review",
|
||||
"recheck": "Re-check",
|
||||
"dontShowAgain": "Don't show again",
|
||||
"notNow": "Not now",
|
||||
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
|
||||
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"linearWorktreeContextBeforeWorkspace": "A worktree was started from a Linear task in ",
|
||||
"linearWorktreeContextAfterWorkspace": ". {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
|
||||
"agentOrchestrationContextBeforeWorkspace": "Orca Orchestration was used in ",
|
||||
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
|
||||
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
|
||||
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"targetRequired": "Orca couldn't tell which runtime should use this skill.",
|
||||
"unsupportedSkill": "This isn't an Orca-managed skill.",
|
||||
|
||||
@@ -3312,13 +3312,14 @@
|
||||
"terminalAria": "Terminal de configuración de habilidad de agente",
|
||||
"update": "Actualizar",
|
||||
"install": "Instalar",
|
||||
"review": "Revisar",
|
||||
"recheck": "Volver a comprobar",
|
||||
"dontShowAgain": "No volver a mostrar",
|
||||
"notNow": "Ahora no",
|
||||
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
|
||||
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"targetRequired": "Orca no pudo resolver el entorno de ejecución que debe leer esta habilidad.",
|
||||
"unsupportedSkill": "Esta no es una habilidad de agente administrada por Orca.",
|
||||
"repairRequiredRuntime": "El entorno de ejecución seleccionado necesita reparación antes de que Orca pueda inspeccionar sus habilidades.",
|
||||
@@ -3346,7 +3347,7 @@
|
||||
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
|
||||
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
|
||||
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3312,13 +3312,14 @@
|
||||
"terminalAria": "エージェントスキル設定ターミナル",
|
||||
"update": "更新",
|
||||
"install": "インストール",
|
||||
"review": "確認",
|
||||
"recheck": "再チェック",
|
||||
"dontShowAgain": "今後表示しない",
|
||||
"notNow": "今はしない",
|
||||
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
|
||||
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"targetRequired": "Orca はこのスキルを読み取るランタイムを解決できませんでした。",
|
||||
"unsupportedSkill": "これは Orca 管理のエージェントスキルではありません。",
|
||||
"repairRequiredRuntime": "Orca がスキルを検査する前に、選択したランタイムの修復が必要です。",
|
||||
@@ -3346,7 +3347,7 @@
|
||||
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
|
||||
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
|
||||
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3312,13 +3312,14 @@
|
||||
"terminalAria": "에이전트 스킬 설정 터미널",
|
||||
"update": "업데이트",
|
||||
"install": "설치",
|
||||
"review": "검토",
|
||||
"recheck": "다시 확인",
|
||||
"dontShowAgain": "다시 표시하지 않기",
|
||||
"notNow": "나중에",
|
||||
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
|
||||
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"targetRequired": "Orca가 이 스킬을 읽어야 하는 런타임을 확인할 수 없습니다.",
|
||||
"unsupportedSkill": "Orca가 관리하는 에이전트 스킬이 아닙니다.",
|
||||
"repairRequiredRuntime": "Orca가 스킬을 검사하기 전에 선택한 런타임을 복구해야 합니다.",
|
||||
@@ -3346,7 +3347,7 @@
|
||||
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
|
||||
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
|
||||
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3312,13 +3312,14 @@
|
||||
"terminalAria": "代理技能设置终端",
|
||||
"update": "更新",
|
||||
"install": "安装",
|
||||
"review": "审查",
|
||||
"recheck": "重新检查",
|
||||
"dontShowAgain": "不再显示",
|
||||
"notNow": "稍后",
|
||||
"linearWorktreeContext": "A worktree was started from a Linear task. {{actionLabel}} the Linear agent skill to enable agents to read and update Linear issues.",
|
||||
"agentOrchestrationContext": "Orca Orchestration was used. {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContext": "Computer Use was used. {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill was used. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"agentOrcaCliContext": "The Orca CLI skill is needed. {{actionLabel}} the CLI skill to enable this workflow to continue reliably.",
|
||||
"targetRequired": "Orca 无法解析应读取此技能的运行时。",
|
||||
"unsupportedSkill": "这不是 Orca 托管的代理技能。",
|
||||
"repairRequiredRuntime": "所选运行时需要修复,Orca 才能检查其技能。",
|
||||
@@ -3346,7 +3347,7 @@
|
||||
"agentOrchestrationContextAfterWorkspace": ". {{actionLabel}} the orchestration skill to enable agents to coordinate reliably.",
|
||||
"agentComputerUseContextBeforeWorkspace": "Computer Use was used in ",
|
||||
"agentComputerUseContextAfterWorkspace": ". {{actionLabel}} the Computer Use skill to enable agents to control apps reliably.",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill was used in ",
|
||||
"agentOrcaCliContextBeforeWorkspace": "The Orca CLI skill is needed in ",
|
||||
"agentOrcaCliContextAfterWorkspace": ". {{actionLabel}} the CLI skill to enable this workflow to continue reliably."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -85,10 +85,17 @@ describe('openMobileEmulatorTab', () => {
|
||||
await expect(openMobileEmulatorTab('wt-1', { targetGroupId: 'group-1' })).resolves.toBe('sim-1')
|
||||
|
||||
expect(calls).toEqual(['ensure', 'attach'])
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'emulator.attach', {
|
||||
worktree: 'wt-1',
|
||||
focus: false
|
||||
})
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'emulator.attach',
|
||||
{
|
||||
worktree: 'wt-1',
|
||||
focus: false
|
||||
},
|
||||
{
|
||||
suppressFeatureInteraction: true
|
||||
}
|
||||
)
|
||||
expect(ensureSimulatorTab).toHaveBeenCalledWith('wt-1', {
|
||||
placement: 'rightSplit',
|
||||
targetGroupId: 'group-1',
|
||||
@@ -163,10 +170,15 @@ describe('openMobileEmulatorTab', () => {
|
||||
|
||||
await expect(launched).resolves.toBe('sim-1')
|
||||
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'emulator.shutdown', {
|
||||
worktree: 'wt-1',
|
||||
managedOnly: true
|
||||
})
|
||||
expect(callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'emulator.shutdown',
|
||||
{
|
||||
worktree: 'wt-1',
|
||||
managedOnly: true
|
||||
},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
expect(consumePrelaunchedSimulatorSession('wt-1')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -92,7 +92,8 @@ export async function openMobileEmulatorTab(
|
||||
{
|
||||
worktree: worktreeId,
|
||||
focus: false
|
||||
}
|
||||
},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
if (!result.attached || !result.info) {
|
||||
throw new Error('Could not start the emulator.')
|
||||
|
||||
@@ -25,10 +25,15 @@ function getUnifiedTabsForWorktree(worktreeId: string): SimulatorTabReference[]
|
||||
}
|
||||
|
||||
function shutdownManagedSimulator(worktreeId: string): Promise<unknown> {
|
||||
return callRuntimeRpc({ kind: 'local' }, 'emulator.shutdown', {
|
||||
worktree: worktreeId,
|
||||
managedOnly: true
|
||||
})
|
||||
return callRuntimeRpc(
|
||||
{ kind: 'local' },
|
||||
'emulator.shutdown',
|
||||
{
|
||||
worktree: worktreeId,
|
||||
managedOnly: true
|
||||
},
|
||||
{ suppressFeatureInteraction: true }
|
||||
)
|
||||
}
|
||||
|
||||
export async function shutdownManagedSimulatorIfNoPane(
|
||||
|
||||
@@ -84,15 +84,15 @@ const mockApi = {
|
||||
skills: {
|
||||
ensureManagedReady: vi.fn().mockResolvedValue({
|
||||
status: 'fallback',
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
runtime: 'remote',
|
||||
scope: 'missing',
|
||||
reason: 'remote-runtime',
|
||||
uiKey: 'remote::linear-tickets:linear-worktree',
|
||||
uiKey: 'remote::orca-linear:linear-worktree',
|
||||
message: 'This skill is on a remote runtime, so Orca needs you to update it there.',
|
||||
request: {
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
remoteRuntime: true
|
||||
}
|
||||
@@ -1775,7 +1775,7 @@ describe('createWorktree base status merge', () => {
|
||||
)
|
||||
|
||||
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
remoteRuntime: true
|
||||
})
|
||||
@@ -1818,7 +1818,7 @@ describe('createWorktree base status merge', () => {
|
||||
)
|
||||
|
||||
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: expect.objectContaining({
|
||||
projectRootPath: '/repo/wt1'
|
||||
@@ -1864,7 +1864,7 @@ describe('createWorktree base status merge', () => {
|
||||
)
|
||||
|
||||
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
discoveryTarget: {
|
||||
projectRootPath: 'C:\\repo\\wt1',
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
type RuntimeClientTarget,
|
||||
RuntimeRpcCallError
|
||||
} from '../../runtime/runtime-rpc-client'
|
||||
import { LINEAR_TICKETS_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import { ORCA_LINEAR_SKILL_NAME } from '@/lib/agent-feature-install-commands'
|
||||
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
|
||||
import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review'
|
||||
@@ -174,7 +174,7 @@ function nudgeLinearManagedSkillSetup(
|
||||
: null
|
||||
void window.api.skills
|
||||
.ensureManagedReady({
|
||||
skillName: LINEAR_TICKETS_SKILL_NAME,
|
||||
skillName: ORCA_LINEAR_SKILL_NAME,
|
||||
context: 'linear-worktree',
|
||||
...(localDiscoveryTarget ? { discoveryTarget: localDiscoveryTarget } : {}),
|
||||
...(target.kind === 'environment' || remote ? { remoteRuntime: true } : {})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PreloadApi } from '../../../preload/api-types'
|
||||
import type { FeatureInteractionState } from '../../../shared/feature-interactions'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import type { ManagedAgentSkillFallback, ManagedAgentSkillUpdated } from '../../../shared/skills'
|
||||
import type { TaskSourceContext } from '../../../shared/task-source-context'
|
||||
|
||||
const TEST_COMMIT_OID = '0123456789abcdef0123456789abcdef01234567'
|
||||
@@ -49,6 +50,8 @@ function installBrowserGlobals(userAgent = 'Linux'): {
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
atob: (value: string) => Buffer.from(value, 'base64').toString('binary'),
|
||||
btoa: (value: string) => Buffer.from(value, 'binary').toString('base64')
|
||||
} as unknown as Window & typeof globalThis
|
||||
@@ -1400,13 +1403,13 @@ describe('web UI preload API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('returns remote managed skill fallback from web ensure checks without emitting setup', async () => {
|
||||
it('returns and emits remote managed skill fallback from web ensure checks', async () => {
|
||||
const { api } = await installApi()
|
||||
const fallback = vi.fn()
|
||||
const unsubscribe = api.skills.onManagedFallback(fallback)
|
||||
|
||||
const result = await api.skills.ensureManagedReady({
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
remoteRuntime: true
|
||||
})
|
||||
@@ -1414,24 +1417,366 @@ describe('web UI preload API', () => {
|
||||
expect(result).toMatchObject({
|
||||
status: 'fallback',
|
||||
reason: 'remote-runtime',
|
||||
skillName: 'linear-tickets'
|
||||
skillName: 'orca-linear'
|
||||
})
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
expect(fallback).toHaveBeenCalledWith(result)
|
||||
fallback.mockClear()
|
||||
|
||||
const cooldown = await api.skills.ensureManagedReady({
|
||||
skillName: 'linear-tickets',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
remoteRuntime: true
|
||||
})
|
||||
expect(cooldown).toMatchObject({
|
||||
status: 'fallback',
|
||||
reason: 'cooldown',
|
||||
skillName: 'linear-tickets'
|
||||
skillName: 'orca-linear'
|
||||
})
|
||||
expect(fallback).not.toHaveBeenCalled()
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('dispatches paired runtime managed skill stream events to web callbacks', async () => {
|
||||
const subscriptions: {
|
||||
method: string
|
||||
params: unknown
|
||||
callbacks: {
|
||||
onResponse: (response: RuntimeRpcResponse<unknown>) => void
|
||||
}
|
||||
}[] = []
|
||||
const unsubscribeRemote = vi.fn()
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown,
|
||||
callbacks: { onResponse: (response: RuntimeRpcResponse<unknown>) => void }
|
||||
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
|
||||
subscriptions.push({ method, params, callbacks })
|
||||
return Promise.resolve({ unsubscribe: unsubscribeRemote, sendBinary: vi.fn() })
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const fallback = vi.fn()
|
||||
const updated = vi.fn()
|
||||
const unsubscribeFallback = globals.window.api.skills.onManagedFallback(fallback)
|
||||
const unsubscribeUpdated = globals.window.api.skills.onManagedUpdated(updated)
|
||||
|
||||
expect(subscriptions).toHaveLength(1)
|
||||
expect(subscriptions[0]).toMatchObject({
|
||||
method: 'skills.managedEvents',
|
||||
params: undefined
|
||||
})
|
||||
|
||||
const fallbackEvent = {
|
||||
status: 'fallback',
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
runtime: 'host',
|
||||
scope: 'missing',
|
||||
reason: 'missing-install',
|
||||
uiKey: 'host::orca-linear:linear-worktree',
|
||||
message: 'Install orca-linear.',
|
||||
request: { skillName: 'orca-linear', context: 'linear-worktree' }
|
||||
} satisfies ManagedAgentSkillFallback
|
||||
const updatedEvent = {
|
||||
status: 'updated',
|
||||
skillName: 'orchestration',
|
||||
context: 'agent-orchestration',
|
||||
runtime: 'host',
|
||||
scope: 'global'
|
||||
} satisfies ManagedAgentSkillUpdated
|
||||
|
||||
subscriptions[0].callbacks.onResponse({
|
||||
id: 'stream-1',
|
||||
ok: true,
|
||||
result: { type: 'fallback', event: fallbackEvent },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
subscriptions[0].callbacks.onResponse({
|
||||
id: 'stream-1',
|
||||
ok: true,
|
||||
result: { type: 'updated', event: updatedEvent },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
|
||||
expect(fallback).toHaveBeenCalledWith(fallbackEvent)
|
||||
expect(updated).toHaveBeenCalledWith(updatedEvent)
|
||||
|
||||
await Promise.resolve()
|
||||
unsubscribeFallback()
|
||||
unsubscribeUpdated()
|
||||
expect(unsubscribeRemote).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resubscribes managed skill callbacks after a same-runtime stream close', async () => {
|
||||
vi.useFakeTimers()
|
||||
const subscriptions: {
|
||||
method: string
|
||||
params: unknown
|
||||
callbacks: {
|
||||
onResponse: (response: RuntimeRpcResponse<unknown>) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
}[] = []
|
||||
const unsubscribeRemote = vi.fn()
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown,
|
||||
callbacks: {
|
||||
onResponse: (response: RuntimeRpcResponse<unknown>) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
|
||||
subscriptions.push({ method, params, callbacks })
|
||||
return Promise.resolve({ unsubscribe: unsubscribeRemote, sendBinary: vi.fn() })
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
|
||||
await Promise.resolve()
|
||||
expect(subscriptions).toHaveLength(1)
|
||||
|
||||
subscriptions[0].callbacks.onClose?.()
|
||||
expect(unsubscribeRemote).toHaveBeenCalledTimes(1)
|
||||
expect(subscriptions).toHaveLength(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(subscriptions).toHaveLength(2)
|
||||
expect(subscriptions[1]).toMatchObject({
|
||||
method: 'skills.managedEvents',
|
||||
params: undefined
|
||||
})
|
||||
|
||||
unsubscribe()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps direct web managed fallback behavior when runtime event subscription fails', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
subscribe(): Promise<never> {
|
||||
return Promise.reject(new Error('method unavailable'))
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const fallback = vi.fn()
|
||||
const unsubscribe = globals.window.api.skills.onManagedFallback(fallback)
|
||||
await Promise.resolve()
|
||||
|
||||
const result = await globals.window.api.skills.ensureManagedReady({
|
||||
skillName: 'orca-linear',
|
||||
context: 'linear-worktree',
|
||||
remoteRuntime: true
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'fallback',
|
||||
reason: 'remote-runtime',
|
||||
skillName: 'orca-linear'
|
||||
})
|
||||
expect(fallback).toHaveBeenCalledWith(result)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('retries managed skill callbacks after a same-runtime pending subscription failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const subscriptions: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
|
||||
subscriptions.push({ method, params })
|
||||
if (subscriptions.length === 1) {
|
||||
return Promise.reject(new Error('runtime starting'))
|
||||
}
|
||||
return Promise.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() })
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
|
||||
await Promise.resolve()
|
||||
expect(subscriptions).toEqual([{ method: 'skills.managedEvents', params: undefined }])
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(subscriptions).toEqual([
|
||||
{ method: 'skills.managedEvents', params: undefined },
|
||||
{ method: 'skills.managedEvents', params: undefined }
|
||||
])
|
||||
|
||||
unsubscribe()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('subscribes managed skill callbacks after a web runtime is paired', async () => {
|
||||
const subscriptions: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
|
||||
subscriptions.push({ method, params })
|
||||
return Promise.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() })
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
|
||||
expect(subscriptions).toEqual([])
|
||||
|
||||
const pairingCode = Buffer.from(
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:1234',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'public-key'
|
||||
})
|
||||
).toString('base64url')
|
||||
|
||||
await globals.window.api.runtimeEnvironments.addFromPairingCode({
|
||||
name: 'Test runtime',
|
||||
pairingCode: `orca://pair?code=${pairingCode}`
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(subscriptions).toEqual([{ method: 'skills.managedEvents', params: undefined }])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('resubscribes managed skill callbacks when a pending stream fails during re-pair', async () => {
|
||||
const rejectFirst: ((error: Error) => void)[] = []
|
||||
const subscriptions: { method: string; params: unknown }[] = []
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
subscribe(
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
|
||||
subscriptions.push({ method, params })
|
||||
if (subscriptions.length === 1) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectFirst.push(reject)
|
||||
})
|
||||
}
|
||||
return Promise.resolve({ unsubscribe: vi.fn(), sendBinary: vi.fn() })
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const unsubscribe = globals.window.api.skills.onManagedFallback(vi.fn())
|
||||
expect(subscriptions).toEqual([{ method: 'skills.managedEvents', params: undefined }])
|
||||
|
||||
const pairingCode = Buffer.from(
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:5678',
|
||||
deviceToken: 'token-2',
|
||||
publicKeyB64: 'public-key-2'
|
||||
})
|
||||
).toString('base64url')
|
||||
|
||||
await globals.window.api.runtimeEnvironments.addFromPairingCode({
|
||||
name: 'Replacement runtime',
|
||||
pairingCode: `orca://pair?code=${pairingCode}`
|
||||
})
|
||||
rejectFirst[0]?.(new Error('old runtime closed'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(subscriptions).toHaveLength(2)
|
||||
})
|
||||
expect(subscriptions[1]).toEqual({ method: 'skills.managedEvents', params: undefined })
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('does not leak a pending managed skill stream subscription during callback churn', async () => {
|
||||
const resolvers: ((handle: { unsubscribe: () => void; sendBinary: () => void }) => void)[] = []
|
||||
const unsubscribeRemote = [vi.fn(), vi.fn()]
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
subscribe(): Promise<{ unsubscribe: () => void; sendBinary: () => void }> {
|
||||
return new Promise((resolve) => {
|
||||
resolvers.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage)
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
|
||||
const unsubscribeFallback = globals.window.api.skills.onManagedFallback(vi.fn())
|
||||
expect(resolvers).toHaveLength(1)
|
||||
|
||||
unsubscribeFallback()
|
||||
const unsubscribeUpdated = globals.window.api.skills.onManagedUpdated(vi.fn())
|
||||
expect(resolvers).toHaveLength(1)
|
||||
|
||||
resolvers[0]?.({ unsubscribe: unsubscribeRemote[0], sendBinary: vi.fn() })
|
||||
await vi.waitFor(() => {
|
||||
expect(unsubscribeRemote[0]).toHaveBeenCalledTimes(1)
|
||||
expect(resolvers).toHaveLength(2)
|
||||
})
|
||||
|
||||
resolvers[1]?.({ unsubscribe: unsubscribeRemote[1], sendBinary: vi.fn() })
|
||||
await Promise.resolve()
|
||||
unsubscribeUpdated()
|
||||
expect(unsubscribeRemote[1]).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects paired web computer-use status failures instead of marking the helper unavailable', async () => {
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
|
||||
@@ -29,8 +29,10 @@ import type {
|
||||
ManagedAgentSkillEnsureRequest,
|
||||
ManagedAgentSkillEnsureResult,
|
||||
ManagedAgentSkillFallback,
|
||||
ManagedAgentSkillUpdated,
|
||||
SkillDiscoveryResult
|
||||
} from '../../../shared/skills'
|
||||
import { shouldEmitManagedAgentSkillFallback } from '../../../shared/skills'
|
||||
import {
|
||||
getDefaultOnboardingState,
|
||||
getDefaultSettings,
|
||||
@@ -127,6 +129,7 @@ let activeClientEnvironmentId: string | null = null
|
||||
let cachedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
|
||||
let cachedDetectedWorktrees: { loadedAt: number; worktrees: Worktree[] } | null = null
|
||||
const runtimeCallQueuePool = new RuntimeRpcCallQueuePool()
|
||||
const activeRuntimeEnvironmentListeners = new Set<() => void>()
|
||||
|
||||
function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -357,6 +360,12 @@ type WebKeybindingDocument = {
|
||||
platforms: Partial<Record<KeybindingPlatform, KeybindingOverrides>>
|
||||
}
|
||||
|
||||
type ManagedSkillRuntimeStreamEvent =
|
||||
| { type: 'fallback'; event: ManagedAgentSkillFallback }
|
||||
| { type: 'updated'; event: ManagedAgentSkillUpdated }
|
||||
| { type: 'ready'; subscriptionId: string }
|
||||
| { type: 'end' }
|
||||
|
||||
export const GITHUB_WEB_RPC_METHODS = {
|
||||
repoSlug: 'github.repoSlug',
|
||||
repoUpstream: 'github.repoUpstream',
|
||||
@@ -1012,6 +1021,7 @@ function createRuntimeEnvironmentsApi(): NonNullable<Partial<PreloadApi>['runtim
|
||||
closeActiveRuntimeClients()
|
||||
activeEnvironment = createStoredWebRuntimeEnvironment({ name, offer })
|
||||
saveStoredWebRuntimeEnvironment(activeEnvironment)
|
||||
notifyActiveRuntimeEnvironmentChanged()
|
||||
return { environment: redactStoredWebRuntimeEnvironment(activeEnvironment) }
|
||||
},
|
||||
resolve: async ({ selector }) =>
|
||||
@@ -2302,8 +2312,127 @@ function createComputerUsePermissionsApi(): NonNullable<
|
||||
|
||||
function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
||||
const fallbackCallbacks = new Set<(event: ManagedAgentSkillFallback) => void>()
|
||||
const updatedCallbacks = new Set<(event: ManagedAgentSkillUpdated) => void>()
|
||||
const cooldownUntilByKey = new Map<string, number>()
|
||||
let managedEventsSubscription: { unsubscribe: () => void } | null = null
|
||||
let managedEventsSubscriptionPromise: Promise<void> | null = null
|
||||
let managedEventsSubscriptionCancelPending = false
|
||||
let managedEventsRetryTimer: number | null = null
|
||||
const cooldownMs = 60_000
|
||||
const managedEventsRetryMs = 5_000
|
||||
const hasManagedEventCallbacks = (): boolean =>
|
||||
fallbackCallbacks.size > 0 || updatedCallbacks.size > 0
|
||||
const clearManagedEventRetryTimer = (): void => {
|
||||
if (managedEventsRetryTimer === null) {
|
||||
return
|
||||
}
|
||||
window.clearTimeout(managedEventsRetryTimer)
|
||||
managedEventsRetryTimer = null
|
||||
}
|
||||
const scheduleManagedEventRetry = (): void => {
|
||||
if (managedEventsRetryTimer !== null || !hasManagedEventCallbacks()) {
|
||||
return
|
||||
}
|
||||
if (!requireActiveEnvironmentOrNull()) {
|
||||
return
|
||||
}
|
||||
managedEventsRetryTimer = window.setTimeout(() => {
|
||||
managedEventsRetryTimer = null
|
||||
ensureManagedEventSubscription()
|
||||
}, managedEventsRetryMs)
|
||||
}
|
||||
const stopManagedEventSubscription = (): void => {
|
||||
const subscription = managedEventsSubscription
|
||||
if (subscription) {
|
||||
managedEventsSubscription = null
|
||||
managedEventsSubscriptionPromise = null
|
||||
managedEventsSubscriptionCancelPending = false
|
||||
subscription.unsubscribe()
|
||||
return
|
||||
}
|
||||
if (managedEventsSubscriptionPromise) {
|
||||
managedEventsSubscriptionCancelPending = true
|
||||
return
|
||||
}
|
||||
managedEventsSubscriptionCancelPending = false
|
||||
}
|
||||
const restartManagedEventSubscription = (): void => {
|
||||
stopManagedEventSubscription()
|
||||
scheduleManagedEventRetry()
|
||||
}
|
||||
const handleManagedStreamEvent = (event: ManagedSkillRuntimeStreamEvent): void => {
|
||||
if (event.type === 'fallback') {
|
||||
fallbackCallbacks.forEach((callback) => callback(event.event))
|
||||
} else if (event.type === 'updated') {
|
||||
updatedCallbacks.forEach((callback) => callback(event.event))
|
||||
} else if (event.type === 'end') {
|
||||
restartManagedEventSubscription()
|
||||
}
|
||||
}
|
||||
const ensureManagedEventSubscription = (): void => {
|
||||
if (
|
||||
managedEventsSubscription ||
|
||||
managedEventsSubscriptionPromise ||
|
||||
!hasManagedEventCallbacks()
|
||||
) {
|
||||
return
|
||||
}
|
||||
const environment = requireActiveEnvironmentOrNull()
|
||||
if (!environment) {
|
||||
return
|
||||
}
|
||||
clearManagedEventRetryTimer()
|
||||
const client = getClientForEnvironment(environment)
|
||||
const subscribedEnvironmentId = environment.id
|
||||
managedEventsSubscriptionCancelPending = false
|
||||
managedEventsSubscriptionPromise = client
|
||||
.subscribe(
|
||||
'skills.managedEvents',
|
||||
undefined,
|
||||
{
|
||||
onResponse: (response) => {
|
||||
if (!response.ok) {
|
||||
stopManagedEventSubscription()
|
||||
return
|
||||
}
|
||||
handleManagedStreamEvent(response.result as ManagedSkillRuntimeStreamEvent)
|
||||
},
|
||||
onError: () => {
|
||||
restartManagedEventSubscription()
|
||||
},
|
||||
onClose: () => {
|
||||
restartManagedEventSubscription()
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
.then((subscription) => {
|
||||
if (managedEventsSubscriptionCancelPending) {
|
||||
managedEventsSubscriptionCancelPending = false
|
||||
managedEventsSubscriptionPromise = null
|
||||
subscription.unsubscribe()
|
||||
ensureManagedEventSubscription()
|
||||
return
|
||||
}
|
||||
managedEventsSubscription = subscription
|
||||
managedEventsSubscriptionPromise = null
|
||||
if (!hasManagedEventCallbacks()) {
|
||||
stopManagedEventSubscription()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
managedEventsSubscription = null
|
||||
managedEventsSubscriptionPromise = null
|
||||
managedEventsSubscriptionCancelPending = false
|
||||
const active = requireActiveEnvironmentOrNull()
|
||||
if (hasManagedEventCallbacks() && active && active.id !== subscribedEnvironmentId) {
|
||||
ensureManagedEventSubscription()
|
||||
} else {
|
||||
scheduleManagedEventRetry()
|
||||
}
|
||||
})
|
||||
}
|
||||
activeRuntimeEnvironmentListeners.add(ensureManagedEventSubscription)
|
||||
return {
|
||||
discover: () =>
|
||||
callRuntimeResult<SkillDiscoveryResult>('skills.discover', undefined, 15_000).catch(() => ({
|
||||
@@ -2346,15 +2475,33 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
|
||||
request
|
||||
} satisfies ManagedAgentSkillFallback
|
||||
cooldownUntilByKey.set(uiKey, Date.now() + cooldownMs)
|
||||
if (shouldEmitManagedAgentSkillFallback(result)) {
|
||||
fallbackCallbacks.forEach((callback) => callback(result))
|
||||
}
|
||||
return Promise.resolve(result satisfies ManagedAgentSkillEnsureResult)
|
||||
},
|
||||
onManagedFallback: (callback) => {
|
||||
fallbackCallbacks.add(callback)
|
||||
ensureManagedEventSubscription()
|
||||
return () => {
|
||||
fallbackCallbacks.delete(callback)
|
||||
if (!hasManagedEventCallbacks()) {
|
||||
clearManagedEventRetryTimer()
|
||||
stopManagedEventSubscription()
|
||||
}
|
||||
}
|
||||
},
|
||||
onManagedUpdated: () => () => {}
|
||||
onManagedUpdated: (callback) => {
|
||||
updatedCallbacks.add(callback)
|
||||
ensureManagedEventSubscription()
|
||||
return () => {
|
||||
updatedCallbacks.delete(callback)
|
||||
if (!hasManagedEventCallbacks()) {
|
||||
clearManagedEventRetryTimer()
|
||||
stopManagedEventSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2670,6 +2817,13 @@ function disconnectActiveRuntimeEnvironment(): void {
|
||||
closeActiveRuntimeClients()
|
||||
clearStoredWebRuntimeEnvironment()
|
||||
activeEnvironment = null
|
||||
notifyActiveRuntimeEnvironmentChanged()
|
||||
}
|
||||
|
||||
function notifyActiveRuntimeEnvironmentChanged(): void {
|
||||
for (const listener of activeRuntimeEnvironmentListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEnvironment(selector: string): StoredWebRuntimeEnvironment {
|
||||
|
||||
@@ -28,6 +28,7 @@ export type FeatureInteractionId =
|
||||
| 'agent-orchestration-setup'
|
||||
| 'agent-orchestration'
|
||||
| 'mobile-emulator-agent-setup'
|
||||
| 'mobile-emulator-agent-use'
|
||||
| 'ai-commit-generation'
|
||||
| 'ai-pr-generation'
|
||||
| 'claude-account-switching'
|
||||
@@ -105,6 +106,10 @@ export const FEATURE_INTERACTIONS = [
|
||||
id: 'mobile-emulator-agent-setup',
|
||||
interaction: 'Mobile Emulator agent CLI or skill setup opened'
|
||||
},
|
||||
{
|
||||
id: 'mobile-emulator-agent-use',
|
||||
interaction: 'mobile emulator runtime method used'
|
||||
},
|
||||
{
|
||||
id: 'ai-commit-generation',
|
||||
interaction: 'AI commit message generation enabled or used'
|
||||
|
||||
@@ -49,6 +49,7 @@ export const FEATURE_INTERACTION_CATEGORY_BY_ID = {
|
||||
'agent-orchestration-setup': 'setup',
|
||||
'agent-orchestration': 'collaboration',
|
||||
'mobile-emulator-agent-setup': 'setup',
|
||||
'mobile-emulator-agent-use': 'agent',
|
||||
'ai-commit-generation': 'source_control',
|
||||
'ai-pr-generation': 'source_control',
|
||||
'claude-account-switching': 'settings',
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('feature interactions', () => {
|
||||
'agent-orchestration-setup',
|
||||
'agent-orchestration',
|
||||
'mobile-emulator-agent-setup',
|
||||
'mobile-emulator-agent-use',
|
||||
'ai-commit-generation',
|
||||
'ai-pr-generation',
|
||||
'claude-account-switching',
|
||||
|
||||
@@ -15,6 +15,10 @@ export function withBrowserPaneUiRuntimeRpcSource(value: unknown): unknown {
|
||||
}
|
||||
|
||||
export function isBrowserPaneUiRuntimeRpcParams(value: unknown): boolean {
|
||||
return isUiOwnedRuntimeRpcParams(value)
|
||||
}
|
||||
|
||||
export function isUiOwnedRuntimeRpcParams(value: unknown): boolean {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime'
|
||||
import type {
|
||||
COMPUTER_USE_SKILL_NAME,
|
||||
LINEAR_TICKETS_SKILL_NAME,
|
||||
ORCA_LINEAR_SKILL_NAME,
|
||||
ORCA_CLI_SKILL_NAME,
|
||||
ORCHESTRATION_SKILL_NAME
|
||||
} from './agent-feature-install-commands'
|
||||
@@ -59,7 +59,7 @@ export type SkillFrontmatterSummary = {
|
||||
|
||||
export type ManagedAgentSkillName =
|
||||
| typeof COMPUTER_USE_SKILL_NAME
|
||||
| typeof LINEAR_TICKETS_SKILL_NAME
|
||||
| typeof ORCA_LINEAR_SKILL_NAME
|
||||
| typeof ORCA_CLI_SKILL_NAME
|
||||
| typeof ORCHESTRATION_SKILL_NAME
|
||||
|
||||
@@ -154,8 +154,8 @@ export function shouldEmitManagedAgentSkillFallback(
|
||||
return (
|
||||
result.status === 'fallback' &&
|
||||
result.reason !== 'cooldown' &&
|
||||
// Why: the modal is an install/update surface; fallbacks without a command
|
||||
// only create a dead-end Re-check loop.
|
||||
Boolean(result.manualCommand)
|
||||
// Why: these are integration/no-op states, not actionable user setup states.
|
||||
result.reason !== 'target-required' &&
|
||||
result.reason !== 'unsupported-skill'
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user