- 新增 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)
128 lines
5.1 KiB
TypeScript
128 lines
5.1 KiB
TypeScript
/**
|
|
* Pure derivations for the Subagent page's background-job section. Kept
|
|
* framework-free so the node test environment can unit-test them: the job
|
|
* rows are collected from the per-session lists the client reads through the
|
|
* plugin's `jobs.list` route (DSH 0.1.7 dropped the harness `session/jobs`
|
|
* push mirror that used to feed `jobsBySession`, and the registry's access
|
|
* fence admits a job only to its OWNER session — so the caller fans one read
|
|
* out per tree session). Nothing here issues requests, and the row ordering /
|
|
* status mapping mirror the official ui-jobs header list.
|
|
*/
|
|
import type {
|
|
SidebarJobStatus,
|
|
SidebarJobView,
|
|
} from '../context-types.ts'
|
|
import type { CopyKey } from './locales.ts'
|
|
import { treeSessionIds } from './subagent-lineage.ts'
|
|
|
|
/** One row of the jobs section: the job plus its owning session's title. */
|
|
export interface TreeJob {
|
|
ownerSessionId: string
|
|
ownerTitle: string
|
|
job: SidebarJobView
|
|
}
|
|
|
|
/** Whether the registry still holds the job open (its duration ticks). */
|
|
export function isJobLive(job: SidebarJobView): boolean {
|
|
return job.status === 'running' || job.status === 'stopping'
|
|
}
|
|
|
|
// The lineage walk itself lives in ./subagent-lineage.ts (the single shared
|
|
// subagent-origin chain implementation); re-exported for the established
|
|
// import sites of this module.
|
|
export { treeSessionIds }
|
|
|
|
/**
|
|
* Whether a NEW background job appeared for the current session between two
|
|
* 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) && (since === undefined || job.startedAt >= since))
|
|
}
|
|
|
|
/**
|
|
* Live rows first in start order, then settled rows newest-first (mirror of
|
|
* the official ui-jobs ordering); a tie falls back to start order so the
|
|
* sort never depends on the host's map iteration.
|
|
*/
|
|
export function orderJobs(rows: readonly TreeJob[]): TreeJob[] {
|
|
return [...rows].sort((left, right) => {
|
|
const liveLeft = isJobLive(left.job)
|
|
if (liveLeft !== isJobLive(right.job)) return liveLeft ? -1 : 1
|
|
if (liveLeft) return left.job.startedAt - right.job.startedAt
|
|
const finished = (right.job.finishedAt ?? right.job.startedAt) - (left.job.finishedAt ?? left.job.startedAt)
|
|
return finished !== 0 ? finished : left.job.startedAt - right.job.startedAt
|
|
})
|
|
}
|
|
|
|
/** The sidebar's StateDot states for the five wire statuses. */
|
|
export type JobDotState = 'ongoing' | 'warning' | 'done' | 'error'
|
|
|
|
/**
|
|
* Status marker semantics. `stopping` and `killed` share the attention
|
|
* color: both mean the work ended (or is ending) on request rather than on
|
|
* its own.
|
|
*/
|
|
export function jobDotState(status: SidebarJobStatus): JobDotState {
|
|
switch (status) {
|
|
case 'running': return 'ongoing'
|
|
case 'stopping': return 'warning'
|
|
case 'completed': return 'done'
|
|
case 'killed': return 'warning'
|
|
case 'failed': return 'error'
|
|
}
|
|
}
|
|
|
|
/** Human status word of one wire status (localized through the passed translator). */
|
|
export function jobStatusLabel(
|
|
status: SidebarJobStatus,
|
|
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
|
): string {
|
|
switch (status) {
|
|
case 'running': return t('jobStatusRunning')
|
|
case 'stopping': return t('jobStatusStopping')
|
|
case 'completed': return t('jobStatusCompleted')
|
|
case 'killed': return t('jobStatusKilled')
|
|
case 'failed': return t('jobStatusFailed')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Elapsed time in at most two adjacent units (mirror of the official
|
|
* ui-jobs duration wording). A background job that outlives an hour is
|
|
* already exceptional, so hours is the widest unit.
|
|
*/
|
|
export function formatJobDuration(
|
|
elapsedMs: number,
|
|
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
|
): string {
|
|
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
|
|
const seconds = total % 60
|
|
const minutes = Math.floor(total / 60) % 60
|
|
const hours = Math.floor(total / 3_600)
|
|
if (hours > 0) return t('jobDurationHours', { hours, minutes })
|
|
if (minutes > 0) return t('jobDurationMinutes', { minutes, seconds })
|
|
return t('jobDurationSeconds', { seconds })
|
|
}
|