fmt(js): npm run fix on merge (#88813)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
133381508f
commit
a3995f8aed
@@ -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<T> {
|
||||
* 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> = []
|
||||
|
||||
@@ -16,25 +16,34 @@ import type { CompletionEvent } from './completion-notify'
|
||||
* bans import() in type annotations). */
|
||||
type Rest = <T>(path: string, opts?: PluginRestOptions) => Promise<T>
|
||||
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<boolean>
|
||||
}
|
||||
|
||||
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<Mod> {
|
||||
return import('./completion-notify')
|
||||
}
|
||||
|
||||
const ev = (id: number, kind = 'created', payload: Record<string, unknown> | null = null, taskId = `t${id}`): CompletionEvent => ({
|
||||
const ev = (
|
||||
id: number,
|
||||
kind = 'created',
|
||||
payload: Record<string, unknown> | 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<string, number>([['a', 100], ['b', 200]])
|
||||
const latest = new Map<string, number>([
|
||||
['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<string, number>([['a', 100], ['b', 50]])
|
||||
const latest = new Map<string, number>([
|
||||
['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) }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -92,11 +92,15 @@ export function bindCompletionNotify(r: Rest, pluginTranslate?: PluginTranslate,
|
||||
}
|
||||
|
||||
async function ensureBaseline(slug: string): Promise<void> {
|
||||
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<boolean> {
|
||||
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
|
||||
|
||||
@@ -150,6 +150,7 @@ export async function setMainModelAssignment(
|
||||
): Promise<ModelAssignmentResponse> {
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user