mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
feat(api): add ask fabro session endpoints (#342)
## Summary Adds the run-backed API surface needed for a real Ask Fabro sidebar: run readiness metadata, detailed session projections, session-scoped event listing/attach streaming, and turn control that exposes durable turn IDs and machine-readable failures. ## What Changed - Extended the OpenAPI contract and regenerated Rust/TypeScript clients for `Run.ask_fabro`, `SessionDetail`, `SessionTurn`, paginated run sessions, session event APIs, and optional client-supplied `turn_id` values. - Updated `fabro-types` and `fabro-store` so durable `run.session.*` events project active turn state, transcript messages, and the latest owning run event sequence. - Implemented server routing for session details, `/events`, `/attach`, turn conflict headers, typed turn failure codes, and cheap run readiness decoration across run responses. - Added browser helpers for POST turn streaming and session attach SSE parsing, plus an exported generated `sessionsApi`. ## Verification - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` - `cargo build --workspace` - `cargo test -p fabro-types run_session_turn_failed_defaults_code_for_old_events` - `cargo test -p fabro-store run_sessions::tests` - `cargo test -p fabro-api` - `cargo test -p fabro-server --features test-support --test it api::sessions` - `cargo test -p fabro-server --features test-support --test it api::runs` - `cd apps/fabro-web && bun test app/lib/session-stream.test.ts` - `cd apps/fabro-web && bun run typecheck` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 Codex (context unknown, medium reasoning) via [Codex](https://openai.com/codex/)
This commit is contained in:
parent
54bc67017e
commit
296fbddec9
41 changed files with 2053 additions and 155 deletions
|
|
@ -16,6 +16,7 @@ import {
|
|||
RunOutputsApi,
|
||||
RunsApi,
|
||||
SecretsApi,
|
||||
SessionsApi,
|
||||
SettingsApi,
|
||||
SystemApi,
|
||||
WorkflowsApi,
|
||||
|
|
@ -114,6 +115,11 @@ export const secretsApi = new SecretsApi(
|
|||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const sessionsApi = new SessionsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
generatedAxios,
|
||||
);
|
||||
export const settingsApi = new SettingsApi(
|
||||
generatedApiConfiguration,
|
||||
"",
|
||||
|
|
@ -194,6 +200,34 @@ function apiErrorFromAxios(error: unknown): ApiError | null {
|
|||
});
|
||||
}
|
||||
|
||||
export async function apiErrorFromFetchResponse(response: Response): Promise<ApiError | null> {
|
||||
if (response.ok) return null;
|
||||
|
||||
const body = await readFetchErrorBody(response);
|
||||
const requestId = requestIdFromHeaders(response.headers) ?? extractRequestId(body);
|
||||
return new ApiError({
|
||||
status: response.status,
|
||||
message: extractErrorDetail(body) ?? (response.statusText || `HTTP ${response.status}`),
|
||||
requestId,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function readFetchErrorBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
const text = await response.text().catch(() => "");
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractErrorDetail(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const errors = (body as Record<string, unknown>).errors;
|
||||
|
|
|
|||
146
apps/fabro-web/app/lib/session-stream.test.ts
Normal file
146
apps/fabro-web/app/lib/session-stream.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { afterEach, describe, expect, mock, test } from "bun:test";
|
||||
|
||||
import { ApiError } from "./api-client";
|
||||
import {
|
||||
attachSessionEvents,
|
||||
streamSessionTurn,
|
||||
type SessionStreamEvent,
|
||||
} from "./session-stream";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
function streamResponse(chunks: string[], status = 200, headers: HeadersInit = {}) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("session stream helpers", () => {
|
||||
test("posts a turn and parses chunked SSE event envelopes", async () => {
|
||||
const events: SessionStreamEvent[] = [];
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
streamResponse(
|
||||
[
|
||||
"id: 3\nevent: run.session.turn.started\n",
|
||||
'data: {"seq":3,"event":{"event":"run.session.turn.started","properties":{"turn_id":"turn_1"}}}\n\n',
|
||||
],
|
||||
200,
|
||||
{ "x-fabro-turn-id": "turn_1" },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await streamSessionTurn({
|
||||
sessionId: "ses_1",
|
||||
input: "Summarize",
|
||||
turnId: "turn_1",
|
||||
fetchImpl: fetchMock,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(result.turnId).toBe("turn_1");
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/v1/sessions/ses_1/turns");
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toEqual({
|
||||
input: "Summarize",
|
||||
turn_id: "turn_1",
|
||||
});
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.seq).toBe(3);
|
||||
expect(events[0]?.event.event).toBe("run.session.turn.started");
|
||||
});
|
||||
|
||||
test("attaches to session events from a run sequence", async () => {
|
||||
const events: SessionStreamEvent[] = [];
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
streamResponse([
|
||||
'data: {"seq":7,"event":{"event":"run.session.assistant_message","properties":{}}}\n\n',
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await attachSessionEvents({
|
||||
sessionId: "ses_1",
|
||||
sinceSeq: 7,
|
||||
fetchImpl: fetchMock,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"/api/v1/sessions/ses_1/attach?since_seq=7",
|
||||
);
|
||||
expect(events[0]?.seq).toBe(7);
|
||||
});
|
||||
|
||||
test("parses CRLF-delimited SSE frames", async () => {
|
||||
const events: SessionStreamEvent[] = [];
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
streamResponse([
|
||||
'data: {"seq":8,"event":{"event":"run.session.assistant_message","properties":{}}}\r\n\r\n',
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await attachSessionEvents({
|
||||
sessionId: "ses_1",
|
||||
fetchImpl: fetchMock,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(events[0]?.seq).toBe(8);
|
||||
});
|
||||
|
||||
test("converts non-2xx responses to ApiError", async () => {
|
||||
const fetchMock = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [{
|
||||
status: "409",
|
||||
title: "Conflict",
|
||||
detail: "Session already has an active turn.",
|
||||
code: "session_active_turn",
|
||||
}],
|
||||
}),
|
||||
{
|
||||
status: 409,
|
||||
headers: { "x-request-id": "req_1" },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
streamSessionTurn({
|
||||
sessionId: "ses_1",
|
||||
input: "Summarize",
|
||||
fetchImpl: fetchMock,
|
||||
onEvent: () => {},
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
requestId: "req_1",
|
||||
message: "Session already has an active turn.",
|
||||
} satisfies Partial<ApiError>);
|
||||
});
|
||||
});
|
||||
141
apps/fabro-web/app/lib/session-stream.ts
Normal file
141
apps/fabro-web/app/lib/session-stream.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
SessionsApiAxiosParamCreator,
|
||||
type EventEnvelope,
|
||||
type SubmitTurnRequest,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import {
|
||||
apiErrorFromFetchResponse,
|
||||
generatedApiConfiguration,
|
||||
} from "./api-client";
|
||||
|
||||
export type SessionStreamEvent = EventEnvelope;
|
||||
|
||||
type FetchLike = (
|
||||
input: string,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
interface SessionStreamOptions {
|
||||
sessionId: string;
|
||||
signal?: AbortSignal;
|
||||
fetchImpl?: FetchLike;
|
||||
onEvent: (event: SessionStreamEvent) => void;
|
||||
}
|
||||
|
||||
export interface StreamSessionTurnOptions extends SessionStreamOptions {
|
||||
input: string;
|
||||
turnId?: string;
|
||||
}
|
||||
|
||||
export interface StreamSessionTurnResult {
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
export interface AttachSessionEventsOptions extends SessionStreamOptions {
|
||||
sinceSeq?: number;
|
||||
}
|
||||
|
||||
export async function streamSessionTurn({
|
||||
sessionId,
|
||||
input,
|
||||
turnId,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
onEvent,
|
||||
}: StreamSessionTurnOptions): Promise<StreamSessionTurnResult> {
|
||||
const body: SubmitTurnRequest = { input };
|
||||
if (turnId) body.turn_id = turnId;
|
||||
|
||||
const request = await SessionsApiAxiosParamCreator(
|
||||
generatedApiConfiguration,
|
||||
).submitSessionTurn(sessionId, body, { signal });
|
||||
const response = await fetchImpl(request.url, fetchInitFromAxiosRequest(request.options));
|
||||
await throwIfApiError(response);
|
||||
|
||||
await readEventStream(response, onEvent);
|
||||
return { turnId: response.headers.get("x-fabro-turn-id") };
|
||||
}
|
||||
|
||||
export async function attachSessionEvents({
|
||||
sessionId,
|
||||
sinceSeq,
|
||||
signal,
|
||||
fetchImpl = fetch,
|
||||
onEvent,
|
||||
}: AttachSessionEventsOptions): Promise<void> {
|
||||
const request = await SessionsApiAxiosParamCreator(
|
||||
generatedApiConfiguration,
|
||||
).attachSessionEvents(sessionId, sinceSeq, { signal });
|
||||
const response = await fetchImpl(request.url, fetchInitFromAxiosRequest(request.options));
|
||||
await throwIfApiError(response);
|
||||
|
||||
await readEventStream(response, onEvent);
|
||||
}
|
||||
|
||||
async function throwIfApiError(response: Response): Promise<void> {
|
||||
const error = await apiErrorFromFetchResponse(response);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
function fetchInitFromAxiosRequest(options: {
|
||||
method?: string;
|
||||
headers?: unknown;
|
||||
data?: unknown;
|
||||
signal?: unknown;
|
||||
}): RequestInit {
|
||||
const init: RequestInit = {
|
||||
method: options.method,
|
||||
credentials: "same-origin",
|
||||
headers: options.headers as HeadersInit,
|
||||
signal: options.signal as AbortSignal | undefined,
|
||||
};
|
||||
if (options.data !== undefined) {
|
||||
init.body = typeof options.data === "string"
|
||||
? options.data
|
||||
: JSON.stringify(options.data);
|
||||
}
|
||||
return init;
|
||||
}
|
||||
|
||||
async function readEventStream(
|
||||
response: Response,
|
||||
onEvent: (event: SessionStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
if (!response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = drainSseBuffer(buffer, onEvent);
|
||||
}
|
||||
|
||||
buffer += decoder.decode();
|
||||
drainSseBuffer(`${buffer}\n\n`, onEvent);
|
||||
}
|
||||
|
||||
function drainSseBuffer(
|
||||
buffer: string,
|
||||
onEvent: (event: SessionStreamEvent) => void,
|
||||
): string {
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const match = /\r?\n\r?\n/g.exec(buffer.slice(cursor));
|
||||
if (!match) return buffer.slice(cursor);
|
||||
const next = cursor + match.index;
|
||||
const frame = buffer.slice(cursor, next);
|
||||
cursor = next + match[0].length;
|
||||
const data = frame
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice("data:".length).trimStart())
|
||||
.join("\n");
|
||||
if (!data) continue;
|
||||
onEvent(JSON.parse(data) as SessionStreamEvent);
|
||||
}
|
||||
}
|
||||
|
|
@ -822,6 +822,26 @@ paths:
|
|||
operationId: listRunSessions
|
||||
tags: [Sessions]
|
||||
summary: List run sessions
|
||||
parameters:
|
||||
- name: page[limit]
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
- name: page[offset]
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 0
|
||||
minimum: 0
|
||||
- name: order
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [updated_desc, created_desc]
|
||||
default: updated_desc
|
||||
responses:
|
||||
"200":
|
||||
description: Ask Fabro sessions for the run
|
||||
|
|
@ -870,11 +890,11 @@ paths:
|
|||
summary: Get session
|
||||
responses:
|
||||
"200":
|
||||
description: Session record
|
||||
description: Session detail
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SessionRecord"
|
||||
$ref: "#/components/schemas/SessionDetail"
|
||||
"404":
|
||||
description: Session not found
|
||||
headers:
|
||||
|
|
@ -884,6 +904,84 @@ paths:
|
|||
application/json:
|
||||
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 session events
|
||||
description: Returns run event envelopes filtered to this session's durable `run.session.*` events. `since_seq` uses the owning run event sequence.
|
||||
parameters:
|
||||
- name: since_seq
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
minimum: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 100
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
responses:
|
||||
"200":
|
||||
description: Session-scoped run events
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaginatedEventList"
|
||||
"404":
|
||||
description: Session not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/sessions/{id}/attach:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/components/schemas/SessionId"
|
||||
get:
|
||||
operationId: attachSessionEvents
|
||||
tags: [Sessions]
|
||||
summary: Attach to session events
|
||||
description: Replays and streams this session's durable `run.session.*` events from the owning run event log. The stream remains open until the client disconnects or the server shuts down.
|
||||
parameters:
|
||||
- name: since_seq
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
responses:
|
||||
"200":
|
||||
description: Streamed session-scoped run events
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
type: string
|
||||
"404":
|
||||
description: Session not found
|
||||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/sessions/{id}/turns:
|
||||
parameters:
|
||||
- name: id
|
||||
|
|
@ -905,6 +1003,11 @@ paths:
|
|||
responses:
|
||||
"200":
|
||||
description: Streamed session events
|
||||
headers:
|
||||
x-fabro-turn-id:
|
||||
description: Durable turn id accepted for this streamed turn.
|
||||
schema:
|
||||
$ref: "#/components/schemas/TurnId"
|
||||
content:
|
||||
text/event-stream:
|
||||
schema:
|
||||
|
|
@ -932,6 +1035,10 @@ paths:
|
|||
headers:
|
||||
x-request-id:
|
||||
$ref: "#/components/headers/XRequestId"
|
||||
x-fabro-active-turn-id:
|
||||
description: Durable id of the currently active turn.
|
||||
schema:
|
||||
$ref: "#/components/schemas/TurnId"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
@ -5338,6 +5445,22 @@ components:
|
|||
type: string
|
||||
enum: [idle, running, failed]
|
||||
|
||||
SessionTurn:
|
||||
description: Currently active durable session turn.
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- started_at
|
||||
- input
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/TurnId"
|
||||
started_at:
|
||||
type: string
|
||||
format: date-time
|
||||
input:
|
||||
type: string
|
||||
|
||||
SessionMessage:
|
||||
description: Persisted full-fidelity session transcript message.
|
||||
type: object
|
||||
|
|
@ -5373,6 +5496,7 @@ components:
|
|||
- id
|
||||
- run_id
|
||||
- status
|
||||
- active_turn
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
|
|
@ -5386,6 +5510,10 @@ components:
|
|||
$ref: "#/components/schemas/SessionStatus"
|
||||
model:
|
||||
type: ["string", "null"]
|
||||
active_turn:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SessionTurn"
|
||||
- type: "null"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
|
@ -5400,6 +5528,7 @@ components:
|
|||
- id
|
||||
- run_id
|
||||
- status
|
||||
- active_turn
|
||||
- created_at
|
||||
- updated_at
|
||||
properties:
|
||||
|
|
@ -5413,6 +5542,10 @@ components:
|
|||
$ref: "#/components/schemas/SessionStatus"
|
||||
model:
|
||||
type: ["string", "null"]
|
||||
active_turn:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SessionTurn"
|
||||
- type: "null"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
|
@ -5420,6 +5553,47 @@ components:
|
|||
type: string
|
||||
format: date-time
|
||||
|
||||
SessionDetail:
|
||||
description: Session metadata plus durable transcript projection.
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- run_id
|
||||
- status
|
||||
- active_turn
|
||||
- created_at
|
||||
- updated_at
|
||||
- messages
|
||||
- last_seq
|
||||
properties:
|
||||
id:
|
||||
$ref: "#/components/schemas/SessionId"
|
||||
run_id:
|
||||
type: string
|
||||
title:
|
||||
type: ["string", "null"]
|
||||
status:
|
||||
$ref: "#/components/schemas/SessionStatus"
|
||||
model:
|
||||
type: ["string", "null"]
|
||||
active_turn:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SessionTurn"
|
||||
- type: "null"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
messages:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SessionMessage"
|
||||
last_seq:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
CreateRunSessionRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -5436,6 +5610,8 @@ components:
|
|||
properties:
|
||||
input:
|
||||
type: string
|
||||
turn_id:
|
||||
$ref: "#/components/schemas/TurnId"
|
||||
|
||||
PaginatedSessionList:
|
||||
description: Paginated list of sessions.
|
||||
|
|
@ -7908,6 +8084,7 @@ components:
|
|||
- timestamps
|
||||
- timing
|
||||
- billing
|
||||
- ask_fabro
|
||||
- diff
|
||||
- pull_request
|
||||
- current_question
|
||||
|
|
@ -7974,6 +8151,8 @@ components:
|
|||
oneOf:
|
||||
- $ref: "#/components/schemas/RunBillingSummary"
|
||||
- type: "null"
|
||||
ask_fabro:
|
||||
$ref: "#/components/schemas/AskFabro"
|
||||
diff:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/DiffSummary"
|
||||
|
|
@ -7991,6 +8170,27 @@ components:
|
|||
links:
|
||||
$ref: "#/components/schemas/RunLinks"
|
||||
|
||||
AskFabro:
|
||||
description: Readiness and defaults for starting an Ask Fabro session on this run.
|
||||
type: object
|
||||
required:
|
||||
- available
|
||||
- unavailable_reason
|
||||
- default_model
|
||||
properties:
|
||||
available:
|
||||
type: boolean
|
||||
unavailable_reason:
|
||||
type: ["string", "null"]
|
||||
enum:
|
||||
- feature_disabled
|
||||
- no_sandbox
|
||||
- sandbox_not_ready
|
||||
- llm_unconfigured
|
||||
- null
|
||||
default_model:
|
||||
type: ["string", "null"]
|
||||
|
||||
WorkflowRef:
|
||||
type: object
|
||||
required: [slug, name, graph_name, node_count, edge_count]
|
||||
|
|
|
|||
|
|
@ -475,12 +475,15 @@ fn main() {
|
|||
("SandboxState", "fabro_types::SandboxState", &[]),
|
||||
("SandboxResources", "fabro_types::SandboxResources", &[]),
|
||||
("SandboxTimestamps", "fabro_types::SandboxTimestamps", &[]),
|
||||
("AskFabro", "fabro_types::AskFabro", &[]),
|
||||
("SessionId", "fabro_types::SessionId", &[]),
|
||||
("TurnId", "fabro_types::TurnId", &[]),
|
||||
("SessionStatus", "fabro_types::SessionStatus", &[]),
|
||||
("SessionTurn", "fabro_types::SessionTurn", &[]),
|
||||
("SessionMessage", "fabro_types::SessionMessage", &[]),
|
||||
("SessionRecord", "fabro_types::SessionRecord", &[]),
|
||||
("SessionSummary", "fabro_types::SessionSummary", &[]),
|
||||
("SessionDetail", "fabro_types::SessionDetail", &[]),
|
||||
];
|
||||
for (name, path, impls) in replacements {
|
||||
settings.with_replacement(*name, *path, impls.iter().copied());
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ pub mod types {
|
|||
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason,
|
||||
};
|
||||
pub use fabro_types::{
|
||||
AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, DiffStats, DiffSummary,
|
||||
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||
AskFabro, AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, DiffStats,
|
||||
DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||
FailureSignature, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord,
|
||||
PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest,
|
||||
PairStatus, PairTarget, PairTargetSelector, PairTranscriptEntry, PairTranscriptResponse,
|
||||
|
|
@ -45,9 +45,10 @@ pub mod types {
|
|||
RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork,
|
||||
SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources,
|
||||
SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps,
|
||||
SecretMetadata, SecretType, ServerSettings, SessionId, SessionMessage, SessionRecord,
|
||||
SessionStatus, SessionSummary, StageCompletion, StageHandler, StageOutcome,
|
||||
StageProjection, StageState, SystemActorKind, TurnId, UserPrincipal, WorkflowSettings,
|
||||
SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionMessage,
|
||||
SessionRecord, SessionStatus, SessionSummary, SessionTurn, StageCompletion, StageHandler,
|
||||
StageOutcome, StageProjection, StageState, SystemActorKind, TurnId, UserPrincipal,
|
||||
WorkflowSettings,
|
||||
};
|
||||
|
||||
pub use crate::generated::types::*;
|
||||
|
|
|
|||
|
|
@ -169,6 +169,26 @@ fn run_event_round_trips_agent_interrupt_injected() {
|
|||
assert_run_event_round_trip(value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_turn_failed_defaults_code_for_legacy_payloads() {
|
||||
let value = json!({
|
||||
"id": "evt_session_failed",
|
||||
"ts": "2026-05-20T12:00:00Z",
|
||||
"run_id": fixtures::RUN_1,
|
||||
"event": "run.session.turn.failed",
|
||||
"session_id": "01HZX6M0P7SE4VJ9Y3X2B8E9QF",
|
||||
"properties": {
|
||||
"turn_id": "01HZX6M29F1CD5YYMHT1F5D7WQ",
|
||||
"error": "provider unavailable"
|
||||
}
|
||||
});
|
||||
|
||||
let event: ApiRunEvent = serde_json::from_value(value).unwrap();
|
||||
let round_trip = serde_json::to_value(event).unwrap();
|
||||
assert_eq!(round_trip["properties"]["code"], "agent_error");
|
||||
assert_eq!(round_trip["properties"]["retryable"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_event_round_trips_stage_started() {
|
||||
let value = json!({
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ use chrono::{TimeZone, Utc};
|
|||
use fabro_api::types::{RepositoryRef as ApiRepositoryRef, Run as ApiRun};
|
||||
use fabro_types::status::{RunStatus, SuccessReason};
|
||||
use fabro_types::{
|
||||
DiffSummary, PullRequestLink, RepositoryProvider, RepositoryRef, Run, RunBillingSummary, RunId,
|
||||
RunLifecycle, RunLinks, RunOrigin, RunTimestamps, RunTiming, WorkflowRef,
|
||||
AskFabro, AskFabroUnavailableReason, DiffSummary, PullRequestLink, RepositoryProvider,
|
||||
RepositoryRef, Run, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin, RunTimestamps,
|
||||
RunTiming, WorkflowRef,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -67,6 +68,11 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
billing: Some(RunBillingSummary {
|
||||
total_usd_micros: Some(123),
|
||||
}),
|
||||
ask_fabro: AskFabro {
|
||||
available: false,
|
||||
unavailable_reason: Some(AskFabroUnavailableReason::SandboxNotReady),
|
||||
default_model: Some("gpt-5.4".to_string()),
|
||||
},
|
||||
diff: Some(DiffSummary {
|
||||
files_changed: 3,
|
||||
additions: 12,
|
||||
|
|
@ -138,6 +144,11 @@ fn run_summary_json_matches_openapi_shape() {
|
|||
"billing": {
|
||||
"total_usd_micros": 123
|
||||
},
|
||||
"ask_fabro": {
|
||||
"available": false,
|
||||
"unavailable_reason": "sandbox_not_ready",
|
||||
"default_model": "gpt-5.4"
|
||||
},
|
||||
"diff": {
|
||||
"files_changed": 3,
|
||||
"additions": 12,
|
||||
|
|
@ -225,6 +236,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
|
|||
assert_eq!(summary.lifecycle.pending_control, None);
|
||||
assert_eq!(summary.timing.map(|t| t.wall_time_ms), None);
|
||||
assert_eq!(summary.billing, None);
|
||||
assert_eq!(summary.ask_fabro, AskFabro::default());
|
||||
assert_eq!(summary.superseded_by, None);
|
||||
assert_eq!(summary.diff, None);
|
||||
assert_eq!(summary.pull_request, None);
|
||||
|
|
|
|||
79
lib/crates/fabro-api/tests/session_contract_round_trip.rs
Normal file
79
lib/crates/fabro-api/tests/session_contract_round_trip.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_api::types::{
|
||||
SessionDetail as ApiSessionDetail, SessionRecord as ApiSessionRecord,
|
||||
SessionSummary as ApiSessionSummary, SessionTurn as ApiSessionTurn, SubmitTurnRequest,
|
||||
};
|
||||
use fabro_types::{
|
||||
SessionDetail, SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary,
|
||||
SessionTurn, TurnId, fixtures,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn session_contract_reuses_domain_types() {
|
||||
assert_same_type::<ApiSessionTurn, SessionTurn>();
|
||||
assert_same_type::<ApiSessionRecord, SessionRecord>();
|
||||
assert_same_type::<ApiSessionSummary, SessionSummary>();
|
||||
assert_same_type::<ApiSessionDetail, SessionDetail>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_detail_round_trips_messages_active_turn_and_last_seq() {
|
||||
let created_at = Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 0).unwrap();
|
||||
let turn_started_at = Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 1).unwrap();
|
||||
let updated_at = Utc.with_ymd_and_hms(2026, 5, 20, 12, 0, 2).unwrap();
|
||||
let session_id = SessionId::new();
|
||||
let turn_id = TurnId::new();
|
||||
let detail = SessionDetail::new(
|
||||
SessionRecord {
|
||||
id: session_id,
|
||||
run_id: fixtures::RUN_1,
|
||||
title: Some("Ask Fabro".to_string()),
|
||||
status: SessionStatus::Running,
|
||||
model: Some("gpt-5.4".to_string()),
|
||||
active_turn: Some(SessionTurn {
|
||||
id: turn_id,
|
||||
started_at: turn_started_at,
|
||||
input: "What changed?".to_string(),
|
||||
}),
|
||||
created_at,
|
||||
updated_at,
|
||||
},
|
||||
vec![SessionMessage::user("What changed?", updated_at)],
|
||||
7,
|
||||
);
|
||||
|
||||
let value = serde_json::to_value(&detail).expect("detail should serialize");
|
||||
assert_eq!(value["active_turn"]["id"], turn_id.to_string());
|
||||
assert_eq!(value["messages"][0]["kind"], "user");
|
||||
assert_eq!(value["last_seq"], 7);
|
||||
|
||||
let round_trip: ApiSessionDetail =
|
||||
serde_json::from_value(value.clone()).expect("detail should deserialize");
|
||||
assert_eq!(serde_json::to_value(round_trip).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_turn_request_accepts_client_turn_id() {
|
||||
let turn_id = TurnId::new();
|
||||
let request: SubmitTurnRequest = serde_json::from_value(json!({
|
||||
"input": "Summarize this run",
|
||||
"turn_id": turn_id.to_string()
|
||||
}))
|
||||
.expect("submit turn request should deserialize");
|
||||
|
||||
assert_eq!(request.input, "Summarize this run");
|
||||
assert_eq!(request.turn_id, Some(turn_id));
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
TypeId::of::<U>(),
|
||||
"{} should be the same type as {}",
|
||||
type_name::<T>(),
|
||||
type_name::<U>()
|
||||
);
|
||||
}
|
||||
|
|
@ -650,7 +650,8 @@ impl Client {
|
|||
.map_err(|()| anyhow!("server base URL cannot accept path segments"))?
|
||||
.extend(["api", "v1", "sessions", &session_id.to_string(), "turns"]);
|
||||
let body = types::SubmitTurnRequest {
|
||||
input: input.into(),
|
||||
input: input.into(),
|
||||
turn_id: None,
|
||||
};
|
||||
let response = self
|
||||
.send_http(|http_client| {
|
||||
|
|
|
|||
|
|
@ -1151,6 +1151,7 @@ mod runs {
|
|||
billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary {
|
||||
total_usd_micros: Some(total_usd_micros),
|
||||
}),
|
||||
ask_fabro: Default::default(),
|
||||
diff: None,
|
||||
pull_request: None,
|
||||
current_question: None,
|
||||
|
|
|
|||
|
|
@ -82,9 +82,9 @@ use fabro_types::settings::server::{
|
|||
};
|
||||
use fabro_types::settings::{InterpString, RunNamespace};
|
||||
use fabro_types::{
|
||||
AgentBackend, EventBody, InterviewQuestionRecord, PairId, PairMessageId, PairTarget, Principal,
|
||||
PullRequestLink, QuestionType, RunBlobId, RunControlAction, RunEvent, RunId, ServerSettings,
|
||||
SessionCapability,
|
||||
AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId,
|
||||
PairMessageId, PairTarget, Principal, PullRequestLink, QuestionType, RunBlobId,
|
||||
RunControlAction, RunEvent, RunId, ServerSettings, SessionCapability,
|
||||
};
|
||||
use fabro_util::error::{
|
||||
SharedError, collect_causes, render_compact_with_causes, render_with_causes,
|
||||
|
|
@ -175,6 +175,15 @@ pub struct PaginationParams {
|
|||
pub offset: u32,
|
||||
}
|
||||
|
||||
pub(crate) fn paginate_items<T>(items: Vec<T>, pagination: &PaginationParams) -> (Vec<T>, bool) {
|
||||
let limit = pagination.limit.clamp(1, 100) as usize;
|
||||
let offset = pagination.offset.min(MAX_PAGE_OFFSET) as usize;
|
||||
let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect();
|
||||
let has_more = data.len() > limit;
|
||||
data.truncate(limit);
|
||||
(data, has_more)
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(crate) struct DfParams {
|
||||
#[serde(default)]
|
||||
|
|
@ -631,6 +640,43 @@ pub struct AppState {
|
|||
|
||||
type PullRequestCreateLocks = Arc<Mutex<HashMap<RunId, Arc<AsyncMutex<()>>>>>;
|
||||
|
||||
struct AskFabroReadiness {
|
||||
feature_enabled: bool,
|
||||
default_model: Option<String>,
|
||||
}
|
||||
|
||||
impl AskFabroReadiness {
|
||||
fn decorate(&self, mut run: fabro_types::Run) -> fabro_types::Run {
|
||||
run.ask_fabro = self.ask_fabro_for(&run);
|
||||
run
|
||||
}
|
||||
|
||||
fn ask_fabro_for(&self, run: &fabro_types::Run) -> AskFabro {
|
||||
let unavailable_reason = if !self.feature_enabled {
|
||||
Some(AskFabroUnavailableReason::FeatureDisabled)
|
||||
} else if run.sandbox.is_none() {
|
||||
Some(AskFabroUnavailableReason::NoSandbox)
|
||||
} else if run
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.and_then(|sandbox| sandbox.runtime.as_ref())
|
||||
.is_none()
|
||||
{
|
||||
Some(AskFabroUnavailableReason::SandboxNotReady)
|
||||
} else if self.default_model.is_none() {
|
||||
Some(AskFabroUnavailableReason::LlmUnconfigured)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
AskFabro {
|
||||
available: unavailable_reason.is_none(),
|
||||
unavailable_reason,
|
||||
default_model: self.default_model.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PullRequestCreateGuard {
|
||||
locks: PullRequestCreateLocks,
|
||||
run_id: RunId,
|
||||
|
|
@ -810,6 +856,46 @@ impl AppState {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn decorate_run_summary(&self, run: fabro_types::Run) -> fabro_types::Run {
|
||||
self.ask_fabro_readiness().await.decorate(run)
|
||||
}
|
||||
|
||||
pub(crate) async fn decorate_run_summaries(
|
||||
&self,
|
||||
runs: Vec<fabro_types::Run>,
|
||||
) -> Vec<fabro_types::Run> {
|
||||
let readiness = self.ask_fabro_readiness().await;
|
||||
runs.into_iter()
|
||||
.map(|run| readiness.decorate(run))
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn ask_fabro_readiness(&self) -> AskFabroReadiness {
|
||||
let feature_enabled = self.server_settings().features.session_sandboxes;
|
||||
if !feature_enabled {
|
||||
return AskFabroReadiness {
|
||||
feature_enabled,
|
||||
default_model: None,
|
||||
};
|
||||
}
|
||||
|
||||
let provider_ids = self.ready_llm_provider_ids().await;
|
||||
let default_model = if provider_ids.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.catalog()
|
||||
.default_for_configured_ids(&provider_ids)
|
||||
.id
|
||||
.clone(),
|
||||
)
|
||||
};
|
||||
AskFabroReadiness {
|
||||
feature_enabled,
|
||||
default_model,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn vault_or_env(&self, name: &str) -> Option<String> {
|
||||
process_env_var(name).or_else(|| {
|
||||
self.vault
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
|
||||
async fn run_response(state: &AppState, id: RunId, status: StatusCode) -> Response {
|
||||
match state.store.get_cached_summary(&id).await {
|
||||
Ok(Some(summary)) => (status, Json(summary)).into_response(),
|
||||
Ok(Some(summary)) => {
|
||||
(status, Json(state.decorate_run_summary(summary).await)).into_response()
|
||||
}
|
||||
Ok(None) => ApiError::not_found("Run not found.").into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ use tokio::fs;
|
|||
use tracing::info;
|
||||
|
||||
use super::super::{
|
||||
AppState, ListResponse, MAX_PAGE_OFFSET, PaginationParams, RunExecutionMode,
|
||||
answer_from_request, api_question_from_pending_interview, default_page_limit,
|
||||
delete_run_internal, load_pending_interview, managed_run, parse_run_id_path,
|
||||
reject_if_archived, resolve_interp_string, submit_pending_interview_answer, workflow_event,
|
||||
AppState, ListResponse, PaginationParams, RunExecutionMode, answer_from_request,
|
||||
api_question_from_pending_interview, default_page_limit, delete_run_internal,
|
||||
load_pending_interview, managed_run, paginate_items, parse_run_id_path, reject_if_archived,
|
||||
resolve_interp_string, submit_pending_interview_answer, workflow_event,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::principal_middleware::{
|
||||
|
|
@ -147,15 +147,6 @@ pub(crate) fn board_columns(include_archived: bool) -> Vec<BoardColumnDefinition
|
|||
columns
|
||||
}
|
||||
|
||||
fn paginate_items<T>(items: Vec<T>, pagination: &PaginationParams) -> (Vec<T>, bool) {
|
||||
let limit = pagination.limit.clamp(1, 100) as usize;
|
||||
let offset = pagination.offset.min(MAX_PAGE_OFFSET) as usize;
|
||||
let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect();
|
||||
let has_more = data.len() > limit;
|
||||
data.truncate(limit);
|
||||
(data, has_more)
|
||||
}
|
||||
|
||||
async fn list_board_runs(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -190,15 +181,20 @@ async fn list_board_runs(
|
|||
})
|
||||
.collect();
|
||||
let (page_summaries, has_more) = paginate_items(board_summaries, ¶ms.pagination());
|
||||
let data = state
|
||||
.decorate_run_summaries(
|
||||
page_summaries
|
||||
.into_iter()
|
||||
.map(|entry| entry.summary)
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"columns": board_columns(include_archived),
|
||||
"data": page_summaries
|
||||
.into_iter()
|
||||
.map(|entry| entry.summary)
|
||||
.collect::<Vec<_>>(),
|
||||
"data": data,
|
||||
"meta": { "has_more": has_more }
|
||||
})),
|
||||
)
|
||||
|
|
@ -232,7 +228,11 @@ async fn link_run_parent(
|
|||
return err.into_response();
|
||||
}
|
||||
if child.parent_id == Some(parent_id) {
|
||||
return (StatusCode::OK, Json(child)).into_response();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(state.decorate_run_summary(child).await),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let Ok(run_store) = state.store.open_run(&child_id).await else {
|
||||
|
|
@ -268,7 +268,11 @@ async fn unlink_run_parent(
|
|||
}
|
||||
};
|
||||
let Some(previous_parent_id) = child.parent_id else {
|
||||
return (StatusCode::OK, Json(child)).into_response();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(state.decorate_run_summary(child).await),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let Ok(run_store) = state.store.open_run(&child_id).await else {
|
||||
|
|
@ -321,7 +325,11 @@ async fn validate_parent_link(
|
|||
|
||||
async fn updated_run_response(state: &AppState, run_id: &RunId) -> Response {
|
||||
match state.store.get_cached_summary(run_id).await {
|
||||
Ok(Some(summary)) => (StatusCode::OK, Json(summary)).into_response(),
|
||||
Ok(Some(summary)) => (
|
||||
StatusCode::OK,
|
||||
Json(state.decorate_run_summary(summary).await),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => ApiError::not_found("Run not found.").into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
|
|
@ -350,6 +358,7 @@ async fn list_runs(
|
|||
.filter(|summary| include_archived || !summary.lifecycle.archived)
|
||||
.collect::<Vec<_>>();
|
||||
let (data, has_more) = paginate_items(items, ¶ms.pagination());
|
||||
let data = state.decorate_run_summaries(data).await;
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
|
|
@ -430,7 +439,10 @@ async fn resolve_run(
|
|||
.and_then(|repository| repository.origin_url.clone())
|
||||
},
|
||||
) {
|
||||
Ok(run) => (StatusCode::OK, Json(run.clone())).into_response(),
|
||||
Ok(run) => {
|
||||
let run = state.decorate_run_summary(run.clone()).await;
|
||||
(StatusCode::OK, Json(run)).into_response()
|
||||
}
|
||||
Err(err @ (ResolveRunError::InvalidSelector | ResolveRunError::AmbiguousPrefix { .. })) => {
|
||||
ApiError::bad_request(err.to_string()).into_response()
|
||||
}
|
||||
|
|
@ -487,7 +499,11 @@ async fn update_run(
|
|||
}
|
||||
};
|
||||
if current.title == title {
|
||||
return (StatusCode::OK, Json(current)).into_response();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(state.decorate_run_summary(current).await),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let run_store = match state.store.open_run(&id).await {
|
||||
|
|
@ -508,7 +524,11 @@ async fn update_run(
|
|||
}
|
||||
|
||||
match state.store.get_cached_summary(&id).await {
|
||||
Ok(Some(summary)) => (StatusCode::OK, Json(summary)).into_response(),
|
||||
Ok(Some(summary)) => (
|
||||
StatusCode::OK,
|
||||
Json(state.decorate_run_summary(summary).await),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => ApiError::not_found("Run not found.").into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
|
|
@ -605,7 +625,11 @@ async fn create_run(
|
|||
);
|
||||
}
|
||||
|
||||
(StatusCode::CREATED, Json(summary)).into_response()
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
Json(state.decorate_run_summary(summary).await),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn run_provenance(headers: &HeaderMap, subject: &Principal) -> RunProvenance {
|
||||
|
|
@ -703,7 +727,9 @@ async fn get_run_status(
|
|||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
match state.store.get_cached_summary(&id).await {
|
||||
Ok(Some(run)) => (StatusCode::OK, Json(run)).into_response(),
|
||||
Ok(Some(run)) => {
|
||||
(StatusCode::OK, Json(state.decorate_run_summary(run).await)).into_response()
|
||||
}
|
||||
Ok(None) => ApiError::not_found("Run not found.").into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
|
|
@ -14,7 +14,9 @@ use fabro_agent::{
|
|||
AgentEvent, AgentProfile, AnthropicProfile, Error as AgentError, GeminiProfile, OpenAiProfile,
|
||||
Session, SessionEvent, SessionOptions, ToolApprovalAdapter, WebFetchSummarizer,
|
||||
};
|
||||
use fabro_api::types::{CreateRunSessionRequest, SubmitTurnRequest};
|
||||
use fabro_api::types::{
|
||||
CreateRunSessionRequest, PaginatedEventList, PaginationMeta, SubmitTurnRequest,
|
||||
};
|
||||
use fabro_llm::client::Client as LlmClient;
|
||||
use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ProviderId};
|
||||
use fabro_sandbox::reconnect::reconnect_for_run;
|
||||
|
|
@ -23,22 +25,26 @@ use fabro_store::{
|
|||
};
|
||||
use fabro_types::run_event::{
|
||||
RunSessionAssistantDeltaProps, RunSessionAssistantMessageProps, RunSessionCreatedProps,
|
||||
RunSessionToolCallCompletedProps, RunSessionToolCallStartedProps, RunSessionTurnFailedProps,
|
||||
RunSessionTurnInterruptedProps, RunSessionTurnStartedProps, RunSessionTurnSucceededProps,
|
||||
RunSessionUserMessageProps,
|
||||
RunSessionToolCallCompletedProps, RunSessionToolCallStartedProps, RunSessionTurnFailedCode,
|
||||
RunSessionTurnFailedProps, RunSessionTurnInterruptedProps, RunSessionTurnStartedProps,
|
||||
RunSessionTurnSucceededProps, RunSessionUserMessageProps,
|
||||
};
|
||||
use fabro_types::settings::{ModelRef as SettingsModelRef, ModelRegistry, ResolvedModelRef};
|
||||
use fabro_types::{
|
||||
EventBody, EventEnvelope, PermissionLevel, RunEvent, RunId, SessionId, SessionRecord, TurnId,
|
||||
EventBody, EventEnvelope, PermissionLevel, RunEvent, RunId, SessionDetail, SessionId, TurnId,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use super::super::session_runtime::{InterruptTurnError, SessionTurnLease, StartTurnError};
|
||||
use super::super::{AppState, ListResponse};
|
||||
use super::super::{
|
||||
AppState, EventListParams, PaginationParams, paginate_items, parse_run_id_path,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use crate::principal_middleware::RequiredUser;
|
||||
use crate::server_secrets::LlmClientResult;
|
||||
|
|
@ -57,6 +63,8 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
"/sessions/{id}",
|
||||
get(get_session).fallback(session_method_not_found),
|
||||
)
|
||||
.route("/sessions/{id}/events", get(list_session_events))
|
||||
.route("/sessions/{id}/attach", get(attach_session_events))
|
||||
.route(
|
||||
"/sessions/{id}/turns",
|
||||
post(submit_turn).fallback(session_method_not_found),
|
||||
|
|
@ -67,14 +75,31 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum RunSessionListOrder {
|
||||
#[default]
|
||||
UpdatedDesc,
|
||||
CreatedDesc,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ListRunSessionsParams {
|
||||
#[serde(flatten)]
|
||||
pagination: PaginationParams,
|
||||
#[serde(default)]
|
||||
order: RunSessionListOrder,
|
||||
}
|
||||
|
||||
async fn list_run_sessions(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(run_id): Path<String>,
|
||||
Query(params): Query<ListRunSessionsParams>,
|
||||
) -> Response {
|
||||
let run_id = match parse_run_id(&run_id) {
|
||||
let run_id = match parse_run_id_path(&run_id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => return err.into_response(),
|
||||
Err(response) => return response,
|
||||
};
|
||||
let run_store = match open_run_reader(&state, run_id).await {
|
||||
Ok(store) => store,
|
||||
|
|
@ -82,7 +107,28 @@ async fn list_run_sessions(
|
|||
};
|
||||
match run_store.list_events().await {
|
||||
Ok(events) => {
|
||||
Json(ListResponse::new(project_run_sessions(run_id, &events))).into_response()
|
||||
let mut sessions = project_run_sessions(run_id, &events);
|
||||
match params.order {
|
||||
RunSessionListOrder::UpdatedDesc => sessions.sort_by(|left, right| {
|
||||
right
|
||||
.updated_at
|
||||
.cmp(&left.updated_at)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
.then_with(|| right.id.cmp(&left.id))
|
||||
}),
|
||||
RunSessionListOrder::CreatedDesc => sessions.sort_by(|left, right| {
|
||||
right
|
||||
.created_at
|
||||
.cmp(&left.created_at)
|
||||
.then_with(|| right.id.cmp(&left.id))
|
||||
}),
|
||||
}
|
||||
let (data, has_more) = paginate_items(sessions, ¶ms.pagination);
|
||||
Json(serde_json::json!({
|
||||
"data": data,
|
||||
"meta": { "has_more": has_more }
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => store_error(&err).into_response(),
|
||||
}
|
||||
|
|
@ -94,9 +140,9 @@ async fn create_run_session(
|
|||
Path(run_id): Path<String>,
|
||||
Json(request): Json<CreateRunSessionRequest>,
|
||||
) -> Response {
|
||||
let run_id = match parse_run_id(&run_id) {
|
||||
let run_id = match parse_run_id_path(&run_id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => return err.into_response(),
|
||||
Err(response) => return response,
|
||||
};
|
||||
let run_store = match open_run(&state, run_id).await {
|
||||
Ok(store) => store,
|
||||
|
|
@ -157,13 +203,145 @@ async fn get_session(
|
|||
Ok(context) => context,
|
||||
Err(response) => return response,
|
||||
};
|
||||
Json(session).into_response()
|
||||
Json(SessionDetail::new(
|
||||
session.record,
|
||||
session.runtime_context,
|
||||
session.last_seq,
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn session_method_not_found() -> Response {
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
async fn list_session_events(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<EventListParams>,
|
||||
) -> Response {
|
||||
let session_id = match parse_session_id(&id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => return err.into_response(),
|
||||
};
|
||||
let (_, run_store) = match load_session_run_reader(&state, session_id).await {
|
||||
Ok(context) => context,
|
||||
Err(response) => return response,
|
||||
};
|
||||
match run_store
|
||||
.list_events_for_session_from_with_limit(session_id, params.since_seq(), params.limit())
|
||||
.await
|
||||
{
|
||||
Ok(mut data) => {
|
||||
let limit = params.limit();
|
||||
let has_more = data.len() > limit;
|
||||
data.truncate(limit);
|
||||
Json(PaginatedEventList {
|
||||
data,
|
||||
meta: PaginationMeta { has_more },
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(err) => store_error(&err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AttachSessionParams {
|
||||
#[serde(default)]
|
||||
since_seq: Option<u32>,
|
||||
}
|
||||
|
||||
async fn attach_session_events(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<AttachSessionParams>,
|
||||
) -> Response {
|
||||
const ATTACH_REPLAY_BATCH_LIMIT: usize = 256;
|
||||
|
||||
let session_id = match parse_session_id(&id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => return err.into_response(),
|
||||
};
|
||||
let (_, run_store) = match load_session_run_reader(&state, session_id).await {
|
||||
Ok(context) => context,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let start_seq = match params.since_seq {
|
||||
Some(seq) => seq.max(1),
|
||||
None => match run_store.list_events().await {
|
||||
Ok(events) => events.last().map_or(1, |event| event.seq.saturating_add(1)),
|
||||
Err(err) => return store_error(&err).into_response(),
|
||||
},
|
||||
};
|
||||
let shutdown = state.shutdown_token();
|
||||
let (sender, receiver) = mpsc::channel(SESSION_SSE_BUFFER_CAPACITY);
|
||||
tokio::spawn(async move {
|
||||
let mut next_seq = start_seq;
|
||||
|
||||
loop {
|
||||
let Ok(replay_batch) = run_store
|
||||
.list_events_for_session_from_with_limit(
|
||||
session_id,
|
||||
next_seq,
|
||||
ATTACH_REPLAY_BATCH_LIMIT,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let replay_has_more = replay_batch.len() > ATTACH_REPLAY_BATCH_LIMIT;
|
||||
|
||||
for event in replay_batch.into_iter().take(ATTACH_REPLAY_BATCH_LIMIT) {
|
||||
next_seq = event.seq.saturating_add(1);
|
||||
if let Some(sse_event) = session_sse_event(&event) {
|
||||
if !send_attach_sse_event(&sender, &shutdown, sse_event).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if replay_has_more {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let Ok(mut live_stream) = run_store.watch_events_from(next_seq) else {
|
||||
return;
|
||||
};
|
||||
let session_id_string = session_id.to_string();
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = shutdown.cancelled() => break,
|
||||
() = sender.closed() => break,
|
||||
next = live_stream.next() => {
|
||||
let Some(result) = next else {
|
||||
return;
|
||||
};
|
||||
let Ok(event) = result else {
|
||||
return;
|
||||
};
|
||||
if event_matches_session(&event, &session_id_string) {
|
||||
if let Some(sse_event) = session_sse_event(&event) {
|
||||
if !send_attach_sse_event(&sender, &shutdown, sse_event).await {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Sse::new(ReceiverStream::new(receiver))
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn submit_turn(
|
||||
_auth: RequiredUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -180,12 +358,25 @@ async fn submit_turn(
|
|||
};
|
||||
let input = request.input;
|
||||
|
||||
let turn_id = TurnId::new();
|
||||
let turn_id = match request.turn_id {
|
||||
Some(turn_id) => turn_id,
|
||||
None => TurnId::new(),
|
||||
};
|
||||
let turn_lease = match state.session_runtimes().reserve_turn(session_id, turn_id) {
|
||||
Ok(lease) => lease,
|
||||
Err(StartTurnError::ActiveTurn) => {
|
||||
return ApiError::new(StatusCode::CONFLICT, "Session already has an active turn.")
|
||||
.into_response();
|
||||
Err(StartTurnError::ActiveTurn { turn_id }) => {
|
||||
let mut response = ApiError::with_code(
|
||||
StatusCode::CONFLICT,
|
||||
"Session already has an active turn.",
|
||||
"session_active_turn",
|
||||
)
|
||||
.into_response();
|
||||
if let Ok(value) = HeaderValue::from_str(&turn_id.to_string()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-fabro-active-turn-id", value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -213,9 +404,13 @@ async fn submit_turn(
|
|||
tokio::spawn(run_streaming_turn(
|
||||
state, run_id, run_store, session, turn_id, input, sender, turn_lease,
|
||||
));
|
||||
Sse::new(ReceiverStream::new(receiver))
|
||||
let mut response = Sse::new(ReceiverStream::new(receiver))
|
||||
.keep_alive(KeepAlive::default())
|
||||
.into_response()
|
||||
.into_response();
|
||||
if let Ok(value) = HeaderValue::from_str(&turn_id.to_string()) {
|
||||
response.headers_mut().insert("x-fabro-turn-id", value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
async fn interrupt_turn(
|
||||
|
|
@ -310,11 +505,13 @@ async fn run_streaming_turn(
|
|||
&sender,
|
||||
run_id,
|
||||
session_id,
|
||||
EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps {
|
||||
turn_failed_body(
|
||||
turn_id,
|
||||
error: err.to_string(),
|
||||
output: None,
|
||||
}),
|
||||
err.to_string(),
|
||||
None,
|
||||
err.code(),
|
||||
err.retryable(),
|
||||
),
|
||||
Utc::now(),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -370,11 +567,8 @@ async fn run_streaming_turn(
|
|||
error: Some(err.to_string()),
|
||||
})
|
||||
} else {
|
||||
EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps {
|
||||
turn_id,
|
||||
error: err.to_string(),
|
||||
output: outcome.output,
|
||||
})
|
||||
let code = agent_failure_code(&err);
|
||||
turn_failed_body(turn_id, err.to_string(), outcome.output, code, false)
|
||||
};
|
||||
let _ =
|
||||
append_and_send_event(&run_store, &sender, run_id, session_id, body, Utc::now())
|
||||
|
|
@ -387,11 +581,13 @@ async fn run_streaming_turn(
|
|||
&sender,
|
||||
run_id,
|
||||
session_id,
|
||||
EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps {
|
||||
turn_failed_body(
|
||||
turn_id,
|
||||
error: err.to_string(),
|
||||
output: outcome.output,
|
||||
}),
|
||||
err.to_string(),
|
||||
outcome.output,
|
||||
RunSessionTurnFailedCode::AgentError,
|
||||
false,
|
||||
),
|
||||
Utc::now(),
|
||||
)
|
||||
.await;
|
||||
|
|
@ -404,13 +600,45 @@ struct TurnExecutionOutcome {
|
|||
output: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum AskFabroBuildError {
|
||||
#[error("{0}")]
|
||||
LlmUnconfigured(String),
|
||||
#[error("{0}")]
|
||||
ModelUnavailable(String),
|
||||
#[error("run has no sandbox available for Ask Fabro")]
|
||||
NoSandbox,
|
||||
#[error("run sandbox is unavailable for Ask Fabro: {0}")]
|
||||
SandboxUnavailable(#[source] anyhow::Error),
|
||||
#[error("failed to create Ask Fabro agent session: {0}")]
|
||||
Agent(#[source] anyhow::Error),
|
||||
}
|
||||
|
||||
impl AskFabroBuildError {
|
||||
fn code(&self) -> RunSessionTurnFailedCode {
|
||||
match self {
|
||||
Self::NoSandbox => RunSessionTurnFailedCode::NoSandbox,
|
||||
Self::SandboxUnavailable(_) => RunSessionTurnFailedCode::SandboxUnavailable,
|
||||
Self::LlmUnconfigured(_) => RunSessionTurnFailedCode::LlmUnconfigured,
|
||||
Self::ModelUnavailable(_) => RunSessionTurnFailedCode::ModelUnavailable,
|
||||
Self::Agent(_) => RunSessionTurnFailedCode::AgentError,
|
||||
}
|
||||
}
|
||||
|
||||
fn retryable(&self) -> bool {
|
||||
matches!(self, Self::SandboxUnavailable(_))
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_agent_session(
|
||||
state: &AppState,
|
||||
run_id: RunId,
|
||||
session: &ProjectedRunSession,
|
||||
) -> anyhow::Result<Session> {
|
||||
) -> Result<Session, AskFabroBuildError> {
|
||||
let catalog = state.catalog();
|
||||
let llm_result = state.resolve_llm_client().await?;
|
||||
let llm_result = state.resolve_llm_client().await.map_err(|err| {
|
||||
AskFabroBuildError::LlmUnconfigured(format!("LLM credentials are not configured: {err}"))
|
||||
})?;
|
||||
for (provider, issue) in &llm_result.auth_issues {
|
||||
warn!(provider = %provider, error = %issue, "LLM provider unavailable due to auth issue");
|
||||
}
|
||||
|
|
@ -420,21 +648,39 @@ async fn build_agent_session(
|
|||
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 message = format!("LLM credentials not configured for provider '{provider_id}'");
|
||||
return if session.record.model.is_some() {
|
||||
Err(AskFabroBuildError::ModelUnavailable(message))
|
||||
} else {
|
||||
Err(AskFabroBuildError::LlmUnconfigured(message))
|
||||
};
|
||||
}
|
||||
|
||||
let run_store = state.store_ref().open_run_reader(&run_id).await?;
|
||||
let projection = run_store.state().await?;
|
||||
let run_store = state
|
||||
.store_ref()
|
||||
.open_run_reader(&run_id)
|
||||
.await
|
||||
.map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?;
|
||||
let projection = run_store
|
||||
.state()
|
||||
.await
|
||||
.map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))?;
|
||||
let sandbox_record = projection
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("run has no sandbox available for Ask Fabro"))?;
|
||||
.ok_or(AskFabroBuildError::NoSandbox)?;
|
||||
if sandbox_record.runtime.is_none() {
|
||||
return Err(AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!(
|
||||
"run sandbox runtime is not ready"
|
||||
)));
|
||||
}
|
||||
let sandbox = reconnect_for_run(
|
||||
sandbox_record,
|
||||
state.vault_or_env("DAYTONA_API_KEY"),
|
||||
Some(run_id),
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(AskFabroBuildError::SandboxUnavailable)?;
|
||||
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::from(sandbox);
|
||||
let profile = build_profile(
|
||||
provider_id,
|
||||
|
|
@ -459,26 +705,32 @@ async fn build_agent_session(
|
|||
config,
|
||||
None,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
.map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))
|
||||
}
|
||||
|
||||
fn selected_session_model(
|
||||
catalog: &Catalog,
|
||||
llm_result: &LlmClientResult,
|
||||
session: &ProjectedRunSession,
|
||||
) -> anyhow::Result<(ProviderId, String, AgentProfileKind)> {
|
||||
) -> Result<(ProviderId, String, AgentProfileKind), AskFabroBuildError> {
|
||||
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"))?,
|
||||
Some(model_id) => catalog.get(model_id).ok_or_else(|| {
|
||||
AskFabroBuildError::ModelUnavailable(format!(
|
||||
"session model '{model_id}' is not in the catalog"
|
||||
))
|
||||
})?,
|
||||
None => catalog.default_for_configured_ids(&configured_provider_ids),
|
||||
};
|
||||
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_or_else(|| {
|
||||
AskFabroBuildError::ModelUnavailable(format!(
|
||||
"provider '{provider_id}' is not configured"
|
||||
))
|
||||
})?;
|
||||
Ok((provider_id, model, profile_kind))
|
||||
}
|
||||
|
||||
|
|
@ -672,6 +924,33 @@ fn record_turn_output(output: &mut Option<String>, event: &SessionEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
fn turn_failed_body(
|
||||
turn_id: TurnId,
|
||||
error: String,
|
||||
output: Option<String>,
|
||||
code: RunSessionTurnFailedCode,
|
||||
retryable: bool,
|
||||
) -> EventBody {
|
||||
EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps {
|
||||
turn_id,
|
||||
error,
|
||||
output,
|
||||
code,
|
||||
retryable,
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_failure_code(err: &AgentError) -> RunSessionTurnFailedCode {
|
||||
match err {
|
||||
AgentError::ToolExecution(message)
|
||||
if message.contains("denied") || message.contains("not allowed") =>
|
||||
{
|
||||
RunSessionTurnFailedCode::ToolDenied
|
||||
}
|
||||
_ => RunSessionTurnFailedCode::AgentError,
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_agent_event(
|
||||
run_store: &RunDatabase,
|
||||
run_id: RunId,
|
||||
|
|
@ -789,6 +1068,38 @@ async fn send_sse_event(sender: &SessionSseSender, event: &EventEnvelope) -> boo
|
|||
.is_ok()
|
||||
}
|
||||
|
||||
fn session_sse_event(event: &EventEnvelope) -> Option<Event> {
|
||||
let data = serde_json::to_string(event).ok()?;
|
||||
Some(
|
||||
Event::default()
|
||||
.id(event.seq.to_string())
|
||||
.event(event.event.event_name())
|
||||
.data(data),
|
||||
)
|
||||
}
|
||||
|
||||
async fn send_attach_sse_event(
|
||||
sender: &SessionSseSender,
|
||||
shutdown: &CancellationToken,
|
||||
event: Event,
|
||||
) -> bool {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = shutdown.cancelled() => false,
|
||||
() = sender.closed() => false,
|
||||
result = sender.send(Ok(event)) => result.is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn event_matches_session(event: &EventEnvelope, session_id: &str) -> bool {
|
||||
event
|
||||
.event
|
||||
.session_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| id == session_id)
|
||||
&& event.event.body.is_run_session_event()
|
||||
}
|
||||
|
||||
async fn load_session(
|
||||
state: &AppState,
|
||||
session_id: SessionId,
|
||||
|
|
@ -812,7 +1123,7 @@ async fn load_session(
|
|||
async fn load_session_read(
|
||||
state: &AppState,
|
||||
session_id: SessionId,
|
||||
) -> Result<(RunId, SessionRecord), Response> {
|
||||
) -> Result<(RunId, 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()),
|
||||
|
|
@ -823,12 +1134,35 @@ async fn load_session_read(
|
|||
Ok(events) => events,
|
||||
Err(err) => return Err(store_error(&err).into_response()),
|
||||
};
|
||||
match project_run_session(run_id, session_id, &events) {
|
||||
match fabro_store::project_run_session_with_context(run_id, session_id, &events) {
|
||||
Some(session) => Ok((run_id, session)),
|
||||
None => Err(ApiError::not_found("Session not found.").into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_session_run_reader(
|
||||
state: &AppState,
|
||||
session_id: SessionId,
|
||||
) -> Result<(RunId, RunDatabase), 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_for_session_from_with_limit(session_id, 1, 0)
|
||||
.await
|
||||
{
|
||||
Ok(events) => events,
|
||||
Err(err) => return Err(store_error(&err).into_response()),
|
||||
};
|
||||
if events.is_empty() {
|
||||
return Err(ApiError::not_found("Session not found.").into_response());
|
||||
}
|
||||
Ok((run_id, run_store))
|
||||
}
|
||||
|
||||
async fn open_run(state: &AppState, run_id: RunId) -> Result<RunDatabase, Response> {
|
||||
state.store_ref().open_run(&run_id).await.map_err(|err| {
|
||||
if matches!(err, fabro_store::Error::RunNotFound(_)) {
|
||||
|
|
@ -857,12 +1191,6 @@ fn store_error(err: &fabro_store::Error) -> ApiError {
|
|||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||
}
|
||||
|
||||
fn parse_run_id(value: &str) -> Result<RunId, ApiError> {
|
||||
value
|
||||
.parse()
|
||||
.map_err(|err| ApiError::bad_request(format!("Invalid run ID: {err}")))
|
||||
}
|
||||
|
||||
fn parse_session_id(value: &str) -> Result<SessionId, ApiError> {
|
||||
value
|
||||
.parse()
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ impl SessionRuntimeManager {
|
|||
.active_turn
|
||||
.lock()
|
||||
.expect("session active turn lock poisoned");
|
||||
if active.is_some() {
|
||||
return Err(StartTurnError::ActiveTurn);
|
||||
if let Some(active) = active.as_ref() {
|
||||
return Err(StartTurnError::ActiveTurn {
|
||||
turn_id: active.turn_id,
|
||||
});
|
||||
}
|
||||
*active = Some(ActiveTurn {
|
||||
turn_id,
|
||||
|
|
@ -134,7 +136,7 @@ struct ActiveTurn {
|
|||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StartTurnError {
|
||||
ActiveTurn,
|
||||
ActiveTurn { turn_id: TurnId },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -52,6 +52,58 @@ async fn request_json(
|
|||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_responses_include_ask_fabro_affordance() {
|
||||
let settings = settings_from_toml(
|
||||
r"
|
||||
_version = 1
|
||||
|
||||
[features]
|
||||
session_sandboxes = true
|
||||
",
|
||||
);
|
||||
let state = fabro_server::test_support::TestAppStateBuilder::new()
|
||||
.runtime_settings(settings.server_settings, settings.manifest_run_defaults)
|
||||
.env_lookup(|name| (name == "OPENAI_API_KEY").then(|| "test-key".to_string()))
|
||||
.build();
|
||||
let app = fabro_server::test_support::build_test_router(state);
|
||||
let created = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
|
||||
let run_id = created["id"].as_str().unwrap();
|
||||
|
||||
assert_eq!(created["ask_fabro"]["available"], false);
|
||||
assert_eq!(
|
||||
created["ask_fabro"]["unavailable_reason"],
|
||||
"sandbox_not_ready"
|
||||
);
|
||||
assert_eq!(created["ask_fabro"]["default_model"], "gpt-5.4");
|
||||
|
||||
let get_request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let fetched = response_json(
|
||||
app.clone().oneshot(get_request).await.unwrap(),
|
||||
StatusCode::OK,
|
||||
format!("GET /api/v1/runs/{run_id}"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(fetched["ask_fabro"], created["ask_fabro"]);
|
||||
|
||||
let list_request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let list = response_json(
|
||||
app.clone().oneshot(list_request).await.unwrap(),
|
||||
StatusCode::OK,
|
||||
"GET /api/v1/runs",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(list["data"][0]["ask_fabro"], created["ask_fabro"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retrieve_run_settings_returns_dense_snapshot() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ async fn run_bound_session_is_created_as_run_event_and_resolves_by_flat_id() {
|
|||
assert_eq!(fetched["id"], session_id);
|
||||
assert_eq!(fetched["run_id"], run_id);
|
||||
assert_session_metadata_only(&fetched);
|
||||
assert_eq!(fetched["messages"].as_array().unwrap().len(), 0);
|
||||
assert!(fetched["active_turn"].is_null());
|
||||
|
||||
let events_request = Request::builder()
|
||||
.method("GET")
|
||||
|
|
@ -123,6 +125,7 @@ async fn run_bound_session_is_created_as_run_event_and_resolves_by_flat_id() {
|
|||
.filter(|event| event["session_id"] == session_id)
|
||||
.collect();
|
||||
assert_eq!(session_events.len(), 1);
|
||||
assert_eq!(fetched["last_seq"], session_events[0]["seq"]);
|
||||
assert_eq!(session_events[0]["event"], "run.session.created");
|
||||
assert!(
|
||||
session_events[0]["properties"].get("permissions").is_none(),
|
||||
|
|
@ -273,6 +276,10 @@ async fn session_turn_fails_when_selected_model_provider_is_unconfigured() {
|
|||
.expect("submit-turn request should build");
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response.headers().contains_key("x-fabro-turn-id"),
|
||||
"submit turn should return the generated turn id"
|
||||
);
|
||||
let events = session_sse_events(response).await;
|
||||
|
||||
let failed = events
|
||||
|
|
@ -286,6 +293,8 @@ async fn session_turn_fails_when_selected_model_provider_is_unconfigured() {
|
|||
.contains("provider 'openai'"),
|
||||
"failure should be for the selected model provider: {failed:?}"
|
||||
);
|
||||
assert_eq!(failed["properties"]["code"], "model_unavailable");
|
||||
assert_eq!(failed["properties"]["retryable"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -312,7 +321,7 @@ async fn session_metadata_patch_route_is_removed() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn derived_session_read_routes_are_removed() {
|
||||
async fn unsupported_derived_turn_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;
|
||||
|
|
@ -321,11 +330,7 @@ async fn derived_session_read_routes_are_removed() {
|
|||
.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"),
|
||||
] {
|
||||
for path in [format!("/sessions/{session_id}/turns/{turn_id}")] {
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&path))
|
||||
|
|
@ -340,6 +345,68 @@ async fn derived_session_read_routes_are_removed() {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_events_are_filtered_by_session_and_paginated_by_run_sequence() {
|
||||
let app = test_app_with_no_providers();
|
||||
let run_id = create_run(&app).await;
|
||||
let first = create_session(&app, &run_id, "First").await;
|
||||
let second = create_session(&app, &run_id, "Second").await;
|
||||
let first_id = first["id"].as_str().unwrap();
|
||||
let second_id = second["id"].as_str().unwrap();
|
||||
let get_first = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/sessions/{first_id}")))
|
||||
.body(Body::empty())
|
||||
.expect("get-session request should build");
|
||||
let first_detail = response_json(
|
||||
app.clone().oneshot(get_first).await.unwrap(),
|
||||
StatusCode::OK,
|
||||
format!("GET /api/v1/sessions/{first_id}"),
|
||||
)
|
||||
.await;
|
||||
let after_first_created_seq = first_detail["last_seq"].as_u64().unwrap() + 1;
|
||||
|
||||
let turn_id = fabro_types::TurnId::new();
|
||||
let submit = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/sessions/{first_id}/turns")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(format!(
|
||||
r#"{{"turn_id":"{turn_id}","input":"Which provider?"}}"#
|
||||
)))
|
||||
.expect("submit-turn request should build");
|
||||
let response = app.clone().oneshot(submit).await.unwrap();
|
||||
assert_eq!(
|
||||
response.headers().get("x-fabro-turn-id").unwrap(),
|
||||
turn_id.to_string().as_str()
|
||||
);
|
||||
let _ = session_sse_events(response).await;
|
||||
|
||||
let request = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!(
|
||||
"/sessions/{first_id}/events?since_seq={after_first_created_seq}&limit=1"
|
||||
)))
|
||||
.body(Body::empty())
|
||||
.expect("session events request should build");
|
||||
let page = response_json(
|
||||
app.clone().oneshot(request).await.unwrap(),
|
||||
StatusCode::OK,
|
||||
format!("GET /api/v1/sessions/{first_id}/events"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(page["data"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(page["data"][0]["session_id"], first_id);
|
||||
assert_ne!(page["data"][0]["session_id"], second_id);
|
||||
assert_eq!(page["data"][0]["event"], "run.session.turn.started");
|
||||
assert_eq!(
|
||||
page["data"][0]["properties"]["turn_id"],
|
||||
turn_id.to_string()
|
||||
);
|
||||
assert_eq!(page["meta"]["has_more"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inactive_turn_interrupt_returns_conflict() {
|
||||
let app = fabro_server::test_support::build_test_router(test_app_state());
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
|||
use fabro_types::run_event::{RunSessionToolCallCompletedProps, RunSessionToolCallStartedProps};
|
||||
use fabro_types::{
|
||||
EventBody, EventEnvelope, RunId, SessionId, SessionMessage, SessionRecord, SessionStatus,
|
||||
SessionSummary,
|
||||
SessionSummary, SessionTurn,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ use serde_json::json;
|
|||
pub struct ProjectedRunSession {
|
||||
pub record: SessionRecord,
|
||||
pub runtime_context: Vec<SessionMessage>,
|
||||
pub last_seq: u32,
|
||||
}
|
||||
|
||||
pub fn project_run_sessions(run_id: RunId, events: &[EventEnvelope]) -> Vec<SessionSummary> {
|
||||
|
|
@ -79,18 +80,26 @@ impl RunSessionProjection {
|
|||
let projected = ProjectedRunSession {
|
||||
record,
|
||||
runtime_context: Vec::new(),
|
||||
last_seq: envelope.seq,
|
||||
};
|
||||
self.sessions.insert(session_id, projected);
|
||||
}
|
||||
EventBody::RunSessionTurnStarted(_) => {
|
||||
EventBody::RunSessionTurnStarted(props) => {
|
||||
if let Some(session) = self.sessions.get_mut(&session_id) {
|
||||
session.last_seq = envelope.seq;
|
||||
session.record.status = SessionStatus::Running;
|
||||
session.record.active_turn = Some(SessionTurn {
|
||||
id: props.turn_id,
|
||||
started_at: envelope.event.ts,
|
||||
input: props.input.clone(),
|
||||
});
|
||||
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) {
|
||||
session.last_seq = envelope.seq;
|
||||
if project_context {
|
||||
session
|
||||
.runtime_context
|
||||
|
|
@ -102,6 +111,7 @@ impl RunSessionProjection {
|
|||
EventBody::RunSessionAssistantMessage(props) => {
|
||||
let project_context = self.should_project_context(session_id);
|
||||
if let Some(session) = self.sessions.get_mut(&session_id) {
|
||||
session.last_seq = envelope.seq;
|
||||
if project_context {
|
||||
session.runtime_context.push(SessionMessage::Assistant {
|
||||
content: props.text.clone(),
|
||||
|
|
@ -115,9 +125,16 @@ impl RunSessionProjection {
|
|||
session.record.updated_at = envelope.event.ts;
|
||||
}
|
||||
}
|
||||
EventBody::RunSessionAssistantDelta(_) => {
|
||||
if let Some(session) = self.sessions.get_mut(&session_id) {
|
||||
session.last_seq = envelope.seq;
|
||||
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) {
|
||||
session.last_seq = envelope.seq;
|
||||
if project_context {
|
||||
append_tool_call(session, props);
|
||||
}
|
||||
|
|
@ -127,6 +144,7 @@ impl RunSessionProjection {
|
|||
EventBody::RunSessionToolCallCompleted(props) => {
|
||||
let project_context = self.should_project_context(session_id);
|
||||
if let Some(session) = self.sessions.get_mut(&session_id) {
|
||||
session.last_seq = envelope.seq;
|
||||
if project_context {
|
||||
append_tool_result(session, props, envelope.event.ts);
|
||||
}
|
||||
|
|
@ -134,10 +152,10 @@ impl RunSessionProjection {
|
|||
}
|
||||
}
|
||||
EventBody::RunSessionTurnFailed(_) => {
|
||||
self.finish_turn(session_id, true, envelope.event.ts);
|
||||
self.finish_turn(session_id, true, envelope.event.ts, envelope.seq);
|
||||
}
|
||||
EventBody::RunSessionTurnSucceeded(_) | EventBody::RunSessionTurnInterrupted(_) => {
|
||||
self.finish_turn(session_id, false, envelope.event.ts);
|
||||
self.finish_turn(session_id, false, envelope.event.ts, envelope.seq);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -149,13 +167,16 @@ impl RunSessionProjection {
|
|||
session_id: SessionId,
|
||||
failed: bool,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
seq: u32,
|
||||
) {
|
||||
if let Some(session) = self.sessions.get_mut(&session_id) {
|
||||
session.last_seq = seq;
|
||||
session.record.status = if failed {
|
||||
SessionStatus::Failed
|
||||
} else {
|
||||
SessionStatus::Idle
|
||||
};
|
||||
session.record.active_turn = None;
|
||||
session.record.updated_at = timestamp;
|
||||
}
|
||||
}
|
||||
|
|
@ -216,8 +237,8 @@ mod tests {
|
|||
use chrono::{TimeZone, Utc};
|
||||
use fabro_types::run_event::{
|
||||
RunSessionAssistantMessageProps, RunSessionCreatedProps, RunSessionToolCallCompletedProps,
|
||||
RunSessionToolCallStartedProps, RunSessionTurnStartedProps, RunSessionTurnSucceededProps,
|
||||
RunSessionUserMessageProps,
|
||||
RunSessionToolCallStartedProps, RunSessionTurnFailedCode, RunSessionTurnFailedProps,
|
||||
RunSessionTurnStartedProps, RunSessionTurnSucceededProps, RunSessionUserMessageProps,
|
||||
};
|
||||
use fabro_types::{EventBody, EventEnvelope, RunEvent, SessionMessage, TurnId, fixtures};
|
||||
use serde_json::json;
|
||||
|
|
@ -392,6 +413,94 @@ mod tests {
|
|||
assert!(value.get("deleted_at").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_tracks_active_turn_and_last_matching_sequence() {
|
||||
let session_id = fabro_types::SessionId::new();
|
||||
let other_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::RunSessionTurnStarted(RunSessionTurnStartedProps {
|
||||
turn_id,
|
||||
input: "Summarize".to_string(),
|
||||
}),
|
||||
),
|
||||
event(
|
||||
3,
|
||||
other_session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: Some("Other".to_string()),
|
||||
model: None,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
let session = project_run_session_with_context(fixtures::RUN_1, session_id, &events)
|
||||
.expect("session should project from run events");
|
||||
|
||||
assert_eq!(session.last_seq, 2);
|
||||
let active = session.record.active_turn.expect("turn should be active");
|
||||
assert_eq!(active.id, turn_id);
|
||||
assert_eq!(active.started_at, events[1].event.ts);
|
||||
assert_eq!(active.input, "Summarize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_clears_active_turn_when_turn_finishes() {
|
||||
let session_id = fabro_types::SessionId::new();
|
||||
let turn_id = TurnId::new();
|
||||
|
||||
for body in [
|
||||
EventBody::RunSessionTurnSucceeded(RunSessionTurnSucceededProps {
|
||||
turn_id,
|
||||
output: None,
|
||||
}),
|
||||
EventBody::RunSessionTurnFailed(RunSessionTurnFailedProps {
|
||||
turn_id,
|
||||
error: "no sandbox".to_string(),
|
||||
output: None,
|
||||
code: RunSessionTurnFailedCode::default(),
|
||||
retryable: false,
|
||||
}),
|
||||
] {
|
||||
let events = vec![
|
||||
event(
|
||||
1,
|
||||
session_id,
|
||||
EventBody::RunSessionCreated(RunSessionCreatedProps {
|
||||
title: None,
|
||||
model: None,
|
||||
}),
|
||||
),
|
||||
event(
|
||||
2,
|
||||
session_id,
|
||||
EventBody::RunSessionTurnStarted(RunSessionTurnStartedProps {
|
||||
turn_id,
|
||||
input: "Summarize".to_string(),
|
||||
}),
|
||||
),
|
||||
event(3, session_id, body),
|
||||
];
|
||||
|
||||
let session = project_run_session_with_context(fixtures::RUN_1, session_id, &events)
|
||||
.expect("session should project from run events");
|
||||
|
||||
assert_eq!(session.last_seq, 3);
|
||||
assert_eq!(session.record.active_turn, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn event(seq: u32, session_id: fabro_types::SessionId, body: EventBody) -> EventEnvelope {
|
||||
let event = RunEvent {
|
||||
id: format!("evt-{seq}"),
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ use fabro_types::run_event::{
|
|||
};
|
||||
use fabro_types::settings::run::RunSandboxSettings;
|
||||
use fabro_types::{
|
||||
AgentBackend, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, Conclusion,
|
||||
EventBody, FailureSignature, InterviewQuestionRecord, Outcome, PendingInterviewRecord,
|
||||
PullRequestLink, RepositoryRef, Run, RunBillingSummary, RunControlAction, RunDiff, RunEvent,
|
||||
RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox,
|
||||
RunSandboxRuntime, RunSpec, RunStatus, RunTimestamps, SandboxProvider, StageCompletion,
|
||||
StageHandler, StageId, StageOutcome, StageProjection, StageState, StartRecord, WorkflowRef,
|
||||
first_event_seq,
|
||||
AgentBackend, AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination,
|
||||
Conclusion, EventBody, FailureSignature, InterviewQuestionRecord, Outcome,
|
||||
PendingInterviewRecord, PullRequestLink, RepositoryRef, Run, RunBillingSummary,
|
||||
RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,
|
||||
RunProjection, RunSandbox, RunSandboxRuntime, RunSpec, RunStatus, RunTimestamps,
|
||||
SandboxProvider, StageCompletion, StageHandler, StageId, StageOutcome, StageProjection,
|
||||
StageState, StartRecord, WorkflowRef, first_event_seq,
|
||||
};
|
||||
use fabro_util::error::render_compact_with_causes;
|
||||
use serde_json::Value;
|
||||
|
|
@ -674,6 +674,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
|
|||
billing: total_usd_micros.map(|total_usd_micros| RunBillingSummary {
|
||||
total_usd_micros: Some(total_usd_micros),
|
||||
}),
|
||||
ask_fabro: AskFabro::default(),
|
||||
diff: diff_summary,
|
||||
pull_request: state.pull_request.clone(),
|
||||
current_question,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
|||
|
||||
use bytes::Bytes;
|
||||
use chrono::Utc;
|
||||
use fabro_types::{RunBlobId, RunEvent, RunId};
|
||||
use fabro_types::{RunBlobId, RunEvent, RunId, SessionId};
|
||||
use futures::Stream;
|
||||
use slatedb::{Db, DbRead};
|
||||
use tokio::sync::{Mutex, broadcast, mpsc};
|
||||
|
|
@ -339,6 +339,25 @@ impl RunDatabase {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Returns up to `limit + 1` durable Ask Fabro session events for the given
|
||||
/// session, starting at `start_seq`. The extra item lets callers compute
|
||||
/// `has_more` without a second read.
|
||||
pub async fn list_events_for_session_from_with_limit(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
start_seq: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<EventEnvelope>> {
|
||||
list_events_for_session_from_with_limit(
|
||||
&self.inner.db,
|
||||
&self.inner.run_id,
|
||||
session_id,
|
||||
start_seq,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn watch_events_from(
|
||||
&self,
|
||||
seq: u32,
|
||||
|
|
@ -570,6 +589,57 @@ where
|
|||
Ok(events)
|
||||
}
|
||||
|
||||
async fn list_events_for_session_from_with_limit<R>(
|
||||
db: &R,
|
||||
run_id: &RunId,
|
||||
session_id: SessionId,
|
||||
start_seq: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<EventEnvelope>>
|
||||
where
|
||||
R: DbRead + Sync,
|
||||
{
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SessionEventProbe<'a> {
|
||||
#[serde(default, borrow)]
|
||||
session_id: Option<&'a str>,
|
||||
#[serde(rename = "event", default, borrow)]
|
||||
event_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
let session_id_string = session_id.to_string();
|
||||
let max_events = limit.saturating_add(1);
|
||||
let mut iter = db.scan_prefix(keys::run_events_prefix(run_id)).await?;
|
||||
let mut events = Vec::new();
|
||||
while let Some(entry) = iter.next().await? {
|
||||
let key = key_to_string(&entry.key)?;
|
||||
let Some(seq) = keys::parse_event_seq(&key) else {
|
||||
continue;
|
||||
};
|
||||
if seq < start_seq {
|
||||
continue;
|
||||
}
|
||||
|
||||
let probe: SessionEventProbe = serde_json::from_slice(&entry.value)?;
|
||||
if probe.session_id != Some(session_id_string.as_str())
|
||||
|| !probe
|
||||
.event_name
|
||||
.is_some_and(|name| name.starts_with("run.session."))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let event: RunEvent = serde_json::from_slice(&entry.value)?;
|
||||
if event.body.is_run_session_event() {
|
||||
events.push(EventEnvelope { seq, event });
|
||||
if events.len() >= max_events {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
async fn list_blobs<R>(db: &R) -> Result<Vec<RunBlobId>>
|
||||
where
|
||||
R: DbRead + Sync,
|
||||
|
|
@ -597,7 +667,7 @@ mod tests {
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_types::{Graph, RunId, StageId, WorkflowSettings};
|
||||
use fabro_types::{Graph, RunId, SessionId, StageId, WorkflowSettings};
|
||||
use object_store::memory::InMemory;
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -624,6 +694,24 @@ mod tests {
|
|||
stage_prompt_payload_for_stage(run_id, idx, node_id, None)
|
||||
}
|
||||
|
||||
fn session_message_payload(run_id: &RunId, idx: u32, session_id: SessionId) -> EventPayload {
|
||||
EventPayload::new(
|
||||
json!({
|
||||
"id": format!("evt-session-{idx}"),
|
||||
"ts": "2026-04-09T12:00:00Z",
|
||||
"run_id": run_id.to_string(),
|
||||
"session_id": session_id.to_string(),
|
||||
"event": "run.session.user_message",
|
||||
"properties": {
|
||||
"turn_id": fabro_types::TurnId::new().to_string(),
|
||||
"text": format!("message {idx}"),
|
||||
},
|
||||
}),
|
||||
run_id,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn run_created_payload(run_id: &RunId) -> EventPayload {
|
||||
EventPayload::new(
|
||||
json!({
|
||||
|
|
@ -827,4 +915,51 @@ mod tests {
|
|||
let seqs: Vec<u32> = events.iter().map(|e| e.seq).collect();
|
||||
assert_eq!(seqs, vec![3]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_events_for_session_returns_only_matching_run_session_events() {
|
||||
let run = fresh_run().await;
|
||||
let run_id = run.run_id();
|
||||
let session_id = SessionId::new();
|
||||
let other_session_id = SessionId::new();
|
||||
run.append_event(&stage_prompt_payload(&run_id, 1, Some("noise")))
|
||||
.await
|
||||
.unwrap();
|
||||
run.append_event(&session_message_payload(&run_id, 2, session_id))
|
||||
.await
|
||||
.unwrap();
|
||||
run.append_event(&session_message_payload(&run_id, 3, other_session_id))
|
||||
.await
|
||||
.unwrap();
|
||||
run.append_event(&session_message_payload(&run_id, 4, session_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let events = run
|
||||
.list_events_for_session_from_with_limit(session_id, 1, 100)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let seqs: Vec<u32> = events.iter().map(|e| e.seq).collect();
|
||||
assert_eq!(seqs, vec![3, 5]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_events_for_session_returns_limit_plus_one_for_has_more_signal() {
|
||||
let run = fresh_run().await;
|
||||
let run_id = run.run_id();
|
||||
let session_id = SessionId::new();
|
||||
for idx in 1..=5 {
|
||||
run.append_event(&session_message_payload(&run_id, idx, session_id))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let events = run
|
||||
.list_events_for_session_from_with_limit(session_id, 1, 2)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 3);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ mod tests {
|
|||
},
|
||||
timing: None,
|
||||
billing: None,
|
||||
ask_fabro: fabro_types::AskFabro::default(),
|
||||
diff: None,
|
||||
pull_request: None,
|
||||
current_question: None,
|
||||
|
|
|
|||
|
|
@ -513,6 +513,7 @@ mod tests {
|
|||
},
|
||||
timing: None,
|
||||
billing: None,
|
||||
ask_fabro: fabro_types::AskFabro::default(),
|
||||
diff: None,
|
||||
pull_request: None,
|
||||
current_question: None,
|
||||
|
|
|
|||
|
|
@ -466,6 +466,7 @@ mod tests {
|
|||
},
|
||||
timing: None,
|
||||
billing: None,
|
||||
ask_fabro: fabro_types::AskFabro::default(),
|
||||
diff: None,
|
||||
pull_request: None,
|
||||
current_question: None,
|
||||
|
|
|
|||
|
|
@ -104,8 +104,8 @@ pub use run_projection::{
|
|||
};
|
||||
pub use run_sandbox::{RunSandbox, RunSandboxRuntime};
|
||||
pub use run_summary::{
|
||||
AutomationRef, Run, RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin,
|
||||
RunOriginKind, RunTimestamps, WorkflowRef,
|
||||
AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunBillingSummary, RunError,
|
||||
RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind, RunTimestamps, WorkflowRef,
|
||||
};
|
||||
pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title};
|
||||
pub use sandbox_details::{
|
||||
|
|
@ -119,8 +119,8 @@ pub use sandbox_services::{
|
|||
};
|
||||
pub use secret::{SecretMetadata, SecretType};
|
||||
pub use session::{
|
||||
PermissionLevel, SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary,
|
||||
TurnId,
|
||||
PermissionLevel, SessionDetail, SessionId, SessionMessage, SessionRecord, SessionStatus,
|
||||
SessionSummary, SessionTurn, TurnId,
|
||||
};
|
||||
pub use stage_completion::StageCompletion;
|
||||
pub use stage_handler::StageHandler;
|
||||
|
|
|
|||
|
|
@ -559,6 +559,10 @@ impl EventBody {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn is_run_session_event(&self) -> bool {
|
||||
self.event_name().starts_with("run.session.")
|
||||
}
|
||||
|
||||
fn properties_value(&self) -> serde_json::Result<Value> {
|
||||
if let Self::Unknown { properties, .. } = self {
|
||||
return Ok(properties.clone());
|
||||
|
|
|
|||
|
|
@ -63,12 +63,42 @@ pub struct RunSessionTurnSucceededProps {
|
|||
pub output: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Default,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
strum::Display,
|
||||
strum::EnumString,
|
||||
strum::IntoStaticStr,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum RunSessionTurnFailedCode {
|
||||
NoSandbox,
|
||||
SandboxUnavailable,
|
||||
LlmUnconfigured,
|
||||
ModelUnavailable,
|
||||
ToolDenied,
|
||||
#[default]
|
||||
AgentError,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunSessionTurnFailedProps {
|
||||
pub turn_id: TurnId,
|
||||
pub error: String,
|
||||
pub turn_id: TurnId,
|
||||
pub error: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<String>,
|
||||
pub output: Option<String>,
|
||||
#[serde(default)]
|
||||
pub code: RunSessionTurnFailedCode,
|
||||
#[serde(default)]
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,37 @@ use crate::{
|
|||
RunControlAction, RunId, RunSandbox, RunStatus, RunTiming,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AskFabro {
|
||||
pub available: bool,
|
||||
#[serde(default)]
|
||||
pub unavailable_reason: Option<AskFabroUnavailableReason>,
|
||||
#[serde(default)]
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
strum::Display,
|
||||
strum::EnumString,
|
||||
strum::IntoStaticStr,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum AskFabroUnavailableReason {
|
||||
FeatureDisabled,
|
||||
NoSandbox,
|
||||
SandboxNotReady,
|
||||
LlmUnconfigured,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Run {
|
||||
pub id: RunId,
|
||||
|
|
@ -40,6 +71,8 @@ pub struct Run {
|
|||
#[serde(default)]
|
||||
pub billing: Option<RunBillingSummary>,
|
||||
#[serde(default)]
|
||||
pub ask_fabro: AskFabro,
|
||||
#[serde(default)]
|
||||
pub diff: Option<DiffSummary>,
|
||||
#[serde(default)]
|
||||
pub pull_request: Option<PullRequestLink>,
|
||||
|
|
|
|||
|
|
@ -48,15 +48,24 @@ impl SessionStatus {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionTurn {
|
||||
pub id: TurnId,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionRecord {
|
||||
pub id: SessionId,
|
||||
pub run_id: RunId,
|
||||
pub title: Option<String>,
|
||||
pub status: SessionStatus,
|
||||
pub model: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub id: SessionId,
|
||||
pub run_id: RunId,
|
||||
pub title: Option<String>,
|
||||
pub status: SessionStatus,
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub active_turn: Option<SessionTurn>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl SessionRecord {
|
||||
|
|
@ -67,6 +76,7 @@ impl SessionRecord {
|
|||
title: None,
|
||||
status: SessionStatus::Idle,
|
||||
model: None,
|
||||
active_turn: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
|
|
@ -75,25 +85,47 @@ impl SessionRecord {
|
|||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionSummary {
|
||||
pub id: SessionId,
|
||||
pub run_id: RunId,
|
||||
pub title: Option<String>,
|
||||
pub status: SessionStatus,
|
||||
pub model: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub id: SessionId,
|
||||
pub run_id: RunId,
|
||||
pub title: Option<String>,
|
||||
pub status: SessionStatus,
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub active_turn: Option<SessionTurn>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<&SessionRecord> for SessionSummary {
|
||||
fn from(record: &SessionRecord) -> Self {
|
||||
Self {
|
||||
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,
|
||||
id: record.id,
|
||||
run_id: record.run_id,
|
||||
title: record.title.clone(),
|
||||
status: record.status,
|
||||
model: record.model.clone(),
|
||||
active_turn: record.active_turn.clone(),
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionDetail {
|
||||
#[serde(flatten)]
|
||||
pub record: SessionRecord,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<SessionMessage>,
|
||||
pub last_seq: u32,
|
||||
}
|
||||
|
||||
impl SessionDetail {
|
||||
pub fn new(record: SessionRecord, messages: Vec<SessionMessage>, last_seq: u32) -> Self {
|
||||
Self {
|
||||
record,
|
||||
messages,
|
||||
last_seq,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ use std::collections::BTreeMap;
|
|||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome};
|
||||
use fabro_types::run_event::run::{RunCreatedProps, RunParentLinkedProps, RunParentUnlinkedProps};
|
||||
use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::settings::run::RunGoal;
|
||||
use fabro_types::{EventBody, WorkflowSettings, fixtures};
|
||||
use fabro_types::{EventBody, TurnId, WorkflowSettings, fixtures};
|
||||
|
||||
fn templated_settings() -> WorkflowSettings {
|
||||
let mut settings = WorkflowSettings::default();
|
||||
|
|
@ -142,3 +143,20 @@ fn run_parent_events_round_trip_parent_ids() {
|
|||
serde_json::from_value(unlinked_json).expect("unlinked event should deserialize");
|
||||
assert_eq!(unlinked_round_trip.event_name(), "run.parent.unlinked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_session_turn_failed_defaults_code_for_old_events() {
|
||||
let turn_id = TurnId::new();
|
||||
let props: RunSessionTurnFailedProps = serde_json::from_value(serde_json::json!({
|
||||
"turn_id": turn_id,
|
||||
"error": "legacy failure"
|
||||
}))
|
||||
.expect("legacy failed props should deserialize");
|
||||
|
||||
assert_eq!(props.code, RunSessionTurnFailedCode::AgentError);
|
||||
assert!(!props.retryable);
|
||||
|
||||
let json = serde_json::to_value(props).expect("props should serialize");
|
||||
assert_eq!(json["code"], "agent_error");
|
||||
assert_eq!(json["retryable"], false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1638,6 +1638,7 @@ mod tests {
|
|||
},
|
||||
timing: None,
|
||||
billing: None,
|
||||
ask_fabro: fabro_types::AskFabro::default(),
|
||||
diff: None,
|
||||
pull_request: None,
|
||||
current_question: None,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ models/artifact-batch-upload-manifest.ts
|
|||
models/artifact-entry.ts
|
||||
models/artifact-list-response.ts
|
||||
models/artifacts-settings.ts
|
||||
models/ask-fabro.ts
|
||||
models/auth-config-response.ts
|
||||
models/auth-me-response.ts
|
||||
models/auth-method.ts
|
||||
|
|
@ -371,10 +372,12 @@ models/server-settings.ts
|
|||
models/server-slate-db-settings.ts
|
||||
models/server-storage-settings.ts
|
||||
models/server-web-settings.ts
|
||||
models/session-detail.ts
|
||||
models/session-message.ts
|
||||
models/session-record.ts
|
||||
models/session-status.ts
|
||||
models/session-summary.ts
|
||||
models/session-turn.ts
|
||||
models/slack-integration-settings.ts
|
||||
models/ssh-access-request.ts
|
||||
models/ssh-access-response.ts
|
||||
|
|
|
|||
224
lib/packages/fabro-api-client/src/api/sessions-api.ts
generated
224
lib/packages/fabro-api-client/src/api/sessions-api.ts
generated
|
|
@ -28,8 +28,12 @@ import type { ErrorResponse } from '../models';
|
|||
// @ts-ignore
|
||||
import type { EventEnvelope } from '../models';
|
||||
// @ts-ignore
|
||||
import type { PaginatedEventList } from '../models';
|
||||
// @ts-ignore
|
||||
import type { PaginatedSessionList } from '../models';
|
||||
// @ts-ignore
|
||||
import type { SessionDetail } from '../models';
|
||||
// @ts-ignore
|
||||
import type { SessionRecord } from '../models';
|
||||
// @ts-ignore
|
||||
import type { SubmitTurnRequest } from '../models';
|
||||
|
|
@ -38,6 +42,51 @@ import type { SubmitTurnRequest } from '../models';
|
|||
*/
|
||||
export const SessionsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
* Replays and streams this session\'s durable `run.session.*` events from the owning run event log. The stream remains open until the client disconnects or the server shuts down.
|
||||
* @summary Attach to session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
attachSessionEvents: async (id: string, sinceSeq?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('attachSessionEvents', 'id', id)
|
||||
const localVarPath = `/api/v1/sessions/{id}/attach`
|
||||
.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)
|
||||
|
||||
if (sinceSeq !== undefined) {
|
||||
localVarQueryParameter['since_seq'] = sinceSeq;
|
||||
}
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'text/event-stream,application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Creates a read-only Ask Fabro session bound to the run.
|
||||
* @summary Create run session
|
||||
|
|
@ -171,10 +220,13 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat
|
|||
*
|
||||
* @summary List run sessions
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {number} [pageLimit]
|
||||
* @param {number} [pageOffset]
|
||||
* @param {ListRunSessionsOrderEnum} [order]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRunSessions: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
listRunSessions: async (id: string, pageLimit?: number, pageOffset?: number, order?: ListRunSessionsOrderEnum, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('listRunSessions', 'id', id)
|
||||
const localVarPath = `/api/v1/runs/{id}/sessions`
|
||||
|
|
@ -196,6 +248,68 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat
|
|||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (pageLimit !== undefined) {
|
||||
localVarQueryParameter['page[limit]'] = pageLimit;
|
||||
}
|
||||
|
||||
if (pageOffset !== undefined) {
|
||||
localVarQueryParameter['page[offset]'] = pageOffset;
|
||||
}
|
||||
|
||||
if (order !== undefined) {
|
||||
localVarQueryParameter['order'] = order;
|
||||
}
|
||||
|
||||
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 run event envelopes filtered to this session\'s durable `run.session.*` events. `since_seq` uses the owning run event sequence.
|
||||
* @summary List session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {number} [limit]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listSessionEvents: async (id: string, sinceSeq?: number, limit?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('listSessionEvents', 'id', id)
|
||||
const localVarPath = `/api/v1/sessions/{id}/events`
|
||||
.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)
|
||||
|
||||
if (sinceSeq !== undefined) {
|
||||
localVarQueryParameter['since_seq'] = sinceSeq;
|
||||
}
|
||||
|
||||
if (limit !== undefined) {
|
||||
localVarQueryParameter['limit'] = limit;
|
||||
}
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
|
|
@ -261,6 +375,20 @@ export const SessionsApiAxiosParamCreator = function (configuration?: Configurat
|
|||
export const SessionsApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = SessionsApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
* Replays and streams this session\'s durable `run.session.*` events from the owning run event log. The stream remains open until the client disconnects or the server shuts down.
|
||||
* @summary Attach to session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async attachSessionEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.attachSessionEvents(id, sinceSeq, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['SessionsApi.attachSessionEvents']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Creates a read-only Ask Fabro session bound to the run.
|
||||
* @summary Create run session
|
||||
|
|
@ -282,7 +410,7 @@ export const SessionsApiFp = function(configuration?: Configuration) {
|
|||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getSession(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SessionRecord>> {
|
||||
async getSession(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SessionDetail>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getSession(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['SessionsApi.getSession']?.[localVarOperationServerIndex]?.url;
|
||||
|
|
@ -306,15 +434,33 @@ export const SessionsApiFp = function(configuration?: Configuration) {
|
|||
*
|
||||
* @summary List run sessions
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {number} [pageLimit]
|
||||
* @param {number} [pageOffset]
|
||||
* @param {ListRunSessionsOrderEnum} [order]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listRunSessions(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedSessionList>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listRunSessions(id, options);
|
||||
async listRunSessions(id: string, pageLimit?: number, pageOffset?: number, order?: ListRunSessionsOrderEnum, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedSessionList>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listRunSessions(id, pageLimit, pageOffset, order, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['SessionsApi.listRunSessions']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns run event envelopes filtered to this session\'s durable `run.session.*` events. `since_seq` uses the owning run event sequence.
|
||||
* @summary List session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {number} [limit]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listSessionEvents(id: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedEventList>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listSessionEvents(id, sinceSeq, limit, 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);
|
||||
},
|
||||
/**
|
||||
* Starts a streamed turn immediately. Background turns are not supported in this API version.
|
||||
* @summary Submit a session turn
|
||||
|
|
@ -338,6 +484,17 @@ export const SessionsApiFp = function(configuration?: Configuration) {
|
|||
export const SessionsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = SessionsApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
* Replays and streams this session\'s durable `run.session.*` events from the owning run event log. The stream remains open until the client disconnects or the server shuts down.
|
||||
* @summary Attach to session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
attachSessionEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig): AxiosPromise<string> {
|
||||
return localVarFp.attachSessionEvents(id, sinceSeq, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Creates a read-only Ask Fabro session bound to the run.
|
||||
* @summary Create run session
|
||||
|
|
@ -356,7 +513,7 @@ export const SessionsApiFactory = function (configuration?: Configuration, baseP
|
|||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getSession(id: string, options?: RawAxiosRequestConfig): AxiosPromise<SessionRecord> {
|
||||
getSession(id: string, options?: RawAxiosRequestConfig): AxiosPromise<SessionDetail> {
|
||||
return localVarFp.getSession(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
|
|
@ -374,11 +531,26 @@ export const SessionsApiFactory = function (configuration?: Configuration, baseP
|
|||
*
|
||||
* @summary List run sessions
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {number} [pageLimit]
|
||||
* @param {number} [pageOffset]
|
||||
* @param {ListRunSessionsOrderEnum} [order]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRunSessions(id: string, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedSessionList> {
|
||||
return localVarFp.listRunSessions(id, options).then((request) => request(axios, basePath));
|
||||
listRunSessions(id: string, pageLimit?: number, pageOffset?: number, order?: ListRunSessionsOrderEnum, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedSessionList> {
|
||||
return localVarFp.listRunSessions(id, pageLimit, pageOffset, order, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns run event envelopes filtered to this session\'s durable `run.session.*` events. `since_seq` uses the owning run event sequence.
|
||||
* @summary List session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {number} [limit]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listSessionEvents(id: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedEventList> {
|
||||
return localVarFp.listSessionEvents(id, sinceSeq, limit, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Starts a streamed turn immediately. Background turns are not supported in this API version.
|
||||
|
|
@ -398,6 +570,18 @@ export const SessionsApiFactory = function (configuration?: Configuration, baseP
|
|||
* SessionsApi - object-oriented interface
|
||||
*/
|
||||
export class SessionsApi extends BaseAPI {
|
||||
/**
|
||||
* Replays and streams this session\'s durable `run.session.*` events from the owning run event log. The stream remains open until the client disconnects or the server shuts down.
|
||||
* @summary Attach to session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public attachSessionEvents(id: string, sinceSeq?: number, options?: RawAxiosRequestConfig) {
|
||||
return SessionsApiFp(this.configuration).attachSessionEvents(id, sinceSeq, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a read-only Ask Fabro session bound to the run.
|
||||
* @summary Create run session
|
||||
|
|
@ -437,11 +621,27 @@ export class SessionsApi extends BaseAPI {
|
|||
*
|
||||
* @summary List run sessions
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {number} [pageLimit]
|
||||
* @param {number} [pageOffset]
|
||||
* @param {ListRunSessionsOrderEnum} [order]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listRunSessions(id: string, options?: RawAxiosRequestConfig) {
|
||||
return SessionsApiFp(this.configuration).listRunSessions(id, options).then((request) => request(this.axios, this.basePath));
|
||||
public listRunSessions(id: string, pageLimit?: number, pageOffset?: number, order?: ListRunSessionsOrderEnum, options?: RawAxiosRequestConfig) {
|
||||
return SessionsApiFp(this.configuration).listRunSessions(id, pageLimit, pageOffset, order, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns run event envelopes filtered to this session\'s durable `run.session.*` events. `since_seq` uses the owning run event sequence.
|
||||
* @summary List session events
|
||||
* @param {string} id
|
||||
* @param {number} [sinceSeq]
|
||||
* @param {number} [limit]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listSessionEvents(id: string, sinceSeq?: number, limit?: number, options?: RawAxiosRequestConfig) {
|
||||
return SessionsApiFp(this.configuration).listSessionEvents(id, sinceSeq, limit, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -456,3 +656,9 @@ export class SessionsApi extends BaseAPI {
|
|||
return SessionsApiFp(this.configuration).submitSessionTurn(id, submitTurnRequest, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
export const ListRunSessionsOrderEnum = {
|
||||
UPDATED_DESC: 'updated_desc',
|
||||
CREATED_DESC: 'created_desc'
|
||||
} as const;
|
||||
export type ListRunSessionsOrderEnum = typeof ListRunSessionsOrderEnum[keyof typeof ListRunSessionsOrderEnum];
|
||||
|
|
|
|||
33
lib/packages/fabro-api-client/src/models/ask-fabro.ts
generated
Normal file
33
lib/packages/fabro-api-client/src/models/ask-fabro.ts
generated
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Readiness and defaults for starting an Ask Fabro session on this run.
|
||||
*/
|
||||
export interface AskFabro {
|
||||
'available': boolean;
|
||||
'unavailable_reason': AskFabroUnavailableReasonEnum | null;
|
||||
'default_model': string | null;
|
||||
}
|
||||
|
||||
export const AskFabroUnavailableReasonEnum = {
|
||||
FEATURE_DISABLED: 'feature_disabled',
|
||||
NO_SANDBOX: 'no_sandbox',
|
||||
SANDBOX_NOT_READY: 'sandbox_not_ready',
|
||||
LLM_UNCONFIGURED: 'llm_unconfigured'
|
||||
} as const;
|
||||
|
||||
export type AskFabroUnavailableReasonEnum = typeof AskFabroUnavailableReasonEnum[keyof typeof AskFabroUnavailableReasonEnum];
|
||||
|
|
@ -10,6 +10,7 @@ export * from './artifact-batch-upload-manifest';
|
|||
export * from './artifact-entry';
|
||||
export * from './artifact-list-response';
|
||||
export * from './artifacts-settings';
|
||||
export * from './ask-fabro';
|
||||
export * from './auth-config-response';
|
||||
export * from './auth-me-response';
|
||||
export * from './auth-method';
|
||||
|
|
@ -347,10 +348,12 @@ export * from './server-settings';
|
|||
export * from './server-slate-db-settings';
|
||||
export * from './server-storage-settings';
|
||||
export * from './server-web-settings';
|
||||
export * from './session-detail';
|
||||
export * from './session-message';
|
||||
export * from './session-record';
|
||||
export * from './session-status';
|
||||
export * from './session-summary';
|
||||
export * from './session-turn';
|
||||
export * from './slack-integration-settings';
|
||||
export * from './ssh-access-request';
|
||||
export * from './ssh-access-response';
|
||||
|
|
|
|||
4
lib/packages/fabro-api-client/src/models/run.ts
generated
4
lib/packages/fabro-api-client/src/models/run.ts
generated
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AskFabro } from './ask-fabro';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { AutomationRef } from './automation-ref';
|
||||
|
|
@ -87,6 +90,7 @@ export interface Run {
|
|||
'timestamps': RunTimestamps;
|
||||
'timing': RunTiming | null;
|
||||
'billing': RunBillingSummary | null;
|
||||
'ask_fabro': AskFabro;
|
||||
'diff': DiffSummary | null;
|
||||
'pull_request': PullRequestLink | null;
|
||||
'current_question': RunQuestion | null;
|
||||
|
|
|
|||
43
lib/packages/fabro-api-client/src/models/session-detail.ts
generated
Normal file
43
lib/packages/fabro-api-client/src/models/session-detail.ts
generated
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/* 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 { SessionMessage } from './session-message';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SessionStatus } from './session-status';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SessionTurn } from './session-turn';
|
||||
|
||||
/**
|
||||
* Session metadata plus durable transcript projection.
|
||||
*/
|
||||
export interface SessionDetail {
|
||||
/**
|
||||
* Durable session identifier.
|
||||
*/
|
||||
'id': string;
|
||||
'run_id': string;
|
||||
'title'?: string | null;
|
||||
'status': SessionStatus;
|
||||
'model'?: string | null;
|
||||
'active_turn': SessionTurn | null;
|
||||
'created_at': string;
|
||||
'updated_at': string;
|
||||
'messages': Array<SessionMessage>;
|
||||
'last_seq': number;
|
||||
}
|
||||
|
|
@ -16,6 +16,9 @@
|
|||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SessionStatus } from './session-status';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SessionTurn } from './session-turn';
|
||||
|
||||
/**
|
||||
* Ask Fabro session metadata derived from the owning run event stream.
|
||||
|
|
@ -29,6 +32,7 @@ export interface SessionRecord {
|
|||
'title'?: string | null;
|
||||
'status': SessionStatus;
|
||||
'model'?: string | null;
|
||||
'active_turn': SessionTurn | null;
|
||||
'created_at': string;
|
||||
'updated_at': string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SessionStatus } from './session-status';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { SessionTurn } from './session-turn';
|
||||
|
||||
/**
|
||||
* List projection of an Ask Fabro session.
|
||||
|
|
@ -29,6 +32,7 @@ export interface SessionSummary {
|
|||
'title'?: string | null;
|
||||
'status': SessionStatus;
|
||||
'model'?: string | null;
|
||||
'active_turn': SessionTurn | null;
|
||||
'created_at': string;
|
||||
'updated_at': string;
|
||||
}
|
||||
|
|
|
|||
27
lib/packages/fabro-api-client/src/models/session-turn.ts
generated
Normal file
27
lib/packages/fabro-api-client/src/models/session-turn.ts
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Currently active durable session turn.
|
||||
*/
|
||||
export interface SessionTurn {
|
||||
/**
|
||||
* Durable session turn identifier.
|
||||
*/
|
||||
'id': string;
|
||||
'started_at': string;
|
||||
'input': string;
|
||||
}
|
||||
|
|
@ -16,4 +16,8 @@
|
|||
|
||||
export interface SubmitTurnRequest {
|
||||
'input': string;
|
||||
/**
|
||||
* Durable session turn identifier.
|
||||
*/
|
||||
'turn_id'?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue