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