diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index 1d65727df3..6b006b1ef2 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -9,7 +9,14 @@ * desktop's selection never flips the server-wide current-board pointer. */ -import { atom, type PluginOs, type PluginRestOptions, type PluginStorage, type PluginTranslate, queryClient } from '@hermes/plugin-sdk' +import { + atom, + type PluginOs, + type PluginRestOptions, + type PluginStorage, + type PluginTranslate, + queryClient +} from '@hermes/plugin-sdk' // Native completion notification. import { bindCompletionNotify, type CompletionEvent, onKanbanEventsFrame } from './completion-notify' @@ -85,7 +92,12 @@ interface Persisted { * runs on unload/disable — so nothing (store sync, socket) survives a toggle * or duplicates on re-enable. The events socket is pinned to a board at * handshake, so a board switch closes + reopens it. */ -export function bindApi(r: Rest, storage: PluginStorage, socket: Socket, notifyDoors?: { os?: PluginOs; t?: PluginTranslate }): () => void { +export function bindApi( + r: Rest, + storage: PluginStorage, + socket: Socket, + notifyDoors?: { os?: PluginOs; t?: PluginTranslate } +): () => void { rest = r bindCompletionNotify(r, notifyDoors?.t, notifyDoors?.os) const unsubs: Array<() => void> = [] diff --git a/apps/desktop/src/plugins/kanban/completion-notify.test.ts b/apps/desktop/src/plugins/kanban/completion-notify.test.ts index ee1252d497..3c1d0f4416 100644 --- a/apps/desktop/src/plugins/kanban/completion-notify.test.ts +++ b/apps/desktop/src/plugins/kanban/completion-notify.test.ts @@ -16,25 +16,34 @@ import type { CompletionEvent } from './completion-notify' * bans import() in type annotations). */ type Rest = (path: string, opts?: PluginRestOptions) => Promise type Translate = (key: string, ...args: unknown[]) => string -interface OsDoor { notify: (input: { title: string; body?: string; silent?: boolean }) => void } +interface OsDoor { + notify: (input: { title: string; body?: string; silent?: boolean }) => void +} interface Mod { bindCompletionNotify(r: Rest, t?: Translate, os?: OsDoor): void onKanbanEventsFrame(slug: string, events?: CompletionEvent[]): Promise } const { hostMock } = vi.hoisted(() => ({ - hostMock: { notify: vi.fn(), navigate: vi.fn() }, + hostMock: { notify: vi.fn(), navigate: vi.fn() } })) vi.mock('@hermes/plugin-sdk', () => ({ host: hostMock, // Pulled in transitively via ./i18n (the module reads its `en` bundle for // fallback titles); never called in these tests. - usePluginI18n: () => (key: string) => key, + usePluginI18n: () => (key: string) => key })) -type NotifyInput = { message: string; title?: string; kind?: string; detail?: string; action?: { label: string; onClick: () => void } } -const lastNotify = (): NotifyInput => hostMock.notify.mock.calls[hostMock.notify.mock.calls.length - 1][0] as NotifyInput +type NotifyInput = { + message: string + title?: string + kind?: string + detail?: string + action?: { label: string; onClick: () => void } +} +const lastNotify = (): NotifyInput => + hostMock.notify.mock.calls[hostMock.notify.mock.calls.length - 1][0] as NotifyInput /** Rest stub: GET /board resolves to the current latest_event_id. */ function makeRest(latest: () => number) { @@ -53,11 +62,16 @@ async function loadModule(): Promise { return import('./completion-notify') } -const ev = (id: number, kind = 'created', payload: Record | null = null, taskId = `t${id}`): CompletionEvent => ({ +const ev = ( + id: number, + kind = 'created', + payload: Record | null = null, + taskId = `t${id}` +): CompletionEvent => ({ id, kind, task_id: taskId, - payload, + payload }) beforeEach(() => { @@ -83,7 +97,7 @@ describe('authoritative baseline', () => { m.bindCompletionNotify(makeRest(() => 100) as never) const fired = await m.onKanbanEventsFrame('smoke', [ - ev(101, 'completed', { summary: 'Done', artifacts: ['/tmp/x/report.md'] }), + ev(101, 'completed', { summary: 'Done', artifacts: ['/tmp/x/report.md'] }) ]) expect(fired).toBe(true) @@ -113,7 +127,12 @@ describe('authoritative baseline', () => { expect(hostMock.notify).toHaveBeenCalledTimes(2) // Reconnect replays from 0: ids <= cursor are history, only 103 is new. - await m.onKanbanEventsFrame('smoke', [ev(99, 'completed'), ev(101, 'completed'), ev(102, 'completed'), ev(103, 'completed')]) + await m.onKanbanEventsFrame('smoke', [ + ev(99, 'completed'), + ev(101, 'completed'), + ev(102, 'completed'), + ev(103, 'completed') + ]) expect(hostMock.notify).toHaveBeenCalledTimes(3) expect(hostMock.notify.mock.calls[2][0]).toMatchObject({ message: 't103' }) @@ -165,7 +184,9 @@ describe('authoritative baseline', () => { const rest = vi.fn(async (path: string) => { if (path.startsWith('/board')) { - if (failBoard) {throw new Error('board unavailable')} + if (failBoard) { + throw new Error('board unavailable') + } return { latest_event_id: 200 } } @@ -226,7 +247,10 @@ describe('cursor advancement', () => { describe('board isolation', () => { it('never mixes cursors between boards', async () => { - const latest = new Map([['a', 100], ['b', 200]]) + const latest = new Map([ + ['a', 100], + ['b', 200] + ]) const rest = vi.fn(async (path: string) => { if (path.startsWith('/board')) { @@ -250,7 +274,10 @@ describe('board isolation', () => { }) it('switch away and back reuses the prior cursor, never reset to current MAX', async () => { - const latest = new Map([['a', 100], ['b', 50]]) + const latest = new Map([ + ['a', 100], + ['b', 50] + ]) const rest = vi.fn(async (path: string) => { if (path.startsWith('/board')) { @@ -308,7 +335,7 @@ describe('notification content', () => { title: 'Task completed', message: 'Done', detail: 't101', - action: { label: 'Open Kanban', onClick: expect.any(Function) }, + action: { label: 'Open Kanban', onClick: expect.any(Function) } }) }) @@ -317,7 +344,7 @@ describe('notification content', () => { m.bindCompletionNotify(makeRest(() => 100) as never) await m.onKanbanEventsFrame('smoke', [ - ev(101, 'completed', { summary: 'Done', artifacts: ['/work/x/out/report.md'] }), + ev(101, 'completed', { summary: 'Done', artifacts: ['/work/x/out/report.md'] }) ]) expect(lastNotify().detail).toBe('t101 · report.md') @@ -328,7 +355,7 @@ describe('notification content', () => { m.bindCompletionNotify(makeRest(() => 100) as never) await m.onKanbanEventsFrame('smoke', [ - ev(101, 'completed', { summary: 'Done', artifacts: ['/a/1.md', '/b/2.md', '/c/3.md'] }), + ev(101, 'completed', { summary: 'Done', artifacts: ['/a/1.md', '/b/2.md', '/c/3.md'] }) ]) expect(lastNotify().detail).toBe('t101 · 3 artifacts') @@ -341,7 +368,7 @@ describe('notification content', () => { await m.onKanbanEventsFrame('smoke', [ ev(101, 'completed', null), ev(102, 'completed', { summary: 42, artifacts: 'nope' }), - ev(103, 'completed', { summary: 'ok', artifacts: [7, ' /tmp/x/ok.md ', null] }), + ev(103, 'completed', { summary: 'ok', artifacts: [7, ' /tmp/x/ok.md ', null] }) ]) // All three are unseen completions; none may throw. @@ -396,7 +423,7 @@ describe('terminal kinds beyond completed', () => { kind: 'warning', title: 'Task blocked — needs your input', message: 'needs API key', - detail: 't101', + detail: 't101' }) }) @@ -427,7 +454,11 @@ describe('terminal kinds beyond completed', () => { const m = await loadModule() m.bindCompletionNotify(makeRest(() => 100) as never) - await m.onKanbanEventsFrame('smoke', [ev(101, 'status', { status: 'running' }), ev(102, 'archived'), ev(103, 'unblocked')]) + await m.onKanbanEventsFrame('smoke', [ + ev(101, 'status', { status: 'running' }), + ev(102, 'archived'), + ev(103, 'unblocked') + ]) expect(hostMock.notify).not.toHaveBeenCalled() // Cursor moved past 103: a replayed blocked at 102 stays silent, 104 fires. @@ -448,12 +479,16 @@ describe('native OS door', () => { expect(os.notify).toHaveBeenCalledTimes(1) expect(os.notify.mock.calls[0][0]).toEqual({ title: 'Task blocked — needs your input', - body: 'needs input\nt101', + body: 'needs input\nt101' }) }) it('an os door that throws never breaks the toast or the frame result', async () => { - const os = { notify: vi.fn(() => { throw new Error('no shell') }) } + const os = { + notify: vi.fn(() => { + throw new Error('no shell') + }) + } const m = await loadModule() m.bindCompletionNotify(makeRest(() => 100) as never, undefined, os) @@ -480,11 +515,17 @@ describe('native OS door', () => { describe('i18n routing', () => { it('uses the bound plugin translator when it resolves the key', async () => { const t = vi.fn((key: string, ...args: unknown[]) => { - if (key === 'notify.completedTitle') {return 'タスク完了'} + if (key === 'notify.completedTitle') { + return 'タスク完了' + } - if (key === 'notify.openKanban') {return 'かんばんを開く'} + if (key === 'notify.openKanban') { + return 'かんばんを開く' + } - if (key === 'notify.artifacts') {return `成果物 ${args[0]} 件`} + if (key === 'notify.artifacts') { + return `成果物 ${args[0]} 件` + } return key }) @@ -497,7 +538,7 @@ describe('i18n routing', () => { expect(lastNotify()).toMatchObject({ title: 'タスク完了', detail: 't101 · 成果物 2 件', - action: { label: 'かんばんを開く', onClick: expect.any(Function) }, + action: { label: 'かんばんを開く', onClick: expect.any(Function) } }) }) diff --git a/apps/desktop/src/plugins/kanban/completion-notify.ts b/apps/desktop/src/plugins/kanban/completion-notify.ts index efdba3f47b..6a4acabe32 100644 --- a/apps/desktop/src/plugins/kanban/completion-notify.ts +++ b/apps/desktop/src/plugins/kanban/completion-notify.ts @@ -92,11 +92,15 @@ export function bindCompletionNotify(r: Rest, pluginTranslate?: PluginTranslate, } async function ensureBaseline(slug: string): Promise { - if (seenEventIdByBoard.has(slug) || baselinePending.has(slug)) { return } + if (seenEventIdByBoard.has(slug) || baselinePending.has(slug)) { + return + } baselinePending.add(slug) try { - const board = (await rest!<{ latest_event_id?: unknown }>(`/board?board=${encodeURIComponent(slug)}`)) as { latest_event_id?: unknown } + const board = (await rest!<{ latest_event_id?: unknown }>(`/board?board=${encodeURIComponent(slug)}`)) as { + latest_event_id?: unknown + } seenEventIdByBoard.set(slug, typeof board.latest_event_id === 'number' ? board.latest_event_id : 0) } catch { // Fail-closed: unknown baseline → notifications stay suppressed. @@ -133,41 +137,72 @@ function notifyOne(kind: string, spec: { titleKey: string; toast: ToastKind }, e const taskId = (ev.task_id ?? '').trim() const body = bodyFor(kind, ev) - const artifacts = kind === 'completed' && Array.isArray(ev.payload?.artifacts) - ? (ev.payload!.artifacts as unknown[]).filter((a): a is string => typeof a === 'string' && a.trim().length > 0).map(a => a.trim()) - : [] + const artifacts = + kind === 'completed' && Array.isArray(ev.payload?.artifacts) + ? (ev.payload!.artifacts as unknown[]) + .filter((a): a is string => typeof a === 'string' && a.trim().length > 0) + .map(a => a.trim()) + : [] - const artifactText = artifacts.length === 1 ? (artifacts[0].split(/[\\/]/).pop() || artifacts[0]) : artifacts.length > 1 ? t('notify.artifacts', artifacts.length) : '' + const artifactText = + artifacts.length === 1 + ? artifacts[0].split(/[\\/]/).pop() || artifacts[0] + : artifacts.length > 1 + ? t('notify.artifacts', artifacts.length) + : '' const detail = [taskId, artifactText].filter(Boolean).join(' · ') const title = t(spec.titleKey) const message = body || taskId || title - host.notify({ kind: spec.toast, title, message, ...(detail ? { detail } : {}), action: { label: t('notify.openKanban'), onClick: () => host.navigate('/kanban') } }) + host.notify({ + kind: spec.toast, + title, + message, + ...(detail ? { detail } : {}), + action: { label: t('notify.openKanban'), onClick: () => host.navigate('/kanban') } + }) // Native OS notification — the desktop shell fires it only while the user // is away from Hermes (the toast above covers the foreground case). Isolated: // a missing/broken shell must not mark the toast as unfired. - try { osDoor?.notify({ title, body: [message, detail].filter(Boolean).join('\n') }) } catch { /* swallowed */ } + try { + osDoor?.notify({ title, body: [message, detail].filter(Boolean).join('\n') }) + } catch { + /* swallowed */ + } } /** Consume one /events frame for a board. Returns true when a terminal-event * notification was fired. Never throws: notification failure cannot * interfere with api.ts cache invalidation. */ export async function onKanbanEventsFrame(slug: string, events?: CompletionEvent[]): Promise { - if (!events?.length || slug === '' || !rest) { return false } + if (!events?.length || slug === '' || !rest) { + return false + } await ensureBaseline(slug) const seen = seenEventIdByBoard.get(slug) - if (seen === undefined) { return false } // fail-closed + if (seen === undefined) { + return false + } // fail-closed let fired = false let cursor = seen for (const ev of events) { - if (typeof ev.id !== 'number' || ev.id <= cursor) { continue } + if (typeof ev.id !== 'number' || ev.id <= cursor) { + continue + } cursor = ev.id seenEventIdByBoard.set(slug, cursor) const spec = TERMINAL_NOTIFY.get(ev.kind ?? '') - if (spec) { try { notifyOne(ev.kind!, spec, ev); fired = true } catch { /* swallowed */ } } + if (spec) { + try { + notifyOne(ev.kind!, spec, ev) + fired = true + } catch { + /* swallowed */ + } + } } return fired diff --git a/apps/desktop/src/store/cron-model-impact.ts b/apps/desktop/src/store/cron-model-impact.ts index 67ef869641..798149cc28 100644 --- a/apps/desktop/src/store/cron-model-impact.ts +++ b/apps/desktop/src/store/cron-model-impact.ts @@ -150,6 +150,7 @@ export async function setMainModelAssignment( ): Promise { const { connection, generation } = beginCronModelImpactAssignment() const profile = profileIdentity() + // Only pass the extra arg when a scope override exists, so unscoped callers // keep the exact legacy call shape. const result =