surface Claude Code Workflow runs in convo + sidebar (T-416)

A `Workflow` tool-use launches its multi-agent run in the background and
returns immediately; the real fan-out arrives out-of-band on stream-json
`type:"system"` task_* events (task_started / task_progress / task_updated /
task_notification) keyed by the launching tool-use id — which clide was
dropping. (Wire shape captured by two live stream-json probes; recorded on
the ticket.)

- workflow_run.dart: a pure, Flutter-free WorkflowRun/WorkflowAgent model
  that folds those events (phases, per-agent start→progress→done deltas,
  usage) into a snapshot.
- StreamJsonSession recognises the events, accumulates a
  Map<toolUseId, WorkflowRun>, and exposes `workflows` + `workflowsStream`.
- A `Workflow` tool-use with a live run renders a dedicated run card —
  phase groups, per-agent rows with spinner/check status, usage, and the
  script — falling back to the generic tool card pre-progress or on reload.
  The run breaks the activity cluster so it's always first-class (like T-342).
- The sidebar Activity tab adds a WORKFLOWS section: one row per run with its
  done/total agent count, tinted by running/done state.

Closes T-416 and the T-410 epic (all children done).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:44:41 +02:00
co-authored by Claude Opus 4.8
parent 57978c54c5
commit b399acedbf
17 changed files with 871 additions and 4 deletions
@@ -171,6 +171,9 @@ bool _isFoldable(ConversationItem item, FoldLevel level, Map<String, String> too
// breaks the cluster at every level, including L3, so parallel agents
// never merge into one Activity card.
if (isAgentTool(name)) return false;
// A Workflow run is a first-class orchestration card too (T-416): it owns
// the live agent fan-out, so it never folds into a generic Activity card.
if (name == 'Workflow') return false;
// The Edit/Write call stays first-class with its diff at L1/L2.
if (level == FoldLevel.everything) return true;
return !isDiffTool(name);
@@ -39,6 +39,7 @@ import 'package:clide/builtin/claude/src/team_broker.dart' show TeamBroker, Team
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, parseUsageText;
import 'package:clide/builtin/claude/src/transcript_publisher.dart' show ClaudeConversation;
import 'package:clide/builtin/claude/src/transcript_reader.dart' show AssistantTextMessage, ConversationItem, SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart' show WorkflowRun;
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
@@ -87,7 +88,9 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
StreamSubscription<Message>? _tabSub;
StreamSubscription<SessionStatus>? _primarySub;
StreamSubscription<ConversationItem>? _primaryItemsSub;
StreamSubscription<Map<String, WorkflowRun>>? _primaryWorkflowsSub;
ClaudeUsage? _usage;
Map<String, WorkflowRun> _workflows = const {};
StreamSubscription<void>? _brokerChangeSub;
Timer? _timer;
late final Future<ClaudeStats> Function() _load;
@@ -204,15 +207,32 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_primarySub = null;
_primaryItemsSub?.cancel();
_primaryItemsSub = null;
_primaryWorkflowsSub?.cancel();
_primaryWorkflowsSub = null;
if (session == null) {
if (_primaryStatus != null && mounted) setState(() => _primaryStatus = null);
if (mounted && (_primaryStatus != null || _workflows.isNotEmpty)) {
setState(() {
_primaryStatus = null;
_workflows = const {};
});
}
return;
}
final seed = session.status;
if (mounted) setState(() => _primaryStatus = seed);
if (mounted) {
setState(() {
_primaryStatus = seed;
_workflows = session.workflows;
});
}
_primarySub = session.statusStream.listen((s) {
if (mounted) setState(() => _primaryStatus = s);
});
// The Activity tab's WORKFLOWS section tracks the primary session's live
// workflow runs (T-416).
_primaryWorkflowsSub = session.workflowsStream.listen((w) {
if (mounted) setState(() => _workflows = w);
});
// Watch for /usage responses: CLI-local output arrives as synthetic
// assistant text; when it parses as usage, the Activity block updates
// (T-415). Driven by the refresh control publishing '/usage'.
@@ -271,6 +291,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
_tabSub?.cancel();
_primarySub?.cancel();
_primaryItemsSub?.cancel();
_primaryWorkflowsSub?.cancel();
_brokerChangeSub?.cancel();
_injectCtl.dispose();
_config?.removeListener(_onConfigChange);
@@ -286,7 +307,7 @@ class _ClaudeMetaSidebarState extends State<ClaudeMetaSidebar> {
SidebarTabStrip(current: _tab, memberCount: _members.length, onPick: (t) => setState(() => _tab = t)),
Expanded(
child: switch (_tab) {
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage),
SidebarTab.activity => ActivityTabView(stats: _stats, primaryStatus: _primaryStatus, config: _config, usage: _usage, workflows: _workflows),
SidebarTab.team => TeamTabView(
members: _members,
memberStatus: _memberStatus,
+16
View File
@@ -25,6 +25,7 @@ import 'slash_commands.dart';
import 'stream_json_session.dart';
import 'task_list.dart';
import 'transcript_reader.dart';
import 'workflow_run.dart';
/// The Claude conversation pane. Drives `claude` over the stream-json control
/// protocol (D-77/D-78): a [StreamJsonSession] owns the process, its events
@@ -76,6 +77,7 @@ class _ClaudePaneState extends State<ClaudePane> {
StreamSubscription<ProjectOpened>? _projectSub;
StreamSubscription<Message>? _commandSub;
StreamSubscription<String>? _modelErrorSub;
StreamSubscription<Map<String, WorkflowRun>>? _workflowsSub;
ConversationController? _conversation;
StreamJsonSession? _session;
SessionStatus _status = const SessionStatus();
@@ -213,6 +215,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
// The orchestrator owns the session, so disposing this pane does NOT kill
// it — that's what lets a hidden/kept-alive pane keep its session (T-169).
// A secondary tab being *closed* is a real teardown, so close its session;
@@ -268,6 +272,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
@@ -385,6 +391,13 @@ class _ClaudePaneState extends State<ClaudePane> {
if (!mounted) return;
setState(() => _status = s);
});
// Workflow runs arrive on out-of-band system events that add no
// conversation item, so the view won't rebuild on its own — drive a
// rebuild as the run map changes so the workflow card updates live (T-416).
_workflowsSub = managed.session.workflowsStream.listen((_) {
if (!mounted) return;
setState(() {});
});
// A rejected /model change (unknown name) rolls back silently in the
// status — say why out loud (T-408).
_modelErrorSub = managed.session.modelErrors.listen((msg) {
@@ -671,6 +684,8 @@ class _ClaudePaneState extends State<ClaudePane> {
_endSub = null;
_modelErrorSub?.cancel();
_modelErrorSub = null;
_workflowsSub?.cancel();
_workflowsSub = null;
_modelPickerOpen = false;
_effortPickerOpen = false;
_permissionPickerOpen = false;
@@ -729,6 +744,7 @@ class _ClaudePaneState extends State<ClaudePane> {
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
workflows: _session?.workflows ?? const <String, WorkflowRun>{},
emptyState: ClaudeBanner(
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
workspace: _repoRoot,
@@ -15,12 +15,14 @@ import 'dart:io';
import 'package:clide/builtin/claude/src/activity_cluster.dart';
import 'package:clide/builtin/claude/src/bash_tail_source.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show shortModelLabel;
import 'package:clide/builtin/claude/src/conversation_card.dart';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/file_tail_follower.dart';
import 'package:clide/builtin/claude/src/image_thumbnail.dart';
import 'package:clide/builtin/claude/src/prompt_card.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/src/facade.dart';
import 'package:clide/kernel/src/syntax/language_map.dart';
import 'package:clide/kernel/src/theme/controller.dart';
@@ -38,11 +40,18 @@ class ConversationView extends StatefulWidget {
this.hiddenToolUseIds = const <String>{},
this.toolUseOutcomes = const <String, bool>{},
this.quietErrorToolUseIds = const <String>{},
this.workflows = const <String, WorkflowRun>{},
this.foldLevel = FoldLevel.tools,
});
final ConversationController controller;
/// Live Workflow runs keyed by their launching `Workflow` tool-use id
/// (T-416). A `Workflow` tool-use card with a matching run renders the
/// dedicated run card (phases, agent rows, status) instead of the generic
/// tool card; absent (pre-progress, or on reload) it falls back to generic.
final Map<String, WorkflowRun> workflows;
/// How aggressively consecutive meta items (tool calls/results, thinking)
/// fold into collapsible activity cards (T-230). Default L1 ([FoldLevel.tools]).
final FoldLevel foldLevel;
@@ -332,6 +341,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
),
FoldedCluster(:final items) => _ActivityCard(
key: ValueKey('cluster.${items.first.uuid}'),
@@ -343,6 +353,7 @@ class _ConversationViewState extends State<ConversationView> {
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: fold.promptsByToolUseId,
runByToolUseId: fold.runByToolUseId,
workflows: widget.workflows,
),
EditRun(:final edits) => _EditRunCard(
key: ValueKey('edits.${edits.first.uuid}'),
@@ -503,6 +514,7 @@ class _ConversationTurn extends StatelessWidget {
this.resultByToolUseId = const <String, ToolResultMessage>{},
this.promptsByToolUseId = const <String, List<UserMessage>>{},
this.runByToolUseId = const <String, List<ConversationItem>>{},
this.workflows = const <String, WorkflowRun>{},
});
final ConversationItem item;
@@ -538,6 +550,9 @@ class _ConversationTurn extends StatelessWidget {
/// thinking, tool cards) nested under the Agent card in a holder (T-264).
final Map<String, List<ConversationItem>> runByToolUseId;
/// Live Workflow runs keyed by launching tool-use id (T-416).
final Map<String, WorkflowRun> workflows;
@override
Widget build(BuildContext context) {
final i = item;
@@ -697,6 +712,13 @@ class _ConversationTurn extends StatelessWidget {
/// and its own per-item mark. An Agent/Task call also nests its visible
/// sub-agent run in a second collapser below (T-264).
Widget _toolUseCollapser(AssistantToolUse t) {
// A Workflow tool-use with a live run (T-416) renders the dedicated run
// card — phases, agent rows, status — instead of the generic tool card. No
// run yet (pre-progress, or on reload where the system events are gone)
// falls through to the generic collapser below.
if (t.name == 'Workflow' && workflows[t.toolUseId] != null) {
return _workflowCard(t, workflows[t.toolUseId]!);
}
final outcome = toolUseOutcomes[t.toolUseId];
final color = outcome == null ? tokens.globalFocus : (outcome ? tokens.statusSuccess : tokens.statusError);
final collapser = ClideCollapserCard(
@@ -740,6 +762,99 @@ class _ConversationTurn extends StatelessWidget {
);
}
/// A dedicated card for a Workflow run (T-416): the harness's multi-agent
/// orchestration. The collapser header carries the run's live status (spinner
/// while running, check when done) and a `done/total agents` counter; the body
/// lists each fanned-out agent — grouped under phase headers when the workflow
/// declared phases — plus the run's usage and the orchestration script.
Widget _workflowCard(AssistantToolUse t, WorkflowRun run) {
final title = run.name ?? 'workflow';
final color = run.done ? tokens.statusSuccess : tokens.globalFocus;
final counter = run.agentCount == 0 ? 'starting' : '${run.doneCount}/${run.agentCount} agents';
final detail = run.done ? (run.summary ?? run.description) : run.description;
final collapsedSummary = (detail == null || detail == title) ? title : '$title · $detail';
return ClideCollapserCard(
label: 'workflow',
color: color,
collapsedSummary: collapsedSummary,
counter: counter,
status: run.done ? ClideRunStatus.success : ClideRunStatus.running,
children: [_workflowBody(t, run)],
);
}
Widget _workflowBody(AssistantToolUse t, WorkflowRun run) {
final agents = run.orderedAgents;
final phases = run.orderedPhases;
final rows = <Widget>[];
if (phases.isEmpty) {
rows.addAll(agents.map(_workflowAgentRow));
} else {
for (final p in phases) {
rows.add(
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: ClideText(p.title.toUpperCase(), muted: true, fontSize: clideFontMeta - 1, fontWeight: FontWeight.w600),
),
);
rows.addAll(agents.where((a) => a.phaseIndex == p.index).map(_workflowAgentRow));
}
// Agents the deltas never tagged with a phase still render, after the
// phased groups, so nothing fanned out is silently dropped.
rows.addAll(agents.where((a) => a.phaseIndex == null).map(_workflowAgentRow));
}
if (rows.isEmpty) {
rows.add(ClideText('Launching…', muted: true, fontSize: clideFontMeta));
}
final script = t.input['script'];
return ConversationCard(
variant: ConversationCardVariant.bordered,
accent: run.done ? tokens.statusSuccess : tokens.globalFocus,
label: run.name ?? 'workflow',
copyText: script is String ? script : const JsonEncoder.withIndent(' ').convert(t.input),
body: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: rows),
extraSegments: [
if (run.totalTokens != null && run.totalTokens! > 0)
CardSegment(
label: 'usage',
child: ClideText('${run.totalTokens} tokens${run.durationMs != null ? ' · ${run.durationMs} ms' : ''}', muted: true, fontSize: clideFontMeta),
),
if (script is String)
CardSegment(
label: 'script',
child: ClideCodeBlock(source: script, language: 'javascript'),
),
],
margin: const EdgeInsets.only(bottom: kClideCardHeaderPadH),
);
}
/// One agent row in a workflow card: a state glyph (spinner while running, a
/// muted check once done), the agent's label, and its model (T-416).
Widget _workflowAgentRow(WorkflowAgent a) {
final done = a.state == WorkflowAgentState.done;
final Widget glyph = done
? ClideIcon(PhosphorIcons.byName('check'), size: 12, color: tokens.statusSuccess)
: ClideSpinner(size: 12, color: tokens.globalTextMuted);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(width: 16, child: Center(child: glyph)),
const SizedBox(width: 6),
Expanded(
child: ClideText(a.label, fontSize: clideFontMeta, maxLines: 1, overflow: TextOverflow.ellipsis),
),
if (a.model != null && a.model!.isNotEmpty) ...[
const SizedBox(width: 8),
ClideText(shortModelLabel(a.model!), muted: true, fontSize: clideFontMeta - 1),
],
],
),
);
}
/// The inner content card for a tool use (T-305): the call body + folded
/// CALL/PROMPT/RESULT segments + its own per-item status mark, with NO own
/// collapse caret — the enclosing collapser owns collapse. Used both as a
@@ -907,6 +1022,7 @@ class _ActivityCard extends StatelessWidget {
required this.resultByToolUseId,
required this.promptsByToolUseId,
required this.runByToolUseId,
this.workflows = const <String, WorkflowRun>{},
});
final List<ConversationItem> items;
@@ -917,6 +1033,7 @@ class _ActivityCard extends StatelessWidget {
final Map<String, ToolResultMessage> resultByToolUseId;
final Map<String, List<UserMessage>> promptsByToolUseId;
final Map<String, List<ConversationItem>> runByToolUseId;
final Map<String, WorkflowRun> workflows;
@override
Widget build(BuildContext context) {
@@ -938,6 +1055,7 @@ class _ActivityCard extends StatelessWidget {
resultByToolUseId: resultByToolUseId,
promptsByToolUseId: promptsByToolUseId,
runByToolUseId: runByToolUseId,
workflows: workflows,
),
],
);
@@ -9,12 +9,20 @@ import 'package:clide/builtin/claude/src/claude_stats.dart';
import 'package:clide/builtin/claude/src/claude_status.dart' show ClaudeUsage, formatTokenCount, permissionModeLabel, shortModelLabel;
import 'package:clide/builtin/claude/src/meta_sidebar/models.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart' show SessionStatus;
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
class ActivityTabView extends StatelessWidget {
const ActivityTabView({super.key, required this.stats, required this.primaryStatus, required this.config, this.usage});
const ActivityTabView({
super.key,
required this.stats,
required this.primaryStatus,
required this.config,
this.usage,
this.workflows = const <String, WorkflowRun>{},
});
final ClaudeStats stats;
final SessionStatus? primaryStatus;
@@ -24,6 +32,11 @@ class ActivityTabView extends StatelessWidget {
/// control (T-415). Null until the first refresh.
final ClaudeUsage? usage;
/// Live Workflow runs in the primary session, keyed by launching tool-use id
/// (T-416). Rendered as an aggregate WORKFLOWS section — one row per run with
/// its done/total agent count and running/done state.
final Map<String, WorkflowRun> workflows;
/// Publish a slash command for the primary pane to execute — the session
/// controls are the same code path as typing the command (D-6).
void _command(BuildContext context, String text) {
@@ -36,6 +49,7 @@ class ActivityTabView extends StatelessWidget {
final latest = stats.latest;
final u = usage;
final sections = <MetaSection>[
..._workflowSection(tokens),
if (u != null)
MetaSection('USAGE', [
if (u.session != null) MetaRow('session', u.session!),
@@ -94,6 +108,24 @@ class ActivityTabView extends StatelessWidget {
);
}
/// An aggregate WORKFLOWS section while one or more workflow runs exist this
/// session (T-416): a row per run — its name and `done/total agents`, tinted
/// focus while running and success once complete.
List<MetaSection> _workflowSection(SurfaceTokens tokens) {
final runs = workflows.values.toList();
if (runs.isEmpty) return const [];
return [
MetaSection('WORKFLOWS', [
for (final r in runs)
MetaRow(
r.name ?? r.taskId ?? 'workflow',
r.agentCount == 0 ? (r.done ? 'done' : 'starting') : '${r.doneCount}/${r.agentCount} agents${r.done ? '' : ''}',
valueColor: r.done ? tokens.statusSuccess : tokens.globalFocus,
),
]),
];
}
List<MetaSection> _runtimeSection(SurfaceTokens tokens) {
final st = primaryStatus;
final skills = config?.skills.length;
@@ -19,6 +19,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:clide/builtin/claude/src/workflow_run.dart';
import 'package:clide/src/util/value_stream.dart';
/// The claude subprocess, abstracted so tests drive it without spawning.
@@ -377,6 +378,20 @@ class StreamJsonSession {
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
/// Live Workflow runs, keyed by their launching `Workflow` tool-use id
/// (T-416). Accumulated from the out-of-band `system` task_* events the
/// harness emits while a workflow runs in the background; the conversation
/// card and the sidebar indicator both read this snapshot. Ephemeral — the
/// events aren't in the resumed transcript, so this is empty on reload.
final _workflows = <String, WorkflowRun>{};
final _workflowsCtl = ValueStream<Map<String, WorkflowRun>>.seeded(const {});
/// The current workflow runs, keyed by launching tool-use id.
Map<String, WorkflowRun> get workflows => Map.unmodifiable(_workflows);
/// Emits the workflow-run map whenever a `system` task event updates it.
Stream<Map<String, WorkflowRun>> get workflowsStream => _workflowsCtl.stream;
/// Whether a turn is in flight (between a send and claude's `result`). Drives
/// the composer's Stop affordance.
bool _busy = false;
@@ -501,6 +516,15 @@ class StreamJsonSession {
return;
}
// Workflow run progress (T-416): the harness reports a backgrounded Workflow
// tool's fan-out on out-of-band `system` task_* events keyed by the
// launching tool-use id. Fold them into the run snapshot and notify; they
// carry no conversation item, so don't fall through to the parser.
if (isWorkflowSystemEvent(ev)) {
_onWorkflowEvent(ev);
return;
}
// Finalise a streamed reply: when the real text `assistant` event for a
// message we streamed arrives, reuse the placeholder's `partial-<id>` uuid
// so the controller replaces the placeholder in place rather than appending
@@ -574,6 +598,15 @@ class StreamJsonSession {
}
}
/// Fold one workflow `system` task event into its run snapshot, keyed by the
/// launching tool-use id, and publish the updated map (T-416).
void _onWorkflowEvent(Map<String, dynamic> ev) {
final id = ev['tool_use_id'] as String;
final prior = _workflows[id] ?? WorkflowRun(toolUseId: id);
_workflows[id] = prior.foldEvent(ev);
_workflowsCtl.add(Map.unmodifiable(_workflows));
}
/// Handle an inbound `control_request`. `can_use_tool` becomes a [ToolPrompt]
/// item the UI resolves; every other subtype is answered with an error so
/// the turn never hangs waiting on us (D-78).
@@ -938,6 +971,7 @@ class StreamJsonSession {
await _proc.kill();
await _items.close();
await _statusCtl.close();
await _workflowsCtl.close();
await _sessionIdCtl.close();
await _pendingCtl.close();
await _busyCtl.close();
+234
View File
@@ -0,0 +1,234 @@
/// Live state of a Claude Code Workflow run (T-416).
///
/// A Workflow is the harness's multi-agent orchestration tool. The model calls
/// it as an ordinary `tool_use` (`name: "Workflow"`, `input: {script}`); the
/// tool returns immediately ("launched in background") and the run's real
/// progress arrives out-of-band on stream-json `type: "system"` events keyed by
/// the launching tool-use id. This file is the pure, Flutter-free model that
/// folds those events into a snapshot the conversation/sidebar surfaces render.
///
/// Wire shape (captured by the T-416 spike, claude 2.1.175):
/// - `task_started` — task_id, tool_use_id, description, workflow_name,
/// prompt (script source)
/// - `task_progress` — usage{total_tokens,tool_uses,duration_ms}, summary,
/// and `workflow_progress[]`, a DELTA list mixing
/// `{type:"workflow_phase", index, title}` and
/// `{type:"workflow_agent", index, label, phaseIndex?,
/// phaseTitle?, model, state(start|progress|done),
/// agentId?}` — partial, merged by index.
/// - `task_updated` — patch{status, end_time}
/// - `task_notification` — terminal status:"completed", summary, usage
///
/// Limit: these events are ephemeral (not persisted to the resumed transcript
/// JSONL), so live progress shows during the session; on reload only the tool
/// card + its "launched in background" result survive.
library;
/// Lifecycle of a single workflow agent, from its `state` field.
enum WorkflowAgentState { start, progress, done, unknown }
WorkflowAgentState parseWorkflowAgentState(Object? raw) => switch (raw) {
'start' || 'queued' || 'running' => WorkflowAgentState.start,
'progress' => WorkflowAgentState.progress,
'done' || 'complete' || 'completed' => WorkflowAgentState.done,
_ => WorkflowAgentState.unknown,
};
/// One phase declared by `meta.phases` / a `phase()` call.
class WorkflowPhase {
const WorkflowPhase({required this.index, required this.title});
final int index;
final String title;
}
/// One agent fanned out by the workflow. Fields accrete across `task_progress`
/// deltas — a later delta fills in `agentId` / upgrades `model` / advances
/// `state`, so [mergeDelta] overlays non-null fields onto the prior snapshot.
class WorkflowAgent {
const WorkflowAgent({
required this.index,
required this.label,
this.model,
this.state = WorkflowAgentState.start,
this.agentId,
this.phaseIndex,
this.phaseTitle,
});
final int index;
final String label;
final String? model;
final WorkflowAgentState state;
final String? agentId;
final int? phaseIndex;
final String? phaseTitle;
/// Fold a raw `workflow_agent` delta entry onto this snapshot, keeping prior
/// values where the delta omits a field.
WorkflowAgent mergeDelta(Map<String, dynamic> e) => WorkflowAgent(
index: index,
label: (e['label'] as String?)?.isNotEmpty == true ? e['label'] as String : label,
model: (e['model'] as String?) ?? model,
state: e.containsKey('state') ? parseWorkflowAgentState(e['state']) : state,
agentId: (e['agentId'] as String?) ?? agentId,
phaseIndex: (e['phaseIndex'] as num?)?.toInt() ?? phaseIndex,
phaseTitle: (e['phaseTitle'] as String?) ?? phaseTitle,
);
static WorkflowAgent fromDelta(Map<String, dynamic> e) => WorkflowAgent(
index: (e['index'] as num).toInt(),
label: (e['label'] as String?) ?? '',
model: e['model'] as String?,
state: parseWorkflowAgentState(e['state']),
agentId: e['agentId'] as String?,
phaseIndex: (e['phaseIndex'] as num?)?.toInt(),
phaseTitle: e['phaseTitle'] as String?,
);
}
/// An immutable snapshot of one workflow run. [foldEvent] returns a new snapshot
/// with a single `system` task event applied (the session keeps one per
/// launching tool-use id and replaces it as events arrive).
class WorkflowRun {
const WorkflowRun({
required this.toolUseId,
this.taskId,
this.name,
this.description,
this.summary,
this.done = false,
this.totalTokens,
this.toolUses,
this.durationMs,
this.phases = const {},
this.agents = const {},
});
/// The launching `Workflow` tool-use id — the join key to the conversation
/// card and across all of this run's system events.
final String toolUseId;
/// The harness task id (e.g. `wy01fihjt`), assigned at `task_started`.
final String? taskId;
/// `workflow_name` from `meta.name`.
final String? name;
final String? description;
final String? summary;
/// True once a `task_updated{status:completed}` or `task_notification`
/// terminal event lands.
final bool done;
final int? totalTokens;
final int? toolUses;
final int? durationMs;
/// Phase index → phase. Empty for a phase-less workflow.
final Map<int, WorkflowPhase> phases;
/// Agent index → agent snapshot.
final Map<int, WorkflowAgent> agents;
bool get running => !done;
int get agentCount => agents.length;
int get doneCount => agents.values.where((a) => a.state == WorkflowAgentState.done).length;
/// Agents in index order — the order the script fanned them out.
List<WorkflowAgent> get orderedAgents {
final list = agents.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
/// Phases in index order.
List<WorkflowPhase> get orderedPhases {
final list = phases.values.toList()..sort((a, b) => a.index.compareTo(b.index));
return list;
}
WorkflowRun _copyWith({
String? taskId,
String? name,
String? description,
String? summary,
bool? done,
int? totalTokens,
int? toolUses,
int? durationMs,
Map<int, WorkflowPhase>? phases,
Map<int, WorkflowAgent>? agents,
}) => WorkflowRun(
toolUseId: toolUseId,
taskId: taskId ?? this.taskId,
name: name ?? this.name,
description: description ?? this.description,
summary: summary ?? this.summary,
done: done ?? this.done,
totalTokens: totalTokens ?? this.totalTokens,
toolUses: toolUses ?? this.toolUses,
durationMs: durationMs ?? this.durationMs,
phases: phases ?? this.phases,
agents: agents ?? this.agents,
);
/// Apply one `system` task event ([ev]) and return the updated snapshot.
/// [ev] must already be the decoded envelope; unknown subtypes return `this`.
WorkflowRun foldEvent(Map<String, dynamic> ev) {
switch (ev['subtype']) {
case 'task_started':
return _copyWith(taskId: ev['task_id'] as String?, name: ev['workflow_name'] as String?, description: ev['description'] as String?);
case 'task_progress':
return _foldProgress(ev);
case 'task_updated':
final patch = ev['patch'];
final status = patch is Map ? patch['status'] as String? : null;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null);
case 'task_notification':
final status = ev['status'] as String?;
return _copyWith(done: status == 'completed' || status == 'failed' ? true : null, summary: ev['summary'] as String?)._foldUsage(ev['usage']);
default:
return this;
}
}
WorkflowRun _foldProgress(Map<String, dynamic> ev) {
final phases = Map<int, WorkflowPhase>.from(this.phases);
final agents = Map<int, WorkflowAgent>.from(this.agents);
final progress = ev['workflow_progress'];
if (progress is List) {
for (final raw in progress) {
if (raw is! Map) continue;
final e = raw.cast<String, dynamic>();
final idx = (e['index'] as num?)?.toInt();
if (idx == null) continue;
switch (e['type']) {
case 'workflow_phase':
phases[idx] = WorkflowPhase(index: idx, title: (e['title'] as String?) ?? 'phase $idx');
case 'workflow_agent':
final prior = agents[idx];
agents[idx] = prior != null ? prior.mergeDelta(e) : WorkflowAgent.fromDelta(e);
}
}
}
return _copyWith(summary: ev['summary'] as String?, phases: phases, agents: agents)._foldUsage(ev['usage']);
}
WorkflowRun _foldUsage(Object? usage) {
if (usage is! Map) return this;
return _copyWith(
totalTokens: (usage['total_tokens'] as num?)?.toInt(),
toolUses: (usage['tool_uses'] as num?)?.toInt(),
durationMs: (usage['duration_ms'] as num?)?.toInt(),
);
}
}
/// The `system` subtypes that carry workflow run progress (T-416). Other system
/// subtypes (`init`, `hook_*`, `thinking_tokens`) are unrelated and left alone.
const Set<String> kWorkflowSystemSubtypes = {'task_started', 'task_progress', 'task_updated', 'task_notification'};
/// True when [ev] is a `system` event carrying workflow run progress that names
/// a launching tool-use id we can key on.
bool isWorkflowSystemEvent(Map<String, dynamic> ev) =>
ev['type'] == 'system' && kWorkflowSystemSubtypes.contains(ev['subtype']) && (ev['tool_use_id'] as String?)?.isNotEmpty == true;