Files
apes-sidebar/src/client/subagent-jobs.ts
T
Menghuan1918 48294d336e 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)
2026-09-27 22:18:35 +08:00

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 })
}