rootAncestor / countSubagentDescendants / treeSessionIds 三份「沿 subagent-origin 链上行 + seen 集合防环 fail-soft」实现收敛到新模块 src/client/subagent-lineage.ts:核心走链是单个私有生成器 subagentOriginChain()(seen 防环、只在节点为 subagent 且有 parent 时 前行),三个公开函数迁入并基于它重写;isSideThreadSummary 一并迁入 (countSubagentDescendants 依赖它,迁入可避免 detect↔lineage 循环 依赖),subagent-detect.ts / subagent-jobs.ts re-export 全部迁出 符号,既有 import 调用点零变化。 语义逐字保留的等价性论证: - rootAncestor:链为空返回 start.id(原循环条件不满足即返回 current?.id);链非空返回 byId[末节点.parentId]?.id ?? sessionId (原循环退出时 current 恰为该行,断链/行缺失回退 sessionId 不变)。 - countSubagentDescendants:生成器在 seen.add 后 yield、消费方判 parentId === sessionId 即 break——与原循环体内判断顺序一致; descendant 的 running 判定、side-thread 过滤位置不变。 - treeSessionIds:根行缺失(byId[rootId] 无行)时任何上行都不可能 「访问到」根(原实现只匹配真实访问过的行),故提前返回空集; 根行自身(非 subagent)经 summary.id === rootId 特判入集;其余 行经链节点 parentId === rootId 判定,等价于原「上行走到 id === rootId 的行」——两种表述指向同一行(链上节点的 next 行)。 directSubagentCount 不迁移:它是对直接子行的单层过滤,没有上行 walk,无共享逻辑(背景侦查将其归入 walk 家族与实际代码不符)。 collectBranchIds 遍历的是 catalog 结构而非 session 链,保持不动。
136 lines
5.1 KiB
TypeScript
136 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 arrive through the harness `session/jobs` push mirror
|
|
* (`jobsBySession` in the sessions list feed) — nothing here issues
|
|
* requests, and the row ordering / status mapping mirror the official
|
|
* ui-jobs header list.
|
|
*/
|
|
import type {
|
|
SidebarSessionList,
|
|
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 one session between two
|
|
* consecutive list snapshots (a job id the previous snapshot 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 — its baseline starts at the current snapshot).
|
|
*/
|
|
export function detectNewJob(
|
|
prev: SidebarSessionList,
|
|
next: SidebarSessionList,
|
|
sessionId: string,
|
|
): boolean {
|
|
const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.id))
|
|
return (next.jobsBySession?.[sessionId] ?? []).some(job => !prevIds.has(job.id))
|
|
}
|
|
|
|
/**
|
|
* Collect the background jobs of the whole current tree, owner-labeled.
|
|
* Sessions without a mirror entry contribute nothing; an absent mirror
|
|
* (runtime older than the jobs feed) yields an empty list.
|
|
*/
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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 })
|
|
}
|