From 2d1ad808968787ccb9a935e1d8091c1df13b6d7b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:12:14 -0700 Subject: [PATCH] fix(skills): fail closed on stale freshness scans --- .../generate-skill-bundle-manifest.mjs | 13 +- .../generate-skill-bundle-manifest.test.mjs | 22 +++ .../skills/SkillFreshnessNudge.test.tsx | 19 ++- .../components/skills/SkillFreshnessNudge.tsx | 15 +- .../SkillFreshnessUpdateDialog.test.tsx | 37 ++++- .../skills/SkillFreshnessUpdateDialog.tsx | 18 +- .../src/hooks/useSkillFreshness.test.tsx | 98 ++++++++++- src/renderer/src/hooks/useSkillFreshness.ts | 157 +++++++++++------- 8 files changed, 300 insertions(+), 79 deletions(-) diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index bdcc0aecca4..c3542d6f0e2 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -4,6 +4,7 @@ import { constants } from 'node:fs' import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' +import { isDeepStrictEqual } from 'node:util' const SCHEMA_VERSION = 1 const SCRIPT_DIR = import.meta.dirname @@ -424,15 +425,17 @@ function assertReleasedHistoryPreserved(committedRegistry, artifacts) { for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0 const regenerated = artifacts.snapshotRegistry.skills[name] ?? [] + if (releasedCount < Math.max(0, committedSnapshots.length - 1)) { + throw new Error( + `Released snapshot history is incomplete for ${name}. ` + + 'Fetch all release tags before regenerating skill artifacts.' + ) + } const protectedCount = Math.min(committedSnapshots.length, releasedCount) for (let index = 0; index < protectedCount; index += 1) { const committed = committedSnapshots[index] const rebuilt = regenerated[index] - if ( - !rebuilt || - rebuilt.releaseRevision !== committed.releaseRevision || - rebuilt.packageDigest !== committed.packageDigest - ) { + if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) { throw new Error( `Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` + 'Released snapshots are append-only; a deliberate identity migration must update this check.' diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs index 063b30c57f3..cf4a8a100e5 100644 --- a/config/scripts/generate-skill-bundle-manifest.test.mjs +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -111,6 +111,28 @@ describe('skill bundle manifest generator', () => { artifacts ) ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }] + } + }, + artifacts + ) + ).toThrow('Released snapshot history changed for orca-cli at revision 2') + expect(() => + assertReleasedHistoryPreserved( + { + schemaVersion: 1, + skills: { + 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] + } + }, + { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } } + ) + ).toThrow('Released snapshot history is incomplete for orca-cli') expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() }) diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx index d6dd054e453..ca0090a6c68 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx @@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({ toastDismiss: vi.fn(), requestDialog: vi.fn(), settingsLoaded: true, - inventory: null as SkillFreshnessInventory | null + inventory: null as SkillFreshnessInventory | null, + error: null as string | null })) function placement( @@ -58,7 +59,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({ useSkillFreshness: () => ({ inventory: mocks.inventory, loading: false, - error: null, + error: mocks.error, refresh: vi.fn() }) })) @@ -106,6 +107,7 @@ describe('SkillFreshnessNudge', () => { mocks.dismissed = [] mocks.settingsLoaded = true mocks.inventory = eligibleInventory() + mocks.error = null mocks.updateSettings.mockReset() mocks.updateSettings.mockResolvedValue(undefined) mocks.toastInfo.mockReset() @@ -162,6 +164,19 @@ describe('SkillFreshnessNudge', () => { expect(mocks.updateSettings).not.toHaveBeenCalled() }) + it('retracts a stale nudge when re-inventory fails', async () => { + await renderNudge() + const options = mocks.toastInfo.mock.calls[0]?.[1] + + mocks.inventory = null + mocks.error = 'scan failed' + await rerenderNudge() + options.onDismiss() + + expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast') + expect(mocks.updateSettings).not.toHaveBeenCalled() + }) + it('persists the exact placement/revision key once on explicit dismissal', async () => { await renderNudge() diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx index c4ea551a5c4..1d1b4fd668b 100644 --- a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx @@ -35,7 +35,18 @@ export function SkillFreshnessNudge(): null { useEffect(() => { const inventory = state.inventory - if (!settingsLoaded || !inventory) { + if (!settingsLoaded) { + return + } + if (!inventory) { + const active = activeNudgeRef.current + if (state.error && active) { + // Why: a failed re-check cannot keep advertising authority derived from + // old bytes; retract without turning the scan failure into a dismissal. + active.persistDismissal = false + activeNudgeRef.current = null + toast.dismiss(active.id) + } return } const eligibleNames = new Set(inventory.eligibleUpdateNames) @@ -152,7 +163,7 @@ export function SkillFreshnessNudge(): null { } ) activeNudgeRef.current = nextActive - }, [dismissed, settingsLoaded, state.inventory, updateSettings]) + }, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings]) return null } diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index d9af5daf44a..5c14b94182d 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -16,6 +16,7 @@ import { const mocks = vi.hoisted(() => ({ inventory: null as SkillFreshnessInventory | null, loading: false, + error: null as string | null, refresh: vi.fn(), terminalProps: [] as { command: string @@ -30,7 +31,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({ useSkillFreshness: () => ({ inventory: mocks.inventory, loading: mocks.loading, - error: null, + error: mocks.error, refresh: mocks.refresh }) })) @@ -139,6 +140,7 @@ describe('SkillFreshnessUpdateDialog', () => { consumeSkillFreshnessUpdateDialogRequest() mocks.inventory = eligibleInventory() mocks.loading = false + mocks.error = null mocks.refresh.mockReset() mocks.notifyChanged.mockReset() mocks.terminalProps.length = 0 @@ -210,6 +212,14 @@ describe('SkillFreshnessUpdateDialog', () => { await act(async () => { mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' }) }) + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe( + 'npx skills update orca-cli --global' + ) + mocks.inventory = { schemaVersion: 1, installations: [ @@ -218,6 +228,7 @@ describe('SkillFreshnessUpdateDialog', () => { eligibleUpdateNames: [], scannedAt: 2 } + mocks.loading = false await rerender() expect(container?.textContent).toContain('All installed Orca skills are up to date.') @@ -226,6 +237,30 @@ describe('SkillFreshnessUpdateDialog', () => { ) }) + it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => { + await renderDialog() + await openViaRequest() + expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull() + + mocks.inventory = null + mocks.loading = true + await rerender() + + expect(container?.textContent).toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + + it('shows a failed scan as an error instead of indefinite progress', async () => { + mocks.inventory = null + mocks.error = 'Could not inspect installed skills.' + await renderDialog() + await openViaRequest() + + expect(container?.textContent).toContain('Could not inspect installed skills.') + expect(container?.textContent).not.toContain('Checking installed Orca skills') + expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull() + }) + it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => { await renderDialog() await openViaRequest() diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx index 2b0a4ec97ce..61b56fee950 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx @@ -149,7 +149,15 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { }, []) useEffect(() => { - if (!open || state.loading || state.error) { + if (!open) { + return + } + if (state.loading || state.error || !inventory) { + // Why: a scan invalidates the authorization behind an unsubmitted draft. + // A running command keeps its PTY until exit, but stale drafts fail closed. + if (!terminalSubmittedRef.current && terminalCommand !== null) { + setTerminalCommand(null) + } return } if (awaitingExitRefresh) { @@ -226,9 +234,11 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element { - {state.error ?
{state.error}
: null} - -{state.error}
+ ) : ( +