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::{
72    Effect, Event, EventEnvelope, LogValidator, Performer, RunId, SequenceNumber, TokenUsage,
73};
74use salvor_llm::{ContentDelta, MessageAccumulator, StreamEvent};
75use salvor_runtime::{
76    RuntimeError, hash_value, response_value, usage_of, validate_against_schema, validate_labels,
77};
78use salvor_tools::{ToolCtx, ToolOutcome};
79use serde::Deserialize;
80use serde_json::{Value, json};
81use time::format_description::well_known::Rfc3339;
82use tokio::sync::mpsc;
83use tokio_stream::wrappers::ReceiverStream;
84use uuid::Uuid;
85
86use crate::error::ApiError;
87use crate::executor::{ModelExecutor, ModelStream};
88use crate::state::{AppState, ClientRunLease};
89use std::sync::Arc;
90
91/// The header carrying the per-run drive token on a guarded append.
92const DRIVE_TOKEN_HEADER: &str = "x-drive-token";
93
94/// The largest event-append body this surface accepts, before parsing.
95const MAX_EVENTS_BODY: usize = 8 * 1024 * 1024;
96
97/// The most envelopes one append batch may carry, so a single request cannot
98/// grow a log without bound.
99const MAX_EVENTS_PER_BATCH: usize = 1024;
100
101/// The body of `POST /v1/client-runs`.
102#[derive(Debug, Deserialize)]
103struct OpenRequest {
104    /// The agent this run drives under (`agent_def_hash`). Informational:
105    /// the client records it inside the `RunStarted` event it appends.
106    #[serde(default)]
107    agent: Option<String>,
108    /// The run input. Informational, for the same reason as `agent`.
109    #[serde(default)]
110    input: Value,
111    /// An optional caller-chosen run id (a UUID). Minted when omitted.
112    #[serde(default)]
113    run_id: Option<String>,
114    /// Whether to record model request bodies on the intent (per-run, off by
115    /// default). Governs the server-performed model step, not yet implemented
116    /// on this surface.
117    #[serde(default)]
118    record_prompts: bool,
119}
120
121/// The body of `POST /v1/client-runs/{id}/events`.
122#[derive(Debug, Deserialize)]
123struct AppendRequest {
124    /// The envelopes to append, in order. Each is the pinned event-envelope
125    /// wire JSON already used by the event stream and `salvor history --json`.
126    events: Vec<EventEnvelope>,
127}
128
129/// The `?from_seq=` query on the log read.
130#[derive(Debug, Default, Deserialize)]
131pub struct LogQuery {
132    /// Return only envelopes at or after this sequence number.
133    #[serde(default)]
134    from_seq: Option<u64>,
135}
136
137/// The body of `POST /v1/client-runs/{id}/model-step`.
138#[derive(Debug, Deserialize)]
139struct ModelStepRequest {
140    /// The log position the client's cursor reserved for the model intent.
141    seq: u64,
142    /// The client's canonical model request value (a `MessageRequest` as JSON).
143    /// The server hashes and forwards exactly these bytes.
144    request: Value,
145}
146
147/// The `?stream=` query on the model step.
148#[derive(Debug, Default, Deserialize)]
149pub struct ModelStepQuery {
150    /// When `1` or `true`, stream provider events for a live ticker. The
151    /// `Accept: text/event-stream` header selects streaming too.
152    #[serde(default)]
153    stream: Option<String>,
154}
155
156/// The body of `POST /v1/client-runs/{id}/tool-step`.
157#[derive(Debug, Deserialize)]
158struct ToolStepRequest {
159    /// The log position the client's cursor reserved for the tool intent.
160    seq: u64,
161    /// The registered tool's name. Unknown to the registry is an error, and no
162    /// intent is written.
163    tool: String,
164    /// The typed input passed to the tool, recorded on the intent verbatim.
165    input: Value,
166    /// The idempotency key for this attempt, when the tool has one. The client
167    /// draws it from a recorded `RandomObserved` so it reproduces on replay.
168    #[serde(default)]
169    idempotency_key: Option<String>,
170    /// A client-declared effect, accepted for shape parity but deliberately
171    /// ignored: the recorded effect is the registry's operator-declared
172    /// one, so a caller cannot up- or down-grade it.
173    #[serde(default)]
174    #[allow(dead_code)]
175    effect: Option<Effect>,
176}
177
178/// The body of `POST /v1/client-runs/{id}/client-tool-intent`.
179///
180/// Notice what is NOT here, next to [`ToolStepRequest`]: no `effect` and no
181/// `idempotency_key`. Both come from the operator's declaration or from the
182/// server's own derivation, so there is no field for a caller to fill in.
183#[derive(Debug, Deserialize)]
184struct ClientToolIntentRequest {
185    /// The log position the client's cursor reserved for the tool intent.
186    seq: u64,
187    /// The declared client-performed tool's name. Undeclared is an error, and
188    /// no intent is written.
189    tool: String,
190    /// The input the client is about to perform the call with, checked against
191    /// the declared `input_schema` and then recorded on the intent verbatim.
192    input: Value,
193}
194
195/// The body of `POST /v1/client-runs/{id}/client-tool-completion`.
196#[derive(Debug, Deserialize)]
197struct ClientToolCompletionRequest {
198    /// The intent's position, which must be the pending intent at the log's end.
199    seq: u64,
200    /// What the client reports the call returned, checked against the declared
201    /// `output_schema` before it is recorded.
202    output: Value,
203}
204
205/// The body of `POST /v1/client-runs/{id}/resolve`.
206#[derive(Debug, Deserialize)]
207struct ResolveRequest {
208    /// The output to record for the dangling write, verbatim, exactly as the
209    /// server-driven resolve takes it.
210    output: Value,
211}
212
213/// `POST /v1/client-runs`: open a fresh client-driven run, or re-open (resume)
214/// one this process already holds.
215///
216/// A fresh run comes back with an empty log and a new drive token; the client
217/// appends its own `RunStarted` as the first event through the append endpoint.
218/// Re-opening a known client run returns its full recorded log and a fresh
219/// lease, for a refreshed tab to rebuild its cursor. A chosen id that already
220/// has history but is not a client-driven run this process opened is refused,
221/// so the client-driven and server-driven modes cannot collide.
222pub async fn open(
223    State(state): State<AppState>,
224    body: Bytes,
225) -> Result<impl IntoResponse, ApiError> {
226    let request: OpenRequest = parse_body(&body)?;
227    // `agent` and `input` are accepted but not enforced against the appended
228    // RunStarted; they matter once the server performs model calls.
229    let _ = (&request.agent, &request.input);
230
231    let run_id = match &request.run_id {
232        Some(text) => parse_run_id(text)?,
233        None => RunId::new(),
234    };
235
236    // A re-open of a run this process opened: return its log and a fresh lease.
237    if state.is_client_run(run_id) {
238        let log = state.store().read_log(run_id).await.map_err(store_error)?;
239        let drive_token = state.lease_client_run(run_id, request.record_prompts);
240        return Ok((StatusCode::OK, Json(open_body(run_id, &drive_token, &log))));
241    }
242
243    // A run id with existing history that this process did not open as a
244    // client-driven run is foreign (a server-driven run, or one from before a
245    // restart): refuse it rather than adopt it.
246    let log = state.store().read_log(run_id).await.map_err(store_error)?;
247    if !log.is_empty() {
248        return Err(ApiError::RunExists(format!(
249            "run {} already has recorded history and is not a client-driven run on this server; \
250             it cannot be opened for client-driven runs",
251            run_id.as_uuid()
252        )));
253    }
254
255    let drive_token = state.lease_client_run(run_id, request.record_prompts);
256    Ok((
257        StatusCode::CREATED,
258        Json(open_body(run_id, &drive_token, &[])),
259    ))
260}
261
262/// `GET /v1/client-runs/{id}/log`: the recorded envelopes, for cursor rebuild.
263///
264/// `?from_seq=<n>` returns only envelopes at or after `n`, so a resuming client
265/// that already holds a prefix fetches just the tail. The read needs no drive
266/// token (a second viewer may read), but it serves only client-driven runs this
267/// process opened, keeping the two modes' surfaces apart.
268pub async fn get_log(
269    State(state): State<AppState>,
270    Path(run_id_text): Path<String>,
271    Query(query): Query<LogQuery>,
272) -> Result<impl IntoResponse, ApiError> {
273    let run_id = parse_run_id(&run_id_text)?;
274    if !state.is_client_run(run_id) {
275        return Err(unknown_client_run(run_id));
276    }
277    let mut log = state.store().read_log(run_id).await.map_err(store_error)?;
278    if let Some(from) = query.from_seq {
279        log.retain(|env| env.seq.get() >= from);
280    }
281    Ok(Json(json!({ "log": log })))
282}
283
284/// `POST /v1/client-runs/{id}/events`: the generic guarded append.
285///
286/// Each envelope is re-folded through the `salvor-replay` append-guard against
287/// the run's current log. A byte-identical re-append at an existing position is
288/// a `200` no-op (a safe retry after a network blip); different bytes there, or
289/// an illegal next event, is a `409`. Model and tool events are refused: they
290/// belong to the server-performed model-step and tool-step endpoints, not to
291/// this generic append. The whole batch is validated
292/// before anything is written, so a batch that turns illegal appends nothing.
293///
294/// Every envelope's `recorded_at` is overwritten with [`AppState::now`] before
295/// it is folded or written (see the module docs): `recorded_at` is the store's
296/// fact, not the client's claim, so whatever a submitted envelope carries in
297/// that field is never trusted or stored.
298pub async fn append(
299    State(state): State<AppState>,
300    Path(run_id_text): Path<String>,
301    headers: HeaderMap,
302    body: Bytes,
303) -> Result<impl IntoResponse, ApiError> {
304    let run_id = parse_run_id(&run_id_text)?;
305
306    // The per-run lease gate.
307    authorize_drive(&state, run_id, &headers)?;
308
309    // Body-size discipline, as a fast precheck before parsing.
310    if body.len() > MAX_EVENTS_BODY {
311        return Err(ApiError::PayloadTooLarge(format!(
312            "append body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
313            body.len()
314        )));
315    }
316    let request: AppendRequest = parse_body(&body)?;
317    if request.events.len() > MAX_EVENTS_PER_BATCH {
318        return Err(ApiError::PayloadTooLarge(format!(
319            "append batch carries {} events, over the {MAX_EVENTS_PER_BATCH} cap",
320            request.events.len()
321        )));
322    }
323
324    let stored = state.store().read_log(run_id).await.map_err(store_error)?;
325    let mut validator = LogValidator::new(stored);
326    let mut appended: Vec<u64> = Vec::with_capacity(request.events.len());
327    let mut to_append: Vec<EventEnvelope> = Vec::new();
328
329    for mut candidate in request.events {
330        if candidate.run_id != run_id {
331            return Err(ApiError::Divergence(format!(
332                "event names run {} but the path is run {}",
333                candidate.run_id.as_uuid(),
334                run_id.as_uuid()
335            )));
336        }
337        reject_side_effecting_kind(&candidate)?;
338        // The client synthesizes its own `RunStarted` (see the module docs);
339        // this append is the one place the server ever sees it, so it is
340        // where the sanity bounds on any carried `labels` are enforced. A
341        // byte-identical retry at an already-recorded position (handled just
342        // below) was validated the first time it landed, so re-checking here
343        // is cheap and harmless, never a behavior change.
344        if let Event::RunStarted {
345            labels: Some(labels),
346            ..
347        } = &candidate.event
348        {
349            validate_labels(labels).map_err(ApiError::BadRequest)?;
350        }
351
352        let next_seq = validator.next_seq();
353        if candidate.seq < next_seq {
354            // An already-recorded position: idempotent retry or divergence.
355            // `recorded_at` is the store's fact, not the client's claim (see
356            // the module docs), so a retry's legality never turns on whatever
357            // timestamp this attempt happened to carry: canonicalize it to
358            // the already-recorded stamp before comparing the rest byte for
359            // byte.
360            let index = candidate.seq.get() as usize;
361            let recorded = &validator.log()[index];
362            candidate.recorded_at = recorded.recorded_at;
363            if *recorded == candidate {
364                appended.push(candidate.seq.get());
365                continue;
366            }
367            return Err(ApiError::Divergence(format!(
368                "different bytes submitted at the already-recorded seq {}",
369                candidate.seq.get()
370            )));
371        }
372
373        // A new position: the server stamps its own clock reading, the same
374        // source every server-performed step uses, and ignores whatever
375        // `recorded_at` the client submitted. `recorded_at` is the store's
376        // fact, not the client's claim.
377        candidate.recorded_at = state.now();
378
379        // The append-guard decides legality.
380        validator
381            .push(candidate.clone())
382            .map_err(|error| ApiError::Divergence(error.to_string()))?;
383        appended.push(candidate.seq.get());
384        to_append.push(candidate);
385    }
386
387    // The batch validated end to end; commit the genuinely new events.
388    for envelope in &to_append {
389        state.store().append(envelope).await.map_err(append_error)?;
390    }
391
392    Ok((StatusCode::OK, Json(json!({ "appended": appended }))))
393}
394
395/// `POST /v1/client-runs/{id}/model-step`: the server-performed model call.
396///
397/// The client's cursor reserved `seq` as the model intent's position and hands
398/// the server the request to perform. The server recomputes `request_hash` from
399/// the body with the same canonical hash the runtime uses (so the client cannot
400/// lie about the hash), appends `ModelCallRequested` write-ahead, performs the
401/// call through the injected [`ModelExecutor`], appends `ModelCallCompleted`,
402/// and returns the completion. It mirrors `RunCtx::model_call` server-side.
403///
404/// Retry identity is `(seq, request_hash)`, mirroring `ReplayCursor::model_call`:
405///
406/// - A completed step already recorded at `seq` with the same hash returns the
407///   recorded completion; the provider is not called and the log does not grow.
408/// - A dangling intent at `seq` with the same hash (the tab died mid-call) is
409///   re-executed: an unanswered model request has no external effect to double,
410///   so the fresh completion correlates to the recorded intent.
411/// - A different hash at `seq`, or a non-model event there, is `409 divergence`.
412///
413/// With `Accept: text/event-stream` (or `?stream=1`) the provider's events
414/// stream as server-sent frames for a live ticker, and the assembled completion
415/// is recorded once at the end (byte-identical to the non-streaming path), so a
416/// tab that drops mid-stream leaves a dangling intent, re-issued safely.
417pub async fn model_step(
418    State(state): State<AppState>,
419    Path(run_id_text): Path<String>,
420    Query(query): Query<ModelStepQuery>,
421    headers: HeaderMap,
422    body: Bytes,
423) -> Result<Response, ApiError> {
424    let run_id = parse_run_id(&run_id_text)?;
425    let lease = authorize_drive(&state, run_id, &headers)?;
426
427    if body.len() > MAX_EVENTS_BODY {
428        return Err(ApiError::PayloadTooLarge(format!(
429            "model-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
430            body.len()
431        )));
432    }
433    let ModelStepRequest { seq, request } = parse_body(&body)?;
434
435    // Recompute the hash from the submitted body with the runtime's own
436    // canonical hash: the hash the server records is the hash it will send.
437    let request_hash = hash_value(&request);
438    let log = state.store().read_log(run_id).await.map_err(store_error)?;
439    let plan = plan_model_step(&log, seq, &request_hash)?;
440    let streaming = wants_stream(&headers, &query);
441
442    match plan {
443        ModelStepPlan::Replay { response, usage } => {
444            // Already recorded: answer from the log, call nothing, grow nothing.
445            if streaming {
446                Ok(single_complete_stream(&response, usage))
447            } else {
448                Ok(completion_body(&response, usage).into_response())
449            }
450        }
451        ModelStepPlan::Perform { append_intent } => {
452            let executor = state.model_executor().ok_or_else(|| {
453                ApiError::ModelExecutorUnavailable(
454                    "this server has no model executor wired, so it cannot perform a model step"
455                        .to_owned(),
456                )
457            })?;
458
459            // Write-ahead: record the intent before the provider is contacted,
460            // so a crash mid-call leaves a dangling intent (re-issued on retry).
461            // A dangling-intent retry skips this: the intent is already recorded.
462            if append_intent {
463                let request_body = lease.record_prompts.then(|| request.clone());
464                let intent = EventEnvelope::new(
465                    run_id,
466                    SequenceNumber::new(seq),
467                    state.now(),
468                    Event::ModelCallRequested {
469                        seq: SequenceNumber::new(seq),
470                        request_hash: request_hash.clone(),
471                        request_body,
472                    },
473                );
474                let mut validator = LogValidator::new(log);
475                validator
476                    .push(intent.clone())
477                    .map_err(|error| ApiError::Divergence(error.to_string()))?;
478                state.store().append(&intent).await.map_err(append_error)?;
479            }
480
481            if streaming {
482                perform_streaming(state, run_id, seq, request, executor).await
483            } else {
484                perform_unary(&state, run_id, seq, request, executor.as_ref()).await
485            }
486        }
487    }
488}
489
490/// What a model step must do, decided from the recorded log alone.
491enum ModelStepPlan {
492    /// The step is already recorded: return this completion, execute nothing.
493    Replay {
494        /// The recorded response value.
495        response: Value,
496        /// The recorded token usage.
497        usage: TokenUsage,
498    },
499    /// The step must be performed. `append_intent` is true for a fresh call and
500    /// false for a dangling-intent re-issue (the intent is already recorded).
501    Perform {
502        /// Whether to write the intent before executing.
503        append_intent: bool,
504    },
505}
506
507/// Decides the model step from the log and the recomputed hash, mirroring
508/// `ReplayCursor::model_call`'s replay/re-issue/divergence branches.
509fn plan_model_step(
510    log: &[EventEnvelope],
511    seq: u64,
512    request_hash: &str,
513) -> Result<ModelStepPlan, ApiError> {
514    let next = log.len() as u64;
515    if seq == next {
516        // A fresh intent at the next contiguous position.
517        return Ok(ModelStepPlan::Perform {
518            append_intent: true,
519        });
520    }
521    if seq > next {
522        return Err(ApiError::Divergence(format!(
523            "model-step seq {seq} is beyond the log end {next}"
524        )));
525    }
526
527    // The position is already recorded: it must be the model intent, its hash
528    // must match, and its completion (if any) decides replay versus re-issue.
529    let recorded = &log[seq as usize];
530    let Event::ModelCallRequested {
531        request_hash: recorded_hash,
532        ..
533    } = &recorded.event
534    else {
535        return Err(ApiError::Divergence(format!(
536            "seq {seq} already holds a non-model event; it is not a model-step position"
537        )));
538    };
539    if recorded_hash != request_hash {
540        return Err(ApiError::Divergence(format!(
541            "model-step at seq {seq} carries a request hash that differs from the recorded intent"
542        )));
543    }
544    match log.get(seq as usize + 1) {
545        Some(next_env) => match &next_env.event {
546            Event::ModelCallCompleted {
547                seq: corr,
548                response,
549                usage,
550            } if corr.get() == seq => Ok(ModelStepPlan::Replay {
551                response: response.clone(),
552                usage: *usage,
553            }),
554            _ => Err(ApiError::Divergence(format!(
555                "the event after the intent at seq {seq} is not its completion"
556            ))),
557        },
558        // A dangling intent (the last event): re-issue the call.
559        None => Ok(ModelStepPlan::Perform {
560            append_intent: false,
561        }),
562    }
563}
564
565/// Performs a non-streaming model call: execute, record the completion, and
566/// return `{ response, usage }`.
567async fn perform_unary(
568    state: &AppState,
569    run_id: RunId,
570    seq: u64,
571    request: Value,
572    executor: &dyn ModelExecutor,
573) -> Result<Response, ApiError> {
574    let response = executor
575        .execute(request)
576        .await
577        .map_err(ApiError::ModelExecution)?;
578    let usage = usage_of(&response);
579    let response_value = response_value(&response);
580    append_completion(state, run_id, seq, &response_value, usage).await?;
581    Ok(completion_body(&response_value, usage).into_response())
582}
583
584/// Performs a streaming model call: open the provider stream, then hand a
585/// server-sent-events body a background task drives (ticker frames, then the
586/// recorded completion). Opening the stream synchronously means a failure to
587/// open is a proper error envelope, not a half-open stream.
588async fn perform_streaming(
589    state: AppState,
590    run_id: RunId,
591    seq: u64,
592    request: Value,
593    executor: Arc<dyn ModelExecutor>,
594) -> Result<Response, ApiError> {
595    let stream = executor
596        .open_stream(request)
597        .await
598        .map_err(ApiError::ModelExecution)?;
599    let (tx, rx) = mpsc::channel::<Result<SseEvent, Infallible>>(64);
600    tokio::spawn(drive_model_stream(state, run_id, seq, stream, tx));
601    Ok(Sse::new(ReceiverStream::new(rx))
602        .keep_alive(KeepAlive::default())
603        .into_response())
604}
605
606/// Pumps the provider stream: forward each event as a ticker frame and fold it
607/// into a [`MessageAccumulator`], then record the assembled completion once and
608/// send the final `complete` frame. A mid-stream error, an accumulation
609/// failure, or a completion-append failure sends an `error` frame and records
610/// nothing, so the write-ahead intent is left dangling and the run stays
611/// drivable.
612async fn drive_model_stream(
613    state: AppState,
614    run_id: RunId,
615    seq: u64,
616    mut stream: Box<dyn ModelStream>,
617    tx: mpsc::Sender<Result<SseEvent, Infallible>>,
618) {
619    let mut accumulator = MessageAccumulator::new();
620    loop {
621        match stream.next_event().await {
622            Some(Ok(event)) => {
623                if let Err(error) = accumulator.apply(&event) {
624                    let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
625                    return;
626                }
627                if let Some(frame) = ticker_frame(&event)
628                    && tx
629                        .send(Ok(SseEvent::default()
630                            .event("delta")
631                            .data(frame.to_string())))
632                        .await
633                        .is_err()
634                {
635                    // The client hung up; stop, leaving the intent dangling.
636                    return;
637                }
638            }
639            Some(Err(message)) => {
640                let _ = tx.send(Ok(error_frame(&message))).await;
641                return;
642            }
643            None => break,
644        }
645    }
646
647    let response = match accumulator.into_message() {
648        Ok(response) => response,
649        Err(error) => {
650            let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
651            return;
652        }
653    };
654    let usage = usage_of(&response);
655    let response_value = response_value(&response);
656    if append_completion(&state, run_id, seq, &response_value, usage)
657        .await
658        .is_err()
659    {
660        let _ = tx
661            .send(Ok(error_frame("recording the model completion failed")))
662            .await;
663        return;
664    }
665    let complete = completion_json(&response_value, usage);
666    let _ = tx
667        .send(Ok(SseEvent::default()
668            .event("complete")
669            .data(complete.to_string())))
670        .await;
671}
672
673/// Records the `ModelCallCompleted` at `seq + 1`, correlated to the intent at
674/// `seq`, after validating it is the legal next event.
675async fn append_completion(
676    state: &AppState,
677    run_id: RunId,
678    seq: u64,
679    response: &Value,
680    usage: TokenUsage,
681) -> Result<(), ApiError> {
682    let completion = EventEnvelope::new(
683        run_id,
684        SequenceNumber::new(seq + 1),
685        state.now(),
686        Event::ModelCallCompleted {
687            seq: SequenceNumber::new(seq),
688            response: response.clone(),
689            usage,
690        },
691    );
692    let log = state.store().read_log(run_id).await.map_err(store_error)?;
693    let mut validator = LogValidator::new(log);
694    validator
695        .push(completion.clone())
696        .map_err(|error| ApiError::Divergence(error.to_string()))?;
697    state
698        .store()
699        .append(&completion)
700        .await
701        .map_err(append_error)
702}
703
704/// Whether the request selects the streaming variant: `?stream=1`/`true`, or an
705/// `Accept: text/event-stream` header.
706fn wants_stream(headers: &HeaderMap, query: &ModelStepQuery) -> bool {
707    if let Some(flag) = &query.stream
708        && (flag == "1" || flag == "true")
709    {
710        return true;
711    }
712    headers
713        .get(ACCEPT)
714        .and_then(|value| value.to_str().ok())
715        .is_some_and(|accept| accept.contains("text/event-stream"))
716}
717
718/// The ticker frame for a provider event, or `None` for events with nothing a
719/// live ticker shows (start/stop/ping). Text and thinking deltas and the final
720/// usage are what a token/cost ticker consumes.
721fn ticker_frame(event: &StreamEvent) -> Option<Value> {
722    match event {
723        StreamEvent::ContentBlockDelta { index, delta } => match delta {
724            ContentDelta::Text { text } => {
725                Some(json!({ "type": "text_delta", "index": index, "text": text }))
726            }
727            ContentDelta::Thinking { thinking } => {
728                Some(json!({ "type": "thinking_delta", "index": index, "thinking": thinking }))
729            }
730            _ => None,
731        },
732        StreamEvent::MessageDelta { usage, .. } => {
733            Some(json!({ "type": "usage", "output_tokens": usage.output_tokens }))
734        }
735        _ => None,
736    }
737}
738
739/// A one-frame server-sent-events body carrying an already-recorded completion,
740/// for a streaming request that resolves to a replay (no live tokens).
741fn single_complete_stream(response: &Value, usage: TokenUsage) -> Response {
742    let frame = SseEvent::default()
743        .event("complete")
744        .data(completion_json(response, usage).to_string());
745    Sse::new(tokio_stream::once(Ok::<_, Infallible>(frame)))
746        .keep_alive(KeepAlive::default())
747        .into_response()
748}
749
750/// The `{ response, usage }` JSON both the non-streaming body and the `complete`
751/// frame carry.
752fn completion_json(response: &Value, usage: TokenUsage) -> Value {
753    json!({ "response": response, "usage": usage })
754}
755
756/// The non-streaming `200` body.
757fn completion_body(response: &Value, usage: TokenUsage) -> Json<Value> {
758    Json(completion_json(response, usage))
759}
760
761/// An `error` server-sent-events frame carrying a human message.
762fn error_frame(message: &str) -> SseEvent {
763    SseEvent::default()
764        .event("error")
765        .data(json!({ "message": message }).to_string())
766}
767
768/// The `201`/`200` open response body.
769fn open_body(run_id: RunId, drive_token: &str, log: &[EventEnvelope]) -> Value {
770    json!({
771        "run": run_id.as_uuid().to_string(),
772        "drive_token": drive_token,
773        "log": log,
774    })
775}
776
777/// `POST /v1/client-runs/{id}/tool-step`: the server-performed tool call.
778///
779/// The client's cursor reserved `seq` as the tool intent's position. The server
780/// looks the tool up in its injected [`ToolRegistry`](crate::ToolRegistry),
781/// takes the operator-declared [`Effect`] from that registration (never from
782/// the client, so a caller cannot up- or down-grade it), appends
783/// `ToolCallRequested` write-ahead, dispatches the tool, appends
784/// `ToolCallCompleted`, and returns the output. It mirrors `RunCtx::tool_call`
785/// server-side, and its retry and reconciliation branches mirror
786/// `ReplayCursor::tool_call`:
787///
788/// - A completed step recorded at `seq` with the same (tool, input, effect,
789///   key) returns the recorded output; the tool is not dispatched and the log
790///   does not grow.
791/// - A dangling `Read`/`Idempotent` intent at `seq` (the tab died mid-call) is
792///   re-executed under the RECORDED idempotency key, so an idempotent retry
793///   reuses the exact key the provider collapses duplicates on.
794/// - A dangling `Write` intent is `409 needs_reconciliation` carrying the
795///   recorded intent as evidence, and nothing is dispatched: the write may have
796///   landed, and only [`resolve`] may record its completion.
797/// - A different (tool, input, effect, key) at `seq`, or a non-tool event
798///   there, is `409 divergence`.
799///
800/// An unknown tool (or no registry at all) writes nothing, mirroring the model
801/// step's no-executor rule: the step is retriable once the tool is registered.
802pub async fn tool_step(
803    State(state): State<AppState>,
804    Path(run_id_text): Path<String>,
805    headers: HeaderMap,
806    body: Bytes,
807) -> Result<Json<Value>, ApiError> {
808    let run_id = parse_run_id(&run_id_text)?;
809    authorize_drive(&state, run_id, &headers)?;
810
811    if body.len() > MAX_EVENTS_BODY {
812        return Err(ApiError::PayloadTooLarge(format!(
813            "tool-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
814            body.len()
815        )));
816    }
817    let request: ToolStepRequest = parse_body(&body)?;
818
819    // Look the tool up before anything is written. No registry is a 503; a
820    // registry without the named tool is a 404. Either way, nothing is written.
821    let registry = state.tool_registry().ok_or_else(|| {
822        ApiError::ToolRegistryUnavailable(
823            "this server has no tool registry wired, so it cannot perform a tool step".to_owned(),
824        )
825    })?;
826    let tool = registry.get(&request.tool).ok_or_else(|| {
827        ApiError::UnknownTool(format!(
828            "no tool named `{}` is registered on this server",
829            request.tool
830        ))
831    })?;
832
833    // The effect is the registry's operator declaration, never the client's.
834    // The client-declared `effect` field on the body is dropped here.
835    let effect = tool.effect();
836    let ToolStepRequest {
837        seq,
838        tool: tool_name,
839        input,
840        idempotency_key,
841        effect: _,
842    } = request;
843
844    let log = state.store().read_log(run_id).await.map_err(store_error)?;
845    let plan = plan_tool_step(
846        &log,
847        seq,
848        &tool_name,
849        &input,
850        effect,
851        idempotency_key.as_deref(),
852    )?;
853
854    match plan {
855        ToolStepPlan::Replay { output } => Ok(tool_output_body(&output)),
856        ToolStepPlan::Reconcile { intent } => Err(ApiError::NeedsReconciliation {
857            message: format!(
858                "run {} needs reconciliation: a write was recorded but never completed, so it \
859                 may or may not have taken effect. Verify externally, then resolve it",
860                run_id.as_uuid()
861            ),
862            intent,
863        }),
864        ToolStepPlan::Perform {
865            append_intent,
866            exec_key,
867        } => {
868            // Write-ahead: record the intent before the tool runs, so a crash
869            // mid-call leaves a dangling intent (re-issued or reconciled on
870            // retry, per effect). A dangling re-issue skips this: the intent is
871            // already recorded.
872            if append_intent {
873                let intent = EventEnvelope::new(
874                    run_id,
875                    SequenceNumber::new(seq),
876                    state.now(),
877                    Event::ToolCallRequested {
878                        seq: SequenceNumber::new(seq),
879                        tool: tool_name.clone(),
880                        input: input.clone(),
881                        effect,
882                        idempotency_key: exec_key.clone(),
883                        performed_by: None,
884                    },
885                );
886                let mut validator = LogValidator::new(log);
887                validator
888                    .push(intent.clone())
889                    .map_err(|error| ApiError::Divergence(error.to_string()))?;
890                state.store().append(&intent).await.map_err(append_error)?;
891            }
892
893            // Dispatch through the same erased contract the runtime uses, with
894            // the idempotency key on the context so an idempotent retry reuses
895            // it. A dispatch failure is an error envelope with no completion, so
896            // the intent is left dangling (legal, the crash story).
897            let ctx = ToolCtx::new(exec_key);
898            let outcome = tool
899                .call_json(&ctx, input)
900                .await
901                .map_err(|error| ApiError::ToolExecution(error.to_string()))?;
902            let output = match outcome {
903                ToolOutcome::Output(value) => value,
904                ToolOutcome::Suspend(_) => {
905                    return Err(ApiError::ToolExecution(format!(
906                        "tool `{tool_name}` suspended, which a server-performed tool step does \
907                         not support; no completion recorded"
908                    )));
909                }
910            };
911            append_tool_completion(&state, run_id, seq, &output).await?;
912            Ok(tool_output_body(&output))
913        }
914    }
915}
916
917/// What a tool step must do, decided from the recorded log and the registry's
918/// effect alone.
919enum ToolStepPlan {
920    /// The step is already recorded: return this output, dispatch nothing.
921    Replay {
922        /// The recorded tool output.
923        output: Value,
924    },
925    /// A dangling write: surface reconciliation with this intent evidence,
926    /// dispatch nothing.
927    Reconcile {
928        /// The recorded write intent, for the error body.
929        intent: Value,
930    },
931    /// The step must be performed. `append_intent` is true for a fresh call and
932    /// false for a dangling re-issue (the intent is already recorded); `exec_key`
933    /// is the idempotency key to dispatch under (the recorded key on a re-issue).
934    Perform {
935        /// Whether to write the intent before dispatching.
936        append_intent: bool,
937        /// The idempotency key handed to the tool for this attempt.
938        exec_key: Option<String>,
939    },
940}
941
942/// Decides the tool step from the log and the registry's effect, mirroring
943/// `ReplayCursor::tool_call`'s replay, re-issue, reconciliation, and divergence
944/// branches. The effect is the registry's, so a client cannot change it.
945fn plan_tool_step(
946    log: &[EventEnvelope],
947    seq: u64,
948    tool: &str,
949    input: &Value,
950    effect: Effect,
951    idempotency_key: Option<&str>,
952) -> Result<ToolStepPlan, ApiError> {
953    let next = log.len() as u64;
954    if seq == next {
955        // A fresh intent at the next contiguous position.
956        return Ok(ToolStepPlan::Perform {
957            append_intent: true,
958            exec_key: idempotency_key.map(ToOwned::to_owned),
959        });
960    }
961    if seq > next {
962        return Err(ApiError::Divergence(format!(
963            "tool-step seq {seq} is beyond the log end {next}"
964        )));
965    }
966
967    // The position is already recorded: it must be the tool intent, and its
968    // (tool, input, effect, key) must all match, exactly as the cursor checks.
969    let recorded = &log[seq as usize];
970    let Event::ToolCallRequested {
971        tool: recorded_tool,
972        input: recorded_input,
973        effect: recorded_effect,
974        idempotency_key: recorded_key,
975        ..
976    } = &recorded.event
977    else {
978        return Err(ApiError::Divergence(format!(
979            "seq {seq} already holds a non-tool event; it is not a tool-step position"
980        )));
981    };
982    if recorded_tool != tool
983        || recorded_input != input
984        || *recorded_effect != effect
985        || recorded_key.as_deref() != idempotency_key
986    {
987        return Err(ApiError::Divergence(format!(
988            "tool-step at seq {seq} diverges from the recorded intent (tool, input, effect, or key)"
989        )));
990    }
991    match log.get(seq as usize + 1) {
992        Some(next_env) => match &next_env.event {
993            Event::ToolCallCompleted {
994                seq: corr, output, ..
995            } if corr.get() == seq => Ok(ToolStepPlan::Replay {
996                output: output.clone(),
997            }),
998            _ => Err(ApiError::Divergence(format!(
999                "the event after the intent at seq {seq} is not its completion"
1000            ))),
1001        },
1002        // A dangling intent (the last event): the effect decides. Write never
1003        // re-executes; Read/Idempotent re-execute under the RECORDED key.
1004        None => match effect {
1005            Effect::Write => Ok(ToolStepPlan::Reconcile {
1006                intent: intent_evidence(recorded),
1007            }),
1008            Effect::Read | Effect::Idempotent => Ok(ToolStepPlan::Perform {
1009                append_intent: false,
1010                exec_key: recorded_key.clone(),
1011            }),
1012        },
1013    }
1014}
1015
1016/// The reconciliation evidence carried in a `needs_reconciliation` error body:
1017/// the recorded write intent plus when it was recorded, mirroring the
1018/// server-driven resolve's `reconcile_intent` and `json::pending` shapes.
1019fn intent_evidence(envelope: &EventEnvelope) -> Value {
1020    let Event::ToolCallRequested {
1021        seq,
1022        tool,
1023        input,
1024        effect,
1025        idempotency_key,
1026        ..
1027    } = &envelope.event
1028    else {
1029        return Value::Null;
1030    };
1031    json!({
1032        "kind": "tool",
1033        "seq": seq.get(),
1034        "tool": tool,
1035        "input": input,
1036        "effect": effect,
1037        "idempotency_key": idempotency_key,
1038        "recorded_at": envelope.recorded_at.format(&Rfc3339).unwrap_or_default(),
1039    })
1040}
1041
1042/// Records the `ToolCallCompleted` at `seq + 1`, correlated to the intent at
1043/// `seq`, after validating it is the legal next event.
1044async fn append_tool_completion(
1045    state: &AppState,
1046    run_id: RunId,
1047    seq: u64,
1048    output: &Value,
1049) -> Result<(), ApiError> {
1050    let completion = EventEnvelope::new(
1051        run_id,
1052        SequenceNumber::new(seq + 1),
1053        state.now(),
1054        Event::ToolCallCompleted {
1055            seq: SequenceNumber::new(seq),
1056            output: output.clone(),
1057            deduplicated_from: None,
1058        },
1059    );
1060    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1061    let mut validator = LogValidator::new(log);
1062    validator
1063        .push(completion.clone())
1064        .map_err(|error| ApiError::Divergence(error.to_string()))?;
1065    state
1066        .store()
1067        .append(&completion)
1068        .await
1069        .map_err(append_error)
1070}
1071
1072/// The `200` tool-step body, `{ "output": <json> }`.
1073fn tool_output_body(output: &Value) -> Json<Value> {
1074    Json(json!({ "output": output }))
1075}
1076
1077/// `POST /v1/client-runs/{id}/resolve`: record a dangling write's completion by
1078/// hand for a client-driven run, the drive-token-gated twin of the
1079/// server-driven `POST /v1/runs/{id}/resolve`.
1080///
1081/// State-validated exactly like the server-driven resolve: it is legal only
1082/// when the run's log ends at a dangling `Write` intent, it correlates the
1083/// caller-supplied output to that intent, and it dispatches nothing. It reuses
1084/// the same `Runtime::resolve` the server-driven endpoint does, so the two
1085/// share one reconciliation contract. After it records the completion the run
1086/// is drivable again, so the client re-fetches the log and its cursor sails
1087/// past the once-dangling intent.
1088pub async fn resolve(
1089    State(state): State<AppState>,
1090    Path(run_id_text): Path<String>,
1091    headers: HeaderMap,
1092    body: Bytes,
1093) -> Result<Json<Value>, ApiError> {
1094    let run_id = parse_run_id(&run_id_text)?;
1095    authorize_drive(&state, run_id, &headers)?;
1096    let request: ResolveRequest = parse_body(&body)?;
1097
1098    match state.runtime().resolve(run_id, request.output).await {
1099        Ok(_) => Ok(Json(json!({
1100            "run": run_id.as_uuid().to_string(),
1101            "resolved": true,
1102        }))),
1103        Err(RuntimeError::NotReconcilable { status, .. }) => Err(ApiError::WrongState(format!(
1104            "run {} does not need reconciliation (status: {status}); there is no dangling write \
1105             to resolve",
1106            run_id.as_uuid()
1107        ))),
1108        Err(error) => Err(ApiError::Internal(error.to_string())),
1109    }
1110}
1111
1112/// The idempotency key a CLIENT-performed tool call presents: derived by this
1113/// server from where the call sits in the run (run id, sequence, tool name),
1114/// never supplied by the caller.
1115///
1116/// This deliberately differs from the server-performed [`tool_step`], where the
1117/// client supplies `idempotency_key` on the request body and the server records
1118/// what it was given. The difference is not an oversight, and the older
1119/// endpoint should not be "fixed" to match.
1120///
1121/// There, salvor performs the call. The party choosing the key is not the party
1122/// making the write, and a key chosen badly costs the caller nothing but its own
1123/// retry failing to collapse. Here the client both chooses the key and performs
1124/// the write, in a process salvor never sees. That is the one case where the
1125/// party choosing the key is also the party who benefits from a duplicate
1126/// landing: a client that wants to be paid twice supplies a fresh key for the
1127/// second attempt and the provider, seeing two distinct calls, honors both,
1128/// while salvor's log shows two honest-looking intents. Deriving the key removes
1129/// the choice. The same (run, seq, tool) always derives the same key, so an
1130/// honest retry after a dropped response presents the identical key the first
1131/// attempt did and the provider collapses the pair, and a second attempt cannot
1132/// present a different one.
1133///
1134/// Shaped after `salvor_engine`'s `fork_safe_idempotency_key`: a canonical hash
1135/// of a small JSON object, using the same `hash_value` the rest of the workspace
1136/// hashes with, so the key is reproducible across processes and languages and a
1137/// client can derive it independently to check the server's work.
1138fn client_tool_idempotency_key(run_id: RunId, seq: u64, tool: &str) -> String {
1139    hash_value(&json!({
1140        "run": run_id.as_uuid().to_string(),
1141        "seq": seq,
1142        "tool": tool,
1143    }))
1144}
1145
1146/// `POST /v1/client-runs/{id}/client-tool-intent`: open a client-performed tool
1147/// call.
1148///
1149/// The counterpart of [`tool_step`] for a tool salvor holds no code for. The
1150/// client is about to run the call in its OWN process, with its own secrets;
1151/// this endpoint records that it is about to, so the intent is in the log before
1152/// the effect happens, exactly as the write-ahead rule demands of a call salvor
1153/// performs itself. Requires the `X-Drive-Token` header, like every other
1154/// driving endpoint.
1155///
1156/// What the server takes from the operator's declaration rather than the
1157/// request: the [`Effect`] (so a caller cannot up- or down-grade its own write
1158/// into a freely retried read), the input schema the input is checked against
1159/// before anything is written, and the idempotency key, which is DERIVED here
1160/// (see [`client_tool_idempotency_key`]). The client supplies only the position,
1161/// the name, and the input.
1162///
1163/// The intent goes through the same [`LogValidator`] guard every other append on
1164/// this surface uses, so ordering and correlation stay enforced: an intent at a
1165/// position the log is not ready for is a `409 divergence` and nothing is
1166/// written. A byte-identical re-post at an already-recorded position is a `200`
1167/// that re-derives the same key and writes nothing, the safe retry a dropped
1168/// response leaves behind.
1169///
1170/// The response carries the derived key and a `settled` flag: `true` when the
1171/// intent at this position already has its completion recorded, so a caller
1172/// re-posting an intent it believes it already opened can tell "safe to
1173/// perform" from "already done" without reading the log. The client performs
1174/// the work under the key and then posts [`client_tool_completion`].
1175pub async fn client_tool_intent(
1176    State(state): State<AppState>,
1177    Path(run_id_text): Path<String>,
1178    headers: HeaderMap,
1179    body: Bytes,
1180) -> Result<Json<Value>, ApiError> {
1181    let run_id = parse_run_id(&run_id_text)?;
1182    authorize_drive(&state, run_id, &headers)?;
1183
1184    if body.len() > MAX_EVENTS_BODY {
1185        return Err(ApiError::PayloadTooLarge(format!(
1186            "client-tool-intent body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
1187            body.len()
1188        )));
1189    }
1190    let request: ClientToolIntentRequest = parse_body(&body)?;
1191
1192    // The declaration is looked up before anything is written. Declarations are
1193    // loaded by the operator and never registered over HTTP (see
1194    // `crate::client_tools`), so an unknown name is a `404` the operator fixes,
1195    // not something a caller can create for itself.
1196    let decls = state.client_tools();
1197    let decl = decls.get(&request.tool).ok_or_else(|| {
1198        ApiError::UnknownTool(format!(
1199            "no client-performed tool named `{}` is declared on this server; declarations are \
1200             loaded by the operator (`salvor serve --client-tool <FILE>`) and are never \
1201             registered over HTTP",
1202            request.tool
1203        ))
1204    })?;
1205
1206    // The input is checked against the OPERATOR's schema before the intent is
1207    // recorded, so a malformed call never becomes history: on the failure path
1208    // this endpoint writes nothing at all and the run is untouched.
1209    validate_against_schema(&request.input, &decl.input_schema).map_err(|error| {
1210        ApiError::BadRequest(format!(
1211            "the input does not match the declared input_schema for `{}`: {error}",
1212            request.tool
1213        ))
1214    })?;
1215    let effect = decl.effect;
1216
1217    let key = client_tool_idempotency_key(run_id, request.seq, &request.tool);
1218    let intent = EventEnvelope::new(
1219        run_id,
1220        SequenceNumber::new(request.seq),
1221        state.now(),
1222        Event::ToolCallRequested {
1223            seq: SequenceNumber::new(request.seq),
1224            tool: request.tool.clone(),
1225            input: request.input.clone(),
1226            effect,
1227            idempotency_key: Some(key.clone()),
1228            // The whole point of the stage: the log says who performed this, so
1229            // a later reader can tell a call salvor witnessed from a call it was
1230            // told about.
1231            performed_by: Some(Performer::Client),
1232        },
1233    );
1234
1235    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1236    if (request.seq as usize) < log.len() {
1237        // An already-recorded position. The derivation is a pure function of
1238        // (run, seq, tool), so an identical re-post re-derives the recorded key
1239        // and can simply be handed it back: the client retries its own call
1240        // under the same key and the provider collapses the duplicate. Compare
1241        // the events rather than the envelopes, because `recorded_at` is this
1242        // store's stamp from the first attempt and would never match a fresh one.
1243        let recorded = &log[request.seq as usize];
1244        if recorded.event == intent.event {
1245            let settled = intent_is_settled(&log, request.seq);
1246            return Ok(Json(intent_body(request.seq, &key, effect, settled)));
1247        }
1248        return Err(ApiError::Divergence(format!(
1249            "seq {} already holds a different event; it is not this client-tool intent's position",
1250            request.seq
1251        )));
1252    }
1253
1254    // The same append-guard the generic append and both server-performed steps
1255    // push through: it decides whether this is the legal next event.
1256    let mut validator = LogValidator::new(log);
1257    validator
1258        .push(intent.clone())
1259        .map_err(|error| ApiError::Divergence(error.to_string()))?;
1260    state.store().append(&intent).await.map_err(append_error)?;
1261    // A freshly-recorded intent can never already be settled: the append above
1262    // just placed it at the log's new end, with nothing after it yet.
1263    Ok(Json(intent_body(request.seq, &key, effect, false)))
1264}
1265
1266/// Whether the tool intent at `seq` already has its `ToolCallCompleted`
1267/// recorded in `log`. The append-guard only ever admits a completion for the
1268/// same `seq` immediately after its intent (see [`append_tool_completion`]),
1269/// so it is enough to check the very next slot.
1270fn intent_is_settled(log: &[EventEnvelope], seq: u64) -> bool {
1271    log.get(seq as usize + 1).is_some_and(|envelope| {
1272        matches!(
1273            &envelope.event,
1274            Event::ToolCallCompleted { seq: completed_seq, .. } if completed_seq.get() == seq
1275        )
1276    })
1277}
1278
1279/// The `200` client-tool-intent body: the position, the DERIVED idempotency key
1280/// the client must perform under, the operator-declared effect it was
1281/// recorded with, and whether this position's completion is ALREADY recorded.
1282///
1283/// `settled` exists for a caller re-posting an intent it already believes it
1284/// opened, most pointedly a payments caller checking a write before it acts on
1285/// the response: without it, a retried intent and a fresh one look identical
1286/// (same `200`, same key), and a caller cannot tell "safe to perform" from
1287/// "already done, do not perform it again" without separately reading the log.
1288/// On a freshly-recorded intent it is always `false`; on a byte-identical
1289/// re-post it reflects whether the completion has landed since.
1290fn intent_body(seq: u64, idempotency_key: &str, effect: Effect, settled: bool) -> Value {
1291    json!({
1292        "seq": seq,
1293        "idempotency_key": idempotency_key,
1294        "effect": effect,
1295        "settled": settled,
1296    })
1297}
1298
1299/// `POST /v1/client-runs/{id}/client-tool-completion`: record that a
1300/// client-performed tool call finished.
1301///
1302/// The client ran the call in its own process and is now reporting the result.
1303/// Salvor did not witness it, so everything this endpoint can check, it checks
1304/// before the report becomes history. Requires the `X-Drive-Token` header.
1305///
1306/// It refuses, recording nothing, when:
1307///
1308/// - the log does not end at a tool intent, or ends at one whose `seq` is not
1309///   the one this request names (`409 divergence`);
1310/// - the pending intent was performed by the SERVER (`403`): a client must not
1311///   close a call salvor made, since salvor holds the real result;
1312/// - the declaration says `trust_completion = false` (`403`);
1313/// - the declaration carries no `output_schema` (`403`): with nothing to check
1314///   the report against, the completion is unfalsifiable, which is exactly what
1315///   the schema exists to prevent;
1316/// - the reported output fails the declared `output_schema` (`400`);
1317/// - a `require_equal` field's reported value differs from the value the intent
1318///   recorded (`403`): the output schema is a shape check and cannot know what
1319///   was authorized, so a client report may not alter a pinned field.
1320///
1321/// The checks run in that order: the trust refusal fires before any value is
1322/// compared, then the output shape, then the per-field equality.
1323///
1324/// # Where a refused completion leaves the run, and why nothing else changes
1325///
1326/// A refusal is not a dead end and needed no new state to express. The log still
1327/// ends at the recorded `ToolCallRequested`, and for an `Effect::Write` the pure
1328/// fold in `salvor-replay` ALREADY reports that as
1329/// [`RunStatus::NeedsReconciliation`](salvor_replay::RunStatus), because an
1330/// uncompleted write intent as the log's last word is precisely what that status
1331/// means. `POST /v1/client-runs/{id}/resolve` already exists to settle it by
1332/// hand, once a person has verified externally whether the call landed.
1333///
1334/// So `trust_completion = false` is fully implemented here, at the completion
1335/// boundary, and deliberately NOT in `derive_state`. That fold is a pure
1336/// function of the log with no access to declarations, and it must stay that
1337/// way: a log has to mean the same thing to a replay on another machine that
1338/// has never seen this server's `--client-tool` files. A later reader who goes
1339/// looking for the strict mode in the fold will not find it, and that is the
1340/// design, not an omission.
1341pub async fn client_tool_completion(
1342    State(state): State<AppState>,
1343    Path(run_id_text): Path<String>,
1344    headers: HeaderMap,
1345    body: Bytes,
1346) -> Result<Json<Value>, ApiError> {
1347    let run_id = parse_run_id(&run_id_text)?;
1348    authorize_drive(&state, run_id, &headers)?;
1349
1350    if body.len() > MAX_EVENTS_BODY {
1351        return Err(ApiError::PayloadTooLarge(format!(
1352            "client-tool-completion body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
1353            body.len()
1354        )));
1355    }
1356    let request: ClientToolCompletionRequest = parse_body(&body)?;
1357
1358    // A completion settles the log's LAST event, which must be the intent this
1359    // request names. Anything else and the client and the log disagree about
1360    // what is outstanding.
1361    let log = state.store().read_log(run_id).await.map_err(store_error)?;
1362    let pending = log.last().ok_or_else(|| {
1363        ApiError::Divergence(format!(
1364            "run {} has recorded nothing, so it has no client-performed tool call to complete",
1365            run_id.as_uuid()
1366        ))
1367    })?;
1368    let Event::ToolCallRequested {
1369        seq: intent_seq,
1370        tool,
1371        input: intent_input,
1372        performed_by,
1373        ..
1374    } = &pending.event
1375    else {
1376        return Err(ApiError::Divergence(format!(
1377            "run {} does not end at a tool intent, so there is no tool call to complete",
1378            run_id.as_uuid()
1379        )));
1380    };
1381    if intent_seq.get() != request.seq {
1382        return Err(ApiError::Divergence(format!(
1383            "the pending tool intent is at seq {}, not the seq {} this completion names",
1384            intent_seq.get(),
1385            request.seq
1386        )));
1387    }
1388    // A client may close only a call a client made. The server-performed
1389    // tool-step records its own completion from the output it saw, so a client
1390    // completion there would be overwriting a witnessed fact with a claim.
1391    if *performed_by != Some(Performer::Client) {
1392        return Err(ApiError::ClientCompletionRefused(format!(
1393            "the pending tool call at seq {} was performed by this server, not by the client, so \
1394             a client may not record its completion",
1395            request.seq
1396        )));
1397    }
1398    let tool = tool.clone();
1399
1400    let decls = state.client_tools();
1401    let decl = decls.get(&tool).ok_or_else(|| {
1402        ApiError::UnknownTool(format!(
1403            "no client-performed tool named `{tool}` is declared on this server, so the completion \
1404             reported for the intent at seq {} cannot be checked",
1405            request.seq
1406        ))
1407    })?;
1408
1409    if !decl.trust_completion {
1410        return Err(ApiError::ClientCompletionRefused(format!(
1411            "tool `{tool}` is declared with trust_completion = false, so a client may not record \
1412             its own completion for it; verify the call externally, then settle it by hand with \
1413             POST /v1/client-runs/{}/resolve",
1414            run_id.as_uuid()
1415        )));
1416    }
1417    let Some(output_schema) = &decl.output_schema else {
1418        return Err(ApiError::ClientCompletionRefused(format!(
1419            "tool `{tool}` declares no output_schema, so a client-reported completion carries \
1420             nothing this server can check; declare an output_schema for it, or settle the call \
1421             by hand with POST /v1/client-runs/{}/resolve",
1422            run_id.as_uuid()
1423        )));
1424    };
1425    validate_against_schema(&request.output, output_schema).map_err(|error| {
1426        ApiError::BadRequest(format!(
1427            "the reported output does not match the declared output_schema for `{tool}`: {error}"
1428        ))
1429    })?;
1430
1431    // The output schema is a shape check and cannot know what was authorized, so
1432    // a report claiming a different amount than the intent recorded passes it. A
1433    // require_equal field closes that gap: the reported value must be JSON-equal
1434    // to the value the intent recorded. The load-time rule guarantees each named
1435    // field is required on both sides, so both values are present to compare.
1436    for field in &decl.require_equal {
1437        let authorized = intent_input.get(field).unwrap_or(&Value::Null);
1438        let reported = request.output.get(field).unwrap_or(&Value::Null);
1439        if authorized != reported {
1440            return Err(ApiError::ClientCompletionRefused(format!(
1441                "tool `{tool}` reported `{field}` as {reported} for the intent at seq {}, but the \
1442                 intent recorded {authorized}; a client report may not alter a require_equal field. \
1443                 If the provider genuinely did something different, settle it by hand with POST \
1444                 /v1/client-runs/{}/resolve",
1445                request.seq,
1446                run_id.as_uuid()
1447            )));
1448        }
1449    }
1450
1451    // The completion goes through the same guard and the same helper the
1452    // server-performed tool step records its own completion with, so the two
1453    // surfaces write byte-identical `ToolCallCompleted` events.
1454    append_tool_completion(&state, run_id, request.seq, &request.output).await?;
1455    Ok(Json(json!({
1456        "seq": request.seq,
1457        "completed": true,
1458    })))
1459}
1460
1461/// Refuses a model or tool event on the generic append: those are recorded
1462/// through the server-performed model-step and tool-step endpoints, or, for a
1463/// call the CLIENT performs in its own process, through the client-tool-intent
1464/// and client-tool-completion endpoints. All four kinds stay refused here.
1465///
1466/// A client-performed tool call is possible, in other words; it is just not
1467/// possible by hand-appending an event. That is the same rule the server-
1468/// performed steps live under, and for the same reason: the effect class, the
1469/// input check, and the idempotency key are the server's to decide from an
1470/// operator's declaration, and an event submitted whole would carry the caller's
1471/// answers to all three.
1472fn reject_side_effecting_kind(candidate: &EventEnvelope) -> Result<(), ApiError> {
1473    use salvor_core::Event;
1474    let kind = match &candidate.event {
1475        Event::ModelCallRequested { .. } => "ModelCallRequested",
1476        Event::ModelCallCompleted { .. } => "ModelCallCompleted",
1477        Event::ToolCallRequested { .. } => "ToolCallRequested",
1478        Event::ToolCallCompleted { .. } => "ToolCallCompleted",
1479        _ => return Ok(()),
1480    };
1481    Err(ApiError::UnsupportedEventKind(format!(
1482        "the generic append accepts control and context events only; `{kind}` is recorded through \
1483         the model-step or tool-step endpoint"
1484    )))
1485}
1486
1487/// The per-run lease gate shared by every driving endpoint: the run must be a
1488/// client-driven run this server opened, and the request must carry its current
1489/// drive token in the `X-Drive-Token` header. Returns the lease so the caller
1490/// can read `record_prompts`.
1491fn authorize_drive(
1492    state: &AppState,
1493    run_id: RunId,
1494    headers: &HeaderMap,
1495) -> Result<ClientRunLease, ApiError> {
1496    let lease = state
1497        .client_run(run_id)
1498        .ok_or_else(|| unknown_client_run(run_id))?;
1499    let presented = headers
1500        .get(DRIVE_TOKEN_HEADER)
1501        .and_then(|value| value.to_str().ok());
1502    match presented {
1503        None => Err(ApiError::MissingDriveToken(format!(
1504            "run {} requires a drive token in the `{DRIVE_TOKEN_HEADER}` header",
1505            run_id.as_uuid()
1506        ))),
1507        Some(token) if token != lease.drive_token => Err(ApiError::InvalidDriveToken(format!(
1508            "the presented drive token is not the current lease for run {}",
1509            run_id.as_uuid()
1510        ))),
1511        Some(_) => {
1512            // The driver presented its current token: it is alive. Refresh the
1513            // lease's `last_seen` so the liveness evidence on GET /v1/runs reads
1514            // "attached". This is the whole heartbeat: it rides on the real
1515            // guarded operation, never a separate ping.
1516            state.touch_client_run(run_id);
1517            Ok(lease)
1518        }
1519    }
1520}
1521
1522/// Parses a JSON body into `T`, mapping a decode failure to a `400`.
1523fn parse_body<T: for<'de> Deserialize<'de>>(body: &Bytes) -> Result<T, ApiError> {
1524    serde_json::from_slice(body)
1525        .map_err(|error| ApiError::BadRequest(format!("request body is not valid JSON: {error}")))
1526}
1527
1528/// Parses a run id from its UUID string, mapping a bad id to a `400`.
1529fn parse_run_id(text: &str) -> Result<RunId, ApiError> {
1530    Uuid::parse_str(text).map(RunId::from_uuid).map_err(|_| {
1531        ApiError::BadRequest(format!("`{text}` is not a valid run id (expected a UUID)"))
1532    })
1533}
1534
1535/// The not-found error for a run that is not a client-driven run here.
1536fn unknown_client_run(run_id: RunId) -> ApiError {
1537    ApiError::UnknownRun(format!(
1538        "no client-driven run {} on this server; open it first",
1539        run_id.as_uuid()
1540    ))
1541}
1542
1543/// Maps a store read error to a `500`.
1544fn store_error(error: salvor_store::StoreError) -> ApiError {
1545    ApiError::Internal(format!("store: {error}"))
1546}
1547
1548/// Maps a store append error: a position taken out from under a validated batch
1549/// (a lost lease race) is a `409` divergence, anything else a `500`.
1550fn append_error(error: salvor_store::StoreError) -> ApiError {
1551    match error {
1552        salvor_store::StoreError::Conflict { seq, .. } => ApiError::Divergence(format!(
1553            "seq {} was taken by another writer before the append landed",
1554            SequenceNumber::get(seq)
1555        )),
1556        other => ApiError::Internal(format!("store: {other}")),
1557    }
1558}