Skip to main content

salvor_server/
client_runs.rs

1//! The client-driven run surface: open or resume a run, read its log, and the
2//! generic guarded append for control and deterministic-context events.
3//!
4//! # Who owns the loop
5//!
6//! The server-driven endpoints in [`crate::runs`] own the loop: the server
7//! drives a run in a background task and the client submits data and reads
8//! events. This surface inverts that. The client (a browser folding the run's
9//! log in a wasm `ReplayCursor`, or an SDK) owns the loop and streams the
10//! events it produces; the server owns the durable log and, on every append,
11//! re-folds the log with the pure `salvor-replay` append-guard to confirm the
12//! incoming event is the one legal next event. The trust boundary is narrow and
13//! honest: the guard proves the run history is well formed (shape, correlation,
14//! ordering, terminal rules), which is all a log validator can prove.
15//!
16//! # Scope
17//!
18//! This surface carries only the control and deterministic-context events the
19//! client's cursor emits itself and that hold no secret and no side effect:
20//! `RunStarted`, `NowObserved`, `RandomObserved`, `Suspended`, `Resumed`,
21//! `BudgetExceeded`, `RunCompleted`, `RunFailed`. The side-effecting steps (the
22//! model call and the tool call, which the server must perform because it holds
23//! the key or the binary) are not supported here, so a model or tool event is
24//! refused with a clear error.
25//!
26//! # The single-writer lease
27//!
28//! Opening a run mints a per-run `drive_token`, required on every append. It is
29//! the per-run gate that layers on top of the process-wide bearer: one
30//! authenticated caller still cannot drive another caller's run, and a second
31//! live driver without the current lease is refused. Re-opening a run mints a
32//! fresh lease, so a resuming tab always holds the current one.
33//!
34//! # Labels on a client-driven run
35//!
36//! The client, not this server, synthesizes the run's `RunStarted` (see [`open`]):
37//! there is no server-side "creation" step here the way [`crate::runs::start`]'s
38//! `StartRequest` has one. So the correlation `labels` a caller wants land in the
39//! `RunStarted` payload the client builds and appends, and the one place this
40//! server ever inspects them is [`append`], the moment that event is accepted:
41//! the sanity bounds (see `salvor_runtime::validate_labels`) are checked there,
42//! against whatever `labels` the submitted event carries, before it is written.
43//!
44//! # `recorded_at` is stamped here, never trusted from the wire
45//!
46//! Every [`EventEnvelope`] carries a `recorded_at`. On the server-performed
47//! steps ([`model_step`], [`tool_step`], and their completions) it was always
48//! [`AppState::now`], because this server built those envelopes itself. This
49//! generic append is the one surface where the envelope arrives already built,
50//! by the client, and it is the one place `recorded_at` used to be taken on
51//! faith: a browser's clock is not this store's clock, and a run with an
52//! honest server-performed step next to a client-appended `RunStarted` stamped
53//! at the Unix epoch is a store that no longer tells the truth about when
54//! things happened. So [`append`] overwrites every incoming envelope's
55//! `recorded_at` with [`AppState::now`] before it is folded or written;
56//! whatever the client sent in that field is discarded. The event kind, its
57//! payload, and its `seq` are still exactly what the client submitted (those
58//! remain the client's fact, since the client is the one driving the run); only
59//! the "when was this durably recorded" stamp is the server's, uniformly,
60//! everywhere an envelope is written.
61
62use std::convert::Infallible;
63
64use axum::Json;
65use axum::body::Bytes;
66use axum::extract::{Path, Query, State};
67use axum::http::header::ACCEPT;
68use axum::http::{HeaderMap, StatusCode};
69use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
70use axum::response::{IntoResponse, Response};
71use salvor_core::{Effect, Event, EventEnvelope, LogValidator, RunId, SequenceNumber, TokenUsage};
72use salvor_llm::{ContentDelta, MessageAccumulator, StreamEvent};
73use salvor_runtime::{RuntimeError, hash_value, response_value, usage_of, validate_labels};
74use salvor_tools::{ToolCtx, ToolOutcome};
75use serde::Deserialize;
76use serde_json::{Value, json};
77use time::format_description::well_known::Rfc3339;
78use tokio::sync::mpsc;
79use tokio_stream::wrappers::ReceiverStream;
80use uuid::Uuid;
81
82use crate::error::ApiError;
83use crate::executor::{ModelExecutor, ModelStream};
84use crate::state::{AppState, ClientRunLease};
85use std::sync::Arc;
86
87/// The header carrying the per-run drive token on a guarded append.
88const DRIVE_TOKEN_HEADER: &str = "x-drive-token";
89
90/// The largest event-append body this surface accepts, before parsing.
91const MAX_EVENTS_BODY: usize = 8 * 1024 * 1024;
92
93/// The most envelopes one append batch may carry, so a single request cannot
94/// grow a log without bound.
95const MAX_EVENTS_PER_BATCH: usize = 1024;
96
97/// The body of `POST /v1/client-runs`.
98#[derive(Debug, Deserialize)]
99struct OpenRequest {
100    /// The agent this run drives under (`agent_def_hash`). Informational:
101    /// the client records it inside the `RunStarted` event it appends.
102    #[serde(default)]
103    agent: Option<String>,
104    /// The run input. Informational, for the same reason as `agent`.
105    #[serde(default)]
106    input: Value,
107    /// An optional caller-chosen run id (a UUID). Minted when omitted.
108    #[serde(default)]
109    run_id: Option<String>,
110    /// Whether to record model request bodies on the intent (per-run, off by
111    /// default). Governs the server-performed model step, not yet implemented
112    /// on this surface.
113    #[serde(default)]
114    record_prompts: bool,
115}
116
117/// The body of `POST /v1/client-runs/{id}/events`.
118#[derive(Debug, Deserialize)]
119struct AppendRequest {
120    /// The envelopes to append, in order. Each is the pinned event-envelope
121    /// wire JSON already used by the event stream and `salvor history --json`.
122    events: Vec<EventEnvelope>,
123}
124
125/// The `?from_seq=` query on the log read.
126#[derive(Debug, Default, Deserialize)]
127pub struct LogQuery {
128    /// Return only envelopes at or after this sequence number.
129    #[serde(default)]
130    from_seq: Option<u64>,
131}
132
133/// The body of `POST /v1/client-runs/{id}/model-step`.
134#[derive(Debug, Deserialize)]
135struct ModelStepRequest {
136    /// The log position the client's cursor reserved for the model intent.
137    seq: u64,
138    /// The client's canonical model request value (a `MessageRequest` as JSON).
139    /// The server hashes and forwards exactly these bytes.
140    request: Value,
141}
142
143/// The `?stream=` query on the model step.
144#[derive(Debug, Default, Deserialize)]
145pub struct ModelStepQuery {
146    /// When `1` or `true`, stream provider events for a live ticker. The
147    /// `Accept: text/event-stream` header selects streaming too.
148    #[serde(default)]
149    stream: Option<String>,
150}
151
152/// The body of `POST /v1/client-runs/{id}/tool-step`.
153#[derive(Debug, Deserialize)]
154struct ToolStepRequest {
155    /// The log position the client's cursor reserved for the tool intent.
156    seq: u64,
157    /// The registered tool's name. Unknown to the registry is an error, and no
158    /// intent is written.
159    tool: String,
160    /// The typed input passed to the tool, recorded on the intent verbatim.
161    input: Value,
162    /// The idempotency key for this attempt, when the tool has one. The client
163    /// draws it from a recorded `RandomObserved` so it reproduces on replay.
164    #[serde(default)]
165    idempotency_key: Option<String>,
166    /// A client-declared effect, accepted for shape parity but deliberately
167    /// ignored: the recorded effect is the registry's operator-declared
168    /// one, so a caller cannot up- or down-grade it.
169    #[serde(default)]
170    #[allow(dead_code)]
171    effect: Option<Effect>,
172}
173
174/// The body of `POST /v1/client-runs/{id}/resolve`.
175#[derive(Debug, Deserialize)]
176struct ResolveRequest {
177    /// The output to record for the dangling write, verbatim, exactly as the
178    /// server-driven resolve takes it.
179    output: Value,
180}
181
182/// `POST /v1/client-runs`: open a fresh client-driven run, or re-open (resume)
183/// one this process already holds.
184///
185/// A fresh run comes back with an empty log and a new drive token; the client
186/// appends its own `RunStarted` as the first event through the append endpoint.
187/// Re-opening a known client run returns its full recorded log and a fresh
188/// lease, for a refreshed tab to rebuild its cursor. A chosen id that already
189/// has history but is not a client-driven run this process opened is refused,
190/// so the client-driven and server-driven modes cannot collide.
191pub async fn open(
192    State(state): State<AppState>,
193    body: Bytes,
194) -> Result<impl IntoResponse, ApiError> {
195    let request: OpenRequest = parse_body(&body)?;
196    // `agent` and `input` are accepted but not enforced against the appended
197    // RunStarted; they matter once the server performs model calls.
198    let _ = (&request.agent, &request.input);
199
200    let run_id = match &request.run_id {
201        Some(text) => parse_run_id(text)?,
202        None => RunId::new(),
203    };
204
205    // A re-open of a run this process opened: return its log and a fresh lease.
206    if state.is_client_run(run_id) {
207        let log = state.store().read_log(run_id).await.map_err(store_error)?;
208        let drive_token = state.lease_client_run(run_id, request.record_prompts);
209        return Ok((StatusCode::OK, Json(open_body(run_id, &drive_token, &log))));
210    }
211
212    // A run id with existing history that this process did not open as a
213    // client-driven run is foreign (a server-driven run, or one from before a
214    // restart): refuse it rather than adopt it.
215    let log = state.store().read_log(run_id).await.map_err(store_error)?;
216    if !log.is_empty() {
217        return Err(ApiError::RunExists(format!(
218            "run {} already has recorded history and is not a client-driven run on this server; \
219             it cannot be opened for client-driven runs",
220            run_id.as_uuid()
221        )));
222    }
223
224    let drive_token = state.lease_client_run(run_id, request.record_prompts);
225    Ok((
226        StatusCode::CREATED,
227        Json(open_body(run_id, &drive_token, &[])),
228    ))
229}
230
231/// `GET /v1/client-runs/{id}/log`: the recorded envelopes, for cursor rebuild.
232///
233/// `?from_seq=<n>` returns only envelopes at or after `n`, so a resuming client
234/// that already holds a prefix fetches just the tail. The read needs no drive
235/// token (a second viewer may read), but it serves only client-driven runs this
236/// process opened, keeping the two modes' surfaces apart.
237pub async fn get_log(
238    State(state): State<AppState>,
239    Path(run_id_text): Path<String>,
240    Query(query): Query<LogQuery>,
241) -> Result<impl IntoResponse, ApiError> {
242    let run_id = parse_run_id(&run_id_text)?;
243    if !state.is_client_run(run_id) {
244        return Err(unknown_client_run(run_id));
245    }
246    let mut log = state.store().read_log(run_id).await.map_err(store_error)?;
247    if let Some(from) = query.from_seq {
248        log.retain(|env| env.seq.get() >= from);
249    }
250    Ok(Json(json!({ "log": log })))
251}
252
253/// `POST /v1/client-runs/{id}/events`: the generic guarded append.
254///
255/// Each envelope is re-folded through the `salvor-replay` append-guard against
256/// the run's current log. A byte-identical re-append at an existing position is
257/// a `200` no-op (a safe retry after a network blip); different bytes there, or
258/// an illegal next event, is a `409`. Model and tool events are refused: they
259/// belong to the server-performed model-step and tool-step endpoints, not to
260/// this generic append. The whole batch is validated
261/// before anything is written, so a batch that turns illegal appends nothing.
262///
263/// Every envelope's `recorded_at` is overwritten with [`AppState::now`] before
264/// it is folded or written (see the module docs): `recorded_at` is the store's
265/// fact, not the client's claim, so whatever a submitted envelope carries in
266/// that field is never trusted or stored.
267pub async fn append(
268    State(state): State<AppState>,
269    Path(run_id_text): Path<String>,
270    headers: HeaderMap,
271    body: Bytes,
272) -> Result<impl IntoResponse, ApiError> {
273    let run_id = parse_run_id(&run_id_text)?;
274
275    // The per-run lease gate.
276    authorize_drive(&state, run_id, &headers)?;
277
278    // Body-size discipline, as a fast precheck before parsing.
279    if body.len() > MAX_EVENTS_BODY {
280        return Err(ApiError::PayloadTooLarge(format!(
281            "append body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
282            body.len()
283        )));
284    }
285    let request: AppendRequest = parse_body(&body)?;
286    if request.events.len() > MAX_EVENTS_PER_BATCH {
287        return Err(ApiError::PayloadTooLarge(format!(
288            "append batch carries {} events, over the {MAX_EVENTS_PER_BATCH} cap",
289            request.events.len()
290        )));
291    }
292
293    let stored = state.store().read_log(run_id).await.map_err(store_error)?;
294    let mut validator = LogValidator::new(stored);
295    let mut appended: Vec<u64> = Vec::with_capacity(request.events.len());
296    let mut to_append: Vec<EventEnvelope> = Vec::new();
297
298    for mut candidate in request.events {
299        if candidate.run_id != run_id {
300            return Err(ApiError::Divergence(format!(
301                "event names run {} but the path is run {}",
302                candidate.run_id.as_uuid(),
303                run_id.as_uuid()
304            )));
305        }
306        reject_side_effecting_kind(&candidate)?;
307        // The client synthesizes its own `RunStarted` (see the module docs);
308        // this append is the one place the server ever sees it, so it is
309        // where the sanity bounds on any carried `labels` are enforced. A
310        // byte-identical retry at an already-recorded position (handled just
311        // below) was validated the first time it landed, so re-checking here
312        // is cheap and harmless, never a behavior change.
313        if let Event::RunStarted {
314            labels: Some(labels),
315            ..
316        } = &candidate.event
317        {
318            validate_labels(labels).map_err(ApiError::BadRequest)?;
319        }
320
321        let next_seq = validator.next_seq();
322        if candidate.seq < next_seq {
323            // An already-recorded position: idempotent retry or divergence.
324            // `recorded_at` is the store's fact, not the client's claim (see
325            // the module docs), so a retry's legality never turns on whatever
326            // timestamp this attempt happened to carry: canonicalize it to
327            // the already-recorded stamp before comparing the rest byte for
328            // byte.
329            let index = candidate.seq.get() as usize;
330            let recorded = &validator.log()[index];
331            candidate.recorded_at = recorded.recorded_at;
332            if *recorded == candidate {
333                appended.push(candidate.seq.get());
334                continue;
335            }
336            return Err(ApiError::Divergence(format!(
337                "different bytes submitted at the already-recorded seq {}",
338                candidate.seq.get()
339            )));
340        }
341
342        // A new position: the server stamps its own clock reading, the same
343        // source every server-performed step uses, and ignores whatever
344        // `recorded_at` the client submitted. `recorded_at` is the store's
345        // fact, not the client's claim.
346        candidate.recorded_at = state.now();
347
348        // The append-guard decides legality.
349        validator
350            .push(candidate.clone())
351            .map_err(|error| ApiError::Divergence(error.to_string()))?;
352        appended.push(candidate.seq.get());
353        to_append.push(candidate);
354    }
355
356    // The batch validated end to end; commit the genuinely new events.
357    for envelope in &to_append {
358        state.store().append(envelope).await.map_err(append_error)?;
359    }
360
361    Ok((StatusCode::OK, Json(json!({ "appended": appended }))))
362}
363
364/// `POST /v1/client-runs/{id}/model-step`: the server-performed model call.
365///
366/// The client's cursor reserved `seq` as the model intent's position and hands
367/// the server the request to perform. The server recomputes `request_hash` from
368/// the body with the same canonical hash the runtime uses (so the client cannot
369/// lie about the hash), appends `ModelCallRequested` write-ahead, performs the
370/// call through the injected [`ModelExecutor`], appends `ModelCallCompleted`,
371/// and returns the completion. It mirrors `RunCtx::model_call` server-side.
372///
373/// Retry identity is `(seq, request_hash)`, mirroring `ReplayCursor::model_call`:
374///
375/// - A completed step already recorded at `seq` with the same hash returns the
376///   recorded completion; the provider is not called and the log does not grow.
377/// - A dangling intent at `seq` with the same hash (the tab died mid-call) is
378///   re-executed: an unanswered model request has no external effect to double,
379///   so the fresh completion correlates to the recorded intent.
380/// - A different hash at `seq`, or a non-model event there, is `409 divergence`.
381///
382/// With `Accept: text/event-stream` (or `?stream=1`) the provider's events
383/// stream as server-sent frames for a live ticker, and the assembled completion
384/// is recorded once at the end (byte-identical to the non-streaming path), so a
385/// tab that drops mid-stream leaves a dangling intent, re-issued safely.
386pub async fn model_step(
387    State(state): State<AppState>,
388    Path(run_id_text): Path<String>,
389    Query(query): Query<ModelStepQuery>,
390    headers: HeaderMap,
391    body: Bytes,
392) -> Result<Response, ApiError> {
393    let run_id = parse_run_id(&run_id_text)?;
394    let lease = authorize_drive(&state, run_id, &headers)?;
395
396    if body.len() > MAX_EVENTS_BODY {
397        return Err(ApiError::PayloadTooLarge(format!(
398            "model-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
399            body.len()
400        )));
401    }
402    let ModelStepRequest { seq, request } = parse_body(&body)?;
403
404    // Recompute the hash from the submitted body with the runtime's own
405    // canonical hash: the hash the server records is the hash it will send.
406    let request_hash = hash_value(&request);
407    let log = state.store().read_log(run_id).await.map_err(store_error)?;
408    let plan = plan_model_step(&log, seq, &request_hash)?;
409    let streaming = wants_stream(&headers, &query);
410
411    match plan {
412        ModelStepPlan::Replay { response, usage } => {
413            // Already recorded: answer from the log, call nothing, grow nothing.
414            if streaming {
415                Ok(single_complete_stream(&response, usage))
416            } else {
417                Ok(completion_body(&response, usage).into_response())
418            }
419        }
420        ModelStepPlan::Perform { append_intent } => {
421            let executor = state.model_executor().ok_or_else(|| {
422                ApiError::ModelExecutorUnavailable(
423                    "this server has no model executor wired, so it cannot perform a model step"
424                        .to_owned(),
425                )
426            })?;
427
428            // Write-ahead: record the intent before the provider is contacted,
429            // so a crash mid-call leaves a dangling intent (re-issued on retry).
430            // A dangling-intent retry skips this: the intent is already recorded.
431            if append_intent {
432                let request_body = lease.record_prompts.then(|| request.clone());
433                let intent = EventEnvelope::new(
434                    run_id,
435                    SequenceNumber::new(seq),
436                    state.now(),
437                    Event::ModelCallRequested {
438                        seq: SequenceNumber::new(seq),
439                        request_hash: request_hash.clone(),
440                        request_body,
441                    },
442                );
443                let mut validator = LogValidator::new(log);
444                validator
445                    .push(intent.clone())
446                    .map_err(|error| ApiError::Divergence(error.to_string()))?;
447                state.store().append(&intent).await.map_err(append_error)?;
448            }
449
450            if streaming {
451                perform_streaming(state, run_id, seq, request, executor).await
452            } else {
453                perform_unary(&state, run_id, seq, request, executor.as_ref()).await
454            }
455        }
456    }
457}
458
459/// What a model step must do, decided from the recorded log alone.
460enum ModelStepPlan {
461    /// The step is already recorded: return this completion, execute nothing.
462    Replay {
463        /// The recorded response value.
464        response: Value,
465        /// The recorded token usage.
466        usage: TokenUsage,
467    },
468    /// The step must be performed. `append_intent` is true for a fresh call and
469    /// false for a dangling-intent re-issue (the intent is already recorded).
470    Perform {
471        /// Whether to write the intent before executing.
472        append_intent: bool,
473    },
474}
475
476/// Decides the model step from the log and the recomputed hash, mirroring
477/// `ReplayCursor::model_call`'s replay/re-issue/divergence branches.
478fn plan_model_step(
479    log: &[EventEnvelope],
480    seq: u64,
481    request_hash: &str,
482) -> Result<ModelStepPlan, ApiError> {
483    let next = log.len() as u64;
484    if seq == next {
485        // A fresh intent at the next contiguous position.
486        return Ok(ModelStepPlan::Perform {
487            append_intent: true,
488        });
489    }
490    if seq > next {
491        return Err(ApiError::Divergence(format!(
492            "model-step seq {seq} is beyond the log end {next}"
493        )));
494    }
495
496    // The position is already recorded: it must be the model intent, its hash
497    // must match, and its completion (if any) decides replay versus re-issue.
498    let recorded = &log[seq as usize];
499    let Event::ModelCallRequested {
500        request_hash: recorded_hash,
501        ..
502    } = &recorded.event
503    else {
504        return Err(ApiError::Divergence(format!(
505            "seq {seq} already holds a non-model event; it is not a model-step position"
506        )));
507    };
508    if recorded_hash != request_hash {
509        return Err(ApiError::Divergence(format!(
510            "model-step at seq {seq} carries a request hash that differs from the recorded intent"
511        )));
512    }
513    match log.get(seq as usize + 1) {
514        Some(next_env) => match &next_env.event {
515            Event::ModelCallCompleted {
516                seq: corr,
517                response,
518                usage,
519            } if corr.get() == seq => Ok(ModelStepPlan::Replay {
520                response: response.clone(),
521                usage: *usage,
522            }),
523            _ => Err(ApiError::Divergence(format!(
524                "the event after the intent at seq {seq} is not its completion"
525            ))),
526        },
527        // A dangling intent (the last event): re-issue the call.
528        None => Ok(ModelStepPlan::Perform {
529            append_intent: false,
530        }),
531    }
532}
533
534/// Performs a non-streaming model call: execute, record the completion, and
535/// return `{ response, usage }`.
536async fn perform_unary(
537    state: &AppState,
538    run_id: RunId,
539    seq: u64,
540    request: Value,
541    executor: &dyn ModelExecutor,
542) -> Result<Response, ApiError> {
543    let response = executor
544        .execute(request)
545        .await
546        .map_err(ApiError::ModelExecution)?;
547    let usage = usage_of(&response);
548    let response_value = response_value(&response);
549    append_completion(state, run_id, seq, &response_value, usage).await?;
550    Ok(completion_body(&response_value, usage).into_response())
551}
552
553/// Performs a streaming model call: open the provider stream, then hand a
554/// server-sent-events body a background task drives (ticker frames, then the
555/// recorded completion). Opening the stream synchronously means a failure to
556/// open is a proper error envelope, not a half-open stream.
557async fn perform_streaming(
558    state: AppState,
559    run_id: RunId,
560    seq: u64,
561    request: Value,
562    executor: Arc<dyn ModelExecutor>,
563) -> Result<Response, ApiError> {
564    let stream = executor
565        .open_stream(request)
566        .await
567        .map_err(ApiError::ModelExecution)?;
568    let (tx, rx) = mpsc::channel::<Result<SseEvent, Infallible>>(64);
569    tokio::spawn(drive_model_stream(state, run_id, seq, stream, tx));
570    Ok(Sse::new(ReceiverStream::new(rx))
571        .keep_alive(KeepAlive::default())
572        .into_response())
573}
574
575/// Pumps the provider stream: forward each event as a ticker frame and fold it
576/// into a [`MessageAccumulator`], then record the assembled completion once and
577/// send the final `complete` frame. A mid-stream error, an accumulation
578/// failure, or a completion-append failure sends an `error` frame and records
579/// nothing, so the write-ahead intent is left dangling and the run stays
580/// drivable.
581async fn drive_model_stream(
582    state: AppState,
583    run_id: RunId,
584    seq: u64,
585    mut stream: Box<dyn ModelStream>,
586    tx: mpsc::Sender<Result<SseEvent, Infallible>>,
587) {
588    let mut accumulator = MessageAccumulator::new();
589    loop {
590        match stream.next_event().await {
591            Some(Ok(event)) => {
592                if let Err(error) = accumulator.apply(&event) {
593                    let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
594                    return;
595                }
596                if let Some(frame) = ticker_frame(&event)
597                    && tx
598                        .send(Ok(SseEvent::default()
599                            .event("delta")
600                            .data(frame.to_string())))
601                        .await
602                        .is_err()
603                {
604                    // The client hung up; stop, leaving the intent dangling.
605                    return;
606                }
607            }
608            Some(Err(message)) => {
609                let _ = tx.send(Ok(error_frame(&message))).await;
610                return;
611            }
612            None => break,
613        }
614    }
615
616    let response = match accumulator.into_message() {
617        Ok(response) => response,
618        Err(error) => {
619            let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
620            return;
621        }
622    };
623    let usage = usage_of(&response);
624    let response_value = response_value(&response);
625    if append_completion(&state, run_id, seq, &response_value, usage)
626        .await
627        .is_err()
628    {
629        let _ = tx
630            .send(Ok(error_frame("recording the model completion failed")))
631            .await;
632        return;
633    }
634    let complete = completion_json(&response_value, usage);
635    let _ = tx
636        .send(Ok(SseEvent::default()
637            .event("complete")
638            .data(complete.to_string())))
639        .await;
640}
641
642/// Records the `ModelCallCompleted` at `seq + 1`, correlated to the intent at
643/// `seq`, after validating it is the legal next event.
644async fn append_completion(
645    state: &AppState,
646    run_id: RunId,
647    seq: u64,
648    response: &Value,
649    usage: TokenUsage,
650) -> Result<(), ApiError> {
651    let completion = EventEnvelope::new(
652        run_id,
653        SequenceNumber::new(seq + 1),
654        state.now(),
655        Event::ModelCallCompleted {
656            seq: SequenceNumber::new(seq),
657            response: response.clone(),
658            usage,
659        },
660    );
661    let log = state.store().read_log(run_id).await.map_err(store_error)?;
662    let mut validator = LogValidator::new(log);
663    validator
664        .push(completion.clone())
665        .map_err(|error| ApiError::Divergence(error.to_string()))?;
666    state
667        .store()
668        .append(&completion)
669        .await
670        .map_err(append_error)
671}
672
673/// Whether the request selects the streaming variant: `?stream=1`/`true`, or an
674/// `Accept: text/event-stream` header.
675fn wants_stream(headers: &HeaderMap, query: &ModelStepQuery) -> bool {
676    if let Some(flag) = &query.stream
677        && (flag == "1" || flag == "true")
678    {
679        return true;
680    }
681    headers
682        .get(ACCEPT)
683        .and_then(|value| value.to_str().ok())
684        .is_some_and(|accept| accept.contains("text/event-stream"))
685}
686
687/// The ticker frame for a provider event, or `None` for events with nothing a
688/// live ticker shows (start/stop/ping). Text and thinking deltas and the final
689/// usage are what a token/cost ticker consumes.
690fn ticker_frame(event: &StreamEvent) -> Option<Value> {
691    match event {
692        StreamEvent::ContentBlockDelta { index, delta } => match delta {
693            ContentDelta::Text { text } => {
694                Some(json!({ "type": "text_delta", "index": index, "text": text }))
695            }
696            ContentDelta::Thinking { thinking } => {
697                Some(json!({ "type": "thinking_delta", "index": index, "thinking": thinking }))
698            }
699            _ => None,
700        },
701        StreamEvent::MessageDelta { usage, .. } => {
702            Some(json!({ "type": "usage", "output_tokens": usage.output_tokens }))
703        }
704        _ => None,
705    }
706}
707
708/// A one-frame server-sent-events body carrying an already-recorded completion,
709/// for a streaming request that resolves to a replay (no live tokens).
710fn single_complete_stream(response: &Value, usage: TokenUsage) -> Response {
711    let frame = SseEvent::default()
712        .event("complete")
713        .data(completion_json(response, usage).to_string());
714    Sse::new(tokio_stream::once(Ok::<_, Infallible>(frame)))
715        .keep_alive(KeepAlive::default())
716        .into_response()
717}
718
719/// The `{ response, usage }` JSON both the non-streaming body and the `complete`
720/// frame carry.
721fn completion_json(response: &Value, usage: TokenUsage) -> Value {
722    json!({ "response": response, "usage": usage })
723}
724
725/// The non-streaming `200` body.
726fn completion_body(response: &Value, usage: TokenUsage) -> Json<Value> {
727    Json(completion_json(response, usage))
728}
729
730/// An `error` server-sent-events frame carrying a human message.
731fn error_frame(message: &str) -> SseEvent {
732    SseEvent::default()
733        .event("error")
734        .data(json!({ "message": message }).to_string())
735}
736
737/// The `201`/`200` open response body.
738fn open_body(run_id: RunId, drive_token: &str, log: &[EventEnvelope]) -> Value {
739    json!({
740        "run": run_id.as_uuid().to_string(),
741        "drive_token": drive_token,
742        "log": log,
743    })
744}
745
746/// `POST /v1/client-runs/{id}/tool-step`: the server-performed tool call.
747///
748/// The client's cursor reserved `seq` as the tool intent's position. The server
749/// looks the tool up in its injected [`ToolRegistry`](crate::ToolRegistry),
750/// takes the operator-declared [`Effect`] from that registration (never from
751/// the client, so a caller cannot up- or down-grade it), appends
752/// `ToolCallRequested` write-ahead, dispatches the tool, appends
753/// `ToolCallCompleted`, and returns the output. It mirrors `RunCtx::tool_call`
754/// server-side, and its retry and reconciliation branches mirror
755/// `ReplayCursor::tool_call`:
756///
757/// - A completed step recorded at `seq` with the same (tool, input, effect,
758///   key) returns the recorded output; the tool is not dispatched and the log
759///   does not grow.
760/// - A dangling `Read`/`Idempotent` intent at `seq` (the tab died mid-call) is
761///   re-executed under the RECORDED idempotency key, so an idempotent retry
762///   reuses the exact key the provider collapses duplicates on.
763/// - A dangling `Write` intent is `409 needs_reconciliation` carrying the
764///   recorded intent as evidence, and nothing is dispatched: the write may have
765///   landed, and only [`resolve`] may record its completion.
766/// - A different (tool, input, effect, key) at `seq`, or a non-tool event
767///   there, is `409 divergence`.
768///
769/// An unknown tool (or no registry at all) writes nothing, mirroring the model
770/// step's no-executor rule: the step is retriable once the tool is registered.
771pub async fn tool_step(
772    State(state): State<AppState>,
773    Path(run_id_text): Path<String>,
774    headers: HeaderMap,
775    body: Bytes,
776) -> Result<Json<Value>, ApiError> {
777    let run_id = parse_run_id(&run_id_text)?;
778    authorize_drive(&state, run_id, &headers)?;
779
780    if body.len() > MAX_EVENTS_BODY {
781        return Err(ApiError::PayloadTooLarge(format!(
782            "tool-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
783            body.len()
784        )));
785    }
786    let request: ToolStepRequest = parse_body(&body)?;
787
788    // Look the tool up before anything is written. No registry is a 503; a
789    // registry without the named tool is a 404. Either way, nothing is written.
790    let registry = state.tool_registry().ok_or_else(|| {
791        ApiError::ToolRegistryUnavailable(
792            "this server has no tool registry wired, so it cannot perform a tool step".to_owned(),
793        )
794    })?;
795    let tool = registry.get(&request.tool).ok_or_else(|| {
796        ApiError::UnknownTool(format!(
797            "no tool named `{}` is registered on this server",
798            request.tool
799        ))
800    })?;
801
802    // The effect is the registry's operator declaration, never the client's.
803    // The client-declared `effect` field on the body is dropped here.
804    let effect = tool.effect();
805    let ToolStepRequest {
806        seq,
807        tool: tool_name,
808        input,
809        idempotency_key,
810        effect: _,
811    } = request;
812
813    let log = state.store().read_log(run_id).await.map_err(store_error)?;
814    let plan = plan_tool_step(
815        &log,
816        seq,
817        &tool_name,
818        &input,
819        effect,
820        idempotency_key.as_deref(),
821    )?;
822
823    match plan {
824        ToolStepPlan::Replay { output } => Ok(tool_output_body(&output)),
825        ToolStepPlan::Reconcile { intent } => Err(ApiError::NeedsReconciliation {
826            message: format!(
827                "run {} needs reconciliation: a write was recorded but never completed, so it \
828                 may or may not have taken effect. Verify externally, then resolve it",
829                run_id.as_uuid()
830            ),
831            intent,
832        }),
833        ToolStepPlan::Perform {
834            append_intent,
835            exec_key,
836        } => {
837            // Write-ahead: record the intent before the tool runs, so a crash
838            // mid-call leaves a dangling intent (re-issued or reconciled on
839            // retry, per effect). A dangling re-issue skips this: the intent is
840            // already recorded.
841            if append_intent {
842                let intent = EventEnvelope::new(
843                    run_id,
844                    SequenceNumber::new(seq),
845                    state.now(),
846                    Event::ToolCallRequested {
847                        seq: SequenceNumber::new(seq),
848                        tool: tool_name.clone(),
849                        input: input.clone(),
850                        effect,
851                        idempotency_key: exec_key.clone(),
852                    },
853                );
854                let mut validator = LogValidator::new(log);
855                validator
856                    .push(intent.clone())
857                    .map_err(|error| ApiError::Divergence(error.to_string()))?;
858                state.store().append(&intent).await.map_err(append_error)?;
859            }
860
861            // Dispatch through the same erased contract the runtime uses, with
862            // the idempotency key on the context so an idempotent retry reuses
863            // it. A dispatch failure is an error envelope with no completion, so
864            // the intent is left dangling (legal, the crash story).
865            let ctx = ToolCtx::new(exec_key);
866            let outcome = tool
867                .call_json(&ctx, input)
868                .await
869                .map_err(|error| ApiError::ToolExecution(error.to_string()))?;
870            let output = match outcome {
871                ToolOutcome::Output(value) => value,
872                ToolOutcome::Suspend(_) => {
873                    return Err(ApiError::ToolExecution(format!(
874                        "tool `{tool_name}` suspended, which a server-performed tool step does \
875                         not support; no completion recorded"
876                    )));
877                }
878            };
879            append_tool_completion(&state, run_id, seq, &output).await?;
880            Ok(tool_output_body(&output))
881        }
882    }
883}
884
885/// What a tool step must do, decided from the recorded log and the registry's
886/// effect alone.
887enum ToolStepPlan {
888    /// The step is already recorded: return this output, dispatch nothing.
889    Replay {
890        /// The recorded tool output.
891        output: Value,
892    },
893    /// A dangling write: surface reconciliation with this intent evidence,
894    /// dispatch nothing.
895    Reconcile {
896        /// The recorded write intent, for the error body.
897        intent: Value,
898    },
899    /// The step must be performed. `append_intent` is true for a fresh call and
900    /// false for a dangling re-issue (the intent is already recorded); `exec_key`
901    /// is the idempotency key to dispatch under (the recorded key on a re-issue).
902    Perform {
903        /// Whether to write the intent before dispatching.
904        append_intent: bool,
905        /// The idempotency key handed to the tool for this attempt.
906        exec_key: Option<String>,
907    },
908}
909
910/// Decides the tool step from the log and the registry's effect, mirroring
911/// `ReplayCursor::tool_call`'s replay, re-issue, reconciliation, and divergence
912/// branches. The effect is the registry's, so a client cannot change it.
913fn plan_tool_step(
914    log: &[EventEnvelope],
915    seq: u64,
916    tool: &str,
917    input: &Value,
918    effect: Effect,
919    idempotency_key: Option<&str>,
920) -> Result<ToolStepPlan, ApiError> {
921    let next = log.len() as u64;
922    if seq == next {
923        // A fresh intent at the next contiguous position.
924        return Ok(ToolStepPlan::Perform {
925            append_intent: true,
926            exec_key: idempotency_key.map(ToOwned::to_owned),
927        });
928    }
929    if seq > next {
930        return Err(ApiError::Divergence(format!(
931            "tool-step seq {seq} is beyond the log end {next}"
932        )));
933    }
934
935    // The position is already recorded: it must be the tool intent, and its
936    // (tool, input, effect, key) must all match, exactly as the cursor checks.
937    let recorded = &log[seq as usize];
938    let Event::ToolCallRequested {
939        tool: recorded_tool,
940        input: recorded_input,
941        effect: recorded_effect,
942        idempotency_key: recorded_key,
943        ..
944    } = &recorded.event
945    else {
946        return Err(ApiError::Divergence(format!(
947            "seq {seq} already holds a non-tool event; it is not a tool-step position"
948        )));
949    };
950    if recorded_tool != tool
951        || recorded_input != input
952        || *recorded_effect != effect
953        || recorded_key.as_deref() != idempotency_key
954    {
955        return Err(ApiError::Divergence(format!(
956            "tool-step at seq {seq} diverges from the recorded intent (tool, input, effect, or key)"
957        )));
958    }
959    match log.get(seq as usize + 1) {
960        Some(next_env) => match &next_env.event {
961            Event::ToolCallCompleted { seq: corr, output } if corr.get() == seq => {
962                Ok(ToolStepPlan::Replay {
963                    output: output.clone(),
964                })
965            }
966            _ => Err(ApiError::Divergence(format!(
967                "the event after the intent at seq {seq} is not its completion"
968            ))),
969        },
970        // A dangling intent (the last event): the effect decides. Write never
971        // re-executes; Read/Idempotent re-execute under the RECORDED key.
972        None => match effect {
973            Effect::Write => Ok(ToolStepPlan::Reconcile {
974                intent: intent_evidence(recorded),
975            }),
976            Effect::Read | Effect::Idempotent => Ok(ToolStepPlan::Perform {
977                append_intent: false,
978                exec_key: recorded_key.clone(),
979            }),
980        },
981    }
982}
983
984/// The reconciliation evidence carried in a `needs_reconciliation` error body:
985/// the recorded write intent plus when it was recorded, mirroring the
986/// server-driven resolve's `reconcile_intent` and `json::pending` shapes.
987fn intent_evidence(envelope: &EventEnvelope) -> Value {
988    let Event::ToolCallRequested {
989        seq,
990        tool,
991        input,
992        effect,
993        idempotency_key,
994    } = &envelope.event
995    else {
996        return Value::Null;
997    };
998    json!({
999        "kind": "tool",
1000        "seq": seq.get(),
1001        "tool": tool,
1002        "input": input,
1003        "effect": effect,
1004        "idempotency_key": idempotency_key,
1005        "recorded_at": envelope.recorded_at.format(&Rfc3339).unwrap_or_default(),
1006    })
1007}
1008
1009/// Records the `ToolCallCompleted` at `seq + 1`, correlated to the intent at
1010/// `seq`, after validating it is the legal next event.
1011async fn append_tool_completion(
1012    state: &AppState,
1013    run_id: RunId,
1014    seq: u64,
1015    output: &Value,
1016) -> Result<(), ApiError> {
1017    let completion = EventEnvelope::new(
1018        run_id,
1019        SequenceNumber::new(seq + 1),
1020        state.now(),
1021        Event::ToolCallCompleted {
1022            seq: SequenceNumber::new(seq),
1023            output: output.clone(),
1024        },
1025    );
1026    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1027    let mut validator = LogValidator::new(log);
1028    validator
1029        .push(completion.clone())
1030        .map_err(|error| ApiError::Divergence(error.to_string()))?;
1031    state
1032        .store()
1033        .append(&completion)
1034        .await
1035        .map_err(append_error)
1036}
1037
1038/// The `200` tool-step body, `{ "output": <json> }`.
1039fn tool_output_body(output: &Value) -> Json<Value> {
1040    Json(json!({ "output": output }))
1041}
1042
1043/// `POST /v1/client-runs/{id}/resolve`: record a dangling write's completion by
1044/// hand for a client-driven run, the drive-token-gated twin of the
1045/// server-driven `POST /v1/runs/{id}/resolve`.
1046///
1047/// State-validated exactly like the server-driven resolve: it is legal only
1048/// when the run's log ends at a dangling `Write` intent, it correlates the
1049/// caller-supplied output to that intent, and it dispatches nothing. It reuses
1050/// the same `Runtime::resolve` the server-driven endpoint does, so the two
1051/// share one reconciliation contract. After it records the completion the run
1052/// is drivable again, so the client re-fetches the log and its cursor sails
1053/// past the once-dangling intent.
1054pub async fn resolve(
1055    State(state): State<AppState>,
1056    Path(run_id_text): Path<String>,
1057    headers: HeaderMap,
1058    body: Bytes,
1059) -> Result<Json<Value>, ApiError> {
1060    let run_id = parse_run_id(&run_id_text)?;
1061    authorize_drive(&state, run_id, &headers)?;
1062    let request: ResolveRequest = parse_body(&body)?;
1063
1064    match state.runtime().resolve(run_id, request.output).await {
1065        Ok(_) => Ok(Json(json!({
1066            "run": run_id.as_uuid().to_string(),
1067            "resolved": true,
1068        }))),
1069        Err(RuntimeError::NotReconcilable { status, .. }) => Err(ApiError::WrongState(format!(
1070            "run {} does not need reconciliation (status: {status}); there is no dangling write \
1071             to resolve",
1072            run_id.as_uuid()
1073        ))),
1074        Err(error) => Err(ApiError::Internal(error.to_string())),
1075    }
1076}
1077
1078/// Refuses a model or tool event on the generic append: those are recorded
1079/// through the server-performed model-step and tool-step endpoints,
1080/// never hand-appended here.
1081fn reject_side_effecting_kind(candidate: &EventEnvelope) -> Result<(), ApiError> {
1082    use salvor_core::Event;
1083    let kind = match &candidate.event {
1084        Event::ModelCallRequested { .. } => "ModelCallRequested",
1085        Event::ModelCallCompleted { .. } => "ModelCallCompleted",
1086        Event::ToolCallRequested { .. } => "ToolCallRequested",
1087        Event::ToolCallCompleted { .. } => "ToolCallCompleted",
1088        _ => return Ok(()),
1089    };
1090    Err(ApiError::UnsupportedEventKind(format!(
1091        "the generic append accepts control and context events only; `{kind}` is recorded through \
1092         the model-step or tool-step endpoint"
1093    )))
1094}
1095
1096/// The per-run lease gate shared by every driving endpoint: the run must be a
1097/// client-driven run this server opened, and the request must carry its current
1098/// drive token in the `X-Drive-Token` header. Returns the lease so the caller
1099/// can read `record_prompts`.
1100fn authorize_drive(
1101    state: &AppState,
1102    run_id: RunId,
1103    headers: &HeaderMap,
1104) -> Result<ClientRunLease, ApiError> {
1105    let lease = state
1106        .client_run(run_id)
1107        .ok_or_else(|| unknown_client_run(run_id))?;
1108    let presented = headers
1109        .get(DRIVE_TOKEN_HEADER)
1110        .and_then(|value| value.to_str().ok());
1111    match presented {
1112        None => Err(ApiError::MissingDriveToken(format!(
1113            "run {} requires a drive token in the `{DRIVE_TOKEN_HEADER}` header",
1114            run_id.as_uuid()
1115        ))),
1116        Some(token) if token != lease.drive_token => Err(ApiError::InvalidDriveToken(format!(
1117            "the presented drive token is not the current lease for run {}",
1118            run_id.as_uuid()
1119        ))),
1120        Some(_) => {
1121            // The driver presented its current token: it is alive. Refresh the
1122            // lease's `last_seen` so the liveness evidence on GET /v1/runs reads
1123            // "attached". This is the whole heartbeat — it rides on the real
1124            // guarded operation, never a separate ping.
1125            state.touch_client_run(run_id);
1126            Ok(lease)
1127        }
1128    }
1129}
1130
1131/// Parses a JSON body into `T`, mapping a decode failure to a `400`.
1132fn parse_body<T: for<'de> Deserialize<'de>>(body: &Bytes) -> Result<T, ApiError> {
1133    serde_json::from_slice(body)
1134        .map_err(|error| ApiError::BadRequest(format!("request body is not valid JSON: {error}")))
1135}
1136
1137/// Parses a run id from its UUID string, mapping a bad id to a `400`.
1138fn parse_run_id(text: &str) -> Result<RunId, ApiError> {
1139    Uuid::parse_str(text).map(RunId::from_uuid).map_err(|_| {
1140        ApiError::BadRequest(format!("`{text}` is not a valid run id (expected a UUID)"))
1141    })
1142}
1143
1144/// The not-found error for a run that is not a client-driven run here.
1145fn unknown_client_run(run_id: RunId) -> ApiError {
1146    ApiError::UnknownRun(format!(
1147        "no client-driven run {} on this server; open it first",
1148        run_id.as_uuid()
1149    ))
1150}
1151
1152/// Maps a store read error to a `500`.
1153fn store_error(error: salvor_store::StoreError) -> ApiError {
1154    ApiError::Internal(format!("store: {error}"))
1155}
1156
1157/// Maps a store append error: a position taken out from under a validated batch
1158/// (a lost lease race) is a `409` divergence, anything else a `500`.
1159fn append_error(error: salvor_store::StoreError) -> ApiError {
1160    match error {
1161        salvor_store::StoreError::Conflict { seq, .. } => ApiError::Divergence(format!(
1162            "seq {} was taken by another writer before the append landed",
1163            SequenceNumber::get(seq)
1164        )),
1165        other => ApiError::Internal(format!("store: {other}")),
1166    }
1167}