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
|
||||
}
|
||||
Reference in New Issue
Block a user