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}