From 86b1fbef7f4d73e582932067c499a69ca72c9648 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Thu, 21 May 2026 10:53:41 -0400 Subject: [PATCH] feat(api): bind sessions to runs (#336) ## Summary Ask Fabro sessions are now run-bound instead of standalone. Sessions are created under their owning run, then accessed by flat session ID routes, with durable state projected from the run event stream rather than a separate session store. ## Changes - Move session creation/listing to `POST/GET /api/v1/runs/{id}/sessions` while keeping flat session reads, turns, interrupts, and event streams under `/api/v1/sessions/{id}/...`. - Add typed `run.session.*` events, ULID-backed session/turn IDs, read-only default permissions, and a rebuildable SlateDB `session_id -> run_id` index. - Remove the old file-backed session store and wire the server, runtime, Rust client, generated API crates, and TypeScript client around run event projections. - Replace the old top-level CLI session command with `fabro run ask` for chatting with a run. - Regenerate the TypeScript API client; this also catches up existing generated models for Pair/run event detail schemas already present in the OpenAPI spec. ## Validation - `cargo build -p fabro-api -p fabro-client -p fabro-server -p fabro-cli` - `cargo nextest run -p fabro-server --features test-support -E 'test(run_bound_session_is_created_as_run_event_and_resolves_by_flat_id) | test(sessions_are_listed_only_under_their_owning_run)'` - `cargo nextest run -p fabro-store projection_rebuilds_runtime_context_from_run_events` - `cargo +nightly-2026-04-14 clippy -p fabro-api -p fabro-client -p fabro-store -p fabro-server -p fabro-cli --all-targets -- -D warnings` - `cargo +nightly-2026-04-14 fmt --check --all` - `cd lib/packages/fabro-api-client && bun run typecheck && cd ../../../apps/fabro-web && bun run typecheck` - `git diff --check` --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) Generated with GPT-5 via [Codex](https://openai.com/codex) --- docs/public/api-reference/demo-mode.mdx | 2 +- docs/public/api-reference/fabro-api.yaml | 544 +++---- docs/public/reference/cli.mdx | 43 +- lib/crates/fabro-agent/src/cli.rs | 21 +- lib/crates/fabro-agent/src/lib.rs | 1 + lib/crates/fabro-agent/src/session.rs | 31 +- .../fabro-agent/src/tool_permissions.rs | 23 + lib/crates/fabro-api/build.rs | 8 - lib/crates/fabro-api/src/lib.rs | 13 +- .../tests/permission_level_round_trip.rs | 53 - lib/crates/fabro-cli/src/args.rs | 47 +- lib/crates/fabro-cli/src/commands/mod.rs | 1 - lib/crates/fabro-cli/src/commands/run/ask.rs | 96 ++ lib/crates/fabro-cli/src/commands/run/mod.rs | 2 + lib/crates/fabro-cli/src/commands/session.rs | 140 -- lib/crates/fabro-cli/src/main.rs | 4 - lib/crates/fabro-cli/tests/it/cmd/fabro.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/mod.rs | 1 - lib/crates/fabro-cli/tests/it/cmd/session.rs | 114 -- lib/crates/fabro-client/src/client.rs | 28 +- lib/crates/fabro-server/src/server.rs | 17 +- .../src/server/handler/sessions.rs | 1359 +++++++---------- .../src/server/session_runtime.rs | 54 +- lib/crates/fabro-server/src/server/tests.rs | 776 +--------- lib/crates/fabro-server/tests/it/api/mod.rs | 1 + .../fabro-server/tests/it/api/sessions.rs | 395 +++++ lib/crates/fabro-store/src/keys.rs | 10 +- lib/crates/fabro-store/src/lib.rs | 195 +-- lib/crates/fabro-store/src/run_sessions.rs | 414 +++++ lib/crates/fabro-store/src/session_store.rs | 404 ----- lib/crates/fabro-store/src/slate/mod.rs | 49 +- lib/crates/fabro-store/src/slate/run_store.rs | 8 +- lib/crates/fabro-types/src/lib.rs | 4 +- lib/crates/fabro-types/src/run_event/mod.rs | 43 + .../fabro-types/src/run_event/session.rs | 79 + lib/crates/fabro-types/src/session.rs | 140 +- .../src/.openapi-generator/FILES | 31 +- .../src/api/human-in-the-loop-api.ts | 517 ++++++- .../src/api/run-internals-api.ts | 126 +- .../fabro-api-client/src/api/sessions-api.ts | 559 ++----- ...level.ts => create-run-session-request.ts} | 23 +- .../fabro-api-client/src/models/index.ts | 31 +- .../src/models/pair-message-record.ts | 34 + .../src/models/pair-message-request.ts | 20 + .../src/models/pair-record.ts | 34 + ...ssion-request.ts => pair-start-request.ts} | 15 +- .../models/{turn-status.ts => pair-status.ts} | 16 +- .../src/models/pair-target-selector.ts | 20 + .../src/models/pair-target.ts | 25 + ...pair-transcript-assistant-message-model.ts | 21 + .../pair-transcript-assistant-message.ts | 42 + ...quest.ts => pair-transcript-detail-ref.ts} | 8 +- .../src/models/pair-transcript-entry.ts | 47 + .../src/models/pair-transcript-error.ts | 41 + .../models/pair-transcript-response-meta.ts | 20 + ...nt-list.ts => pair-transcript-response.ts} | 16 +- .../models/pair-transcript-system-message.ts | 44 + .../src/models/pair-transcript-tool-call.ts | 52 + .../models/pair-transcript-user-message.ts | 42 + .../src/models/pair-transcript-warning.ts | 42 + .../run-event-detail-response-content.ts | 30 + ....ts => run-event-detail-response-event.ts} | 30 +- .../src/models/run-event-detail-response.ts | 30 + ...rn-list.ts => run-pair-status-response.ts} | 17 +- .../run-projection-checkpoints-inner-inner.ts | 4 +- .../src/models/session-record.ts | 19 +- .../src/models/session-status.ts | 9 +- .../src/models/session-summary.ts | 10 +- .../src/models/turn-record.ts | 42 - 69 files changed, 3297 insertions(+), 3842 deletions(-) create mode 100644 lib/crates/fabro-agent/src/tool_permissions.rs delete mode 100644 lib/crates/fabro-api/tests/permission_level_round_trip.rs create mode 100644 lib/crates/fabro-cli/src/commands/run/ask.rs delete mode 100644 lib/crates/fabro-cli/src/commands/session.rs delete mode 100644 lib/crates/fabro-cli/tests/it/cmd/session.rs create mode 100644 lib/crates/fabro-server/tests/it/api/sessions.rs create mode 100644 lib/crates/fabro-store/src/run_sessions.rs delete mode 100644 lib/crates/fabro-store/src/session_store.rs create mode 100644 lib/crates/fabro-types/src/run_event/session.rs rename lib/packages/fabro-api-client/src/models/{permission-level.ts => create-run-session-request.ts} (55%) create mode 100644 lib/packages/fabro-api-client/src/models/pair-message-record.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-message-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-record.ts rename lib/packages/fabro-api-client/src/models/{create-session-request.ts => pair-start-request.ts} (62%) rename lib/packages/fabro-api-client/src/models/{turn-status.ts => pair-status.ts} (63%) create mode 100644 lib/packages/fabro-api-client/src/models/pair-target-selector.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-target.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message-model.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message.ts rename lib/packages/fabro-api-client/src/models/{update-session-request.ts => pair-transcript-detail-ref.ts} (78%) create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-entry.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-error.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-response-meta.ts rename lib/packages/fabro-api-client/src/models/{paginated-session-event-list.ts => pair-transcript-response.ts} (61%) create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-system-message.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-tool-call.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-user-message.ts create mode 100644 lib/packages/fabro-api-client/src/models/pair-transcript-warning.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-event-detail-response-content.ts rename lib/packages/fabro-api-client/src/models/{session-event-envelope.ts => run-event-detail-response-event.ts} (53%) create mode 100644 lib/packages/fabro-api-client/src/models/run-event-detail-response.ts rename lib/packages/fabro-api-client/src/models/{paginated-turn-list.ts => run-pair-status-response.ts} (65%) delete mode 100644 lib/packages/fabro-api-client/src/models/turn-record.ts diff --git a/docs/public/api-reference/demo-mode.mdx b/docs/public/api-reference/demo-mode.mdx index 5d3670c0a..bbef30e99 100644 --- a/docs/public/api-reference/demo-mode.mdx +++ b/docs/public/api-reference/demo-mode.mdx @@ -53,5 +53,5 @@ Demo mode implements every API endpoint. Read endpoints return static data repre ## Limitations - **No state changes.** Write operations return a success response but nothing is persisted. Creating a run returns a fixed ID; it won't appear in subsequent list calls. -- **No SSE streaming.** Event stream endpoints (`/api/v1/runs/{id}/events`, `/api/v1/sessions/{id}/events`) return immediately rather than streaming. +- **No SSE streaming.** Event stream endpoints such as `/api/v1/runs/{id}/events` return immediately rather than streaming. - **Static data only.** The same data is returned regardless of path parameters — requesting any run ID returns the same run detail. diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 3f017ee30..566fd498a 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -16,7 +16,7 @@ tags: - name: Runs description: Run management operations - name: Sessions - description: Persistent Fabro agent sessions + description: Ask Fabro sessions bound to runs - name: Human-in-the-Loop description: Questions, answers, and steering for runs - name: Run Outputs @@ -815,28 +815,31 @@ paths: # ── Sessions ────────────────────────────────────────────────────────── - /api/v1/sessions: + /api/v1/runs/{id}/sessions: + parameters: + - $ref: "#/components/parameters/RunId" get: - operationId: listSessions + operationId: listRunSessions tags: [Sessions] - summary: List sessions + summary: List run sessions responses: "200": - description: Durable session summaries + description: Ask Fabro sessions for the run content: application/json: schema: $ref: "#/components/schemas/PaginatedSessionList" post: - operationId: createSession + operationId: createRunSession tags: [Sessions] - summary: Create session + summary: Create run session + description: Creates a read-only Ask Fabro session bound to the run. requestBody: required: true content: application/json: schema: - $ref: "#/components/schemas/CreateSessionRequest" + $ref: "#/components/schemas/CreateRunSessionRequest" responses: "201": description: Session created @@ -881,31 +884,6 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - patch: - operationId: updateSession - tags: [Sessions] - summary: Update session - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/UpdateSessionRequest" - responses: - "200": - description: Updated session record - content: - application/json: - schema: - $ref: "#/components/schemas/SessionRecord" - delete: - operationId: deleteSession - tags: [Sessions] - summary: Delete session - responses: - "204": - description: Session deleted - /api/v1/sessions/{id}/turns: parameters: - name: id @@ -913,17 +891,6 @@ paths: required: true schema: $ref: "#/components/schemas/SessionId" - get: - operationId: listSessionTurns - tags: [Sessions] - summary: List session turns - responses: - "200": - description: Session turns - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedTurnList" post: operationId: submitSessionTurn tags: [Sessions] @@ -970,39 +937,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/sessions/{id}/turns/{turnId}: - parameters: - - name: id - in: path - required: true - schema: - $ref: "#/components/schemas/SessionId" - - name: turnId - in: path - required: true - schema: - $ref: "#/components/schemas/TurnId" - get: - operationId: getSessionTurn - tags: [Sessions] - summary: Get session turn - responses: - "200": - description: Turn record - content: - application/json: - schema: - $ref: "#/components/schemas/TurnRecord" - "404": - description: Turn not found - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/sessions/{id}/turns/{turnId}/interrupt: parameters: - name: id @@ -1025,9 +959,9 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/SessionEventEnvelope" + $ref: "#/components/schemas/EventEnvelope" "404": - description: Session turn not found + description: Session not found headers: x-request-id: $ref: "#/components/headers/XRequestId" @@ -1036,7 +970,7 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" "409": - description: Turn is already terminal or is not active for this session + description: Turn is not active for this session headers: x-request-id: $ref: "#/components/headers/XRequestId" @@ -1045,36 +979,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/sessions/{id}/events: - parameters: - - name: id - in: path - required: true - schema: - $ref: "#/components/schemas/SessionId" - get: - operationId: listSessionEvents - tags: [Sessions] - summary: List or stream session events - description: Returns JSON replay by default. When the `Accept` header includes `text/event-stream`, replays durable events from `since_seq` or `Last-Event-ID`, then stays attached for live events. - parameters: - - name: since_seq - in: query - required: false - schema: - type: integer - minimum: 1 - responses: - "200": - description: Durable session events or live SSE stream - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedSessionEventList" - text/event-stream: - schema: - type: string - # ── Runs ────────────────────────────────────────────────────────────── /api/v1/runs: @@ -5173,12 +5077,13 @@ components: discriminator: propertyName: kind - PairTranscriptBase: + PairTranscriptUserMessage: type: object - required: [kind, seq, event_id, ts, pair_id, target] + required: [kind, seq, event_id, ts, pair_id, target, message_id, text] properties: kind: type: string + enum: [user_message] seq: type: integer minimum: 1 @@ -5191,138 +5096,167 @@ components: $ref: "#/components/schemas/PairId" target: $ref: "#/components/schemas/PairTarget" - - PairTranscriptUserMessage: - allOf: - - $ref: "#/components/schemas/PairTranscriptBase" - - type: object - required: [message_id, text] - properties: - kind: - type: string - enum: [user_message] - message_id: - $ref: "#/components/schemas/PairMessageId" - client_message_id: - type: ["string", "null"] - text: - type: string + message_id: + $ref: "#/components/schemas/PairMessageId" + client_message_id: + type: ["string", "null"] + text: + type: string PairTranscriptSystemMessage: - allOf: - - $ref: "#/components/schemas/PairTranscriptBase" - - type: object - required: [system_message_kind, text] - properties: - kind: - type: string - enum: [system_message] - system_message_kind: - type: string - enum: [human_joined, human_left] - text: - type: string + type: object + required: [kind, seq, event_id, ts, pair_id, target, system_message_kind, text] + properties: + kind: + type: string + enum: [system_message] + seq: + type: integer + minimum: 1 + event_id: + type: string + ts: + type: string + format: date-time + pair_id: + $ref: "#/components/schemas/PairId" + target: + $ref: "#/components/schemas/PairTarget" + system_message_kind: + type: string + enum: [human_joined, human_left] + text: + type: string PairTranscriptAssistantMessage: - allOf: - - $ref: "#/components/schemas/PairTranscriptBase" - - type: object - required: [text, model, tool_call_count] + type: object + required: [kind, seq, event_id, ts, pair_id, target, text, model, tool_call_count] + properties: + kind: + type: string + enum: [assistant_message] + seq: + type: integer + minimum: 1 + event_id: + type: string + ts: + type: string + format: date-time + pair_id: + $ref: "#/components/schemas/PairId" + target: + $ref: "#/components/schemas/PairTarget" + text: + type: string + model: + type: object + required: [provider, model_id] properties: - kind: + provider: type: string - enum: [assistant_message] - text: + model_id: type: string - model: - type: object - required: [provider, model_id] - properties: - provider: - type: string - model_id: - type: string - speed: - type: ["string", "null"] - tool_call_count: - type: integer - minimum: 0 + speed: + type: ["string", "null"] + tool_call_count: + type: integer + minimum: 0 PairTranscriptToolCall: - allOf: - - $ref: "#/components/schemas/PairTranscriptBase" - - type: object - required: [tool_call_id, tool_name, status, summary, is_error, truncated, detail_ref] - properties: - kind: - type: string - enum: [tool_call] - tool_call_id: - type: string - tool_name: - type: string - status: - type: string - enum: [started, completed] - summary: - type: string - is_error: - type: boolean - truncated: - type: boolean - detail_ref: - type: object - required: [seq] - properties: - seq: - type: integer - minimum: 1 - tool_call_id: - type: string + type: object + required: [kind, seq, event_id, ts, pair_id, target, tool_call_id, tool_name, status, summary, is_error, truncated, detail_ref] + properties: + kind: + type: string + enum: [tool_call] + seq: + type: integer + minimum: 1 + event_id: + type: string + ts: + type: string + format: date-time + pair_id: + $ref: "#/components/schemas/PairId" + target: + $ref: "#/components/schemas/PairTarget" + tool_call_id: + type: string + tool_name: + type: string + status: + type: string + enum: [started, completed] + summary: + type: string + is_error: + type: boolean + truncated: + type: boolean + detail_ref: + $ref: "#/components/schemas/PairTranscriptDetailRef" PairTranscriptError: - allOf: - - $ref: "#/components/schemas/PairTranscriptBase" - - type: object - required: [message, detail_ref] - properties: - kind: - type: string - enum: [error] - message: - type: string - detail_ref: - type: object - required: [seq] - properties: - seq: - type: integer - minimum: 1 - tool_call_id: - type: string + type: object + required: [kind, seq, event_id, ts, pair_id, target, message, detail_ref] + properties: + kind: + type: string + enum: [error] + seq: + type: integer + minimum: 1 + event_id: + type: string + ts: + type: string + format: date-time + pair_id: + $ref: "#/components/schemas/PairId" + target: + $ref: "#/components/schemas/PairTarget" + message: + type: string + detail_ref: + $ref: "#/components/schemas/PairTranscriptDetailRef" PairTranscriptWarning: - allOf: - - $ref: "#/components/schemas/PairTranscriptBase" - - type: object - required: [warning_kind, message, detail_ref] - properties: - kind: - type: string - enum: [warning] - warning_kind: - type: string - message: - type: string - detail_ref: - type: object - required: [seq] - properties: - seq: - type: integer - minimum: 1 - tool_call_id: - type: string + type: object + required: [kind, seq, event_id, ts, pair_id, target, warning_kind, message, detail_ref] + properties: + kind: + type: string + enum: [warning] + seq: + type: integer + minimum: 1 + event_id: + type: string + ts: + type: string + format: date-time + pair_id: + $ref: "#/components/schemas/PairId" + target: + $ref: "#/components/schemas/PairTarget" + warning_kind: + type: string + message: + type: string + detail_ref: + $ref: "#/components/schemas/PairTranscriptDetailRef" + + PairTranscriptDetailRef: + type: object + required: [seq] + properties: + seq: + type: integer + minimum: 1 + tool_call_id: + type: string RunEventDetailResponse: type: object @@ -5402,11 +5336,7 @@ components: SessionStatus: type: string - enum: [idle, running, failed, closed, deleted] - - TurnStatus: - type: string - enum: [running, succeeded, failed, interrupted] + enum: [idle, running, failed] SessionMessage: description: Persisted full-fidelity session transcript message. @@ -5437,71 +5367,50 @@ components: items: {} SessionRecord: - description: Durable session metadata and compactable runtime context. + description: Ask Fabro session metadata derived from the owning run event stream. type: object required: - id + - run_id - status - - permissions - created_at - updated_at - - runtime_context properties: id: $ref: "#/components/schemas/SessionId" + run_id: + type: string title: type: ["string", "null"] status: $ref: "#/components/schemas/SessionStatus" - working_dir: - type: ["string", "null"] - provider: - type: ["string", "null"] model: type: ["string", "null"] - permissions: - $ref: "#/components/schemas/PermissionLevel" created_at: type: string format: date-time updated_at: type: string format: date-time - deleted_at: - type: ["string", "null"] - format: date-time - runtime_context: - type: array - items: - $ref: "#/components/schemas/SessionMessage" - - PermissionLevel: - description: Agent tool permission level applied to a session. - type: string - enum: - - read-only - - read-write - - full SessionSummary: - description: List projection of a durable session. + description: List projection of an Ask Fabro session. type: object required: - id + - run_id - status - created_at - updated_at properties: id: $ref: "#/components/schemas/SessionId" + run_id: + type: string title: type: ["string", "null"] status: $ref: "#/components/schemas/SessionStatus" - working_dir: - type: ["string", "null"] - provider: - type: ["string", "null"] model: type: ["string", "null"] created_at: @@ -5511,87 +5420,14 @@ components: type: string format: date-time - TurnRecord: - description: One externally submitted input plus terminal output metadata. Full resumable context is stored on SessionRecord.runtime_context. + CreateRunSessionRequest: type: object - required: - - id - - session_id - - input - - status - - created_at - - updated_at - properties: - id: - $ref: "#/components/schemas/TurnId" - session_id: - $ref: "#/components/schemas/SessionId" - input: - type: string - status: - $ref: "#/components/schemas/TurnStatus" - output: - type: ["string", "null"] - error: - type: ["string", "null"] - created_at: - type: string - format: date-time - updated_at: - type: string - format: date-time - completed_at: - type: ["string", "null"] - format: date-time - - SessionEventEnvelope: - description: Append-only durable session event envelope. - type: object - required: - - seq - - session_id - - event - - properties - - ts - properties: - seq: - type: integer - minimum: 0 - session_id: - $ref: "#/components/schemas/SessionId" - turn_id: - oneOf: - - $ref: "#/components/schemas/TurnId" - - type: "null" - event: - type: string - example: turn.running - properties: {} - ts: - type: string - format: date-time - - CreateSessionRequest: - type: object - required: - - permissions properties: title: type: string - working_dir: - type: string - provider: - type: string model: type: string - permissions: - $ref: "#/components/schemas/PermissionLevel" - - UpdateSessionRequest: - type: object - properties: - title: - type: string + description: Catalog model ID or alias, or provider-qualified provider/model reference. Stored as the canonical catalog model ID. SubmitTurnRequest: type: object @@ -5615,34 +5451,6 @@ components: meta: $ref: "#/components/schemas/PaginationMeta" - PaginatedTurnList: - description: Paginated list of turns. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/TurnRecord" - meta: - $ref: "#/components/schemas/PaginationMeta" - - PaginatedSessionEventList: - description: Paginated list of session events. - type: object - required: - - data - - meta - properties: - data: - type: array - items: - $ref: "#/components/schemas/SessionEventEnvelope" - meta: - $ref: "#/components/schemas/PaginationMeta" - PaginatedBoardRunList: description: Paginated list of board runs with shared canonical fields plus board metadata. type: object diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 0db3c3791..6535b2e94 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -65,6 +65,7 @@ fabro [OPTIONS] [COMMAND] | --- | --- | | `fabro archive` | Mark terminal runs as archived (reviewed, no further action needed). Archived runs are hidden from default listings | | `fabro artifact` | Inspect and copy run artifacts (screenshots, reports, traces) | +| `fabro ask` | Ask Fabro a read-only question about a run | | `fabro attach` | Attach to a running or finished workflow run | | `fabro auth` | Manage CLI authentication state | | `fabro completion` | Generate shell completions | @@ -93,7 +94,6 @@ fabro [OPTIONS] [COMMAND] | `fabro sandbox` | Sandbox operations (cp, ssh, preview) | | `fabro secret` | Manage server-owned secrets | | `fabro server` | Server operations | -| `fabro session` | Run a persistent Fabro agent session | | `fabro settings` | Inspect effective settings | | `fabro start` | Start a created workflow run on the server | | `fabro steer` | Steer a running agent mid-execution | @@ -187,6 +187,28 @@ fabro artifact list [OPTIONS] | `--retry ` | Filter to artifacts from a specific retry attempt | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +### `fabro ask` + +Ask Fabro a read-only question about a run + +```bash +fabro ask [OPTIONS] --prompt +``` + +#### Arguments + +| Name | Description | +| --- | --- | +| `RUN` | Run ID prefix or workflow name | + +#### Options + +| Option | Description | +| --- | --- | +| `--model ` | Optional model name | +| `-p, --prompt ` | Question to ask | +| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | + ### `fabro attach` Attach to a running or finished workflow run @@ -1267,25 +1289,6 @@ fabro server stop [OPTIONS] | `--storage-dir ` | Local storage directory (default: ~/.fabro/storage) | | `--timeout ` | Seconds to wait for graceful shutdown before SIGKILL
Default: `10` | -### `fabro session` - -Run a persistent Fabro agent session - -```bash -fabro session [OPTIONS] --prompt -``` - -#### Options - -| Option | Description | -| --- | --- | -| `--model ` | Model name (defaults per provider) | -| `--permissions ` | Permission level for tool execution
Values: `read-only`, `read-write`, `full` | -| `-p, --prompt ` | Task prompt | -| `--provider ` | LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) | -| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | -| `--storage-dir ` | Local storage directory (default: ~/.fabro/storage) | - ### `fabro settings` Inspect effective settings diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 4901c3a31..2ebcbc990 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -30,6 +30,7 @@ use tokio::sync::{Mutex as AsyncMutex, RwLock as AsyncRwLock}; use crate::config::{ToolApprovalAdapter, ToolApprovalFn, ToolHookCallback}; use crate::error::InterruptReason; use crate::subagent::{SessionFactory, SubAgentManager}; +use crate::tool_permissions::{is_auto_approved, tool_category}; use crate::tools::WebFetchSummarizer; use crate::{ AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, LocalSandbox, Message, @@ -120,26 +121,6 @@ impl AgentArgs { } } -fn tool_category(name: &str) -> &'static str { - match name { - "read_file" | "read_many_files" | "grep" | "glob" | "list_dir" => "read", - "write_file" | "edit_file" | "apply_patch" => "write", - // subagent tools inherit parent permissions, always allowed - "spawn_agent" | "send_input" | "wait" | "close_agent" => "subagent", - // shell and unknown tools require highest permission - _ => "shell", - } -} - -fn is_auto_approved(level: PermissionLevel, category: &str) -> bool { - matches!( - (level, category), - (_, "read" | "subagent") - | (PermissionLevel::ReadWrite | PermissionLevel::Full, "write") - | (PermissionLevel::Full, "shell") - ) -} - #[allow( clippy::print_stderr, reason = "Interactive approval prompts belong on stderr, not assistant output." diff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/crates/fabro-agent/src/lib.rs index bd7b25aa9..4f809dde5 100644 --- a/lib/crates/fabro-agent/src/lib.rs +++ b/lib/crates/fabro-agent/src/lib.rs @@ -20,6 +20,7 @@ pub mod session; pub mod skills; pub mod subagent; pub mod tool_execution; +pub mod tool_permissions; pub mod tool_registry; pub mod tools; pub mod truncation; diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index caf4d9f89..9b0b0d1a1 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -15,7 +15,7 @@ use fabro_llm::{Error as LlmError, retry}; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_mcp::connection_manager::McpConnectionManager; use fabro_model::{AgentProfileKind, Catalog, ModelRef, Speed}; -use fabro_types::{Principal, SessionRecord, SessionStatus, SteeringMessage}; +use fabro_types::{Principal, SessionMessage, SessionRecord, SteeringMessage}; use futures::StreamExt; use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast}; use tokio::time; @@ -381,6 +381,7 @@ impl Session { pub fn from_record( record: &SessionRecord, + runtime_context: &[SessionMessage], llm_client: Client, provider_profile: Arc, sandbox: Arc, @@ -395,33 +396,13 @@ impl Session { subagent_manager, ); session.id = record.id.to_string(); - session.history = - History::from_session_messages(&record.runtime_context).map_err(|err| { - Error::InvalidState(format!("invalid persisted session context: {err}")) - })?; - session.state = match record.status { - SessionStatus::Closed | SessionStatus::Deleted => SessionState::Closed, - SessionStatus::Running | SessionStatus::Failed | SessionStatus::Idle => { - SessionState::Idle - } - }; + session.history = History::from_session_messages(runtime_context).map_err(|err| { + Error::InvalidState(format!("invalid persisted session context: {err}")) + })?; + session.state = SessionState::Idle; Ok(session) } - #[must_use] - pub fn to_record(&self, mut record: SessionRecord) -> SessionRecord { - record.status = match self.state { - SessionState::Closed => SessionStatus::Closed, - SessionState::Thinking | SessionState::Executing => SessionStatus::Running, - SessionState::Idle => SessionStatus::Idle, - }; - record.provider = Some(self.provider_id().to_string()); - record.model = Some(self.model().to_string()); - record.runtime_context = self.history.to_session_messages(); - record.updated_at = chrono::Utc::now(); - record - } - pub fn set_tool_env_provider(&mut self, provider: Arc) { self.tool_env_provider = Some(provider); } diff --git a/lib/crates/fabro-agent/src/tool_permissions.rs b/lib/crates/fabro-agent/src/tool_permissions.rs new file mode 100644 index 000000000..3a62931f1 --- /dev/null +++ b/lib/crates/fabro-agent/src/tool_permissions.rs @@ -0,0 +1,23 @@ +use fabro_types::PermissionLevel; + +pub fn tool_category(name: &str) -> &'static str { + match name { + "read_file" | "read_many_files" | "grep" | "glob" | "list_dir" => "read", + "write_file" | "edit_file" | "apply_patch" => "write", + "spawn_agent" | "send_input" | "wait" | "close_agent" => "subagent", + _ => "shell", + } +} + +pub fn is_auto_approved(level: PermissionLevel, category: &str) -> bool { + matches!( + (level, category), + (_, "read" | "subagent") + | (PermissionLevel::ReadWrite | PermissionLevel::Full, "write") + | (PermissionLevel::Full, "shell") + ) +} + +pub fn is_tool_auto_approved(level: PermissionLevel, tool_name: &str) -> bool { + is_auto_approved(level, tool_category(tool_name)) +} diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 523c41f10..eb779ff40 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -476,17 +476,9 @@ fn main() { ("SessionId", "fabro_types::SessionId", &[]), ("TurnId", "fabro_types::TurnId", &[]), ("SessionStatus", "fabro_types::SessionStatus", &[]), - ("TurnStatus", "fabro_types::TurnStatus", &[]), - ("PermissionLevel", "fabro_types::PermissionLevel", &[]), ("SessionMessage", "fabro_types::SessionMessage", &[]), ("SessionRecord", "fabro_types::SessionRecord", &[]), ("SessionSummary", "fabro_types::SessionSummary", &[]), - ("TurnRecord", "fabro_types::TurnRecord", &[]), - ( - "SessionEventEnvelope", - "fabro_types::SessionEventEnvelope", - &[], - ), ]; for (name, path, impls) in replacements { settings.with_replacement(*name, *path, impls.iter().copied()); diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 5eb098478..e20c484cb 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -37,18 +37,17 @@ pub mod types { FailureSignature, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget, PairTargetSelector, PairTranscriptEntry, PairTranscriptResponse, - PendingInterviewRecord, PermissionLevel, PreRunPushOutcome, Principal, PullRequest, - PullRequestDetails, PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, - PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, Run, + 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, SessionEventEnvelope, SessionId, - SessionMessage, SessionRecord, SessionStatus, SessionSummary, StageCompletion, - StageHandler, StageOutcome, StageProjection, StageState, SystemActorKind, TurnId, - TurnRecord, TurnStatus, UserPrincipal, WorkflowSettings, + SecretMetadata, SecretType, ServerSettings, SessionId, SessionMessage, SessionRecord, + SessionStatus, SessionSummary, StageCompletion, StageHandler, StageOutcome, + StageProjection, StageState, SystemActorKind, TurnId, UserPrincipal, WorkflowSettings, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/permission_level_round_trip.rs b/lib/crates/fabro-api/tests/permission_level_round_trip.rs deleted file mode 100644 index f6473b450..000000000 --- a/lib/crates/fabro-api/tests/permission_level_round_trip.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::any::{TypeId, type_name}; - -use fabro_api::types::PermissionLevel as ApiPermissionLevel; -use fabro_types::PermissionLevel; -use serde_json::json; - -#[test] -fn permission_level_reuses_canonical_type() { - assert_same_type::(); -} - -#[test] -fn permission_level_serializes_as_kebab_case_strings() { - assert_eq!( - serde_json::to_value(PermissionLevel::ReadOnly).unwrap(), - json!("read-only") - ); - assert_eq!( - serde_json::to_value(PermissionLevel::ReadWrite).unwrap(), - json!("read-write") - ); - assert_eq!( - serde_json::to_value(PermissionLevel::Full).unwrap(), - json!("full") - ); -} - -#[test] -fn permission_level_deserializes_each_variant() { - let read_only: PermissionLevel = serde_json::from_value(json!("read-only")).unwrap(); - assert_eq!(read_only, PermissionLevel::ReadOnly); - let read_write: PermissionLevel = serde_json::from_value(json!("read-write")).unwrap(); - assert_eq!(read_write, PermissionLevel::ReadWrite); - let full: PermissionLevel = serde_json::from_value(json!("full")).unwrap(); - assert_eq!(full, PermissionLevel::Full); -} - -#[test] -fn permission_level_rejects_unknown_values() { - assert!(serde_json::from_value::(json!("readonly")).is_err()); - assert!(serde_json::from_value::(json!("read_only")).is_err()); - assert!(serde_json::from_value::(json!("")).is_err()); -} - -fn assert_same_type() { - assert_eq!( - TypeId::of::(), - TypeId::of::(), - "{} should be the same type as {}", - type_name::(), - type_name::() - ); -} diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index d0b0f85a4..5a42e6c78 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use clap::{Args, Parser, Subcommand, ValueEnum}; -use fabro_agent::cli::{AgentArgs, PermissionLevel}; +use fabro_agent::cli::AgentArgs; use fabro_config::{CliLayer, CliLoggingLayer, CliOutputLayer, CliUpdatesLayer}; use fabro_server::serve::DEFAULT_TCP_PORT; use fabro_static::EnvVars; @@ -759,6 +759,23 @@ pub(crate) struct SteerArgs { pub(crate) interrupt: bool, } +#[derive(Args)] +pub(crate) struct AskArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Run ID prefix or workflow name + pub(crate) run: String, + + /// Question to ask + #[arg(short = 'p', long = "prompt")] + pub(crate) prompt: String, + + /// Optional model name + #[arg(long)] + pub(crate) model: Option, +} + #[derive(Args)] pub(crate) struct WorkflowListArgs; @@ -1054,28 +1071,6 @@ pub(crate) struct ExecArgs { pub(crate) agent: AgentArgs, } -#[derive(Args)] -pub(crate) struct SessionArgs { - #[command(flatten)] - pub(crate) connection: ServerConnectionArgs, - - /// Task prompt - #[arg(short = 'p', long = "prompt")] - pub(crate) prompt: String, - - /// LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) - #[arg(long)] - pub(crate) provider: Option, - - /// Model name (defaults per provider) - #[arg(long)] - pub(crate) model: Option, - - /// Permission level for tool execution - #[arg(long = "permissions", value_name = "LEVEL", value_enum)] - pub(crate) permissions: Option, -} - #[derive(Args)] pub(crate) struct UpgradeArgs { /// Target version (e.g. "0.5.0", "v0.5.0", or "v0.177.0-alpha.1") @@ -1125,6 +1120,8 @@ pub(crate) enum RunCommands { Wait(WaitArgs), /// Steer a running agent mid-execution Steer(SteerArgs), + /// Ask Fabro a read-only question about a run + Ask(AskArgs), } impl RunCommands { @@ -1141,6 +1138,7 @@ impl RunCommands { Self::Resume(_) => "resume", Self::Rewind(_) => "rewind", Self::Steer(_) => "steer", + Self::Ask(_) => "ask", Self::Fork(_) => "fork", Self::Wait(_) => "wait", } @@ -1209,8 +1207,6 @@ pub(crate) enum Commands { /// Run an agentic coding session #[command(hide = true)] Exec(ExecArgs), - /// Run a persistent Fabro agent session - Session(SessionArgs), #[command(flatten)] RunCmd(RunCommands), /// Validate run configuration without executing @@ -1317,7 +1313,6 @@ impl Commands { }, Self::Dump(_) => "dump", Self::Exec(_) => "exec", - Self::Session(_) => "session", Self::RunCmd(cmd) => cmd.name(), Self::Preflight(_) => "preflight", Self::Validate(_) => "validate", diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index a59e86990..71d66bd08 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -21,7 +21,6 @@ pub(crate) mod runs; pub(crate) mod sandbox; pub(crate) mod secret; pub(crate) mod server; -pub(crate) mod session; pub(crate) mod system; pub(crate) mod uninstall; pub(crate) mod upgrade; diff --git a/lib/crates/fabro-cli/src/commands/run/ask.rs b/lib/crates/fabro-cli/src/commands/run/ask.rs new file mode 100644 index 000000000..1b38ad734 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/run/ask.rs @@ -0,0 +1,96 @@ +use anyhow::{Result, bail}; +use fabro_api::types::CreateRunSessionRequest; +use fabro_store::EventEnvelope; + +use crate::args::AskArgs; +use crate::command_context::CommandContext; + +pub(crate) async fn run(args: AskArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let client = ctx.server().await?; + let run_id = client.resolve_run(&args.run).await?.id; + let session = client + .create_run_session(run_id, CreateRunSessionRequest { + title: Some(session_title(&args.prompt)), + model: args.model, + }) + .await?; + let mut stream = client + .submit_session_turn_stream(session.id, args.prompt) + .await?; + + let mut terminal_error = None; + let mut saw_terminal = false; + while let Some(event) = stream.next_event().await? { + render_event(&event, ctx.json_output())?; + match event.event.event_name() { + "run.session.turn.succeeded" | "run.session.turn.interrupted" => { + saw_terminal = true; + } + "run.session.turn.failed" => { + saw_terminal = true; + terminal_error = Some( + event + .event + .properties()? + .get("error") + .and_then(serde_json::Value::as_str) + .unwrap_or("session turn failed") + .to_string(), + ); + } + _ => {} + } + } + + if let Some(error) = terminal_error { + bail!(error); + } + if !saw_terminal { + bail!("session turn ended before a terminal event was received"); + } + Ok(()) +} + +fn session_title(prompt: &str) -> String { + const MAX_CHARS: usize = 80; + let trimmed = prompt.trim(); + if trimmed.chars().count() <= MAX_CHARS { + return trimmed.to_string(); + } + let mut title = trimmed.chars().take(MAX_CHARS - 3).collect::(); + title.push_str("..."); + title +} + +#[allow( + clippy::print_stdout, + reason = "The ask command streams assistant output and JSON events to stdout." +)] +fn render_event(event: &EventEnvelope, json_output: bool) -> Result<()> { + if json_output { + println!("{}", serde_json::to_string(event)?); + return Ok(()); + } + + match event.event.event_name() { + "run.session.assistant_delta" => { + let properties = event.event.properties()?; + if let Some(delta) = properties.get("delta").and_then(serde_json::Value::as_str) { + print!("{delta}"); + } + } + "run.session.assistant_message" => { + let properties = event.event.properties()?; + if let Some(text) = properties + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|text| !text.is_empty()) + { + println!("{text}"); + } + } + _ => {} + } + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 330e56e9c..859659c35 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -8,6 +8,7 @@ use crate::shared::print_json_pretty; #[cfg(feature = "sleep_inhibitor")] use crate::sleep_inhibitor; +pub(crate) mod ask; pub(crate) mod attach; pub(crate) mod checkpoints; pub(crate) mod command; @@ -127,5 +128,6 @@ pub(crate) async fn dispatch( wait::run(&args, &styles, base_ctx).await } RunCommands::Steer(args) => steer::run(args, base_ctx).await, + RunCommands::Ask(args) => ask::run(args, base_ctx).await, } } diff --git a/lib/crates/fabro-cli/src/commands/session.rs b/lib/crates/fabro-cli/src/commands/session.rs deleted file mode 100644 index 7ffb33324..000000000 --- a/lib/crates/fabro-cli/src/commands/session.rs +++ /dev/null @@ -1,140 +0,0 @@ -use anyhow::{Result, bail}; -use fabro_api::types::CreateSessionRequest; -use fabro_types::settings::InterpString; -use fabro_types::settings::run::AgentPermissions; -use fabro_types::{PermissionLevel, SessionEventEnvelope}; - -use crate::args::SessionArgs; -use crate::command_context::CommandContext; - -pub(crate) async fn execute(args: SessionArgs, base_ctx: &CommandContext) -> Result<()> { - let ctx = base_ctx.with_connection(&args.connection)?; - let client = ctx.server().await?; - let request = CreateSessionRequest { - title: Some(session_title(&args.prompt)), - working_dir: Some(ctx.cwd().to_string_lossy().into_owned()), - provider: session_provider(&args, &ctx), - model: session_model(&args, &ctx), - permissions: session_permissions(&args, &ctx), - }; - let session = client.create_session(request).await?; - let mut stream = client - .submit_session_turn_stream(session.id, args.prompt) - .await?; - - let mut terminal_error = None; - let mut saw_terminal = false; - while let Some(event) = stream.next_event().await? { - render_event(&event, ctx.json_output())?; - match event.event.as_str() { - "turn.succeeded" | "turn.interrupted" => saw_terminal = true, - "turn.failed" => { - saw_terminal = true; - terminal_error = Some( - event - .properties - .get("error") - .and_then(serde_json::Value::as_str) - .unwrap_or("session turn failed") - .to_string(), - ); - } - _ => {} - } - } - - if let Some(error) = terminal_error { - bail!(error); - } - if !saw_terminal { - bail!("session turn ended before a terminal event was received"); - } - Ok(()) -} - -fn session_title(prompt: &str) -> String { - const MAX_CHARS: usize = 80; - let trimmed = prompt.trim(); - if trimmed.chars().count() <= MAX_CHARS { - return trimmed.to_string(); - } - let mut title = trimmed.chars().take(MAX_CHARS - 3).collect::(); - title.push_str("..."); - title -} - -fn session_provider(args: &SessionArgs, ctx: &CommandContext) -> Option { - args.provider.clone().or_else(|| { - ctx.user_settings() - .cli - .exec - .model - .provider - .as_ref() - .map(InterpString::as_source) - }) -} - -fn session_model(args: &SessionArgs, ctx: &CommandContext) -> Option { - args.model.clone().or_else(|| { - ctx.user_settings() - .cli - .exec - .model - .name - .as_ref() - .map(InterpString::as_source) - }) -} - -fn session_permissions(args: &SessionArgs, ctx: &CommandContext) -> PermissionLevel { - args.permissions - .or_else(|| { - ctx.user_settings() - .cli - .exec - .agent - .permissions - .map(|permissions| match permissions { - AgentPermissions::ReadOnly => PermissionLevel::ReadOnly, - AgentPermissions::ReadWrite => PermissionLevel::ReadWrite, - AgentPermissions::Full => PermissionLevel::Full, - }) - }) - .unwrap_or(PermissionLevel::ReadWrite) -} - -#[allow( - clippy::print_stdout, - reason = "The session command streams assistant output and JSON events to stdout." -)] -fn render_event(event: &SessionEventEnvelope, json_output: bool) -> Result<()> { - if json_output { - println!("{}", serde_json::to_string(event)?); - return Ok(()); - } - - match event.event.as_str() { - "turn.text_delta" => { - if let Some(delta) = event - .properties - .get("delta") - .and_then(serde_json::Value::as_str) - { - print!("{delta}"); - } - } - "turn.assistant_message" => { - if let Some(text) = event - .properties - .get("text") - .and_then(serde_json::Value::as_str) - .filter(|text| !text.is_empty()) - { - println!("{text}"); - } - } - _ => {} - } - Ok(()) -} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index f5b3c4f27..7cf2cfa98 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -263,7 +263,6 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) { command.as_ref(), Commands::RunCmd(RunCommands::Run(_) | RunCommands::Create(_)) | Commands::Exec(_) - | Commands::Session(_) | Commands::Repo(_) | Commands::Install { .. } ) { @@ -277,9 +276,6 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) { Commands::Exec(args) => { commands::exec::execute(args, &base_ctx).await?; } - Commands::Session(args) => { - commands::session::execute(args, &base_ctx).await?; - } Commands::RunCmd(cmd) => { Box::pin(commands::run::dispatch(cmd, &base_ctx, worker_token)).await?; } diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index fffe5378f..e38f3cfb0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -12,7 +12,6 @@ fn help() { Usage: fabro [OPTIONS] [COMMAND] Commands: - session Run a persistent Fabro agent session run Launch a workflow run create Create a workflow run (allocate run dir, persist spec) start Start a created workflow run on the server @@ -24,6 +23,7 @@ fn help() { fork Fork a workflow run from an earlier checkpoint into a new run wait Block until a workflow run completes steer Steer a running agent mid-execution + ask Ask Fabro a read-only question about a run preflight Validate run configuration without executing validate Validate a workflow graph Render a workflow graph as SVG diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index fa0545f48..79adb52e8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -58,7 +58,6 @@ mod send_panic; mod server_start; mod server_status; mod server_stop; -mod session; mod start; pub(crate) mod support; mod system; diff --git a/lib/crates/fabro-cli/tests/it/cmd/session.rs b/lib/crates/fabro-cli/tests/it/cmd/session.rs deleted file mode 100644 index 1059d584f..000000000 --- a/lib/crates/fabro-cli/tests/it/cmd/session.rs +++ /dev/null @@ -1,114 +0,0 @@ -use fabro_test::{fabro_snapshot, test_context}; -use httpmock::MockServer; - -#[test] -fn help() { - let context = test_context!(); - let mut cmd = context.command(); - cmd.args(["session", "--help"]); - fabro_snapshot!(context.filters(), cmd, @" - success: true - exit_code: 0 - ----- stdout ----- - Run a persistent Fabro agent session - - Usage: fabro session [OPTIONS] --prompt - - Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro/storage) [env: FABRO_STORAGE_DIR=] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - -p, --prompt Task prompt - --provider LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --model Model name (defaults per provider) - --verbose Enable verbose output [env: FABRO_VERBOSE=] - --permissions Permission level for tool execution [possible values: read-only, read-write, full] - -h, --help Print help - ----- stderr ----- - "); -} - -#[test] -fn prompt_creates_session_and_streams_turn_events() { - let context = test_context!(); - let server = MockServer::start(); - let session_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; - let turn_id = "01BX5ZZKBKACTAV9WEVGEMMVRZ"; - - let create = server.mock(|when, then| { - when.method("POST").path("/api/v1/sessions"); - then.status(201) - .header("Content-Type", "application/json") - .json_body(serde_json::json!({ - "id": session_id, - "title": "say hello", - "status": "idle", - "working_dir": context.temp_dir.to_string_lossy(), - "provider": "openai", - "model": "gpt-5.4-mini", - "permissions": "read-write", - "created_at": "2026-04-05T12:00:00Z", - "updated_at": "2026-04-05T12:00:00Z", - "deleted_at": null, - "runtime_context": [] - })); - }); - let submit = server.mock(|when, then| { - when.method("POST") - .path(format!("/api/v1/sessions/{session_id}/turns")); - then.status(200) - .header("Content-Type", "text/event-stream") - .body(format!( - "data: {}\n\ndata: {}\n\n", - serde_json::json!({ - "seq": 1, - "session_id": session_id, - "turn_id": turn_id, - "event": "turn.assistant_message", - "properties": {"text": "Hello from server"}, - "ts": "2026-04-05T12:00:01Z" - }), - serde_json::json!({ - "seq": 2, - "session_id": session_id, - "turn_id": turn_id, - "event": "turn.succeeded", - "properties": {"turn_id": turn_id}, - "ts": "2026-04-05T12:00:02Z" - }) - )); - }); - - let output = context - .command() - .args([ - "session", - "--server", - &format!("{}/api/v1", server.base_url()), - "--provider", - "openai", - "--model", - "gpt-5.4-mini", - "-p", - "say hello", - ]) - .output() - .expect("session command should execute"); - - create.assert(); - submit.assert(); - assert!( - output.status.success(), - "command failed:\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert_eq!( - String::from_utf8_lossy(&output.stdout), - "Hello from server\n" - ); - assert_eq!(String::from_utf8_lossy(&output.stderr), ""); -} diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 4a633dc97..48245a409 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -15,8 +15,8 @@ use fabro_types::settings::run::MergeStrategy; use fabro_types::{ ArtifactUpload, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, PairTargetSelector, PairTranscriptResponse, Run, RunBlobId, RunEvent, - RunEventDetailResponse, RunId, RunPairStatusResponse, RunProjection, SessionEventEnvelope, - SessionId, SessionRecord, StageId, + RunEventDetailResponse, RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, + StageId, }; use fabro_util::exit::{ErrorExt, ExitClass}; use futures::future::BoxFuture; @@ -53,7 +53,7 @@ type HttpByteStream = Pin> + Send>>; pub struct SessionEventStream { stream: HttpByteStream, pending_bytes: Vec, - buffered_events: VecDeque, + buffered_events: VecDeque, } pub struct RewindRunResult { @@ -186,7 +186,7 @@ impl SessionEventStream { } } - pub async fn next_event(&mut self) -> Result> { + pub async fn next_event(&mut self) -> Result> { loop { if let Some(event) = self.buffered_events.pop_front() { return Ok(Some(event)); @@ -613,11 +613,23 @@ impl Client { .context("server returned invalid JSON for server settings") } - pub async fn create_session(&self, body: types::CreateSessionRequest) -> Result { + pub async fn create_run_session( + &self, + run_id: RunId, + body: types::CreateRunSessionRequest, + ) -> Result { let response = self - .send_api( - |client| async move { client.create_session().body(body.clone()).send().await }, - ) + .send_api(|client| { + let body = body.clone(); + async move { + client + .create_run_session() + .id(run_id.to_string()) + .body(body) + .send() + .await + } + }) .await?; Ok(response.into_inner()) } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index f16f7f1d1..561f4c2db 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -72,7 +72,7 @@ use fabro_slack::{blocks as slack_blocks, connection as slack_connection}; use fabro_static::EnvVars; use fabro_store::{ ArtifactKey, ArtifactStore, Database, EventEnvelope, EventPayload, NodeArtifact, - PendingInterviewRecord, SessionStore, StageArtifactEntry, StageId, + PendingInterviewRecord, StageArtifactEntry, StageId, }; #[cfg(test)] use fabro_types::BlockedReason; @@ -595,7 +595,6 @@ pub struct AppState { runs: Mutex>, aggregate_billing: Mutex, store: Arc, - session_store: SessionStore, session_runtimes: SessionRuntimeManager, artifact_store: ArtifactStore, worker_tokens: WorkerTokenKeys, @@ -850,10 +849,6 @@ impl AppState { &self.store } - pub(crate) fn session_store(&self) -> &SessionStore { - &self.session_store - } - pub(crate) fn session_runtimes(&self) -> &SessionRuntimeManager { &self.session_runtimes } @@ -1678,15 +1673,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result>; pub(super) fn routes() -> Router> { Router::new() - .route("/sessions", get(list_sessions).post(create_session)) + .route( + "/runs/{run_id}/sessions", + get(list_run_sessions).post(create_run_session), + ) .route( "/sessions/{id}", - get(get_session) - .patch(update_session) - .delete(delete_session), + get(get_session).fallback(session_method_not_found), + ) + .route( + "/sessions/{id}/turns", + post(submit_turn).fallback(session_method_not_found), ) - .route("/sessions/{id}/turns", get(list_turns).post(submit_turn)) - .route("/sessions/{id}/turns/{turnId}", get(get_turn)) .route( "/sessions/{id}/turns/{turnId}/interrupt", post(interrupt_turn), ) - .route("/sessions/{id}/events", get(list_events)) } -#[derive(Debug, serde::Deserialize)] -struct CreateSessionRequest { - #[serde(default)] - title: Option, - #[serde(default)] - working_dir: Option, - #[serde(default)] - provider: Option, - #[serde(default)] - model: Option, - permissions: PermissionLevel, -} - -#[derive(Debug, serde::Deserialize)] -struct UpdateSessionRequest { - #[serde(default)] - title: Option, -} - -#[derive(Debug, serde::Deserialize)] -struct SubmitTurnRequest { - input: String, -} - -#[derive(Debug, serde::Deserialize)] -struct EventQuery { - #[serde(default)] - since_seq: Option, -} - -async fn list_sessions(_auth: RequiredUser, State(state): State>) -> Response { - match state.session_store().list_sessions().await { - Ok(sessions) => Json(ListResponse::new(sessions)).into_response(), - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() +async fn list_run_sessions( + _auth: RequiredUser, + State(state): State>, + Path(run_id): Path, +) -> Response { + let run_id = match parse_run_id(&run_id) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + let run_store = match open_run_reader(&state, run_id).await { + Ok(store) => store, + Err(response) => return response, + }; + match run_store.list_events().await { + Ok(events) => { + Json(ListResponse::new(project_run_sessions(run_id, &events))).into_response() } + Err(err) => store_error(&err).into_response(), } } -async fn create_session( +async fn create_run_session( _auth: RequiredUser, State(state): State>, - Json(request): Json, + Path(run_id): Path, + Json(request): Json, ) -> Response { - let now = Utc::now(); - let session_id = SessionId::new(); - let mut record = SessionRecord::new(session_id, now); - record.title = request.title; - record.working_dir = request.working_dir; - record.provider = request.provider; - record.model = request.model; - record.permissions = request.permissions; + let run_id = match parse_run_id(&run_id) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + let run_store = match open_run(&state, run_id).await { + Ok(store) => store, + Err(response) => return response, + }; + let model = match canonical_session_model(state.catalog().as_ref(), request.model.as_deref()) { + Ok(model) => model, + Err(err) => return err.into_response(), + }; - match state.session_store().create_session(record).await { - Ok(record) => { - let _ = append_session_event( - state.as_ref(), - SessionEventEnvelope::new( - session_id, - None, - "session.created", - json!({ "title": record.title }), - now, - ), - ) - .await; - (StatusCode::CREATED, Json(record)).into_response() - } - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() - } + let session_id = SessionId::new(); + let now = Utc::now(); + if let Err(err) = state + .store_ref() + .put_session_run_index(&session_id, &run_id) + .await + { + return store_error(&err).into_response(); + } + + let event = match append_run_session_event( + &run_store, + run_id, + session_id, + EventBody::RunSessionCreated(RunSessionCreatedProps { + title: request.title, + model, + }), + now, + ) + .await + { + Ok(event) => event, + Err(err) => return store_error(&err).into_response(), + }; + + let events = vec![event]; + match project_run_session(run_id, session_id, &events) { + Some(record) => (StatusCode::CREATED, Json(record)).into_response(), + None => ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "Session event projection failed.", + ) + .into_response(), } } @@ -138,66 +153,15 @@ async fn get_session( Ok(id) => id, Err(err) => return err.into_response(), }; - match state.session_store().get_session(session_id).await { - Ok(Some(record)) => Json(record).into_response(), - Ok(None) => ApiError::not_found("Session not found.").into_response(), - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() - } - } + let (_, session) = match load_session_read(&state, session_id).await { + Ok(context) => context, + Err(response) => return response, + }; + Json(session).into_response() } -async fn update_session( - _auth: RequiredUser, - State(state): State>, - Path(id): Path, - Json(request): Json, -) -> Response { - let session_id = match parse_session_id(&id) { - Ok(id) => id, - Err(err) => return err.into_response(), - }; - let mut record = match state.session_store().get_session(session_id).await { - Ok(Some(record)) => record, - Ok(None) => return ApiError::not_found("Session not found.").into_response(), - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - }; - if let Some(title) = request.title { - record.title = Some(title); - record.updated_at = Utc::now(); - } - match state.session_store().update_session(record).await { - Ok(record) => Json(record).into_response(), - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() - } - } -} - -async fn delete_session( - _auth: RequiredUser, - State(state): State>, - Path(id): Path, -) -> Response { - let session_id = match parse_session_id(&id) { - Ok(id) => id, - Err(err) => return err.into_response(), - }; - if state.session_runtimes().has_active_turn(session_id) { - return ApiError::new(StatusCode::CONFLICT, "Session has an active turn.").into_response(); - } - match state.session_store().delete_session(session_id).await { - Ok(()) => { - state.session_runtimes().unload_idle(session_id).await; - StatusCode::NO_CONTENT.into_response() - } - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() - } - } +async fn session_method_not_found() -> Response { + StatusCode::NOT_FOUND.into_response() } async fn submit_turn( @@ -210,15 +174,11 @@ async fn submit_turn( Ok(id) => id, Err(err) => return err.into_response(), }; - let Some(session_record) = (match state.session_store().get_session(session_id).await { - Ok(session) => session, - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - }) else { - return ApiError::not_found("Session not found.").into_response(); + let (run_id, run_store, session) = match load_session(&state, session_id).await { + Ok(context) => context, + Err(response) => return response, }; + let input = request.input; let turn_id = TurnId::new(); let turn_lease = match state.session_runtimes().reserve_turn(session_id, turn_id) { @@ -228,82 +188,36 @@ async fn submit_turn( .into_response(); } }; - let now = Utc::now(); - let turn = TurnRecord { - id: turn_id, - session_id, - input: request.input.clone(), - status: TurnStatus::Running, - output: None, - error: None, - created_at: now, - updated_at: now, - completed_at: None, - }; - if let Err(err) = state.session_store().append_turn(turn).await { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); - } + let (sender, receiver) = mpsc::channel(SESSION_SSE_BUFFER_CAPACITY); + let now = Utc::now(); + for body in [ + EventBody::RunSessionTurnStarted(RunSessionTurnStartedProps { + turn_id, + input: input.clone(), + }), + EventBody::RunSessionUserMessage(RunSessionUserMessageProps { + turn_id, + text: input.clone(), + }), + ] { + match append_and_send_event(&run_store, &sender, run_id, session_id, body, now).await { + Ok(()) => {} + Err(err) => { + drop(turn_lease); + return store_error(&err).into_response(); + } + } + } + tokio::spawn(run_streaming_turn( - state, - session_record, - turn_id, - request.input, - sender, - turn_lease, + state, run_id, run_store, session, turn_id, input, sender, turn_lease, )); Sse::new(ReceiverStream::new(receiver)) .keep_alive(KeepAlive::default()) .into_response() } -async fn list_turns( - _auth: RequiredUser, - State(state): State>, - Path(id): Path, -) -> Response { - let session_id = match parse_session_id(&id) { - Ok(id) => id, - Err(err) => return err.into_response(), - }; - match state.session_store().get_session(session_id).await { - Ok(Some(_)) => {} - Ok(None) => return ApiError::not_found("Session not found.").into_response(), - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - } - match state.session_store().list_turns(session_id).await { - Ok(turns) => Json(ListResponse::new(turns)).into_response(), - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() - } - } -} - -async fn get_turn( - _auth: RequiredUser, - State(state): State>, - Path((id, turn_id)): Path<(String, String)>, -) -> Response { - let session_id = match parse_session_id(&id) { - Ok(id) => id, - Err(err) => return err.into_response(), - }; - let turn_id = match parse_turn_id(&turn_id) { - Ok(id) => id, - Err(err) => return err.into_response(), - }; - match state.session_store().get_turn(session_id, turn_id).await { - Ok(Some(turn)) => Json(turn).into_response(), - Ok(None) => ApiError::not_found("Turn not found.").into_response(), - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() - } - } -} - async fn interrupt_turn( _auth: RequiredUser, State(state): State>, @@ -317,44 +231,29 @@ async fn interrupt_turn( Ok(id) => id, Err(err) => return err.into_response(), }; - match state.session_store().get_turn(session_id, turn_id).await { - Ok(Some(turn)) => { - if matches!( - turn.status, - TurnStatus::Succeeded | TurnStatus::Failed | TurnStatus::Interrupted - ) { - return ApiError::new(StatusCode::CONFLICT, "Turn is already terminal.") - .into_response(); - } - } - Ok(None) => return ApiError::not_found("Turn not found.").into_response(), - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - } + let (run_id, run_store, _) = match load_session(&state, session_id).await { + Ok(context) => context, + Err(response) => return response, + }; let pending_interrupt = match state .session_runtimes() .request_interrupt(session_id, turn_id) { Ok(pending_interrupt) => pending_interrupt, - Err(err) => match err { - InterruptTurnError::NotActive => { - return ApiError::new(StatusCode::CONFLICT, "Turn is not active for this session.") - .into_response(); - } - }, + Err(InterruptTurnError::NotActive) => { + return ApiError::new(StatusCode::CONFLICT, "Turn is not active for this session.") + .into_response(); + } }; - let now = Utc::now(); - match append_session_event( - state.as_ref(), - SessionEventEnvelope::new( - session_id, - Some(turn_id), - "turn.interrupt_requested", - json!({ "turn_id": turn_id }), - now, - ), + match append_run_session_event( + &run_store, + run_id, + session_id, + EventBody::RunSessionTurnInterrupted(RunSessionTurnInterruptedProps { + turn_id, + error: Some("Interrupted.".to_string()), + }), + Utc::now(), ) .await { @@ -364,203 +263,61 @@ async fn interrupt_turn( } Err(err) => { drop(pending_interrupt); - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + store_error(&err).into_response() } } } -async fn list_events( - _auth: RequiredUser, - State(state): State>, - Path(id): Path, - Query(query): Query, - headers: HeaderMap, -) -> Response { - let session_id = match parse_session_id(&id) { - Ok(id) => id, - Err(err) => return err.into_response(), - }; - match state.session_store().get_session(session_id).await { - Ok(Some(_)) => {} - Ok(None) => return ApiError::not_found("Session not found.").into_response(), - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - } - let since_seq = session_event_since_seq(&query, &headers); - if wants_session_event_stream(&headers) { - return stream_events(state, session_id, since_seq).await; - } - match state - .session_store() - .list_events(session_id, Some(since_seq)) - .await - { - Ok(events) => Json(ListResponse::new(events)).into_response(), - Err(err) => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() - } - } -} - -fn session_event_since_seq(query: &EventQuery, headers: &HeaderMap) -> u32 { - query - .since_seq - .or_else(|| { - headers - .get("last-event-id") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .map(|seq| seq.saturating_add(1)) - }) - .unwrap_or(1) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SessionEventResponse { - Json, - Stream, -} - -fn wants_session_event_stream(headers: &HeaderMap) -> bool { - headers - .get(header::ACCEPT) - .and_then(|value| value.to_str().ok()) - .and_then(preferred_session_event_response) - == Some(SessionEventResponse::Stream) -} - -fn preferred_session_event_response(accept: &str) -> Option { - accept.split(',').find_map(|part| { - let media_type = part.trim().split(';').next().unwrap_or_default().trim(); - match media_type { - "text/event-stream" => Some(SessionEventResponse::Stream), - "application/json" | "application/*" | "*/*" => Some(SessionEventResponse::Json), - _ => None, - } - }) -} - -async fn stream_events(state: Arc, session_id: SessionId, since_seq: u32) -> Response { - let mut live_rx = state.session_runtimes().subscribe_events(session_id); - let persisted = match state - .session_store() - .list_events(session_id, Some(since_seq)) - .await - { - Ok(events) => events, - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - }; - - let (sender, receiver) = mpsc::channel(SESSION_SSE_BUFFER_CAPACITY); - let stream_state = Arc::clone(&state); - tokio::spawn(async move { - let mut last_seq = since_seq.saturating_sub(1); - for event in persisted { - last_seq = last_seq.max(event.seq); - if !send_sse_event(&sender, &event).await { - return; - } - } - - loop { - let event = tokio::select! { - () = sender.closed() => break, - event = live_rx.recv() => event, - }; - match event { - Ok(event) => { - if event.seq > last_seq { - last_seq = event.seq; - if !send_sse_event(&sender, &event).await { - break; - } - } - } - Err(RecvError::Lagged(_)) => { - match stream_state - .session_store() - .list_events(session_id, Some(last_seq.saturating_add(1))) - .await - { - Ok(events) => { - for event in events { - if event.seq > last_seq { - last_seq = event.seq; - if !send_sse_event(&sender, &event).await { - return; - } - } - } - } - Err(err) => { - error!( - error = ?err, - session_id = %session_id, - "Failed to replay lagged session events" - ); - break; - } - } - } - Err(RecvError::Closed) => break, - } - } - }); - - Sse::new(ReceiverStream::new(receiver)) - .keep_alive(KeepAlive::default()) - .into_response() -} - async fn run_streaming_turn( state: Arc, - mut record: SessionRecord, + run_id: RunId, + run_store: RunDatabase, + session: ProjectedRunSession, turn_id: TurnId, input: String, sender: SessionSseSender, turn_lease: SessionTurnLease, ) { - let runtime_entry = turn_lease.entry(); - if let Err(err) = mark_turn_running(&state, &mut record, turn_id, &sender).await { - error!(error = ?err, session_id = %record.id, turn_id = %turn_id, "Failed to mark session turn running"); - mark_turn_failed(&state, &mut record, turn_id, err.to_string(), &sender).await; - return; - } - + let session_id = session.record.id; if turn_lease.interrupt_requested() { - record.status = SessionStatus::Idle; - if let Err(err) = mark_turn_finished( - &state, - record, - turn_id, - TurnStatus::Interrupted, - None, - Some("Interrupted.".to_string()), + let _ = append_and_send_event( + &run_store, &sender, + run_id, + session_id, + EventBody::RunSessionTurnInterrupted(RunSessionTurnInterruptedProps { + turn_id, + error: Some("Interrupted.".to_string()), + }), + Utc::now(), ) - .await - { - error!(error = ?err, turn_id = %turn_id, "Failed to persist interrupted session turn"); - } + .await; return; } let outcome = { + let runtime_entry = turn_lease.entry(); let mut session_slot = runtime_entry.lock_session().await; if session_slot.is_none() { - match build_agent_session(&state, &record).await { - Ok(session) => { - *session_slot = Some(session); + match build_agent_session(&state, run_id, &session).await { + Ok(agent_session) => { + *session_slot = Some(agent_session); } Err(err) => { - error!(error = ?err, session_id = %record.id, turn_id = %turn_id, "Failed to build session runtime"); - drop(session_slot); - mark_turn_failed(&state, &mut record, turn_id, err.to_string(), &sender).await; + error!(error = ?err, session_id = %session_id, turn_id = %turn_id, "Failed to build run-backed session runtime"); + let _ = append_and_send_event( + &run_store, + &sender, + run_id, + session_id, + EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps { + turn_id, + error: err.to_string(), + output: None, + }), + Utc::now(), + ) + .await; return; } } @@ -572,226 +329,87 @@ async fn run_streaming_turn( turn_lease.attach_cancel_token(&cancel_token); let initialize = !runtime_entry.is_initialized(); let mut output = None; - let result = drive_agent_session( - &state, + let result = Box::pin(drive_agent_session( + &run_store, session, - record.id, + run_id, + session_id, turn_id, &input, initialize, &sender, &mut output, - ) + )) .await; if initialize && matches!(result, Ok(Ok(()))) { runtime_entry.mark_initialized(); } - let final_record = session.to_record(record); - TurnExecutionOutcome { - result, - final_record, - output, - } + TurnExecutionOutcome { result, output } }; - let should_unload_runtime = !matches!(&outcome.result, Ok(Ok(()))); match outcome.result { Ok(Ok(())) => { - let mut final_record = outcome.final_record; - final_record.status = SessionStatus::Idle; - if let Err(err) = mark_turn_finished( - &state, - final_record, - turn_id, - TurnStatus::Succeeded, - outcome.output, - None, + let _ = append_and_send_event( + &run_store, &sender, + run_id, + session_id, + EventBody::RunSessionTurnSucceeded(RunSessionTurnSucceededProps { + turn_id, + output: outcome.output, + }), + Utc::now(), ) - .await - { - error!(error = ?err, turn_id = %turn_id, "Failed to persist successful session turn"); - } + .await; } Ok(Err(err)) => { - let status = if matches!(err, AgentError::Interrupted(_)) { - TurnStatus::Interrupted + turn_lease.entry().clear_session().await; + let body = if matches!(err, AgentError::Interrupted(_)) { + EventBody::RunSessionTurnInterrupted(RunSessionTurnInterruptedProps { + turn_id, + error: Some(err.to_string()), + }) } else { - TurnStatus::Failed + EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps { + turn_id, + error: err.to_string(), + output: outcome.output, + }) }; - let mut final_record = outcome.final_record; - final_record.status = if status == TurnStatus::Interrupted { - SessionStatus::Idle - } else { - SessionStatus::Failed - }; - if let Err(update_err) = mark_turn_finished( - &state, - final_record, - turn_id, - status, - outcome.output, - Some(err.to_string()), - &sender, - ) - .await - { - error!(error = ?update_err, turn_id = %turn_id, "Failed to persist failed session turn"); - } + let _ = + append_and_send_event(&run_store, &sender, run_id, session_id, body, Utc::now()) + .await; } Err(err) => { - let mut final_record = outcome.final_record; - final_record.status = SessionStatus::Failed; - if let Err(update_err) = mark_turn_finished( - &state, - final_record, - turn_id, - TurnStatus::Failed, - outcome.output, - Some(err.to_string()), + turn_lease.entry().clear_session().await; + let _ = append_and_send_event( + &run_store, &sender, + run_id, + session_id, + EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps { + turn_id, + error: err.to_string(), + output: outcome.output, + }), + Utc::now(), ) - .await - { - error!(error = ?update_err, turn_id = %turn_id, "Failed to persist errored session turn"); - } + .await; } } - if should_unload_runtime { - runtime_entry.clear_session().await; - } } struct TurnExecutionOutcome { - result: anyhow::Result>, - final_record: SessionRecord, - output: Option, -} - -fn record_turn_output(output: &mut Option, event: &SessionEvent) { - if let AgentEvent::AssistantMessage { text, .. } = &event.event { - *output = Some(text.clone()); - } -} - -async fn mark_turn_running( - state: &AppState, - record: &mut SessionRecord, - turn_id: TurnId, - sender: &SessionSseSender, -) -> anyhow::Result<()> { - let now = Utc::now(); - record.status = SessionStatus::Running; - record.updated_at = now; - state.session_store().update_session(record.clone()).await?; - - if let Some(mut turn) = state.session_store().get_turn(record.id, turn_id).await? { - turn.status = TurnStatus::Running; - turn.updated_at = now; - state.session_store().update_turn(turn).await?; - } - - append_and_send_event( - state, - sender, - SessionEventEnvelope::new( - record.id, - Some(turn_id), - "turn.running", - json!({ "turn_id": turn_id }), - now, - ), - ) - .await?; - Ok(()) -} - -async fn mark_turn_failed( - state: &AppState, - record: &mut SessionRecord, - turn_id: TurnId, - error: String, - sender: &SessionSseSender, -) { - record.status = SessionStatus::Failed; - let _ = mark_turn_finished( - state, - record.clone(), - turn_id, - TurnStatus::Failed, - None, - Some(error), - sender, - ) - .await; -} - -async fn mark_turn_finished( - state: &AppState, - mut record: SessionRecord, - turn_id: TurnId, - status: TurnStatus, + result: anyhow::Result>, output: Option, - error: Option, - sender: &SessionSseSender, -) -> anyhow::Result<()> { - let now = Utc::now(); - record.updated_at = now; - state.session_store().update_session(record.clone()).await?; - - if let Some(mut turn) = state.session_store().get_turn(record.id, turn_id).await? { - turn.status = status; - turn.output = output; - turn.error = error.clone(); - turn.updated_at = now; - turn.completed_at = Some(now); - state.session_store().update_turn(turn).await?; - } - - let event_name = match status { - TurnStatus::Succeeded => "turn.succeeded", - TurnStatus::Interrupted => "turn.interrupted", - TurnStatus::Failed => "turn.failed", - TurnStatus::Running => "turn.running", - }; - let mut properties = json!({ "turn_id": turn_id }); - if let Some(error) = error { - properties["error"] = serde_json::Value::String(error); - } - append_and_send_event( - state, - sender, - SessionEventEnvelope::new(record.id, Some(turn_id), event_name, properties, now), - ) - .await?; - Ok(()) } -async fn build_agent_session(state: &AppState, record: &SessionRecord) -> anyhow::Result { +async fn build_agent_session( + state: &AppState, + run_id: RunId, + session: &ProjectedRunSession, +) -> anyhow::Result { let catalog = state.catalog(); - let requested_provider_id = record - .provider - .as_deref() - .map_or_else(ProviderId::anthropic, ProviderId::from); - let (provider_id, profile_kind) = { - let provider = catalog.provider(&requested_provider_id).ok_or_else(|| { - anyhow::anyhow!("provider '{requested_provider_id}' is not configured") - })?; - (provider.id.clone(), provider.agent_profile) - }; - let model = record - .model - .clone() - .or_else(|| { - catalog - .default_for_provider(&provider_id) - .map(|model| model.id.clone()) - }) - .ok_or_else(|| { - anyhow::anyhow!( - "provider '{provider_id}' has no default model in the catalog; pass --model explicitly" - ) - })?; let llm_result = state.resolve_llm_client().await?; for (provider, issue) in &llm_result.auth_issues { warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue"); @@ -799,14 +417,25 @@ async fn build_agent_session(state: &AppState, record: &SessionRecord) -> anyhow for issue in &llm_result.registration_issues { warn!(provider = %issue.provider, error = %issue.error, "LLM provider unavailable due to registration issue"); } + let (provider_id, model, profile_kind) = + selected_session_model(&catalog, &llm_result, session)?; if !llm_result.client.has_provider(provider_id.as_str()) { anyhow::bail!("LLM credentials not configured for provider '{provider_id}'"); } - let working_dir = resolve_working_dir(record).await?; - let sandbox: Arc = Arc::new(ReadBeforeWriteSandbox::new(Arc::new( - LocalSandbox::new(working_dir.clone()), - ))); + let run_store = state.store_ref().open_run_reader(&run_id).await?; + let projection = run_store.state().await?; + let sandbox_record = projection + .sandbox + .as_ref() + .ok_or_else(|| anyhow::anyhow!("run has no sandbox available for Ask Fabro"))?; + let sandbox = reconnect_for_run( + sandbox_record, + state.vault_or_env("DAYTONA_API_KEY"), + Some(run_id), + ) + .await?; + let sandbox: Arc = Arc::from(sandbox); let profile = build_profile( provider_id, profile_kind, @@ -815,38 +444,119 @@ async fn build_agent_session(state: &AppState, record: &SessionRecord) -> anyhow Arc::clone(&catalog), ); let config = SessionOptions { - git_root: Some(working_dir.to_string_lossy().into_owned()), - tool_hooks: Some(Arc::new(ToolApprovalAdapter(build_tool_approval( - record.permissions, - )))), + tool_hooks: Some(Arc::new(ToolApprovalAdapter( + build_ask_fabro_tool_approval(), + ))), ..SessionOptions::default() }; - Session::from_record(record, llm_result.client, profile, sandbox, config, None) - .map_err(Into::into) + Session::from_record( + &session.record, + &session.runtime_context, + llm_result.client, + profile, + sandbox, + config, + None, + ) + .map_err(Into::into) } -async fn resolve_working_dir(record: &SessionRecord) -> anyhow::Result { - let working_dir = match &record.working_dir { - Some(working_dir) => PathBuf::from(working_dir), - None => std::env::current_dir()?, +fn selected_session_model( + catalog: &Catalog, + llm_result: &LlmClientResult, + session: &ProjectedRunSession, +) -> anyhow::Result<(ProviderId, String, AgentProfileKind)> { + let configured_provider_ids = llm_result.provider_ids(); + let selected = match session.record.model.as_deref() { + Some(model_id) => catalog + .get(model_id) + .ok_or_else(|| anyhow::anyhow!("session model '{model_id}' is not in the catalog"))?, + None => catalog.default_for_configured_ids(&configured_provider_ids), }; - if !working_dir.is_absolute() { - anyhow::bail!("working_dir must be an absolute path for v1 sessions"); + let provider_id = selected.provider.clone(); + let model = selected.id.clone(); + let profile_kind = catalog + .effective_agent_profile(&provider_id, Some(&model)) + .ok_or_else(|| anyhow::anyhow!("provider '{provider_id}' is not configured"))?; + Ok((provider_id, model, profile_kind)) +} + +fn canonical_session_model( + catalog: &Catalog, + requested: Option<&str>, +) -> Result, ApiError> { + let Some(requested) = requested else { + return Ok(None); + }; + let requested = requested.trim(); + if requested.is_empty() { + return Err(ApiError::bad_request("Session model must not be empty.")); } - let metadata = fs::metadata(&working_dir).await.map_err(|err| { - anyhow::anyhow!( - "working_dir is only supported for local same-machine server targets; server cannot access {}: {err}", - working_dir.display() - ) + let model_ref = requested + .parse::() + .map_err(|err| ApiError::bad_request(err.to_string()))?; + let registry = CatalogModelRegistry { catalog }; + match model_ref + .resolve(®istry) + .map_err(|err| ApiError::bad_request(err.to_string()))? + { + ResolvedModelRef::Provider(provider) => Err(ApiError::bad_request(format!( + "Session model reference '{provider}' names a provider; include a model ID." + ))), + ResolvedModelRef::Model { + provider: Some(provider), + model, + } => resolve_provider_qualified_session_model(catalog, &provider, &model).map(Some), + ResolvedModelRef::Model { + provider: None, + model, + } => catalog + .get(&model) + .map(|model| Some(model.id.clone())) + .ok_or_else(|| ApiError::bad_request(format!("Unknown session model '{model}'."))), + } +} + +fn resolve_provider_qualified_session_model( + catalog: &Catalog, + provider_ref: &str, + model_ref: &str, +) -> Result { + let provider_id = ProviderId::new(provider_ref); + let provider = catalog.provider(&provider_id).ok_or_else(|| { + ApiError::bad_request(format!("Unknown session model provider '{provider_ref}'.")) })?; - if !metadata.is_dir() { - anyhow::bail!( - "working_dir is only supported for local same-machine server targets; {} is not a directory", - working_dir.display() - ); + let model = catalog + .get(model_ref) + .ok_or_else(|| ApiError::bad_request(format!("Unknown session model '{model_ref}'.")))?; + if model.provider != provider.id { + return Err(ApiError::bad_request(format!( + "Session model '{model_ref}' belongs to provider '{}', not '{}'.", + model.provider, provider.id + ))); + } + Ok(model.id.clone()) +} + +struct CatalogModelRegistry<'a> { + catalog: &'a Catalog, +} + +impl ModelRegistry for CatalogModelRegistry<'_> { + fn is_provider(&self, token: &str) -> bool { + self.catalog.provider(&ProviderId::new(token)).is_some() + } + + fn is_model(&self, token: &str) -> bool { + self.catalog.get(token).is_some() + } + + fn provider_of(&self, token: &str) -> Option { + self.catalog + .get(token) + .map(|model| model.provider.to_string()) } - Ok(working_dir) } fn build_profile( @@ -902,39 +612,22 @@ fn summarizer_model_id( } } -fn build_tool_approval(level: PermissionLevel) -> ToolApprovalFn { - Arc::new(move |tool_name: &str, _args: &serde_json::Value| { - if is_auto_approved(level, tool_category(tool_name)) { +fn build_ask_fabro_tool_approval() -> ToolApprovalFn { + Arc::new(move |tool_name: &str, _args: &Value| { + if is_tool_auto_approved(PermissionLevel::ReadOnly, tool_name) { Ok(()) } else { Err(format!( - "{tool_name} tool denied at current permission level" + "{tool_name} tool denied by Ask Fabro read-only policy" )) } }) } -fn tool_category(name: &str) -> &'static str { - match name { - "read_file" | "read_many_files" | "grep" | "glob" | "list_dir" => "read", - "write_file" | "edit_file" | "apply_patch" => "write", - "spawn_agent" | "send_input" | "wait" | "close_agent" => "subagent", - _ => "shell", - } -} - -fn is_auto_approved(level: PermissionLevel, category: &str) -> bool { - matches!( - (level, category), - (_, "read" | "subagent") - | (PermissionLevel::ReadWrite | PermissionLevel::Full, "write") - | (PermissionLevel::Full, "shell") - ) -} - async fn drive_agent_session( - state: &AppState, + run_store: &RunDatabase, session: &mut Session, + run_id: RunId, session_id: SessionId, turn_id: TurnId, input: &str, @@ -956,7 +649,7 @@ async fn drive_agent_session( result = &mut process => { while let Ok(event) = receiver.try_recv() { record_turn_output(output, &event); - persist_agent_event(state, session_id, turn_id, event, sender).await?; + persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?; } return Ok(result); } @@ -964,7 +657,7 @@ async fn drive_agent_session( match event { Ok(event) => { record_turn_output(output, &event); - persist_agent_event(state, session_id, turn_id, event, sender).await?; + persist_agent_event(run_store, run_id, session_id, turn_id, event, sender).await?; } Err(RecvError::Lagged(_) | RecvError::Closed) => {} } @@ -973,219 +666,203 @@ async fn drive_agent_session( } } +fn record_turn_output(output: &mut Option, event: &SessionEvent) { + if let AgentEvent::AssistantMessage { text, .. } = &event.event { + *output = Some(text.clone()); + } +} + async fn persist_agent_event( - state: &AppState, + run_store: &RunDatabase, + run_id: RunId, session_id: SessionId, turn_id: TurnId, event: SessionEvent, sender: &SessionSseSender, ) -> anyhow::Result<()> { - append_and_send_event( - state, - sender, - agent_event_envelope(session_id, turn_id, event), - ) - .await + let ts = event.timestamp.into(); + let Some(body) = agent_event_payload(turn_id, event.event) else { + return Ok(()); + }; + append_and_send_event(run_store, sender, run_id, session_id, body, ts) + .await + .map_err(Into::into) +} + +fn agent_event_payload(event_turn_id: TurnId, event: AgentEvent) -> Option { + match event { + AgentEvent::AssistantMessage { + text, model, usage, .. + } => Some(EventBody::RunSessionAssistantMessage( + RunSessionAssistantMessageProps { + turn_id: event_turn_id, + text, + model: Some(model.model_id), + usage: serde_json::to_value(usage).unwrap_or(Value::Null), + }, + )), + AgentEvent::TextDelta { delta } | AgentEvent::ReasoningDelta { delta } => Some( + EventBody::RunSessionAssistantDelta(RunSessionAssistantDeltaProps { + turn_id: event_turn_id, + delta, + }), + ), + AgentEvent::ToolCallStarted { + tool_name, + tool_call_id, + arguments, + } => Some(EventBody::RunSessionToolCallStarted( + RunSessionToolCallStartedProps { + turn_id: event_turn_id, + tool_name, + tool_call_id, + arguments, + }, + )), + AgentEvent::ToolCallCompleted { + tool_name, + tool_call_id, + output, + is_error, + } => Some(EventBody::RunSessionToolCallCompleted( + RunSessionToolCallCompletedProps { + turn_id: event_turn_id, + tool_name, + tool_call_id, + output, + is_error, + }, + )), + _ => None, + } } async fn append_and_send_event( - state: &AppState, + run_store: &RunDatabase, sender: &SessionSseSender, - event: SessionEventEnvelope, -) -> anyhow::Result<()> { - let event = append_session_event(state, event).await?; + run_id: RunId, + session_id: SessionId, + body: EventBody, + ts: DateTime, +) -> fabro_store::Result<()> { + let event = append_run_session_event(run_store, run_id, session_id, body, ts).await?; send_sse_event(sender, &event).await; Ok(()) } -async fn append_session_event( - state: &AppState, - event: SessionEventEnvelope, -) -> anyhow::Result { - let event = state.session_store().append_event(event).await?; - state.session_runtimes().broadcast_event(&event); - Ok(event) +async fn append_run_session_event( + run_store: &RunDatabase, + run_id: RunId, + session_id: SessionId, + body: EventBody, + ts: DateTime, +) -> fabro_store::Result { + let event = RunEvent { + id: format!("evt_{}", ulid::Ulid::new()), + ts, + run_id, + node_id: None, + node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, + session_id: Some(session_id.to_string()), + parent_session_id: None, + tool_call_id: None, + actor: None, + body, + }; + let payload = EventPayload::new(event.to_value()?, &run_id)?; + run_store.append_event_envelope(&payload).await } -async fn send_sse_event(sender: &SessionSseSender, event: &SessionEventEnvelope) -> bool { +async fn send_sse_event(sender: &SessionSseSender, event: &EventEnvelope) -> bool { let Ok(data) = serde_json::to_string(event) else { return true; }; sender .send(Ok(Event::default() .id(event.seq.to_string()) - .event(event.event.clone()) + .event(event.event.event_name()) .data(data))) .await .is_ok() } -fn agent_event_envelope( +async fn load_session( + state: &AppState, session_id: SessionId, - turn_id: TurnId, - event: SessionEvent, -) -> SessionEventEnvelope { - let ts = event.timestamp.into(); - let event_name = agent_event_name(&event.event); - let properties = agent_event_properties(event); - SessionEventEnvelope::new(session_id, Some(turn_id), event_name, properties, ts) -} - -fn agent_event_name(event: &AgentEvent) -> &'static str { - match event { - AgentEvent::SessionStarted { .. } => "session.started", - AgentEvent::SessionEnded => "session.ended", - AgentEvent::ProcessingEnd => "turn.processing_end", - AgentEvent::UserInput { .. } => "turn.user_input", - AgentEvent::AssistantTextStart => "turn.assistant_text_start", - AgentEvent::AssistantOutputReplace { .. } => "turn.assistant_output_replace", - AgentEvent::AssistantMessage { .. } => "turn.assistant_message", - AgentEvent::TextDelta { .. } => "turn.text_delta", - AgentEvent::ReasoningDelta { .. } => "turn.reasoning_delta", - AgentEvent::ToolCallStarted { .. } => "turn.tool_call_started", - AgentEvent::ToolCallOutputDelta { .. } => "turn.tool_call_output_delta", - AgentEvent::ToolCallCompleted { .. } => "turn.tool_call_completed", - AgentEvent::Error { .. } => "turn.error", - AgentEvent::Warning { .. } => "turn.warning", - AgentEvent::LoopDetected => "turn.loop_detected", - AgentEvent::TurnLimitReached { .. } => "turn.limit_reached", - AgentEvent::SkillExpanded { .. } => "turn.skill_expanded", - AgentEvent::SteeringInjected { .. } => "turn.steering_injected", - AgentEvent::CompactionStarted { .. } => "turn.compaction_started", - AgentEvent::CompactionCompleted { .. } => "turn.compaction_completed", - AgentEvent::LlmRetry { .. } => "turn.llm_retry", - AgentEvent::SubAgentSpawned { .. } => "turn.subagent_spawned", - AgentEvent::SubAgentCompleted { .. } => "turn.subagent_completed", - AgentEvent::SubAgentFailed { .. } => "turn.subagent_failed", - AgentEvent::SubAgentClosed { .. } => "turn.subagent_closed", - AgentEvent::McpServerReady { .. } => "session.mcp_server_ready", - AgentEvent::McpServerFailed { .. } => "session.mcp_server_failed", +) -> Result<(RunId, RunDatabase, ProjectedRunSession), Response> { + let run_id = match state.store_ref().get_session_run_id(&session_id).await { + Ok(Some(run_id)) => run_id, + Ok(None) => return Err(ApiError::not_found("Session not found.").into_response()), + Err(err) => return Err(store_error(&err).into_response()), + }; + let run_store = open_run(state, run_id).await?; + let events = match run_store.list_events().await { + Ok(events) => events, + Err(err) => return Err(store_error(&err).into_response()), + }; + match fabro_store::project_run_session_with_context(run_id, session_id, &events) { + Some(session) => Ok((run_id, run_store, session)), + None => Err(ApiError::not_found("Session not found.").into_response()), } } -fn agent_event_properties(event: SessionEvent) -> serde_json::Value { - match event.event { - AgentEvent::UserInput { text } => json!({ "text": text }), - AgentEvent::AssistantOutputReplace { text, reasoning } => { - json!({ "text": text, "reasoning": reasoning }) - } - AgentEvent::AssistantMessage { - text, - model, - usage, - tool_call_count, - } => json!({ - "text": text, - "model": model, - "usage": usage, - "tool_call_count": tool_call_count - }), - AgentEvent::TextDelta { delta } - | AgentEvent::ReasoningDelta { delta } - | AgentEvent::ToolCallOutputDelta { delta } => json!({ "delta": delta }), - AgentEvent::ToolCallStarted { - tool_name, - tool_call_id, - arguments, - } => json!({ - "tool_name": tool_name, - "tool_call_id": tool_call_id, - "arguments": arguments - }), - AgentEvent::ToolCallCompleted { - tool_name, - tool_call_id, - output, - is_error, - } => json!({ - "tool_name": tool_name, - "tool_call_id": tool_call_id, - "output": output, - "is_error": is_error - }), - AgentEvent::Error { error } => json!({ "error": error.to_string() }), - AgentEvent::Warning { - kind, - message, - details, - } => json!({ "kind": kind, "message": message, "details": details }), - AgentEvent::TurnLimitReached { max_turns } => json!({ "max_turns": max_turns }), - AgentEvent::SkillExpanded { skill_name } => json!({ "skill_name": skill_name }), - AgentEvent::SteeringInjected { text, actor } => json!({ "text": text, "actor": actor }), - AgentEvent::CompactionStarted { - estimated_tokens, - context_window_size, - } => json!({ - "estimated_tokens": estimated_tokens, - "context_window_size": context_window_size - }), - AgentEvent::CompactionCompleted { - original_turn_count, - preserved_turn_count, - summary_token_estimate, - tracked_file_count, - } => json!({ - "original_turn_count": original_turn_count, - "preserved_turn_count": preserved_turn_count, - "summary_token_estimate": summary_token_estimate, - "tracked_file_count": tracked_file_count - }), - AgentEvent::LlmRetry { - provider, - model, - attempt, - delay_secs, - error, - } => json!({ - "provider": provider, - "model": model, - "attempt": attempt, - "delay_secs": delay_secs, - "error": error.to_string() - }), - AgentEvent::SubAgentSpawned { - agent_id, - depth, - task, - } => json!({ "agent_id": agent_id, "depth": depth, "task": task }), - AgentEvent::SubAgentCompleted { - agent_id, - depth, - success, - turns_used, - } => json!({ - "agent_id": agent_id, - "depth": depth, - "success": success, - "turns_used": turns_used - }), - AgentEvent::SubAgentFailed { - agent_id, - depth, - error, - } => json!({ "agent_id": agent_id, "depth": depth, "error": error.to_string() }), - AgentEvent::SubAgentClosed { agent_id, depth } => { - json!({ "agent_id": agent_id, "depth": depth }) - } - AgentEvent::McpServerReady { - server_name, - tool_count, - } => json!({ "server_name": server_name, "tool_count": tool_count }), - AgentEvent::McpServerFailed { server_name, error } => { - json!({ "server_name": server_name, "error": error }) - } - AgentEvent::SessionStarted { provider, model } => { - json!({ "provider": provider, "model": model }) - } - AgentEvent::SessionEnded - | AgentEvent::ProcessingEnd - | AgentEvent::AssistantTextStart - | AgentEvent::LoopDetected => { - json!({}) - } +async fn load_session_read( + state: &AppState, + session_id: SessionId, +) -> Result<(RunId, SessionRecord), Response> { + let run_id = match state.store_ref().get_session_run_id(&session_id).await { + Ok(Some(run_id)) => run_id, + Ok(None) => return Err(ApiError::not_found("Session not found.").into_response()), + Err(err) => return Err(store_error(&err).into_response()), + }; + let run_store = open_run_reader(state, run_id).await?; + let events = match run_store.list_events().await { + Ok(events) => events, + Err(err) => return Err(store_error(&err).into_response()), + }; + match project_run_session(run_id, session_id, &events) { + Some(session) => Ok((run_id, session)), + None => Err(ApiError::not_found("Session not found.").into_response()), } } +async fn open_run(state: &AppState, run_id: RunId) -> Result { + state.store_ref().open_run(&run_id).await.map_err(|err| { + if matches!(err, fabro_store::Error::RunNotFound(_)) { + ApiError::not_found("Run not found.").into_response() + } else { + store_error(&err).into_response() + } + }) +} + +async fn open_run_reader(state: &AppState, run_id: RunId) -> Result { + state + .store_ref() + .open_run_reader(&run_id) + .await + .map_err(|err| { + if matches!(err, fabro_store::Error::RunNotFound(_)) { + ApiError::not_found("Run not found.").into_response() + } else { + store_error(&err).into_response() + } + }) +} + +fn store_error(err: &fabro_store::Error) -> ApiError { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) +} + +fn parse_run_id(value: &str) -> Result { + value + .parse() + .map_err(|err| ApiError::bad_request(format!("Invalid run ID: {err}"))) +} + fn parse_session_id(value: &str) -> Result { value .parse() diff --git a/lib/crates/fabro-server/src/server/session_runtime.rs b/lib/crates/fabro-server/src/server/session_runtime.rs index 5dbb818e0..019c197e1 100644 --- a/lib/crates/fabro-server/src/server/session_runtime.rs +++ b/lib/crates/fabro-server/src/server/session_runtime.rs @@ -2,12 +2,10 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use fabro_agent::Session; -use fabro_types::{SessionEventEnvelope, SessionId, TurnId}; -use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard, broadcast}; +use fabro_types::{SessionId, TurnId}; +use tokio::sync::{Mutex as AsyncMutex, MutexGuard as AsyncMutexGuard}; use tokio_util::sync::CancellationToken; -const SESSION_EVENT_BROADCAST_CAPACITY: usize = 1024; - #[derive(Default)] pub(crate) struct SessionRuntimeManager { entries: Mutex>>, @@ -22,11 +20,6 @@ impl SessionRuntimeManager { self.entry(session_id) } - pub(crate) fn has_active_turn(&self, session_id: SessionId) -> bool { - self.existing_entry(session_id) - .is_some_and(|entry| entry.has_active_turn()) - } - pub(crate) fn reserve_turn( &self, session_id: SessionId, @@ -73,37 +66,6 @@ impl SessionRuntimeManager { Ok(PendingTurnInterrupt { entry, turn_id }) } - pub(crate) fn subscribe_events( - &self, - session_id: SessionId, - ) -> broadcast::Receiver { - self.load_or_create_runtime(session_id).live_tx.subscribe() - } - - pub(crate) fn broadcast_event(&self, event: &SessionEventEnvelope) { - let Some(entry) = self.existing_entry(event.session_id) else { - return; - }; - let _ = entry.live_tx.send(event.clone()); - } - - pub(crate) async fn unload_idle(&self, session_id: SessionId) -> bool { - let entry = { - let mut entries = self.entries.lock().expect("session runtime map poisoned"); - let Some(entry) = entries.get(&session_id) else { - return true; - }; - if entry.has_active_turn() { - return false; - } - entries - .remove(&session_id) - .expect("session runtime entry should exist") - }; - entry.clear_session().await; - true - } - fn entry(&self, session_id: SessionId) -> Arc { let mut entries = self.entries.lock().expect("session runtime map poisoned"); Arc::clone( @@ -126,17 +88,14 @@ pub(crate) struct SessionRuntimeEntry { session: AsyncMutex>, initialized: Mutex, active_turn: Mutex>, - live_tx: broadcast::Sender, } impl SessionRuntimeEntry { fn new() -> Self { - let (live_tx, _) = broadcast::channel(SESSION_EVENT_BROADCAST_CAPACITY); Self { - session: AsyncMutex::new(None), + session: AsyncMutex::new(None), initialized: Mutex::new(false), active_turn: Mutex::new(None), - live_tx, } } @@ -165,13 +124,6 @@ impl SessionRuntimeEntry { .lock() .expect("session initialized lock poisoned") = false; } - - fn has_active_turn(&self) -> bool { - self.active_turn - .lock() - .expect("session active turn lock poisoned") - .is_some() - } } struct ActiveTurn { diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 793f7fb9e..35da8ff60 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::convert::Infallible; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; @@ -9,7 +8,6 @@ use std::sync::{Arc as StdArc, Mutex as StdMutex}; use axum::body::Body; use axum::http::{Method, Request, header}; -use axum::response::sse::{Event as SseEvent, Sse}; use chrono::{Duration as ChronoDuration, Utc}; use fabro_config::ServerSettingsBuilder; use fabro_config::bind::Bind; @@ -23,11 +21,9 @@ use fabro_types::settings::ServerAuthMethod; use fabro_types::{ AgentBackend, AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph, InterviewQuestionRecord, Node, Outcome, QuestionType, RunBlobId, RunId, RunSpec, - SandboxProvider, SessionMessage, SuccessReason, SystemActorKind, WorkflowSettings, fixtures, + SandboxProvider, SuccessReason, SystemActorKind, WorkflowSettings, fixtures, }; use fabro_util::check_report::CheckStatus; -use futures_util::stream; -use http_body_util::BodyExt as _; use httpmock::Method::{GET, POST}; use httpmock::MockServer; use serde_json::json; @@ -133,36 +129,6 @@ async fn body_json(body: Body) -> serde_json::Value { serde_json::from_slice(&bytes).unwrap() } -async fn read_sse_until(body: &mut Body, expected_event: &str) -> String { - let mut sse_data = String::new(); - for _ in 0..32 { - let frame = tokio::time::timeout(Duration::from_secs(2), body.frame()) - .await - .unwrap_or_else(|_| panic!("timed out waiting for SSE event {expected_event}")) - .unwrap_or_else(|| panic!("SSE ended before event {expected_event}")) - .expect("SSE frame should be readable"); - if let Some(data) = frame.data_ref() { - sse_data.push_str(&String::from_utf8_lossy(data)); - if sse_events(&sse_data).iter().any(|event| { - event["event"] - .as_str() - .is_some_and(|name| name == expected_event) - }) { - return sse_data; - } - } - } - panic!("SSE event {expected_event} not found in {sse_data}"); -} - -fn sse_events(sse_data: &str) -> Vec { - sse_data - .lines() - .filter_map(|line| line.strip_prefix("data:")) - .filter_map(|json| serde_json::from_str(json.trim()).ok()) - .collect() -} - fn run_json_id(run: &serde_json::Value) -> Option<&str> { run["id"].as_str().or_else(|| run["run_id"].as_str()) } @@ -269,39 +235,6 @@ fn openai_responses_payload(text: &str) -> serde_json::Value { }) } -fn openai_stream_body(text: &str) -> String { - let created = json!({ - "type": "response.created", - "response": { - "id": "resp_session", - "model": "gpt-5.4-mini" - } - }); - let delta = json!({ - "type": "response.output_text.delta", - "delta": text - }); - let completed = json!({ - "type": "response.completed", - "response": { - "id": "resp_session", - "model": "gpt-5.4-mini", - "status": "completed", - "usage": { - "input_tokens": 10, - "output_tokens": 5, - "input_tokens_details": { "cached_tokens": 0 }, - "output_tokens_details": { "reasoning_tokens": 0 } - } - } - }); - format!( - "event: response.created\ndata: {created}\n\n\ - event: response.output_text.delta\ndata: {delta}\n\n\ - event: response.completed\ndata: {completed}\n\n" - ) -} - macro_rules! assert_status { ($response:expr, $expected:expr) => { fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!())) @@ -2334,713 +2267,6 @@ async fn create_run(app: &Router, dot_source: &str) -> String { body["id"].as_str().unwrap().to_string() } -#[tokio::test] -async fn create_session_rejects_missing_permissions() { - let state = test_app_state_with_isolated_storage(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(api("/sessions")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({"working_dir": "/tmp"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn create_session_rejects_unknown_permission_value() { - let state = test_app_state_with_isolated_storage(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(api("/sessions")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({"permissions": "readonly"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); -} - -#[tokio::test] -async fn session_apis_create_list_replay_events_and_delete() { - let state = test_app_state_with_isolated_storage(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let create_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api("/sessions")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "title": "Investigate failure", - "working_dir": "/tmp", - "provider": "openai", - "model": "gpt-5.4-mini", - "permissions": "read-only" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let created = response_json!(create_response, StatusCode::CREATED).await; - let session_id = created["id"] - .as_str() - .expect("create session response should include id") - .to_string(); - assert_eq!(created["status"], "idle"); - assert_eq!(created["working_dir"], "/tmp"); - - let list_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api("/sessions")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let list = response_json!(list_response, StatusCode::OK).await; - assert_eq!(list["data"].as_array().unwrap().len(), 1); - - let turns_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let turns = response_json!(turns_response, StatusCode::OK).await; - assert!(turns["data"].as_array().unwrap().is_empty()); - - let replay_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/events?since_seq=1"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let replay = response_json!(replay_response, StatusCode::OK).await; - assert_eq!(replay["data"][0]["seq"], 1); - assert_eq!(replay["data"][0]["event"], "session.created"); - - let generated_client_replay_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/events?since_seq=1"))) - .header(header::ACCEPT, "application/json,text/event-stream") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let generated_client_replay = - response_json!(generated_client_replay_response, StatusCode::OK).await; - assert_eq!( - generated_client_replay["data"][0]["event"], - "session.created" - ); - - let header_replay_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/events"))) - .header("last-event-id", "1") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let header_replay = response_json!(header_replay_response, StatusCode::OK).await; - assert!( - header_replay["data"] - .as_array() - .expect("events response should contain array") - .is_empty() - ); - - let delete_response = app - .clone() - .oneshot( - Request::builder() - .method("DELETE") - .uri(api(&format!("/sessions/{session_id}"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - checked_response!(delete_response, StatusCode::NO_CONTENT).await; - - let get_deleted_response = app - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - checked_response!(get_deleted_response, StatusCode::NOT_FOUND).await; -} - -#[tokio::test] -async fn streaming_session_turn_persists_terminal_failure_event() { - let state = test_app_state_with_isolated_storage(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let create_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api("/sessions")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "working_dir": "/tmp", - "provider": "openai", - "model": "gpt-5.4-mini", - "permissions": "read-only" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let created = response_json!(create_response, StatusCode::CREATED).await; - let session_id = created["id"].as_str().unwrap().to_string(); - - let stream_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({"input": "hello"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(stream_response.status(), StatusCode::OK); - let body = String::from_utf8( - to_bytes(stream_response.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), - ) - .unwrap(); - assert!(body.contains("turn.running"), "SSE body: {body}"); - assert!(body.contains("turn.failed"), "SSE body: {body}"); - assert!( - body.contains("LLM credentials not configured for provider 'openai'"), - "SSE body: {body}" - ); - - let turns_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let turns = response_json!(turns_response, StatusCode::OK).await; - assert_eq!(turns["data"][0]["status"], "failed"); - - let events_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/events"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let events = response_json!(events_response, StatusCode::OK).await; - let event_names = events["data"] - .as_array() - .unwrap() - .iter() - .map(|event| event["event"].as_str().unwrap()) - .collect::>(); - assert_eq!(event_names, vec![ - "session.created", - "turn.running", - "turn.failed" - ]); - - let turn_id = turns["data"][0]["id"].as_str().unwrap(); - let interrupt_response = app - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!( - "/sessions/{session_id}/turns/{turn_id}/interrupt" - ))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let interrupt_error = response_json!(interrupt_response, StatusCode::CONFLICT).await; - assert_eq!( - interrupt_error["errors"][0]["detail"], - "Turn is already terminal." - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_events_sse_replays_history_then_streams_live_events() { - let state = test_app_state_with_isolated_storage(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let create_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api("/sessions")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "working_dir": "/tmp", - "provider": "openai", - "model": "gpt-5.4-mini", - "permissions": "read-only" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let created = response_json!(create_response, StatusCode::CREATED).await; - let session_id = created["id"].as_str().unwrap().to_string(); - - let events_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/events?since_seq=1"))) - .header(header::ACCEPT, "text/event-stream") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(events_response.status(), StatusCode::OK); - assert!( - events_response - .headers() - .get(header::CONTENT_TYPE) - .unwrap() - .to_str() - .unwrap() - .contains("text/event-stream") - ); - let mut events_body = events_response.into_body(); - let replay = read_sse_until(&mut events_body, "session.created").await; - assert_eq!(sse_events(&replay)[0]["event"], "session.created"); - - let stream_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({"input": "hello"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(stream_response.status(), StatusCode::OK); - let _ = to_bytes(stream_response.into_body(), usize::MAX) - .await - .unwrap(); - - let live = read_sse_until(&mut events_body, "turn.failed").await; - let event_names = sse_events(&live) - .into_iter() - .map(|event| event["event"].as_str().unwrap().to_string()) - .collect::>(); - assert!(event_names.contains(&"turn.running".to_string())); - assert!(event_names.contains(&"turn.failed".to_string())); -} - -#[tokio::test] -async fn streaming_session_turn_updates_runtime_context_without_copying_prior_history_to_turn() { - let llm = MockServer::start_async().await; - let response_mock = llm - .mock_async(|when, then| { - when.method(POST) - .path("/v1/responses") - .header("authorization", "Bearer openai-key"); - then.status(200) - .header("content-type", "text/event-stream") - .body(openai_stream_body("new answer")); - }) - .await; - // Use an isolated storage root so parallel tests do not race on the - // shared default session storage directory. `session_store` writes - // `session.json` via `fs::write`, which truncates before writing; a - // concurrent reader can observe the empty file and fail to deserialize. - let storage_dir = std::env::temp_dir().join(format!("fabro-server-test-{}", Ulid::new())); - std::fs::create_dir_all(&storage_dir).expect("test storage dir should be creatable"); - let server_settings = server_settings_from_toml(&format!( - r#" -_version = 1 - -[server.storage] -root = "{}" - -[server.auth] -methods = ["dev-token"] -"#, - storage_dir.display() - )); - let state = TestAppStateBuilder::new() - .runtime_settings(server_settings, RunLayer::default()) - .max_concurrent_runs(5) - .provider_base_url("openai", llm.url("/v1")) - .build(); - state - .vault - .write() - .await - .set("OPENAI_API_KEY", "openai-key", SecretType::Token, None) - .unwrap(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let create_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api("/sessions")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "working_dir": "/tmp", - "provider": "openai", - "model": "gpt-5.4-mini", - "permissions": "read-only" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let created = response_json!(create_response, StatusCode::CREATED).await; - let session_id = created["id"].as_str().unwrap().to_string(); - let session_id_typed = session_id.parse().unwrap(); - let now = Utc::now(); - let mut record = state - .session_store() - .get_session(session_id_typed) - .await - .unwrap() - .unwrap(); - record.runtime_context = vec![ - SessionMessage::user("prior question", now), - SessionMessage::Assistant { - content: "prior answer".to_string(), - tool_calls: Vec::new(), - provider_parts: Vec::new(), - usage: json!({ "input_tokens": 0, "output_tokens": 0 }), - response_id: "prior_resp".to_string(), - timestamp: now, - }, - ]; - state.session_store().update_session(record).await.unwrap(); - - let stream_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({"input": "new question"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(stream_response.status(), StatusCode::OK); - let body = String::from_utf8( - to_bytes(stream_response.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), - ) - .unwrap(); - assert!(body.contains("turn.succeeded"), "SSE body: {body}"); - - let session_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let session = response_json!(session_response, StatusCode::OK).await; - let runtime_context = session["runtime_context"].as_array().unwrap(); - assert_eq!(runtime_context.len(), 4); - assert!( - runtime_context - .iter() - .any(|message| message["content"] == "prior question") - ); - assert!( - runtime_context - .iter() - .any(|message| message["content"] == "new answer") - ); - - let turns_response = app - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let turns = response_json!(turns_response, StatusCode::OK).await; - let turn = &turns["data"][0]; - assert_eq!(turn["input"], "new question"); - assert_eq!(turn["output"], "new answer"); - assert!(turn.get("messages").is_none()); - response_mock.assert_async().await; -} - -async fn hanging_openai_responses() --> Sse>> { - let created = json!({ - "type": "response.created", - "response": { - "id": "resp_hanging", - "model": "gpt-5.4-mini" - } - }); - let first = stream::once(async move { - Ok(SseEvent::default() - .event("response.created") - .data(created.to_string())) - }); - Sse::new(first.chain(stream::pending())) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn interrupt_active_session_turn_cancels_runtime_and_persists_interrupted() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let llm_addr = listener.local_addr().unwrap(); - let llm_app = Router::new().route("/v1/responses", post(hanging_openai_responses)); - let llm_handle = tokio::spawn(async move { - axum::serve(listener, llm_app).await.unwrap(); - }); - // Use an isolated storage root so that other tests' AppState startup does - // not run `recover_stale_running_state` against this test's session - // directory and mark its in-flight turn as Interrupted. - let storage_dir = std::env::temp_dir().join(format!("fabro-server-test-{}", Ulid::new())); - std::fs::create_dir_all(&storage_dir).expect("test storage dir should be creatable"); - let server_settings = server_settings_from_toml(&format!( - r#" -_version = 1 - -[server.storage] -root = "{}" - -[server.auth] -methods = ["dev-token"] -"#, - storage_dir.display() - )); - let state = TestAppStateBuilder::new() - .runtime_settings(server_settings, RunLayer::default()) - .max_concurrent_runs(5) - .provider_base_url("openai", format!("http://{llm_addr}/v1")) - .build(); - state - .vault - .write() - .await - .set("OPENAI_API_KEY", "openai-key", SecretType::Token, None) - .unwrap(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let create_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api("/sessions")) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({ - "working_dir": "/tmp", - "provider": "openai", - "model": "gpt-5.4-mini", - "permissions": "read-only" - }) - .to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - let created = response_json!(create_response, StatusCode::CREATED).await; - let session_id = created["id"].as_str().unwrap().to_string(); - - let stream_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({"input": "please wait"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(stream_response.status(), StatusCode::OK); - let mut stream_body = stream_response.into_body(); - // Wait for `turn.assistant_text_start` so the agent is committed to the - // in-flight LLM call. Waiting only for `turn.running` (emitted before - // `build_agent_session` and `process_input`) leaves a window in which the - // turn can reach a terminal state before the interrupt request arrives, - // making the assertions below flaky under heavy CI load. - let running = read_sse_until(&mut stream_body, "turn.assistant_text_start").await; - let turn_id = sse_events(&running) - .into_iter() - .find_map(|event| { - (event["event"] == "turn.running") - .then(|| event["turn_id"].as_str().unwrap().to_string()) - }) - .expect("running event should include turn id"); - - let conflict_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/sessions/{session_id}/turns"))) - .header("content-type", "application/json") - .body(Body::from( - serde_json::json!({"input": "second"}).to_string(), - )) - .unwrap(), - ) - .await - .unwrap(); - checked_response!(conflict_response, StatusCode::CONFLICT).await; - - let delete_response = app - .clone() - .oneshot( - Request::builder() - .method("DELETE") - .uri(api(&format!("/sessions/{session_id}"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let delete_error = response_json!(delete_response, StatusCode::CONFLICT).await; - assert_eq!( - delete_error["errors"][0]["detail"], - "Session has an active turn." - ); - - let interrupt_response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!( - "/sessions/{session_id}/turns/{turn_id}/interrupt" - ))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let interrupt_event = response_json!(interrupt_response, StatusCode::ACCEPTED).await; - assert_eq!(interrupt_event["event"], "turn.interrupt_requested"); - - let interrupted = read_sse_until(&mut stream_body, "turn.interrupted").await; - assert!( - sse_events(&interrupted) - .iter() - .any(|event| event["event"] == "turn.interrupted") - ); - - let turn_response = app - .oneshot( - Request::builder() - .method("GET") - .uri(api(&format!("/sessions/{session_id}/turns/{turn_id}"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let turn = response_json!(turn_response, StatusCode::OK).await; - assert_eq!(turn["status"], "interrupted"); - llm_handle.abort(); -} - #[tokio::test] async fn create_run_response_includes_web_url_when_web_enabled() { let state = test_app_state_with_options( diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index 9ec22972a..ccc725908 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -7,6 +7,7 @@ mod routing; mod run_files; mod runs; mod sandbox_vnc; +mod sessions; mod settings; mod system; mod tcp; diff --git a/lib/crates/fabro-server/tests/it/api/sessions.rs b/lib/crates/fabro-server/tests/it/api/sessions.rs new file mode 100644 index 000000000..15554a223 --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/sessions.rs @@ -0,0 +1,395 @@ +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use crate::helpers::{ + MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state, + test_app_with_no_providers, +}; + +async fn create_run(app: &axum::Router) -> String { + let request = Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&minimal_manifest_json(MINIMAL_DOT)) + .expect("manifest should serialize"), + )) + .expect("create-run request should build"); + let body = response_json( + app.clone().oneshot(request).await.unwrap(), + StatusCode::CREATED, + "POST /api/v1/runs", + ) + .await; + body["id"] + .as_str() + .expect("create-run response should include an id") + .to_string() +} + +async fn create_session(app: &axum::Router, run_id: &str, title: &str) -> serde_json::Value { + create_session_with_body(app, run_id, serde_json::json!({ "title": title })).await +} + +async fn create_session_with_model( + app: &axum::Router, + run_id: &str, + title: &str, + model: &str, +) -> serde_json::Value { + create_session_with_body( + app, + run_id, + serde_json::json!({ "title": title, "model": model }), + ) + .await +} + +async fn create_session_with_body( + app: &axum::Router, + run_id: &str, + body: serde_json::Value, +) -> serde_json::Value { + response_json( + create_session_response(app, run_id, body).await, + StatusCode::CREATED, + format!("POST /api/v1/runs/{run_id}/sessions"), + ) + .await +} + +async fn create_session_response( + app: &axum::Router, + run_id: &str, + body: serde_json::Value, +) -> axum::response::Response { + let request = Request::builder() + .method("POST") + .uri(api(&format!("/runs/{run_id}/sessions"))) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&body).expect("session request should serialize"), + )) + .expect("create-session request should build"); + app.clone().oneshot(request).await.unwrap() +} + +#[tokio::test] +async fn run_bound_session_is_created_as_run_event_and_resolves_by_flat_id() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + + let created = create_session(&app, &run_id, "Ask Fabro").await; + let session_id = created["id"] + .as_str() + .expect("session response should include an id"); + assert_eq!(created["run_id"], run_id); + assert_eq!(created["title"], "Ask Fabro"); + assert_session_metadata_only(&created); + assert!(session_id.parse::().is_ok()); + + let get_request = Request::builder() + .method("GET") + .uri(api(&format!("/sessions/{session_id}"))) + .body(Body::empty()) + .expect("get-session request should build"); + let fetched = response_json( + app.clone().oneshot(get_request).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/sessions/{session_id}"), + ) + .await; + assert_eq!(fetched["id"], session_id); + assert_eq!(fetched["run_id"], run_id); + assert_session_metadata_only(&fetched); + + let events_request = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/events"))) + .body(Body::empty()) + .expect("run-events request should build"); + let events = response_json( + app.clone().oneshot(events_request).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/events"), + ) + .await; + let session_events: Vec<_> = events["data"] + .as_array() + .expect("events response should include data") + .iter() + .filter(|event| event["session_id"] == session_id) + .collect(); + assert_eq!(session_events.len(), 1); + assert_eq!(session_events[0]["event"], "run.session.created"); + assert!( + session_events[0]["properties"].get("permissions").is_none(), + "run session creation event should not expose permissions" + ); +} + +#[tokio::test] +async fn sessions_are_listed_only_under_their_owning_run() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let first_run_id = create_run(&app).await; + let second_run_id = create_run(&app).await; + let created = create_session(&app, &first_run_id, "First run chat").await; + + let first_request = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{first_run_id}/sessions"))) + .body(Body::empty()) + .expect("list sessions request should build"); + let first = response_json( + app.clone().oneshot(first_request).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/runs/{first_run_id}/sessions"), + ) + .await; + assert_eq!(first["data"].as_array().unwrap().len(), 1); + assert_eq!(first["data"][0]["id"], created["id"]); + assert_session_metadata_only(&first["data"][0]); + + let second_request = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{second_run_id}/sessions"))) + .body(Body::empty()) + .expect("list sessions request should build"); + let second = response_json( + app.clone().oneshot(second_request).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/runs/{second_run_id}/sessions"), + ) + .await; + assert!(second["data"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn supplied_session_model_alias_is_canonicalized() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + + let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await; + assert_eq!(created["model"], "gpt-5.4"); + + let events_request = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/events"))) + .body(Body::empty()) + .expect("run-events request should build"); + let events = response_json( + app.clone().oneshot(events_request).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/events"), + ) + .await; + + let created_event = events["data"] + .as_array() + .expect("events response should include data") + .iter() + .find(|event| event["event"] == "run.session.created") + .expect("session creation event should be recorded"); + assert_eq!(created_event["properties"]["model"], "gpt-5.4"); +} + +#[tokio::test] +async fn provider_qualified_session_model_is_canonicalized() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + + let created = create_session_with_model(&app, &run_id, "Ask Fabro", "openai/gpt-5.4").await; + + assert_eq!(created["model"], "gpt-5.4"); +} + +#[tokio::test] +async fn invalid_session_model_refs_are_rejected_at_creation() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + + for model in ["not-a-real-model", "openai", "openai/", "anthropic/gpt-5.4"] { + let response = create_session_response( + &app, + &run_id, + serde_json::json!({ "title": "Ask Fabro", "model": model }), + ) + .await; + response_status( + response, + StatusCode::BAD_REQUEST, + format!("POST /api/v1/runs/{run_id}/sessions with model {model}"), + ) + .await; + } +} + +#[tokio::test] +async fn ambiguous_session_model_refs_are_rejected_at_creation() { + let mut catalog_settings = fabro_model::catalog::LlmCatalogSettings::default(); + catalog_settings.providers.insert( + "openai".to_string(), + fabro_model::catalog::ProviderCatalogSettings { + aliases: Some(vec!["gpt54".to_string()]), + ..fabro_model::catalog::ProviderCatalogSettings::default() + }, + ); + let state = fabro_server::test_support::TestAppStateBuilder::new() + .llm_catalog_settings(catalog_settings) + .build(); + let app = fabro_server::test_support::build_test_router(state); + let run_id = create_run(&app).await; + + let response = create_session_response( + &app, + &run_id, + serde_json::json!({ "title": "Ask Fabro", "model": "gpt54" }), + ) + .await; + response_status( + response, + StatusCode::BAD_REQUEST, + format!("POST /api/v1/runs/{run_id}/sessions with ambiguous model gpt54"), + ) + .await; +} + +#[tokio::test] +async fn session_turn_fails_when_selected_model_provider_is_unconfigured() { + let app = test_app_with_no_providers(); + let run_id = create_run(&app).await; + let created = create_session_with_model(&app, &run_id, "Ask Fabro", "gpt54").await; + let session_id = created["id"] + .as_str() + .expect("session response should include an id"); + + let request = Request::builder() + .method("POST") + .uri(api(&format!("/sessions/{session_id}/turns"))) + .header("content-type", "application/json") + .body(Body::from(r#"{"input":"Which provider are you using?"}"#)) + .expect("submit-turn request should build"); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let events = session_sse_events(response).await; + + let failed = events + .iter() + .find(|event| event["event"] == "run.session.turn.failed") + .expect("selected provider failure should be streamed"); + assert!( + failed["properties"]["error"] + .as_str() + .expect("failure event should include an error") + .contains("provider 'openai'"), + "failure should be for the selected model provider: {failed:?}" + ); +} + +#[tokio::test] +async fn session_metadata_patch_route_is_removed() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + let created = create_session(&app, &run_id, "Ask Fabro").await; + let session_id = created["id"] + .as_str() + .expect("session response should include an id"); + + let request = Request::builder() + .method("PATCH") + .uri(api(&format!("/sessions/{session_id}"))) + .header("content-type", "application/json") + .body(Body::from(r#"{"title":"Renamed"}"#)) + .expect("patch-session request should build"); + response_status( + app.clone().oneshot(request).await.unwrap(), + StatusCode::NOT_FOUND, + format!("PATCH /api/v1/sessions/{session_id}"), + ) + .await; +} + +#[tokio::test] +async fn derived_session_read_routes_are_removed() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + let created = create_session(&app, &run_id, "Ask Fabro").await; + let session_id = created["id"] + .as_str() + .expect("session response should include an id"); + let turn_id = fabro_types::TurnId::new(); + + for path in [ + format!("/sessions/{session_id}/turns"), + format!("/sessions/{session_id}/turns/{turn_id}"), + format!("/sessions/{session_id}/events"), + ] { + let request = Request::builder() + .method("GET") + .uri(api(&path)) + .body(Body::empty()) + .expect("removed session read request should build"); + response_status( + app.clone().oneshot(request).await.unwrap(), + StatusCode::NOT_FOUND, + format!("GET /api/v1{path}"), + ) + .await; + } +} + +#[tokio::test] +async fn inactive_turn_interrupt_returns_conflict() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + let run_id = create_run(&app).await; + let created = create_session(&app, &run_id, "Ask Fabro").await; + let session_id = created["id"] + .as_str() + .expect("session response should include an id"); + let turn_id = fabro_types::TurnId::new(); + + let request = Request::builder() + .method("POST") + .uri(api(&format!( + "/sessions/{session_id}/turns/{turn_id}/interrupt" + ))) + .body(Body::empty()) + .expect("interrupt request should build"); + response_status( + app.clone().oneshot(request).await.unwrap(), + StatusCode::CONFLICT, + format!("POST /api/v1/sessions/{session_id}/turns/{turn_id}/interrupt"), + ) + .await; +} + +async fn session_sse_events(response: axum::response::Response) -> Vec { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("session event stream body should be readable"); + let body = String::from_utf8(bytes.to_vec()).expect("session event stream should be UTF-8"); + body.lines() + .filter_map(|line| line.strip_prefix("data: ")) + .map(|data| serde_json::from_str(data).expect("session event data should be JSON")) + .collect() +} + +fn assert_session_metadata_only(value: &serde_json::Value) { + let object = value + .as_object() + .expect("session response should be a JSON object"); + for field in [ + "working_dir", + "provider", + "permissions", + "deleted_at", + "runtime_context", + ] { + assert!( + !object.contains_key(field), + "session metadata should not expose {field}" + ); + } +} diff --git a/lib/crates/fabro-store/src/keys.rs b/lib/crates/fabro-store/src/keys.rs index 7d043532a..2451df632 100644 --- a/lib/crates/fabro-store/src/keys.rs +++ b/lib/crates/fabro-store/src/keys.rs @@ -1,6 +1,6 @@ use std::fmt::{self, Write}; -use fabro_types::{RunBlobId, RunId}; +use fabro_types::{RunBlobId, RunId, SessionId}; #[derive(Debug, PartialEq, Eq)] pub(crate) struct SlateKey(String); @@ -70,6 +70,14 @@ pub(crate) fn blobs_prefix() -> SlateKey { SlateKey::new("blobs").with("sha256").into_prefix() } +pub(crate) fn sessions_by_id_prefix() -> SlateKey { + SlateKey::new("sessions").with("by-id").into_prefix() +} + +pub(crate) fn session_by_id_key(session_id: &SessionId) -> SlateKey { + SlateKey::new("sessions").with("by-id").with(session_id) +} + // --- Parsing --- pub(crate) fn parse_event_seq(key: &str) -> Option { diff --git a/lib/crates/fabro-store/src/lib.rs b/lib/crates/fabro-store/src/lib.rs index e5494cfdc..4cf30b51b 100644 --- a/lib/crates/fabro-store/src/lib.rs +++ b/lib/crates/fabro-store/src/lib.rs @@ -5,9 +5,9 @@ mod error; mod keyed_mutex; mod keys; mod record; +mod run_sessions; mod run_state; mod serializable_projection; -mod session_store; mod slate; mod types; @@ -20,9 +20,12 @@ pub use fabro_types::{ EventEnvelope, PendingInterviewRecord, Run, RunBlobId, RunProjection, StageId, StageProjection, }; pub(crate) use keyed_mutex::KeyedMutex; +pub use run_sessions::{ + ProjectedRunSession, project_run_session, project_run_session_with_context, + project_run_sessions, +}; pub use run_state::RunProjectionReducer; pub use serializable_projection::SerializableProjection; -pub use session_store::SessionStore; pub use slate::{ AuthCode, AuthCodeStore, Blob, BlobStore, CachedRunProjection, ConsumeOutcome, Database, RefreshToken, RefreshTokenStore, RunCatalogIndex, RunDatabase, Runs, UnreadableRun, @@ -35,191 +38,3 @@ pub struct ListRunsQuery { pub end: Option>, pub parent_id: Option, } - -#[cfg(test)] -mod session_store_contract_tests { - use chrono::Utc; - use fabro_types::{ - SessionEventEnvelope, SessionId, SessionRecord, SessionStatus, TurnId, TurnRecord, - TurnStatus, - }; - use serde_json::json; - - use crate::SessionStore; - - #[tokio::test] - async fn create_list_read_delete_sessions_and_replay_events() { - let root = tempfile::tempdir().expect("temp store root should exist"); - let store = SessionStore::new(root.path().join("sessions")); - let session_id = SessionId::new(); - let turn_id = TurnId::new(); - let now = Utc::now(); - let mut session = SessionRecord::new(session_id, now); - session.title = Some("Investigate failure".to_string()); - session.working_dir = Some("/tmp/project".to_string()); - session.provider = Some("openai".to_string()); - session.model = Some("gpt-5.4-mini".to_string()); - - store - .create_session(session.clone()) - .await - .expect("session should persist"); - store - .append_turn(TurnRecord { - id: turn_id, - session_id, - input: "hello".to_string(), - status: TurnStatus::Succeeded, - output: Some("world".to_string()), - error: None, - created_at: now, - updated_at: now, - completed_at: Some(now), - }) - .await - .expect("turn should persist"); - let first = store - .append_event(SessionEventEnvelope::new( - session_id, - Some(turn_id), - "session.created", - json!({"title": "Investigate failure"}), - now, - )) - .await - .expect("first event should persist"); - let second = store - .append_event(SessionEventEnvelope::new( - session_id, - Some(turn_id), - "turn.completed", - json!({"ok": true}), - now, - )) - .await - .expect("second event should persist"); - - assert_eq!(first.seq, 1); - assert_eq!(second.seq, 2); - assert_eq!( - store - .get_session(session_id) - .await - .expect("session read should succeed") - .expect("session should exist") - .working_dir - .as_deref(), - Some("/tmp/project") - ); - assert_eq!( - store - .list_sessions() - .await - .expect("session list should succeed") - .len(), - 1 - ); - assert_eq!( - store - .list_turns(session_id) - .await - .expect("turn list should succeed") - .len(), - 1 - ); - let replayed = store - .list_events(session_id, Some(2)) - .await - .expect("event replay should succeed"); - assert_eq!( - replayed - .into_iter() - .map(|event| event.event) - .collect::>(), - vec!["turn.completed".to_string()] - ); - - store - .delete_session(session_id) - .await - .expect("delete should succeed"); - assert!( - store - .get_session(session_id) - .await - .expect("session read should succeed") - .is_none() - ); - assert!( - store - .list_sessions() - .await - .expect("session list should succeed") - .is_empty() - ); - } - - #[test] - fn session_and_turn_ids_reject_malformed_values() { - assert!("not-a-ulid".parse::().is_err()); - assert!("not-a-ulid".parse::().is_err()); - assert_eq!(SessionStatus::Idle.as_str(), "idle"); - } - - #[tokio::test] - async fn stale_running_sessions_and_turns_recover_to_idle_and_interrupted() { - let root = tempfile::tempdir().expect("temp store root should exist"); - let store = SessionStore::new(root.path().join("sessions")); - let session_id = SessionId::new(); - let running_turn_id = TurnId::new(); - let now = Utc::now(); - let mut session = SessionRecord::new(session_id, now); - session.status = SessionStatus::Running; - store - .create_session(session) - .await - .expect("session should persist"); - - store - .append_turn(TurnRecord { - id: running_turn_id, - session_id, - input: "hello".to_string(), - status: TurnStatus::Running, - output: None, - error: None, - created_at: now, - updated_at: now, - completed_at: None, - }) - .await - .expect("turn should persist"); - - let recovered_at = now + chrono::Duration::seconds(5); - store - .recover_stale_running_state(recovered_at) - .expect("stale runtime state should recover"); - - let recovered = store - .get_session(session_id) - .await - .expect("session read should succeed") - .expect("session should exist"); - assert_eq!(recovered.status, SessionStatus::Idle); - assert_eq!(recovered.updated_at, recovered_at); - - let turns = store - .list_turns(session_id) - .await - .expect("turn list should succeed"); - assert_eq!(turns.len(), 1); - for turn in turns { - assert_eq!(turn.status, TurnStatus::Interrupted); - assert_eq!(turn.completed_at, Some(recovered_at)); - assert_eq!( - turn.error.as_deref(), - Some("Server restarted before the turn completed.") - ); - } - } -} diff --git a/lib/crates/fabro-store/src/run_sessions.rs b/lib/crates/fabro-store/src/run_sessions.rs new file mode 100644 index 000000000..b40bfe4a9 --- /dev/null +++ b/lib/crates/fabro-store/src/run_sessions.rs @@ -0,0 +1,414 @@ +use std::collections::BTreeMap; + +use fabro_types::run_event::{RunSessionToolCallCompletedProps, RunSessionToolCallStartedProps}; +use fabro_types::{ + EventBody, EventEnvelope, RunId, SessionId, SessionMessage, SessionRecord, SessionStatus, + SessionSummary, +}; +use serde_json::json; + +#[derive(Debug, Clone, PartialEq)] +pub struct ProjectedRunSession { + pub record: SessionRecord, + pub runtime_context: Vec, +} + +pub fn project_run_sessions(run_id: RunId, events: &[EventEnvelope]) -> Vec { + let mut projection = RunSessionProjection::metadata_only(); + projection.apply(run_id, events); + projection + .sessions + .values() + .map(|session| SessionSummary::from(&session.record)) + .collect() +} + +pub fn project_run_session( + run_id: RunId, + session_id: SessionId, + events: &[EventEnvelope], +) -> Option { + project_run_session_with_context(run_id, session_id, events).map(|session| session.record) +} + +pub fn project_run_session_with_context( + run_id: RunId, + session_id: SessionId, + events: &[EventEnvelope], +) -> Option { + let mut projection = RunSessionProjection::with_context_for(session_id); + projection.apply(run_id, events); + projection.sessions.remove(&session_id) +} + +struct RunSessionProjection { + sessions: BTreeMap, + context: RuntimeContextProjection, +} + +enum RuntimeContextProjection { + None, + Session(SessionId), +} + +impl RunSessionProjection { + fn metadata_only() -> Self { + Self { + sessions: BTreeMap::new(), + context: RuntimeContextProjection::None, + } + } + + fn with_context_for(session_id: SessionId) -> Self { + Self { + sessions: BTreeMap::new(), + context: RuntimeContextProjection::Session(session_id), + } + } + + fn apply(&mut self, run_id: RunId, events: &[EventEnvelope]) { + for envelope in events { + let Some(session_id) = event_session_id(envelope) else { + continue; + }; + match &envelope.event.body { + EventBody::RunSessionCreated(props) => { + let mut record = SessionRecord::new(session_id, run_id, envelope.event.ts); + record.title.clone_from(&props.title); + record.model.clone_from(&props.model); + let projected = ProjectedRunSession { + record, + runtime_context: Vec::new(), + }; + self.sessions.insert(session_id, projected); + } + EventBody::RunSessionTurnStarted(_) => { + if let Some(session) = self.sessions.get_mut(&session_id) { + session.record.status = SessionStatus::Running; + session.record.updated_at = envelope.event.ts; + } + } + EventBody::RunSessionUserMessage(props) => { + let project_context = self.should_project_context(session_id); + if let Some(session) = self.sessions.get_mut(&session_id) { + if project_context { + session + .runtime_context + .push(SessionMessage::user(props.text.clone(), envelope.event.ts)); + } + session.record.updated_at = envelope.event.ts; + } + } + EventBody::RunSessionAssistantMessage(props) => { + let project_context = self.should_project_context(session_id); + if let Some(session) = self.sessions.get_mut(&session_id) { + if project_context { + session.runtime_context.push(SessionMessage::Assistant { + content: props.text.clone(), + tool_calls: Vec::new(), + provider_parts: Vec::new(), + usage: props.usage.clone(), + response_id: String::new(), + timestamp: envelope.event.ts, + }); + } + session.record.updated_at = envelope.event.ts; + } + } + EventBody::RunSessionToolCallStarted(props) => { + let project_context = self.should_project_context(session_id); + if let Some(session) = self.sessions.get_mut(&session_id) { + if project_context { + append_tool_call(session, props); + } + session.record.updated_at = envelope.event.ts; + } + } + EventBody::RunSessionToolCallCompleted(props) => { + let project_context = self.should_project_context(session_id); + if let Some(session) = self.sessions.get_mut(&session_id) { + if project_context { + append_tool_result(session, props, envelope.event.ts); + } + session.record.updated_at = envelope.event.ts; + } + } + EventBody::RunSessionTurnFailed(_) => { + self.finish_turn(session_id, true, envelope.event.ts); + } + EventBody::RunSessionTurnSucceeded(_) | EventBody::RunSessionTurnInterrupted(_) => { + self.finish_turn(session_id, false, envelope.event.ts); + } + _ => {} + } + } + } + + fn finish_turn( + &mut self, + session_id: SessionId, + failed: bool, + timestamp: chrono::DateTime, + ) { + if let Some(session) = self.sessions.get_mut(&session_id) { + session.record.status = if failed { + SessionStatus::Failed + } else { + SessionStatus::Idle + }; + session.record.updated_at = timestamp; + } + } + + fn should_project_context(&self, session_id: SessionId) -> bool { + match self.context { + RuntimeContextProjection::None => false, + RuntimeContextProjection::Session(target) => target == session_id, + } + } +} + +fn event_session_id(envelope: &EventEnvelope) -> Option { + envelope + .event + .session_id + .as_deref() + .and_then(|id| id.parse().ok()) +} + +fn append_tool_call(session: &mut ProjectedRunSession, props: &RunSessionToolCallStartedProps) { + if let Some(SessionMessage::Assistant { tool_calls, .. }) = session + .runtime_context + .iter_mut() + .rev() + .find(|message| matches!(message, SessionMessage::Assistant { .. })) + { + tool_calls.push(json!({ + "id": props.tool_call_id.clone(), + "name": props.tool_name.clone(), + "arguments": props.arguments.clone(), + })); + } +} + +fn append_tool_result( + session: &mut ProjectedRunSession, + props: &RunSessionToolCallCompletedProps, + timestamp: chrono::DateTime, +) { + let result = json!({ + "tool_call_id": props.tool_call_id.clone(), + "content": props.output.clone(), + "is_error": props.is_error, + }); + if let Some(SessionMessage::ToolResults { results, .. }) = session.runtime_context.last_mut() { + results.push(result); + } else { + session.runtime_context.push(SessionMessage::ToolResults { + results: vec![result], + timestamp, + }); + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + use fabro_types::run_event::{ + RunSessionAssistantMessageProps, RunSessionCreatedProps, RunSessionToolCallCompletedProps, + RunSessionToolCallStartedProps, RunSessionTurnStartedProps, RunSessionTurnSucceededProps, + RunSessionUserMessageProps, + }; + use fabro_types::{EventBody, EventEnvelope, RunEvent, SessionMessage, TurnId, fixtures}; + use serde_json::json; + + use super::{project_run_session, project_run_session_with_context}; + + #[test] + fn projection_rebuilds_runtime_context_from_run_events() { + let session_id = fabro_types::SessionId::new(); + let turn_id = TurnId::new(); + let events = vec![ + event( + 1, + session_id, + EventBody::RunSessionCreated(RunSessionCreatedProps { + title: Some("Ask".to_string()), + model: Some("test-model".to_string()), + }), + ), + event( + 2, + session_id, + EventBody::RunSessionTurnStarted(RunSessionTurnStartedProps { + turn_id, + input: "What happened?".to_string(), + }), + ), + event( + 3, + session_id, + EventBody::RunSessionUserMessage(RunSessionUserMessageProps { + turn_id, + text: "What happened?".to_string(), + }), + ), + event( + 4, + session_id, + EventBody::RunSessionAssistantMessage(RunSessionAssistantMessageProps { + turn_id, + text: "The run finished.".to_string(), + model: Some("test-model".to_string()), + usage: json!({ "output_tokens": 4 }), + }), + ), + event( + 5, + session_id, + EventBody::RunSessionTurnSucceeded(RunSessionTurnSucceededProps { + turn_id, + output: Some("The run finished.".to_string()), + }), + ), + ]; + + let session = project_run_session_with_context(fixtures::RUN_1, session_id, &events) + .expect("session should project from run events"); + + assert_eq!(session.runtime_context.len(), 2); + assert!(matches!( + &session.runtime_context[0], + SessionMessage::User { content, .. } if content == "What happened?" + )); + assert!(matches!( + &session.runtime_context[1], + SessionMessage::Assistant { content, usage, .. } + if content == "The run finished." && usage == &json!({ "output_tokens": 4 }) + )); + } + + #[test] + fn projection_rebuilds_tool_calls_and_results() { + let session_id = fabro_types::SessionId::new(); + let turn_id = TurnId::new(); + let events = vec![ + event( + 1, + session_id, + EventBody::RunSessionCreated(RunSessionCreatedProps { + title: None, + model: None, + }), + ), + event( + 2, + session_id, + EventBody::RunSessionAssistantMessage(RunSessionAssistantMessageProps { + turn_id, + text: String::new(), + model: Some("test-model".to_string()), + usage: json!({}), + }), + ), + event( + 3, + session_id, + EventBody::RunSessionToolCallStarted(RunSessionToolCallStartedProps { + turn_id, + tool_name: "read_file".to_string(), + tool_call_id: "call_1".to_string(), + arguments: json!({ "path": "README.md" }), + }), + ), + event( + 4, + session_id, + EventBody::RunSessionToolCallCompleted(RunSessionToolCallCompletedProps { + turn_id, + tool_name: "read_file".to_string(), + tool_call_id: "call_1".to_string(), + output: json!("contents"), + is_error: false, + }), + ), + ]; + + let session = project_run_session_with_context(fixtures::RUN_1, session_id, &events) + .expect("session should project from run events"); + + assert!(matches!( + &session.runtime_context[0], + SessionMessage::Assistant { tool_calls, .. } + if tool_calls == &vec![json!({ + "id": "call_1", + "name": "read_file", + "arguments": { "path": "README.md" }, + })] + )); + assert!(matches!( + &session.runtime_context[1], + SessionMessage::ToolResults { results, .. } + if results == &vec![json!({ + "tool_call_id": "call_1", + "content": "contents", + "is_error": false, + })] + )); + } + + #[test] + fn public_session_record_projection_omits_runtime_context() { + let session_id = fabro_types::SessionId::new(); + let turn_id = TurnId::new(); + let events = vec![ + event( + 1, + session_id, + EventBody::RunSessionCreated(RunSessionCreatedProps { + title: Some("Ask".to_string()), + model: Some("test-model".to_string()), + }), + ), + event( + 2, + session_id, + EventBody::RunSessionUserMessage(RunSessionUserMessageProps { + turn_id, + text: "What happened?".to_string(), + }), + ), + ]; + + let session = project_run_session(fixtures::RUN_1, session_id, &events) + .expect("session should project from run events"); + assert_eq!(session.updated_at, events[1].event.ts); + let value = serde_json::to_value(session).expect("session should serialize"); + + assert!(value.get("runtime_context").is_none()); + assert!(value.get("working_dir").is_none()); + assert!(value.get("provider").is_none()); + assert!(value.get("permissions").is_none()); + assert!(value.get("deleted_at").is_none()); + } + + fn event(seq: u32, session_id: fabro_types::SessionId, body: EventBody) -> EventEnvelope { + let event = RunEvent { + id: format!("evt-{seq}"), + ts: Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, seq).unwrap(), + run_id: fixtures::RUN_1, + node_id: None, + node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, + session_id: Some(session_id.to_string()), + parent_session_id: None, + tool_call_id: None, + actor: None, + body, + }; + + EventEnvelope { seq, event } + } +} diff --git a/lib/crates/fabro-store/src/session_store.rs b/lib/crates/fabro-store/src/session_store.rs deleted file mode 100644 index dc26b6cc0..000000000 --- a/lib/crates/fabro-store/src/session_store.rs +++ /dev/null @@ -1,404 +0,0 @@ -use std::fs as std_fs; -use std::io::ErrorKind; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use chrono::{DateTime, Utc}; -use fabro_types::{ - SessionEventEnvelope, SessionId, SessionRecord, SessionStatus, SessionSummary, TurnId, - TurnRecord, TurnStatus, -}; -use serde::Serialize; -use serde::de::DeserializeOwned; -use tokio::fs; -use tokio::io::AsyncWriteExt as _; -use tokio::sync::Mutex; - -use crate::{Error, Result}; - -#[derive(Clone, Debug)] -pub struct SessionStore { - root: PathBuf, - write_lock: Arc>, -} - -impl SessionStore { - #[must_use] - pub fn new(root: impl Into) -> Self { - Self { - root: root.into(), - write_lock: Arc::new(Mutex::new(())), - } - } - - #[must_use] - pub fn root(&self) -> &Path { - &self.root - } - - pub async fn create_session(&self, mut record: SessionRecord) -> Result { - let _guard = self.write_lock.lock().await; - let dir = self.session_dir(record.id); - match fs::try_exists(&dir).await { - Ok(true) => return Err(Error::SessionAlreadyExists(record.id.to_string())), - Ok(false) => {} - Err(err) => return Err(err.into()), - } - fs::create_dir_all(dir.join("turns")).await?; - record.deleted_at = None; - write_json(&self.session_path(record.id), &record).await?; - Ok(record) - } - - pub async fn list_sessions(&self) -> Result> { - let mut summaries = Vec::new(); - let mut entries = match fs::read_dir(&self.root).await { - Ok(entries) => entries, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; - while let Some(entry) = entries.next_entry().await? { - let file_type = entry.file_type().await?; - if !file_type.is_dir() { - continue; - } - let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { - continue; - }; - let Ok(session_id) = name.parse::() else { - continue; - }; - let Some(record) = self.get_session_including_deleted(session_id).await? else { - continue; - }; - if record.deleted_at.is_none() { - summaries.push(SessionSummary::from(&record)); - } - } - summaries.sort_by(|a, b| { - b.updated_at - .cmp(&a.updated_at) - .then_with(|| a.id.cmp(&b.id)) - }); - Ok(summaries) - } - - pub async fn get_session(&self, id: SessionId) -> Result> { - let Some(record) = self.get_session_including_deleted(id).await? else { - return Ok(None); - }; - Ok(record.deleted_at.is_none().then_some(record)) - } - - async fn get_session_including_deleted(&self, id: SessionId) -> Result> { - read_optional_json(&self.session_path(id)).await - } - - pub async fn update_session(&self, record: SessionRecord) -> Result { - let _guard = self.write_lock.lock().await; - if self - .get_session_including_deleted(record.id) - .await? - .is_none() - { - return Err(Error::SessionNotFound(record.id.to_string())); - } - write_json(&self.session_path(record.id), &record).await?; - Ok(record) - } - - pub async fn delete_session(&self, id: SessionId) -> Result<()> { - let _guard = self.write_lock.lock().await; - let Some(mut record) = self.get_session_including_deleted(id).await? else { - return Ok(()); - }; - let now = Utc::now(); - record.status = SessionStatus::Deleted; - record.updated_at = now; - record.deleted_at = Some(now); - write_json(&self.session_path(id), &record).await - } - - #[expect( - clippy::disallowed_methods, - reason = "Server startup recovery runs from synchronous AppState construction before routes are served." - )] - pub fn recover_stale_running_state(&self, recovered_at: DateTime) -> Result<()> { - let entries = match std_fs::read_dir(&self.root) { - Ok(entries) => entries, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(err.into()), - }; - for entry in entries { - let entry = entry?; - if !entry.file_type()?.is_dir() { - continue; - } - let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { - continue; - }; - let Ok(session_id) = name.parse::() else { - continue; - }; - let session_path = self.session_path(session_id); - let mut session: SessionRecord = match read_json_sync(&session_path) { - Ok(session) => session, - Err(Error::Io(err)) if err.kind() == ErrorKind::NotFound => continue, - Err(Error::Serde(_)) => continue, - Err(err) => return Err(err), - }; - if session.deleted_at.is_some() { - continue; - } - if session.status == SessionStatus::Running { - session.status = SessionStatus::Idle; - session.updated_at = recovered_at; - write_json_sync(&session_path, &session)?; - } - self.recover_stale_turns(session_id, recovered_at)?; - } - Ok(()) - } - - #[expect( - clippy::disallowed_methods, - reason = "Server startup recovery runs from synchronous AppState construction before routes are served." - )] - fn recover_stale_turns( - &self, - session_id: SessionId, - recovered_at: DateTime, - ) -> Result<()> { - let entries = match std_fs::read_dir(self.turns_dir(session_id)) { - Ok(entries) => entries, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()), - Err(err) => return Err(err.into()), - }; - for entry in entries { - let entry = entry?; - if !entry.file_type()?.is_file() { - continue; - } - let path = entry.path(); - if !path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) - { - continue; - } - let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - if stem.parse::().is_err() { - continue; - } - let mut turn: TurnRecord = match read_json_sync(&path) { - Ok(turn) => turn, - Err(Error::Io(err)) if err.kind() == ErrorKind::NotFound => continue, - Err(Error::Serde(_)) => continue, - Err(err) => return Err(err), - }; - if turn.status != TurnStatus::Running { - continue; - } - turn.status = TurnStatus::Interrupted; - turn.updated_at = recovered_at; - turn.completed_at = Some(recovered_at); - turn.error = Some("Server restarted before the turn completed.".to_string()); - write_json_sync(&path, &turn)?; - } - Ok(()) - } - - pub async fn append_turn(&self, record: TurnRecord) -> Result { - let _guard = self.write_lock.lock().await; - if self.get_session(record.session_id).await?.is_none() { - return Err(Error::SessionNotFound(record.session_id.to_string())); - } - fs::create_dir_all(self.turns_dir(record.session_id)).await?; - write_json(&self.turn_path(record.session_id, record.id), &record).await?; - Ok(record) - } - - pub async fn update_turn(&self, record: TurnRecord) -> Result { - let _guard = self.write_lock.lock().await; - if self.get_session(record.session_id).await?.is_none() { - return Err(Error::SessionNotFound(record.session_id.to_string())); - } - let path = self.turn_path(record.session_id, record.id); - match fs::try_exists(&path).await { - Ok(true) => {} - Ok(false) => return Err(Error::SessionNotFound(record.id.to_string())), - Err(err) => return Err(err.into()), - } - write_json(&path, &record).await?; - Ok(record) - } - - pub async fn get_turn( - &self, - session_id: SessionId, - turn_id: TurnId, - ) -> Result> { - read_optional_json(&self.turn_path(session_id, turn_id)).await - } - - pub async fn list_turns(&self, session_id: SessionId) -> Result> { - let mut turns: Vec = Vec::new(); - let mut entries = match fs::read_dir(self.turns_dir(session_id)).await { - Ok(entries) => entries, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; - while let Some(entry) = entries.next_entry().await? { - let file_type = entry.file_type().await?; - if !file_type.is_file() { - continue; - } - let path = entry.path(); - if !path - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) - { - continue; - } - let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { - continue; - }; - if stem.parse::().is_err() { - continue; - } - turns.push(read_json(&path).await?); - } - turns.sort_by_key(|turn| turn.created_at); - Ok(turns) - } - - pub async fn append_event( - &self, - mut event: SessionEventEnvelope, - ) -> Result { - let _guard = self.write_lock.lock().await; - if self.get_session(event.session_id).await?.is_none() { - return Err(Error::SessionNotFound(event.session_id.to_string())); - } - let path = self.events_path(event.session_id); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await?; - } - event.seq = next_event_seq(&path).await?; - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .await?; - let mut bytes = serde_json::to_vec(&event)?; - bytes.push(b'\n'); - file.write_all(&bytes).await?; - file.flush().await?; - Ok(event) - } - - pub async fn list_events( - &self, - session_id: SessionId, - since_seq: Option, - ) -> Result> { - let start = since_seq.unwrap_or(1); - let path = self.events_path(session_id); - let contents = match fs::read_to_string(path).await { - Ok(contents) => contents, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err.into()), - }; - let mut events = Vec::new(); - for line in contents.lines().filter(|line| !line.trim().is_empty()) { - let event: SessionEventEnvelope = serde_json::from_str(line)?; - if event.seq >= start { - events.push(event); - } - } - events.sort_by_key(|event| event.seq); - Ok(events) - } - - fn session_dir(&self, id: SessionId) -> PathBuf { - self.root.join(id.to_string()) - } - - fn session_path(&self, id: SessionId) -> PathBuf { - self.session_dir(id).join("session.json") - } - - fn turns_dir(&self, session_id: SessionId) -> PathBuf { - self.session_dir(session_id).join("turns") - } - - fn turn_path(&self, session_id: SessionId, turn_id: TurnId) -> PathBuf { - self.turns_dir(session_id).join(format!("{turn_id}.json")) - } - - fn events_path(&self, session_id: SessionId) -> PathBuf { - self.session_dir(session_id).join("events.jsonl") - } -} - -async fn next_event_seq(path: &Path) -> Result { - let contents = match fs::read_to_string(path).await { - Ok(contents) => contents, - Err(err) if err.kind() == ErrorKind::NotFound => return Ok(1), - Err(err) => return Err(err.into()), - }; - let mut max_seq = 0; - for line in contents.lines().filter(|line| !line.trim().is_empty()) { - let event: SessionEventEnvelope = serde_json::from_str(line)?; - max_seq = max_seq.max(event.seq); - } - Ok(max_seq.saturating_add(1).max(1)) -} - -async fn write_json(path: &Path, value: &T) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).await?; - } - let mut bytes = serde_json::to_vec_pretty(value)?; - bytes.push(b'\n'); - fs::write(path, bytes).await?; - Ok(()) -} - -async fn read_json(path: &Path) -> Result { - let bytes = fs::read(path).await?; - Ok(serde_json::from_slice(&bytes)?) -} - -async fn read_optional_json(path: &Path) -> Result> { - match fs::read(path).await { - Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), - Err(err) => Err(err.into()), - } -} - -#[expect( - clippy::disallowed_methods, - reason = "Used only by synchronous server startup recovery before routes are served." -)] -fn write_json_sync(path: &Path, value: &T) -> Result<()> { - if let Some(parent) = path.parent() { - std_fs::create_dir_all(parent)?; - } - let mut bytes = serde_json::to_vec_pretty(value)?; - bytes.push(b'\n'); - std_fs::write(path, bytes)?; - Ok(()) -} - -#[expect( - clippy::disallowed_methods, - reason = "Used only by synchronous server startup recovery before routes are served." -)] -fn read_json_sync(path: &Path) -> Result { - let bytes = std_fs::read(path)?; - Ok(serde_json::from_slice(&bytes)?) -} diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index ed01a9e73..2b9cfc98a 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -14,7 +14,7 @@ pub use auth_codes::{AuthCode, AuthCodeStore}; pub use auth_tokens::{ConsumeOutcome, RefreshToken, RefreshTokenStore}; pub use blob_store::{Blob, BlobStore}; use chrono::{DateTime, Utc}; -use fabro_types::{Run, RunId}; +use fabro_types::{Run, RunId, SessionId}; use object_store::ObjectStore; pub use projection_cache::CachedRunProjection; use projection_cache::RunProjectionCache; @@ -34,6 +34,11 @@ pub struct UnreadableRun { pub error: String, } +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +struct SessionRunIndexEntry { + run_id: RunId, +} + #[derive(Clone)] pub struct Database { object_store: Arc, @@ -292,6 +297,29 @@ impl Database { Ok(self.projection_cache.get_summary(run_id).await) } + pub async fn put_session_run_index( + &self, + session_id: &SessionId, + run_id: &RunId, + ) -> Result<()> { + let db = self.open_db().await?; + db.put( + keys::session_by_id_key(session_id), + serde_json::to_vec(&SessionRunIndexEntry { run_id: *run_id })?, + ) + .await?; + Ok(()) + } + + pub async fn get_session_run_id(&self, session_id: &SessionId) -> Result> { + let db = self.open_db().await?; + if let Some(bytes) = db.get(keys::session_by_id_key(session_id)).await? { + let entry: SessionRunIndexEntry = serde_json::from_slice(&bytes)?; + return Ok(Some(entry.run_id)); + } + Ok(None) + } + pub(crate) async fn remove_cached_run(&self, run_id: &RunId) { self.projection_cache.remove(run_id).await; } @@ -315,11 +343,30 @@ impl Database { for key in keys_to_delete { db.delete(key).await?; } + self.delete_session_indexes_for_run(run_id).await?; self.catalog_index().await?.remove(run_id).await?; self.remove_cached_run(run_id).await; Ok(()) } + async fn delete_session_indexes_for_run(&self, run_id: &RunId) -> Result<()> { + let db = self.open_db().await?; + let mut keys_to_delete = Vec::new(); + let mut iter = db.scan_prefix(keys::sessions_by_id_prefix()).await?; + while let Some(entry) = iter.next().await? { + let index: SessionRunIndexEntry = serde_json::from_slice(&entry.value)?; + if index.run_id == *run_id { + keys_to_delete.push(String::from_utf8(entry.key.to_vec()).map_err(|err| { + Error::Other(format!("stored key is not valid UTF-8: {err}")) + })?); + } + } + for key in keys_to_delete { + db.delete(key).await?; + } + Ok(()) + } + pub async fn auth_codes(&self) -> Result> { let store = self .auth_codes diff --git a/lib/crates/fabro-store/src/slate/run_store.rs b/lib/crates/fabro-store/src/slate/run_store.rs index 11524ea5f..a72b7d1de 100644 --- a/lib/crates/fabro-store/src/slate/run_store.rs +++ b/lib/crates/fabro-store/src/slate/run_store.rs @@ -235,6 +235,10 @@ impl RunDatabase { impl RunDatabase { pub async fn append_event(&self, payload: &EventPayload) -> Result { + Ok(self.append_event_envelope(payload).await?.seq) + } + + pub async fn append_event_envelope(&self, payload: &EventPayload) -> Result { if self.read_only { return Err(Error::ReadOnly); } @@ -262,7 +266,7 @@ impl RunDatabase { match Self::build_cached_projection(&self.inner.db, &self.inner.run_id).await { Ok(Some(entry)) => { self.inner.shared_projection_cache.replace(entry).await; - return Ok(seq); + return Ok(event); } Ok(None) => { self.inner @@ -289,7 +293,7 @@ impl RunDatabase { ); return Err(err); } - Ok(seq) + Ok(event) } pub async fn list_events(&self) -> Result> { diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 66e9a6815..e64de3f97 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -118,8 +118,8 @@ pub use sandbox_services::{ }; pub use secret::{SecretMetadata, SecretType}; pub use session::{ - PermissionLevel, SessionEventEnvelope, SessionId, SessionMessage, SessionRecord, SessionStatus, - SessionSummary, TurnId, TurnRecord, TurnStatus, + PermissionLevel, SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, + TurnId, }; pub use stage_completion::StageCompletion; pub use stage_handler::StageHandler; diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 7bf7e6e7d..680ade98a 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod infra; pub mod misc; pub mod run; +pub mod session; pub mod stage; pub use agent::*; @@ -14,6 +15,7 @@ use serde::de::Error as DeError; use serde::ser::Error as SerError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value, json}; +pub use session::*; pub use stage::*; use crate::{ParallelBranchId, Principal, RunId, StageId}; @@ -96,6 +98,26 @@ pub enum EventBody { RunUnarchived(RunUnarchivedProps), #[serde(rename = "run.title.updated")] RunTitleUpdated(RunTitleUpdatedProps), + #[serde(rename = "run.session.created")] + RunSessionCreated(RunSessionCreatedProps), + #[serde(rename = "run.session.turn.started")] + RunSessionTurnStarted(RunSessionTurnStartedProps), + #[serde(rename = "run.session.user_message")] + RunSessionUserMessage(RunSessionUserMessageProps), + #[serde(rename = "run.session.assistant_delta")] + RunSessionAssistantDelta(RunSessionAssistantDeltaProps), + #[serde(rename = "run.session.assistant_message")] + RunSessionAssistantMessage(RunSessionAssistantMessageProps), + #[serde(rename = "run.session.tool_call.started")] + RunSessionToolCallStarted(RunSessionToolCallStartedProps), + #[serde(rename = "run.session.tool_call.completed")] + RunSessionToolCallCompleted(RunSessionToolCallCompletedProps), + #[serde(rename = "run.session.turn.succeeded")] + RunSessionTurnSucceeded(RunSessionTurnSucceededProps), + #[serde(rename = "run.session.turn.failed")] + RunSessionTurnFailed(RunSessionTurnFailedProps), + #[serde(rename = "run.session.turn.interrupted")] + RunSessionTurnInterrupted(RunSessionTurnInterruptedProps), #[serde(rename = "run.parent.linked")] RunParentLinked(RunParentLinkedProps), #[serde(rename = "run.parent.unlinked")] @@ -405,6 +427,16 @@ impl EventBody { Self::RunArchived(_) => "run.archived", Self::RunUnarchived(_) => "run.unarchived", Self::RunTitleUpdated(_) => "run.title.updated", + Self::RunSessionCreated(_) => "run.session.created", + Self::RunSessionTurnStarted(_) => "run.session.turn.started", + Self::RunSessionUserMessage(_) => "run.session.user_message", + Self::RunSessionAssistantDelta(_) => "run.session.assistant_delta", + Self::RunSessionAssistantMessage(_) => "run.session.assistant_message", + Self::RunSessionToolCallStarted(_) => "run.session.tool_call.started", + Self::RunSessionToolCallCompleted(_) => "run.session.tool_call.completed", + Self::RunSessionTurnSucceeded(_) => "run.session.turn.succeeded", + Self::RunSessionTurnFailed(_) => "run.session.turn.failed", + Self::RunSessionTurnInterrupted(_) => "run.session.turn.interrupted", Self::RunParentLinked(_) => "run.parent.linked", Self::RunParentUnlinked(_) => "run.parent.unlinked", Self::RunCompleted(_) => "run.completed", @@ -561,6 +593,17 @@ fn is_known_event_name(event: &str) -> bool { | "run.superseded_by" | "run.archived" | "run.unarchived" + | "run.title.updated" + | "run.session.created" + | "run.session.turn.started" + | "run.session.user_message" + | "run.session.assistant_delta" + | "run.session.assistant_message" + | "run.session.tool_call.started" + | "run.session.tool_call.completed" + | "run.session.turn.succeeded" + | "run.session.turn.failed" + | "run.session.turn.interrupted" | "run.parent.linked" | "run.parent.unlinked" | "run.completed" diff --git a/lib/crates/fabro-types/src/run_event/session.rs b/lib/crates/fabro-types/src/run_event/session.rs new file mode 100644 index 000000000..53e4773b8 --- /dev/null +++ b/lib/crates/fabro-types/src/run_event/session.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::TurnId; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionCreatedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionTurnStartedProps { + pub turn_id: TurnId, + pub input: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionUserMessageProps { + pub turn_id: TurnId, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionAssistantDeltaProps { + pub turn_id: TurnId, + pub delta: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionAssistantMessageProps { + pub turn_id: TurnId, + pub text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default)] + pub usage: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionToolCallStartedProps { + pub turn_id: TurnId, + pub tool_name: String, + pub tool_call_id: String, + pub arguments: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionToolCallCompletedProps { + pub turn_id: TurnId, + pub tool_name: String, + pub tool_call_id: String, + pub output: Value, + pub is_error: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionTurnSucceededProps { + pub turn_id: TurnId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionTurnFailedProps { + pub turn_id: TurnId, + pub error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSessionTurnInterruptedProps { + pub turn_id: TurnId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} diff --git a/lib/crates/fabro-types/src/session.rs b/lib/crates/fabro-types/src/session.rs index 3cc99fc32..e97101ae5 100644 --- a/lib/crates/fabro-types/src/session.rs +++ b/lib/crates/fabro-types/src/session.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; +use crate::RunId; use crate::id::ulid_id; ulid_id!(SessionId); @@ -39,8 +40,6 @@ pub enum SessionStatus { Idle, Running, Failed, - Closed, - Deleted, } impl SessionStatus { @@ -49,134 +48,56 @@ impl SessionStatus { } } -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum TurnStatus { - Running, - Succeeded, - Failed, - Interrupted, -} - -impl TurnStatus { - pub fn as_str(self) -> &'static str { - self.into() - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionRecord { - pub id: SessionId, - pub title: Option, - pub status: SessionStatus, - pub working_dir: Option, - pub provider: Option, - pub model: Option, - pub permissions: PermissionLevel, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, - #[serde(default)] - pub runtime_context: Vec, + pub id: SessionId, + pub run_id: RunId, + pub title: Option, + pub status: SessionStatus, + pub model: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } impl SessionRecord { - pub fn new(id: SessionId, now: DateTime) -> Self { + pub fn new(id: SessionId, run_id: RunId, now: DateTime) -> Self { Self { id, + run_id, title: None, status: SessionStatus::Idle, - working_dir: None, - provider: None, model: None, - permissions: PermissionLevel::ReadWrite, created_at: now, updated_at: now, - deleted_at: None, - runtime_context: Vec::new(), } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionSummary { - pub id: SessionId, - pub title: Option, - pub status: SessionStatus, - pub working_dir: Option, - pub provider: Option, - pub model: Option, - pub created_at: DateTime, - pub updated_at: DateTime, + pub id: SessionId, + pub run_id: RunId, + pub title: Option, + pub status: SessionStatus, + pub model: Option, + pub created_at: DateTime, + pub updated_at: DateTime, } impl From<&SessionRecord> for SessionSummary { fn from(record: &SessionRecord) -> Self { Self { - id: record.id, - title: record.title.clone(), - status: record.status, - working_dir: record.working_dir.clone(), - provider: record.provider.clone(), - model: record.model.clone(), - created_at: record.created_at, - updated_at: record.updated_at, + id: record.id, + run_id: record.run_id, + title: record.title.clone(), + status: record.status, + model: record.model.clone(), + created_at: record.created_at, + updated_at: record.updated_at, } } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TurnRecord { - pub id: TurnId, - pub session_id: SessionId, - pub input: String, - pub status: TurnStatus, - pub output: Option, - pub error: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub completed_at: Option>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SessionEventEnvelope { - pub seq: u32, - pub session_id: SessionId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_id: Option, - pub event: String, - pub properties: serde_json::Value, - pub ts: DateTime, -} - -impl SessionEventEnvelope { - pub fn new( - session_id: SessionId, - turn_id: Option, - event: impl Into, - properties: serde_json::Value, - ts: DateTime, - ) -> Self { - Self { - seq: 0, - session_id, - turn_id, - event: event.into(), - properties, - ts, - } - } - - #[must_use] - pub fn with_seq(mut self, seq: u32) -> Self { - self.seq = seq; - self - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SessionMessage { @@ -225,3 +146,16 @@ impl SessionMessage { } } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::SessionStatus; + + #[test] + fn session_status_rejects_removed_terminal_states() { + assert!(serde_json::from_value::(json!("closed")).is_err()); + assert!(serde_json::from_value::(json!("deleted")).is_err()); + } +} diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index bfc203488..0954e48c3 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -64,8 +64,8 @@ models/completion-usage.ts models/conclusion.ts models/create-completion-request.ts models/create-run-pull-request-request.ts +models/create-run-session-request.ts models/create-secret-request.ts -models/create-session-request.ts models/daytona-network-layer-one-of-allow-list.ts models/daytona-network-layer-one-of.ts models/daytona-network-layer.ts @@ -190,13 +190,28 @@ models/paginated-run-file-list.ts models/paginated-run-list.ts models/paginated-run-stage-list.ts models/paginated-saved-query-list.ts -models/paginated-session-event-list.ts models/paginated-session-list.ts -models/paginated-turn-list.ts models/paginated-workflow-list-response.ts models/pagination-meta.ts +models/pair-message-record.ts +models/pair-message-request.ts +models/pair-record.ts +models/pair-start-request.ts +models/pair-status.ts +models/pair-target-selector.ts +models/pair-target.ts +models/pair-transcript-assistant-message-model.ts +models/pair-transcript-assistant-message.ts +models/pair-transcript-detail-ref.ts +models/pair-transcript-entry.ts +models/pair-transcript-error.ts +models/pair-transcript-response-meta.ts +models/pair-transcript-response.ts +models/pair-transcript-system-message.ts +models/pair-transcript-tool-call.ts +models/pair-transcript-user-message.ts +models/pair-transcript-warning.ts models/pending-interview-record.ts -models/permission-level.ts models/pre-run-push-outcome-failed.ts models/pre-run-push-outcome-not-attempted.ts models/pre-run-push-outcome-skipped-no-remote.ts @@ -268,6 +283,9 @@ models/run-commits-meta.ts models/run-control-action.ts models/run-diff.ts models/run-error.ts +models/run-event-detail-response-content.ts +models/run-event-detail-response-event.ts +models/run-event-detail-response.ts models/run-event.ts models/run-execution-settings.ts models/run-failure.ts @@ -288,6 +306,7 @@ models/run-model-settings.ts models/run-model.ts models/run-namespace.ts models/run-origin.ts +models/run-pair-status-response.ts models/run-prepare-settings.ts models/run-projection.ts models/run-provenance.ts @@ -352,7 +371,6 @@ models/server-settings.ts models/server-slate-db-settings.ts models/server-storage-settings.ts models/server-web-settings.ts -models/session-event-envelope.ts models/session-message.ts models/session-record.ts models/session-status.ts @@ -392,11 +410,8 @@ models/system-resources-response.ts models/system-run-counts.ts models/timeline-entry-response.ts models/tls-mode.ts -models/turn-record.ts -models/turn-status.ts models/update-run-parent-request.ts models/update-run-request.ts -models/update-session-request.ts models/user-response.ts models/validate-response.ts models/vnc-preview-response.ts diff --git a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts index 0512b7b6d..189517deb 100644 --- a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts +++ b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts @@ -26,10 +26,22 @@ import type { ErrorResponse } from '../models'; // @ts-ignore import type { PaginatedApiQuestionList } from '../models'; // @ts-ignore +import type { PairMessageRecord } from '../models'; +// @ts-ignore +import type { PairMessageRequest } from '../models'; +// @ts-ignore +import type { PairRecord } from '../models'; +// @ts-ignore +import type { PairStartRequest } from '../models'; +// @ts-ignore +import type { PairTranscriptResponse } from '../models'; +// @ts-ignore import type { PreviewUrlRequest } from '../models'; // @ts-ignore import type { PreviewUrlResponse } from '../models'; // @ts-ignore +import type { RunPairStatusResponse } from '../models'; +// @ts-ignore import type { SandboxDetails } from '../models'; // @ts-ignore import type { SandboxFileListResponse } from '../models'; @@ -135,6 +147,50 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * + * @summary End Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + endRunPair: async (id: string, pairId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('endRunPair', 'id', id) + // verify required parameter 'pairId' is not null or undefined + assertParamExists('endRunPair', 'pairId', pairId) + const localVarPath = `/api/v1/runs/{id}/pair/{pair_id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))) + .replace(`{${"pair_id"}}`, encodeURIComponent(String(pairId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL @@ -180,6 +236,144 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * + * @summary Get Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunPair: async (id: string, pairId: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getRunPair', 'id', id) + // verify required parameter 'pairId' is not null or undefined + assertParamExists('getRunPair', 'pairId', pairId) + const localVarPath = `/api/v1/runs/{id}/pair/{pair_id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))) + .replace(`{${"pair_id"}}`, encodeURIComponent(String(pairId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get Run Pair Status + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunPairStatus: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getRunPairStatus', 'id', id) + const localVarPath = `/api/v1/runs/{id}/pair` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @summary Get Run Pair Transcript + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {number} [sinceSeq] First event sequence number to include. + * @param {number} [limit] Maximum number of events to return. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunPairTranscript: async (id: string, pairId: string, sinceSeq?: number, limit?: number, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getRunPairTranscript', 'id', id) + // verify required parameter 'pairId' is not null or undefined + assertParamExists('getRunPairTranscript', 'pairId', pairId) + const localVarPath = `/api/v1/runs/{id}/pair/{pair_id}/transcript` + .replace(`{${"id"}}`, encodeURIComponent(String(id))) + .replace(`{${"pair_id"}}`, encodeURIComponent(String(pairId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (sinceSeq !== undefined) { + localVarQueryParameter['since_seq'] = sinceSeq; + } + + if (limit !== undefined) { + localVarQueryParameter['limit'] = limit; + } + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File @@ -501,6 +695,100 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * + * @summary Send Run Pair Message + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {PairMessageRequest} pairMessageRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + sendRunPairMessage: async (id: string, pairId: string, pairMessageRequest: PairMessageRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('sendRunPairMessage', 'id', id) + // verify required parameter 'pairId' is not null or undefined + assertParamExists('sendRunPairMessage', 'pairId', pairId) + // verify required parameter 'pairMessageRequest' is not null or undefined + assertParamExists('sendRunPairMessage', 'pairMessageRequest', pairMessageRequest) + const localVarPath = `/api/v1/runs/{id}/pair/{pair_id}/messages` + .replace(`{${"id"}}`, encodeURIComponent(String(id))) + .replace(`{${"pair_id"}}`, encodeURIComponent(String(pairId))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(pairMessageRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Starts pairing with exactly one selected active API-mode agent target. + * @summary Start Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {PairStartRequest} pairStartRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startRunPair: async (id: string, pairStartRequest: PairStartRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('startRunPair', 'id', id) + // verify required parameter 'pairStartRequest' is not null or undefined + assertParamExists('startRunPair', 'pairStartRequest', pairStartRequest) + const localVarPath = `/api/v1/runs/{id}/pair` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(pairStartRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active steerable agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next steerable agent session. * @summary Steer Run @@ -631,6 +919,20 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.createSandboxVncPreview']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * + * @summary End Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async endRunPair(id: string, pairId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.endRunPair(id, pairId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.endRunPair']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL @@ -645,6 +947,49 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.generatePreviewUrl']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * + * @summary Get Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getRunPair(id: string, pairId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getRunPair(id, pairId, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.getRunPair']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get Run Pair Status + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getRunPairStatus(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getRunPairStatus(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.getRunPairStatus']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * + * @summary Get Run Pair Transcript + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {number} [sinceSeq] First event sequence number to include. + * @param {number} [limit] Maximum number of events to return. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getRunPairTranscript(id: string, pairId: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getRunPairTranscript(id, pairId, sinceSeq, limit, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.getRunPairTranscript']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File @@ -743,6 +1088,35 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.retrieveRunSandbox']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * + * @summary Send Run Pair Message + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {PairMessageRequest} pairMessageRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async sendRunPairMessage(id: string, pairId: string, pairMessageRequest: PairMessageRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.sendRunPairMessage(id, pairId, pairMessageRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.sendRunPairMessage']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Starts pairing with exactly one selected active API-mode agent target. + * @summary Start Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {PairStartRequest} pairStartRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async startRunPair(id: string, pairStartRequest: PairStartRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.startRunPair(id, pairStartRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.startRunPair']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active steerable agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next steerable agent session. * @summary Steer Run @@ -802,6 +1176,17 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, createSandboxVncPreview(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.createSandboxVncPreview(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary End Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + endRunPair(id: string, pairId: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.endRunPair(id, pairId, options).then((request) => request(axios, basePath)); + }, /** * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL @@ -813,6 +1198,40 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary Get Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunPair(id: string, pairId: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getRunPair(id, pairId, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get Run Pair Status + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunPairStatus(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getRunPairStatus(id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @summary Get Run Pair Transcript + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {number} [sinceSeq] First event sequence number to include. + * @param {number} [limit] Maximum number of events to return. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunPairTranscript(id: string, pairId: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getRunPairTranscript(id, pairId, sinceSeq, limit, options).then((request) => request(axios, basePath)); + }, /** * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File @@ -890,6 +1309,29 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, retrieveRunSandbox(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.retrieveRunSandbox(id, options).then((request) => request(axios, basePath)); }, + /** + * + * @summary Send Run Pair Message + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {PairMessageRequest} pairMessageRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + sendRunPairMessage(id: string, pairId: string, pairMessageRequest: PairMessageRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.sendRunPairMessage(id, pairId, pairMessageRequest, options).then((request) => request(axios, basePath)); + }, + /** + * Starts pairing with exactly one selected active API-mode agent target. + * @summary Start Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {PairStartRequest} pairStartRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + startRunPair(id: string, pairStartRequest: PairStartRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.startRunPair(id, pairStartRequest, options).then((request) => request(axios, basePath)); + }, /** * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active steerable agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next steerable agent session. * @summary Steer Run @@ -943,6 +1385,18 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).createSandboxVncPreview(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary End Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public endRunPair(id: string, pairId: string, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).endRunPair(id, pairId, options).then((request) => request(this.axios, this.basePath)); + } + /** * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL @@ -955,6 +1409,43 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary Get Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getRunPair(id: string, pairId: string, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).getRunPair(id, pairId, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get Run Pair Status + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getRunPairStatus(id: string, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).getRunPairStatus(id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @summary Get Run Pair Transcript + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {number} [sinceSeq] First event sequence number to include. + * @param {number} [limit] Maximum number of events to return. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getRunPairTranscript(id: string, pairId: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).getRunPairTranscript(id, pairId, sinceSeq, limit, options).then((request) => request(this.axios, this.basePath)); + } + /** * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File @@ -1039,6 +1530,31 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).retrieveRunSandbox(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @summary Send Run Pair Message + * @param {string} id Unique run identifier (ULID). + * @param {string} pairId + * @param {PairMessageRequest} pairMessageRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public sendRunPairMessage(id: string, pairId: string, pairMessageRequest: PairMessageRequest, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).sendRunPairMessage(id, pairId, pairMessageRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Starts pairing with exactly one selected active API-mode agent target. + * @summary Start Run Pair + * @param {string} id Unique run identifier (ULID). + * @param {PairStartRequest} pairStartRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public startRunPair(id: string, pairStartRequest: PairStartRequest, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).startRunPair(id, pairStartRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active steerable agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next steerable agent session. * @summary Steer Run @@ -1064,4 +1580,3 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).submitRunAnswer(id, qid, submitAnswerRequest, options).then((request) => request(this.axios, this.basePath)); } } - diff --git a/lib/packages/fabro-api-client/src/api/run-internals-api.ts b/lib/packages/fabro-api-client/src/api/run-internals-api.ts index 8735a190f..2ef4dbd6a 100644 --- a/lib/packages/fabro-api-client/src/api/run-internals-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-internals-api.ts @@ -5,7 +5,7 @@ * 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 @@ -40,6 +40,8 @@ import type { RunCheckpoint } from '../models'; // @ts-ignore import type { RunEvent } from '../models'; // @ts-ignore +import type { RunEventDetailResponse } from '../models'; +// @ts-ignore import type { RunProjection } from '../models'; // @ts-ignore import type { WorkflowSettings } from '../models'; @@ -56,7 +58,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config * Appends a validated event to the run event log. Intended for trusted internal callers. * @summary Append Run Event * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent + * @param {RunEvent} runEvent * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -142,6 +144,55 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config options: localVarRequestOptions, }; }, + /** + * Returns one stored run event by source event sequence with content fields separated and truncated. + * @summary Get Run Event Detail + * @param {string} id Unique run identifier (ULID). + * @param {number} seq + * @param {number} [maxContentLength] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunEventDetail: async (id: string, seq: number, maxContentLength?: number, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getRunEventDetail', 'id', id) + // verify required parameter 'seq' is not null or undefined + assertParamExists('getRunEventDetail', 'seq', seq) + const localVarPath = `/api/v1/runs/{id}/events/{seq}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))) + .replace(`{${"seq"}}`, encodeURIComponent(String(seq))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (maxContentLength !== undefined) { + localVarQueryParameter['max_content_length'] = maxContentLength; + } + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -573,12 +624,12 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. * @param {number} retry Retry attempt number for the artifact. - * @param {File} body + * @param {File} body * @param {string} [filename] Relative artifact path for `application/octet-stream` uploads. Ignored for multipart uploads. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -761,7 +812,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config * Writes an opaque binary blob and returns its content-addressed blob identifier. * @summary Write Run Blob * @param {string} id Unique run identifier (ULID). - * @param {File} body + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -815,7 +866,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { * Appends a validated event to the run event log. Intended for trusted internal callers. * @summary Append Run Event * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent + * @param {RunEvent} runEvent * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -839,6 +890,21 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.attachRunEvents']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Returns one stored run event by source event sequence with content fields separated and truncated. + * @summary Get Run Event Detail + * @param {string} id Unique run identifier (ULID). + * @param {number} seq + * @param {number} [maxContentLength] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getRunEventDetail(id: string, seq: number, maxContentLength?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getRunEventDetail(id, seq, maxContentLength, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.getRunEventDetail']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -971,12 +1037,12 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. * @param {number} retry Retry attempt number for the artifact. - * @param {File} body + * @param {File} body * @param {string} [filename] Relative artifact path for `application/octet-stream` uploads. Ignored for multipart uploads. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -1031,7 +1097,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { * Writes an opaque binary blob and returns its content-addressed blob identifier. * @summary Write Run Blob * @param {string} id Unique run identifier (ULID). - * @param {File} body + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1054,7 +1120,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b * Appends a validated event to the run event log. Intended for trusted internal callers. * @summary Append Run Event * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent + * @param {RunEvent} runEvent * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1072,6 +1138,18 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b attachRunEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.attachRunEvents(id, sinceSeq, options).then((request) => request(axios, basePath)); }, + /** + * Returns one stored run event by source event sequence with content fields separated and truncated. + * @summary Get Run Event Detail + * @param {string} id Unique run identifier (ULID). + * @param {number} seq + * @param {number} [maxContentLength] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getRunEventDetail(id: string, seq: number, maxContentLength?: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getRunEventDetail(id, seq, maxContentLength, options).then((request) => request(axios, basePath)); + }, /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -1177,12 +1255,12 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b return localVarFp.listStageEvents(id, stageId, sinceSeq, limit, options).then((request) => request(axios, basePath)); }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. * @param {number} retry Retry attempt number for the artifact. - * @param {File} body + * @param {File} body * @param {string} [filename] Relative artifact path for `application/octet-stream` uploads. Ignored for multipart uploads. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -1225,7 +1303,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b * Writes an opaque binary blob and returns its content-addressed blob identifier. * @summary Write Run Blob * @param {string} id Unique run identifier (ULID). - * @param {File} body + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1243,7 +1321,7 @@ export class RunInternalsApi extends BaseAPI { * Appends a validated event to the run event log. Intended for trusted internal callers. * @summary Append Run Event * @param {string} id Unique run identifier (ULID). - * @param {RunEvent} runEvent + * @param {RunEvent} runEvent * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1263,6 +1341,19 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).attachRunEvents(id, sinceSeq, options).then((request) => request(this.axios, this.basePath)); } + /** + * Returns one stored run event by source event sequence with content fields separated and truncated. + * @summary Get Run Event Detail + * @param {string} id Unique run identifier (ULID). + * @param {number} seq + * @param {number} [maxContentLength] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getRunEventDetail(id: string, seq: number, maxContentLength?: number, options?: RawAxiosRequestConfig) { + return RunInternalsApiFp(this.configuration).getRunEventDetail(id, seq, maxContentLength, options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns the worker tracing log for a run when it is available. * @summary Get Run Logs @@ -1377,12 +1468,12 @@ export class RunInternalsApi extends BaseAPI { } /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. * @param {number} retry Retry attempt number for the artifact. - * @param {File} body + * @param {File} body * @param {string} [filename] Relative artifact path for `application/octet-stream` uploads. Ignored for multipart uploads. * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -1429,7 +1520,7 @@ export class RunInternalsApi extends BaseAPI { * Writes an opaque binary blob and returns its content-addressed blob identifier. * @summary Write Run Blob * @param {string} id Unique run identifier (ULID). - * @param {File} body + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1437,4 +1528,3 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).writeRunBlob(id, body, options).then((request) => request(this.axios, this.basePath)); } } - diff --git a/lib/packages/fabro-api-client/src/api/sessions-api.ts b/lib/packages/fabro-api-client/src/api/sessions-api.ts index 7dfebba34..404b0e16d 100644 --- a/lib/packages/fabro-api-client/src/api/sessions-api.ts +++ b/lib/packages/fabro-api-client/src/api/sessions-api.ts @@ -5,7 +5,7 @@ * 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 @@ -22,41 +22,37 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj // @ts-ignore import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; // @ts-ignore -import type { CreateSessionRequest } from '../models'; +import type { CreateRunSessionRequest } from '../models'; // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore -import type { PaginatedSessionEventList } from '../models'; +import type { EventEnvelope } from '../models'; // @ts-ignore import type { PaginatedSessionList } from '../models'; // @ts-ignore -import type { PaginatedTurnList } from '../models'; -// @ts-ignore -import type { SessionEventEnvelope } from '../models'; -// @ts-ignore import type { SessionRecord } from '../models'; // @ts-ignore import type { SubmitTurnRequest } from '../models'; -// @ts-ignore -import type { TurnRecord } from '../models'; -// @ts-ignore -import type { UpdateSessionRequest } from '../models'; /** * SessionsApi - axios parameter creator */ export const SessionsApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * - * @summary Create session - * @param {CreateSessionRequest} createSessionRequest + * Creates a read-only Ask Fabro session bound to the run. + * @summary Create run session + * @param {string} id Unique run identifier (ULID). + * @param {CreateRunSessionRequest} createRunSessionRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createSession: async (createSessionRequest: CreateSessionRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'createSessionRequest' is not null or undefined - assertParamExists('createSession', 'createSessionRequest', createSessionRequest) - const localVarPath = `/api/v1/sessions`; + createRunSession: async (id: string, createRunSessionRequest: CreateRunSessionRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('createRunSession', 'id', id) + // verify required parameter 'createRunSessionRequest' is not null or undefined + assertParamExists('createRunSession', 'createRunSessionRequest', createRunSessionRequest) + const localVarPath = `/api/v1/runs/{id}/sessions` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -80,7 +76,7 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(createSessionRequest, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(createRunSessionRequest, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -88,48 +84,9 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat }; }, /** - * - * @summary Delete session - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - deleteSession: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('deleteSession', 'id', id) - const localVarPath = `/api/v1/sessions/{id}` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * + * * @summary Get session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -167,54 +124,10 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat }; }, /** - * - * @summary Get session turn - * @param {string} id - * @param {string} turnId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getSessionTurn: async (id: string, turnId: string, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('getSessionTurn', 'id', id) - // verify required parameter 'turnId' is not null or undefined - assertParamExists('getSessionTurn', 'turnId', turnId) - const localVarPath = `/api/v1/sessions/{id}/turns/{turnId}` - .replace(`{${"id"}}`, encodeURIComponent(String(id))) - .replace(`{${"turnId"}}`, encodeURIComponent(String(turnId))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * + * * @summary Interrupt a session turn - * @param {string} id - * @param {string} turnId + * @param {string} id + * @param {string} turnId * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -255,17 +168,16 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat }; }, /** - * Returns JSON replay by default. When the `Accept` header includes `text/event-stream`, replays durable events from `since_seq` or `Last-Event-ID`, then stays attached for live events. - * @summary List or stream session events - * @param {string} id - * @param {number} [sinceSeq] + * + * @summary List run sessions + * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSessionEvents: async (id: string, sinceSeq?: number, options: RawAxiosRequestConfig = {}): Promise => { + listRunSessions: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined - assertParamExists('listSessionEvents', 'id', id) - const localVarPath = `/api/v1/sessions/{id}/events` + assertParamExists('listRunSessions', 'id', id) + const localVarPath = `/api/v1/runs/{id}/sessions` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -284,86 +196,6 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (sinceSeq !== undefined) { - localVarQueryParameter['since_seq'] = sinceSeq; - } - - localVarHeaderParameter['Accept'] = 'application/json,text/event-stream'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @summary List session turns - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listSessionTurns: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('listSessionTurns', 'id', id) - const localVarPath = `/api/v1/sessions/{id}/turns` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @summary List sessions - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listSessions: async (options: RawAxiosRequestConfig = {}): Promise => { - const localVarPath = `/api/v1/sessions`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -378,8 +210,8 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat /** * Starts a streamed turn immediately. Background turns are not supported in this API version. * @summary Submit a session turn - * @param {string} id - * @param {SubmitTurnRequest} submitTurnRequest + * @param {string} id + * @param {SubmitTurnRequest} submitTurnRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -415,51 +247,6 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.data = serializeDataIfNeeded(submitTurnRequest, localVarRequestOptions, configuration) - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @summary Update session - * @param {string} id - * @param {UpdateSessionRequest} updateSessionRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateSession: async (id: string, updateSessionRequest: UpdateSessionRequest, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('updateSession', 'id', id) - // verify required parameter 'updateSessionRequest' is not null or undefined - assertParamExists('updateSession', 'updateSessionRequest', updateSessionRequest) - const localVarPath = `/api/v1/sessions/{id}` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication SessionCookie required - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - localVarHeaderParameter['Content-Type'] = 'application/json'; - localVarHeaderParameter['Accept'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(updateSessionRequest, localVarRequestOptions, configuration) - return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -475,35 +262,23 @@ export const SessionsApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = SessionsApiAxiosParamCreator(configuration) return { /** - * - * @summary Create session - * @param {CreateSessionRequest} createSessionRequest + * Creates a read-only Ask Fabro session bound to the run. + * @summary Create run session + * @param {string} id Unique run identifier (ULID). + * @param {CreateRunSessionRequest} createRunSessionRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async createSession(createSessionRequest: CreateSessionRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createSession(createSessionRequest, options); + async createRunSession(id: string, createRunSessionRequest: CreateRunSessionRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createRunSession(id, createRunSessionRequest, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SessionsApi.createSession']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['SessionsApi.createRunSession']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * - * @summary Delete session - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async deleteSession(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.deleteSession(id, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SessionsApi.deleteSession']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * + * * @summary Get session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -514,77 +289,37 @@ export const SessionsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * - * @summary Get session turn - * @param {string} id - * @param {string} turnId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async getSessionTurn(id: string, turnId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getSessionTurn(id, turnId, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SessionsApi.getSessionTurn']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * + * * @summary Interrupt a session turn - * @param {string} id - * @param {string} turnId + * @param {string} id + * @param {string} turnId * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async interruptSessionTurn(id: string, turnId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async interruptSessionTurn(id: string, turnId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.interruptSessionTurn(id, turnId, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['SessionsApi.interruptSessionTurn']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Returns JSON replay by default. When the `Accept` header includes `text/event-stream`, replays durable events from `since_seq` or `Last-Event-ID`, then stays attached for live events. - * @summary List or stream session events - * @param {string} id - * @param {number} [sinceSeq] + * + * @summary List run sessions + * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listSessionEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listSessionEvents(id, sinceSeq, options); + async listRunSessions(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRunSessions(id, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SessionsApi.listSessionEvents']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * - * @summary List session turns - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listSessionTurns(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listSessionTurns(id, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SessionsApi.listSessionTurns']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, - /** - * - * @summary List sessions - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async listSessions(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listSessions(options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SessionsApi.listSessions']?.[localVarOperationServerIndex]?.url; + const localVarOperationServerBasePath = operationServerMap['SessionsApi.listRunSessions']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** * Starts a streamed turn immediately. Background turns are not supported in this API version. * @summary Submit a session turn - * @param {string} id - * @param {SubmitTurnRequest} submitTurnRequest + * @param {string} id + * @param {SubmitTurnRequest} submitTurnRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -594,20 +329,6 @@ export const SessionsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['SessionsApi.submitSessionTurn']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, - /** - * - * @summary Update session - * @param {string} id - * @param {UpdateSessionRequest} updateSessionRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async updateSession(id: string, updateSessionRequest: UpdateSessionRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateSession(id, updateSessionRequest, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['SessionsApi.updateSession']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, } }; @@ -618,29 +339,20 @@ export const SessionsApiFactory = function (configuration?: Configuration, baseP const localVarFp = SessionsApiFp(configuration) return { /** - * - * @summary Create session - * @param {CreateSessionRequest} createSessionRequest + * Creates a read-only Ask Fabro session bound to the run. + * @summary Create run session + * @param {string} id Unique run identifier (ULID). + * @param {CreateRunSessionRequest} createRunSessionRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createSession(createSessionRequest: CreateSessionRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.createSession(createSessionRequest, options).then((request) => request(axios, basePath)); + createRunSession(id: string, createRunSessionRequest: CreateRunSessionRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createRunSession(id, createRunSessionRequest, options).then((request) => request(axios, basePath)); }, /** - * - * @summary Delete session - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - deleteSession(id: string, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.deleteSession(id, options).then((request) => request(axios, basePath)); - }, - /** - * + * * @summary Get session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -648,79 +360,37 @@ export const SessionsApiFactory = function (configuration?: Configuration, baseP return localVarFp.getSession(id, options).then((request) => request(axios, basePath)); }, /** - * - * @summary Get session turn - * @param {string} id - * @param {string} turnId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getSessionTurn(id: string, turnId: string, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.getSessionTurn(id, turnId, options).then((request) => request(axios, basePath)); - }, - /** - * + * * @summary Interrupt a session turn - * @param {string} id - * @param {string} turnId + * @param {string} id + * @param {string} turnId * @param {*} [options] Override http request option. * @throws {RequiredError} */ - interruptSessionTurn(id: string, turnId: string, options?: RawAxiosRequestConfig): AxiosPromise { + interruptSessionTurn(id: string, turnId: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.interruptSessionTurn(id, turnId, options).then((request) => request(axios, basePath)); }, /** - * Returns JSON replay by default. When the `Accept` header includes `text/event-stream`, replays durable events from `since_seq` or `Last-Event-ID`, then stays attached for live events. - * @summary List or stream session events - * @param {string} id - * @param {number} [sinceSeq] + * + * @summary List run sessions + * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listSessionEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.listSessionEvents(id, sinceSeq, options).then((request) => request(axios, basePath)); - }, - /** - * - * @summary List session turns - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listSessionTurns(id: string, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.listSessionTurns(id, options).then((request) => request(axios, basePath)); - }, - /** - * - * @summary List sessions - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - listSessions(options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.listSessions(options).then((request) => request(axios, basePath)); + listRunSessions(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listRunSessions(id, options).then((request) => request(axios, basePath)); }, /** * Starts a streamed turn immediately. Background turns are not supported in this API version. * @summary Submit a session turn - * @param {string} id - * @param {SubmitTurnRequest} submitTurnRequest + * @param {string} id + * @param {SubmitTurnRequest} submitTurnRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ submitSessionTurn(id: string, submitTurnRequest: SubmitTurnRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.submitSessionTurn(id, submitTurnRequest, options).then((request) => request(axios, basePath)); }, - /** - * - * @summary Update session - * @param {string} id - * @param {UpdateSessionRequest} updateSessionRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - updateSession(id: string, updateSessionRequest: UpdateSessionRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.updateSession(id, updateSessionRequest, options).then((request) => request(axios, basePath)); - }, }; }; @@ -729,31 +399,21 @@ export const SessionsApiFactory = function (configuration?: Configuration, baseP */ export class SessionsApi extends BaseAPI { /** - * - * @summary Create session - * @param {CreateSessionRequest} createSessionRequest + * Creates a read-only Ask Fabro session bound to the run. + * @summary Create run session + * @param {string} id Unique run identifier (ULID). + * @param {CreateRunSessionRequest} createRunSessionRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public createSession(createSessionRequest: CreateSessionRequest, options?: RawAxiosRequestConfig) { - return SessionsApiFp(this.configuration).createSession(createSessionRequest, options).then((request) => request(this.axios, this.basePath)); + public createRunSession(id: string, createRunSessionRequest: CreateRunSessionRequest, options?: RawAxiosRequestConfig) { + return SessionsApiFp(this.configuration).createRunSession(id, createRunSessionRequest, options).then((request) => request(this.axios, this.basePath)); } /** - * - * @summary Delete session - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public deleteSession(id: string, options?: RawAxiosRequestConfig) { - return SessionsApiFp(this.configuration).deleteSession(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * + * * @summary Get session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -762,22 +422,10 @@ export class SessionsApi extends BaseAPI { } /** - * - * @summary Get session turn - * @param {string} id - * @param {string} turnId - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public getSessionTurn(id: string, turnId: string, options?: RawAxiosRequestConfig) { - return SessionsApiFp(this.configuration).getSessionTurn(id, turnId, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * + * * @summary Interrupt a session turn - * @param {string} id - * @param {string} turnId + * @param {string} id + * @param {string} turnId * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -786,60 +434,25 @@ export class SessionsApi extends BaseAPI { } /** - * Returns JSON replay by default. When the `Accept` header includes `text/event-stream`, replays durable events from `since_seq` or `Last-Event-ID`, then stays attached for live events. - * @summary List or stream session events - * @param {string} id - * @param {number} [sinceSeq] + * + * @summary List run sessions + * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public listSessionEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig) { - return SessionsApiFp(this.configuration).listSessionEvents(id, sinceSeq, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @summary List session turns - * @param {string} id - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public listSessionTurns(id: string, options?: RawAxiosRequestConfig) { - return SessionsApiFp(this.configuration).listSessionTurns(id, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @summary List sessions - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public listSessions(options?: RawAxiosRequestConfig) { - return SessionsApiFp(this.configuration).listSessions(options).then((request) => request(this.axios, this.basePath)); + public listRunSessions(id: string, options?: RawAxiosRequestConfig) { + return SessionsApiFp(this.configuration).listRunSessions(id, options).then((request) => request(this.axios, this.basePath)); } /** * Starts a streamed turn immediately. Background turns are not supported in this API version. * @summary Submit a session turn - * @param {string} id - * @param {SubmitTurnRequest} submitTurnRequest + * @param {string} id + * @param {SubmitTurnRequest} submitTurnRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ public submitSessionTurn(id: string, submitTurnRequest: SubmitTurnRequest, options?: RawAxiosRequestConfig) { return SessionsApiFp(this.configuration).submitSessionTurn(id, submitTurnRequest, options).then((request) => request(this.axios, this.basePath)); } - - /** - * - * @summary Update session - * @param {string} id - * @param {UpdateSessionRequest} updateSessionRequest - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - public updateSession(id: string, updateSessionRequest: UpdateSessionRequest, options?: RawAxiosRequestConfig) { - return SessionsApiFp(this.configuration).updateSession(id, updateSessionRequest, options).then((request) => request(this.axios, this.basePath)); - } } - diff --git a/lib/packages/fabro-api-client/src/models/permission-level.ts b/lib/packages/fabro-api-client/src/models/create-run-session-request.ts similarity index 55% rename from lib/packages/fabro-api-client/src/models/permission-level.ts rename to lib/packages/fabro-api-client/src/models/create-run-session-request.ts index 0b4042c99..6b11c4b39 100644 --- a/lib/packages/fabro-api-client/src/models/permission-level.ts +++ b/lib/packages/fabro-api-client/src/models/create-run-session-request.ts @@ -5,7 +5,7 @@ * 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 @@ -14,17 +14,10 @@ -/** - * Agent tool permission level applied to a session. - */ - -export const PermissionLevel = { - READ_ONLY: 'read-only', - READ_WRITE: 'read-write', - FULL: 'full' -} as const; - -export type PermissionLevel = typeof PermissionLevel[keyof typeof PermissionLevel]; - - - +export interface CreateRunSessionRequest { + 'title'?: string; + /** + * Catalog model ID or alias, or provider-qualified provider/model reference. Stored as the canonical catalog model ID. + */ + 'model'?: string; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index e258475fe..ee5532b9a 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -41,8 +41,8 @@ export * from './completion-usage'; export * from './conclusion'; export * from './create-completion-request'; export * from './create-run-pull-request-request'; +export * from './create-run-session-request'; export * from './create-secret-request'; -export * from './create-session-request'; export * from './daytona-network-layer'; export * from './daytona-network-layer-one-of'; export * from './daytona-network-layer-one-of-allow-list'; @@ -166,13 +166,28 @@ export * from './paginated-run-file-list'; export * from './paginated-run-list'; export * from './paginated-run-stage-list'; export * from './paginated-saved-query-list'; -export * from './paginated-session-event-list'; export * from './paginated-session-list'; -export * from './paginated-turn-list'; export * from './paginated-workflow-list-response'; export * from './pagination-meta'; +export * from './pair-message-record'; +export * from './pair-message-request'; +export * from './pair-record'; +export * from './pair-start-request'; +export * from './pair-status'; +export * from './pair-target'; +export * from './pair-target-selector'; +export * from './pair-transcript-assistant-message'; +export * from './pair-transcript-assistant-message-model'; +export * from './pair-transcript-detail-ref'; +export * from './pair-transcript-entry'; +export * from './pair-transcript-error'; +export * from './pair-transcript-response'; +export * from './pair-transcript-response-meta'; +export * from './pair-transcript-system-message'; +export * from './pair-transcript-tool-call'; +export * from './pair-transcript-user-message'; +export * from './pair-transcript-warning'; export * from './pending-interview-record'; -export * from './permission-level'; export * from './pre-run-push-outcome'; export * from './pre-run-push-outcome-failed'; export * from './pre-run-push-outcome-not-attempted'; @@ -246,6 +261,9 @@ export * from './run-control-action'; export * from './run-diff'; export * from './run-error'; export * from './run-event'; +export * from './run-event-detail-response'; +export * from './run-event-detail-response-content'; +export * from './run-event-detail-response-event'; export * from './run-execution-settings'; export * from './run-failure'; export * from './run-files-meta'; @@ -265,6 +283,7 @@ export * from './run-model'; export * from './run-model-settings'; export * from './run-namespace'; export * from './run-origin'; +export * from './run-pair-status-response'; export * from './run-prepare-settings'; export * from './run-projection'; export * from './run-provenance'; @@ -328,7 +347,6 @@ export * from './server-settings'; export * from './server-slate-db-settings'; export * from './server-storage-settings'; export * from './server-web-settings'; -export * from './session-event-envelope'; export * from './session-message'; export * from './session-record'; export * from './session-status'; @@ -368,11 +386,8 @@ export * from './system-resources-response'; export * from './system-run-counts'; export * from './timeline-entry-response'; export * from './tls-mode'; -export * from './turn-record'; -export * from './turn-status'; export * from './update-run-parent-request'; export * from './update-run-request'; -export * from './update-session-request'; export * from './user-response'; export * from './validate-response'; export * from './vnc-preview-response'; diff --git a/lib/packages/fabro-api-client/src/models/pair-message-record.ts b/lib/packages/fabro-api-client/src/models/pair-message-record.ts new file mode 100644 index 000000000..5f7407669 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-message-record.ts @@ -0,0 +1,34 @@ +/* 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 { PairTargetSelector } from './pair-target-selector'; + +export interface PairMessageRecord { + /** + * Durable pair message identifier. + */ + 'message_id': string; + 'client_message_id'?: string | null; + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'run_id': string; + 'target': PairTargetSelector; + 'text': string; + 'accepted_at': string; +} diff --git a/lib/packages/fabro-api-client/src/models/pair-message-request.ts b/lib/packages/fabro-api-client/src/models/pair-message-request.ts new file mode 100644 index 000000000..2a6607d94 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-message-request.ts @@ -0,0 +1,20 @@ +/* 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 PairMessageRequest { + 'text': string; + 'client_message_id'?: string; +} diff --git a/lib/packages/fabro-api-client/src/models/pair-record.ts b/lib/packages/fabro-api-client/src/models/pair-record.ts new file mode 100644 index 000000000..34b029029 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-record.ts @@ -0,0 +1,34 @@ +/* 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 { PairStatus } from './pair-status'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTarget } from './pair-target'; + +export interface PairRecord { + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'run_id': string; + 'status': PairStatus; + 'started_at': string; + 'ended_at'?: string | null; + 'failure_reason'?: string | null; + 'target': PairTarget; +} diff --git a/lib/packages/fabro-api-client/src/models/create-session-request.ts b/lib/packages/fabro-api-client/src/models/pair-start-request.ts similarity index 62% rename from lib/packages/fabro-api-client/src/models/create-session-request.ts rename to lib/packages/fabro-api-client/src/models/pair-start-request.ts index ab72408b5..677571c21 100644 --- a/lib/packages/fabro-api-client/src/models/create-session-request.ts +++ b/lib/packages/fabro-api-client/src/models/pair-start-request.ts @@ -5,7 +5,7 @@ * 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 @@ -15,15 +15,8 @@ // May contain unused imports in some cases // @ts-ignore -import type { PermissionLevel } from './permission-level'; +import type { PairTargetSelector } from './pair-target-selector'; -export interface CreateSessionRequest { - 'title'?: string; - 'working_dir'?: string; - 'provider'?: string; - 'model'?: string; - 'permissions': PermissionLevel; +export interface PairStartRequest { + 'target': PairTargetSelector; } - - - diff --git a/lib/packages/fabro-api-client/src/models/turn-status.ts b/lib/packages/fabro-api-client/src/models/pair-status.ts similarity index 63% rename from lib/packages/fabro-api-client/src/models/turn-status.ts rename to lib/packages/fabro-api-client/src/models/pair-status.ts index b2e2cc894..8b64c2256 100644 --- a/lib/packages/fabro-api-client/src/models/turn-status.ts +++ b/lib/packages/fabro-api-client/src/models/pair-status.ts @@ -5,7 +5,7 @@ * 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 @@ -15,14 +15,10 @@ -export const TurnStatus = { - RUNNING: 'running', - SUCCEEDED: 'succeeded', - FAILED: 'failed', - INTERRUPTED: 'interrupted' +export const PairStatus = { + ACTIVE: 'active', + ENDED: 'ended', + FAILED: 'failed' } as const; -export type TurnStatus = typeof TurnStatus[keyof typeof TurnStatus]; - - - +export type PairStatus = typeof PairStatus[keyof typeof PairStatus]; diff --git a/lib/packages/fabro-api-client/src/models/pair-target-selector.ts b/lib/packages/fabro-api-client/src/models/pair-target-selector.ts new file mode 100644 index 000000000..966bce5dd --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-target-selector.ts @@ -0,0 +1,20 @@ +/* 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 PairTargetSelector { + 'stage_id': string; + 'agent_session_id': string; +} diff --git a/lib/packages/fabro-api-client/src/models/pair-target.ts b/lib/packages/fabro-api-client/src/models/pair-target.ts new file mode 100644 index 000000000..a6f271b9e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-target.ts @@ -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 PairTarget { + 'stage_id': string; + 'node_id': string; + 'node_label': string; + 'visit': number; + 'agent_session_id': string; + 'provider'?: string; + 'model'?: string; +} diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message-model.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message-model.ts new file mode 100644 index 000000000..4418424d8 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message-model.ts @@ -0,0 +1,21 @@ +/* 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 PairTranscriptAssistantMessageModel { + 'provider': string; + 'model_id': string; + 'speed'?: string | null; +} diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message.ts new file mode 100644 index 000000000..725199548 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-assistant-message.ts @@ -0,0 +1,42 @@ +/* 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 { PairTarget } from './pair-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptAssistantMessageModel } from './pair-transcript-assistant-message-model'; + +export interface PairTranscriptAssistantMessage { + 'kind': PairTranscriptAssistantMessageKindEnum; + 'seq': number; + 'event_id': string; + 'ts': string; + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'target': PairTarget; + 'text': string; + 'model': PairTranscriptAssistantMessageModel; + 'tool_call_count': number; +} + +export const PairTranscriptAssistantMessageKindEnum = { + ASSISTANT_MESSAGE: 'assistant_message' +} as const; + +export type PairTranscriptAssistantMessageKindEnum = typeof PairTranscriptAssistantMessageKindEnum[keyof typeof PairTranscriptAssistantMessageKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/update-session-request.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-detail-ref.ts similarity index 78% rename from lib/packages/fabro-api-client/src/models/update-session-request.ts rename to lib/packages/fabro-api-client/src/models/pair-transcript-detail-ref.ts index d1b0bd395..7c168a332 100644 --- a/lib/packages/fabro-api-client/src/models/update-session-request.ts +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-detail-ref.ts @@ -5,7 +5,7 @@ * 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 @@ -14,7 +14,7 @@ -export interface UpdateSessionRequest { - 'title'?: string; +export interface PairTranscriptDetailRef { + 'seq': number; + 'tool_call_id'?: string; } - diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-entry.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-entry.ts new file mode 100644 index 000000000..b55e10c18 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-entry.ts @@ -0,0 +1,47 @@ +/* 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 { PairTarget } from './pair-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptAssistantMessage } from './pair-transcript-assistant-message'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptAssistantMessageModel } from './pair-transcript-assistant-message-model'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptDetailRef } from './pair-transcript-detail-ref'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptError } from './pair-transcript-error'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptSystemMessage } from './pair-transcript-system-message'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptToolCall } from './pair-transcript-tool-call'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptUserMessage } from './pair-transcript-user-message'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptWarning } from './pair-transcript-warning'; + +/** + * @type PairTranscriptEntry + */ +export type PairTranscriptEntry = PairTranscriptAssistantMessage | PairTranscriptError | PairTranscriptSystemMessage | PairTranscriptToolCall | PairTranscriptUserMessage | PairTranscriptWarning; diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-error.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-error.ts new file mode 100644 index 000000000..ccab55770 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-error.ts @@ -0,0 +1,41 @@ +/* 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 { PairTarget } from './pair-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptDetailRef } from './pair-transcript-detail-ref'; + +export interface PairTranscriptError { + 'kind': PairTranscriptErrorKindEnum; + 'seq': number; + 'event_id': string; + 'ts': string; + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'target': PairTarget; + 'message': string; + 'detail_ref': PairTranscriptDetailRef; +} + +export const PairTranscriptErrorKindEnum = { + ERROR: 'error' +} as const; + +export type PairTranscriptErrorKindEnum = typeof PairTranscriptErrorKindEnum[keyof typeof PairTranscriptErrorKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-response-meta.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-response-meta.ts new file mode 100644 index 000000000..6638978f6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-response-meta.ts @@ -0,0 +1,20 @@ +/* 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 PairTranscriptResponseMeta { + 'next_since_seq': number; + 'has_more': boolean; +} diff --git a/lib/packages/fabro-api-client/src/models/paginated-session-event-list.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-response.ts similarity index 61% rename from lib/packages/fabro-api-client/src/models/paginated-session-event-list.ts rename to lib/packages/fabro-api-client/src/models/pair-transcript-response.ts index 96a2f06ba..dfd8b6168 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-session-event-list.ts +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-response.ts @@ -5,7 +5,7 @@ * 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 @@ -15,16 +15,12 @@ // May contain unused imports in some cases // @ts-ignore -import type { PaginationMeta } from './pagination-meta'; +import type { PairTranscriptEntry } from './pair-transcript-entry'; // May contain unused imports in some cases // @ts-ignore -import type { SessionEventEnvelope } from './session-event-envelope'; +import type { PairTranscriptResponseMeta } from './pair-transcript-response-meta'; -/** - * Paginated list of session events. - */ -export interface PaginatedSessionEventList { - 'data': Array; - 'meta': PaginationMeta; +export interface PairTranscriptResponse { + 'data': Array; + 'meta': PairTranscriptResponseMeta; } - diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-system-message.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-system-message.ts new file mode 100644 index 000000000..674db665a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-system-message.ts @@ -0,0 +1,44 @@ +/* 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 { PairTarget } from './pair-target'; + +export interface PairTranscriptSystemMessage { + 'kind': PairTranscriptSystemMessageKindEnum; + 'seq': number; + 'event_id': string; + 'ts': string; + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'target': PairTarget; + 'system_message_kind': PairTranscriptSystemMessageSystemMessageKindEnum; + 'text': string; +} + +export const PairTranscriptSystemMessageKindEnum = { + SYSTEM_MESSAGE: 'system_message' +} as const; + +export type PairTranscriptSystemMessageKindEnum = typeof PairTranscriptSystemMessageKindEnum[keyof typeof PairTranscriptSystemMessageKindEnum]; +export const PairTranscriptSystemMessageSystemMessageKindEnum = { + HUMAN_JOINED: 'human_joined', + HUMAN_LEFT: 'human_left' +} as const; + +export type PairTranscriptSystemMessageSystemMessageKindEnum = typeof PairTranscriptSystemMessageSystemMessageKindEnum[keyof typeof PairTranscriptSystemMessageSystemMessageKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-tool-call.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-tool-call.ts new file mode 100644 index 000000000..3c37513f1 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-tool-call.ts @@ -0,0 +1,52 @@ +/* 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 { PairTarget } from './pair-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptDetailRef } from './pair-transcript-detail-ref'; + +export interface PairTranscriptToolCall { + 'kind': PairTranscriptToolCallKindEnum; + 'seq': number; + 'event_id': string; + 'ts': string; + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'target': PairTarget; + 'tool_call_id': string; + 'tool_name': string; + 'status': PairTranscriptToolCallStatusEnum; + 'summary': string; + 'is_error': boolean; + 'truncated': boolean; + 'detail_ref': PairTranscriptDetailRef; +} + +export const PairTranscriptToolCallKindEnum = { + TOOL_CALL: 'tool_call' +} as const; + +export type PairTranscriptToolCallKindEnum = typeof PairTranscriptToolCallKindEnum[keyof typeof PairTranscriptToolCallKindEnum]; +export const PairTranscriptToolCallStatusEnum = { + STARTED: 'started', + COMPLETED: 'completed' +} as const; + +export type PairTranscriptToolCallStatusEnum = typeof PairTranscriptToolCallStatusEnum[keyof typeof PairTranscriptToolCallStatusEnum]; diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-user-message.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-user-message.ts new file mode 100644 index 000000000..756bd0617 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-user-message.ts @@ -0,0 +1,42 @@ +/* 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 { PairTarget } from './pair-target'; + +export interface PairTranscriptUserMessage { + 'kind': PairTranscriptUserMessageKindEnum; + 'seq': number; + 'event_id': string; + 'ts': string; + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'target': PairTarget; + /** + * Durable pair message identifier. + */ + 'message_id': string; + 'client_message_id'?: string | null; + 'text': string; +} + +export const PairTranscriptUserMessageKindEnum = { + USER_MESSAGE: 'user_message' +} as const; + +export type PairTranscriptUserMessageKindEnum = typeof PairTranscriptUserMessageKindEnum[keyof typeof PairTranscriptUserMessageKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/pair-transcript-warning.ts b/lib/packages/fabro-api-client/src/models/pair-transcript-warning.ts new file mode 100644 index 000000000..7c8cfb8b1 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pair-transcript-warning.ts @@ -0,0 +1,42 @@ +/* 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 { PairTarget } from './pair-target'; +// May contain unused imports in some cases +// @ts-ignore +import type { PairTranscriptDetailRef } from './pair-transcript-detail-ref'; + +export interface PairTranscriptWarning { + 'kind': PairTranscriptWarningKindEnum; + 'seq': number; + 'event_id': string; + 'ts': string; + /** + * Durable run pair identifier. + */ + 'pair_id': string; + 'target': PairTarget; + 'warning_kind': string; + 'message': string; + 'detail_ref': PairTranscriptDetailRef; +} + +export const PairTranscriptWarningKindEnum = { + WARNING: 'warning' +} as const; + +export type PairTranscriptWarningKindEnum = typeof PairTranscriptWarningKindEnum[keyof typeof PairTranscriptWarningKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/run-event-detail-response-content.ts b/lib/packages/fabro-api-client/src/models/run-event-detail-response-content.ts new file mode 100644 index 000000000..211dcc46e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-event-detail-response-content.ts @@ -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. + */ + + + +export interface RunEventDetailResponseContent { + 'kind': RunEventDetailResponseContentKindEnum; + 'value': string; +} + +export const RunEventDetailResponseContentKindEnum = { + TEXT: 'text', + TOOL_OUTPUT: 'tool_output', + TOOL_ARGUMENTS: 'tool_arguments', + ERROR: 'error', + DETAILS: 'details' +} as const; + +export type RunEventDetailResponseContentKindEnum = typeof RunEventDetailResponseContentKindEnum[keyof typeof RunEventDetailResponseContentKindEnum]; diff --git a/lib/packages/fabro-api-client/src/models/session-event-envelope.ts b/lib/packages/fabro-api-client/src/models/run-event-detail-response-event.ts similarity index 53% rename from lib/packages/fabro-api-client/src/models/session-event-envelope.ts rename to lib/packages/fabro-api-client/src/models/run-event-detail-response-event.ts index a4c0f9243..b5f059e07 100644 --- a/lib/packages/fabro-api-client/src/models/session-event-envelope.ts +++ b/lib/packages/fabro-api-client/src/models/run-event-detail-response-event.ts @@ -5,7 +5,7 @@ * 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 @@ -13,22 +13,20 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { Principal } from './principal'; -/** - * Append-only durable session event envelope. - */ -export interface SessionEventEnvelope { +export interface RunEventDetailResponseEvent { 'seq': number; - /** - * Durable session identifier. - */ - 'session_id': string; - /** - * Durable session turn identifier. - */ - 'turn_id'?: string | null; - 'event': string; - 'properties': any; + 'id': string; 'ts': string; + 'run_id': string; + 'event': string; + 'actor'?: Principal; + 'session_id'?: string; + 'node_id'?: string; + 'node_label'?: string; + 'stage_id'?: string; + 'tool_call_id'?: string; } - diff --git a/lib/packages/fabro-api-client/src/models/run-event-detail-response.ts b/lib/packages/fabro-api-client/src/models/run-event-detail-response.ts new file mode 100644 index 000000000..3d50004b6 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-event-detail-response.ts @@ -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 { RunEventDetailResponseContent } from './run-event-detail-response-content'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunEventDetailResponseEvent } from './run-event-detail-response-event'; + +export interface RunEventDetailResponse { + 'event': RunEventDetailResponseEvent; + 'properties': { [key: string]: any; }; + 'content'?: RunEventDetailResponseContent; + 'truncated': boolean; + 'redacted': boolean; + 'max_content_length': number; +} diff --git a/lib/packages/fabro-api-client/src/models/paginated-turn-list.ts b/lib/packages/fabro-api-client/src/models/run-pair-status-response.ts similarity index 65% rename from lib/packages/fabro-api-client/src/models/paginated-turn-list.ts rename to lib/packages/fabro-api-client/src/models/run-pair-status-response.ts index 05b1c8630..5bf9a5adc 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-turn-list.ts +++ b/lib/packages/fabro-api-client/src/models/run-pair-status-response.ts @@ -5,7 +5,7 @@ * 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 @@ -15,16 +15,13 @@ // May contain unused imports in some cases // @ts-ignore -import type { PaginationMeta } from './pagination-meta'; +import type { PairRecord } from './pair-record'; // May contain unused imports in some cases // @ts-ignore -import type { TurnRecord } from './turn-record'; +import type { PairTarget } from './pair-target'; -/** - * Paginated list of turns. - */ -export interface PaginatedTurnList { - 'data': Array; - 'meta': PaginationMeta; +export interface RunPairStatusResponse { + 'run_id': string; + 'current_pair'?: PairRecord | null; + 'targets': Array; } - diff --git a/lib/packages/fabro-api-client/src/models/run-projection-checkpoints-inner-inner.ts b/lib/packages/fabro-api-client/src/models/run-projection-checkpoints-inner-inner.ts index 30f5384aa..b4eb59dcd 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection-checkpoints-inner-inner.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection-checkpoints-inner-inner.ts @@ -5,7 +5,7 @@ * 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 @@ -21,5 +21,3 @@ import type { RunCheckpoint } from './run-checkpoint'; * @type RunProjectionCheckpointsInnerInner */ export type RunProjectionCheckpointsInnerInner = RunCheckpoint | number; - - diff --git a/lib/packages/fabro-api-client/src/models/session-record.ts b/lib/packages/fabro-api-client/src/models/session-record.ts index 25e1094c9..7c89e9047 100644 --- a/lib/packages/fabro-api-client/src/models/session-record.ts +++ b/lib/packages/fabro-api-client/src/models/session-record.ts @@ -5,7 +5,7 @@ * 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 @@ -13,35 +13,22 @@ */ -// May contain unused imports in some cases -// @ts-ignore -import type { PermissionLevel } from './permission-level'; -// May contain unused imports in some cases -// @ts-ignore -import type { SessionMessage } from './session-message'; // May contain unused imports in some cases // @ts-ignore import type { SessionStatus } from './session-status'; /** - * Durable session metadata and compactable runtime context. + * Ask Fabro session metadata derived from the owning run event stream. */ export interface SessionRecord { /** * Durable session identifier. */ 'id': string; + 'run_id': string; 'title'?: string | null; 'status': SessionStatus; - 'working_dir'?: string | null; - 'provider'?: string | null; 'model'?: string | null; - 'permissions': PermissionLevel; 'created_at': string; 'updated_at': string; - 'deleted_at'?: string | null; - 'runtime_context': Array; } - - - diff --git a/lib/packages/fabro-api-client/src/models/session-status.ts b/lib/packages/fabro-api-client/src/models/session-status.ts index b2fcce960..b0c6568ab 100644 --- a/lib/packages/fabro-api-client/src/models/session-status.ts +++ b/lib/packages/fabro-api-client/src/models/session-status.ts @@ -5,7 +5,7 @@ * 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 @@ -18,12 +18,7 @@ export const SessionStatus = { IDLE: 'idle', RUNNING: 'running', - FAILED: 'failed', - CLOSED: 'closed', - DELETED: 'deleted' + FAILED: 'failed' } as const; export type SessionStatus = typeof SessionStatus[keyof typeof SessionStatus]; - - - diff --git a/lib/packages/fabro-api-client/src/models/session-summary.ts b/lib/packages/fabro-api-client/src/models/session-summary.ts index 92c0f1d15..207c6d663 100644 --- a/lib/packages/fabro-api-client/src/models/session-summary.ts +++ b/lib/packages/fabro-api-client/src/models/session-summary.ts @@ -5,7 +5,7 @@ * 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 @@ -18,21 +18,17 @@ import type { SessionStatus } from './session-status'; /** - * List projection of a durable session. + * List projection of an Ask Fabro session. */ export interface SessionSummary { /** * Durable session identifier. */ 'id': string; + 'run_id': string; 'title'?: string | null; 'status': SessionStatus; - 'working_dir'?: string | null; - 'provider'?: string | null; 'model'?: string | null; 'created_at': string; 'updated_at': string; } - - - diff --git a/lib/packages/fabro-api-client/src/models/turn-record.ts b/lib/packages/fabro-api-client/src/models/turn-record.ts deleted file mode 100644 index cf989b465..000000000 --- a/lib/packages/fabro-api-client/src/models/turn-record.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* 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 { TurnStatus } from './turn-status'; - -/** - * One externally submitted input plus terminal output metadata. Full resumable context is stored on SessionRecord.runtime_context. - */ -export interface TurnRecord { - /** - * Durable session turn identifier. - */ - 'id': string; - /** - * Durable session identifier. - */ - 'session_id': string; - 'input': string; - 'status': TurnStatus; - 'output'?: string | null; - 'error'?: string | null; - 'created_at': string; - 'updated_at': string; - 'completed_at'?: string | null; -} - - -