Move agent state (todos, subagents, skills, MCP) onto StageProjection (#364)

## Summary

The `GET /runs/{id}` response now carries per-stage agent state — todos,
subagents, skills, and MCP server status — directly on each
`StageProjection`, unblocking the agent-stage sidebar without any new
endpoints.

## What changed and why

**`RunProjection.todos_by_list` removed.** The run-level map was the
only home for todos, but every list was already 1:1 with a stage (keyed
by `openai_plan:<session>` or `anthropic_tasks:<root_session>`). Moving
todos onto `StageProjection.todos: Option<TodoListProjection>`
eliminates the awkward cross-stage indirection with no loss of
expressiveness.

**Four new fields on `StageProjection`** (`todos`, `subagents`,
`skills`, `mcp_servers`) plus five new projection-side types
(`SubAgentProjection`, `SubAgentStatus`, `SkillsProjection`,
`ActivatedSkill`, `McpServerProjection`, `McpServerStatus`). All
colocated in `run_projection.rs`; no new modules. Four existing
event-payload types are reused directly (`TodoListProjection`,
`AgentSkillSummary`, `AgentSkillActivationSource`,
`AgentMcpToolSummary`) via `with_replacement(...)` in
`fabro-api/build.rs` so progenitor doesn't generate parallel `ApiFoo`
aliases.

**Reducer rerouting** (`run_state.rs`): `TodoCreated/Updated/Deleted`
now resolve the owning stage via the existing
`stage_at_stored_or_current_visit` helper and mutate `stage.todos`
directly. Eight new event arms handle
`AgentSubSpawned/Completed/Failed/Closed`,
`AgentSkillsDiscovered/Activated`, and `AgentMcpReady/Failed` using the
same `stage_at_stored_or_visit` pattern already used for other agent
events.

**Design decisions called out in the plan:**
- `SkillActivated` appends every activation (event-sourced replay
semantics); the UI can collapse if needed.
- `upsert_mcp_server` replaces by `server_name`, so a server that
recovers after a failure shows its final state.
- `SubAgentStatus` and `McpServerStatus` are projection-side enums,
intentionally distinct from the runtime per-process status types in
`fabro-agent`.

**OpenAPI + TS client** updated in lockstep: new schemas added,
`todos_by_list` removed from `RunProjection`, `bun run generate`
regenerated 20+ new model files.

### Plan Summary

- Extend `StageProjection` with `todos`, `subagents`, `skills`,
`mcp_servers` + supporting types in `run_projection.rs`
- Remove `RunProjection.todos_by_list` (no cross-stage use case)
- Reroute todo reducer handlers; add 8 new event arms for
subagent/skill/MCP events
- Wire reused types through `fabro-api/build.rs` `with_replacement`;
regenerate TS client
- Update all existing todo reducer tests to read from `stage.todos`; add
new test modules for subagent, skill, and MCP event families
- Update stale comment in `run-events.ts`


### Fabro Details

<details>
<summary>Ran 9 stages in 38m 52s for $13.29</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 16s | – | 0 |
| implement | 16m 8s | $7.80 | 0 |
| simplify_opus | 10m 34s | $3.91 | 0 |
| simplify_gpt | 3m 23s | $1.58 | 0 |
| verify | 3m 51s | – | 0 |
| fmt | 2s | – | 0 |
| **Total** | **38m 52s** | **$13.29** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-23 13:26:51 -04:00 • committed by GitHub
parent 7e6052bec3
commit f81c5b96b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1436 additions and 112 deletions

View file

@ -96,8 +96,8 @@ const STEERING_EVENTS = new Set([
"agent.steer.buffered",
"agent.steer.dropped",
]);
// Todo / task mutation events refresh `getRunState` consumers (so the
// projected `todos_by_list` map updates live) and the run events list.
// Todo / task mutation events refresh `getRunState` consumers (so per-stage
// todo projections update live) and the run events list.
const TODO_EVENTS = new Set([
"todo.created",
"todo.updated",
@ -252,4 +252,4 @@ export function useRunEvents(runId: string | undefined) {
if (!runId) return;
return subscribeToRunEvents(runId, mutate as MutateFn);
}, [mutate, runId]);
}
}

View file

