feat(host): add workflows.list and teams.* sidebar routes

- workflows.list folds tool-workflow/* session events (the same four types
  the official workflow-run panel folds) across the whole session tree,
  merging the stored log with a live session/event mirror deduped by seq;
  absence of events yields an empty list, never an error.
- teams.view / teams.taskCreate / teams.taskUpdate ride the experimental
  agentTeams service's Remote vocabulary with structural degradation:
  layer absent -> {available:false}; root leads no team -> {team:null};
  CAS conflicts pass through the service's own result union.
- wire gains the team-error code; context-types gains structural mirrors
  of the team view/request vocabulary (no experimental package import).
- 29 unit tests across fold, route merging/degradation, and CAS shaping.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:14 +08:00
parent ad6b0b1486
commit c445495f8b
9 changed files with 1016 additions and 0 deletions
+71
View File
@@ -275,6 +275,77 @@ export interface SidebarAgentPresetsService {
mount(agentCtx: unknown, presetId: string): Promise<void>
}
/**
* The experimental Agent Teams service face (`ctx.agentTeams`, mounted only
* when the deployment loads `dsh-experimental-agent-team-profile`; absent →
* `ctx.get` returns undefined and the Teams block hides). Only the
* browser-facing Remote vocabulary the Tasks page needs is mirrored —
* structurally, so the plugin never imports the experimental package.
*/
export interface SidebarAgentTeamsService {
/** The agent's team membership, or undefined for a non-team/stale agent. */
tryMembership(agent: unknown): unknown
/** The point-in-time team snapshot the official panel renders. */
remoteView(agent: unknown): Promise<{ members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] }>
/** Create one shared task (CAS-free; ids are server-issued). */
remoteCreateTask(agent: unknown, req: SidebarCreateTeamTaskRequest): Promise<SidebarTeamTaskMutationResult>
/** Compare-and-set mutation of one shared task (stale revision → conflict). */
remoteUpdateTask(agent: unknown, req: SidebarUpdateTeamTaskRequest): Promise<SidebarTeamTaskMutationResult>
}
/** One team member as the runtime-enriched view reports it. */
export interface SidebarTeamMemberView {
/** The member's session id (the teammate's child session under the lead). */
id: string
name: string
role: 'lead' | 'teammate'
status: 'running' | 'idle' | 'inactive' | 'provisioning' | 'failed'
description?: string
provider?: string
context?: 'fresh' | 'fork'
model?: string
diagnostics: string[]
}
/** One shared task-board row (durable fields plus derived readiness). */
export interface SidebarTeamTaskView {
id: string
revision: number
subject: string
description: string
status: 'pending' | 'in_progress' | 'completed' | 'deleted'
ownerName?: string
blockedBy: string[]
writeScopes: string[]
ready: boolean
writeScopeWarnings: string[]
}
/** Input for creating one shared task. */
export interface SidebarCreateTeamTaskRequest {
subject: string
description: string
blockedBy?: readonly string[]
writeScopes?: readonly string[]
}
/** Input for one CAS task mutation. */
export interface SidebarUpdateTeamTaskRequest {
taskId: string
expectedRevision: number
action: 'claim' | 'release' | 'edit' | 'set_dependencies' | 'complete' | 'reopen' | 'reassign' | 'delete'
subject?: string
description?: string
blockedBy?: readonly string[]
writeScopes?: readonly string[]
owner?: string
}
/** Browser task-mutation result (stale revisions kept distinct, passthrough). */
export type SidebarTeamTaskMutationResult =
| { ok: true; value: SidebarTeamTaskView }
| { ok: false; error: { code: 'team-task-conflict' | 'team-rejected'; message: string } }
/** The host session-title service face (mirror of the sessionTitle service). */
export interface SidebarSessionTitleService {
/** Rename one live session's title (pins it against auto-regeneration). */
+21
View File
@@ -43,6 +43,8 @@ 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'
import { buildSidechatApi } from './sidechat-routes.ts'
import { createAssistantLiveBuffer, type AssistantLiveBuffer } from './assistant-live.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
@@ -272,6 +274,17 @@ function buildApi(
// `subagents.history` calls. The route degrades to a 503 when the host
// subagent runtime is absent (the page has no topology to show anyway).
const subagentLiveApi: SidebarSubagentLiveRoutes = buildSubagentLiveApi(ctx)
// Workflow runs: no service registry exists in DSH (runs are
// holder-owned), so the route folds the `tool-workflow/*` session events
// the tool's recorder appends — the same four types the official panel
// folds in the browser. A deployment that never runs workflows simply
// returns an empty list (absence is normal, never an error).
const workflowsApi: SidebarWorkflowRoutes = buildWorkflowsApi(ctx)
// Agent Teams (experimental layer): the routes degrade structurally —
// service absent → `{available:false}` and the client hides the block;
// the tree root leading no team → `{team:null}`. Mutations ride the
// service's own CAS result union (conflicts stay distinct).
const teamsApi: SidebarTeamsRoutes = buildTeamsApi(ctx)
return {
'session.cwd': async (payload) => {
const { sessionId, cwd } = await cwdOf(payload)
@@ -484,6 +497,14 @@ function buildApi(
// 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),
// Workflow runs of the whole tree (folded from `tool-workflow/*`
// session events; empty list when the tree never ran one).
'workflows.list': (payload) => workflowsApi.list(payload),
// Agent Teams (experimental): the root-led team's roster + task board,
// and the task board's create/CAS-update mutations.
'teams.view': (payload) => teamsApi.view(payload),
'teams.taskCreate': (payload) => teamsApi.taskCreate(payload),
'teams.taskUpdate': (payload) => teamsApi.taskUpdate(payload),
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
+167
View File
@@ -0,0 +1,167 @@
/**
* The Agent Teams routes of the /sidebar JSON API ('teams.view' /
* 'teams.taskCreate' / 'teams.taskUpdate'). The experimental
* `dsh-experimental-agent-team` service (`ctx.agentTeams`) exists only when
* the deployment opts in, so the routes degrade structurally:
*
* - service absent (`ctx.get('agentTeams')` undefined) → `{available:false}`
* — the client hides the whole Teams block silently (absence is a
* deployment fact, never an error banner);
* - the tree's root agent is not a team member (`tryMembership` miss) →
* `{available:true, team:null}` — the page shows the plain agent graph;
* - hit → the service's own Remote vocabulary (`remoteView` /
* `remoteCreateTask` / `remoteUpdateTask`), whose mutation result union
* already keeps CAS conflicts (`team-task-conflict`) distinct from other
* rejections — the route passes it through untouched so the client's
* edit popover can render "someone else changed this task, refreshing".
*
* Teams are implicit (TeamId ≡ the lead's session id) and every service
* method demands the live member Agent as its authority token, so the
* routes re-derive the caller per request (`ctx.agents.get(rootSessionId)`)
* exactly like the jobs.kill fence. Zero DSH source changes; the plugin
* never imports the experimental package (structural mirrors only).
*/
import type {
Context,
SidebarAgentTeamsService,
SidebarAgentsService,
SidebarCreateTeamTaskRequest,
SidebarTeamMemberView,
SidebarTeamTaskMutationResult,
SidebarTeamTaskView,
SidebarUpdateTeamTaskRequest,
} from './context-types.ts'
import { requireString, SidebarError } from './wire.ts'
/** The Agent Teams routes of the sidebar API. */
export interface SidebarTeamsRoutes {
/**
* The team led by the tree's root agent, if any.
* @param payload - `{ rootSessionId }`.
* @returns `{ available: false }` when the deployment lacks the
* experimental layer; `{ available: true, team: null }` when the root
* leads no team; otherwise the members + task board.
*/
view(payload: unknown): Promise<
| { available: false }
| { available: true; team: { members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] } | null }
>
/** Create one shared task (payload = `{ rootSessionId, ...request }`). */
taskCreate(payload: unknown): Promise<SidebarTeamTaskMutationResult>
/** CAS-mutate one shared task (payload = `{ rootSessionId, ...request }`). */
taskUpdate(payload: unknown): Promise<SidebarTeamTaskMutationResult>
}
/** The CAS actions the wire accepts (mirrors the domain union). */
const TASK_ACTIONS = new Set([
'claim', 'release', 'edit', 'set_dependencies', 'complete', 'reopen', 'reassign', 'delete',
])
/** Narrow an unknown payload value to a string array (else undefined). */
function stringArrayOf(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined
const out = value.filter((item): item is string => typeof item === 'string')
return out.length === value.length ? out : undefined
}
/**
* Build the Agent Teams routes bound to the plugin context.
* @param ctx - host plugin context.
*/
export function buildTeamsApi(ctx: Context): SidebarTeamsRoutes {
const teams = ctx.get('agentTeams') as SidebarAgentTeamsService | undefined
const agents = ctx.get('agents') as SidebarAgentsService | undefined
/**
* The live lead agent of the requested tree plus its membership check.
* Throws 503 team-error when the experimental layer is absent (a mutation
* can never be accepted there); returns undefined when the root simply
* leads no team (a normal page state).
*/
const membershipOf = async (
payload: unknown,
): Promise<{ svc: SidebarAgentTeamsService; agent: unknown } | undefined> => {
if (teams === undefined) {
throw new SidebarError('team-error', 'the agent-teams layer is not mounted in this deployment', 503)
}
const rootSessionId = requireString(payload, 'rootSessionId')
const agent = agents?.get(rootSessionId)
if (agent === undefined) {
// The root is not live in this process (cold session / other harness):
// teams are live-led by definition, so there is nothing to show.
return undefined
}
try {
if (await teams.tryMembership(agent) === undefined) return undefined
} catch {
return undefined
}
return { svc: teams, agent }
}
/** Service rejections become a 400 team-error; CAS conflicts ride the union. */
const serviceError = (error: unknown): SidebarError =>
new SidebarError('team-error', error instanceof Error ? error.message : String(error), 400)
return {
async view(payload) {
if (teams === undefined) return { available: false }
const membership = await membershipOf(payload)
if (membership === undefined) return { available: true, team: null }
try {
const team = await membership.svc.remoteView(membership.agent)
return { available: true, team }
} catch (error) {
throw serviceError(error)
}
},
async taskCreate(payload) {
const membership = await membershipOf(payload)
if (membership === undefined) {
throw new SidebarError('team-error', 'the tree root leads no team', 404)
}
const record = payload as Record<string, unknown>
const req: SidebarCreateTeamTaskRequest = {
subject: requireString(payload, 'subject'),
description: typeof record.description === 'string' ? record.description : '',
...(stringArrayOf(record.blockedBy) !== undefined ? { blockedBy: stringArrayOf(record.blockedBy) } : {}),
...(stringArrayOf(record.writeScopes) !== undefined ? { writeScopes: stringArrayOf(record.writeScopes) } : {}),
}
try {
return await membership.svc.remoteCreateTask(membership.agent, req)
} catch (error) {
throw serviceError(error)
}
},
async taskUpdate(payload) {
const membership = await membershipOf(payload)
if (membership === undefined) {
throw new SidebarError('team-error', 'the tree root leads no team', 404)
}
const record = payload as Record<string, unknown>
const expectedRevision = record.expectedRevision
if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
throw new SidebarError('bad-request', 'missing or invalid "expectedRevision"')
}
const action = record.action
if (typeof action !== 'string' || !TASK_ACTIONS.has(action)) {
throw new SidebarError('bad-request', 'missing or invalid "action"')
}
const req: SidebarUpdateTeamTaskRequest = {
taskId: requireString(payload, 'taskId'),
expectedRevision,
action: action as SidebarUpdateTeamTaskRequest['action'],
...(typeof record.subject === 'string' ? { subject: record.subject } : {}),
...(typeof record.description === 'string' ? { description: record.description } : {}),
...(stringArrayOf(record.blockedBy) !== undefined ? { blockedBy: stringArrayOf(record.blockedBy) } : {}),
...(stringArrayOf(record.writeScopes) !== undefined ? { writeScopes: stringArrayOf(record.writeScopes) } : {}),
...(typeof record.owner === 'string' ? { owner: record.owner } : {}),
}
try {
return await membership.svc.remoteUpdateTask(membership.agent, req)
} catch (error) {
throw serviceError(error)
}
},
}
}
+1
View File
@@ -16,6 +16,7 @@ export type SidebarErrorCode =
| 'fs-error'
| 'git-error'
| 'job-error'
| 'team-error'
| 'sidechat-error'
| 'subagents-unavailable'
| 'settings-rejected'
+121
View File
@@ -0,0 +1,121 @@
/**
* The workflow-runs route of the /sidebar JSON API ('workflows.list'). DSH
* keeps workflow runs holder-owned and process-local — there is NO service
* registry, NO HTTP route, and NO persistence for them (`dsh-workflow`
* README: "Runs are holder-owned, not service-tracked"). The one durable
* observation surface is the session event log: `dsh-tool-workflow`'s
* recorder appends four `tool-workflow/*` event types to the calling agent's
* session (top-level runs only), which is exactly what the official
* `dsh-client-ui-workflow-run` panel folds in the browser.
*
* This route folds the SAME events server-side for every session of the
* current tree (the root plus its subagent descendants), so the Tasks page
* can hang run nodes under the agent that started them. Like the job-output
* replay it merges the store's event log with a live `session/event` mirror
* (deduped by seq), because the store session can lag the live append feed
* after a host restart. Zero DSH writes; the runs of a deployment that never
* uses the workflow tool simply never produce events and the route returns
* an empty list (no error — absence is the normal case, not a failure).
*/
import type { Context, SidebarSessionEvent, SidebarSubagentsService } from './context-types.ts'
import { foldWorkflowRuns, type WorkflowRunView } from './workflow-runs.ts'
import { requireString } from './wire.ts'
/** The workflow-runs route of the sidebar API. */
export interface SidebarWorkflowRoutes {
/**
* Fold the workflow runs of every session in one tree.
* @param payload - `{ rootSessionId }`.
* @returns `{ runs: WorkflowRunView[] }`, oldest run first; empty when the
* tree never ran a workflow (or the DSH version predates the recorder).
*/
list(payload: unknown): Promise<{ runs: WorkflowRunView[] }>
}
/** Per-session cap of mirrored live workflow events (a bounded, lossy ring). */
const MIRROR_MAX_ENTRIES = 500
/**
* The live workflow-event mirror: subscribes to the session append feed and
* caches `tool-workflow/*` rows the session store's own log can lag behind
* (the same rehydration-boundary gap the job-output mirror exists for).
*/
function createWorkflowEventMirror(ctx: Context): { entries(sessionId: string): readonly SidebarSessionEvent[] } {
const perSession = new Map<string, SidebarSessionEvent[]>()
if (typeof ctx.on !== 'function') {
// Test doubles without the event API degrade to seed-only folding.
return { entries: () => [] }
}
const dispose = ctx.on('session/event', (session, event) => {
const sessionId = (session as { id?: unknown } | null)?.id
if (typeof sessionId !== 'string' || !event.type.startsWith('tool-workflow/')) return
let list = perSession.get(sessionId)
if (list === undefined) perSession.set(sessionId, list = [])
// The vendored cordis `on` overload wins over the plugin's string-keyed
// one and types the event as the rich SessionEvent union; the mirror
// only ever needs the minimal structural slice, so restate it here.
const minimal: SidebarSessionEvent = {
type: event.type,
seq: event.seq,
time: event.time,
data: event.data as Record<string, unknown>,
}
list.push(minimal)
if (list.length > MIRROR_MAX_ENTRIES) {
list.splice(0, list.length - MIRROR_MAX_ENTRIES)
}
})
ctx.effect(() => dispose, 'dsh-better-sidebar: workflow event mirror')
return { entries: (sessionId) => perSession.get(sessionId) ?? [] }
}
/**
* Build the workflow-runs route bound to the plugin context. The tree walk
* reuses the host subagent runtime (`ctx.get('subagents')`); without it the
* route still folds the root session alone (a degraded but honest tree of
* one), matching the Subagent page's own fallback lineage.
* @param ctx - host plugin context.
*/
export function buildWorkflowsApi(ctx: Context): SidebarWorkflowRoutes {
const mirror = createWorkflowEventMirror(ctx)
/** The tree's session ids: the root plus every descendant the catalog knows. */
const treeSessionIds = async (rootSessionId: string): Promise<string[]> => {
const subagents = ctx.get('subagents') as SidebarSubagentsService | undefined
if (subagents === undefined || typeof subagents.listDescendants !== 'function') {
return [rootSessionId]
}
try {
const descendants = await subagents.listDescendants(rootSessionId)
return [rootSessionId, ...descendants.map(entry => entry.id)]
} catch {
// The catalog read failed (stale root, deployment seam missing): the
// root session alone still yields its own runs.
return [rootSessionId]
}
}
return {
async list(payload) {
const rootSessionId = requireString(payload, 'rootSessionId')
const runs: WorkflowRunView[] = []
for (const sessionId of await treeSessionIds(rootSessionId)) {
try {
// Merge the store's event log with the live mirror, deduped by
// seq — an event mirrored live AND already stored never
// double-folds.
const bySeq = new Map<number, SidebarSessionEvent>()
for (const event of ctx.sessions.get(sessionId)?.snapshotEvents() ?? []) {
if (event.type.startsWith('tool-workflow/')) bySeq.set(event.seq, event)
}
for (const event of mirror.entries(sessionId)) bySeq.set(event.seq, event)
if (bySeq.size === 0) continue
const ordered = [...bySeq.values()].sort((left, right) => left.seq - right.seq)
runs.push(...foldWorkflowRuns(ordered, sessionId))
} catch {
// One session's log is not readable/foldable: skip only it.
}
}
runs.sort((left, right) => left.startedSeq - right.startedSeq)
return { runs }
},
}
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Pure derivation of the workflow-run views shown on the Tasks page: fold one
* session's event log into the `tool-workflow/*` runs that originated there.
*
* The recorder inside DSH's `dsh-tool-workflow` appends exactly four event
* types to the CALLING agent's session (top-level runs only — nested runs
* inside workflow agents are never recorded):
*
* - `tool-workflow/run-start` `{ runId, name }`
* - `tool-workflow/agent-start` `{ runId, seq, label, phase?, childId }`
* - `tool-workflow/agent-end` `{ runId, seq, outcome }`
* - `tool-workflow/run-end` `{ runId, stopReason }`
*
* (Evidence: `dsh-tool-workflow/lib/index.js:37-88`; the official
* `dsh-client-ui-workflow-run` panel folds the identical four types in the
* browser.) This module mirrors that fold framework-free so it is
* unit-testable in the node environment; the host route merges the durable
* log with the live mirror and the client receives ready-made views.
*/
import type { SidebarSessionEvent } from './context-types.ts'
/** Outcome of one workflow member agent (the domain's closed union). */
export type WorkflowAgentOutcome = 'completed' | 'failed' | 'cancelled'
/** Settled reason of one run (`error` maps to the `failed` display status). */
export type WorkflowStopReason = 'completed' | 'cancelled' | 'error'
/** One member agent of a workflow run (`agent-start` + optional `agent-end`). */
export interface WorkflowRunMemberView {
/** Worker-side `agent()` call sequence number (pairs start/end). */
seq: number
/** Model-supplied member label. */
label: string
/** Phase grouping title supplied at start (absent = unphased). */
phase?: string
/** The member's child session id — the jump target of the row. */
childId: string
/** Set once `agent-end` lands; absent while the member runs. */
outcome?: WorkflowAgentOutcome
}
/** Members of one phase, in first-seen order (title undefined = unphased). */
export interface WorkflowRunPhaseView {
title?: string
members: WorkflowRunMemberView[]
}
/** Display status of one run (the official panel's vocabulary). */
export type WorkflowRunStatus = 'running' | 'completed' | 'cancelled' | 'failed'
/** One folded workflow run as the Tasks page renders it. */
export interface WorkflowRunView {
runId: string
name: string
/** The session the run was started from (the events' home session). */
originSessionId: string
status: WorkflowRunStatus
/** Settled reason once `run-end` landed (`error` ⇔ status `failed`). */
stopReason?: WorkflowStopReason
/** Phase-grouped members, phases in first-seen order. */
phases: WorkflowRunPhaseView[]
/** Seq of the run-start event (stable ordering across runs). */
startedSeq: number
/** Epoch ms of the run-start event (durations and sorting). */
startedAt: number
/** Epoch ms of the run-end event; absent while running. */
finishedAt?: number
}
/** The four recorded event types (prefix match keeps the scan cheap). */
const EVENT_PREFIX = 'tool-workflow/'
/** The mutable fold state of one run (member rows indexed by seq). */
interface RunFold {
view: WorkflowRunView
memberBySeq: Map<number, WorkflowRunMemberView>
}
/**
* Fold one session's event log into its workflow runs, oldest first. Rows
* referencing an unknown run (a log window that starts mid-run, or a corrupt
* tail) are skipped — the official panel asserts a leading run-start instead,
* but the route merges two sources where ordering is guaranteed anyway.
* @param events - the session's append-only event log (oldest → newest).
* @param originSessionId - the session the log belongs to.
* @returns the session's runs in run-start order.
*/
export function foldWorkflowRuns(
events: readonly SidebarSessionEvent[],
originSessionId: string,
): WorkflowRunView[] {
const runs: RunFold[] = []
const byRunId = new Map<string, RunFold>()
for (const event of events) {
if (!event.type.startsWith(EVENT_PREFIX)) continue
const data = event.data
switch (event.type) {
case 'tool-workflow/run-start': {
const runId = typeof data.runId === 'string' ? data.runId : undefined
if (runId === undefined || byRunId.has(runId)) break
const fold: RunFold = {
view: {
runId,
name: typeof data.name === 'string' && data.name !== '' ? data.name : runId,
originSessionId,
status: 'running',
phases: [],
startedSeq: event.seq,
startedAt: event.time,
},
memberBySeq: new Map(),
}
byRunId.set(runId, fold)
runs.push(fold)
break
}
case 'tool-workflow/agent-start': {
const fold = runOf(data, byRunId)
const seq = seqOf(data)
if (fold === undefined || seq === undefined || fold.memberBySeq.has(seq)) break
const member: WorkflowRunMemberView = {
seq,
label: typeof data.label === 'string' && data.label !== '' ? data.label : `#${seq}`,
...(typeof data.phase === 'string' ? { phase: data.phase } : {}),
childId: typeof data.childId === 'string' ? data.childId : '',
}
fold.memberBySeq.set(seq, member)
// Phase groups in first-seen order; unphased members share one group.
let phase = fold.view.phases.find(candidate => candidate.title === member.phase)
if (phase === undefined) {
phase = member.phase === undefined ? { members: [] } : { title: member.phase, members: [] }
fold.view.phases.push(phase)
}
phase.members.push(member)
break
}
case 'tool-workflow/agent-end': {
const fold = runOf(data, byRunId)
const seq = seqOf(data)
const member = fold?.memberBySeq.get(seq ?? -1)
if (member === undefined) break
const outcome = data.outcome
if (outcome === 'completed' || outcome === 'failed' || outcome === 'cancelled') {
member.outcome = outcome
}
break
}
case 'tool-workflow/run-end': {
const fold = runOf(data, byRunId)
if (fold === undefined) break
const stopReason = data.stopReason
if (stopReason === 'completed' || stopReason === 'cancelled' || stopReason === 'error') {
fold.view.stopReason = stopReason
fold.view.status = stopReason === 'error' ? 'failed' : stopReason
} else {
fold.view.status = 'failed'
}
fold.view.finishedAt = event.time
break
}
default:
break
}
}
return runs.map(fold => fold.view)
}
/** The run a workflow event belongs to (undefined = unknown/orphan row). */
function runOf(
data: Record<string, unknown>,
byRunId: ReadonlyMap<string, RunFold>,
): RunFold | undefined {
const runId = data.runId
return typeof runId === 'string' ? byRunId.get(runId) : undefined
}
/** The member sequence number of one agent-start/agent-end row. */
function seqOf(data: Record<string, unknown>): number | undefined {
const seq = data.seq
return typeof seq === 'number' && Number.isSafeInteger(seq) && seq >= 0 ? seq : undefined
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Host route tests for the Agent Teams API ('teams.view' / 'teams.taskCreate'
* / 'teams.taskUpdate'): structural degradation (layer absent → available:
* false; root leads no team → team: null), passthrough of the service's own
* Remote vocabulary, CAS conflict unions returned untouched, and payload
* validation. The plugin never imports the experimental package — the fake
* service here is a structural double.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildTeamsApi } from '../src/team-routes.ts'
import type {
Context,
SidebarTeamMemberView,
SidebarTeamTaskView,
} from '../src/context-types.ts'
/** One member row. */
function member(over: Partial<SidebarTeamMemberView> = {}): SidebarTeamMemberView {
return {
id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [], ...over,
}
}
/** One task row. */
function task(over: Partial<SidebarTeamTaskView> = {}): SidebarTeamTaskView {
return {
id: 'task-1', revision: 1, subject: 's', description: 'd', status: 'pending',
blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], ...over,
}
}
/** A context serving the given agentTeams/agents faces. */
function ctxWith(teams: unknown, agents: unknown): Context {
return {
get: (key: string) => (key === 'agentTeams' ? teams : key === 'agents' ? agents : undefined),
} as unknown as Context
}
/** An agents face resolving one live root agent. */
function agentsWithRoot(): { get(id: string): { id: string } | undefined } {
return { get: (id: string) => (id === 'root' ? { id: 'root' } : undefined) }
}
describe('teams.view route', () => {
it('reports available:false when the experimental layer is absent', async () => {
const api = buildTeamsApi(ctxWith(undefined, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: false })
})
it('reports team:null when the root agent is not live', async () => {
const teams = { tryMembership: vi.fn(), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, { get: () => undefined }))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
expect(teams.remoteView).not.toHaveBeenCalled()
})
it('reports team:null when tryMembership misses', async () => {
const teams = { tryMembership: vi.fn(async () => undefined), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
expect(teams.remoteView).not.toHaveBeenCalled()
})
it('reports team:null when tryMembership throws (stale member)', async () => {
const teams = { tryMembership: vi.fn(async () => { throw new Error('stale') }), remoteView: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: null })
})
it('returns the service view on a membership hit', async () => {
const view = { members: [member()], tasks: [task()] }
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteView: vi.fn(async () => view),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).resolves.toEqual({ available: true, team: view })
})
it('maps a remoteView rejection to a 400 team-error', async () => {
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteView: vi.fn(async () => { throw new Error('replay failed') }),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.view({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 400 }),
)
})
})
describe('teams.taskCreate route', () => {
it('passes the shaped request through and returns the union', async () => {
const created = task({ id: 'task-9', subject: '新任务' })
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteCreateTask: vi.fn(async () => ({ ok: true, value: created })),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
const result = await api.taskCreate({
rootSessionId: 'root', subject: '新任务', description: '详', blockedBy: ['task-1'],
})
expect(result).toEqual({ ok: true, value: created })
expect(teams.remoteCreateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ subject: '新任务', description: '详', blockedBy: ['task-1'] },
)
})
it('degrades to 503 on a mutation when the layer is absent', async () => {
const api = buildTeamsApi(ctxWith(undefined, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 503 }),
)
})
it('degrades to 404 when the root leads no team', async () => {
const teams = { tryMembership: vi.fn(async () => undefined) }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root', subject: 's' })).rejects.toThrowError(
expect.objectContaining({ code: 'team-error', status: 404 }),
)
})
it('rejects a missing subject as bad-request', async () => {
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })) }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskCreate({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining({ code: 'bad-request' }),
)
})
})
describe('teams.taskUpdate route', () => {
it('passes the CAS request through, conflict union untouched', async () => {
const conflict = { ok: false, error: { code: 'team-task-conflict', message: 'stale revision' } }
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteUpdateTask: vi.fn(async () => conflict),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
const result = await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 3, action: 'complete',
})
expect(result).toEqual(conflict)
expect(teams.remoteUpdateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 3, action: 'complete' },
)
})
it('validates taskId / expectedRevision / action', async () => {
const teams = { tryMembership: vi.fn(async () => ({ role: 'lead' })), remoteUpdateTask: vi.fn() }
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await expect(api.taskUpdate({ rootSessionId: 'root', expectedRevision: 1, action: 'complete' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
await expect(api.taskUpdate({ rootSessionId: 'root', taskId: 't', action: 'complete' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
await expect(api.taskUpdate({ rootSessionId: 'root', taskId: 't', expectedRevision: -1, action: 'complete' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
await expect(api.taskUpdate({ rootSessionId: 'root', taskId: 't', expectedRevision: 1, action: 'nuke' }))
.rejects.toThrowError(expect.objectContaining({ code: 'bad-request' }))
expect(teams.remoteUpdateTask).not.toHaveBeenCalled()
})
it('forwards optional edit fields when present', async () => {
const teams = {
tryMembership: vi.fn(async () => ({ role: 'lead' })),
remoteUpdateTask: vi.fn(async () => ({ ok: true, value: task() })),
}
const api = buildTeamsApi(ctxWith(teams, agentsWithRoot()))
await api.taskUpdate({
rootSessionId: 'root', taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer',
})
expect(teams.remoteUpdateTask).toHaveBeenCalledWith(
{ id: 'root' },
{ taskId: 'task-1', expectedRevision: 2, action: 'reassign', owner: 'writer' },
)
})
})
+141
View File
@@ -0,0 +1,141 @@
/**
* Host route tests for the workflow-runs API ('workflows.list'): the route
* must enumerate the tree (root + catalog descendants), merge each session's
* stored log with the live `session/event` mirror (deduped by seq), skip
* unreadable sessions, and degrade to a root-only fold when the subagent
* runtime is absent. Absence of workflow events is an empty list, never an
* error.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildWorkflowsApi } from '../src/workflow-routes.ts'
import type {
Context,
SidebarSessionEvent,
SidebarSubagentDescendantEntry,
} from '../src/context-types.ts'
/** One child descendant row. */
function child(id: string): SidebarSubagentDescendantEntry {
return { kind: 'child', id, activity: 'running', hasChildren: false, mode: 'one-shot', parentId: 'root', depth: 1 }
}
/** A session with the given raw events. */
function session(events: SidebarSessionEvent[]) {
return { header: { cwd: '/p' }, snapshotEvents: () => events }
}
/** One workflow event row. */
function ev(seq: number, type: string, data: Record<string, unknown>): SidebarSessionEvent {
return { type, seq, time: 0, data }
}
/** A context with sessions + optional subagents service + optional event feed. */
function ctxWith(options: {
sessions: Map<string, SidebarSessionEvent[]>
subagents?: unknown
on?: (event: string, listener: (session: unknown, event: SidebarSessionEvent) => void) => () => void
}): Context {
return {
sessions: { get: (id: string) => { const e = options.sessions.get(id); return e === undefined ? undefined : session(e) } },
get: (key: string) => (key === 'subagents' ? options.subagents : undefined),
...(options.on === undefined ? {} : { on: options.on }),
effect: () => () => {},
} as unknown as Context
}
describe('workflows.list route', () => {
it('folds the runs of every tree session, oldest first', async () => {
const subagents = { listDescendants: vi.fn(async () => [child('child-a')]) }
const sessions = new Map<string, SidebarSessionEvent[]>([
['child-a', [
ev(1, 'tool-workflow/run-start', { runId: 'r1', name: 'child-run' }),
ev(2, 'tool-workflow/run-end', { runId: 'r1', stopReason: 'completed' }),
]],
['root', [
ev(5, 'tool-workflow/run-start', { runId: 'r2', name: 'root-run' }),
]],
])
const api = buildWorkflowsApi(ctxWith({ sessions, subagents }))
const { runs } = await api.list({ rootSessionId: 'root' })
expect(runs.map(run => [run.runId, run.originSessionId, run.status])).toEqual([
['r1', 'child-a', 'completed'],
['r2', 'root', 'running'],
])
expect(subagents.listDescendants).toHaveBeenCalledWith('root')
})
it('returns an empty list when the tree never ran a workflow', async () => {
const subagents = { listDescendants: vi.fn(async () => [child('child-a')]) }
const sessions = new Map<string, SidebarSessionEvent[]>([
['root', [ev(1, 'assistant/message', { message: { content: [] } })]],
['child-a', []],
])
const api = buildWorkflowsApi(ctxWith({ sessions, subagents }))
await expect(api.list({ rootSessionId: 'root' })).resolves.toEqual({ runs: [] })
})
it('folds the root alone when the subagent runtime is absent', async () => {
const sessions = new Map<string, SidebarSessionEvent[]>([
['root', [ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'solo' })]],
])
const api = buildWorkflowsApi(ctxWith({ sessions }))
const { runs } = await api.list({ rootSessionId: 'root' })
expect(runs.map(run => run.runId)).toEqual(['r'])
})
it('folds the root alone when listDescendants fails', async () => {
const subagents = { listDescendants: vi.fn(async () => { throw new Error('stale') }) }
const sessions = new Map<string, SidebarSessionEvent[]>([
['root', [ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'solo' })]],
])
const api = buildWorkflowsApi(ctxWith({ sessions, subagents }))
const { runs } = await api.list({ rootSessionId: 'root' })
expect(runs.map(run => run.runId)).toEqual(['r'])
})
it('merges the live mirror with the stored log, deduped by seq', async () => {
type Listener = (session: unknown, event: SidebarSessionEvent) => void
let listener: Listener | undefined
const on = (_event: string, bound: Listener) => { listener = bound; return () => {} }
// The stored log ends at the run-start (a rehydrated store session); the
// live mirror carries the rest, re-sending the stored start row too.
const sessions = new Map<string, SidebarSessionEvent[]>([
['root', [ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'live' })]],
])
const api = buildWorkflowsApi(ctxWith({ sessions, on }))
listener?.({ id: 'root' }, ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'live' }))
listener?.({ id: 'root' }, ev(2, 'tool-workflow/agent-start', { runId: 'r', seq: 1, label: 'a', childId: 'c' }))
listener?.({ id: 'root' }, ev(3, 'tool-workflow/run-end', { runId: 'r', stopReason: 'completed' }))
listener?.({ id: 'other' }, ev(1, 'tool-workflow/run-start', { runId: 'stray', name: 'stray' }))
const { runs } = await api.list({ rootSessionId: 'root' })
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({ runId: 'r', status: 'completed' })
expect(runs[0]?.phases[0]?.members).toHaveLength(1)
})
it('skips a session whose log read throws without failing the batch', async () => {
const subagents = { listDescendants: vi.fn(async () => [child('bad'), child('good')]) }
const sessions = {
get: (id: string) => {
if (id === 'good') return session([ev(1, 'tool-workflow/run-start', { runId: 'g', name: 'good' })])
if (id === 'root') return session([])
throw new Error('missing')
},
}
const ctx = {
sessions,
get: (key: string) => (key === 'subagents' ? subagents : undefined),
effect: () => () => {},
} as unknown as Context
const api = buildWorkflowsApi(ctx)
const { runs } = await api.list({ rootSessionId: 'root' })
expect(runs.map(run => run.runId)).toEqual(['g'])
})
it('rejects a missing rootSessionId as bad-request', async () => {
const api = buildWorkflowsApi(ctxWith({ sessions: new Map() }))
await expect(api.list({})).rejects.toThrowError(
expect.objectContaining({ code: 'bad-request' }),
)
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* Unit tests for the workflow-run fold (`foldWorkflowRuns`): the pure
* derivation that turns one session's `tool-workflow/*` events into the run
* views the Tasks page renders (the same four event types the official
* `dsh-client-ui-workflow-run` panel folds).
*/
import { describe, expect, it } from 'vitest'
import { foldWorkflowRuns, type WorkflowRunView } from '../src/workflow-runs.ts'
import type { SidebarSessionEvent } from '../src/context-types.ts'
/** One event row. */
function ev(seq: number, type: string, data: Record<string, unknown>, time = 0): SidebarSessionEvent {
return { type, seq, time, data }
}
/** The full lifecycle of one run with two phases. */
function lifecycle(seq = 1): SidebarSessionEvent[] {
return [
ev(seq, 'tool-workflow/run-start', { runId: 'run-1', name: 'repo-audit' }, 1000),
ev(seq + 1, 'tool-workflow/agent-start', { runId: 'run-1', seq: 1, label: '扫描 src', phase: '侦察', childId: 'child-a' }),
ev(seq + 2, 'tool-workflow/agent-start', { runId: 'run-1', seq: 2, label: '扫描 tests', phase: '侦察', childId: 'child-b' }),
ev(seq + 3, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }),
ev(seq + 4, 'tool-workflow/agent-start', { runId: 'run-1', seq: 3, label: '生成报告', phase: '汇总', childId: 'child-c' }),
ev(seq + 5, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }),
ev(seq + 6, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'completed' }, 5000),
]
}
describe('foldWorkflowRuns', () => {
it('folds a full lifecycle into phases with outcomes', () => {
const runs = foldWorkflowRuns(lifecycle(), 'origin')
expect(runs).toHaveLength(1)
const run = runs[0] as WorkflowRunView
expect(run).toMatchObject({
runId: 'run-1',
name: 'repo-audit',
originSessionId: 'origin',
status: 'completed',
stopReason: 'completed',
startedSeq: 1,
startedAt: 1000,
finishedAt: 5000,
})
expect(run.phases.map(phase => phase.title)).toEqual(['侦察', '汇总'])
const [scout, report] = run.phases
expect(scout?.members.map(member => [member.seq, member.label, member.outcome])).toEqual([
[1, '扫描 src', 'completed'],
[2, '扫描 tests', 'failed'],
])
expect(report?.members).toHaveLength(1)
expect(report?.members[0]).toMatchObject({ seq: 3, label: '生成报告', childId: 'child-c' })
expect(report?.members[0]?.outcome).toBeUndefined()
})
it('keeps a run without run-end running', () => {
const runs = foldWorkflowRuns(lifecycle().slice(0, 3), 'origin')
expect(runs[0]?.status).toBe('running')
expect(runs[0]?.stopReason).toBeUndefined()
expect(runs[0]?.finishedAt).toBeUndefined()
})
it('maps stopReason error to the failed display status', () => {
const runs = foldWorkflowRuns([
ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'n' }),
ev(2, 'tool-workflow/run-end', { runId: 'r', stopReason: 'error' }, 42),
], 'origin')
expect(runs[0]).toMatchObject({ status: 'failed', stopReason: 'error', finishedAt: 42 })
})
it('groups unphased members into one untitled phase in first-seen order', () => {
const runs = foldWorkflowRuns([
ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'n' }),
ev(2, 'tool-workflow/agent-start', { runId: 'r', seq: 1, label: 'a', childId: 'c1' }),
ev(3, 'tool-workflow/agent-start', { runId: 'r', seq: 2, label: 'b', phase: 'p', childId: 'c2' }),
ev(4, 'tool-workflow/agent-start', { runId: 'r', seq: 3, label: 'c', childId: 'c3' }),
], 'origin')
const run = runs[0] as WorkflowRunView
expect(run.phases.map(phase => phase.title)).toEqual([undefined, 'p'])
expect(run.phases[0]?.members.map(member => member.seq)).toEqual([1, 3])
})
it('skips orphan rows that reference an unknown run', () => {
const runs = foldWorkflowRuns([
ev(1, 'tool-workflow/agent-start', { runId: 'ghost', seq: 1, label: 'a', childId: 'c' }),
ev(2, 'tool-workflow/agent-end', { runId: 'ghost', seq: 1, outcome: 'completed' }),
ev(3, 'tool-workflow/run-end', { runId: 'ghost', stopReason: 'completed' }),
], 'origin')
expect(runs).toEqual([])
})
it('ignores a duplicated run-start and non-run events', () => {
const runs = foldWorkflowRuns([
ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'first' }),
ev(2, 'assistant/message', { message: { content: [{ type: 'text', text: 'hi' }] } }),
ev(3, 'tool-workflow/run-start', { runId: 'r', name: 'second' }),
], 'origin')
expect(runs).toHaveLength(1)
expect(runs[0]?.name).toBe('first')
})
it('keeps multiple runs in run-start order with their own member books', () => {
const runs = foldWorkflowRuns([
ev(1, 'tool-workflow/run-start', { runId: 'a', name: 'A' }),
ev(2, 'tool-workflow/agent-start', { runId: 'a', seq: 1, label: 'a1', childId: 'ca' }),
ev(3, 'tool-workflow/run-start', { runId: 'b', name: 'B' }),
ev(4, 'tool-workflow/agent-start', { runId: 'b', seq: 1, label: 'b1', childId: 'cb' }),
ev(5, 'tool-workflow/agent-end', { runId: 'a', seq: 1, outcome: 'completed' }),
], 'origin')
expect(runs.map(run => run.runId)).toEqual(['a', 'b'])
expect(runs[0]?.phases[0]?.members[0]).toMatchObject({ label: 'a1', outcome: 'completed' })
expect(runs[1]?.phases[0]?.members[0]?.label).toBe('b1')
expect(runs[1]?.phases[0]?.members[0]?.outcome).toBeUndefined()
})
it('validates agent-end outcomes and ignores malformed rows', () => {
const runs = foldWorkflowRuns([
ev(1, 'tool-workflow/run-start', { runId: 'r', name: 'n' }),
ev(2, 'tool-workflow/agent-start', { runId: 'r', seq: 1, label: 'a', childId: 'c' }),
ev(3, 'tool-workflow/agent-end', { runId: 'r', seq: 1, outcome: 'bogus' }),
ev(4, 'tool-workflow/agent-end', { runId: 'r', seq: 'not-a-number', outcome: 'completed' }),
], 'origin')
expect(runs[0]?.phases[0]?.members[0]?.outcome).toBeUndefined()
})
it('falls back to runId/#seq labels when name/label are empty', () => {
const runs = foldWorkflowRuns([
ev(1, 'tool-workflow/run-start', { runId: 'r', name: '' }),
ev(2, 'tool-workflow/agent-start', { runId: 'r', seq: 7, label: '', childId: 'c' }),
], 'origin')
expect(runs[0]?.name).toBe('r')
expect(runs[0]?.phases[0]?.members[0]?.label).toBe('#7')
})
})