Skip to main content

akribes_sdk/
models.rs

1/// Data types returned by and sent to the Akribes API.
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5// Re-export AST types referenced by SDK wire models so consumers can pattern-
6// match on the response without depending on `akribes-core` directly.
7pub use akribes_types::ast::{TypeField, TypeRef};
8
9// ── Core resources ───────────────────────────────────────────────────────────
10
11#[derive(Serialize, Deserialize, Clone, Debug)]
12pub struct Project {
13    pub id: i64,
14    pub name: String,
15    pub created_at: String,
16}
17
18#[derive(Serialize, Deserialize, Clone, Debug)]
19pub struct Script {
20    pub id: i64,
21    pub project_id: i64,
22    pub name: String,
23    pub created_at: String,
24}
25
26#[derive(Serialize, Deserialize, Clone, Debug)]
27pub struct ScriptVersion {
28    pub id: i64,
29    pub script_id: i64,
30    pub source: String,
31    pub label: Option<String>,
32    pub published_by: Option<String>,
33    pub created_at: String,
34}
35
36/// Internal wrapper for the publish endpoint's response shape.
37#[derive(Deserialize, Clone, Debug)]
38pub(crate) struct PublishResponse {
39    pub version: ScriptVersion,
40    /// Per-kind counts of dependents that got implicitly contract-rebased
41    /// on first publish (the server skips the unified contract check when
42    /// no channel is pinned yet — see handlers/versions.rs). `None` for
43    /// subsequent publishes, where the check ran for real and any breaks
44    /// would have surfaced as a 409 ContractBreak instead. Surfaced
45    /// upward through `PublishOutcome` so MCP and SDK consumers can show
46    /// "your first publish implicitly rebased N bench cases + M judges".
47    #[serde(default)]
48    pub rebased: Option<Vec<RebaseEntry>>,
49}
50
51/// One row of the `rebased` array on a first-publish response.
52#[derive(Serialize, Deserialize, Clone, Debug)]
53pub struct RebaseEntry {
54    pub kind: String,
55    pub count: usize,
56}
57
58/// User-facing publish outcome — version plus the optional rebase summary.
59/// Returned by `publish().execute()`; the historical `ScriptVersion`-only
60/// return shape is preserved by `execute_version_only()` for callers that
61/// don't care about the rebase signal.
62#[derive(Clone, Debug)]
63pub struct PublishOutcome {
64    pub version: ScriptVersion,
65    pub rebased: Option<Vec<RebaseEntry>>,
66}
67
68#[derive(Serialize, Deserialize, Clone, Debug)]
69pub struct ScriptChannel {
70    pub id: i64,
71    pub script_id: i64,
72    pub name: String,
73    pub version_id: Option<i64>,
74    pub updated_at: Option<String>,
75}
76
77/// A script draft with its parsed input definitions.
78///
79/// `inputs` is a list of `(name, type_display)` pairs. The server sends
80/// these as structured `{name, ty, docs}` objects — the SDK normalizes
81/// that plus the legacy `[[name, type], …]` tuple form into simple
82/// `(name, display_string)` pairs. `type_defs` keeps the server's raw
83/// custom-type block so new fields don't require an SDK bump.
84#[derive(Serialize, Deserialize, Clone, Debug)]
85pub struct Draft {
86    pub source: String,
87    #[serde(deserialize_with = "draft_de::deserialize_inputs")]
88    pub inputs: Vec<(String, String)>,
89    #[serde(default)]
90    pub type_defs: serde_json::Value,
91}
92
93mod draft_de {
94    use serde::de::Error as _;
95    use serde::{Deserialize, Deserializer};
96
97    /// Accept either `[[name, ty_string], ...]` (legacy / tests) or
98    /// `[{name, ty, docs}, ...]` (current server). `ty` may itself be
99    /// either a string or a `TypeRef`-shaped object — render both to a
100    /// source-level display string so downstream code can keep treating
101    /// the type as a plain name.
102    pub(super) fn deserialize_inputs<'de, D>(d: D) -> Result<Vec<(String, String)>, D::Error>
103    where
104        D: Deserializer<'de>,
105    {
106        #[derive(Deserialize)]
107        #[serde(untagged)]
108        enum InputEntry {
109            Tuple(String, String),
110            Object(InputObject),
111        }
112
113        #[derive(Deserialize)]
114        struct InputObject {
115            name: String,
116            #[serde(alias = "type")]
117            ty: serde_json::Value,
118            #[serde(default)]
119            #[allow(dead_code)]
120            docs: Option<String>,
121        }
122
123        let raw: Vec<InputEntry> = Vec::deserialize(d)?;
124        raw.into_iter()
125            .map(|e| match e {
126                InputEntry::Tuple(name, ty) => Ok((name, ty)),
127                InputEntry::Object(o) => {
128                    let display = type_display(&o.ty).ok_or_else(|| {
129                        D::Error::custom(format!(
130                            "input '{}' has unexpected `ty` shape: {}",
131                            o.name, o.ty
132                        ))
133                    })?;
134                    Ok((o.name, display))
135                }
136            })
137            .collect()
138    }
139
140    /// Render a `TypeRef`-shaped JSON payload (or a plain string) as a
141    /// source-level type fragment. Mirrors `akribes_types::ast::TypeRef::display`
142    /// but operates on raw JSON so we don't have to track every AST rename.
143    fn type_display(v: &serde_json::Value) -> Option<String> {
144        if let Some(s) = v.as_str() {
145            return Some(s.to_string());
146        }
147        let obj = v.as_object()?;
148        if let Some(arr) = obj.get("variants").and_then(|v| v.as_array()) {
149            let arms: Vec<String> = arr.iter().filter_map(type_display).collect();
150            if arms.len() == arr.len() {
151                return Some(arms.join(" | "));
152            }
153        }
154        if let Some(arr) = obj.get("choices").and_then(|v| v.as_array()) {
155            let arms: Vec<String> = arr
156                .iter()
157                .filter_map(|c| c.as_str().map(|s| format!("\"{s}\"")))
158                .collect();
159            if arms.len() == arr.len() {
160                return Some(arms.join(" | "));
161            }
162        }
163        let name = obj.get("name").and_then(|n| n.as_str())?;
164        if let Some(inner) = obj
165            .get("inner")
166            .and_then(|i| if i.is_null() { None } else { Some(i) })
167        {
168            if let Some(inner_display) = type_display(inner) {
169                return Some(format!("{name}[{inner_display}]"));
170            }
171        }
172        Some(name.to_string())
173    }
174}
175
176/// A script version with its parsed input definitions.
177/// Returned by the `/latest` endpoint.
178#[derive(Serialize, Deserialize, Clone, Debug)]
179pub struct LatestVersion {
180    pub id: i64,
181    pub script_id: i64,
182    pub source: String,
183    pub label: Option<String>,
184    pub published_by: Option<String>,
185    pub created_at: String,
186    #[serde(default)]
187    pub inputs: Vec<(String, String)>,
188}
189
190// ── Document conversion ─────────────────────────────────────────────────────
191
192/// Result from the `/convert` endpoint.
193///
194/// `document_id` is populated when akribes-server has S3 persistence
195/// configured (the default in prod). Callers that want to re-use the
196/// uploaded file as a `document`-typed input on a subsequent `/run`
197/// call should pass this id instead of the converted markdown.
198#[derive(Serialize, Deserialize, Clone, Debug)]
199pub struct ConvertResult {
200    pub markdown: String,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub document_id: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub filename: Option<String>,
205}
206
207/// S3 document reference — either a pre-signed URL or bucket/key with temp credentials.
208#[derive(Serialize, Deserialize, Clone, Debug)]
209#[serde(untagged)]
210pub enum S3DocumentRef {
211    /// Pre-signed URL — server just fetches it.
212    Presigned { presigned_url: String },
213    /// Bucket + key with temporary credentials.
214    Credentials {
215        bucket: String,
216        key: String,
217        #[serde(skip_serializing_if = "Option::is_none")]
218        region: Option<String>,
219        access_key_id: String,
220        secret_access_key: String,
221        #[serde(skip_serializing_if = "Option::is_none")]
222        session_token: Option<String>,
223    },
224}
225
226// ── Document references ─────────────────────────────────────────────────────
227
228/// A document reference returned when S3 persistence is active.
229#[derive(Serialize, Deserialize, Clone, Debug)]
230pub struct DocumentRef {
231    pub document_id: String,
232    pub filename: String,
233}
234
235/// Full document metadata returned by `GET /documents/{id}`.
236#[derive(Serialize, Deserialize, Clone, Debug)]
237pub struct DocumentMeta {
238    pub id: String,
239    pub filename: String,
240    pub content_type: String,
241    pub size_bytes: i64,
242    pub content_hash: String,
243    pub conversion_status: String,
244    pub conversion_error: Option<String>,
245    pub created_at: String,
246}
247
248// ── Execution ────────────────────────────────────────────────────────────────
249
250#[derive(Serialize, Deserialize, Clone, Debug)]
251pub struct RunResult {
252    pub execution_id: String,
253}
254
255#[derive(Serialize, Deserialize, Clone, Debug)]
256pub struct ExecutionStatus {
257    pub id: String,
258    pub project_id: i64,
259    pub script_name: String,
260    pub status: String,
261    pub started_at: Option<String>,
262    pub finished_at: Option<String>,
263    pub version_id: Option<i64>,
264    pub channel: Option<String>,
265    pub error: Option<String>,
266    pub error_kind: Option<String>,
267    pub result: Option<serde_json::Value>,
268    pub documents: Option<serde_json::Value>,
269    pub triggered_by: Option<String>,
270    #[serde(default)]
271    pub input_tokens: i64,
272    #[serde(default)]
273    pub output_tokens: i64,
274    /// Tokens consumed by tool-response payloads (task 39b).
275    #[serde(default)]
276    pub tool_tokens: i64,
277    pub cost_usd: Option<f64>,
278    /// Workflow's declared return [`TypeRef`], when statically resolvable
279    /// from the source the execution ran against. Lets clients dispatch
280    /// directly into a typed renderer (e.g. `list[Patent]` → typed table)
281    /// instead of inferring shape from the raw value. `None` when the
282    /// server couldn't determine the type (older servers, unparseable
283    /// source, workflows without an explicit terminal `return <call>(...)`).
284    #[serde(default)]
285    pub result_type: Option<TypeRef>,
286    /// Declared record types from the source the execution ran against,
287    /// keyed by `type Name:` identifier (#1172). Lets clients render
288    /// results back to their declared shape (named records, typed columns)
289    /// instead of falling through to JSON shape inference. `None` from
290    /// older servers; an empty map when the source couldn't be parsed.
291    #[serde(default)]
292    pub type_defs: Option<serde_json::Value>,
293    /// ID of the parent execution that spawned this one via
294    /// `spawn_child_execution`. `None` for top-level executions.
295    #[serde(default)]
296    pub parent_execution_id: Option<String>,
297    /// The node ID within the parent execution at which this child was
298    /// spawned. `None` when `parent_execution_id` is `None`.
299    #[serde(default)]
300    pub parent_node_id: Option<String>,
301}
302
303#[derive(Serialize, Deserialize, Clone, Debug)]
304pub struct ExecutionOutput {
305    pub status: String,
306    pub error: Option<String>,
307    pub error_kind: Option<String>,
308    pub result: Option<serde_json::Value>,
309}
310
311/// Summary of a child execution spawned via `spawn_child_execution` (#1054).
312/// Returned by `GET /executions/{id}/children`. For v1 the parent-linkage
313/// columns are typically NULL; this type is forward-looking.
314#[derive(Serialize, Deserialize, Clone, Debug)]
315pub struct ExecutionChildSummary {
316    pub id: String,
317    pub parent_node_id: Option<String>,
318    pub status: String,
319    pub started_at: Option<String>,
320    pub finished_at: Option<String>,
321    pub script_name: String,
322}
323
324/// Per-task cost / token / duration breakdown row returned by
325/// `GET /executions/{id}/tasks`. One row per `execution_tasks` entry,
326/// populated as `TaskEnd` events arrive. Mirrors the server's
327/// `get_execution_tasks` row shape and the TS SDK's `ExecutionTaskSummary`.
328#[derive(Serialize, Deserialize, Clone, Debug)]
329pub struct ExecutionTaskSummary {
330    pub task_name: String,
331    pub model: Option<String>,
332    pub provider: Option<String>,
333    pub input_tokens: i64,
334    pub output_tokens: i64,
335    pub cached_input_tokens: i64,
336    pub cache_write_input_tokens: i64,
337    pub cost_usd: Option<f64>,
338    pub duration_ms: Option<i64>,
339    pub attempt: i32,
340    pub finished_at: String,
341}
342
343/// Envelope returned by `GET /executions/{id}/tasks`. Mirrors the server's
344/// `get_execution_tasks` response and the TS SDK's `ExecutionTasksResponse`.
345#[derive(Serialize, Deserialize, Clone, Debug)]
346pub struct ExecutionTasksResponse {
347    pub execution_id: String,
348    pub tasks: Vec<ExecutionTaskSummary>,
349}
350
351// ── Engine events ────────────────────────────────────────────────────────────
352//
353// The raw wire-format event is now re-exported from `akribes-core`. The SDK's
354// partial 15-variant enum has been removed in favour of that re-export plus
355// the normalized [`crate::events::WorkflowEvent`] for client-friendly
356// consumption.
357
358pub use akribes_types::event::{EngineEvent, TokenUsage};
359
360/// Helper: the variant name of an [`EngineEvent`] as emitted on the wire.
361/// Used by [`crate::events::WorkflowEvent`] to tag catch-all variants.
362pub(crate) fn engine_event_type_name(evt: &EngineEvent) -> &'static str {
363    match evt {
364        EngineEvent::Log(_) => "Log",
365        EngineEvent::LogLevel { .. } => "LogLevel",
366        EngineEvent::StateUpdate(..) => "StateUpdate",
367        EngineEvent::WorkflowStart(_) => "WorkflowStart",
368        EngineEvent::TaskStart(..) => "TaskStart",
369        EngineEvent::TaskPrompt(..) => "TaskPrompt",
370        EngineEvent::TaskEnd { .. } => "TaskEnd",
371        EngineEvent::AgentOutput { .. } => "AgentOutput",
372        EngineEvent::AgentReasoning { .. } => "AgentReasoning",
373        EngineEvent::Suspended { .. } => "Suspended",
374        EngineEvent::Resumed { .. } => "Resumed",
375        EngineEvent::WorkflowEnd(akribes_types::event::WorkflowEndPayload { value: _, .. }) => {
376            "WorkflowEnd"
377        }
378        EngineEvent::Error { .. } => "Error",
379        EngineEvent::NodeStart(..) => "NodeStart",
380        EngineEvent::NodeEnd { .. } => "NodeEnd",
381        EngineEvent::Breakpoint { .. } => "Breakpoint",
382        EngineEvent::BreakpointResumed { .. } => "BreakpointResumed",
383        EngineEvent::ToolCallStart { .. } => "ToolCallStart",
384        EngineEvent::ToolCallEnd { .. } => "ToolCallEnd",
385        EngineEvent::McpServerDegraded { .. } => "McpServerDegraded",
386        EngineEvent::McpServerRecovered { .. } => "McpServerRecovered",
387        EngineEvent::ToolApprovalPending { .. } => "ToolApprovalPending",
388        EngineEvent::ToolApprovalResolved { .. } => "ToolApprovalResolved",
389        EngineEvent::ToolApprovalSkipped { .. } => "ToolApprovalSkipped",
390        EngineEvent::ToolReplayUncertain { .. } => "ToolReplayUncertain",
391        EngineEvent::LLMReplayCacheHit { .. } => "LLMReplayCacheHit",
392        EngineEvent::VerificationStart { .. } => "VerificationStart",
393        EngineEvent::VerificationResult { .. } => "VerificationResult",
394        EngineEvent::ValidationFailure { .. } => "ValidationFailure",
395        EngineEvent::SubScript { .. } => "SubScript",
396        EngineEvent::CachePlanned { .. } => "CachePlanned",
397        EngineEvent::LoopStart { .. } => "LoopStart",
398        EngineEvent::LoopTurn { .. } => "LoopTurn",
399        EngineEvent::LoopEnd { .. } => "LoopEnd",
400        EngineEvent::ContextCompacted { .. } => "ContextCompacted",
401        EngineEvent::ContextOverflow { .. } => "ContextOverflow",
402        // P3 telemetry: persistent task-cache hit (per-task `cache_control`
403        // hit served from `task_cache_entries`). Carried on the wire so
404        // the Studio + bench can attribute "this task was free this run".
405        EngineEvent::TaskCacheHit { .. } => "TaskCacheHit",
406        EngineEvent::LLMResponse { .. } => "LLMResponse",
407        EngineEvent::SubScriptSpawned { .. } => "SubScriptSpawned",
408        EngineEvent::SubScriptResult { .. } => "SubScriptResult",
409        EngineEvent::CheckpointResolution { .. } => "CheckpointResolution",
410        // FIXME(unit-6): The Rust SDK's typed `WorkflowEvent` arms for
411        // container code-execution events land in unit 6 of the
412        // "AI-driven container code execution" feature. Unit 3 (engine
413        // wiring) lands the engine-side variants; unit 6 will replace
414        // these stubs with typed `Runtime*` arms on `WorkflowEvent` and
415        // matching reducer logic so SDK consumers don't see them as
416        // `Other` first. Today they round-trip through the catch-all
417        // path in `events.rs` (`other => Self::Other { ... }`).
418        EngineEvent::RuntimeStart { .. } => "RuntimeStart",
419        EngineEvent::RuntimeStdout { .. } => "RuntimeStdout",
420        EngineEvent::RuntimeStderr { .. } => "RuntimeStderr",
421        EngineEvent::RuntimeEnd { .. } => "RuntimeEnd",
422        EngineEvent::RuntimeError { .. } => "RuntimeError",
423    }
424}
425
426#[derive(Serialize, Deserialize, Clone, Debug)]
427pub struct ExecutionEvents {
428    pub execution_id: String,
429    pub status: String,
430    /// `false` while the execution is still running (snapshot of events so far).
431    /// `true` once the execution has reached a terminal state.
432    pub complete: bool,
433    pub events: Vec<EngineEvent>,
434    /// Cursor for fetching the next page of events.
435    #[serde(default)]
436    pub next_after_id: Option<i64>,
437    /// Whether more events are available beyond this page.
438    #[serde(default)]
439    pub has_more: bool,
440}
441
442// ── Cost aggregation ────────────────────────────────────────────────────
443
444#[derive(Serialize, Deserialize, Clone, Debug)]
445pub struct VersionCost {
446    pub version_id: Option<i64>,
447    pub executions: i64,
448    pub avg_cost_usd: f64,
449    pub total_cost_usd: f64,
450}
451
452#[derive(Serialize, Deserialize, Clone, Debug)]
453pub struct ProjectCost {
454    pub project_id: i64,
455    pub total_executions: i64,
456    pub total_cost_usd: f64,
457    pub avg_cost_usd: f64,
458    pub total_input_tokens: i64,
459    pub total_output_tokens: i64,
460}
461
462#[derive(Serialize, Deserialize, Clone, Debug)]
463pub struct CostAggregation {
464    pub total_executions: i64,
465    pub total_cost_usd: f64,
466    pub avg_cost_usd: f64,
467    pub total_input_tokens: i64,
468    pub total_output_tokens: i64,
469    #[serde(default)]
470    pub total_tool_tokens: i64,
471    #[serde(default)]
472    pub by_version: Vec<VersionCost>,
473}
474
475/// Canonical, cross-SDK name for [`CostAggregation`] (#1193). TS exposes the
476/// same shape as `ScriptCost`; Python re-exports both. New Rust code should
477/// prefer this alias so the type name matches the other SDKs. The legacy
478/// `CostAggregation` name is kept for back-compat — both refer to the same
479/// type.
480pub type ScriptCost = CostAggregation;
481
482// ── Graph ───────────────────────────────────────────────────────────────────
483
484#[derive(Serialize, Deserialize, Clone, Debug)]
485pub struct GraphNode {
486    pub id: usize,
487    pub op_type: String,
488    pub op_name: Option<String>,
489    pub target_var: Option<String>,
490    pub reads: Vec<String>,
491    pub line: usize,
492    pub col: usize,
493}
494
495#[derive(Serialize, Deserialize, Clone, Debug)]
496pub struct GraphEdge {
497    pub from: usize,
498    pub to: usize,
499}
500
501#[derive(Serialize, Deserialize, Clone, Debug)]
502pub struct GraphResponse {
503    pub nodes: Vec<GraphNode>,
504    pub edges: Vec<GraphEdge>,
505}
506
507/// Cross-SDK alias for [`GraphResponse`] (#1189). TS calls the same shape
508/// `ScriptGraph`; Python re-exports both. New Rust code should prefer this
509/// name when interop matters. The legacy `GraphResponse` is kept for
510/// back-compat — both refer to the same type.
511pub type ScriptGraph = GraphResponse;
512
513// ── Hub events (SSE) ─────────────────────────────────────────────────────────
514
515#[derive(Serialize, Deserialize, Clone, Debug)]
516#[serde(tag = "type", content = "payload")]
517pub enum RegistryEvent {
518    ProjectCreated(Project),
519    ProjectUpdated(Project),
520    ProjectDeleted(i64),
521    ScriptCreated {
522        project_id: i64,
523        script: Script,
524    },
525    ScriptUpdated {
526        project_id: i64,
527        script_name: String,
528        version_id: i64,
529        #[serde(default)]
530        channel: Option<String>,
531    },
532    ScriptDeleted {
533        project_id: i64,
534        script_name: String,
535    },
536}
537
538/// Hub-wire events for live bench runs, broadcast on the project `/events`
539/// stream as `HubEvent::Bench(..)`. Mirrors the server's `BenchEvent`
540/// (`crates/akribes-server/src/models.rs`) exactly: adjacently tagged
541/// (`{"type":"RunStarted","payload":{...}}`), three variants that reuse the
542/// existing [`BenchRun`] / [`BenchResult`] row models.
543#[derive(Serialize, Deserialize, Clone, Debug)]
544#[serde(tag = "type", content = "payload")]
545pub enum BenchEvent {
546    /// A bench run transitioned `pending → running`. Carries the row so
547    /// subscribers that didn't cache the trigger response have it.
548    RunStarted {
549        project_id: i64,
550        script_name: String,
551        run: BenchRun,
552    },
553    /// A single case result landed (workflow + judge complete, or a failure
554    /// was recorded). Sent before `RunFinished` so progress indicators can
555    /// update incrementally.
556    ResultRecorded {
557        project_id: i64,
558        script_name: String,
559        run_id: i64,
560        result: BenchResult,
561    },
562    /// The coordinator reached a terminal status (`completed` / `failed` /
563    /// `canceled`). Final aggregates land on the row.
564    RunFinished {
565        project_id: i64,
566        script_name: String,
567        run: BenchRun,
568    },
569}
570
571#[derive(Serialize, Deserialize, Clone, Debug)]
572#[serde(tag = "type", content = "payload")]
573pub enum HubEvent {
574    Execution {
575        project_id: i64,
576        script_name: String,
577        /// The execution row's id. Lets subscribers filter out events
578        /// from a different concurrent run of the same script — without
579        /// it, two callers running the same script around the same time
580        /// see each other's events. Optional on the wire for back-compat
581        /// with older servers that predate the field (#1042 / TS SDK
582        /// `HubEvent.payload.execution_id`).
583        #[serde(default, skip_serializing_if = "Option::is_none")]
584        execution_id: Option<String>,
585        event: EngineEvent,
586        /// Monotonic per-execution sequence number from the
587        /// `execution_events.id` row that this broadcast accompanies.
588        /// Optional on the wire — older subscribers ignore it; missing
589        /// on reconnect-replay frames where we don't have a row yet.
590        #[serde(default, skip_serializing_if = "Option::is_none")]
591        seq: Option<i64>,
592        /// Server-side RFC3339 timestamp with ms precision. Same value
593        /// the REST `get_execution_events` endpoint stamps on each event.
594        #[serde(default, skip_serializing_if = "Option::is_none")]
595        at: Option<String>,
596    },
597    Registry(RegistryEvent),
598    /// A live bench-run lifecycle event (`RunStarted` / `ResultRecorded` /
599    /// `RunFinished`). Previously dropped on the floor — and, because the
600    /// hub batch was decoded as `Vec<HubEvent>` with no catch-all, a Bench
601    /// frame co-occurring with `Execution` frames in the same batch dropped
602    /// the *entire* batch. The per-element decode in `sub::events` plus this
603    /// typed arm fix both halves (#bench-hub-events).
604    Bench(BenchEvent),
605}
606
607// ── Draft response ──────────────────────────────────────────────────────
608
609#[derive(Serialize, Deserialize, Clone, Debug)]
610pub struct PutDraftResponse {
611    #[serde(default)]
612    pub schema_warnings: Vec<ContractWarning>,
613}
614
615#[derive(Serialize, Deserialize, Clone, Debug)]
616pub struct ContractWarning {
617    pub client_id: String,
618    pub client_name: String,
619    pub channel: String,
620    pub mismatch: SchemaMismatch,
621}
622
623// ── Publish dry-run ─────────────────────────────────────────────────────
624
625#[derive(Serialize, Deserialize, Clone, Debug)]
626pub struct DryRunResult {
627    pub dry_run: bool,
628    pub would_break: i64,
629    pub breaking_interests: Vec<BreakingInterest>,
630}
631
632#[derive(Serialize, Deserialize, Clone, Debug)]
633pub struct BreakingInterest {
634    pub client_id: String,
635    pub client_name: String,
636    pub channel: String,
637    pub lifetime: String,
638    pub mismatch: SchemaMismatch,
639}
640
641// ── Client registration ──────────────────────────────────────────────────────
642
643/// Info about a registered client, returned by `GET /projects/{id}/clients`.
644#[derive(Serialize, Deserialize, Clone, Debug)]
645pub struct ClientInfo {
646    pub id: String,
647    pub name: String,
648    pub last_seen: String,
649    #[serde(default)]
650    pub scripts: Vec<String>,
651}
652
653#[derive(Serialize, Deserialize, Clone, Debug)]
654pub struct ClientInterest {
655    pub script_name: String,
656    pub inputs: HashMap<String, String>,
657    #[serde(skip_serializing_if = "Option::is_none")]
658    pub channel: Option<String>,
659    #[serde(skip_serializing_if = "Option::is_none")]
660    pub lifetime: Option<String>,
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub strict: Option<bool>,
663}
664
665#[derive(Serialize, Deserialize, Clone, Debug)]
666pub struct RegisteredInterest {
667    pub script_name: String,
668    pub channel: String,
669    pub bound_version_id: Option<i64>,
670    #[serde(default)]
671    pub input_schema: Vec<(String, String)>,
672}
673
674#[derive(Serialize, Deserialize, Clone, Debug)]
675pub struct RegisterClientResponse {
676    #[serde(default)]
677    pub interests: Vec<RegisteredInterest>,
678}
679
680#[derive(Serialize, Deserialize, Clone, Debug)]
681pub struct SchemaMismatch {
682    #[serde(default)]
683    pub missing: Vec<(String, String)>,
684    #[serde(default)]
685    pub wrong_type: Vec<(String, String, String)>,
686    #[serde(default)]
687    pub extra: Vec<String>,
688}
689
690#[derive(Serialize, Deserialize, Clone, Debug)]
691pub struct ContractLockInfo {
692    pub id: i64,
693    pub client_id: String,
694    pub client_name: String,
695    pub script_name: String,
696    pub channel: String,
697    pub bound_version_id: Option<i64>,
698    pub lifetime: String,
699    pub drifted: bool,
700    pub created_by: Option<String>,
701    pub created_at: String,
702    pub input_schema: String,
703}
704
705// ── Scoped tokens ───────────────────────────────────────────────────────────
706
707#[derive(Serialize, Deserialize, Clone, Debug)]
708pub struct TokenScopes {
709    pub projects: ProjectScope,
710    pub role: TokenRole,
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub scripts: Option<Vec<String>>,
713    #[serde(skip_serializing_if = "Option::is_none")]
714    pub executions: Option<Vec<String>>,
715    /// Whether the new token may itself mint child tokens. Defaults to
716    /// `false`. Service tokens always pass; scoped minters must already have
717    /// `can_mint` set on their own scopes for this to be honored.
718    #[serde(default)]
719    pub can_mint: bool,
720    /// Feature flags granted to this token (e.g. `["lumen"]`). Empty by
721    /// default. Service tokens have all features unless explicitly restricted.
722    #[serde(default, skip_serializing_if = "Vec::is_empty")]
723    pub features: Vec<String>,
724    /// Optional org binding. Studio populates this on every per-user mint so
725    /// akribes-server can stamp `projects.organization_id` and enforce
726    /// `OrgWide` scope checks. Legacy CLI mints leave it `None`.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub org_id: Option<i64>,
729}
730
731#[derive(Serialize, Deserialize, Clone, Debug)]
732#[serde(untagged)]
733pub enum ProjectScope {
734    Wildcard(WildcardMarker),
735    Specific(Vec<i64>),
736}
737
738/// Represents the `"*"` wildcard for project scope.
739#[derive(Clone, Debug)]
740pub struct WildcardMarker;
741
742impl Serialize for WildcardMarker {
743    fn serialize<S: serde::Serializer>(
744        &self,
745        serializer: S,
746    ) -> std::result::Result<S::Ok, S::Error> {
747        serializer.serialize_str("*")
748    }
749}
750
751impl<'de> Deserialize<'de> for WildcardMarker {
752    fn deserialize<D: serde::Deserializer<'de>>(
753        deserializer: D,
754    ) -> std::result::Result<Self, D::Error> {
755        let s = String::deserialize(deserializer)?;
756        if s == "*" {
757            Ok(WildcardMarker)
758        } else {
759            Err(serde::de::Error::custom("expected \"*\""))
760        }
761    }
762}
763
764#[derive(Serialize, Deserialize, Clone, Debug)]
765#[serde(rename_all = "lowercase")]
766pub enum TokenRole {
767    Admin,
768    Editor,
769    Viewer,
770}
771
772#[derive(Serialize, Deserialize, Clone, Debug)]
773pub struct TokenInfo {
774    pub id: String,
775    pub label: String,
776    pub user_email: Option<String>,
777    pub scopes: TokenScopes,
778    pub minted_by: String,
779    pub expires_at: String,
780    pub revoked: bool,
781    pub created_at: String,
782    pub last_used_at: Option<String>,
783}
784
785/// Returned only on creation — the raw token is shown once and never again.
786#[derive(Serialize, Deserialize, Clone, Debug)]
787pub struct MintTokenResponse {
788    pub token: String,
789    pub token_id: String,
790    pub expires_at: String,
791}
792
793/// Request body for minting a new scoped token.
794#[derive(Serialize, Deserialize, Clone, Debug)]
795pub struct MintTokenRequest {
796    #[serde(skip_serializing_if = "Option::is_none")]
797    pub user_email: Option<String>,
798    pub scopes: TokenScopes,
799    pub expires_in: i64,
800    pub label: String,
801}
802
803/// Response from revoking tokens by email.
804#[derive(Serialize, Deserialize, Clone, Debug)]
805pub struct RevokeByEmailResponse {
806    pub revoked: i64,
807}
808
809// ── Ad-hoc execution ────────────────────────────────────────────────────────
810
811#[derive(Serialize, Deserialize, Clone, Debug)]
812pub struct AdhocRunResult {
813    pub execution_id: String,
814    pub project_id: i64,
815}
816
817// ── MCP ─────────────────────────────────────────────────────────────────────
818
819#[derive(Serialize, Deserialize, Clone, Debug)]
820#[serde(rename_all = "lowercase")]
821pub enum McpOrigin {
822    Env,
823    Script,
824    Db,
825}
826
827#[derive(Serialize, Deserialize, Clone, Debug)]
828pub struct McpServerSummary {
829    pub alias: String,
830    pub url: String,
831    pub origin: McpOrigin,
832    pub is_registry: bool,
833    pub status: String,
834    pub tool_count: i64,
835}
836
837#[derive(Serialize, Deserialize, Clone, Debug)]
838pub struct McpToolSummary {
839    pub qualified_name: String,
840    pub server_alias: String,
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub description: Option<String>,
843    pub input_schema: serde_json::Value,
844}
845
846#[derive(Serialize, Deserialize, Clone, Debug)]
847pub struct McpHealth {
848    pub status: String,
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub last_error: Option<String>,
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub last_check_at: Option<String>,
853}
854
855/// Response from `GET /me/sandbox`.
856#[derive(Deserialize, Clone, Debug)]
857pub(crate) struct SandboxProjectIdResponse {
858    pub project_id: i64,
859}
860
861// ── Internal request bodies ──────────────────────────────────────────────────
862
863#[derive(Serialize)]
864pub(crate) struct RegisterRequest {
865    pub id: String,
866    pub name: String,
867    pub interests: Vec<ClientInterest>,
868}
869
870#[derive(Serialize)]
871pub(crate) struct HeartbeatRequest {
872    pub client_id: String,
873}
874
875#[derive(Serialize, Default)]
876pub(crate) struct RunRequest {
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub inputs: Option<HashMap<String, serde_json::Value>>,
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub triggered_by: Option<String>,
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub breakpoint_lines: Option<Vec<usize>>,
883}
884
885#[derive(Serialize)]
886pub(crate) struct CreateProjectRequest<'a> {
887    pub name: &'a str,
888}
889
890#[derive(Serialize)]
891pub(crate) struct UpdateProjectRequest<'a> {
892    pub name: &'a str,
893}
894
895#[derive(Serialize)]
896pub(crate) struct CreateScriptBody<'a> {
897    pub source: &'a str,
898}
899
900#[derive(Serialize)]
901pub(crate) struct RenameScriptRequest<'a> {
902    pub new_name: &'a str,
903}
904
905#[derive(Serialize)]
906pub(crate) struct MoveScriptRequest {
907    pub target_project_id: i64,
908}
909
910#[derive(Serialize)]
911pub(crate) struct ReorderRequest {
912    pub order: Vec<i64>,
913}
914
915/// Response from `POST /projects/{id}/mcp/servers/{alias}/refresh`.
916#[derive(Serialize, Deserialize, Clone, Debug)]
917pub struct McpRefreshResult {
918    pub refreshed: bool,
919    pub alias: String,
920    pub tool_count: usize,
921}
922
923/// Response from `GET /projects/{id}/mcp/servers/{alias}/drift`.
924#[derive(Serialize, Deserialize, Clone, Debug)]
925pub struct McpDriftResult {
926    pub drifted: bool,
927    #[serde(default)]
928    pub added: Vec<String>,
929    #[serde(default)]
930    pub removed: Vec<String>,
931    #[serde(default)]
932    pub reason: Option<String>,
933}
934
935#[derive(Serialize)]
936pub(crate) struct PutDraftRequest<'a> {
937    pub source: &'a str,
938}
939
940#[derive(Serialize, Default)]
941pub(crate) struct PublishRequest {
942    pub channels: Vec<String>,
943    #[serde(skip_serializing_if = "Option::is_none")]
944    pub label: Option<String>,
945    #[serde(skip_serializing_if = "Option::is_none")]
946    pub published_by: Option<String>,
947    #[serde(skip_serializing_if = "Option::is_none")]
948    pub force: Option<bool>,
949    #[serde(skip_serializing_if = "Option::is_none")]
950    pub dry_run: Option<bool>,
951}
952
953#[derive(Serialize)]
954pub(crate) struct CreateChannelRequest<'a> {
955    pub name: &'a str,
956}
957
958#[derive(Serialize)]
959pub(crate) struct MoveChannelRequest {
960    pub version_id: i64,
961    #[serde(skip_serializing_if = "Option::is_none")]
962    pub force: Option<bool>,
963}
964
965#[derive(Serialize)]
966pub(crate) struct RebindLockRequest {
967    pub version_id: Option<i64>,
968}
969
970#[derive(Serialize)]
971pub(crate) struct AdhocRunRequest<'a> {
972    pub source: &'a str,
973    #[serde(skip_serializing_if = "Option::is_none")]
974    pub inputs: Option<HashMap<String, serde_json::Value>>,
975    #[serde(skip_serializing_if = "Option::is_none")]
976    pub breakpoint_lines: Option<Vec<usize>>,
977    /// Release channel for resolving `use foo` references (#1120). When
978    /// `None`, the server applies its default (typically `production`).
979    #[serde(skip_serializing_if = "Option::is_none")]
980    pub channel: Option<&'a str>,
981    /// Opaque identifier recorded with the execution for audit (#1120).
982    #[serde(skip_serializing_if = "Option::is_none")]
983    pub triggered_by: Option<&'a str>,
984}
985
986#[derive(Serialize)]
987pub(crate) struct ResumeRequest {
988    pub token: String,
989    pub data: serde_json::Value,
990}
991
992#[derive(Serialize)]
993pub(crate) struct RunWithS3Request {
994    pub inputs: HashMap<String, S3DocumentRef>,
995    #[serde(skip_serializing_if = "Option::is_none")]
996    pub channel: Option<String>,
997    #[serde(skip_serializing_if = "Option::is_none")]
998    pub triggered_by: Option<String>,
999}
1000
1001#[derive(Serialize, Default)]
1002pub(crate) struct RunFromRequest {
1003    #[serde(skip_serializing_if = "Option::is_none")]
1004    pub inputs: Option<HashMap<String, serde_json::Value>>,
1005    pub seed_env: HashMap<String, serde_json::Value>,
1006    pub skip_node_ids: Vec<usize>,
1007    #[serde(skip_serializing_if = "Option::is_none")]
1008    pub triggered_by: Option<String>,
1009}
1010
1011// ── Document ingest (new API, puto-first) ────────────────────────────────
1012
1013/// Conversion status reported by the ingest endpoints. `Text` means the file
1014/// was ingested via the pure-text fast-path (no VLM/Docling call). `Ready`
1015/// means conversion completed. `Converting` means another caller is currently
1016/// converting these bytes; use [`DocumentsClient::ingest`] to wait.
1017#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
1018#[serde(rename_all = "snake_case")]
1019pub enum ConversionStatus {
1020    Text,
1021    Ready,
1022    Converting,
1023    Pending,
1024    Failed,
1025    #[serde(other)]
1026    Unknown,
1027}
1028
1029#[derive(Serialize, Deserialize, Clone, Debug)]
1030pub struct UploadResult {
1031    pub document_id: String,
1032    pub filename: String,
1033    pub content_hash: String,
1034    pub conversion_status: ConversionStatus,
1035}
1036
1037/// Snapshot of server-side conversion progress for a content hash (#1151).
1038/// Returned by [`crate::sub::documents::DocumentsClient::progress`].
1039/// Mirrors the TS `IngestProgress` and Python `IngestProgress` types.
1040#[derive(Serialize, Deserialize, Clone, Debug)]
1041pub struct IngestProgress {
1042    /// Pages already converted.
1043    pub done: u32,
1044    /// Total pages in the document.
1045    pub total: u32,
1046}
1047
1048/// Wire-level shape of `GET /projects/{pid}/documents/by-hash/{hash}/progress`.
1049#[derive(Deserialize)]
1050#[serde(tag = "state", rename_all = "snake_case")]
1051pub(crate) enum ProgressResponseWire {
1052    Converting { done_pages: u32, total_pages: u32 },
1053    Idle,
1054}
1055
1056#[derive(Clone, Debug)]
1057pub enum ClaimOutcome {
1058    Hit(UploadResult),
1059    Miss,
1060}
1061
1062// Internal wire types.
1063
1064#[derive(Serialize)]
1065pub(crate) struct ClaimRequest<'a> {
1066    pub content_hash: &'a str,
1067    pub filename: &'a str,
1068}
1069
1070/// Wire-level discriminated union returned by POST /projects/{pid}/documents/claim.
1071#[derive(Deserialize)]
1072#[serde(tag = "status", rename_all = "snake_case")]
1073pub(crate) enum ClaimResponseWire {
1074    Hit {
1075        document_id: String,
1076        filename: String,
1077        content_hash: String,
1078        conversion_status: ConversionStatus,
1079    },
1080    Miss,
1081}
1082
1083// ── Bench ────────────────────────────────────────────────────────────────────
1084//
1085// Wire models mirroring `crates/akribes-server/src/models.rs` for the bench
1086// substrate. Timestamps are surfaced as `String` rather than `chrono::DateTime`
1087// to keep the SDK independent of `chrono` — the server emits RFC3339 strings
1088// that round-trip through `String` cleanly.
1089
1090/// Per-script bench configuration. One row per `scripts.id`.
1091/// `judge_script_id` is nullable while the bench is still being authored.
1092#[derive(Serialize, Deserialize, Clone, Debug)]
1093pub struct Bench {
1094    pub id: i64,
1095    pub script_id: i64,
1096    #[serde(default)]
1097    pub judge_script_id: Option<i64>,
1098    pub judge_channel: String,
1099    pub config: serde_json::Value,
1100    pub created_at: String,
1101    pub updated_at: String,
1102}
1103
1104/// Aggregated per-bench summary used by the project-level evals landing page.
1105/// Returned by `GET /projects/{id}/benches`.
1106#[derive(Serialize, Deserialize, Clone, Debug)]
1107pub struct ProjectBenchSummary {
1108    pub bench_id: i64,
1109    pub script_id: i64,
1110    pub script_name: String,
1111    #[serde(default)]
1112    pub judge_script_id: Option<i64>,
1113    #[serde(default)]
1114    pub judge_script_name: Option<String>,
1115    pub judge_channel: String,
1116    pub case_count: i64,
1117    #[serde(default)]
1118    pub latest_run_id: Option<i64>,
1119    #[serde(default)]
1120    pub latest_run_status: Option<String>,
1121    #[serde(default)]
1122    pub latest_run_channel: Option<String>,
1123    #[serde(default)]
1124    pub latest_run_workflow_version_id: Option<i64>,
1125    #[serde(default)]
1126    pub latest_run_at: Option<String>,
1127    #[serde(default)]
1128    pub latest_run_mean_score: Option<f64>,
1129    #[serde(default)]
1130    pub latest_run_cost_usd: Option<f64>,
1131    pub updated_at: String,
1132}
1133
1134/// A single bench-run row. `workflow_version_id` and `judge_version_id` are
1135/// resolved at trigger time so a later channel publish doesn't change what
1136/// this run represents.
1137#[derive(Serialize, Deserialize, Clone, Debug)]
1138pub struct BenchRun {
1139    pub id: i64,
1140    pub bench_id: i64,
1141    pub channel: String,
1142    pub workflow_version_id: i64,
1143    pub judge_version_id: i64,
1144    pub status: String,
1145    #[serde(default)]
1146    pub triggered_by: Option<String>,
1147    pub triggered_at: String,
1148    #[serde(default)]
1149    pub completed_at: Option<String>,
1150    #[serde(default)]
1151    pub total_cost_usd: f64,
1152    #[serde(default)]
1153    pub total_cases: i32,
1154    #[serde(default)]
1155    pub cache_hit_cases: i32,
1156    #[serde(default)]
1157    pub notes: Option<String>,
1158    #[serde(default)]
1159    pub mcp_session_id: Option<String>,
1160    #[serde(default)]
1161    pub case_filter: Option<Vec<String>>,
1162    /// Mean headline score across completed (`status='ok' OR 'cached'`) results
1163    /// in this run. Populated by the list-runs aggregate query; bare
1164    /// GET-single-run + coordinator inserts leave it `None`.
1165    #[serde(default)]
1166    pub mean_headline_score: Option<f64>,
1167    /// Number of results with `status='ok' OR 'cached'`. Populated alongside
1168    /// `mean_headline_score` by the list-runs aggregate.
1169    #[serde(default)]
1170    pub ok_cases: Option<i64>,
1171    /// Per-`BenchResultStatus` row count for this run, surfaced by the
1172    /// list-runs and get-run aggregate queries (#753). Lets consumers
1173    /// render a failure-mix breakdown (workflow_failed vs judge_failed vs
1174    /// skipped) without an N+1 `/results` fetch. Statuses with zero rows
1175    /// may be absent rather than serialised as `0`.
1176    #[serde(default, skip_serializing_if = "Option::is_none")]
1177    pub status_breakdown: Option<std::collections::HashMap<String, i64>>,
1178    /// Name of the judge script whose version produced this run. Joined in by
1179    /// `get_run` and `list_runs` on the server so a caller can deep-link to
1180    /// the judge's source at `judge_version_id` without an N+1 lookup. Empty
1181    /// on coordinator-inserted rows and on benches with no judge wired up.
1182    #[serde(default)]
1183    pub judge_script_name: Option<String>,
1184}
1185
1186/// One per-case score row for a bench run. Carries the workflow execution's
1187/// typed `workflow_output` alongside the judge's `score` blob so the studio's
1188/// typed renderers don't need a second fetch.
1189#[derive(Serialize, Deserialize, Clone, Debug)]
1190pub struct BenchResult {
1191    pub id: i64,
1192    pub bench_run_id: i64,
1193    pub case_id: String,
1194    #[serde(default)]
1195    pub workflow_execution_id: Option<String>,
1196    #[serde(default)]
1197    pub judge_execution_id: Option<String>,
1198    #[serde(default)]
1199    pub score: Option<serde_json::Value>,
1200    #[serde(default)]
1201    pub headline_score: Option<f64>,
1202    pub status: String,
1203    #[serde(default)]
1204    pub cost_usd: f64,
1205    #[serde(default)]
1206    pub duration_ms: Option<i32>,
1207    #[serde(default)]
1208    pub cache_hit: bool,
1209    #[serde(default)]
1210    pub input_hash: Option<String>,
1211    /// Human-readable error message captured when `status` is
1212    /// `workflow_failed` or `judge_failed`; `None` on `ok`/`cached` rows.
1213    /// Mirrors the server's `BenchResult.error` column — present on both the
1214    /// `/bench-runs/{id}/results` read path and the live SSE `result` frame.
1215    #[serde(default)]
1216    pub error: Option<String>,
1217    pub created_at: String,
1218    /// Parsed `WorkflowEnd` payload from the workflow execution. `None` when
1219    /// the workflow failed, was canceled, or this is a cache-hit row.
1220    ///
1221    /// Only the `/bench-runs/{id}/results` read path (which joins
1222    /// `executions.result`) populates this; the live SSE `result` frame
1223    /// broadcasts the bare `BenchResult` row, so this stays `None` on
1224    /// events from [`crate::sub::bench::BenchRunsClient::subscribe_run_events`].
1225    #[serde(default)]
1226    pub workflow_output: Option<serde_json::Value>,
1227}
1228
1229/// Server-side projection of an `executions` row with `kind='case'`. Cases live
1230/// in the same table as live executions.
1231#[derive(Serialize, Deserialize, Clone, Debug)]
1232pub struct BenchCase {
1233    pub id: String,
1234    pub project_id: i64,
1235    pub script_name: String,
1236    #[serde(default)]
1237    pub bench_id: Option<i64>,
1238    pub kind: String,
1239    pub frozen: bool,
1240    #[serde(default)]
1241    pub case_name: Option<String>,
1242    #[serde(default)]
1243    pub inputs: Option<serde_json::Value>,
1244    #[serde(default)]
1245    pub expected_output: Option<serde_json::Value>,
1246    #[serde(default)]
1247    pub ground_truth: Option<serde_json::Value>,
1248    /// SHA-256 hex (lowercase) of `canonical_json(inputs)`. Used as one
1249    /// component of the bench-result cache key. Nullable for legacy rows.
1250    #[serde(default)]
1251    pub input_hash: Option<String>,
1252    pub created_at: String,
1253}
1254
1255/// Returned by `GET /bench-runs/{a}/compare/{b}`. Per-case score delta.
1256#[derive(Serialize, Deserialize, Clone, Debug)]
1257pub struct CompareCase {
1258    pub case_id: String,
1259    pub case_label: String,
1260    #[serde(default)]
1261    pub score_a: Option<f64>,
1262    #[serde(default)]
1263    pub score_b: Option<f64>,
1264    #[serde(default)]
1265    pub delta: Option<f64>,
1266    /// `improved | regressed | unchanged | missing_a | missing_b`.
1267    pub flag: String,
1268}
1269
1270#[derive(Serialize, Deserialize, Clone, Debug)]
1271pub struct CompareAggregate {
1272    pub mean_score_delta: f64,
1273    pub cost_delta_usd: f64,
1274    pub n_regressed: i32,
1275    pub n_improved: i32,
1276    pub n_unchanged: i32,
1277}
1278
1279#[derive(Serialize, Deserialize, Clone, Debug)]
1280pub struct CompareReport {
1281    pub run_a_id: i64,
1282    pub run_b_id: i64,
1283    pub aggregate: CompareAggregate,
1284    pub per_case: Vec<CompareCase>,
1285}
1286
1287/// Single drifted case from `GET /projects/{id}/scripts/{name}/bench/cases/contract-drift`.
1288#[derive(Serialize, Deserialize, Clone, Debug)]
1289pub struct DriftedCase {
1290    pub case_id: String,
1291    pub label: String,
1292    pub what_broke: String,
1293}
1294
1295#[derive(Serialize, Deserialize, Clone, Debug)]
1296pub struct DriftReport {
1297    pub drifted: Vec<DriftedCase>,
1298    #[serde(default)]
1299    pub script_version_id: Option<i64>,
1300    #[serde(default)]
1301    pub published_at: Option<String>,
1302    #[serde(default)]
1303    pub published_by: Option<String>,
1304    pub summary: String,
1305}
1306
1307/// Receipt returned by `PATCH /bench-runs/{id}/tag-session`. Used to confirm
1308/// the coordinator picked up the MCP-session attribution.
1309#[derive(Serialize, Deserialize, Clone, Debug)]
1310pub struct BenchRunTagSessionResponse {
1311    pub tagged: bool,
1312    pub run_id: i64,
1313    pub mcp_session_id: String,
1314}
1315
1316// ── Bench request wire types ────────────────────────────────────────────────
1317
1318#[derive(Serialize, Deserialize, Clone, Debug, Default)]
1319pub struct CreateOrUpdateBenchRequest {
1320    #[serde(skip_serializing_if = "Option::is_none")]
1321    pub judge_script_id: Option<i64>,
1322    #[serde(skip_serializing_if = "Option::is_none")]
1323    pub judge_channel: Option<String>,
1324    #[serde(skip_serializing_if = "Option::is_none")]
1325    pub config: Option<serde_json::Value>,
1326}
1327
1328#[derive(Serialize, Deserialize, Clone, Debug)]
1329pub struct CreateBenchCaseRequest {
1330    pub inputs: serde_json::Value,
1331    #[serde(skip_serializing_if = "Option::is_none")]
1332    pub expected_output: Option<serde_json::Value>,
1333    #[serde(skip_serializing_if = "Option::is_none")]
1334    pub ground_truth: Option<serde_json::Value>,
1335    #[serde(skip_serializing_if = "Option::is_none")]
1336    pub name: Option<String>,
1337}
1338
1339#[derive(Serialize, Deserialize, Clone, Debug, Default)]
1340pub struct PatchBenchCaseRequest {
1341    #[serde(skip_serializing_if = "Option::is_none")]
1342    pub inputs: Option<serde_json::Value>,
1343    #[serde(skip_serializing_if = "Option::is_none")]
1344    pub expected_output: Option<serde_json::Value>,
1345    #[serde(skip_serializing_if = "Option::is_none")]
1346    pub ground_truth: Option<serde_json::Value>,
1347    #[serde(skip_serializing_if = "Option::is_none")]
1348    pub name: Option<String>,
1349}
1350
1351#[derive(Serialize, Deserialize, Clone, Debug, Default)]
1352pub struct PromoteCaseEdits {
1353    #[serde(skip_serializing_if = "Option::is_none")]
1354    pub inputs: Option<serde_json::Value>,
1355    #[serde(skip_serializing_if = "Option::is_none")]
1356    pub expected_output: Option<serde_json::Value>,
1357    #[serde(skip_serializing_if = "Option::is_none")]
1358    pub ground_truth: Option<serde_json::Value>,
1359}
1360
1361#[derive(Serialize, Deserialize, Clone, Debug, Default)]
1362pub struct PromoteExecutionRequest {
1363    #[serde(default, skip_serializing_if = "Option::is_none")]
1364    pub edits: Option<PromoteCaseEdits>,
1365    #[serde(default, skip_serializing_if = "Option::is_none")]
1366    pub name: Option<String>,
1367}
1368
1369#[derive(Serialize, Deserialize, Clone, Debug, Default)]
1370pub struct TriggerBenchRunRequest {
1371    pub channel: String,
1372    #[serde(skip_serializing_if = "Option::is_none")]
1373    pub notes: Option<String>,
1374    /// Optional subset of case IDs. `None` or empty array → run every case.
1375    #[serde(default, skip_serializing_if = "Option::is_none")]
1376    pub case_ids: Option<Vec<String>>,
1377}
1378
1379/// A typed event from the live SSE bench-run stream
1380/// (`GET /bench-runs/{id}/events`, `Accept: text/event-stream`).
1381///
1382/// The server emits three named SSE event types on this path
1383/// (`crates/akribes-server/src/handlers/bench.rs::bench_run_events`):
1384///  - `result` — a freshly-recorded per-case [`BenchResult`] row.
1385///  - `lagged` — the broadcast subscriber fell behind and dropped `n`
1386///    results (`{"dropped":N}`); re-fetch `/bench-runs/{id}/results` for
1387///    the authoritative set.
1388///  - `terminal` — the run reached a terminal status (`{"status":"..."}`);
1389///    the stream ends after this frame. `status` is one of the
1390///    `bench_runs.status` values (`completed`, `failed`, `canceled`,
1391///    or `unknown` if the row vanished).
1392///
1393/// Mirrors the TS SDK's `subscribeRunEvents` handler dispatch
1394/// (`packages/akribes-sdk-ts/src/sub/bench.ts`), with `terminal` lifted
1395/// into a typed variant so Rust consumers can detect end-of-stream
1396/// without a side-channel.
1397#[derive(Clone, Debug)]
1398pub enum BenchRunEvent {
1399    /// A new per-case result row was recorded.
1400    Result(Box<BenchResult>),
1401    /// The broadcast subscriber lagged and dropped `dropped` results.
1402    Lagged { dropped: u64 },
1403    /// The run reached a terminal status; the stream ends after this.
1404    Terminal { status: String },
1405}