diff --git a/src/context-types.ts b/src/context-types.ts index 7c003d0..82582cc 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -275,6 +275,77 @@ export interface SidebarAgentPresetsService { mount(agentCtx: unknown, presetId: string): Promise } +/** + * 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 + /** Compare-and-set mutation of one shared task (stale revision → conflict). */ + remoteUpdateTask(agent: unknown, req: SidebarUpdateTeamTaskRequest): Promise +} + +/** 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). */ diff --git a/src/index.ts b/src/index.ts index 7d18a05..2618e77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 diff --git a/src/team-routes.ts b/src/team-routes.ts new file mode 100644 index 0000000..d91d6d9 --- /dev/null +++ b/src/team-routes.ts @@ -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 + /** CAS-mutate one shared task (payload = `{ rootSessionId, ...request }`). */ + taskUpdate(payload: unknown): Promise +} + +/** 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 + 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 + 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) + } + }, + } +} diff --git a/src/wire.ts b/src/wire.ts index 3326c01..749e56f 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -16,6 +16,7 @@ export type SidebarErrorCode = | 'fs-error' | 'git-error' | 'job-error' + | 'team-error' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' diff --git a/src/workflow-routes.ts b/src/workflow-routes.ts new file mode 100644 index 0000000..ee66261 --- /dev/null +++ b/src/workflow-routes.ts @@ -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() + 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, + } + 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 => { + 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() + 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 } + }, + } +} diff --git a/src/workflow-runs.ts b/src/workflow-runs.ts new file mode 100644 index 0000000..6938834 --- /dev/null +++ b/src/workflow-runs.ts @@ -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 +} + +/** + * 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() + 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, + byRunId: ReadonlyMap, +): 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): number | undefined { + const seq = data.seq + return typeof seq === 'number' && Number.isSafeInteger(seq) && seq >= 0 ? seq : undefined +} diff --git a/tests/team-routes.spec.ts b/tests/team-routes.spec.ts new file mode 100644 index 0000000..3ac5a3d --- /dev/null +++ b/tests/team-routes.spec.ts @@ -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 { + return { + id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [], ...over, + } +} + +/** One task row. */ +function task(over: Partial = {}): 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' }, + ) + }) +}) diff --git a/tests/workflow-routes.spec.ts b/tests/workflow-routes.spec.ts new file mode 100644 index 0000000..90aa66d --- /dev/null +++ b/tests/workflow-routes.spec.ts @@ -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): SidebarSessionEvent { + return { type, seq, time: 0, data } +} + +/** A context with sessions + optional subagents service + optional event feed. */ +function ctxWith(options: { + sessions: Map + 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([ + ['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([ + ['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([ + ['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([ + ['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([ + ['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' }), + ) + }) +}) diff --git a/tests/workflow-runs.spec.ts b/tests/workflow-runs.spec.ts new file mode 100644 index 0000000..2cee60a --- /dev/null +++ b/tests/workflow-runs.spec.ts @@ -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, 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') + }) +})