177 lines
6.8 KiB
TypeScript
177 lines
6.8 KiB
TypeScript
import { atom, map } from 'nanostores'
|
|
|
|
import {
|
|
getActionStatus,
|
|
installSkillFromHub,
|
|
type ProfileScope,
|
|
uninstallSkillFromHub,
|
|
updateSkillsFromHub
|
|
} from '@/hermes'
|
|
import { queryClient } from '@/lib/query-client'
|
|
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
|
|
import { upsertDesktopActionTask } from '@/store/activity'
|
|
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
|
|
|
const POLL_MS = 1200
|
|
|
|
// Shared with hub.tsx's sources useQuery so a finished action refreshes the
|
|
// installed map.
|
|
export const HUB_SOURCES_KEY = ['skill-hub-sources'] as const
|
|
// The Capabilities Skills-list query key (see app/skills/index.tsx) — kept in
|
|
// sync here so a hub (un)install updates the Skills tab, not just the hub.
|
|
const SKILLS_LIST_KEY = ['skills-list'] as const
|
|
// The built-in optional-skills catalog rows in the Skills tab: an install
|
|
// flips one of them to an installed (toggle) row, so the catalog's
|
|
// installed-flags must refetch alongside the skills list.
|
|
export const OFFICIAL_SKILLS_KEY = ['official-skills'] as const
|
|
// Non-identifier key for the fleet-wide "Update installed" action.
|
|
export const UPDATE_ALL_KEY = '__update_all__'
|
|
|
|
export type HubActionKind = 'install' | 'uninstall' | 'update'
|
|
|
|
export interface HubAction {
|
|
kind: HubActionKind
|
|
running: boolean
|
|
lines: string[]
|
|
}
|
|
|
|
// Per-item action status, keyed by skill identifier (or UPDATE_ALL_KEY). Each
|
|
// row drives its own button off ITS entry — one install never touches another.
|
|
export const $hubActions = map<Record<string, HubAction | undefined>>({})
|
|
|
|
// Optimistic installed overrides so a row flips to its resolved state the instant
|
|
// its own action finishes, instead of waiting on (and racing) the sources
|
|
// refetch. install/update → true, uninstall → false; sources reconciles after.
|
|
export const $hubInstalledOverride = map<Record<string, boolean | undefined>>({})
|
|
|
|
// The key whose log the bottom pane currently tails (the latest-started action).
|
|
export const $hubActiveLog = atom<null | string>(null)
|
|
|
|
// Hub action state is per-profile: a profile switch must drop every in-flight
|
|
// entry, optimistic override, and active log so profile A's install/uninstall
|
|
// state can never render (or be polled) in profile B. Cleared at the source so
|
|
// it holds regardless of whether the Hub view is mounted. The epoch bumps on
|
|
// every switch; a runHubAction() started before the switch captures it and bails
|
|
// before any store write once it no longer matches (so an A action finishing
|
|
// after the clear can't repopulate B).
|
|
let _hubProfile: null | string = null
|
|
let _hubEpoch = 0
|
|
|
|
$activeGatewayProfile.subscribe(value => {
|
|
const key = normalizeProfileKey(value)
|
|
|
|
if (_hubProfile !== null && _hubProfile !== key) {
|
|
_hubEpoch += 1
|
|
$hubActions.set({})
|
|
$hubInstalledOverride.set({})
|
|
$hubActiveLog.set(null)
|
|
}
|
|
|
|
_hubProfile = key
|
|
})
|
|
|
|
// One self-contained task: spawn → tail its own action log into the store →
|
|
// mark resolved. Concurrency-safe: state is per-key, so parallel installs never
|
|
// stomp each other, and the sources query is invalidated once at the end.
|
|
// `profile` is the Capabilities profile-scope override — the action (and its
|
|
// status polling) runs against THAT profile's backend; undefined keeps the
|
|
// app-wide active profile (unchanged behavior).
|
|
async function runHubAction(
|
|
key: string,
|
|
kind: HubActionKind,
|
|
spawn: () => Promise<{ name: string }>,
|
|
profile?: ProfileScope
|
|
): Promise<void> {
|
|
const epoch = _hubEpoch
|
|
const switched = () => _hubEpoch !== epoch
|
|
|
|
$hubActions.setKey(key, { kind, running: true, lines: [] })
|
|
$hubActiveLog.set(key)
|
|
|
|
try {
|
|
const started = await spawn()
|
|
let exitCode: number | null = null
|
|
|
|
for (;;) {
|
|
const status = await getActionStatus(started.name, 200, profile)
|
|
|
|
// Profile switched mid-flight: the store was cleared for the new profile,
|
|
// so drop this A-profile result instead of writing it back into B.
|
|
if (switched()) {
|
|
return
|
|
}
|
|
|
|
upsertDesktopActionTask(status)
|
|
$hubActions.setKey(key, { kind, running: status.running, lines: status.lines })
|
|
|
|
if (!status.running) {
|
|
exitCode = status.exit_code
|
|
|
|
break
|
|
}
|
|
|
|
await new Promise(resolve => setTimeout(resolve, POLL_MS))
|
|
}
|
|
|
|
// Only flip the row on a clean exit — a failed install/uninstall must not
|
|
// render as installed/removed.
|
|
if (key !== UPDATE_ALL_KEY && exitCode === 0) {
|
|
$hubInstalledOverride.setKey(key, kind !== 'uninstall')
|
|
}
|
|
|
|
// Refresh the hub's installed map AND the Capabilities Skills list — a hub
|
|
// (un)install adds/removes a skill, so its count/rows must update too.
|
|
void queryClient.invalidateQueries({ queryKey: HUB_SOURCES_KEY })
|
|
void queryClient.invalidateQueries({ queryKey: SKILLS_LIST_KEY })
|
|
void queryClient.invalidateQueries({ queryKey: OFFICIAL_SKILLS_KEY })
|
|
// …and the composer's `/` list, which caches the command catalog for an
|
|
// hour and would otherwise keep offering the skill we just removed.
|
|
invalidateSlashCompletions()
|
|
|
|
// A non-zero exit is a real failure — throw so the caller's catch toasts
|
|
// it. Before this, a failed subprocess (scan gate, network, bad
|
|
// identifier) just stopped silently: no flip, no toast, and the user read
|
|
// the unchanged skills list as "install did nothing" (Aug 2026 report).
|
|
// The last log lines carry the subprocess's actual error.
|
|
if (exitCode !== null && exitCode !== 0) {
|
|
const detail = ($hubActions.get()[key]?.lines ?? []).slice(-3).join('\n').trim()
|
|
|
|
throw new Error(detail || `Action exited with code ${exitCode}`)
|
|
}
|
|
} catch (err) {
|
|
// A profile switch points the next poll at the new backend, which 404s the
|
|
// old action name — that's an abandonment, not a failure, so swallow it
|
|
// instead of letting the caller toast a phantom error. Real (same-profile)
|
|
// failures still propagate.
|
|
if (switched()) {
|
|
return
|
|
}
|
|
|
|
throw err
|
|
} finally {
|
|
// Skip the running=false write after a switch — it would re-add the key the
|
|
// profile-switch clear just dropped.
|
|
const current = $hubActions.get()[key]
|
|
|
|
if (current && !switched()) {
|
|
$hubActions.setKey(key, { ...current, running: false })
|
|
}
|
|
}
|
|
}
|
|
|
|
export function installHubSkill(identifier: string, profile?: ProfileScope): Promise<void> {
|
|
return runHubAction(identifier, 'install', () => installSkillFromHub(identifier, profile), profile)
|
|
}
|
|
|
|
export function uninstallHubSkill(identifier: string, name: string, profile?: ProfileScope): Promise<void> {
|
|
return runHubAction(identifier, 'uninstall', () => uninstallSkillFromHub(name, profile), profile)
|
|
}
|
|
|
|
export function updateHubSkills(profile?: ProfileScope): Promise<void> {
|
|
return runHubAction(UPDATE_ALL_KEY, 'update', () => updateSkillsFromHub(profile), profile)
|
|
}
|
|
|
|
export function closeHubLog(): void {
|
|
$hubActiveLog.set(null)
|
|
}
|