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:
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'job-error'
|
||||
| 'team-error'
|
||||
| 'sidechat-error'
|
||||
| 'subagents-unavailable'
|
||||
| 'settings-rejected'
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user