@ -7758,10 +7758,218 @@ components:
oneOf:
- $ref: "#/components/schemas/BillingModelRef"
- type: "null"
todos:
oneOf:
- $ref: "#/components/schemas/TodoListProjection"
- type: "null"
description: Projected todo / task list for this stage.
subagents:
type: array
description: Subagents spawned by this stage, in replay/insertion order.
items:
$ref: "#/components/schemas/SubAgentProjection"
skills:
$ref: "#/components/schemas/SkillsProjection"
description: Agent skills discovered and activated during this stage.
mcp_servers:
type: array
description: MCP servers observed by this stage.
items:
$ref: "#/components/schemas/McpServerProjection"
state:
$ref: "#/components/schemas/StageState"
description: Lifecycle state of the stage projection.
SubAgentProjection:
description: Current projected state for one subagent spawned by an agent stage.
type: object
required:
- agent_id
- depth
- task
- status
properties:
agent_id:
type: string
depth:
type: integer
minimum: 0
task:
type: string
status:
$ref: "#/components/schemas/SubAgentStatus"
SubAgentStatus:
description: Projected lifecycle status for a subagent.
oneOf:
- $ref: "#/components/schemas/SubAgentStatusRunning"
- $ref: "#/components/schemas/SubAgentStatusCompleted"
- $ref: "#/components/schemas/SubAgentStatusFailed"
- $ref: "#/components/schemas/SubAgentStatusClosed"
discriminator:
propertyName: kind
mapping:
running: "#/components/schemas/SubAgentStatusRunning"
completed: "#/components/schemas/SubAgentStatusCompleted"
failed: "#/components/schemas/SubAgentStatusFailed"
closed: "#/components/schemas/SubAgentStatusClosed"
SubAgentStatusRunning:
type: object
required:
- kind
properties:
kind:
type: string
enum: [running]
SubAgentStatusCompleted:
type: object
required:
- kind
- success
- turns_used
properties:
kind:
type: string
enum: [completed]
success:
type: boolean
turns_used:
type: integer
minimum: 0
SubAgentStatusFailed:
type: object
required:
- kind
- error
properties:
kind:
type: string
enum: [failed]
error:
description: Provider/tool error payload captured by the subagent event.
SubAgentStatusClosed:
type: object
required:
- kind
properties:
kind:
type: string
enum: [closed]
SkillsProjection:
description: Agent skills discovered and activated during a stage.
type: object
required:
- available
- activated
properties:
available:
type: array
items:
$ref: "#/components/schemas/AgentSkillSummary"
activated:
type: array
items:
$ref: "#/components/schemas/ActivatedSkill"
AgentSkillSummary:
description: Summary of an available agent skill.
type: object
required:
- name
- description
properties:
name:
type: string
description:
type: string
ActivatedSkill:
description: One observed agent skill activation.
type: object
required:
- name
- source
properties:
name:
type: string
source:
$ref: "#/components/schemas/AgentSkillActivationSource"
AgentSkillActivationSource:
description: Source that activated an agent skill.
type: string
enum: [slash, tool]
McpServerProjection:
description: Projected state for one MCP server observed by an agent stage.
type: object
required:
- server_name
- tool_count
- status
properties:
server_name:
type: string
tool_count:
type: integer
minimum: 0
status:
$ref: "#/components/schemas/McpServerStatus"
McpServerStatus:
description: Projected MCP server readiness status.
oneOf:
- $ref: "#/components/schemas/McpServerStatusReady"
- $ref: "#/components/schemas/McpServerStatusFailed"
discriminator:
propertyName: kind
mapping:
ready: "#/components/schemas/McpServerStatusReady"
failed: "#/components/schemas/McpServerStatusFailed"
McpServerStatusReady:
type: object
required:
- kind
- tools
properties:
kind:
type: string
enum: [ready]
tools:
type: array
items:
$ref: "#/components/schemas/AgentMcpToolSummary"
McpServerStatusFailed:
type: object
required:
- kind
- error
properties:
kind:
type: string
enum: [failed]
error:
type: string
AgentMcpToolSummary:
description: Summary of one tool exposed by an MCP server.
type: object
required:
- name
- original_name
properties:
name:
type: string
original_name:
type: string
StageModelUsage:
description: Provider, model, and request-control metadata recorded for a stage attempt.
type: object
@ -8069,15 +8277,6 @@ components:
type: object
additionalProperties:
$ref: "#/components/schemas/PendingInterviewRecord"
todos_by_list:
type: object
description: |-
Projected todo / task lists, keyed by `list_id`
(`openai_plan:<session_id>` or
`anthropic_tasks:<root_session_id>`). Built by replaying
`todo.created`, `todo.updated`, and `todo.deleted` events.
additionalProperties:
$ref: "#/components/schemas/TodoListProjection"
stages:
type: object
description: Map from StageId (`node_id@visit`) to stage projection data.

View file

