refactor(tasks): 后台任务改读宿主客户端 jobs 服务,删除自建三条路由
- 新增 src/client/jobs-client.ts:结构探测 ctx.jobs(watchRows / observe / state / kill),引用计数的按会话 roster 订阅、只观测展开的那一行、 跨树去重(无主任务归到承载它的被观测会话,树外 owner 丢弃)。 - 抽屉与输出浮窗改读该服务:行来自推送 roster(状态 / 时长 / owner / 进度), 输出面换 TerminalBlock 显示宿主为观察者流的保留输出(gapBefore → 截断提示、 error → 失败提示),kill 走 jobs.kill(owner, jobId),两击确认不变。 - 自动激活(use-host-feeds)改骑同一条推送 roster;detectNewJob 增加 「观测起点时钟」参数,避免「页面打开时已在跑的任务」被当成新工作。 - 删除 src/jobs-routes.ts(+ tests/jobs-routes.spec.ts)、index.ts 的三条路由、 api.jobsList/jobOutput/jobKill 与 JobOutputResult、context-types 的 SidebarJobsService(换成 SidebarClientJobsService/SidebarJobsSnapshot/ SidebarObservedJob 镜像,SidebarJobView 补 owner/progress)、 页面里的 jobs.list 轮询与 collectTreeJobs。 - 三个 spec 改用假 ctx.jobs 服务(jobs-view 14 例 / 自动激活 12 例 / 页面 20 例):观测只在展开行、切换与关闭释放观测、watch 隐藏时不建立、 卸载时全部释放、无服务时不渲染任务面。 验证:typecheck / lint 通过;pnpm test 1240 passed / 9 skipped;pnpm build + pack 通过。 (cherry picked from commit 7d9b82e73190218f05910433ed520cb90e4d4ab2)
This commit is contained in:
+53
-65
@@ -1,24 +1,26 @@
|
||||
/**
|
||||
* The background-jobs bottom drawer of the Tasks page and its output popover.
|
||||
*
|
||||
* Drawer: the tree's jobs (owner-labeled, fed by the `session/jobs` push
|
||||
* mirror) collapse into a bottom bar that AUTO-COLLAPSES once the tree has
|
||||
* many agents — the manual toggle always wins afterwards.
|
||||
* Drawer: the tree's jobs (owner-labeled, fed by the HOST's client jobs
|
||||
* service — see jobs-client.ts) collapse into a bottom bar that AUTO-COLLAPSES
|
||||
* once the tree has many agents — the manual toggle always wins afterwards.
|
||||
*
|
||||
* Popover: clicking a row opens a DRAGGABLE, portalled output card (see
|
||||
* AnchoredPopover) instead of docking a pane inside the page. It shows the
|
||||
* output the MODEL has read (event replay — never the model's job_output
|
||||
* cursor), with a copy action, a follow-latest switch, the two-click kill,
|
||||
* and a terminal-style tail while the job runs.
|
||||
* output the host streams for an OBSERVER (never the model's consuming
|
||||
* cursor), with a copy action, a follow-latest switch, the two-click kill, and
|
||||
* a terminal-style tail while the job runs.
|
||||
*
|
||||
* Every control is a host primitive (Button / Tag / Switch / StateDot).
|
||||
* Every control is a host primitive (Button / Tag / Switch / StateDot /
|
||||
* TerminalBlock).
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconChevronUpOutlineRegular, IconCopyOutlineRegular, IconStopFillRegular, StateDot, Switch, Tag,
|
||||
type TagTone,
|
||||
TerminalBlock, type TagTone,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarJobView } from '../context-types.ts'
|
||||
import type { SidebarClientJobsService, SidebarJobView, SidebarObservedJob } from '../context-types.ts'
|
||||
import { terminalBlockLabels } from './block-labels.ts'
|
||||
import {
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
@@ -26,12 +28,9 @@ import {
|
||||
jobStatusLabel,
|
||||
type TreeJob,
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './tasks-graph.module.css'
|
||||
|
||||
/** Refresh cadence of an open job-output popover while its job runs. */
|
||||
const JOB_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const JOB_KILL_ARM_MS = 3000
|
||||
/** The agent count at which the drawer starts collapsed. */
|
||||
@@ -47,6 +46,10 @@ function jobTone(job: SidebarJobView): TagTone {
|
||||
|
||||
export interface JobsDrawerProps {
|
||||
rows: readonly TreeJob[]
|
||||
/** The host client jobs service; absent → no kill control is offered. */
|
||||
jobs: SidebarClientJobsService | undefined
|
||||
/** The observed output of the job whose panel is open (host stream). */
|
||||
observed: SidebarObservedJob | undefined
|
||||
/** Agent count of the tree (root included) — the auto-collapse signal. */
|
||||
agentCount: number
|
||||
/** Open the output popover of one row (anchor = the row's main button). */
|
||||
@@ -56,7 +59,7 @@ export interface JobsDrawerProps {
|
||||
}
|
||||
|
||||
export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
const { rows, agentCount, onOpenOutput, openJobId } = props
|
||||
const { rows, agentCount, jobs, onOpenOutput, openJobId } = props
|
||||
const autoOpen = agentCount < JOBS_DRAWER_COLLAPSE_AT
|
||||
/** Manual override; undefined = follow the auto rule. */
|
||||
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
||||
@@ -90,17 +93,18 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
}, [liveCount])
|
||||
|
||||
const kill = useCallback(async (row: TreeJob): Promise<void> => {
|
||||
if (jobs === undefined) return
|
||||
setKillingId(row.job.id)
|
||||
setKillErrorId(undefined)
|
||||
try {
|
||||
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
|
||||
await jobs.kill(row.ownerSessionId, row.job.id)
|
||||
} catch {
|
||||
setKillErrorId(row.job.id)
|
||||
} finally {
|
||||
setKillingId(undefined)
|
||||
setArmedId(undefined)
|
||||
}
|
||||
}, [])
|
||||
}, [jobs])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
@@ -205,46 +209,24 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
|
||||
* keeps its two-click confirm.
|
||||
*/
|
||||
export function JobOutputPopoverContent(props: {
|
||||
ownerSessionId: string
|
||||
/** The host client jobs service (absent → the panel only reports that). */
|
||||
jobs: SidebarClientJobsService | undefined
|
||||
job: SidebarJobView
|
||||
active: boolean
|
||||
/** The owner session the roster carried the job under (kill + observation). */
|
||||
ownerSessionId: string
|
||||
/** The host's streamed observation of THIS job (undefined until it arrives). */
|
||||
observed: SidebarObservedJob | undefined
|
||||
}): ReactNode {
|
||||
const { ownerSessionId, job, active } = props
|
||||
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
|
||||
const { jobs, job, ownerSessionId, observed } = props
|
||||
const [follow, setFollow] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [armed, setArmed] = useState(false)
|
||||
const [killing, setKilling] = useState(false)
|
||||
const [killFailed, setKillFailed] = useState(false)
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const preRef = useRef<HTMLPreElement>(null)
|
||||
const live = isJobLive(job)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
|
||||
setState(result)
|
||||
} catch {
|
||||
// A newer pull aborted this one, or the wire failed: keep the last
|
||||
// known output; only a popover that never loaded anything shows an error.
|
||||
setState(current => (current === 'loading' ? 'error' : current))
|
||||
}
|
||||
}, [ownerSessionId, job.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !live) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
// isJobLive reads only job.status; the job object churns on every catalog
|
||||
// refresh and must not restart the poll interval.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
const text = observed?.text ?? ''
|
||||
const labels = useMemo(() => terminalBlockLabels(), [])
|
||||
|
||||
// The armed kill disarms itself, like the drawer's button.
|
||||
useEffect(() => {
|
||||
@@ -253,26 +235,22 @@ export function JobOutputPopoverContent(props: {
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}, [armed])
|
||||
|
||||
// Terminal-tail behavior: each refresh pins the view to the newest output
|
||||
// Terminal-tail behavior: every frame pins the view to the newest output
|
||||
// while the reader keeps the follow switch on.
|
||||
useEffect(() => {
|
||||
if (!live || !follow || typeof state !== 'object' || state.text.length === 0) return
|
||||
if (!live || !follow || text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
// Same as the poll effect: only the status transition matters.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state, job.status, follow])
|
||||
}, [text, live, follow])
|
||||
|
||||
const text = typeof state === 'object' ? state.text : ''
|
||||
|
||||
/** Two-click kill from the popover (then re-read so the row settles). */
|
||||
/** Two-click kill from the popover (the roster settles the row afterwards). */
|
||||
const kill = async (): Promise<void> => {
|
||||
if (jobs === undefined) return
|
||||
setKilling(true)
|
||||
setKillFailed(false)
|
||||
try {
|
||||
await api.jobKill({ sessionId: ownerSessionId }, job.id)
|
||||
await jobs.kill(ownerSessionId, job.id)
|
||||
setArmed(false)
|
||||
await load()
|
||||
} catch {
|
||||
setKillFailed(true)
|
||||
} finally {
|
||||
@@ -311,16 +289,26 @@ export function JobOutputPopoverContent(props: {
|
||||
{job.detail !== undefined && job.detail !== '' && <span>{job.detail}</span>}
|
||||
<span className={css.popHint}>{t('jobDragHint')}</span>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.popHint}>{t('loading')}</div>}
|
||||
{state === 'error' && <div className={css.popError}>{t('jobOutputError')}</div>}
|
||||
{typeof state === 'object' && (
|
||||
{observed === undefined && <div className={css.popHint}>{t('loading')}</div>}
|
||||
{observed !== undefined && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={`${css.popPre} ${css.jobPopPre}`} data-popover-no-drag>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.popHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.popHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.popHint}>{t('jobOutputTruncated')}</div>}
|
||||
{observed.gapBefore && <div className={css.popHint}>{t('jobOutputTruncated')}</div>}
|
||||
{observed.error !== undefined && <div className={css.popError}>{t('jobOutputError')}</div>}
|
||||
{observed.text.length > 0
|
||||
? (
|
||||
<div ref={preRef as unknown as React.RefObject<HTMLDivElement>} data-popover-no-drag>
|
||||
<TerminalBlock
|
||||
command={job.label}
|
||||
output={observed.text}
|
||||
running={observed.streaming}
|
||||
runStateDot={false}
|
||||
maxLines={Number.POSITIVE_INFINITY}
|
||||
copyText={job.label}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: <div className={css.popHint}>{t('jobNoOutput')}</div>}
|
||||
</>
|
||||
)}
|
||||
<div className={css.jobPopActions} data-popover-no-drag>
|
||||
|
||||
+44
-39
@@ -34,8 +34,9 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarClientJobsService,
|
||||
SidebarObservedJob,
|
||||
SidebarChildLiveView,
|
||||
SidebarJobView,
|
||||
SidebarSessionList,
|
||||
SidebarSubagentAddress,
|
||||
} from '../context-types.ts'
|
||||
@@ -46,9 +47,12 @@ import {
|
||||
} from './subagent-detect.ts'
|
||||
import { subagentCatalogs } from './subagent-catalog.ts'
|
||||
import { treeSessionIds } from './subagent-lineage.ts'
|
||||
import { collectTreeJobs, orderJobs } from './subagent-jobs.ts'
|
||||
import { orderJobs, type TreeJob } from './subagent-jobs.ts'
|
||||
import { api, type TeamsViewResult } from './api.ts'
|
||||
import { usePolling } from './use-polling.ts'
|
||||
import {
|
||||
clientJobs, collectRows, useJobObservation, useJobWatchers, useJobsSnapshot,
|
||||
} from './jobs-client.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { buildTasksModel, type TasksAgentNode, type TasksWorkflowNode } from './tasks-model.ts'
|
||||
import { TasksGraph } from './TasksGraph.tsx'
|
||||
@@ -66,8 +70,6 @@ import legacy from './SubagentView.module.css'
|
||||
const POLL_MS = 3000
|
||||
/** Poll cadence of the workflow-run and team views while the page is visible. */
|
||||
const TELEMETRY_POLL_MS = 5000
|
||||
/** Poll cadence of the background-job lists (one read per tree session). */
|
||||
const JOBS_POLL_MS = 3000
|
||||
|
||||
/**
|
||||
* The workspace face's "show this conversation" verb. DSH moved child and
|
||||
@@ -156,43 +158,34 @@ function useTeamView(
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-job lists of the whole tree, keyed by OWNER session.
|
||||
*
|
||||
* DSH 0.1.7 dropped the client session snapshot's `jobsBySession` push mirror,
|
||||
* so the registry is read through the plugin's own `jobs.list` route — and the
|
||||
* registry's access fence admits a job to its OWNER session only, so the whole
|
||||
* tree is one read per tree session, fanned out on each tick. A host without
|
||||
* the jobs service (503) or one dropped read degrades to an empty set for THAT
|
||||
* session; the next tick retries.
|
||||
* The tree's background jobs, read through the HOST's client jobs service
|
||||
* (`ctx.jobs` — see jobs-client.ts). Watching is per tree session (the host
|
||||
* shares one stream between watchers) and only while the page is on screen;
|
||||
* a deployment without the service renders no jobs surface at all.
|
||||
*/
|
||||
function useTreeJobs(
|
||||
ctx: Context,
|
||||
byId: SidebarSessionList['byId'],
|
||||
rootId: string | undefined,
|
||||
active: boolean,
|
||||
): Readonly<Record<string, readonly SidebarJobView[]>> {
|
||||
): {
|
||||
rows: TreeJob[]
|
||||
jobs: SidebarClientJobsService | undefined
|
||||
/** The observed output per job id (the panel picks its own entry). */
|
||||
observed: Readonly<Record<string, SidebarObservedJob | undefined>>
|
||||
} {
|
||||
const jobs = useMemo(() => clientJobs(ctx), [ctx])
|
||||
const snapshot = useJobsSnapshot(jobs)
|
||||
const treeIds = useMemo(
|
||||
() => (rootId === undefined ? [] : [...treeSessionIds(byId, rootId)]),
|
||||
[byId, rootId],
|
||||
)
|
||||
const [jobsBySession, setJobsBySession] = useState<Readonly<Record<string, readonly SidebarJobView[]>>>({})
|
||||
useEffect(() => { setJobsBySession({}) }, [rootId])
|
||||
const poll = useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||
const entries = await Promise.all(treeIds.map(async (sessionId): Promise<[string, readonly SidebarJobView[]]> => {
|
||||
try {
|
||||
const result = await api.jobsList(sessionId, signal)
|
||||
return [sessionId, result.jobs]
|
||||
} catch {
|
||||
return [sessionId, []]
|
||||
}
|
||||
}))
|
||||
if (!signal.aborted) setJobsBySession(Object.fromEntries(entries))
|
||||
}, [treeIds])
|
||||
usePolling(active && treeIds.length > 0, poll, {
|
||||
intervalMs: JOBS_POLL_MS,
|
||||
mode: 'self-scheduling',
|
||||
immediate: true,
|
||||
})
|
||||
return jobsBySession
|
||||
useJobWatchers(jobs, treeIds, active)
|
||||
const rows = useMemo(
|
||||
() => (rootId === undefined ? [] : orderJobs(collectRows(snapshot, byId, new Set(treeIds)))),
|
||||
[snapshot, byId, rootId, treeIds],
|
||||
)
|
||||
return { rows, jobs, observed: snapshot.observed }
|
||||
}
|
||||
|
||||
/** The open popover of the page (one at a time, anchored). */
|
||||
@@ -380,12 +373,15 @@ export function SubagentView(props: {
|
||||
)
|
||||
const agentCount = totals.count + 1
|
||||
|
||||
/** The tree's ordered job rows (owner-labeled, read per tree session). */
|
||||
const jobsBySession = useTreeJobs(byId, rootId, active)
|
||||
const jobRows = useMemo(
|
||||
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
|
||||
[byId, jobsBySession, rootId],
|
||||
)
|
||||
/** The tree's ordered job rows (owner-labeled, off the host's push roster). */
|
||||
const treeJobs = useTreeJobs(ctx, byId, rootId, active)
|
||||
const jobRows = treeJobs.rows
|
||||
// Only the open panel's job is observed: the host streams output per observer.
|
||||
const openJob = popover?.kind === 'job'
|
||||
? jobRows.find(row => row.job.id === popover.jobId)
|
||||
: undefined
|
||||
useJobObservation(treeJobs.jobs, openJob?.ownerSessionId, openJob?.job.id)
|
||||
const observedJob = openJob === undefined ? undefined : treeJobs.observed[openJob.job.id]
|
||||
|
||||
/** Catalogs that failed to load (surfaced as one banner in both modes). */
|
||||
const failedParents = useMemo(
|
||||
@@ -456,7 +452,14 @@ export function SubagentView(props: {
|
||||
if (popover.kind === 'job') {
|
||||
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
|
||||
if (row === undefined) return null
|
||||
return <JobOutputPopoverContent ownerSessionId={row.ownerSessionId} job={row.job} active={active} />
|
||||
return (
|
||||
<JobOutputPopoverContent
|
||||
jobs={treeJobs.jobs}
|
||||
ownerSessionId={row.ownerSessionId}
|
||||
job={row.job}
|
||||
observed={treeJobs.observed[row.job.id]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const node = model.find(candidate => candidate.id === popover.nodeId)
|
||||
if (node === undefined) return null
|
||||
@@ -587,6 +590,8 @@ export function SubagentView(props: {
|
||||
<JobsDrawer
|
||||
rows={jobRows}
|
||||
agentCount={agentCount}
|
||||
jobs={treeJobs.jobs}
|
||||
observed={observedJob}
|
||||
openJobId={popover?.kind === 'job' ? popover.jobId : undefined}
|
||||
onOpenOutput={(row, anchor) => {
|
||||
setPopover(popover?.kind === 'job' && popover.jobId === row.job.id
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '..
|
||||
import type {
|
||||
SidebarCreateTeamTaskRequest,
|
||||
SidebarChildLiveView,
|
||||
SidebarJobView,
|
||||
SidebarSessionEvent,
|
||||
SidebarTeamMemberView,
|
||||
SidebarTeamTaskMutationResult,
|
||||
@@ -104,21 +103,6 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* One jobs.output response: the output the MODEL has read so far for the
|
||||
* job (replayed from the owner session's event log — the model's
|
||||
* job_output cursor is never touched, so the pane can never steal the
|
||||
* agent's bytes). `read` is false until the model actually called
|
||||
* job_output for the job.
|
||||
*/
|
||||
export interface JobOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
/** Whether the model has read the job at least once. */
|
||||
read: boolean
|
||||
}
|
||||
|
||||
/** The `subagents.live` response: one row per tree child (and the root). */
|
||||
export type SubagentLiveResult = { live: Record<string, SidebarChildLiveView> }
|
||||
|
||||
@@ -348,30 +332,6 @@ export const api = {
|
||||
/** Cherry-pick one commit onto the current branch. */
|
||||
gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) =>
|
||||
call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })),
|
||||
/**
|
||||
* The output the model has read so far for one background job (replayed
|
||||
* from the owner session's event log — never the model's job_output
|
||||
* cursor). The scope MUST be the job's OWNER session.
|
||||
*/
|
||||
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
|
||||
/**
|
||||
* The background-job list of one session (the Tasks page's jobs section and
|
||||
* the job auto-open trigger). DSH 0.1.7 dropped the client session
|
||||
* snapshot's jobs mirror, so the registry is read through the plugin's own
|
||||
* `jobs.list` route. The registry's access fence admits a job to its OWNER
|
||||
* session (and to unowned jobs) only, so a caller that needs the whole tree
|
||||
* asks once per tree session. A host without the jobs service answers 503:
|
||||
* the rejection is the caller's to degrade from (an empty section).
|
||||
*/
|
||||
jobsList: (sessionId: string, signal?: AbortSignal) =>
|
||||
call<{ jobs: SidebarJobView[] }>('jobs.list', { sessionId }, signal),
|
||||
/** Request cancellation of one background job (live jobs flip to stopping). */
|
||||
jobKill: (scope: SessionScope, id: string, reason?: string) =>
|
||||
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
|
||||
id,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
})),
|
||||
/**
|
||||
* One batch live-preview fetch for the whole Subagent tree. The payload is
|
||||
* the already-resolved topology ROOT (not a session scope); the host
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Display copy for the host primitives the sidebar renders OUTSIDE the main
|
||||
* conversation: `TerminalBlock` (a captured command's output surface) is
|
||||
* cordis-free and takes every label as a prop, so the owning render site must
|
||||
* supply them.
|
||||
*
|
||||
* One builder, two consumers (the Side Chat tool card and the Tasks page's
|
||||
* background-job output panel): the two surfaces must read identically, and a
|
||||
* new required label on the host side has exactly one place to land.
|
||||
*/
|
||||
import { t } from './locales.ts'
|
||||
|
||||
/** Labels for the sidebar's markdown / diff / read Block chrome. */
|
||||
export interface BlockLabels {
|
||||
copy: string
|
||||
copied: string
|
||||
collapse: string
|
||||
collapseAria: string
|
||||
expand: (hidden: number) => string
|
||||
expandAria: (hidden: number) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's Blocks reuse the Side Chat dictionary's chrome family (copy
|
||||
* pair + the collapse/expand family that is common to every Block kind).
|
||||
*/
|
||||
export function blockLabels(): BlockLabels {
|
||||
return {
|
||||
copy: t('copy'),
|
||||
copied: t('copied'),
|
||||
collapse: t('sideChatBlockCollapse'),
|
||||
collapseAria: t('sideChatBlockCollapseAria'),
|
||||
expand: (hidden: number) => t('sideChatBlockExpand', { hidden }),
|
||||
expandAria: (hidden: number) => t('sideChatBlockExpandAria', { hidden }),
|
||||
}
|
||||
}
|
||||
|
||||
/** The `TerminalBlockLabels` shape (structural mirror; the host owns the type). */
|
||||
export interface TerminalLabels {
|
||||
signal: (signal: string) => string
|
||||
exitCode: (exitCode: number) => string
|
||||
noExitCode: string
|
||||
running: string
|
||||
failed: string
|
||||
done: string
|
||||
copy: string
|
||||
copied: string
|
||||
noOutput: string
|
||||
collapseAria: string
|
||||
collapse: string
|
||||
expandAria: (hidden: number) => string
|
||||
expand: (hidden: number) => string
|
||||
}
|
||||
|
||||
/** Labels for one `TerminalBlock` (a captured command plus its output). */
|
||||
export function terminalBlockLabels(): TerminalLabels {
|
||||
return {
|
||||
...blockLabels(),
|
||||
signal: (signal: string) => t('sideChatBlockSignal', { signal }),
|
||||
exitCode: (exitCode: number) => t('sideChatBlockExitCode', { code: exitCode }),
|
||||
// DSH 0.1.6-alpha.2 added this pill text: a settle the view cannot
|
||||
// name (killed by an unknown signal, or never started).
|
||||
noExitCode: t('sideChatBlockNoExitCode'),
|
||||
running: t('sideChatBlockRunning'),
|
||||
failed: t('sideChatBlockFailed'),
|
||||
done: t('sideChatBlockDone'),
|
||||
noOutput: t('sideChatBlockNoOutput'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* The Tasks page's read of the HOST's client jobs service (`ctx.jobs`).
|
||||
*
|
||||
* The plugin used to carry its own three-route jobs transport: a per-session
|
||||
* `jobs.list` fan-out every 3s, a `job_output` trace replayed from the owner
|
||||
* session's event log (the registry's only read is the model's CONSUMING
|
||||
* cursor, which a human pane must never move), and a kill passthrough. The
|
||||
* host service replaces all three — and this module is the whole data layer of
|
||||
* that swap, kept apart from the DRAWER so the auto-open trigger in
|
||||
* `sidebar/use-host-feeds.ts` reads the same feed the page renders.
|
||||
*
|
||||
* Structural detection only (the plugin never imports the host package): a
|
||||
* deployment without the service sees `undefined` and renders no jobs surface.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
import type { SidebarSessionList } from '../context-types.ts'
|
||||
import type {
|
||||
Context,
|
||||
SidebarClientJobsService,
|
||||
SidebarJobsSnapshot,
|
||||
} from '../context-types.ts'
|
||||
import { type TreeJob } from './subagent-jobs.ts'
|
||||
|
||||
/** The empty snapshot a deployment without the jobs service reads. */
|
||||
const EMPTY_JOBS: SidebarJobsSnapshot = { rows: {}, observed: {} }
|
||||
|
||||
/**
|
||||
* The client jobs service, when this deployment mounts the host jobs UI.
|
||||
* @param ctx - the client context.
|
||||
*/
|
||||
export function clientJobs(ctx: Context): SidebarClientJobsService | undefined {
|
||||
const service = ctx.get('jobs') as SidebarClientJobsService | undefined
|
||||
if (service === undefined) return undefined
|
||||
if (typeof service.watchRows !== 'function' || typeof service.observe !== 'function') return undefined
|
||||
if (typeof service.kill !== 'function' || service.state === undefined) return undefined
|
||||
return service
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the jobs snapshot (roster + observations).
|
||||
* @param jobs - the client jobs service (absent → an empty snapshot forever).
|
||||
*/
|
||||
export function useJobsSnapshot(jobs: SidebarClientJobsService | undefined): SidebarJobsSnapshot {
|
||||
const subscribe = useMemo(
|
||||
() => (callback: () => void) => (jobs === undefined ? () => {} : jobs.state.subscribe(callback)),
|
||||
[jobs],
|
||||
)
|
||||
const read = useCallback(() => (jobs === undefined ? EMPTY_JOBS : jobs.state.getSnapshot()), [jobs])
|
||||
return useSyncExternalStore(subscribe, read)
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch (and release) one roster per session, while the page is on screen. The
|
||||
* host shares a single stream between watchers of one session, so this is
|
||||
* cheap; the key is the joined id list so a re-render with the same tree does
|
||||
* not re-subscribe.
|
||||
* @param jobs - the client jobs service.
|
||||
* @param sessionIds - the tree's sessions (the root included).
|
||||
* @param active - whether the page is visible at all.
|
||||
*/
|
||||
export function useJobWatchers(
|
||||
jobs: SidebarClientJobsService | undefined,
|
||||
sessionIds: readonly string[],
|
||||
active: boolean,
|
||||
): void {
|
||||
const key = sessionIds.join(',')
|
||||
useEffect(() => {
|
||||
if (jobs === undefined || !active || key === '') return
|
||||
const releases = key.split(',').map(sessionId => jobs.watchRows(sessionId))
|
||||
return () => {
|
||||
for (const release of releases) release()
|
||||
}
|
||||
}, [jobs, key, active])
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe ONE job's output while its panel is open; the release runs on close.
|
||||
* @param jobs - the client jobs service.
|
||||
* @param ownerSessionId - the session the roster carried the job under.
|
||||
* @param jobId - the observed job (undefined closes the observation).
|
||||
*/
|
||||
export function useJobObservation(
|
||||
jobs: SidebarClientJobsService | undefined,
|
||||
ownerSessionId: string | undefined,
|
||||
jobId: string | undefined,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (jobs === undefined || jobId === undefined) return
|
||||
return jobs.observe(ownerSessionId, jobId)
|
||||
}, [jobs, ownerSessionId, jobId])
|
||||
}
|
||||
|
||||
/**
|
||||
* One job's rows as the drawer renders them, deduped across the tree.
|
||||
*
|
||||
* An UNOWNED job is visible to every watched session, so the union of rosters
|
||||
* repeats it: rows are keyed by job id and attributed to the watched session
|
||||
* whose roster carried them (which is also the session the observation and the
|
||||
* kill go through). Jobs owned by a session outside the tree are dropped.
|
||||
*
|
||||
* @param snapshot - the client service snapshot.
|
||||
* @param byId - the session mirror (owner labels).
|
||||
* @param treeIds - the tree's session ids (root included).
|
||||
*/
|
||||
export function collectRows(
|
||||
snapshot: SidebarJobsSnapshot,
|
||||
byId: SidebarSessionList['byId'],
|
||||
treeIds: ReadonlySet<string>,
|
||||
): TreeJob[] {
|
||||
const rows: TreeJob[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const [watched, jobs] of Object.entries(snapshot.rows)) {
|
||||
for (const job of jobs) {
|
||||
if (seen.has(job.id)) continue
|
||||
if (job.owner !== undefined && !treeIds.has(job.owner)) continue
|
||||
seen.add(job.id)
|
||||
rows.push({
|
||||
ownerSessionId: watched,
|
||||
ownerTitle: byId[job.owner ?? watched]?.displayTitle ?? job.owner ?? watched,
|
||||
job,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -5,30 +5,20 @@
|
||||
* jump-back). All of it reacts to the host's live feeds for the CURRENT
|
||||
* session; the sidebar shell only consumes the returned jump-back ref.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { Context, SidebarJobView, SidebarSessionList } from '../../context-types.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
import { isNarrowWidth } from '../breakpoints.ts'
|
||||
import { detectNewDirectSubagent } from '../subagent-detect.ts'
|
||||
import { detectNewJob } from '../subagent-jobs.ts'
|
||||
import { api } from '../api.ts'
|
||||
import { clientJobs, useJobsSnapshot } from '../jobs-client.ts'
|
||||
import { mountedSessionId } from '../native/surface.ts'
|
||||
import { usePolling } from '../use-polling.ts'
|
||||
import { t } from '../locales.ts'
|
||||
|
||||
/** How many consecutive reconnect failures stop the agent-opens push loop
|
||||
* (the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* Background-job poll cadence (ms) for the job auto-open trigger. DSH 0.1.7
|
||||
* removed the client session snapshot's jobs mirror, so the trigger reads the
|
||||
* plugin's `jobs.list` route on a timer instead of reacting to a push. One
|
||||
* in-process registry read per tick bounds the auto-open latency at this
|
||||
* value.
|
||||
*/
|
||||
const JOB_POLL_MS = 2_000
|
||||
|
||||
/**
|
||||
* Subagent auto-open debounce (ms). The host delivers a new child's origin
|
||||
* and its title in SEPARATE frames: a Side Chat thread's first visible
|
||||
@@ -235,42 +225,44 @@ export function useHostFeeds(feeds: {
|
||||
* (0 → N only), ANY new job id triggers: the agent may start several jobs in
|
||||
* one session, and each should surface.
|
||||
*
|
||||
* The list is POLLED: DSH 0.1.7 stopped mirroring background jobs into the
|
||||
* client session snapshot, so the registry (fenced per OWNER session) is
|
||||
* read through the plugin's `jobs.list` route. A fresh page load never
|
||||
* triggers — the FIRST successful read only arms the baseline — and a host
|
||||
* without the jobs service (503) leaves the baseline unarmed, so the first
|
||||
* list that does arrive is never mistaken for new work.
|
||||
* The roster is PUSHED by the host's own client jobs service: one
|
||||
* reference-counted `watchRows` stream for the current session replaces the
|
||||
* 3s `jobs.list` poll this used to run. A fresh page load never triggers —
|
||||
* the FIRST frame only arms the baseline — and a deployment without the
|
||||
* jobs service leaves the baseline unarmed, so the first roster that does
|
||||
* arrive is never mistaken for new work.
|
||||
*
|
||||
* `detectNewJob` also compares against the moment the watch STARTED: the
|
||||
* first frame of a session that already had running jobs must not surface
|
||||
* them as new work.
|
||||
*/
|
||||
const jobsService = clientJobs(ctx)
|
||||
const jobsSnapshot = useJobsSnapshot(jobsService)
|
||||
const jobBaselineRef = useRef<readonly SidebarJobView[] | undefined>(undefined)
|
||||
// A session switch voids the previous session's baseline BEFORE the poller
|
||||
// below restarts on the new id (effects run in declaration order).
|
||||
useEffect(() => { jobBaselineRef.current = undefined }, [sessionId])
|
||||
const pollJobs = useCallback(async (signal: AbortSignal): Promise<void> => {
|
||||
if (sessionId === undefined) return
|
||||
let jobs: readonly SidebarJobView[]
|
||||
try {
|
||||
const result = await api.jobsList(sessionId, signal)
|
||||
jobs = result.jobs
|
||||
} catch {
|
||||
// No jobs service, or a dropped read: keep the last baseline and let the
|
||||
// next tick retry (never treat an unreadable list as an empty one).
|
||||
return
|
||||
const jobWatchStartRef = useRef<number | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (jobsService === undefined || sessionId === undefined) return
|
||||
const release = jobsService.watchRows(sessionId)
|
||||
// A session switch voids the previous session's baseline AND its clock.
|
||||
jobWatchStartRef.current = Date.now()
|
||||
jobBaselineRef.current = undefined
|
||||
return () => {
|
||||
release()
|
||||
jobBaselineRef.current = undefined
|
||||
jobWatchStartRef.current = undefined
|
||||
}
|
||||
if (signal.aborted) return
|
||||
}, [jobsService, sessionId])
|
||||
useEffect(() => {
|
||||
if (jobsService === undefined || sessionId === undefined) return
|
||||
const rows = jobsSnapshot.rows[sessionId] ?? []
|
||||
const prev = jobBaselineRef.current
|
||||
jobBaselineRef.current = jobs
|
||||
jobBaselineRef.current = rows
|
||||
if (prev === undefined) return
|
||||
if (!detectNewJob(prev, jobs)) return
|
||||
if (!detectNewJob(prev, rows, jobWatchStartRef.current)) return
|
||||
if (!store.getPrefs().autoOpenJobs) return
|
||||
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
||||
activateTasksPage(ctx, sessionId, { background: true })
|
||||
}, [sessionId, store, ctx])
|
||||
usePolling(sessionId !== undefined, pollJobs, {
|
||||
intervalMs: JOB_POLL_MS,
|
||||
mode: 'self-scheduling',
|
||||
immediate: true,
|
||||
})
|
||||
}, [jobsService, jobsSnapshot, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
|
||||
+19
-31
@@ -9,7 +9,6 @@
|
||||
* status mapping mirror the official ui-jobs header list.
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarJobStatus,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
@@ -35,42 +34,31 @@ export { treeSessionIds }
|
||||
|
||||
/**
|
||||
* Whether a NEW background job appeared for the current session between two
|
||||
* consecutive reads of its job list (a job id the previous list lacked).
|
||||
* Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the
|
||||
* agent may start several jobs over a session, and each new one should
|
||||
* surface the Tasks page containing the background-jobs section. A fresh page
|
||||
* load never triggers — the caller builds its baseline from the first list it
|
||||
* reads instead of comparing against one (see the poller in
|
||||
* sidebar/use-host-feeds.ts).
|
||||
* roster frames (a job id the previous frame lacked). Unlike the subagent
|
||||
* auto-open (0 → N only), ANY new job id triggers: the agent may start
|
||||
* several jobs over a session, and each new one should surface the Tasks page
|
||||
* that contains the background-jobs section.
|
||||
*
|
||||
* `since` (epoch ms) is the moment the caller STARTED watching the roster, and
|
||||
* it is what keeps a page load honest now that the roster arrives as a push
|
||||
* stream: the host's client jobs model drops a session's key when it sees no
|
||||
* jobs, so an empty first frame is indistinguishable from "not delivered yet".
|
||||
* A job that already existed when the watcher opened therefore looks brand new
|
||||
* to an id-set diff — comparing its start time against `since` filters it out,
|
||||
* while a job the agent starts afterwards still triggers.
|
||||
*
|
||||
* @param prev - the previous frame's rows.
|
||||
* @param next - the current frame's rows.
|
||||
* @param since - epoch ms the watch began; omitted skips the age test.
|
||||
* @returns whether genuinely new work appeared.
|
||||
*/
|
||||
export function detectNewJob(
|
||||
prev: readonly SidebarJobView[],
|
||||
next: readonly SidebarJobView[],
|
||||
since?: number,
|
||||
): boolean {
|
||||
const prevIds = new Set(prev.map(job => job.id))
|
||||
return next.some(job => !prevIds.has(job.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the background jobs of the whole current tree, owner-labeled. The
|
||||
* map is keyed by the OWNER session (the fence's unit of access): sessions the
|
||||
* caller could not read, or that returned nothing, contribute nothing — an
|
||||
* absent entry is an empty set, never a dropped row of another tree.
|
||||
*/
|
||||
export function collectTreeJobs(
|
||||
byId: SidebarSessionList['byId'],
|
||||
jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | undefined,
|
||||
rootId: string | undefined,
|
||||
): TreeJob[] {
|
||||
const rows: TreeJob[] = []
|
||||
if (jobsBySession === undefined) return rows
|
||||
for (const sessionId of treeSessionIds(byId, rootId)) {
|
||||
const jobs = jobsBySession[sessionId]
|
||||
if (jobs === undefined || jobs.length === 0) continue
|
||||
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
|
||||
for (const job of jobs) rows.push({ ownerSessionId: sessionId, ownerTitle, job })
|
||||
}
|
||||
return rows
|
||||
return next.some(job => !prevIds.has(job.id) && (since === undefined || job.startedAt >= since))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+54
-6
@@ -210,8 +210,16 @@ export interface SidebarJobView {
|
||||
kind: string
|
||||
/** Producer-supplied one-line label: the command, or the delegation description. */
|
||||
label: string
|
||||
/**
|
||||
* Owning session; absent for an unowned job, which every caller can see.
|
||||
* The client service's roster is keyed by WATCHED session, so this is what
|
||||
* tells a row whether it belongs to the tree on screen.
|
||||
*/
|
||||
owner?: string
|
||||
/** Current lifecycle state. */
|
||||
status: SidebarJobStatus
|
||||
/** The producer's live progress line ('3/10'), cleared at settlement. */
|
||||
progress?: string
|
||||
/** Kind-specific status detail ('exit code: 3'), present once supplied. */
|
||||
detail?: string
|
||||
/** Epoch ms when the job was registered. */
|
||||
@@ -230,11 +238,51 @@ export interface SidebarJobView {
|
||||
* reads `list` through the plugin's own `jobs.list` route (the client session
|
||||
* snapshot stopped mirroring background jobs in the same release).
|
||||
*/
|
||||
export interface SidebarJobsService {
|
||||
/** Caller-owned and unowned jobs in registration order (the Tasks page list). */
|
||||
list(caller?: string): SidebarJobView[]
|
||||
/** Request cancellation; throws for an unknown or foreign job. */
|
||||
kill(id: string, caller?: string, reason?: string): 'requested' | 'already-finished'
|
||||
/**
|
||||
* One job's retained output as the HOST's client service observes it
|
||||
* (mirror of `@deepseek-ai/dsh-api-job-controller/client`'s observed entry).
|
||||
* `gapBefore` marks output the observer missed between frames; `error` carries
|
||||
* a producer-side failure; `streaming` is true while the job still writes.
|
||||
*/
|
||||
export interface SidebarObservedJob {
|
||||
text: string
|
||||
gapBefore: boolean
|
||||
streaming: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** The host client jobs service's snapshot (roster by session + observations). */
|
||||
export interface SidebarJobsSnapshot {
|
||||
/** Whole-set roster per WATCHED session: empty arrays are how a session ends. */
|
||||
rows: Record<string, SidebarJobView[]>
|
||||
/** Retained output per observed job id. */
|
||||
observed: Record<string, SidebarObservedJob | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's CLIENT jobs service (`ctx.jobs`, mounted by the web profile's
|
||||
* `@deepseek-ai/dsh-api-job-controller/client`), structurally mirrored: the
|
||||
* plugin never imports the host package.
|
||||
*
|
||||
* It replaces the plugin's own three-route jobs transport: `watchRows` keeps
|
||||
* one session's roster current as a push stream, `observe` streams a job's
|
||||
* retained output WITHOUT moving the model's consuming cursor, and `kill` is
|
||||
* the same registry admission the old route forwarded.
|
||||
*/
|
||||
export interface SidebarClientJobsService {
|
||||
state: {
|
||||
getSnapshot(): SidebarJobsSnapshot
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
/** Watch one session's roster; returns the release function (ref-counted). */
|
||||
watchRows(sessionId: string): () => void
|
||||
/**
|
||||
* Observe one job's output; returns the release function. `sessionId` may be
|
||||
* undefined for a job whose owner the roster has not resolved yet.
|
||||
*/
|
||||
observe(sessionId: string | undefined, jobId: string): () => void
|
||||
/** Request cancellation of one job (owned by `sessionId`). */
|
||||
kill(sessionId: string, jobId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** The host agent registry face (structural mirror of the runtime `ctx.agents`). */
|
||||
@@ -660,7 +708,7 @@ export interface SidebarContextShape {
|
||||
/** The client module system (rc.8+ chunk-loader externals). */
|
||||
modules: { import(specifier: string): Promise<unknown> }
|
||||
/** The host background-job registry (optional; routes degrade to 503). */
|
||||
jobs: SidebarJobsService
|
||||
jobs: SidebarClientJobsService
|
||||
/** The host live-agent registry (optional; side chat thread agents). */
|
||||
agents: SidebarAgentsService
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,6 @@ import { launchExternal } from './open-external.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
||||
import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
|
||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
|
||||
import { buildTeamsApi, type SidebarTeamsRoutes } from './team-routes.ts'
|
||||
import { buildWorkflowsApi, type SidebarWorkflowRoutes } from './workflow-routes.ts'
|
||||
@@ -264,12 +263,6 @@ function buildApi(
|
||||
const requested = typeof record?.worktree === 'string' && record.worktree !== '' ? record.worktree : undefined
|
||||
return { sessionId: base.sessionId, cwd: await git.resolveWorktree(base.cwd, requested) }
|
||||
}
|
||||
// Background jobs: the LIST rides the harness's `session/jobs` push
|
||||
// mirror, so these routes only replay output the model has read (from the
|
||||
// session's own event log — no DSH source is touched, the model's
|
||||
// job_output cursor is never consumed) and kill (the registry's stock
|
||||
// API). A deployment without the jobs registry downgrades kill to a 503.
|
||||
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
|
||||
// Subagent live previews: one batch request instead of N per-child
|
||||
// `subagents.history` calls. The route degrades to a 503 when the host
|
||||
// subagent runtime is absent (the page has no topology to show anyway).
|
||||
@@ -484,16 +477,6 @@ function buildApi(
|
||||
const window = filtered.length > CHANGES_EVENTS_CAP ? filtered.slice(filtered.length - CHANGES_EVENTS_CAP) : filtered
|
||||
return { events: window, lastSeq: window.at(-1)?.seq ?? afterSeq }
|
||||
},
|
||||
// Background jobs: list the caller's own jobs, read one job's output (a
|
||||
// REPLAY of what the model has read so far, from the owner session's
|
||||
// event log — the model's job_output cursor is never touched, so the
|
||||
// human pane can never steal the agent's bytes), and kill one job. The
|
||||
// list route exists because DSH 0.1.7 deleted the session/jobs push
|
||||
// mirror the Tasks page used to read. Kill is fenced to the owning
|
||||
// session by the jobs registry.
|
||||
'jobs.list': (payload) => jobsApi.list(payload),
|
||||
'jobs.output': (payload) => jobsApi.output(payload),
|
||||
'jobs.kill': (payload) => jobsApi.kill(payload),
|
||||
// Subagent live previews: one batch request per refresh; the route folds
|
||||
// the newest text/tool activity of every running child in the tree.
|
||||
'subagents.live': (payload) => subagentLiveApi.live(payload),
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Background-job routes of the /sidebar JSON API ('jobs.output' /
|
||||
* 'jobs.kill'). The job LIST needs no route: it arrives through the
|
||||
* harness's `session/jobs` push mirror (`jobsBySession` in the sessions
|
||||
* list feed). The routes:
|
||||
*
|
||||
* - 'jobs.output' — REPLAYS the output the MODEL has read so far for one
|
||||
* job. The source is the owner session's own event log: `tool/call` rows
|
||||
* of `job_output` name the job via `arguments.job_id`, and the paired
|
||||
* `tool/result` rows carry the finalized content the model received.
|
||||
* Because the session store's in-memory log can lag the live append feed
|
||||
* after a host restart (the store session stays frozen at its
|
||||
* rehydration boundary), the plugin ALSO mirrors job_output events from
|
||||
* the live `session/event` feed and merges both sources (deduped by seq).
|
||||
* This touches NO DSH source: the model's `job_output` cursor is never
|
||||
* consumed, and the pane stays empty until the agent reads the job.
|
||||
* - 'jobs.kill' — the registry's stock `kill` (a pristine DSH API), fenced
|
||||
* by the owning session id (the 0.1.7 registry compares `SessionId`, the
|
||||
* 0.1.6 one compared a live Agent). Absent registry → 503, mirroring the
|
||||
* settings routes' optional-service downgrade.
|
||||
*/
|
||||
import type { Context, SidebarJobsService, SidebarJobView, SidebarSessionEvent } from './context-types.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The background-job routes of the sidebar API. */
|
||||
export interface SidebarJobsRoutes {
|
||||
/**
|
||||
* The caller-visible jobs of one session, read straight from the registry.
|
||||
*
|
||||
* DSH 0.1.7 stopped mirroring background jobs into the client session list
|
||||
* (`jobsBySession` is gone with no replacement), so the Tasks page reads
|
||||
* them here instead. This is also the more authoritative source: the mirror
|
||||
* was last-wins over push frames, while the registry is the state itself.
|
||||
*/
|
||||
list(payload: unknown): { jobs: SidebarJobView[] }
|
||||
/** The output the model has read so far for one job (event replay, capped). */
|
||||
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
|
||||
/** Request cancellation of one job (live jobs flip to stopping). */
|
||||
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
|
||||
}
|
||||
|
||||
/** The 'tool/result' message envelope inside a session event's data. */
|
||||
interface ToolResultMessageLike {
|
||||
source?: { kind?: unknown; callId?: unknown }
|
||||
content?: unknown
|
||||
/** The 0.1.7 first-class tool message lifts the error flag onto the message. */
|
||||
isError?: unknown
|
||||
}
|
||||
|
||||
/** One 'tool-result' content block (the inner blocks carry the text). */
|
||||
interface ToolResultBlockLike {
|
||||
type?: unknown
|
||||
content?: unknown
|
||||
isError?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The result blocks and error flag of one tool/result message, read under BOTH
|
||||
* logged shapes: 0.1.6 wrapped the result in a single `type: 'tool-result'`
|
||||
* content block on a user-role message (the text nested inside it, `isError`
|
||||
* on the wrapper), 0.1.7's first-class tool-role message carries the blocks at
|
||||
* the message's own top level with `isError` lifted onto the message.
|
||||
* Historical logs keep the old shape forever, so both are read. Undefined when
|
||||
* the message carries no block array.
|
||||
*/
|
||||
function resultOf(message: ToolResultMessageLike): { blocks: readonly unknown[]; isError: boolean } | undefined {
|
||||
if (!Array.isArray(message.content)) return undefined
|
||||
for (const block of message.content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const wrapper = block as ToolResultBlockLike
|
||||
if (wrapper.type !== 'tool-result') continue
|
||||
return {
|
||||
blocks: Array.isArray(wrapper.content) ? wrapper.content as readonly unknown[] : [],
|
||||
isError: wrapper.isError === true,
|
||||
}
|
||||
}
|
||||
return { blocks: message.content as readonly unknown[], isError: message.isError === true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the plain text of a finalized tool result: its text blocks, joined
|
||||
* with newlines. Error results and non-text blocks contribute nothing.
|
||||
*/
|
||||
function resultText(message: ToolResultMessageLike): string | undefined {
|
||||
const blocks = resultOf(message)?.blocks
|
||||
if (blocks === undefined) return undefined
|
||||
const parts: string[] = []
|
||||
for (const item of blocks) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const textItem = item as { type?: unknown; text?: unknown }
|
||||
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
||||
parts.push(textItem.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** Whether a tool/result is an error result (0.1.7's message-level flag, else
|
||||
* the 0.1.6 wrapper block's flag). */
|
||||
function resultIsError(message: ToolResultMessageLike): boolean {
|
||||
return resultOf(message)?.isError === true
|
||||
}
|
||||
|
||||
/** Whether a job_output result carries no new output — the controller's
|
||||
* model-facing "(no new output)" body, noise for the human pane. */
|
||||
function isNoNewOutput(text: string): boolean {
|
||||
return text.startsWith('(no new output)')
|
||||
}
|
||||
|
||||
/** One compact job_output trace (a tool/call or its paired tool/result). */
|
||||
interface JobOutputTrace {
|
||||
seq: number
|
||||
kind: 'call' | 'result'
|
||||
/** The tool call identity pairing the two rows. */
|
||||
callId: string
|
||||
/** tool/call: the job id parsed from the model arguments. */
|
||||
jobId?: string
|
||||
/** tool/result: the finalized text the model received. */
|
||||
text?: string
|
||||
/** tool/result: whether the result was an error (read counts, text skipped). */
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
/** Extract the job_output trace of one raw session event (undefined = unrelated). */
|
||||
function traceOf(event: SidebarSessionEvent): JobOutputTrace | undefined {
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
|
||||
if (data.name !== 'job_output' || typeof data.callId !== 'string') return undefined
|
||||
let jobId: string | undefined
|
||||
try {
|
||||
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { job_id?: unknown }
|
||||
if (typeof args.job_id === 'string') jobId = args.job_id
|
||||
} catch {
|
||||
// Malformed model arguments: not a job_output pair.
|
||||
}
|
||||
if (jobId === undefined) return undefined
|
||||
return { seq: event.seq, kind: 'call', callId: data.callId, jobId }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
|
||||
if (message === undefined) return undefined
|
||||
const callId = message.source?.callId
|
||||
if (typeof callId !== 'string') return undefined
|
||||
return {
|
||||
seq: event.seq,
|
||||
kind: 'result',
|
||||
callId,
|
||||
text: resultText(message),
|
||||
isError: resultIsError(message),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Per-session cap of mirrored live traces (a bounded, lossy ring). */
|
||||
const MIRROR_MAX_ENTRIES = 200
|
||||
|
||||
/**
|
||||
* The live job_output mirror: subscribes to the session append feed and
|
||||
* caches the job_output traces the session store's own log can lag behind
|
||||
* (after a host restart the store session stays frozen at its rehydration
|
||||
* boundary, so `session.events` misses everything appended since — the very
|
||||
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
|
||||
* same feed to browsers.
|
||||
*/
|
||||
function createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } {
|
||||
const perSession = new Map<string, JobOutputTrace[]>()
|
||||
// tool/call identities per session, so unrelated tool/result rows are
|
||||
// never cached (only job_output results pair with a cached call).
|
||||
const callIds = new Map<string, Set<string>>()
|
||||
if (typeof ctx.on !== 'function') {
|
||||
// Test doubles without the event API degrade to seed-only replay.
|
||||
return { entries: () => [] }
|
||||
}
|
||||
const dispose = ctx.on('session/event', (session, event) => {
|
||||
const sessionId = (session as { id?: unknown } | null)?.id
|
||||
if (typeof sessionId !== 'string') return
|
||||
if (event.type === 'tool/call') {
|
||||
const trace = traceOf(event)
|
||||
if (trace?.kind !== 'call') return
|
||||
let ids = callIds.get(sessionId)
|
||||
if (ids === undefined) callIds.set(sessionId, ids = new Set())
|
||||
ids.add(trace.callId)
|
||||
push(sessionId, trace)
|
||||
} else if (event.type === 'tool/result') {
|
||||
const trace = traceOf(event)
|
||||
if (trace?.kind !== 'result') return
|
||||
if (!callIds.get(sessionId)?.has(trace.callId)) return
|
||||
push(sessionId, trace)
|
||||
}
|
||||
})
|
||||
ctx.effect(() => dispose, 'dsh-better-sidebar: job-output event mirror')
|
||||
|
||||
const push = (sessionId: string, trace: JobOutputTrace): void => {
|
||||
let list = perSession.get(sessionId)
|
||||
if (list === undefined) perSession.set(sessionId, list = [])
|
||||
list.push(trace)
|
||||
if (list.length > MIRROR_MAX_ENTRIES) {
|
||||
const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES)
|
||||
const ids = callIds.get(sessionId)
|
||||
if (ids !== undefined) {
|
||||
for (const entry of removed) {
|
||||
if (entry.kind === 'call') ids.delete(entry.callId)
|
||||
}
|
||||
if (ids.size === 0) callIds.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { entries: (sessionId) => perSession.get(sessionId) ?? [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the jobs routes bound to the plugin context. `list` reads the
|
||||
* registry's own projection, `output` merges the owner session's event log
|
||||
* with the live job_output mirror, and `kill` cancels through the registry.
|
||||
* Every route that needs the registry degrades to a 503 when the deployment
|
||||
* lacks it.
|
||||
* @param ctx - host plugin context.
|
||||
* @param outputLimit - response cap for one output replay in bytes; longer
|
||||
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
|
||||
*/
|
||||
export function buildJobsApi(ctx: Context, outputLimit: number): SidebarJobsRoutes {
|
||||
const jobs = ctx.get('jobs')
|
||||
const mirror = createJobOutputMirror(ctx)
|
||||
/** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
|
||||
const registryError = (error: unknown): SidebarError =>
|
||||
new SidebarError('job-error', error instanceof Error ? error.message : String(error), 404)
|
||||
/** The registry, or the 503 every registry-backed route returns without it. */
|
||||
const requireJobs = (): SidebarJobsService => {
|
||||
if (jobs === undefined) {
|
||||
throw new SidebarError('job-error', 'the background-job registry is not mounted in this deployment', 503)
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
return {
|
||||
list(payload) {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
try {
|
||||
return { jobs: requireJobs().list(sessionId) }
|
||||
} catch (error) {
|
||||
if (error instanceof SidebarError) throw error
|
||||
throw registryError(error)
|
||||
}
|
||||
},
|
||||
output(payload) {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
// Merge the store's event log (durable seed + whatever it received)
|
||||
// with the live mirror, deduped by seq — a trace never double-counts.
|
||||
const bySeq = new Map<number, JobOutputTrace>()
|
||||
for (const event of ctx.sessions.get(sessionId)?.snapshotEvents() ?? []) {
|
||||
const trace = traceOf(event)
|
||||
if (trace !== undefined) bySeq.set(trace.seq, trace)
|
||||
}
|
||||
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace)
|
||||
// Pair calls with results in seq order: the model's reads, oldest first.
|
||||
const jobOf = new Map<string, string>()
|
||||
const parts: string[] = []
|
||||
let read = false
|
||||
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) {
|
||||
if (trace.kind === 'call') {
|
||||
if (trace.jobId !== undefined) jobOf.set(trace.callId, trace.jobId)
|
||||
} else if (jobOf.get(trace.callId) === id) {
|
||||
read = true
|
||||
if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) {
|
||||
parts.push(trace.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = parts.join('\n')
|
||||
return {
|
||||
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
|
||||
truncated: text.length > outputLimit,
|
||||
read,
|
||||
}
|
||||
},
|
||||
kill(payload) {
|
||||
if (jobs === undefined) {
|
||||
throw new SidebarError('job-error', 'the background-job registry is not mounted in this deployment', 503)
|
||||
}
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
const record = payload as { reason?: unknown } | null
|
||||
const reason = typeof record?.reason === 'string' && record.reason !== ''
|
||||
? record.reason
|
||||
: 'user requested via sidebar'
|
||||
try {
|
||||
// The 0.1.7 registry fences by SessionId, not by live Agent.
|
||||
return { ok: true, outcome: jobs.kill(id, sessionId, reason) }
|
||||
} catch (error) {
|
||||
throw registryError(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user