Files
hermes-agent/apps/desktop/src/plugins/kanban/api.ts
T
hermes-seaeye[bot] cb77fcb008 fmt(js): npm run fix on merge (#97638)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-29 03:43:33 +00:00

298 lines
12 KiB
TypeScript

/**
* Kanban data layer. Everything goes through `ctx.rest` — the plugin's own
* `/api/plugins/kanban/*` FastAPI router (`plugins/kanban/dashboard/plugin_api.py`),
* reused as-is via the desktop's namespace-scoped REST door. No new backend.
*
* Fetching, caching, polling, dedupe, and invalidation are React Query's job
* (the app's standard, via the SDK). This module owns the query keys, the REST
* calls, and the selected-board atom — every call passes `?board=<slug>` so the
* 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'
// Native completion notification.
import { bindCompletionNotify, type CompletionEvent, onKanbanEventsFrame } from './completion-notify'
import type {
BoardExportResult,
BoardImportResult,
BoardMeta,
BoardsResponse,
KanbanBoard,
KanbanProfile,
KanbanProject,
KanbanTask,
KanbanTaskDetail,
OrchestrationSettings,
TaskEstimate,
WorkerLog
} from './types'
type Rest = <T>(path: string, opts?: PluginRestOptions) => Promise<T>
type Socket = (path: string, onMessage: (data: unknown) => void) => () => void
let rest: null | Rest = null
let os: null | PluginOs = null
/** Selected board slug ('' = the server's current board). Persisted. */
export const $boardSlug = atom<string>('')
/** Whether the "how this board works" intro was dismissed. Persisted. */
export const $introDismissed = atom<boolean>(false)
/** Sub-group the Running lane by assignee (the dashboard's "lanes by
* profile"). Persisted. */
export const $lanesByProfile = atom<boolean>(false)
/** Per-lane collapse OVERRIDES (true=collapsed, false=expanded). Absence means
* auto: empty lanes collapse to a rail, occupied lanes expand. Persisted. */
export const $collapsedLanes = atom<Record<string, boolean>>({})
const BOARD_SLUG_KEY = 'boardSlug'
const INTRO_KEY = 'introDismissed'
const LANES_KEY = 'lanesByProfile'
const COLLAPSED_KEY = 'collapsedLanes'
/** One live `task_events` frame → precise cache invalidation: the board, plus
* each touched task's detail. The polls (8s board / 4s drawer) stay as the
* fallback — the socket just makes the board feel instant. */
function onEventsFrame(slug: string, data: unknown): void {
const events = (data as { events?: CompletionEvent[] })?.events
if (!events?.length) {
return
}
void queryClient.invalidateQueries({ queryKey: ['kanban', 'board'] })
// Any event can change a board's card count — keep the switcher badge honest.
void queryClient.invalidateQueries({ queryKey: BOARDS_KEY })
for (const taskId of new Set(events.map(event => event.task_id).filter(Boolean))) {
void queryClient.invalidateQueries({ queryKey: taskKey(slug, taskId!) })
}
// Completion notification (after invalidation so notify failure
// never interferes with cache invalidation).
void onKanbanEventsFrame(slug, events).catch(() => undefined)
}
// A persisted, subscribable atom (the structural slice we need — avoids
// importing nanostore's type just to describe one).
interface Persisted<T> {
get(): T
set(value: T): void
listen(cb: (value: T) => void): () => void
}
/** Bind the plugin's doors at register time and return a disposer the host
* 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 {
rest = r
os = notifyDoors?.os ?? null
bindCompletionNotify(r, notifyDoors?.t, notifyDoors?.os)
const unsubs: Array<() => void> = []
// Hydrate an atom from storage and keep storage in sync with it.
const persist = <T>(atom: Persisted<T>, key: string, fallback: T) => {
atom.set(storage.get(key, fallback))
unsubs.push(atom.listen(value => storage.set(key, value)))
}
persist($boardSlug, BOARD_SLUG_KEY, '')
persist($introDismissed, INTRO_KEY, false)
persist($lanesByProfile, LANES_KEY, false)
persist($collapsedLanes, COLLAPSED_KEY, {})
let close: (() => void) | null = null
const open = (slug: string) => {
close?.()
close = socket(slug ? `/events?board=${encodeURIComponent(slug)}` : '/events', data => onEventsFrame(slug, data))
}
open($boardSlug.get())
unsubs.push($boardSlug.listen(open))
return () => {
unsubs.forEach(unsub => unsub())
close?.()
rest = null
os = null
}
}
/** The plugin's OS door, for components too deep to be handed `ctx`. Null
* before `bindApi` and after unload. */
export const pluginOs = (): null | PluginOs => os
function call<T>(path: string, opts?: PluginRestOptions): Promise<T> {
return rest ? rest<T>(path, opts) : Promise.reject(new Error('kanban api not ready'))
}
/** Append the selected board (and other params) to a path. */
function withBoard(path: string, params: Record<string, string> = {}): string {
const search = new URLSearchParams(params)
const slug = $boardSlug.get()
if (slug) {
search.set('board', slug)
}
const qs = search.toString()
return qs ? `${path}?${qs}` : path
}
// ── query keys (all board-scoped so switching boards is a clean cache miss) ──
export const boardKey = (slug: string, archived: boolean) => ['kanban', 'board', slug, archived] as const
export const taskKey = (slug: string, id: string) => ['kanban', 'task', slug, id] as const
export const logKey = (slug: string, id: string) => ['kanban', 'log', slug, id] as const
export const BOARDS_KEY = ['kanban', 'boards'] as const
export const PROFILES_KEY = ['kanban', 'profiles'] as const
export const PROJECTS_KEY = ['kanban', 'projects'] as const
export const ORCHESTRATION_KEY = ['kanban', 'orchestration'] as const
// ── reads ─────────────────────────────────────────────────────────────────────
export const fetchBoard = (archived: boolean) =>
call<KanbanBoard>(withBoard('/board', archived ? { include_archived: 'true' } : {}))
export const fetchTask = (id: string) => call<KanbanTaskDetail>(withBoard(`/tasks/${id}`))
/** Worker stdout/stderr tail (last 16 KiB — plenty for the drawer). */
export const fetchLog = (id: string) => call<WorkerLog>(withBoard(`/tasks/${id}/log`, { tail: '16384' }))
export const fetchBoards = () => call<BoardsResponse>('/boards')
export const fetchProfiles = () => call<{ profiles: KanbanProfile[] }>('/profiles')
/** First-class Hermes projects, for scoping a board's default workspace. */
export const fetchProjects = () => call<{ projects: KanbanProject[] }>('/projects')
export const fetchOrchestration = () => call<OrchestrationSettings>('/orchestration')
// ── writes ────────────────────────────────────────────────────────────────────
// Every board edit nudges the dispatcher (debounced, fire-and-forget) so the
// change takes effect NOW instead of on the next 60s tick — create a ready
// task and the worker spawns immediately, no manual "nudge" ritual. The tick
// is lock-guarded and ~1ms when there's nothing to do, so over-nudging is
// free; failures are non-events (the periodic tick still exists).
let nudgeTimer: null | ReturnType<typeof setTimeout> = null
function autoNudge(): void {
if (nudgeTimer != null) {
clearTimeout(nudgeTimer)
}
nudgeTimer = setTimeout(() => {
nudgeTimer = null
nudgeDispatcher().catch(() => undefined)
}, 400)
}
/** Resolve the write, then kick the dispatcher. Rejections pass through. */
function nudged<T>(write: Promise<T>): Promise<T> {
return write.then(value => {
autoNudge()
return value
})
}
export const patchTask = (id: string, patch: Record<string, unknown>) =>
nudged(call(withBoard(`/tasks/${id}`), { method: 'PATCH', body: patch }))
export const createTask = (body: Record<string, unknown>) =>
nudged(call<{ task: KanbanTask | null; warning?: string }>(withBoard('/tasks'), { method: 'POST', body }))
// Deleting can unblock dependants (a gone parent no longer gates), so it
// nudges too.
export const deleteTask = (id: string) => nudged(call(withBoard(`/tasks/${id}`), { method: 'DELETE' }))
/** One patch, many ids — independent per-id application; returns per-id
* outcomes so the UI can toast partial failures. */
export const bulkTasks = (ids: string[], patch: Record<string, unknown>) =>
nudged(
call<{ results: Array<{ id: string; ok: boolean; error?: string }> }>(withBoard('/tasks/bulk'), {
method: 'POST',
body: { ids, ...patch }
})
)
export const addComment = (id: string, body: string) =>
call(withBoard(`/tasks/${id}/comments`), { method: 'POST', body: { author: 'desktop', body } })
export const reassignTask = (id: string, profile: string) =>
nudged(call(withBoard(`/tasks/${id}/reassign`), { method: 'POST', body: { profile, reclaim_first: true } }))
export const reclaimTask = (id: string) => nudged(call(withBoard(`/tasks/${id}/reclaim`), { method: 'POST', body: {} }))
export const uploadAttachment = (id: string, upload: { filename: string; contentType?: string; bytes: ArrayBuffer }) =>
call(withBoard(`/tasks/${id}/attachments`), { method: 'POST', upload })
export const createBoard = (slug: string, name: string, projectId?: string) =>
call<{ board: { slug: string } }>('/boards', {
method: 'POST',
body: { slug, name, ...(projectId ? { project_id: projectId } : {}) }
})
/** Rough auxiliary-model estimate for a task (tokens + complexity). Makes a
* model call — gate behind an explicit user action + disclaimer. */
export const estimateTask = (id: string) =>
call<TaskEstimate>(withBoard(`/tasks/${id}/estimate`), { method: 'POST', body: {} })
/** Estimate from typed title/body before a task exists (create dialog). */
export const estimateNew = (title: string, body: string) =>
call<TaskEstimate>('/estimate', { method: 'POST', body: { title, body: body || undefined } })
/** Edit a board's display metadata + default project directory. Pass
* `default_workdir: ''` to clear it. Slug is immutable. */
export const updateBoard = (slug: string, patch: Record<string, unknown>) =>
call<{ board: BoardMeta }>(`/boards/${encodeURIComponent(slug)}`, { method: 'PATCH', body: patch })
/** Archive a board to `boards/_archived/` — recoverable, and the backend
* refuses to touch `default`. (`?delete=true` hard-deletes; no caller yet.) */
export const deleteBoard = (slug: string) =>
call<{ result: { action: string; new_path: string }; current: string }>(`/boards/${encodeURIComponent(slug)}`, {
method: 'DELETE'
})
// Board transfer exchanges filesystem paths, not bytes — the picker runs on
// the machine hosting the backend, so the backend reads and writes the file.
export const exportBoard = (slug: string, output: string) =>
call<BoardExportResult>(`/boards/${encodeURIComponent(slug)}/export`, { method: 'POST', body: { output } })
export const importBoard = (archive: string) =>
call<BoardImportResult>('/boards/import', { method: 'POST', body: { archive } })
export const nudgeDispatcher = () => call<{ spawned?: unknown[] }>(withBoard('/dispatch'), { method: 'POST', body: {} })
export const saveOrchestration = (patch: Record<string, unknown>) =>
call<OrchestrationSettings>('/orchestration', { method: 'PUT', body: patch })
export const saveProfileDescription = (name: string, description: string) =>
call(`/profiles/${encodeURIComponent(name)}`, { method: 'PATCH', body: { description } })
export const autoDescribeProfile = (name: string) =>
call<{ ok: boolean; reason?: null | string; description?: null | string }>(
`/profiles/${encodeURIComponent(name)}/describe-auto`,
{ method: 'POST', body: { overwrite: true } }
)