@ -344,6 +344,28 @@ fn main() {
("CommandTermination", "fabro_types::CommandTermination", &[]),
("StageModelUsage", "fabro_types::StageModelUsage", &[]),
("StageProjection", "fabro_types::StageProjection", &[]),
("TodoListProjection", "fabro_types::TodoListProjection", &[]),
("SubAgentProjection", "fabro_types::SubAgentProjection", &[]),
("SubAgentStatus", "fabro_types::SubAgentStatus", &[]),
("SkillsProjection", "fabro_types::SkillsProjection", &[]),
("ActivatedSkill", "fabro_types::ActivatedSkill", &[]),
("AgentSkillSummary", "fabro_types::AgentSkillSummary", &[]),
(
"AgentSkillActivationSource",
"fabro_types::AgentSkillActivationSource",
&[],
),
(
"McpServerProjection",
"fabro_types::McpServerProjection",
&[],
),
("McpServerStatus", "fabro_types::McpServerStatus", &[]),
(
"AgentMcpToolSummary",
"fabro_types::AgentMcpToolSummary",
&[],
),
("SecretMetadata", "fabro_types::SecretMetadata", &[]),
("InterviewOption", "fabro_types::InterviewOption", &[]),
(

View file

@ -32,22 +32,24 @@ pub mod types {
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason,
};
pub use fabro_types::{
ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
AskFabro, AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, DiffStats,
DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
FailureSignature, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord,
PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest,
PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse,
PendingInterviewRecord, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails,
PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink,
PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, Run,
RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse,
RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, RunSandbox,
RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork,
McpServerProjection, McpServerStatus, PairId, PairMessageId, PairMessageRecord,
PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget,
PairTranscriptEntry, PairTranscriptResponse, PendingInterviewRecord, PreRunPushOutcome,
Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,
PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,
QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunEventDetailContentKind,
RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance,
RunSandbox, RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork,
SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources,
SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps,
SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionMessage,
SessionRecord, SessionStatus, SessionSummary, SessionTurn, StageCompletion, StageHandler,
StageModelUsage, StageOutcome, StageProjection, StageState, SystemActorKind, TurnId,
SessionRecord, SessionStatus, SessionSummary, SessionTurn, SkillsProjection,
StageCompletion, StageHandler, StageModelUsage, StageOutcome, StageProjection, StageState,
SubAgentProjection, SubAgentStatus, SystemActorKind, TodoListProjection, TurnId,
UserPrincipal, WorkflowSettings,
};

View file

@ -1,7 +1,18 @@
use std::any::{TypeId, type_name};
use fabro_api::types::StageProjection as ApiStageProjection;
use fabro_types::StageProjection;
use fabro_api::types::{
ActivatedSkill as ApiActivatedSkill, AgentMcpToolSummary as ApiAgentMcpToolSummary,
AgentSkillActivationSource as ApiAgentSkillActivationSource,
AgentSkillSummary as ApiAgentSkillSummary, McpServerProjection as ApiMcpServerProjection,
McpServerStatus as ApiMcpServerStatus, SkillsProjection as ApiSkillsProjection,
StageProjection as ApiStageProjection, SubAgentProjection as ApiSubAgentProjection,
SubAgentStatus as ApiSubAgentStatus, TodoListProjection as ApiTodoListProjection,
};
use fabro_types::{
ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
McpServerProjection, McpServerStatus, SkillsProjection, StageProjection, SubAgentProjection,
SubAgentStatus, TodoListKind, TodoListProjection,
};
use serde_json::json;
#[test]
@ -9,6 +20,20 @@ fn stage_projection_reuses_canonical_type() {
assert_same_type::<ApiStageProjection, StageProjection>();
}
#[test]
fn stage_projection_reuses_nested_agent_state_types() {
assert_same_type::<ApiTodoListProjection, TodoListProjection>();
assert_same_type::<ApiSubAgentProjection, SubAgentProjection>();
assert_same_type::<ApiSubAgentStatus, SubAgentStatus>();
assert_same_type::<ApiSkillsProjection, SkillsProjection>();
assert_same_type::<ApiActivatedSkill, ActivatedSkill>();
assert_same_type::<ApiAgentSkillSummary, AgentSkillSummary>();
assert_same_type::<ApiAgentSkillActivationSource, AgentSkillActivationSource>();
assert_same_type::<ApiMcpServerProjection, McpServerProjection>();
assert_same_type::<ApiMcpServerStatus, McpServerStatus>();
assert_same_type::<ApiAgentMcpToolSummary, AgentMcpToolSummary>();
}
#[test]
fn stage_projection_round_trips_representative_json() {
let value = json!({
@ -49,6 +74,60 @@ fn stage_projection_round_trips_representative_json() {
"cache_read_tokens": 0,
"cache_write_tokens": 0
},
"todos": {
"kind": "openai_plan",
"list_id": "openai_plan:ses_root",
"items": [
{
"id": "todo-1",
"status": "in_progress",
"order": 0,
"subject": "Write tests",
"active_form": "Writing tests"
}
]
},
"subagents": [
{
"agent_id": "sub-1",
"depth": 1,
"task": "Investigate failing test",
"status": {
"kind": "completed",
"success": true,
"turns_used": 3
}
}
],
"skills": {
"available": [
{
"name": "rust",
"description": "Rust workflow help"
}
],
"activated": [
{
"name": "rust",
"source": "slash"
}
]
},
"mcp_servers": [
{
"server_name": "filesystem",
"tool_count": 1,
"status": {
"kind": "ready",
"tools": [
{
"name": "read_file",
"original_name": "read_file"
}
]
}
}
],
"state": "succeeded"
});
@ -56,6 +135,138 @@ fn stage_projection_round_trips_representative_json() {
assert_eq!(serde_json::to_value(state).unwrap(), value);
}
#[test]
fn nested_agent_state_types_match_openapi_json_shape() {
let todo_list = TodoListProjection::new(TodoListKind::OpenAiPlan, "openai_plan:ses_root");
let todo_json = serde_json::to_value(&todo_list).unwrap();
assert_eq!(
todo_json,
json!({
"kind": "openai_plan",
"list_id": "openai_plan:ses_root",
"items": []
})
);
let api_todo_list: ApiTodoListProjection = serde_json::from_value(todo_json).unwrap();
assert_eq!(api_todo_list, todo_list);
let subagent = SubAgentProjection {
agent_id: "sub-1".to_string(),
depth: 1,
task: "Investigate failing test".to_string(),
status: SubAgentStatus::Completed {
success: true,
turns_used: 3,
},
};
let subagent_json = serde_json::to_value(&subagent).unwrap();
assert_eq!(
subagent_json,
json!({
"agent_id": "sub-1",
"depth": 1,
"task": "Investigate failing test",
"status": {
"kind": "completed",
"success": true,
"turns_used": 3
}
})
);
let api_subagent: ApiSubAgentProjection = serde_json::from_value(subagent_json).unwrap();
assert_eq!(api_subagent, subagent);
let skill = AgentSkillSummary {
name: "rust".to_string(),
description: "Rust workflow help".to_string(),
};
let skill_json = serde_json::to_value(&skill).unwrap();
assert_eq!(
skill_json,
json!({
"name": "rust",
"description": "Rust workflow help"
})
);
let api_skill: ApiAgentSkillSummary = serde_json::from_value(skill_json).unwrap();
assert_eq!(api_skill, skill);
let source_json = serde_json::to_value(AgentSkillActivationSource::Slash).unwrap();
assert_eq!(source_json, json!("slash"));
let api_source: ApiAgentSkillActivationSource = serde_json::from_value(source_json).unwrap();
assert_eq!(api_source, AgentSkillActivationSource::Slash);
let activated = ActivatedSkill {
name: "rust".to_string(),
source: AgentSkillActivationSource::Slash,
};
let skills = SkillsProjection {
available: vec![skill],
activated: vec![activated],
};
let skills_json = serde_json::to_value(&skills).unwrap();
assert_eq!(
skills_json,
json!({
"available": [
{
"name": "rust",
"description": "Rust workflow help"
}
],
"activated": [
{
"name": "rust",
"source": "slash"
}
]
})
);
let api_skills: ApiSkillsProjection = serde_json::from_value(skills_json).unwrap();
assert_eq!(api_skills, skills);
let tool = AgentMcpToolSummary {
name: "read_file".to_string(),
original_name: "read_file".to_string(),
};
let tool_json = serde_json::to_value(&tool).unwrap();
assert_eq!(
tool_json,
json!({
"name": "read_file",
"original_name": "read_file"
})
);
let api_tool: ApiAgentMcpToolSummary = serde_json::from_value(tool_json).unwrap();
assert_eq!(api_tool, tool);
let mcp_server = McpServerProjection {
server_name: "filesystem".to_string(),
tool_count: 1,
status: McpServerStatus::Ready { tools: vec![tool] },
};
let mcp_json = serde_json::to_value(&mcp_server).unwrap();
assert_eq!(
mcp_json,
json!({
"server_name": "filesystem",
"tool_count": 1,
"status": {
"kind": "ready",
"tools": [
{
"name": "read_file",
"original_name": "read_file"
}
]
}
})
);
let api_mcp: ApiMcpServerProjection = serde_json::from_value(mcp_json).unwrap();
assert_eq!(api_mcp, mcp_server);
assert_eq!(mcp_server.tool_count, 1);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),

View file

@ -8,12 +8,13 @@ use fabro_types::run_event::{
};
use fabro_types::settings::run::{EnvironmentProvider, RunEnvironmentSettings};
use fabro_types::{
AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, Conclusion,
EventBody, FailureSignature, InterviewQuestionRecord, Outcome, PendingInterviewRecord,
PullRequestLink, RepositoryRef, Run, RunBillingSummary, RunControlAction, RunDiff, RunEvent,
RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox,
RunSandboxRuntime, RunSpec, RunStatus, RunTimestamps, SandboxProvider, StageCompletion,
StageHandler, StageId, StageModelUsage, StageOutcome, StageProjection, StageState, StartRecord,
ActivatedSkill, AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination,
Conclusion, EventBody, FailureSignature, InterviewQuestionRecord, McpServerProjection,
McpServerStatus, Outcome, PendingInterviewRecord, PullRequestLink, RepositoryRef, Run,
RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks,
RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxRuntime, RunSpec, RunStatus,
RunTimestamps, SandboxProvider, StageCompletion, StageHandler, StageId, StageModelUsage,
StageOutcome, StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus,
TodoListProjection, TodoProjection, WorkflowRef, first_event_seq,
};
use fabro_util::error::render_compact_with_causes;
@ -449,13 +450,109 @@ impl RunProjectionReducer for RunProjection {
stage.parallel_results = Some(parallel_results);
}
EventBody::TodoCreated(props) => {
apply_todo_created(self, props);
let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_todo_created(stage, props);
}
EventBody::TodoUpdated(props) => {
apply_todo_updated(self, props);
let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_todo_updated(stage, props);
}
EventBody::TodoDeleted(props) => {
apply_todo_deleted(self, props);
let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_todo_deleted(stage, props);
}
EventBody::AgentSubSpawned(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
stage.subagents.push(SubAgentProjection {
agent_id: props.agent_id.clone(),
depth: props.depth,
task: props.task.clone(),
status: SubAgentStatus::Running,
});
}
EventBody::AgentSubCompleted(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
if let Some(subagent) = subagent_mut(stage, &props.agent_id) {
subagent.status = SubAgentStatus::Completed {
success: props.success,
turns_used: props.turns_used,
};
}
}
EventBody::AgentSubFailed(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
if let Some(subagent) = subagent_mut(stage, &props.agent_id) {
subagent.status = SubAgentStatus::Failed {
error: props.error.clone(),
};
}
}
EventBody::AgentSubClosed(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
if let Some(subagent) = subagent_mut(stage, &props.agent_id) {
subagent.status = SubAgentStatus::Closed;
}
}
EventBody::AgentSkillsDiscovered(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
stage.skills.available.clone_from(&props.skills);
}
EventBody::AgentSkillActivated(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
stage.skills.activated.push(ActivatedSkill {
name: props.skill_name.clone(),
source: props.source,
});
}
EventBody::AgentMcpReady(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
upsert_mcp_server(stage, McpServerProjection {
server_name: props.server_name.clone(),
tool_count: props.tool_count,
status: McpServerStatus::Ready {
tools: props.tools.clone(),
},
});
}
EventBody::AgentMcpFailed(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
upsert_mcp_server(stage, McpServerProjection {
server_name: props.server_name.clone(),
tool_count: 0,
status: McpServerStatus::Failed {
error: props.error.clone(),
},
});
}
_ => {}
}
@ -464,11 +561,18 @@ impl RunProjectionReducer for RunProjection {
}
}
fn apply_todo_created(state: &mut RunProjection, props: &TodoCreatedProps) {
let list = state
.todos_by_list
.entry(props.list_id.clone())
.or_insert_with(|| TodoListProjection::new(props.list_kind, props.list_id.clone()));
fn apply_todo_created(stage: &mut StageProjection, props: &TodoCreatedProps) {
if stage
.todos
.as_ref()
.is_none_or(|list| list.list_id != props.list_id || list.kind != props.list_kind)
{
stage.todos = Some(TodoListProjection::new(
props.list_kind,
props.list_id.clone(),
));
}
let list = stage.todos.as_mut().expect("todo list was just inserted");
list.upsert(TodoProjection {
id: props.todo_id.clone(),
status: props.status,
@ -483,18 +587,49 @@ fn apply_todo_created(state: &mut RunProjection, props: &TodoCreatedProps) {
});
}
fn apply_todo_updated(state: &mut RunProjection, props: &TodoUpdatedProps) {
if let Some(list) = state.todos_by_list.get_mut(&props.list_id) {
fn apply_todo_updated(stage: &mut StageProjection, props: &TodoUpdatedProps) {
if let Some(list) = stage
.todos
.as_mut()
.filter(|list| list.list_id == props.list_id)
{
list.apply_patch(&props.todo_id, &fabro_types::TodoPatch::from_props(props));
}
}
fn apply_todo_deleted(state: &mut RunProjection, props: &TodoDeletedProps) {
if let Some(list) = state.todos_by_list.get_mut(&props.list_id) {
list.remove(&props.todo_id);
if list.items.is_empty() {
state.todos_by_list.remove(&props.list_id);
}
fn apply_todo_deleted(stage: &mut StageProjection, props: &TodoDeletedProps) {
let Some(list) = stage
.todos
.as_mut()
.filter(|list| list.list_id == props.list_id)
else {
return;
};
list.remove(&props.todo_id);
if list.items.is_empty() {
stage.todos = None;
}
}
fn subagent_mut<'a>(
stage: &'a mut StageProjection,
agent_id: &str,
) -> Option<&'a mut SubAgentProjection> {
stage
.subagents
.iter_mut()
.find(|subagent| subagent.agent_id == agent_id)
}
fn upsert_mcp_server(stage: &mut StageProjection, server: McpServerProjection) {
if let Some(existing) = stage
.mcp_servers
.iter_mut()
.find(|existing| existing.server_name == server.server_name)
{
*existing = server;
} else {
stage.mcp_servers.push(server);
}
}
@ -892,8 +1027,11 @@ mod tests {
use fabro_types::run_event::run::RunFailedProps;
use fabro_types::run_event::{
AgentAcpCancelledProps, AgentAcpCompletedProps, AgentAcpStartedProps,
AgentAcpTimedOutProps, AgentMessageProps, AgentSessionActivatedProps,
AgentSessionEndedProps, AgentSessionStartedProps, CheckpointCompletedProps,
AgentAcpTimedOutProps, AgentMcpFailedProps, AgentMcpReadyProps, AgentMcpToolSummary,
AgentMessageProps, AgentSessionActivatedProps, AgentSessionEndedProps,
AgentSessionStartedProps, AgentSkillActivatedProps, AgentSkillActivationSource,
AgentSkillSummary, AgentSkillsDiscoveredProps, AgentSubClosedProps, AgentSubCompletedProps,
AgentSubFailedProps, AgentSubSpawnedProps, CheckpointCompletedProps,
InterviewCompletedProps, InterviewOption, InterviewStartedProps, RunControlEffectProps,
StageCompletedProps, StageFailedProps, StagePromptProps, StageRetryingProps,
StageStartedProps,
@ -901,9 +1039,10 @@ mod tests {
use fabro_types::{
AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint,
CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail,
FailureReason, Graph, Outcome, PullRequestLink, QuestionType, ReasoningEffort, RunBlobId,
RunControlAction, RunDiff, RunEvent, RunSpec, RunStatus, Speed, StageModelUsage,
StageOutcome, StageState, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
FailureReason, Graph, McpServerStatus, Outcome, PullRequestLink, QuestionType,
ReasoningEffort, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSpec, RunStatus, Speed,
StageModelUsage, StageOutcome, StageState, SubAgentStatus, SuccessReason, WorkflowSettings,
first_event_seq, fixtures,
};
use serde_json::json;
@ -3104,10 +3243,21 @@ mod tests {
mod todo_reducer {
use fabro_types::run_event::{TodoCreatedProps, TodoDeletedProps, TodoUpdatedProps};
use fabro_types::{TodoListKind, TodoStatus};
use fabro_types::{TodoListKind, TodoListProjection, TodoStatus};
use super::*;
fn stage_id() -> StageId {
StageId::new("code", 1)
}
fn stage_todos<'a>(state: &'a RunProjection, stage_id: &StageId) -> &'a TodoListProjection {
state
.stage(stage_id)
.and_then(|stage| stage.todos.as_ref())
.expect("stage todos present")
}
fn created(
list: &str,
list_kind: TodoListKind,
@ -3164,30 +3314,32 @@ mod tests {
#[test]
fn replay_reconstructs_current_list() {
let mut state = initialized_projection();
let stage_id = stage_id();
let list = "openai_plan:ses_a";
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
1,
created(list, TodoListKind::OpenAiPlan, "a", 0, "first"),
None,
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
2,
created(list, TodoListKind::OpenAiPlan, "b", 1, "second"),
None,
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
3,
updated_status(list, TodoListKind::OpenAiPlan, "a", TodoStatus::InProgress),
None,
stage_id.clone(),
))
.unwrap();
let projection = state.todos_by_list.get(list).expect("list present");
let projection = stage_todos(&state, &stage_id);
assert_eq!(projection.list_id, list);
assert_eq!(projection.items.len(), 2);
assert_eq!(projection.items[0].id, "a");
assert_eq!(projection.items[0].status, TodoStatus::InProgress);
@ -3197,53 +3349,57 @@ mod tests {
#[test]
fn deleted_todos_are_absent() {
let mut state = initialized_projection();
let stage_id = stage_id();
let list = "openai_plan:ses_a";
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
1,
created(list, TodoListKind::OpenAiPlan, "a", 0, "first"),
None,
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
2,
created(list, TodoListKind::OpenAiPlan, "b", 1, "second"),
None,
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
3,
deleted(list, TodoListKind::OpenAiPlan, "a"),
None,
stage_id.clone(),
))
.unwrap();
let projection = state.todos_by_list.get(list).expect("list present");
let projection = stage_todos(&state, &stage_id);
assert_eq!(projection.items.len(), 1);
assert_eq!(projection.items[0].id, "b");
}
#[test]
fn multiple_lists_stay_isolated() {
fn stage_todo_lists_stay_isolated() {
let mut state = initialized_projection();
let plan_one = StageId::new("plan_one", 1);
let plan_two = StageId::new("plan_two", 1);
let claude = StageId::new("claude", 1);
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
1,
created("openai_plan:s1", TodoListKind::OpenAiPlan, "a", 0, "p1"),
None,
plan_one.clone(),
))
.unwrap();
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
2,
created("openai_plan:s2", TodoListKind::OpenAiPlan, "a", 0, "p2"),
None,
plan_two.clone(),
))
.unwrap();
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
3,
created(
"anthropic_tasks:s_root",
@ -3252,35 +3408,32 @@ mod tests {
0,
"claude task",
),
None,
claude.clone(),
))
.unwrap();
assert_eq!(state.todos_by_list.len(), 3);
assert_eq!(state.todos_by_list["openai_plan:s1"].items[0].subject, "p1");
assert_eq!(state.todos_by_list["openai_plan:s2"].items[0].subject, "p2");
assert_eq!(
state.todos_by_list["anthropic_tasks:s_root"].items[0].subject,
"claude task"
);
assert_eq!(stage_todos(&state, &plan_one).items[0].subject, "p1");
assert_eq!(stage_todos(&state, &plan_two).items[0].subject, "p2");
assert_eq!(stage_todos(&state, &claude).items[0].subject, "claude task");
}
#[test]
fn metadata_patch_merges_and_null_deletes() {
let mut state = initialized_projection();
let stage_id = stage_id();
let list = "anthropic_tasks:r";
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
1,
created(list, TodoListKind::AnthropicTasks, "1", 0, "t"),
None,
stage_id.clone(),
))
.unwrap();
let mut meta = BTreeMap::new();
meta.insert("k1".to_string(), serde_json::json!("v1"));
meta.insert("k2".to_string(), serde_json::json!("v2"));
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
2,
EventBody::TodoUpdated(TodoUpdatedProps {
list_id: list.to_string(),
@ -3296,13 +3449,13 @@ mod tests {
add_blocked_by: None,
metadata_patch: meta,
}),
None,
stage_id.clone(),
))
.unwrap();
let mut delete = BTreeMap::new();
delete.insert("k1".to_string(), serde_json::Value::Null);
state
.apply_event(&test_event(
.apply_event(&test_stage_event(
3,
EventBody::TodoUpdated(TodoUpdatedProps {
list_id: list.to_string(),
@ -3318,13 +3471,242 @@ mod tests {
add_blocked_by: None,
metadata_patch: delete,
}),
None,
stage_id.clone(),
))
.unwrap();
let todo = &state.todos_by_list[list].items[0];
let todo = &stage_todos(&state, &stage_id).items[0];
assert!(!todo.metadata.contains_key("k1"));
assert_eq!(todo.metadata.get("k2"), Some(&serde_json::json!("v2")));
}
}
mod agent_state_reducer {
use super::*;
fn stage_id() -> StageId {
StageId::new("code", 1)
}
#[test]
fn subagent_events_update_stage_projection() {
let mut state = initialized_projection();
let stage_id = stage_id();
state
.apply_event(&test_stage_event(
1,
EventBody::AgentSubSpawned(AgentSubSpawnedProps {
agent_id: "sub-1".to_string(),
depth: 1,
task: "write tests".to_string(),
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.subagents.len(), 1);
assert_eq!(stage.subagents[0].agent_id, "sub-1");
assert_eq!(stage.subagents[0].depth, 1);
assert_eq!(stage.subagents[0].task, "write tests");
assert_eq!(stage.subagents[0].status, SubAgentStatus::Running);
state
.apply_event(&test_stage_event(
2,
EventBody::AgentSubCompleted(AgentSubCompletedProps {
agent_id: "sub-1".to_string(),
depth: 1,
success: true,
turns_used: 3,
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.subagents[0].status, SubAgentStatus::Completed {
success: true,
turns_used: 3,
});
state
.apply_event(&test_stage_event(
3,
EventBody::AgentSubSpawned(AgentSubSpawnedProps {
agent_id: "sub-2".to_string(),
depth: 2,
task: "debug failure".to_string(),
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_stage_event(
4,
EventBody::AgentSubFailed(AgentSubFailedProps {
agent_id: "sub-2".to_string(),
depth: 2,
error: json!({ "message": "boom" }),
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.subagents[1].status, SubAgentStatus::Failed {
error: json!({ "message": "boom" }),
});
state
.apply_event(&test_stage_event(
5,
EventBody::AgentSubClosed(AgentSubClosedProps {
agent_id: "sub-2".to_string(),
depth: 2,
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.subagents[1].status, SubAgentStatus::Closed);
}
#[test]
fn skill_events_update_stage_projection() {
let mut state = initialized_projection();
let stage_id = stage_id();
state
.apply_event(&test_stage_event(
1,
EventBody::AgentSkillsDiscovered(AgentSkillsDiscoveredProps {
provider_profile: "claude".to_string(),
source_dirs: vec![".claude/skills".to_string()],
skills: vec![
AgentSkillSummary {
name: "rust".to_string(),
description: "Rust help".to_string(),
},
AgentSkillSummary {
name: "docs".to_string(),
description: "Docs help".to_string(),
},
],
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_stage_event(
2,
EventBody::AgentSkillActivated(AgentSkillActivatedProps {
skill_name: "rust".to_string(),
source: AgentSkillActivationSource::Slash,
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_stage_event(
3,
EventBody::AgentSkillActivated(AgentSkillActivatedProps {
skill_name: "rust".to_string(),
source: AgentSkillActivationSource::Tool,
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.skills.available.len(), 2);
assert_eq!(stage.skills.available[0].name, "rust");
assert_eq!(stage.skills.activated.len(), 2);
assert_eq!(stage.skills.activated[0].name, "rust");
assert_eq!(
stage.skills.activated[0].source,
AgentSkillActivationSource::Slash
);
assert_eq!(
stage.skills.activated[1].source,
AgentSkillActivationSource::Tool
);
}
#[test]
fn mcp_server_events_update_stage_projection() {
let mut state = initialized_projection();
let stage_id = stage_id();
state
.apply_event(&test_stage_event(
1,
EventBody::AgentMcpReady(AgentMcpReadyProps {
server_name: "filesystem".to_string(),
tool_count: 2,
tools: vec![
AgentMcpToolSummary {
name: "read_file".to_string(),
original_name: "read_file".to_string(),
},
AgentMcpToolSummary {
name: "write_file".to_string(),
original_name: "write_file".to_string(),
},
],
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_stage_event(
2,
EventBody::AgentMcpFailed(AgentMcpFailedProps {
server_name: "github".to_string(),
error: "missing token".to_string(),
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
state
.apply_event(&test_stage_event(
3,
EventBody::AgentMcpReady(AgentMcpReadyProps {
server_name: "filesystem".to_string(),
tool_count: 1,
tools: vec![AgentMcpToolSummary {
name: "read_file".to_string(),
original_name: "read_file".to_string(),
}],
visit: 1,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.mcp_servers.len(), 2);
assert_eq!(stage.mcp_servers[0].server_name, "filesystem");
assert_eq!(stage.mcp_servers[0].tool_count, 1);
assert_eq!(stage.mcp_servers[0].status, McpServerStatus::Ready {
tools: vec![AgentMcpToolSummary {
name: "read_file".to_string(),
original_name: "read_file".to_string(),
}],
});
assert_eq!(stage.mcp_servers[1].server_name, "github");
assert_eq!(stage.mcp_servers[1].tool_count, 0);
assert_eq!(stage.mcp_servers[1].status, McpServerStatus::Failed {
error: "missing token".to_string(),
});
}
}
}

View file

@ -103,8 +103,9 @@ pub use run_event::{
pub use run_failure::RunFailure;
pub use run_id::{RunId, fixtures};
pub use run_projection::{
CheckpointRecord, PendingInterviewRecord, RunProjection, StageModelUsage, StageProjection,
first_event_seq,
ActivatedSkill, CheckpointRecord, McpServerProjection, McpServerStatus, PendingInterviewRecord,
RunProjection, SkillsProjection, StageModelUsage, StageProjection, SubAgentProjection,
SubAgentStatus, first_event_seq,
};
pub use run_sandbox::{RunSandbox, RunSandboxRuntime};
pub use run_summary::{

View file

@ -7,10 +7,11 @@ use fabro_model::{ReasoningEffort, Speed};
use crate::run_event::{AgentSessionActivatedProps, StagePromptProps};
use crate::{
AgentBackend, BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord,
InvalidTransition, ModelRef, PullRequestLink, RunControlAction, RunDiff, RunId, RunSandbox,
RunSpec, RunStatus, StageCompletion, StageHandler, StageId, StageState, StageTiming,
StartRecord, TodoListProjection,
AgentBackend, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition,
ModelRef, PullRequestLink, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus,
StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord,
TodoListProjection,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -35,11 +36,6 @@ pub struct RunProjection {
pub pull_request: Option<PullRequestLink>,
pub superseded_by: Option<RunId>,
pub pending_interviews: BTreeMap<String, PendingInterviewRecord>,
/// Projected todo / task lists, keyed by `list_id` (`openai_plan:<session>`
/// or `anthropic_tasks:<root_session>`). Maintained by replaying
/// `todo.created`, `todo.updated`, and `todo.deleted` events.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub todos_by_list: BTreeMap<String, TodoListProjection>,
stages: HashMap<StageId, StageProjection>,
}
@ -150,9 +146,67 @@ pub struct StageProjection {
pub usage: BilledTokenCounts,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<ModelRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub todos: Option<TodoListProjection>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subagents: Vec<SubAgentProjection>,
#[serde(default, skip_serializing_if = "SkillsProjection::is_empty")]
pub skills: SkillsProjection,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<McpServerProjection>,
pub state: StageState,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SubAgentProjection {
pub agent_id: String,
pub depth: usize,
pub task: String,
pub status: SubAgentStatus,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SubAgentStatus {
Running,
Completed { success: bool, turns_used: usize },
Failed { error: serde_json::Value },
Closed,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct SkillsProjection {
pub available: Vec<AgentSkillSummary>,
pub activated: Vec<ActivatedSkill>,
}
impl SkillsProjection {
#[must_use]
pub fn is_empty(&self) -> bool {
self.available.is_empty() && self.activated.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ActivatedSkill {
pub name: String,
pub source: AgentSkillActivationSource,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct McpServerProjection {
pub server_name: String,
pub tool_count: usize,
pub status: McpServerStatus,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum McpServerStatus {
Ready { tools: Vec<AgentMcpToolSummary> },
Failed { error: String },
}
/// Convert a 1-based event sequence number into the `NonZeroU32` form used for
/// `StageProjection::first_event_seq`. Run event seqs always start at 1.
#[must_use]
@ -171,6 +225,10 @@ impl StageProjection {
timing: None,
usage: BilledTokenCounts::default(),
model: None,
todos: None,
subagents: Vec::new(),
skills: SkillsProjection::default(),
mcp_servers: Vec::new(),
provider_used: None,
diff: None,
script_invocation: None,
@ -247,7 +305,6 @@ impl RunProjection {
pull_request: None,
superseded_by: None,
pending_interviews: BTreeMap::new(),
todos_by_list: BTreeMap::new(),
stages: HashMap::new(),
}
}

View file

@ -21,7 +21,11 @@ base.ts
common.ts
configuration.ts
index.ts
models/activated-skill.ts
models/agent-mcp-tool-summary.ts
models/agent-permissions.ts
models/agent-skill-activation-source.ts
models/agent-skill-summary.ts
models/aggregate-billing-totals.ts
models/aggregate-billing.ts
models/api-question-option.ts
@ -162,7 +166,11 @@ models/manifest-goal.ts
models/manifest-target.ts
models/manifest-workflow-config.ts
models/manifest-workflow.ts
models/mcp-server-projection.ts
models/mcp-server-settings.ts
models/mcp-server-status-failed.ts
models/mcp-server-status-ready.ts
models/mcp-server-status.ts
models/mcp-transport-http.ts
models/mcp-transport-sandbox.ts
models/mcp-transport-stdio.ts
@ -253,6 +261,7 @@ models/pull-request-user.ts
models/pull-request.ts
models/question-type.ts
models/reasoning-effort-feature.ts
models/reasoning-effort.ts
models/related-workflow-diagnostic.ts
models/render-workflow-graph-direction.ts
models/render-workflow-graph-format.ts
@ -377,11 +386,13 @@ models/session-record.ts
models/session-status.ts
models/session-summary.ts
models/session-turn.ts
models/skills-projection.ts
models/slack-integration-settings.ts
models/ssh-access-request.ts
models/ssh-access-response.ts
models/stage-completion.ts
models/stage-handler.ts
models/stage-model-usage.ts
models/stage-outcome.ts
models/stage-projection.ts
models/stage-state.ts
@ -390,6 +401,12 @@ models/stage-timing.ts
models/start-record.ts
models/start-run-request.ts
models/steer-run-request.ts
models/sub-agent-projection.ts
models/sub-agent-status-closed.ts
models/sub-agent-status-completed.ts
models/sub-agent-status-failed.ts
models/sub-agent-status-running.ts
models/sub-agent-status.ts
models/submit-answer-multi-selected-request.ts
models/submit-answer-no-request.ts
models/submit-answer-request.ts

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AgentSkillActivationSource } from './agent-skill-activation-source';
/**
* One observed agent skill activation.
*/
export interface ActivatedSkill {
'name': string;
'source': AgentSkillActivationSource;
}

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Summary of one tool exposed by an MCP server.
*/
export interface AgentMcpToolSummary {
'name': string;
'original_name': string;
}

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Source that activated an agent skill.
*/
export const AgentSkillActivationSource = {
SLASH: 'slash',
TOOL: 'tool'
} as const;
export type AgentSkillActivationSource = typeof AgentSkillActivationSource[keyof typeof AgentSkillActivationSource];

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Summary of an available agent skill.
*/
export interface AgentSkillSummary {
'name': string;
'description': string;
}

View file

@ -1,4 +1,8 @@
export * from './activated-skill';
export * from './agent-mcp-tool-summary';
export * from './agent-permissions';
export * from './agent-skill-activation-source';
export * from './agent-skill-summary';
export * from './aggregate-billing';
export * from './aggregate-billing-totals';
export * from './api-question';
@ -138,7 +142,11 @@ export * from './manifest-goal';
export * from './manifest-target';
export * from './manifest-workflow';
export * from './manifest-workflow-config';
export * from './mcp-server-projection';
export * from './mcp-server-settings';
export * from './mcp-server-status';
export * from './mcp-server-status-failed';
export * from './mcp-server-status-ready';
export * from './mcp-transport';
export * from './mcp-transport-http';
export * from './mcp-transport-sandbox';
@ -354,6 +362,7 @@ export * from './session-record';
export * from './session-status';
export * from './session-summary';
export * from './session-turn';
export * from './skills-projection';
export * from './slack-integration-settings';
export * from './ssh-access-request';
export * from './ssh-access-response';
@ -368,6 +377,12 @@ export * from './stage-timing';
export * from './start-record';
export * from './start-run-request';
export * from './steer-run-request';
export * from './sub-agent-projection';
export * from './sub-agent-status';
export * from './sub-agent-status-closed';
export * from './sub-agent-status-completed';
export * from './sub-agent-status-failed';
export * from './sub-agent-status-running';
export * from './submit-answer-multi-selected-request';
export * from './submit-answer-no-request';
export * from './submit-answer-request';

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { McpServerStatus } from './mcp-server-status';
/**
* Projected state for one MCP server observed by an agent stage.
*/
export interface McpServerProjection {
'server_name': string;
'tool_count': number;
'status': McpServerStatus;
}

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface McpServerStatusFailed {
'kind': McpServerStatusFailedKindEnum;
'error': string;
}
export const McpServerStatusFailedKindEnum = {
FAILED: 'failed'
} as const;
export type McpServerStatusFailedKindEnum = typeof McpServerStatusFailedKindEnum[keyof typeof McpServerStatusFailedKindEnum];

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AgentMcpToolSummary } from './agent-mcp-tool-summary';
export interface McpServerStatusReady {
'kind': McpServerStatusReadyKindEnum;
'tools': Array<AgentMcpToolSummary>;
}
export const McpServerStatusReadyKindEnum = {
READY: 'ready'
} as const;
export type McpServerStatusReadyKindEnum = typeof McpServerStatusReadyKindEnum[keyof typeof McpServerStatusReadyKindEnum];

View file

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AgentMcpToolSummary } from './agent-mcp-tool-summary';
// May contain unused imports in some cases
// @ts-ignore
import type { McpServerStatusFailed } from './mcp-server-status-failed';
// May contain unused imports in some cases
// @ts-ignore
import type { McpServerStatusReady } from './mcp-server-status-ready';
/**
* @type McpServerStatus
* Projected MCP server readiness status.
*/
export type McpServerStatus = { kind: 'failed' } & McpServerStatusFailed | { kind: 'ready' } & McpServerStatusReady;

View file

@ -43,9 +43,6 @@ import type { StageProjection } from './stage-projection';
// May contain unused imports in some cases
// @ts-ignore
import type { StartRecord } from './start-record';
// May contain unused imports in some cases
// @ts-ignore
import type { TodoListProjection } from './todo-list-projection';
/**
* Raw internal run projection derived from the event log.
@ -79,10 +76,6 @@ export interface RunProjection {
'pull_request'?: PullRequestLink | null;
'superseded_by'?: string | null;
'pending_interviews': { [key: string]: PendingInterviewRecord; };
/**
* Projected todo / task lists, keyed by `list_id` (`openai_plan:<session_id>` or `anthropic_tasks:<root_session_id>`). Built by replaying `todo.created`, `todo.updated`, and `todo.deleted` events.
*/
'todos_by_list'?: { [key: string]: TodoListProjection; };
/**
* Map from StageId (`node_id@visit`) to stage projection data.
*/

View file

@ -49,9 +49,6 @@ export interface RunStage {
* 1-based visit count; bumped each time the workflow re-enters this node.
*/
'visit': number;
/**
* Provider, model, and request controls recorded for the latest stage attempt.
*/
'provider_used'?: StageModelUsage | null;
/**
* Wall-clock time the latest attempt of this stage started, if known.

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ActivatedSkill } from './activated-skill';
// May contain unused imports in some cases
// @ts-ignore
import type { AgentSkillSummary } from './agent-skill-summary';
/**
* Agent skills discovered and activated during a stage.
*/
export interface SkillsProjection {
'available': Array<AgentSkillSummary>;
'activated': Array<ActivatedSkill>;
}

View file

@ -24,6 +24,9 @@ import type { ReasoningEffort } from './reasoning-effort';
* Provider, model, and request-control metadata recorded for a stage attempt.
*/
export interface StageModelUsage {
/**
* Source of the stage\'s model usage metadata.
*/
'mode': string;
'provider'?: string | null;
'model'?: string | null;

View file

@ -24,6 +24,12 @@ import type { BillingModelRef } from './billing-model-ref';
import type { CommandTermination } from './command-termination';
// May contain unused imports in some cases
// @ts-ignore
import type { McpServerProjection } from './mcp-server-projection';
// May contain unused imports in some cases
// @ts-ignore
import type { SkillsProjection } from './skills-projection';
// May contain unused imports in some cases
// @ts-ignore
import type { StageCompletion } from './stage-completion';
// May contain unused imports in some cases
// @ts-ignore
@ -34,6 +40,12 @@ import type { StageState } from './stage-state';
// May contain unused imports in some cases
// @ts-ignore
import type { StageTiming } from './stage-timing';
// May contain unused imports in some cases
// @ts-ignore
import type { SubAgentProjection } from './sub-agent-projection';
// May contain unused imports in some cases
// @ts-ignore
import type { TodoListProjection } from './todo-list-projection';
/**
* Observable projection data for one workflow stage execution.
@ -43,9 +55,6 @@ export interface StageProjection {
'prompt'?: string | null;
'response'?: string | null;
'completion'?: StageCompletion | null;
/**
* Provider and model metadata recorded for the stage attempt.
*/
'provider_used'?: StageModelUsage | null;
'diff'?: string | null;
/**
@ -71,6 +80,19 @@ export interface StageProjection {
'timing'?: StageTiming | null;
'usage': BilledTokenCounts;
'model'?: BillingModelRef | null;
'todos'?: TodoListProjection | null;
/**
* Subagents spawned by this stage, in replay/insertion order.
*/
'subagents'?: Array<SubAgentProjection>;
/**
* Agent skills discovered and activated during this stage.
*/
'skills'?: SkillsProjection;
/**
* MCP servers observed by this stage.
*/
'mcp_servers'?: Array<McpServerProjection>;
/**
* Lifecycle state of the stage projection.
*/

View file

@ -0,0 +1,28 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SubAgentStatus } from './sub-agent-status';
/**
* Current projected state for one subagent spawned by an agent stage.
*/
export interface SubAgentProjection {
'agent_id': string;
'depth': number;
'task': string;
'status': SubAgentStatus;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubAgentStatusClosed {
'kind': SubAgentStatusClosedKindEnum;
}
export const SubAgentStatusClosedKindEnum = {
CLOSED: 'closed'
} as const;
export type SubAgentStatusClosedKindEnum = typeof SubAgentStatusClosedKindEnum[keyof typeof SubAgentStatusClosedKindEnum];

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubAgentStatusCompleted {
'kind': SubAgentStatusCompletedKindEnum;
'success': boolean;
'turns_used': number;
}
export const SubAgentStatusCompletedKindEnum = {
COMPLETED: 'completed'
} as const;
export type SubAgentStatusCompletedKindEnum = typeof SubAgentStatusCompletedKindEnum[keyof typeof SubAgentStatusCompletedKindEnum];

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubAgentStatusFailed {
'kind': SubAgentStatusFailedKindEnum;
'error': any;
}
export const SubAgentStatusFailedKindEnum = {
FAILED: 'failed'
} as const;
export type SubAgentStatusFailedKindEnum = typeof SubAgentStatusFailedKindEnum[keyof typeof SubAgentStatusFailedKindEnum];

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface SubAgentStatusRunning {
'kind': SubAgentStatusRunningKindEnum;
}
export const SubAgentStatusRunningKindEnum = {
RUNNING: 'running'
} as const;
export type SubAgentStatusRunningKindEnum = typeof SubAgentStatusRunningKindEnum[keyof typeof SubAgentStatusRunningKindEnum];

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SubAgentStatusClosed } from './sub-agent-status-closed';
// May contain unused imports in some cases
// @ts-ignore
import type { SubAgentStatusCompleted } from './sub-agent-status-completed';
// May contain unused imports in some cases
// @ts-ignore
import type { SubAgentStatusFailed } from './sub-agent-status-failed';
// May contain unused imports in some cases
// @ts-ignore
import type { SubAgentStatusRunning } from './sub-agent-status-running';
/**
* @type SubAgentStatus
* Projected lifecycle status for a subagent.
*/
export type SubAgentStatus = { kind: 'closed' } & SubAgentStatusClosed | { kind: 'completed' } & SubAgentStatusCompleted | { kind: 'failed' } & SubAgentStatusFailed | { kind: 'running' } & SubAgentStatusRunning;