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//! `SleepStarted`, `SleepCompleted`, `BudgetExceeded`, `RunCompleted`,
22//! `RunFailed`. The side-effecting steps (the model call and the tool call) are
23//! not supported here, so a model or tool event is refused with a clear error.
24//! Each has its own endpoint pair instead: [`model_step`] and [`tool_step`] for
25//! a call this server performs because it holds the key or the binary, and
26//! [`client_tool_intent`]/[`client_tool_completion`] and
27//! [`client_model_intent`]/[`client_model_completion`] for a call the CLIENT
28//! performs in its own process and reports back.
29//!
30//! # A client-driven run may sleep, and its client wakes it
31//!
32//! The durable-timer pair belongs on that list for the same reason the
33//! suspension pair does: both halves are recorded facts the client's own
34//! cursor produces, neither holds a secret, and neither has an effect outside
35//! the log. What differs is who ends the wait. Nothing in this process waits
36//! for a client-driven run's deadline: the wake sweeper skips every run a
37//! client holds a lease on, because re-driving one here would be a second
38//! writer racing the client's drive token for the same positions. So the
39//! client wakes its own run, the way the runtime does. On a later drive it
40//! replays its log, finds a `SleepStarted` with no `SleepCompleted` after it,
41//! compares the recorded `wake_at` against a clock reading it records as a
42//! `NowObserved`, and either stops (still asleep, nothing appended) or appends
43//! the `SleepCompleted` and carries on.
44//!
45//! This surface enforces only what it can see. The order of the pair is one
46//! such thing: a `SleepCompleted` may close only a sleep this log has open
47//! (see [`is_sleeping`]). The deadline itself is not, and deliberately so.
48//! `wake_at` is the client's own recorded instant and the clock that decides
49//! it has arrived is the client's; a server that re-judged it against its own
50//! clock would be making a determinism claim about a run it does not drive.
51//!
52//! # The single-writer lease
53//!
54//! Opening a run mints a per-run `drive_token`, required on every append. It is
55//! the per-run gate that layers on top of the process-wide bearer: one
56//! authenticated caller still cannot drive another caller's run, and a second
57//! live driver without the current lease is refused.
58//!
59//! A lease is held until it lapses. Re-opening a run whose lease is still
60//! current is refused with `409 lease_held` rather than handed a fresh token,
61//! because the caller re-opening is usually not the driver that already has the
62//! run: two app instances on one thread, a duplicated tab, a retrying
63//! middleware. Handing the newest caller the lease would put both of them to
64//! work on the same log, and the one that loses the race to a position dies on
65//! a divergence after having already run the step. The driver that holds the
66//! run keeps it until it goes quiet for the lease TTL or the run finishes; the
67//! refusal says how long that is, so the second caller waits instead of
68//! polling.
69//!
70//! Lapsing is the safety net, not the way a drive is meant to end. A driver
71//! that is finished says so with [`release`], and the run is another driver's
72//! on the very next request rather than a TTL later; a driver that will be busy
73//! for longer than the TTL, inside one tool body or one streamed model call,
74//! says THAT with [`heartbeat`], and keeps the run it never actually left.
75//! Without the first, a short-lived process locks the process that follows it
76//! out for a minute for nothing; without the second, a slow step loses a run
77//! its driver is still working on. Recording a dangling write by hand drops the
78//! lease as well, because a write nobody came back to record is a driver that
79//! is gone (see [`resolve`] and [`crate::runs::resolve`]).
80//!
81//! # The lease is process-lived; the run is not
82//!
83//! The lease registry is in memory and dies with the process, which is right
84//! for a lease: a token nobody is holding any more means nothing. What must
85//! not die with it is the fact that the run is client-driven at all, because
86//! every surface that must not become a second writer (this one when a run is
87//! re-opened, [`crate::runs::resume`], the wake sweeper) turns on that fact.
88//! So the run records it: [`append`] stamps `driven_by: client` on the
89//! `RunStarted` it accepts, and [`log_is_client_driven`] reads it back. A
90//! restarted server therefore re-opens a run its client is still driving,
91//! keeps refusing to resume it, and still leaves its timer alone, none of
92//! which it could do from memory it no longer has.
93//!
94//! # Labels on a client-driven run
95//!
96//! The client, not this server, synthesizes the run's `RunStarted` (see [`open`]):
97//! there is no server-side "creation" step here the way [`crate::runs::start`]'s
98//! `StartRequest` has one. So the correlation `labels` a caller wants land in the
99//! `RunStarted` payload the client builds and appends, and the one place this
100//! server ever inspects them is [`append`], the moment that event is accepted:
101//! the sanity bounds (see `salvor_runtime::validate_labels`) are checked there,
102//! against whatever `labels` the submitted event carries, before it is written.
103//!
104//! # `recorded_at` is stamped here, never trusted from the wire
105//!
106//! Every [`EventEnvelope`] carries a `recorded_at`. On the server-performed
107//! steps ([`model_step`], [`tool_step`], and their completions) it was always
108//! [`AppState::now`], because this server built those envelopes itself. This
109//! generic append is the one surface where the envelope arrives already built,
110//! by the client, and it is the one place `recorded_at` used to be taken on
111//! faith: a browser's clock is not this store's clock, and a run with an
112//! honest server-performed step next to a client-appended `RunStarted` stamped
113//! at the Unix epoch is a store that no longer tells the truth about when
114//! things happened. So [`append`] overwrites every incoming envelope's
115//! `recorded_at` with [`AppState::now`] before it is folded or written;
116//! whatever the client sent in that field is discarded. The event kind, its
117//! payload, and its `seq` are still exactly what the client submitted (those
118//! remain the client's fact, since the client is the one driving the run); only
119//! the "when was this durably recorded" stamp is the server's, uniformly,
120//! everywhere an envelope is written.
121
122use std::convert::Infallible;
123use std::time::Duration;
124
125use axum::Json;
126use axum::body::Bytes;
127use axum::extract::{Path, Query, State};
128use axum::http::header::ACCEPT;
129use axum::http::{HeaderMap, StatusCode};
130use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
131use axum::response::{IntoResponse, Response};
132use salvor_core::{
133 DedupOrigin, Effect, Event, EventEnvelope, LogValidator, Performer, RunId, RunStatus,
134 SequenceNumber, TokenUsage, derive_state,
135};
136use salvor_llm::{ContentDelta, MessageAccumulator, StreamEvent};
137use salvor_runtime::{
138 RuntimeError, ToolFailure, ToolFailureKind, encode_failure, hash_value, response_value,
139 usage_of, validate_against_schema, validate_labels,
140};
141use salvor_store::{CallClaim, CallClaimant};
142use salvor_tools::{ToolCtx, ToolOutcome};
143use serde::Deserialize;
144use serde_json::{Value, json};
145use time::format_description::well_known::Rfc3339;
146use tokio::sync::mpsc;
147use tokio_stream::wrappers::ReceiverStream;
148use uuid::Uuid;
149
150use crate::error::ApiError;
151use crate::executor::{ModelExecutor, ModelStream};
152use crate::state::{AppState, ClientRunLease, LeaseRelease};
153use std::sync::Arc;
154
155/// The header carrying the per-run drive token on a guarded append.
156const DRIVE_TOKEN_HEADER: &str = "x-drive-token";
157
158/// The largest event-append body this surface accepts, before parsing.
159const MAX_EVENTS_BODY: usize = 8 * 1024 * 1024;
160
161/// The most envelopes one append batch may carry, so a single request cannot
162/// grow a log without bound.
163const MAX_EVENTS_PER_BATCH: usize = 1024;
164
165/// The body of `POST /v1/client-runs`.
166#[derive(Debug, Deserialize)]
167struct OpenRequest {
168 /// The agent this run drives under (`agent_def_hash`). Informational:
169 /// the client records it inside the `RunStarted` event it appends.
170 #[serde(default)]
171 agent: Option<String>,
172 /// The run input. Informational, for the same reason as `agent`.
173 #[serde(default)]
174 input: Value,
175 /// An optional caller-chosen run id (a UUID). Minted when omitted.
176 #[serde(default)]
177 run_id: Option<String>,
178 /// Whether to record model request bodies on the intent (per-run, off by
179 /// default). Governs the server-performed model step, not yet implemented
180 /// on this surface.
181 #[serde(default)]
182 record_prompts: bool,
183}
184
185/// The body of `POST /v1/client-runs/{id}/events`.
186#[derive(Debug, Deserialize)]
187struct AppendRequest {
188 /// The envelopes to append, in order. Each is the pinned event-envelope
189 /// wire JSON already used by the event stream and `salvor history --json`.
190 events: Vec<EventEnvelope>,
191}
192
193/// The `?from_seq=` query on the log read.
194#[derive(Debug, Default, Deserialize)]
195pub struct LogQuery {
196 /// Return only envelopes at or after this sequence number.
197 #[serde(default)]
198 from_seq: Option<u64>,
199}
200
201/// The body of `POST /v1/client-runs/{id}/model-step`.
202#[derive(Debug, Deserialize)]
203struct ModelStepRequest {
204 /// The log position the client's cursor reserved for the model intent.
205 seq: u64,
206 /// The client's canonical model request value (a `MessageRequest` as JSON).
207 /// The server hashes and forwards exactly these bytes.
208 request: Value,
209}
210
211/// The `?stream=` query on the model step.
212#[derive(Debug, Default, Deserialize)]
213pub struct ModelStepQuery {
214 /// When `1` or `true`, stream provider events for a live ticker. The
215 /// `Accept: text/event-stream` header selects streaming too.
216 #[serde(default)]
217 stream: Option<String>,
218}
219
220/// The body of `POST /v1/client-runs/{id}/tool-step`.
221#[derive(Debug, Deserialize)]
222struct ToolStepRequest {
223 /// The log position the client's cursor reserved for the tool intent.
224 seq: u64,
225 /// The registered tool's name. Unknown to the registry is an error, and no
226 /// intent is written.
227 tool: String,
228 /// The typed input passed to the tool, recorded on the intent verbatim.
229 input: Value,
230 /// The idempotency key for this attempt, when the tool has one. The client
231 /// draws it from a recorded `RandomObserved` so it reproduces on replay.
232 #[serde(default)]
233 idempotency_key: Option<String>,
234 /// A client-declared effect, accepted for shape parity but deliberately
235 /// ignored: the recorded effect is the registry's operator-declared
236 /// one, so a caller cannot up- or down-grade it.
237 #[serde(default)]
238 #[allow(dead_code)]
239 effect: Option<Effect>,
240}
241
242/// The body of `POST /v1/client-runs/{id}/client-tool-intent`.
243///
244/// Notice what is NOT here, next to [`ToolStepRequest`]: no `effect` and no
245/// `idempotency_key`. Both come from the operator's declaration or from the
246/// server's own derivation, so there is no field for a caller to fill in.
247#[derive(Debug, Deserialize)]
248struct ClientToolIntentRequest {
249 /// The log position the client's cursor reserved for the tool intent.
250 seq: u64,
251 /// The declared client-performed tool's name. Undeclared is an error, and
252 /// no intent is written.
253 tool: String,
254 /// The input the client is about to perform the call with, checked against
255 /// the declared `input_schema` and then recorded on the intent verbatim.
256 input: Value,
257}
258
259/// The body of `POST /v1/client-runs/{id}/client-tool-completion`.
260///
261/// Two shapes, and exactly one of them: `output` for a call that returned a
262/// result, `error` for a call that did not. A body carrying both, or neither,
263/// is refused, because the two say opposite things about the same call and this
264/// server has no way to pick.
265#[derive(Debug, Deserialize)]
266struct ClientToolCompletionRequest {
267 /// The intent's position, which must be the pending intent at the log's end.
268 seq: u64,
269 /// What the client reports the call returned, checked against the declared
270 /// `output_schema` before it is recorded.
271 #[serde(default)]
272 output: Option<Value>,
273 /// What the client reports went wrong instead, when the call produced no
274 /// result at all.
275 #[serde(default)]
276 error: Option<ReportedFailure>,
277}
278
279/// A failure a client reports for a call it performed, the `error` half of
280/// [`ClientToolCompletionRequest`].
281///
282/// It carries what the client can honestly say and nothing more. `attempts` is
283/// not on the wire: it counts executions inside salvor's own retry loop, and
284/// there is no such loop here, so the recorded failure says one attempt rather
285/// than taking a number from a caller that could say anything.
286#[derive(Debug, Deserialize)]
287struct ReportedFailure {
288 /// The failure, in full. Recorded verbatim as the sentinel's `message`, the
289 /// same field a native tool's error chain lands in.
290 message: String,
291 /// Which dispatch layer failed, one of `invalid_input`, `handler`, or
292 /// `output_serialization`. Absent means `handler`, which is what a client
293 /// tool that ran and threw is: the layers either side of it are salvor's
294 /// own argument checking and result decoding, and neither exists for a call
295 /// salvor never dispatched.
296 #[serde(default)]
297 kind: Option<String>,
298}
299
300/// The body of `POST /v1/client-runs/{id}/client-model-intent`.
301///
302/// Notice what is NOT here, next to [`ModelStepRequest`]: no `request`. The
303/// server never sees the request, because it is not the one sending it; the
304/// client hashes its own request and reports the hash. Everything this struct
305/// carries is therefore the client's claim, which is exactly the trust posture
306/// a client-performed tool call already lives under.
307#[derive(Debug, Deserialize)]
308struct ClientModelIntentRequest {
309 /// The log position the client's cursor reserved for the model intent.
310 seq: u64,
311 /// The client's canonical hash of the request it is about to send. This is
312 /// the replay-correlation key, and salvor cannot recompute it: it never
313 /// holds the request. A client that hashes inconsistently diverges against
314 /// its own log and nobody else's.
315 request_hash: String,
316 /// The full request, recorded on the intent only when the run was opened
317 /// with `record_prompts: true`, exactly as on the server-performed step.
318 /// Informational: replay correlates on `request_hash` alone.
319 #[serde(default)]
320 request_body: Option<Value>,
321}
322
323/// The body of `POST /v1/client-runs/{id}/client-model-completion`.
324#[derive(Debug, Deserialize)]
325struct ClientModelCompletionRequest {
326 /// The intent's position, which must be the pending intent at the log's end.
327 seq: u64,
328 /// What the client reports the provider returned, recorded verbatim.
329 response: Value,
330 /// The token usage the client reports for the call, in the shape
331 /// [`Event::ModelCallCompleted`] records. Required, because it is what a
332 /// token budget counts, and a completion that quietly reported none would
333 /// under-count every budget the run is held to.
334 usage: TokenUsage,
335}
336
337/// The body of `POST /v1/client-runs/{id}/resolve`.
338#[derive(Debug, Deserialize)]
339struct ResolveRequest {
340 /// The output to record for the dangling write, verbatim, exactly as the
341 /// server-driven resolve takes it.
342 output: Value,
343}
344
345/// `POST /v1/client-runs`: open a fresh client-driven run, or re-open (resume)
346/// one whose log says it is client-driven.
347///
348/// A fresh run comes back with an empty log and a new drive token; the client
349/// appends its own `RunStarted` as the first event through the append endpoint.
350/// Re-opening a known client run returns its full recorded log and a fresh
351/// lease, for a refreshed tab to rebuild its cursor.
352///
353/// # A held lease is not taken away
354///
355/// A re-open only mints a fresh lease when nobody is driving the run: this
356/// process holds no lease for it (it never opened it, or it restarted), the
357/// lease it holds has lapsed because the driver went quiet for the TTL, or the
358/// run has finished and there is nothing left to drive. While a driver's lease
359/// is current, a re-open from anyone else is `409 lease_held`, carrying
360/// `details.lapses_in_seconds` so the caller knows when the hold expires.
361///
362/// The alternative, handing the run to whoever asked most recently, reads
363/// well for the one case it was written for (a tab the user refreshed, whose
364/// old driver is gone) and badly for every other: two app instances on one
365/// thread, a duplicated tab, a middleware that re-opens on a failed call. Each
366/// of those leaves two live drivers appending the same steps to one log, and
367/// the one that loses a position race takes a divergence after it has already
368/// done the work. A refreshed tab still resumes as before, because its old
369/// driver stopped presenting a token and its lease lapses.
370///
371/// A driver re-opening its OWN run, presenting its current token in the
372/// `X-Drive-Token` header, is allowed and keeps the lease it already has: it
373/// gets the recorded log back under the same token, which is what a client
374/// rebuilding its cursor after losing local state needs, and no second writer
375/// appears because the only writer is the one asking. No new token is minted,
376/// so a request already in flight under that token is not invalidated by the
377/// re-open. `record_prompts` on such a re-open is ignored; the lease keeps the
378/// setting it was opened with.
379///
380/// Two things say a run id is client-driven, and either is enough. The first is
381/// this process's own lease registry, which answers for every run opened since
382/// the server started. The second is the run's log: the `RunStarted` at its
383/// head carries `driven_by: client`, stamped by [`append`] when this server
384/// accepted it. The registry dies with the process and the log does not, so
385/// without the second an id opened before a restart would be refused as
386/// foreign, and a client-driven run would be stranded by any restart, which is
387/// the opposite of what a durable log is for. Adopting a run from its log mints
388/// a fresh lease exactly as re-opening one this process already held does; the
389/// client resumes by rebuilding its cursor from the returned log.
390///
391/// A chosen id whose history says nothing of the sort is a server-driven run,
392/// and it is still refused, so the two modes cannot collide over one store.
393pub async fn open(
394 State(state): State<AppState>,
395 headers: HeaderMap,
396 body: Bytes,
397) -> Result<impl IntoResponse, ApiError> {
398 let request: OpenRequest = parse_body(&body)?;
399 // `agent` and `input` are accepted but not enforced against the appended
400 // RunStarted; they matter once the server performs model calls.
401 let _ = (&request.agent, &request.input);
402
403 let run_id = match &request.run_id {
404 Some(text) => parse_run_id(text)?,
405 None => RunId::new(),
406 };
407
408 let log = state.store().read_log(run_id).await.map_err(store_error)?;
409
410 // A re-open: either this process opened the run (the lease registry knows
411 // it, which is also the only evidence a run opened but not yet started has)
412 // or its recorded log says it is client-driven (which survives the restart
413 // the registry does not). Return the recorded log, and a fresh lease unless
414 // a driver still holds one.
415 if state.is_client_run(run_id) || log_is_client_driven(&log) {
416 if let Some((held, remaining)) = state.current_client_lease(run_id)
417 && !run_is_finished(&log)
418 {
419 let presented = headers
420 .get(DRIVE_TOKEN_HEADER)
421 .and_then(|value| value.to_str().ok());
422 if presented != Some(held.drive_token.as_str()) {
423 return Err(lease_held(run_id, remaining));
424 }
425 // The holder re-opening its own run. It is the only writer either
426 // way, so nothing needs taking away: hand back the recorded log
427 // under the token it already has, and count the request as the
428 // proof of life it is.
429 state.touch_client_run(run_id);
430 return Ok((
431 StatusCode::OK,
432 Json(open_body(run_id, &held.drive_token, &log)),
433 ));
434 }
435 let drive_token = state.lease_client_run(run_id, request.record_prompts);
436 return Ok((StatusCode::OK, Json(open_body(run_id, &drive_token, &log))));
437 }
438
439 // A run id with existing history and no client-driven marker is foreign: a
440 // server-driven run, whose driver is this process's own. Refuse it rather
441 // than adopt it and become a second writer on its log.
442 if !log.is_empty() {
443 return Err(ApiError::RunExists(format!(
444 "run {} already has recorded history and its log does not record it as client-driven; \
445 it is a server-driven run, so it cannot be opened for client-driven runs",
446 run_id.as_uuid()
447 )));
448 }
449
450 let drive_token = state.lease_client_run(run_id, request.record_prompts);
451 Ok((
452 StatusCode::CREATED,
453 Json(open_body(run_id, &drive_token, &[])),
454 ))
455}
456
457/// `POST /v1/client-runs/{id}/release`: hand the lease back, so the next open
458/// takes the run at once instead of waiting out the TTL.
459///
460/// A driver that is finished for now says so with this rather than by going
461/// quiet. The lapse is the safety net for a driver that cannot say anything
462/// any more (it crashed, the tab closed); it is a poor way to end a drive that
463/// ended in an orderly fashion, because the run stays unopenable for the rest
464/// of the TTL. That is exactly what a short-lived process hits: an SDK invoke
465/// returns, the process exits, and the very next process is refused
466/// `409 lease_held` for up to a minute for no reason at all.
467///
468/// Only the lease goes. The log is untouched, and the run keeps its recorded
469/// `driven_by: client`, so it is still a client-driven run: a later open adopts
470/// it exactly as it adopts one after a restart, `POST /v1/runs/{id}/resume`
471/// still refuses it, and the wake sweeper still leaves its timer to its client,
472/// all three of which read that marker rather than this registry.
473///
474/// Idempotent: a run with no lease here (already released, lapsed, or never
475/// opened by this process) answers `200` with `released: false`. Nothing to
476/// give back is not an error, because the caller's goal, a run nobody is
477/// holding, is already true. Presenting a token that is not the current lease,
478/// or none at all, IS refused (`403 invalid_drive_token`), because that caller
479/// is asking to end somebody else's hold.
480pub async fn release(
481 State(state): State<AppState>,
482 Path(run_id_text): Path<String>,
483 headers: HeaderMap,
484) -> Result<Json<Value>, ApiError> {
485 let run_id = parse_run_id(&run_id_text)?;
486 let presented = headers
487 .get(DRIVE_TOKEN_HEADER)
488 .and_then(|value| value.to_str().ok());
489 match state.release_client_run(run_id, presented) {
490 LeaseRelease::Released => Ok(Json(json!({ "released": true }))),
491 LeaseRelease::NoLease => Ok(Json(json!({ "released": false }))),
492 // A missing token lands here alongside a wrong one, unlike the driving
493 // endpoints, which answer `401 missing_drive_token` for it. Here the
494 // question is not "did you bring credentials" but "is this hold
495 // yours to end", and the answer to that is no either way.
496 LeaseRelease::NotTheHolder => Err(ApiError::InvalidDriveToken(format!(
497 "the presented drive token is not the current lease for run {}, so it cannot release it",
498 run_id.as_uuid()
499 ))),
500 }
501}
502
503/// `POST /v1/client-runs/{id}/heartbeat`: refresh the lease without driving.
504/// Requires the `X-Drive-Token` header.
505///
506/// Presenting the drive token has always been the heartbeat, and every driving
507/// call carries it. What that misses is the driver that is busy for longer than
508/// the TTL between two calls: a tool that takes minutes, a model body streaming
509/// to the client's own screen. Nothing refreshes the lease while that runs, so
510/// it lapses mid-work and another opener can take the run out from under a
511/// driver that never went anywhere. So a driver with a long stretch of work
512/// ahead of it beats every so often instead.
513///
514/// The answer carries `lapses_in_seconds`, the whole TTL as of this beat, which
515/// is what a client needs to pick its interval without being told the server's
516/// configuration some other way.
517///
518/// A driver could get the same effect by re-opening the run under its own token
519/// (that keeps the lease and counts as proof of life), and that is what the
520/// SDKs did before this existed. It re-reads the whole recorded log every beat
521/// to do it, which is the wrong price for saying "still here".
522pub async fn heartbeat(
523 State(state): State<AppState>,
524 Path(run_id_text): Path<String>,
525 headers: HeaderMap,
526) -> Result<Json<Value>, ApiError> {
527 let run_id = parse_run_id(&run_id_text)?;
528 // The lease gate IS the beat: it checks the token is this run's current
529 // lease and refreshes `last_seen` on the way through, exactly as it does
530 // for an append.
531 authorize_drive(&state, run_id, &headers)?;
532 Ok(Json(
533 json!({ "lapses_in_seconds": whole_seconds(state.client_lease_ttl()) }),
534 ))
535}
536
537/// `GET /v1/client-runs/{id}/log`: the recorded envelopes, for cursor rebuild.
538///
539/// `?from_seq=<n>` returns only envelopes at or after `n`, so a resuming client
540/// that already holds a prefix fetches just the tail. The read needs no drive
541/// token (a second viewer may read), and it needs no lease either: a run whose
542/// driver released it (see [`release`]), whose lease lapsed, or that this
543/// process only knows from a log written before a restart is still a
544/// client-driven run's log, and this is a read, not a step in driving it. So
545/// the gate asks the same two questions [`open`] does for the same reason (see
546/// [`log_is_client_driven`]): this process's lease registry, or, failing that,
547/// the log's own `driven_by: client` marker on its `RunStarted`. A run neither
548/// says is client-driven, because it is server-driven or unknown outright,
549/// still answers `404 unknown_run`.
550pub async fn get_log(
551 State(state): State<AppState>,
552 Path(run_id_text): Path<String>,
553 Query(query): Query<LogQuery>,
554) -> Result<impl IntoResponse, ApiError> {
555 let run_id = parse_run_id(&run_id_text)?;
556 let mut log = state.store().read_log(run_id).await.map_err(store_error)?;
557 if !state.is_client_run(run_id) && !log_is_client_driven(&log) {
558 return Err(unknown_client_run(run_id));
559 }
560 if let Some(from) = query.from_seq {
561 log.retain(|env| env.seq.get() >= from);
562 }
563 Ok(Json(json!({ "log": log })))
564}
565
566/// `POST /v1/client-runs/{id}/events`: the generic guarded append.
567///
568/// Each envelope is re-folded through the `salvor-replay` append-guard against
569/// the run's current log. A byte-identical re-append at an existing position is
570/// a `200` no-op (a safe retry after a network blip); different bytes there, or
571/// an illegal next event, is a `409`. Model and tool events are refused: they
572/// belong to the server-performed model-step and tool-step endpoints, not to
573/// this generic append. A `SleepCompleted` that would close a sleep the log
574/// never started is a `409` too, the one pair-ordering rule this surface adds
575/// on top of the guard (see [`is_sleeping`]). The whole batch is validated
576/// before anything is written, so a batch that turns illegal appends nothing.
577///
578/// Every envelope's `recorded_at` is overwritten with [`AppState::now`] before
579/// it is folded or written (see the module docs): `recorded_at` is the store's
580/// fact, not the client's claim, so whatever a submitted envelope carries in
581/// that field is never trusted or stored.
582pub async fn append(
583 State(state): State<AppState>,
584 Path(run_id_text): Path<String>,
585 headers: HeaderMap,
586 body: Bytes,
587) -> Result<impl IntoResponse, ApiError> {
588 let run_id = parse_run_id(&run_id_text)?;
589
590 // The per-run lease gate.
591 authorize_drive(&state, run_id, &headers)?;
592
593 // Body-size discipline, as a fast precheck before parsing.
594 if body.len() > MAX_EVENTS_BODY {
595 return Err(ApiError::PayloadTooLarge(format!(
596 "append body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
597 body.len()
598 )));
599 }
600 let request: AppendRequest = parse_body(&body)?;
601 if request.events.len() > MAX_EVENTS_PER_BATCH {
602 return Err(ApiError::PayloadTooLarge(format!(
603 "append batch carries {} events, over the {MAX_EVENTS_PER_BATCH} cap",
604 request.events.len()
605 )));
606 }
607
608 let stored = state.store().read_log(run_id).await.map_err(store_error)?;
609 let mut validator = LogValidator::new(stored);
610 let mut appended: Vec<u64> = Vec::with_capacity(request.events.len());
611 let mut to_append: Vec<EventEnvelope> = Vec::new();
612
613 for mut candidate in request.events {
614 if candidate.run_id != run_id {
615 return Err(ApiError::Divergence(format!(
616 "event names run {} but the path is run {}",
617 candidate.run_id.as_uuid(),
618 run_id.as_uuid()
619 )));
620 }
621 reject_side_effecting_kind(&candidate)?;
622 // The client synthesizes its own `RunStarted` (see the module docs);
623 // this append is the one place the server ever sees it, so it is
624 // where the sanity bounds on any carried `labels` are enforced. A
625 // byte-identical retry at an already-recorded position (handled just
626 // below) was validated the first time it landed, so re-checking here
627 // is cheap and harmless, never a behavior change.
628 //
629 // It is also where the run records who drives it. Reaching this line
630 // means the caller holds this run's lease, so the run IS client-driven,
631 // and the head of its log is the one place that fact can be written
632 // down durably. The server stamps it rather than trusting a submitted
633 // value, exactly as it does with `recorded_at` just below: what the
634 // client sent in the field is discarded, so a caller cannot mark a run
635 // client-driven anywhere but here, under a lease this server minted.
636 // Stamping before the retry comparison is what keeps a retry
637 // byte-identical: the resubmitted event is canonicalized the same way
638 // the recorded one was.
639 if let Event::RunStarted {
640 labels, driven_by, ..
641 } = &mut candidate.event
642 {
643 if let Some(labels) = labels {
644 validate_labels(labels).map_err(ApiError::BadRequest)?;
645 }
646 *driven_by = Some(Performer::Client);
647 }
648
649 let next_seq = validator.next_seq();
650 if candidate.seq < next_seq {
651 // An already-recorded position: idempotent retry or divergence.
652 // `recorded_at` is the store's fact, not the client's claim (see
653 // the module docs), so a retry's legality never turns on whatever
654 // timestamp this attempt happened to carry: canonicalize it to
655 // the already-recorded stamp before comparing the rest byte for
656 // byte.
657 let index = candidate.seq.get() as usize;
658 let recorded = &validator.log()[index];
659 candidate.recorded_at = recorded.recorded_at;
660 if *recorded == candidate {
661 appended.push(candidate.seq.get());
662 continue;
663 }
664 return Err(ApiError::Divergence(format!(
665 "different bytes submitted at the already-recorded seq {}",
666 candidate.seq.get()
667 )));
668 }
669
670 // The durable-timer pair's order is this surface's to check, because
671 // the shared append-guard deliberately does not (see [`is_sleeping`]).
672 // The working log, not the stored one, is what a batch carrying both
673 // halves at once must be judged against.
674 if matches!(candidate.event, Event::SleepCompleted {}) && !is_sleeping(validator.log()) {
675 return Err(ApiError::Divergence(format!(
676 "the SleepCompleted at seq {} would close a sleep this run has not started",
677 candidate.seq.get()
678 )));
679 }
680
681 // A new position: the server stamps its own clock reading, the same
682 // source every server-performed step uses, and ignores whatever
683 // `recorded_at` the client submitted. `recorded_at` is the store's
684 // fact, not the client's claim.
685 candidate.recorded_at = state.now();
686
687 // The append-guard decides legality.
688 validator
689 .push(candidate.clone())
690 .map_err(|error| ApiError::Divergence(error.to_string()))?;
691 appended.push(candidate.seq.get());
692 to_append.push(candidate);
693 }
694
695 // The batch validated end to end; commit the genuinely new events.
696 for envelope in &to_append {
697 state.store().append(envelope).await.map_err(append_error)?;
698 }
699
700 Ok((StatusCode::OK, Json(json!({ "appended": appended }))))
701}
702
703/// `POST /v1/client-runs/{id}/model-step`: the server-performed model call.
704///
705/// The client's cursor reserved `seq` as the model intent's position and hands
706/// the server the request to perform. The server recomputes `request_hash` from
707/// the body with the same canonical hash the runtime uses (so the client cannot
708/// lie about the hash), appends `ModelCallRequested` write-ahead, performs the
709/// call through the injected [`ModelExecutor`], appends `ModelCallCompleted`,
710/// and returns the completion. It mirrors `RunCtx::model_call` server-side.
711///
712/// Retry identity is `(seq, request_hash)`, mirroring `ReplayCursor::model_call`:
713///
714/// - A completed step already recorded at `seq` with the same hash returns the
715/// recorded completion; the provider is not called and the log does not grow.
716/// - A dangling intent at `seq` with the same hash (the tab died mid-call) is
717/// re-executed: an unanswered model request has no external effect to double,
718/// so the fresh completion correlates to the recorded intent.
719/// - A different hash at `seq`, or a non-model event there, is `409 divergence`.
720///
721/// With `Accept: text/event-stream` (or `?stream=1`) the provider's events
722/// stream as server-sent frames for a live ticker, and the assembled completion
723/// is recorded once at the end (byte-identical to the non-streaming path), so a
724/// tab that drops mid-stream leaves a dangling intent, re-issued safely.
725pub async fn model_step(
726 State(state): State<AppState>,
727 Path(run_id_text): Path<String>,
728 Query(query): Query<ModelStepQuery>,
729 headers: HeaderMap,
730 body: Bytes,
731) -> Result<Response, ApiError> {
732 let run_id = parse_run_id(&run_id_text)?;
733 let lease = authorize_drive(&state, run_id, &headers)?;
734
735 if body.len() > MAX_EVENTS_BODY {
736 return Err(ApiError::PayloadTooLarge(format!(
737 "model-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
738 body.len()
739 )));
740 }
741 let ModelStepRequest { seq, request } = parse_body(&body)?;
742
743 // Recompute the hash from the submitted body with the runtime's own
744 // canonical hash: the hash the server records is the hash it will send.
745 let request_hash = hash_value(&request);
746 let log = state.store().read_log(run_id).await.map_err(store_error)?;
747 let plan = plan_model_step(&log, seq, &request_hash)?;
748 let streaming = wants_stream(&headers, &query);
749
750 match plan {
751 ModelStepPlan::Replay { response, usage } => {
752 // Already recorded: answer from the log, call nothing, grow nothing.
753 if streaming {
754 Ok(single_complete_stream(&response, usage))
755 } else {
756 Ok(completion_body(&response, usage).into_response())
757 }
758 }
759 ModelStepPlan::Perform { append_intent } => {
760 let executor = state.model_executor().ok_or_else(|| {
761 ApiError::ModelExecutorUnavailable(
762 "this server has no model executor wired, so it cannot perform a model step"
763 .to_owned(),
764 )
765 })?;
766
767 // Write-ahead: record the intent before the provider is contacted,
768 // so a crash mid-call leaves a dangling intent (re-issued on retry).
769 // A dangling-intent retry skips this: the intent is already recorded.
770 if append_intent {
771 let request_body = lease.record_prompts.then(|| request.clone());
772 let intent = EventEnvelope::new(
773 run_id,
774 SequenceNumber::new(seq),
775 state.now(),
776 Event::ModelCallRequested {
777 seq: SequenceNumber::new(seq),
778 request_hash: request_hash.clone(),
779 request_body,
780 // This server is about to make the call itself, so the
781 // performer stays unrecorded: absent means salvor
782 // witnessed it, which is what every model intent
783 // written before the field existed meant.
784 performed_by: None,
785 },
786 );
787 let mut validator = LogValidator::new(log);
788 validator
789 .push(intent.clone())
790 .map_err(|error| ApiError::Divergence(error.to_string()))?;
791 state.store().append(&intent).await.map_err(append_error)?;
792 }
793
794 if streaming {
795 perform_streaming(state, run_id, seq, request, executor).await
796 } else {
797 perform_unary(&state, run_id, seq, request, executor.as_ref()).await
798 }
799 }
800 }
801}
802
803/// What a model step must do, decided from the recorded log alone.
804enum ModelStepPlan {
805 /// The step is already recorded: return this completion, execute nothing.
806 Replay {
807 /// The recorded response value.
808 response: Value,
809 /// The recorded token usage.
810 usage: TokenUsage,
811 },
812 /// The step must be performed. `append_intent` is true for a fresh call and
813 /// false for a dangling-intent re-issue (the intent is already recorded).
814 Perform {
815 /// Whether to write the intent before executing.
816 append_intent: bool,
817 },
818}
819
820/// Decides the model step from the log and the recomputed hash, mirroring
821/// `ReplayCursor::model_call`'s replay/re-issue/divergence branches.
822fn plan_model_step(
823 log: &[EventEnvelope],
824 seq: u64,
825 request_hash: &str,
826) -> Result<ModelStepPlan, ApiError> {
827 let next = log.len() as u64;
828 if seq == next {
829 // A fresh intent at the next contiguous position.
830 return Ok(ModelStepPlan::Perform {
831 append_intent: true,
832 });
833 }
834 if seq > next {
835 return Err(ApiError::Divergence(format!(
836 "model-step seq {seq} is beyond the log end {next}"
837 )));
838 }
839
840 // The position is already recorded: it must be the model intent, its hash
841 // must match, and its completion (if any) decides replay versus re-issue.
842 let recorded = &log[seq as usize];
843 let Event::ModelCallRequested {
844 request_hash: recorded_hash,
845 performed_by,
846 ..
847 } = &recorded.event
848 else {
849 return Err(ApiError::Divergence(format!(
850 "seq {seq} already holds a non-model event; it is not a model-step position"
851 )));
852 };
853 // A call the CLIENT performed is not this endpoint's to re-issue or to
854 // answer. Re-issuing it would let this server witness and record a response
855 // for an intent the log attributes to the client, smearing the one
856 // distinction `performed_by` exists to keep; and a cursor that asks this
857 // server to perform a step its own log says the client performed has
858 // genuinely diverged from that log. Close it with client-model-completion.
859 if *performed_by == Some(Performer::Client) {
860 return Err(ApiError::Divergence(format!(
861 "the model intent at seq {seq} was performed by the client, so this server may not \
862 perform or answer it; record its result with POST \
863 /v1/client-runs/{{id}}/client-model-completion"
864 )));
865 }
866 if recorded_hash != request_hash {
867 return Err(ApiError::Divergence(format!(
868 "model-step at seq {seq} carries a request hash that differs from the recorded intent"
869 )));
870 }
871 match log.get(seq as usize + 1) {
872 Some(next_env) => match &next_env.event {
873 Event::ModelCallCompleted {
874 seq: corr,
875 response,
876 usage,
877 } if corr.get() == seq => Ok(ModelStepPlan::Replay {
878 response: response.clone(),
879 usage: *usage,
880 }),
881 _ => Err(ApiError::Divergence(format!(
882 "the event after the intent at seq {seq} is not its completion"
883 ))),
884 },
885 // A dangling intent (the last event): re-issue the call.
886 None => Ok(ModelStepPlan::Perform {
887 append_intent: false,
888 }),
889 }
890}
891
892/// Performs a non-streaming model call: execute, record the completion, and
893/// return `{ response, usage }`.
894async fn perform_unary(
895 state: &AppState,
896 run_id: RunId,
897 seq: u64,
898 request: Value,
899 executor: &dyn ModelExecutor,
900) -> Result<Response, ApiError> {
901 let response = executor
902 .execute(request)
903 .await
904 .map_err(ApiError::ModelExecution)?;
905 let usage = usage_of(&response);
906 let response_value = response_value(&response);
907 append_completion(state, run_id, seq, &response_value, usage).await?;
908 Ok(completion_body(&response_value, usage).into_response())
909}
910
911/// Performs a streaming model call: open the provider stream, then hand a
912/// server-sent-events body a background task drives (ticker frames, then the
913/// recorded completion). Opening the stream synchronously means a failure to
914/// open is a proper error envelope, not a half-open stream.
915async fn perform_streaming(
916 state: AppState,
917 run_id: RunId,
918 seq: u64,
919 request: Value,
920 executor: Arc<dyn ModelExecutor>,
921) -> Result<Response, ApiError> {
922 let stream = executor
923 .open_stream(request)
924 .await
925 .map_err(ApiError::ModelExecution)?;
926 let (tx, rx) = mpsc::channel::<Result<SseEvent, Infallible>>(64);
927 tokio::spawn(drive_model_stream(state, run_id, seq, stream, tx));
928 Ok(Sse::new(ReceiverStream::new(rx))
929 .keep_alive(KeepAlive::default())
930 .into_response())
931}
932
933/// Pumps the provider stream: forward each event as a ticker frame and fold it
934/// into a [`MessageAccumulator`], then record the assembled completion once and
935/// send the final `complete` frame. A mid-stream error, an accumulation
936/// failure, or a completion-append failure sends an `error` frame and records
937/// nothing, so the write-ahead intent is left dangling and the run stays
938/// drivable.
939async fn drive_model_stream(
940 state: AppState,
941 run_id: RunId,
942 seq: u64,
943 mut stream: Box<dyn ModelStream>,
944 tx: mpsc::Sender<Result<SseEvent, Infallible>>,
945) {
946 let mut accumulator = MessageAccumulator::new();
947 loop {
948 match stream.next_event().await {
949 Some(Ok(event)) => {
950 if let Err(error) = accumulator.apply(&event) {
951 let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
952 return;
953 }
954 if let Some(frame) = ticker_frame(&event)
955 && tx
956 .send(Ok(SseEvent::default()
957 .event("delta")
958 .data(frame.to_string())))
959 .await
960 .is_err()
961 {
962 // The client hung up; stop, leaving the intent dangling.
963 return;
964 }
965 }
966 Some(Err(message)) => {
967 let _ = tx.send(Ok(error_frame(&message))).await;
968 return;
969 }
970 None => break,
971 }
972 }
973
974 let response = match accumulator.into_message() {
975 Ok(response) => response,
976 Err(error) => {
977 let _ = tx.send(Ok(error_frame(&error.to_string()))).await;
978 return;
979 }
980 };
981 let usage = usage_of(&response);
982 let response_value = response_value(&response);
983 if append_completion(&state, run_id, seq, &response_value, usage)
984 .await
985 .is_err()
986 {
987 let _ = tx
988 .send(Ok(error_frame("recording the model completion failed")))
989 .await;
990 return;
991 }
992 let complete = completion_json(&response_value, usage);
993 let _ = tx
994 .send(Ok(SseEvent::default()
995 .event("complete")
996 .data(complete.to_string())))
997 .await;
998}
999
1000/// Records the `ModelCallCompleted` at `seq + 1`, correlated to the intent at
1001/// `seq`, after validating it is the legal next event.
1002async fn append_completion(
1003 state: &AppState,
1004 run_id: RunId,
1005 seq: u64,
1006 response: &Value,
1007 usage: TokenUsage,
1008) -> Result<(), ApiError> {
1009 let completion = EventEnvelope::new(
1010 run_id,
1011 SequenceNumber::new(seq + 1),
1012 state.now(),
1013 Event::ModelCallCompleted {
1014 seq: SequenceNumber::new(seq),
1015 response: response.clone(),
1016 usage,
1017 },
1018 );
1019 let log = state.store().read_log(run_id).await.map_err(store_error)?;
1020 let mut validator = LogValidator::new(log);
1021 validator
1022 .push(completion.clone())
1023 .map_err(|error| ApiError::Divergence(error.to_string()))?;
1024 state
1025 .store()
1026 .append(&completion)
1027 .await
1028 .map_err(append_error)
1029}
1030
1031/// Whether the request selects the streaming variant: `?stream=1`/`true`, or an
1032/// `Accept: text/event-stream` header.
1033fn wants_stream(headers: &HeaderMap, query: &ModelStepQuery) -> bool {
1034 if let Some(flag) = &query.stream
1035 && (flag == "1" || flag == "true")
1036 {
1037 return true;
1038 }
1039 headers
1040 .get(ACCEPT)
1041 .and_then(|value| value.to_str().ok())
1042 .is_some_and(|accept| accept.contains("text/event-stream"))
1043}
1044
1045/// The ticker frame for a provider event, or `None` for events with nothing a
1046/// live ticker shows (start/stop/ping). Text and thinking deltas and the final
1047/// usage are what a token/cost ticker consumes.
1048fn ticker_frame(event: &StreamEvent) -> Option<Value> {
1049 match event {
1050 StreamEvent::ContentBlockDelta { index, delta } => match delta {
1051 ContentDelta::Text { text } => {
1052 Some(json!({ "type": "text_delta", "index": index, "text": text }))
1053 }
1054 ContentDelta::Thinking { thinking } => {
1055 Some(json!({ "type": "thinking_delta", "index": index, "thinking": thinking }))
1056 }
1057 _ => None,
1058 },
1059 StreamEvent::MessageDelta { usage, .. } => {
1060 Some(json!({ "type": "usage", "output_tokens": usage.output_tokens }))
1061 }
1062 _ => None,
1063 }
1064}
1065
1066/// A one-frame server-sent-events body carrying an already-recorded completion,
1067/// for a streaming request that resolves to a replay (no live tokens).
1068fn single_complete_stream(response: &Value, usage: TokenUsage) -> Response {
1069 let frame = SseEvent::default()
1070 .event("complete")
1071 .data(completion_json(response, usage).to_string());
1072 Sse::new(tokio_stream::once(Ok::<_, Infallible>(frame)))
1073 .keep_alive(KeepAlive::default())
1074 .into_response()
1075}
1076
1077/// The `{ response, usage }` JSON both the non-streaming body and the `complete`
1078/// frame carry.
1079fn completion_json(response: &Value, usage: TokenUsage) -> Value {
1080 json!({ "response": response, "usage": usage })
1081}
1082
1083/// The non-streaming `200` body.
1084fn completion_body(response: &Value, usage: TokenUsage) -> Json<Value> {
1085 Json(completion_json(response, usage))
1086}
1087
1088/// An `error` server-sent-events frame carrying a human message.
1089fn error_frame(message: &str) -> SseEvent {
1090 SseEvent::default()
1091 .event("error")
1092 .data(json!({ "message": message }).to_string())
1093}
1094
1095/// The `201`/`200` open response body.
1096fn open_body(run_id: RunId, drive_token: &str, log: &[EventEnvelope]) -> Value {
1097 json!({
1098 "run": run_id.as_uuid().to_string(),
1099 "drive_token": drive_token,
1100 "log": log,
1101 })
1102}
1103
1104/// `POST /v1/client-runs/{id}/tool-step`: the server-performed tool call.
1105///
1106/// The client's cursor reserved `seq` as the tool intent's position. The server
1107/// looks the tool up in its injected [`ToolRegistry`](crate::ToolRegistry),
1108/// takes the operator-declared [`Effect`] from that registration (never from
1109/// the client, so a caller cannot up- or down-grade it), appends
1110/// `ToolCallRequested` write-ahead, dispatches the tool, appends
1111/// `ToolCallCompleted`, and returns the output. It mirrors `RunCtx::tool_call`
1112/// server-side, and its retry and reconciliation branches mirror
1113/// `ReplayCursor::tool_call`:
1114///
1115/// - A completed step recorded at `seq` with the same (tool, input, effect,
1116/// key) returns the recorded output; the tool is not dispatched and the log
1117/// does not grow.
1118/// - A dangling `Read`/`Idempotent` intent at `seq` (the tab died mid-call) is
1119/// re-executed under the RECORDED idempotency key, so an idempotent retry
1120/// reuses the exact key the provider collapses duplicates on.
1121/// - A dangling `Write` intent is `409 needs_reconciliation` carrying the
1122/// recorded intent as evidence, and nothing is dispatched: the write may have
1123/// landed, and only [`resolve`] may record its completion.
1124/// - A different (tool, input, effect, key) at `seq`, or a non-tool event
1125/// there, is `409 divergence`.
1126///
1127/// An unknown tool (or no registry at all) writes nothing, mirroring the model
1128/// step's no-executor rule: the step is retriable once the tool is registered.
1129pub async fn tool_step(
1130 State(state): State<AppState>,
1131 Path(run_id_text): Path<String>,
1132 headers: HeaderMap,
1133 body: Bytes,
1134) -> Result<Json<Value>, ApiError> {
1135 let run_id = parse_run_id(&run_id_text)?;
1136 authorize_drive(&state, run_id, &headers)?;
1137
1138 if body.len() > MAX_EVENTS_BODY {
1139 return Err(ApiError::PayloadTooLarge(format!(
1140 "tool-step body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
1141 body.len()
1142 )));
1143 }
1144 let request: ToolStepRequest = parse_body(&body)?;
1145
1146 // Look the tool up before anything is written. No registry is a 503; a
1147 // registry without the named tool is a 404. Either way, nothing is written.
1148 let registry = state.tool_registry().ok_or_else(|| {
1149 ApiError::ToolRegistryUnavailable(
1150 "this server has no tool registry wired, so it cannot perform a tool step".to_owned(),
1151 )
1152 })?;
1153 let tool = registry.get(&request.tool).ok_or_else(|| {
1154 ApiError::UnknownTool(format!(
1155 "no tool named `{}` is registered on this server",
1156 request.tool
1157 ))
1158 })?;
1159
1160 // The effect is the registry's operator declaration, never the client's.
1161 // The client-declared `effect` field on the body is dropped here.
1162 let effect = tool.effect();
1163 let ToolStepRequest {
1164 seq,
1165 tool: tool_name,
1166 input,
1167 idempotency_key,
1168 effect: _,
1169 } = request;
1170
1171 let log = state.store().read_log(run_id).await.map_err(store_error)?;
1172 let plan = plan_tool_step(
1173 &log,
1174 seq,
1175 &tool_name,
1176 &input,
1177 effect,
1178 idempotency_key.as_deref(),
1179 )?;
1180
1181 match plan {
1182 ToolStepPlan::Replay { output } => Ok(tool_output_body(&output)),
1183 ToolStepPlan::Reconcile { intent } => Err(ApiError::NeedsReconciliation {
1184 message: format!(
1185 "run {} needs reconciliation: a write was recorded but never completed, so it \
1186 may or may not have taken effect. Verify externally, then resolve it",
1187 run_id.as_uuid()
1188 ),
1189 intent,
1190 }),
1191 ToolStepPlan::Perform {
1192 append_intent,
1193 exec_key,
1194 } => {
1195 // Write-ahead: record the intent before the tool runs, so a crash
1196 // mid-call leaves a dangling intent (re-issued or reconciled on
1197 // retry, per effect). A dangling re-issue skips this: the intent is
1198 // already recorded.
1199 if append_intent {
1200 let intent = EventEnvelope::new(
1201 run_id,
1202 SequenceNumber::new(seq),
1203 state.now(),
1204 Event::ToolCallRequested {
1205 seq: SequenceNumber::new(seq),
1206 tool: tool_name.clone(),
1207 input: input.clone(),
1208 effect,
1209 idempotency_key: exec_key.clone(),
1210 performed_by: None,
1211 },
1212 );
1213 let mut validator = LogValidator::new(log);
1214 validator
1215 .push(intent.clone())
1216 .map_err(|error| ApiError::Divergence(error.to_string()))?;
1217 state.store().append(&intent).await.map_err(append_error)?;
1218 }
1219
1220 // Dispatch through the same erased contract the runtime uses, with
1221 // the idempotency key on the context so an idempotent retry reuses
1222 // it. A dispatch failure is an error envelope with no completion, so
1223 // the intent is left dangling (legal, the crash story).
1224 let ctx = ToolCtx::new(exec_key);
1225 let outcome = tool
1226 .call_json(&ctx, input)
1227 .await
1228 .map_err(|error| ApiError::ToolExecution(error.to_string()))?;
1229 let output = match outcome {
1230 ToolOutcome::Output(value) => value,
1231 ToolOutcome::Suspend(_) => {
1232 return Err(ApiError::ToolExecution(format!(
1233 "tool `{tool_name}` suspended, which a server-performed tool step does \
1234 not support; no completion recorded"
1235 )));
1236 }
1237 // Refused for the same reason a suspension is: a step endpoint
1238 // performs one call and answers with its output. Parking the
1239 // run belongs to a driver, and the client owns the loop here.
1240 ToolOutcome::Sleep(_) => {
1241 return Err(ApiError::ToolExecution(format!(
1242 "tool `{tool_name}` asked to sleep, which a server-performed tool step \
1243 does not support; no completion recorded"
1244 )));
1245 }
1246 };
1247 append_tool_completion(&state, run_id, seq, &output, None).await?;
1248 Ok(tool_output_body(&output))
1249 }
1250 }
1251}
1252
1253/// What a tool step must do, decided from the recorded log and the registry's
1254/// effect alone.
1255enum ToolStepPlan {
1256 /// The step is already recorded: return this output, dispatch nothing.
1257 Replay {
1258 /// The recorded tool output.
1259 output: Value,
1260 },
1261 /// A dangling write: surface reconciliation with this intent evidence,
1262 /// dispatch nothing.
1263 Reconcile {
1264 /// The recorded write intent, for the error body.
1265 intent: Value,
1266 },
1267 /// The step must be performed. `append_intent` is true for a fresh call and
1268 /// false for a dangling re-issue (the intent is already recorded); `exec_key`
1269 /// is the idempotency key to dispatch under (the recorded key on a re-issue).
1270 Perform {
1271 /// Whether to write the intent before dispatching.
1272 append_intent: bool,
1273 /// The idempotency key handed to the tool for this attempt.
1274 exec_key: Option<String>,
1275 },
1276}
1277
1278/// Decides the tool step from the log and the registry's effect, mirroring
1279/// `ReplayCursor::tool_call`'s replay, re-issue, reconciliation, and divergence
1280/// branches. The effect is the registry's, so a client cannot change it.
1281fn plan_tool_step(
1282 log: &[EventEnvelope],
1283 seq: u64,
1284 tool: &str,
1285 input: &Value,
1286 effect: Effect,
1287 idempotency_key: Option<&str>,
1288) -> Result<ToolStepPlan, ApiError> {
1289 let next = log.len() as u64;
1290 if seq == next {
1291 // A fresh intent at the next contiguous position.
1292 return Ok(ToolStepPlan::Perform {
1293 append_intent: true,
1294 exec_key: idempotency_key.map(ToOwned::to_owned),
1295 });
1296 }
1297 if seq > next {
1298 return Err(ApiError::Divergence(format!(
1299 "tool-step seq {seq} is beyond the log end {next}"
1300 )));
1301 }
1302
1303 // The position is already recorded: it must be the tool intent, and its
1304 // (tool, input, effect, key) must all match, exactly as the cursor checks.
1305 let recorded = &log[seq as usize];
1306 let Event::ToolCallRequested {
1307 tool: recorded_tool,
1308 input: recorded_input,
1309 effect: recorded_effect,
1310 idempotency_key: recorded_key,
1311 ..
1312 } = &recorded.event
1313 else {
1314 return Err(ApiError::Divergence(format!(
1315 "seq {seq} already holds a non-tool event; it is not a tool-step position"
1316 )));
1317 };
1318 if recorded_tool != tool
1319 || recorded_input != input
1320 || *recorded_effect != effect
1321 || recorded_key.as_deref() != idempotency_key
1322 {
1323 return Err(ApiError::Divergence(format!(
1324 "tool-step at seq {seq} diverges from the recorded intent (tool, input, effect, or key)"
1325 )));
1326 }
1327 match log.get(seq as usize + 1) {
1328 Some(next_env) => match &next_env.event {
1329 Event::ToolCallCompleted {
1330 seq: corr, output, ..
1331 } if corr.get() == seq => Ok(ToolStepPlan::Replay {
1332 output: output.clone(),
1333 }),
1334 _ => Err(ApiError::Divergence(format!(
1335 "the event after the intent at seq {seq} is not its completion"
1336 ))),
1337 },
1338 // A dangling intent (the last event): the effect decides. Write never
1339 // re-executes; Read/Idempotent re-execute under the RECORDED key.
1340 None => match effect {
1341 Effect::Write => Ok(ToolStepPlan::Reconcile {
1342 intent: intent_evidence(recorded),
1343 }),
1344 Effect::Read | Effect::Idempotent => Ok(ToolStepPlan::Perform {
1345 append_intent: false,
1346 exec_key: recorded_key.clone(),
1347 }),
1348 },
1349 }
1350}
1351
1352/// The reconciliation evidence carried in a `needs_reconciliation` error body:
1353/// the recorded write intent plus when it was recorded, mirroring the
1354/// server-driven resolve's `reconcile_intent` and `json::pending` shapes.
1355fn intent_evidence(envelope: &EventEnvelope) -> Value {
1356 let Event::ToolCallRequested {
1357 seq,
1358 tool,
1359 input,
1360 effect,
1361 idempotency_key,
1362 ..
1363 } = &envelope.event
1364 else {
1365 return Value::Null;
1366 };
1367 json!({
1368 "kind": "tool",
1369 "seq": seq.get(),
1370 "tool": tool,
1371 "input": input,
1372 "effect": effect,
1373 "idempotency_key": idempotency_key,
1374 "recorded_at": envelope.recorded_at.format(&Rfc3339).unwrap_or_default(),
1375 })
1376}
1377
1378/// Records the `ToolCallCompleted` at `seq + 1`, correlated to the intent at
1379/// `seq`, after validating it is the legal next event.
1380///
1381/// `deduplicated_from` names the completion this output was copied from, on the
1382/// one path that copies one (a repeated call under a declared idempotency key);
1383/// every other caller passes `None`, and the recorded bytes are then exactly
1384/// what this helper wrote before the field existed.
1385///
1386/// `settled_by` is never stamped here. This is the run recording what it was
1387/// told; only `Runtime::resolve`, where a person records a completion over the
1388/// run's head, names a settler.
1389///
1390/// # It settles the store's claim, when this call holds one
1391///
1392/// A call opened under a DECLARED idempotency key claimed that identity in the
1393/// store before its intent was written (see [`client_tool_intent`]). The
1394/// completion has to release it, in the same atomic step the event is appended,
1395/// or the store would go on saying the call is in flight while its result sits
1396/// recorded, and every later call under that key would be refused forever with
1397/// nothing anywhere to say why. This is the same reconciliation
1398/// `Runtime::resolve` performs for the hand-recorded path, and it is a no-op
1399/// for a positional key, which claims nothing.
1400async fn append_tool_completion(
1401 state: &AppState,
1402 run_id: RunId,
1403 seq: u64,
1404 output: &Value,
1405 deduplicated_from: Option<DedupOrigin>,
1406) -> Result<(), ApiError> {
1407 let completion = EventEnvelope::new(
1408 run_id,
1409 SequenceNumber::new(seq + 1),
1410 state.now(),
1411 Event::ToolCallCompleted {
1412 seq: SequenceNumber::new(seq),
1413 output: output.clone(),
1414 deduplicated_from,
1415 settled_by: None,
1416 },
1417 );
1418 let log = state.store().read_log(run_id).await.map_err(store_error)?;
1419 let held = held_claim(state, &log, run_id, seq).await?;
1420 let mut validator = LogValidator::new(log);
1421 validator
1422 .push(completion.clone())
1423 .map_err(|error| ApiError::Divergence(error.to_string()))?;
1424 match held {
1425 Some(key) => state
1426 .store()
1427 .append_settling_call(
1428 &completion,
1429 CallClaimant {
1430 tool: &key.0,
1431 idempotency_key: &key.1,
1432 run_id,
1433 intent_seq: SequenceNumber::new(seq),
1434 },
1435 )
1436 .await
1437 .map_err(append_error),
1438 None => state
1439 .store()
1440 .append(&completion)
1441 .await
1442 .map_err(append_error),
1443 }
1444}
1445
1446/// The `(tool, idempotency key)` this run's intent at `seq` holds an unsettled
1447/// claim on, if it holds one at all.
1448///
1449/// Mirrors the lookup `Runtime::resolve` does before it settles: a commitment
1450/// that names this exact run and this exact intent, and is not already settled,
1451/// is one this completion owns and must close. Anything else (no commitment,
1452/// somebody else's, one already settled) is left alone, because settling a
1453/// commitment one does not own is refused by the store and would be a bug here
1454/// rather than a race.
1455async fn held_claim(
1456 state: &AppState,
1457 log: &[EventEnvelope],
1458 run_id: RunId,
1459 seq: u64,
1460) -> Result<Option<(String, String)>, ApiError> {
1461 let Some(EventEnvelope {
1462 event:
1463 Event::ToolCallRequested {
1464 tool,
1465 idempotency_key: Some(key),
1466 ..
1467 },
1468 ..
1469 }) = log.get(seq as usize)
1470 else {
1471 return Ok(None);
1472 };
1473 let commitment = state
1474 .store()
1475 .lookup_call(tool, key)
1476 .await
1477 .map_err(store_error)?;
1478 let ours = commitment.is_some_and(|commitment| {
1479 commitment.run_id == run_id
1480 && commitment.intent_seq.get() == seq
1481 && commitment.completion_seq.is_none()
1482 });
1483 Ok(ours.then(|| (tool.clone(), key.clone())))
1484}
1485
1486/// The `200` tool-step body, `{ "output": <json> }`.
1487fn tool_output_body(output: &Value) -> Json<Value> {
1488 Json(json!({ "output": output }))
1489}
1490
1491/// `POST /v1/client-runs/{id}/resolve`: record a dangling write's completion by
1492/// hand for a client-driven run, the drive-token-gated twin of the
1493/// server-driven `POST /v1/runs/{id}/resolve`.
1494///
1495/// State-validated exactly like the server-driven resolve: it is legal only
1496/// when the run's log ends at a dangling `Write` intent, it correlates the
1497/// caller-supplied output to that intent, and it dispatches nothing. It reuses
1498/// the same `Runtime::resolve` the server-driven endpoint does, so the two
1499/// share one reconciliation contract. After it records the completion the run
1500/// is drivable again, so the client re-fetches the log and its cursor sails
1501/// past the once-dangling intent.
1502///
1503/// # The lease a resolve clears, and the one it does not
1504///
1505/// A dangling write means the driver that opened it never came back to record
1506/// what happened, so a resolve is normally the sign that that driver is gone
1507/// and the lease it left behind is holding the run for nobody. Both resolve
1508/// endpoints say so the same way (see
1509/// [`AppState::clear_client_lease`](crate::state::AppState::clear_client_lease)):
1510/// the run's lease is dropped once the resolution is recorded, and the next
1511/// open takes the run at once instead of waiting out the TTL. The operator's
1512/// path, `POST /v1/runs/{id}/resolve`, is where that matters, because the
1513/// caller there presents no token and is by definition not the driver.
1514///
1515/// This endpoint is the exception, and passes its own token to be kept. Getting
1516/// in here at all means presenting the run's current lease, which is the driver
1517/// saying it is right here; revoking it would strand the very caller that just
1518/// proved it is alive, mid-run, over a write it is about to carry on past.
1519pub async fn resolve(
1520 State(state): State<AppState>,
1521 Path(run_id_text): Path<String>,
1522 headers: HeaderMap,
1523 body: Bytes,
1524) -> Result<Json<Value>, ApiError> {
1525 let run_id = parse_run_id(&run_id_text)?;
1526 let lease = authorize_drive(&state, run_id, &headers)?;
1527 let request: ResolveRequest = parse_body(&body)?;
1528
1529 // The same declaration check the operator's resolve endpoint makes, through
1530 // the same helper, so a hand-recorded output meets one set of rules however
1531 // it arrives. Salvor witnessed neither the call nor the resolution, and the
1532 // declaration is the only thing that says what a finished call looks like.
1533 let log = state.store().read_log(run_id).await.map_err(store_error)?;
1534 crate::client_tools::check_client_resolution(&state.client_tools(), &log, &request.output)?;
1535
1536 match state.runtime().resolve(run_id, request.output).await {
1537 Ok(_) => {
1538 // The resolve rule, with this caller's own lease held back from
1539 // it. In the one case where the stored lease is no longer the one
1540 // authorized above (it lapsed while the completion was being
1541 // written and another driver took the run), this does clear that
1542 // driver's lease, which is the same outcome it would meet on its
1543 // next call anyway.
1544 state.clear_client_lease(run_id, Some(&lease.drive_token));
1545 Ok(Json(json!({
1546 "run": run_id.as_uuid().to_string(),
1547 "resolved": true,
1548 })))
1549 }
1550 Err(RuntimeError::NotReconcilable { status, .. }) => {
1551 // Always a client-driven run: getting in here meant presenting its
1552 // drive token.
1553 Err(resolve_refusal(run_id, &log, &status, true))
1554 }
1555 Err(error) => Err(ApiError::Internal(error.to_string())),
1556 }
1557}
1558
1559/// The `409 wrong_state` a resolve answers when the run has no dangling write
1560/// to settle, shared by both resolve endpoints.
1561///
1562/// # Why a client-driven run gets its own sentence
1563///
1564/// The generic form quotes the runtime's status name, and two of those names
1565/// end in "use recover". `recover` is a server-driven verb: it spawns a driver
1566/// task over the run, which is exactly what must never happen to a run whose
1567/// client holds the single-writer lease, and `POST /v1/runs/{id}/resume`
1568/// refuses such a run for that reason. Telling an operator to reach for it is
1569/// telling them to do the one thing the next endpoint will refuse.
1570///
1571/// What a client-driven run's unfinished call actually needs is either nothing
1572/// or a resolve, and which one is a fact about the call, so the message says
1573/// it: an unfinished read or model call is re-performed by the client on its
1574/// next drive, with no operator action at all, while a dangling write is the
1575/// one thing this endpoint settles (and a run holding one never reaches this
1576/// refusal, because it folds to `needs_reconciliation` and the resolve
1577/// succeeds).
1578pub(crate) fn resolve_refusal(
1579 run_id: RunId,
1580 log: &[EventEnvelope],
1581 status: &str,
1582 client_driven: bool,
1583) -> ApiError {
1584 if client_driven && let Some(unfinished) = unfinished_call_sentence(log) {
1585 return ApiError::WrongState(format!(
1586 "run {} has no dangling write to settle: {unfinished}. A write recorded with no \
1587 completion after it is the one thing this endpoint records by hand",
1588 run_id.as_uuid()
1589 ));
1590 }
1591 ApiError::WrongState(format!(
1592 "run {} does not need reconciliation (status: {status}); there is no dangling write to \
1593 resolve",
1594 run_id.as_uuid()
1595 ))
1596}
1597
1598/// How a client-driven run's unfinished call reads in a refusal, or `None` when
1599/// the log does not end at one (the run finished, parked, or never started, all
1600/// of which the status name already describes honestly).
1601fn unfinished_call_sentence(log: &[EventEnvelope]) -> Option<String> {
1602 match &log.last()?.event {
1603 Event::ToolCallRequested {
1604 seq, tool, effect, ..
1605 } if !matches!(effect, Effect::Write) => Some(format!(
1606 "its log ends at an unfinished {effect:?} call to `{tool}` at seq {}, which the client \
1607 performs again on its next drive rather than by being resolved",
1608 seq.get()
1609 )),
1610 Event::ModelCallRequested { seq, .. } => Some(format!(
1611 "its log ends at an unfinished model call at seq {}, which the client performs again \
1612 on its next drive rather than by being resolved",
1613 seq.get()
1614 )),
1615 _ => None,
1616 }
1617}
1618
1619/// The idempotency key a CLIENT-performed tool call presents: derived by this
1620/// server from where the call sits in the run (run id, sequence, tool name),
1621/// never supplied by the caller.
1622///
1623/// This deliberately differs from the server-performed [`tool_step`], where the
1624/// client supplies `idempotency_key` on the request body and the server records
1625/// what it was given. The difference is not an oversight, and the older
1626/// endpoint should not be "fixed" to match.
1627///
1628/// There, salvor performs the call. The party choosing the key is not the party
1629/// making the write, and a key chosen badly costs the caller nothing but its own
1630/// retry failing to collapse. Here the client both chooses the key and performs
1631/// the write, in a process salvor never sees. That is the one case where the
1632/// party choosing the key is also the party who benefits from a duplicate
1633/// landing: a client that wants to be paid twice supplies a fresh key for the
1634/// second attempt and the provider, seeing two distinct calls, honors both,
1635/// while salvor's log shows two honest-looking intents. Deriving the key removes
1636/// the choice. The same (run, seq, tool) always derives the same key, so an
1637/// honest retry after a dropped response presents the identical key the first
1638/// attempt did and the provider collapses the pair, and a second attempt cannot
1639/// present a different one.
1640///
1641/// Shaped after `salvor_engine`'s `fork_safe_idempotency_key`: a canonical hash
1642/// of a small JSON object, using the same `hash_value` the rest of the workspace
1643/// hashes with, so the key is reproducible across processes and languages and a
1644/// client can derive it independently to check the server's work.
1645///
1646/// # What the hash is over, and who chooses
1647///
1648/// The client never chooses, on either shape. What the OPERATOR chooses, in the
1649/// declaration's [`idempotency_key`](crate::client_tools::ClientToolDecl::idempotency_key),
1650/// is what the hash is over:
1651///
1652/// - **No fields declared: `{ run, seq, tool }`.** The call's position in the
1653/// run. This is an attempt identifier and promises exactly one thing: the
1654/// same position, retried, presents the same key. Two calls at two positions
1655/// are two calls, however alike their arguments.
1656/// - **Fields declared: `{ run, tool, <field>: <value>, ... }`.** The call's
1657/// content. `seq` is deliberately absent, which is the whole difference: the
1658/// same refund asked for twice in one run derives one key both times, so the
1659/// second is the same call rather than a second refund. Each value is the
1660/// intent's own recorded input, so the key is a fact about what was
1661/// authorized. The order the operator wrote the names in does not change the
1662/// key: `hash_value` canonicalizes, which sorts object keys, so a
1663/// reordered declaration derives the identical hash and a client deriving it
1664/// independently need not mirror the file's ordering.
1665///
1666/// The run id is on both shapes, so a declared key is an identity within one
1667/// run and never collides with another run's. That is deliberate. Only a tool
1668/// can honestly say two calls in different runs are the same effect, and a
1669/// declaration is the operator's word about a tool this server holds no code
1670/// for; scoping the identity to the run keeps the claim to something the
1671/// operator can actually know.
1672///
1673/// # Errors
1674///
1675/// [`ApiError::BadRequest`] naming the field when a declared key field is
1676/// absent from the input. The load-time rule makes every key field required by
1677/// the input schema, so an input that passed validation carries them all; this
1678/// is the check that the two rules stay in step rather than deriving a key over
1679/// a missing value and collapsing two calls onto one identity.
1680fn client_tool_idempotency_key(
1681 run_id: RunId,
1682 seq: u64,
1683 tool: &str,
1684 key_fields: &[String],
1685 input: &Value,
1686) -> Result<String, ApiError> {
1687 if key_fields.is_empty() {
1688 return Ok(hash_value(&json!({
1689 "run": run_id.as_uuid().to_string(),
1690 "seq": seq,
1691 "tool": tool,
1692 })));
1693 }
1694 let mut identity = serde_json::Map::new();
1695 identity.insert("run".to_owned(), json!(run_id.as_uuid().to_string()));
1696 identity.insert("tool".to_owned(), json!(tool));
1697 for field in key_fields {
1698 let value = input.get(field).ok_or_else(|| {
1699 ApiError::BadRequest(format!(
1700 "tool `{tool}` derives its idempotency key from `{field}`, but the input carries \
1701 no `{field}`; the key names what the call is, so it cannot be derived without it"
1702 ))
1703 })?;
1704 identity.insert(field.clone(), value.clone());
1705 }
1706 Ok(hash_value(&Value::Object(identity)))
1707}
1708
1709/// `POST /v1/client-runs/{id}/client-tool-intent`: open a client-performed tool
1710/// call.
1711///
1712/// The counterpart of [`tool_step`] for a tool salvor holds no code for. The
1713/// client is about to run the call in its OWN process, with its own secrets;
1714/// this endpoint records that it is about to, so the intent is in the log before
1715/// the effect happens, exactly as the write-ahead rule demands of a call salvor
1716/// performs itself. Requires the `X-Drive-Token` header, like every other
1717/// driving endpoint.
1718///
1719/// What the server takes from the operator's declaration rather than the
1720/// request: the [`Effect`] (so a caller cannot up- or down-grade its own write
1721/// into a freely retried read), the input schema the input is checked against
1722/// before anything is written, and the idempotency key, which is DERIVED here
1723/// (see [`client_tool_idempotency_key`]). The client supplies only the position,
1724/// the name, and the input.
1725///
1726/// The intent goes through the same [`LogValidator`] guard every other append on
1727/// this surface uses, so ordering and correlation stay enforced: an intent at a
1728/// position the log is not ready for is a `409 divergence` and nothing is
1729/// written. A byte-identical re-post at an already-recorded position is a `200`
1730/// that re-derives the same key and writes nothing, the safe retry a dropped
1731/// response leaves behind.
1732///
1733/// The response carries the derived key and a `settled` flag: `true` when the
1734/// intent at this position already has its completion recorded, so a caller
1735/// re-posting an intent it believes it already opened can tell "safe to
1736/// perform" from "already done" without reading the log. The client performs
1737/// the work under the key and then posts [`client_tool_completion`].
1738pub async fn client_tool_intent(
1739 State(state): State<AppState>,
1740 Path(run_id_text): Path<String>,
1741 headers: HeaderMap,
1742 body: Bytes,
1743) -> Result<Json<Value>, ApiError> {
1744 let run_id = parse_run_id(&run_id_text)?;
1745 authorize_drive(&state, run_id, &headers)?;
1746
1747 if body.len() > MAX_EVENTS_BODY {
1748 return Err(ApiError::PayloadTooLarge(format!(
1749 "client-tool-intent body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
1750 body.len()
1751 )));
1752 }
1753 let request: ClientToolIntentRequest = parse_body(&body)?;
1754
1755 // The declaration is looked up before anything is written. Declarations are
1756 // loaded by the operator and never registered over HTTP (see
1757 // `crate::client_tools`), so an unknown name is a `404` the operator fixes,
1758 // not something a caller can create for itself.
1759 let decls = state.client_tools();
1760 let decl = decls.get(&request.tool).ok_or_else(|| {
1761 ApiError::UnknownTool(format!(
1762 "no client-performed tool named `{}` is declared on this server; declarations are \
1763 loaded by the operator (`salvor serve --client-tool <FILE>`) and are never \
1764 registered over HTTP",
1765 request.tool
1766 ))
1767 })?;
1768
1769 // The input is checked against the OPERATOR's schema before the intent is
1770 // recorded, so a malformed call never becomes history: on the failure path
1771 // this endpoint writes nothing at all and the run is untouched.
1772 validate_against_schema(&request.input, &decl.input_schema).map_err(|error| {
1773 ApiError::BadRequest(format!(
1774 "the input does not match the declared input_schema for `{}`: {error}",
1775 request.tool
1776 ))
1777 })?;
1778 let effect = decl.effect;
1779 let key_fields = decl.idempotency_key.clone();
1780
1781 let key = client_tool_idempotency_key(
1782 run_id,
1783 request.seq,
1784 &request.tool,
1785 &key_fields,
1786 &request.input,
1787 )?;
1788 let intent = EventEnvelope::new(
1789 run_id,
1790 SequenceNumber::new(request.seq),
1791 state.now(),
1792 Event::ToolCallRequested {
1793 seq: SequenceNumber::new(request.seq),
1794 tool: request.tool.clone(),
1795 input: request.input.clone(),
1796 effect,
1797 idempotency_key: Some(key.clone()),
1798 // The whole point of the stage: the log says who performed this, so
1799 // a later reader can tell a call salvor witnessed from a call it was
1800 // told about.
1801 performed_by: Some(Performer::Client),
1802 },
1803 );
1804
1805 let log = state.store().read_log(run_id).await.map_err(store_error)?;
1806 if (request.seq as usize) < log.len() {
1807 // An already-recorded position. The derivation is a pure function of
1808 // the position or of the input, never of anything this request could
1809 // vary independently, so an identical re-post re-derives the recorded
1810 // key and can simply be handed it back: the client retries its own call
1811 // under the same key and the provider collapses the duplicate. Compare
1812 // the events rather than the envelopes, because `recorded_at` is this
1813 // store's stamp from the first attempt and would never match a fresh one.
1814 let recorded = &log[request.seq as usize];
1815 if recorded.event == intent.event {
1816 let output = recorded_tool_output(&log, request.seq);
1817 return Ok(Json(intent_body(request.seq, &key, effect, output)));
1818 }
1819 return Err(ApiError::Divergence(format!(
1820 "seq {} already holds a different event; it is not this client-tool intent's position",
1821 request.seq
1822 )));
1823 }
1824
1825 // The same append-guard the generic append and both server-performed steps
1826 // push through: it decides whether this is the legal next event. It runs
1827 // before the claim below, and the order matters: a claim is permanent and
1828 // nothing releases it, so claiming an identity for an intent that then
1829 // turns out to be illegal would strand that key forever over a call that
1830 // was never recorded.
1831 let mut validator = LogValidator::new(log);
1832 validator
1833 .push(intent.clone())
1834 .map_err(|error| ApiError::Divergence(error.to_string()))?;
1835
1836 // A declared key is an identity, not an attempt number, so this is the
1837 // moment to find out whether the call it names has already happened. It
1838 // mirrors `RunCtx::tool_call` exactly: the store's claim is the arbiter,
1839 // asked live, before the write-ahead intent is persisted and before the
1840 // client is told it may perform anything.
1841 //
1842 // Only a Write or an Idempotent call asks. A Read has no effect worth an
1843 // identity, and answering a repeated read from an older call would quietly
1844 // freeze a loop that is polling for a change on purpose.
1845 let identity = (!key_fields.is_empty() && deduplicates(effect)).then_some(CallClaimant {
1846 tool: &request.tool,
1847 idempotency_key: &key,
1848 run_id,
1849 intent_seq: SequenceNumber::new(request.seq),
1850 });
1851 let mut copied = None;
1852 if let Some(claimant) = identity {
1853 match state
1854 .store()
1855 .claim_call(claimant)
1856 .await
1857 .map_err(store_error)?
1858 {
1859 // This position is the one execution of the call.
1860 CallClaim::Claimed => {}
1861 CallClaim::Held(commitment) if commitment.completion_seq.is_some() => {
1862 copied = Some(committed_output(&state, commitment).await?);
1863 }
1864 // Held by a call that has not finished. Within one run the
1865 // append-guard's one-pending-call rule refuses the second intent
1866 // before it ever gets here, so this is the guard for the case the
1867 // rule cannot see, and it refuses rather than guessing: nothing is
1868 // recorded, and the run is drivable again the moment the holder is
1869 // settled.
1870 CallClaim::Held(commitment) => {
1871 return Err(ApiError::Divergence(format!(
1872 "the call `{}` names is already open at seq {} of run {} and has not \
1873 finished; settle that call before opening the same one again",
1874 request.tool,
1875 commitment.intent_seq.get(),
1876 commitment.run_id.as_uuid()
1877 )));
1878 }
1879 }
1880 }
1881
1882 // Write-ahead on both paths. An intent that resolves as a duplicate is
1883 // still an honest record of what this run asked for, which is the rule
1884 // `RunCtx::tool_call` follows for the same case.
1885 state.store().append(&intent).await.map_err(append_error)?;
1886
1887 if let Some((output, origin)) = copied {
1888 // The call already happened, so nothing performs it a second time: the
1889 // completion is written here, correlated to the intent just recorded
1890 // and naming what it copied.
1891 append_tool_completion(&state, run_id, request.seq, &output, Some(origin)).await?;
1892 return Ok(Json(intent_body(request.seq, &key, effect, Some(output))));
1893 }
1894 // A freshly-recorded intent can never already be settled: the append above
1895 // just placed it at the log's new end, with nothing after it yet.
1896 Ok(Json(intent_body(request.seq, &key, effect, None)))
1897}
1898
1899/// Whether a call with this effect carries an identity worth deduplicating on,
1900/// the same rule `RunCtx::tool_call` applies to a tool's declared key.
1901fn deduplicates(effect: Effect) -> bool {
1902 matches!(effect, Effect::Write | Effect::Idempotent)
1903}
1904
1905/// The recorded output of the completion a settled commitment points at, and
1906/// the origin to name on the copy.
1907///
1908/// The output is read back through `read_log`, so the origin run's hash chain
1909/// is verified before a single byte is copied, exactly as the runtime's own
1910/// deduplication reads it.
1911async fn committed_output(
1912 state: &AppState,
1913 commitment: salvor_store::CallCommitment,
1914) -> Result<(Value, DedupOrigin), ApiError> {
1915 let origin_log = state
1916 .store()
1917 .read_log(commitment.run_id)
1918 .await
1919 .map_err(store_error)?;
1920 let output = recorded_tool_output(&origin_log, commitment.intent_seq.get()).ok_or_else(|| {
1921 ApiError::Internal(format!(
1922 "the store says run {} settled this call at seq {}, but that log holds no completion \
1923 there",
1924 commitment.run_id.as_uuid(),
1925 commitment.intent_seq.get()
1926 ))
1927 })?;
1928 Ok((
1929 output,
1930 DedupOrigin {
1931 run_id: commitment.run_id,
1932 seq: commitment.intent_seq,
1933 },
1934 ))
1935}
1936
1937/// The output recorded for the tool intent at `seq`, when its
1938/// `ToolCallCompleted` is already in `log`; `None` while the call is still
1939/// open.
1940///
1941/// The append-guard only ever admits a completion for the same `seq`
1942/// immediately after its intent (see [`append_tool_completion`]), so it is
1943/// enough to check the very next slot.
1944fn recorded_tool_output(log: &[EventEnvelope], seq: u64) -> Option<Value> {
1945 match &log.get(seq as usize + 1)?.event {
1946 Event::ToolCallCompleted {
1947 seq: completed_seq,
1948 output,
1949 ..
1950 } if completed_seq.get() == seq => Some(output.clone()),
1951 _ => None,
1952 }
1953}
1954
1955/// The `200` client-tool-intent body: the position, the DERIVED idempotency key
1956/// the client must perform under, the operator-declared effect it was
1957/// recorded with, whether this position's completion is ALREADY recorded, and
1958/// that completion's output when it is.
1959///
1960/// `settled` exists for a caller re-posting an intent it already believes it
1961/// opened, most pointedly a payments caller checking a write before it acts on
1962/// the response: without it, a retried intent and a fresh one look identical
1963/// (same `200`, same key), and a caller cannot tell "safe to perform" from
1964/// "already done, do not perform it again" without separately reading the log.
1965///
1966/// The recorded `output` rides along on a settled answer, the same way
1967/// [`client_model_intent_body`] carries a recorded response, and for a sharper
1968/// reason here: a call answered from a DECLARED idempotency key is settled the
1969/// instant its intent is opened, without the client having performed anything,
1970/// so this response is the only place the client learns what the call it just
1971/// asked for returned. The key is omitted entirely while the call is open, so
1972/// an unsettled answer is byte for byte what it was before there was an output
1973/// to carry.
1974fn intent_body(seq: u64, idempotency_key: &str, effect: Effect, output: Option<Value>) -> Value {
1975 let mut body = json!({
1976 "seq": seq,
1977 "idempotency_key": idempotency_key,
1978 "effect": effect,
1979 "settled": output.is_some(),
1980 });
1981 if let Some(output) = output {
1982 body.as_object_mut()
1983 .expect("the intent body is a JSON object")
1984 .insert("output".to_owned(), output);
1985 }
1986 body
1987}
1988
1989/// Which of the two shapes a client-tool completion arrived in, after the
1990/// either-or rule has been applied to the request body.
1991enum Reported {
1992 /// The call returned this.
1993 Output(Value),
1994 /// The call produced nothing and failed like this.
1995 Error(ReportedFailure),
1996}
1997
1998/// Records a client-reported failure as the completion for the intent at `seq`.
1999///
2000/// The recorded output is the `__salvor_error` sentinel, built by
2001/// `salvor_runtime::wire`'s own [`encode_failure`], so the bytes are the ones
2002/// the runtime writes when a native tool exhausts its retries. That parity is
2003/// the whole point: a failure is not a new kind of event and not a new run
2004/// state, it is the outcome a completion is allowed to carry, and a log written
2005/// through this endpoint has to mean to a replay exactly what a natively
2006/// recorded one means.
2007///
2008/// # Errors
2009///
2010/// [`ApiError::BadRequest`] for a `kind` that is not one of the three recorded
2011/// layers, and whatever [`append_tool_completion`] reports.
2012async fn record_reported_failure(
2013 state: &AppState,
2014 run_id: RunId,
2015 seq: u64,
2016 failure: ReportedFailure,
2017) -> Result<Json<Value>, ApiError> {
2018 let kind = match failure.kind.as_deref() {
2019 None => ToolFailureKind::Handler,
2020 Some(named) => ToolFailureKind::from_wire(named).ok_or_else(|| {
2021 ApiError::BadRequest(format!(
2022 "`{named}` is not a failure kind; use `invalid_input`, `handler`, or \
2023 `output_serialization`, or omit it for `handler`"
2024 ))
2025 })?,
2026 };
2027 let output = encode_failure(&ToolFailure {
2028 kind,
2029 message: failure.message,
2030 // One attempt. `attempts` counts executions inside salvor's own retry
2031 // loop, and salvor ran no loop over a call it did not dispatch, so a
2032 // number taken from the wire would be the client describing machinery
2033 // that never touched its call.
2034 attempts: 1,
2035 });
2036 append_tool_completion(state, run_id, seq, &output, None).await?;
2037 Ok(Json(json!({ "seq": seq, "completed": true })))
2038}
2039
2040/// `POST /v1/client-runs/{id}/client-tool-completion`: record that a
2041/// client-performed tool call finished.
2042///
2043/// The client ran the call in its own process and is now reporting what
2044/// happened. Salvor did not witness it, so everything this endpoint can check,
2045/// it checks before the report becomes history. Requires the `X-Drive-Token`
2046/// header.
2047///
2048/// # Two shapes, and exactly one of them
2049///
2050/// The body carries `output`, what the call returned, or `error`, what went
2051/// wrong instead when it returned nothing at all. Both, or neither, is a `400`:
2052/// they say opposite things about the same call and this server has no way to
2053/// pick between them.
2054///
2055/// The `error` shape records the same `__salvor_error` sentinel completion the
2056/// runtime records when a NATIVE tool exhausts its retries, through
2057/// `salvor_runtime::wire`'s own encoder, so the bytes match (see
2058/// [`record_reported_failure`]). A failure is not a new event and not a new run
2059/// state: it is an outcome a completion is allowed to carry, so a recorded
2060/// failure SETTLES the call exactly as a native one does, and the run carries on
2061/// with the failure replaying from the log rather than the call happening again.
2062///
2063/// It refuses, recording nothing, when:
2064///
2065/// - the body carries both `output` and `error`, or neither (`400`);
2066/// - the log does not end at a tool intent, or ends at one whose `seq` is not
2067/// the one this request names (`409 divergence`);
2068/// - the pending intent was performed by the SERVER (`403`): a client must not
2069/// close a call salvor made, since salvor holds the real result;
2070/// - the declaration says `trust_completion = false` (`403`), for a reported
2071/// failure as much as for a reported result. "It did not land" is a claim
2072/// about money made by the party that benefits from it being believed, so an
2073/// untrusted write is left dangling for a person either way;
2074/// - the declaration carries no `output_schema` AND the body reports an output
2075/// (`403`): with nothing to check the report against, the completion is
2076/// unfalsifiable, which is exactly what the schema exists to prevent. A
2077/// reported failure carries no value to check and is unaffected;
2078/// - the reported output fails the declared `output_schema` (`400`);
2079/// - a `require_equal` field's reported value differs from the value the intent
2080/// recorded (`403`): the output schema is a shape check and cannot know what
2081/// was authorized, so a client report may not alter a pinned field;
2082/// - a reported `kind` names no recorded failure layer (`400`).
2083///
2084/// The checks run in that order: the either-or rule first, then correlation,
2085/// then the trust refusal before any value is compared, then the output shape,
2086/// then the per-field equality. The `output_schema`, `require_equal`, and
2087/// value-shape checks are skipped on the `error` path, which is the absence of a
2088/// value for them to look at rather than a relaxation of the rules.
2089///
2090/// # Where a refused completion leaves the run, and why nothing else changes
2091///
2092/// A refusal is not a dead end and needed no new state to express. The log still
2093/// ends at the recorded `ToolCallRequested`, and for an `Effect::Write` the pure
2094/// fold in `salvor-replay` ALREADY reports that as
2095/// [`RunStatus::NeedsReconciliation`](salvor_replay::RunStatus), because an
2096/// uncompleted write intent as the log's last word is precisely what that status
2097/// means. `POST /v1/client-runs/{id}/resolve` already exists to settle it by
2098/// hand, once a person has verified externally whether the call landed.
2099///
2100/// So `trust_completion = false` is fully implemented here, at the completion
2101/// boundary, and deliberately NOT in `derive_state`. That fold is a pure
2102/// function of the log with no access to declarations, and it must stay that
2103/// way: a log has to mean the same thing to a replay on another machine that
2104/// has never seen this server's `--client-tool` files. A later reader who goes
2105/// looking for the strict mode in the fold will not find it, and that is the
2106/// design, not an omission.
2107pub async fn client_tool_completion(
2108 State(state): State<AppState>,
2109 Path(run_id_text): Path<String>,
2110 headers: HeaderMap,
2111 body: Bytes,
2112) -> Result<Json<Value>, ApiError> {
2113 let run_id = parse_run_id(&run_id_text)?;
2114 authorize_drive(&state, run_id, &headers)?;
2115
2116 if body.len() > MAX_EVENTS_BODY {
2117 return Err(ApiError::PayloadTooLarge(format!(
2118 "client-tool-completion body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
2119 body.len()
2120 )));
2121 }
2122 let request: ClientToolCompletionRequest = parse_body(&body)?;
2123 let reported = match (request.output, request.error) {
2124 (Some(output), None) => Reported::Output(output),
2125 (None, Some(error)) => Reported::Error(error),
2126 (Some(_), Some(_)) => {
2127 return Err(ApiError::BadRequest(
2128 "a completion carries `output` or `error`, never both: they say opposite things \
2129 about the same call"
2130 .to_owned(),
2131 ));
2132 }
2133 (None, None) => {
2134 return Err(ApiError::BadRequest(
2135 "a completion must carry `output` (what the call returned) or `error` (what went \
2136 wrong instead)"
2137 .to_owned(),
2138 ));
2139 }
2140 };
2141
2142 // A completion settles the log's LAST event, which must be the intent this
2143 // request names. Anything else and the client and the log disagree about
2144 // what is outstanding.
2145 let log = state.store().read_log(run_id).await.map_err(store_error)?;
2146 let pending = log.last().ok_or_else(|| {
2147 ApiError::Divergence(format!(
2148 "run {} has recorded nothing, so it has no client-performed tool call to complete",
2149 run_id.as_uuid()
2150 ))
2151 })?;
2152 let Event::ToolCallRequested {
2153 seq: intent_seq,
2154 tool,
2155 input: intent_input,
2156 performed_by,
2157 ..
2158 } = &pending.event
2159 else {
2160 return Err(ApiError::Divergence(format!(
2161 "run {} does not end at a tool intent, so there is no tool call to complete",
2162 run_id.as_uuid()
2163 )));
2164 };
2165 if intent_seq.get() != request.seq {
2166 return Err(ApiError::Divergence(format!(
2167 "the pending tool intent is at seq {}, not the seq {} this completion names",
2168 intent_seq.get(),
2169 request.seq
2170 )));
2171 }
2172 // A client may close only a call a client made. The server-performed
2173 // tool-step records its own completion from the output it saw, so a client
2174 // completion there would be overwriting a witnessed fact with a claim.
2175 if *performed_by != Some(Performer::Client) {
2176 return Err(ApiError::ClientCompletionRefused(format!(
2177 "the pending tool call at seq {} was performed by this server, not by the client, so \
2178 a client may not record its completion",
2179 request.seq
2180 )));
2181 }
2182 let tool = tool.clone();
2183
2184 let decls = state.client_tools();
2185 let decl = decls.get(&tool).ok_or_else(|| {
2186 ApiError::UnknownTool(format!(
2187 "no client-performed tool named `{tool}` is declared on this server, so the completion \
2188 reported for the intent at seq {} cannot be checked",
2189 request.seq
2190 ))
2191 })?;
2192
2193 if !decl.trust_completion {
2194 return Err(ApiError::ClientCompletionRefused(format!(
2195 "tool `{tool}` is declared with trust_completion = false, so a client may not record \
2196 its own completion for it; verify the call externally, then settle it by hand with \
2197 POST /v1/runs/{}/resolve or `salvor resolve`",
2198 run_id.as_uuid()
2199 )));
2200 }
2201 // A reported failure stops here, on the checks that are about trust rather
2202 // than about a value. There is no output to hold against the declared
2203 // shape, and no field to pin to what was authorized, so the two remaining
2204 // guards have nothing to say: skipping them is the absence of a value, not
2205 // a relaxation of the rules. What IS recorded is byte for byte what the
2206 // runtime records when a native tool exhausts its retries, so a log replays
2207 // identically whichever side the call was performed on.
2208 let output = match reported {
2209 Reported::Output(output) => output,
2210 Reported::Error(failure) => {
2211 return record_reported_failure(&state, run_id, request.seq, failure).await;
2212 }
2213 };
2214
2215 let Some(output_schema) = &decl.output_schema else {
2216 return Err(ApiError::ClientCompletionRefused(format!(
2217 "tool `{tool}` declares no output_schema, so a client-reported completion carries \
2218 nothing this server can check; declare an output_schema for it, or settle the call \
2219 by hand with POST /v1/runs/{}/resolve or `salvor resolve`",
2220 run_id.as_uuid()
2221 )));
2222 };
2223 validate_against_schema(&output, output_schema).map_err(|error| {
2224 ApiError::BadRequest(format!(
2225 "the reported output does not match the declared output_schema for `{tool}`: {error}"
2226 ))
2227 })?;
2228
2229 // The output schema is a shape check and cannot know what was authorized, so
2230 // a report claiming a different amount than the intent recorded passes it. A
2231 // require_equal field closes that gap: the reported value must be JSON-equal
2232 // to the value the intent recorded. The load-time rule guarantees each named
2233 // field is required on both sides, so both values are present to compare.
2234 for field in &decl.require_equal {
2235 let authorized = intent_input.get(field).unwrap_or(&Value::Null);
2236 let claimed = output.get(field).unwrap_or(&Value::Null);
2237 if authorized != claimed {
2238 return Err(ApiError::ClientCompletionRefused(format!(
2239 "tool `{tool}` reported `{field}` as {claimed} for the intent at seq {}, but the \
2240 intent recorded {authorized}; a client report may not alter a require_equal field. \
2241 If the provider genuinely did something different, settle it by hand with POST \
2242 /v1/runs/{}/resolve or `salvor resolve`",
2243 request.seq,
2244 run_id.as_uuid()
2245 )));
2246 }
2247 }
2248
2249 // The completion goes through the same guard and the same helper the
2250 // server-performed tool step records its own completion with, so the two
2251 // surfaces write byte-identical `ToolCallCompleted` events.
2252 append_tool_completion(&state, run_id, request.seq, &output, None).await?;
2253 Ok(Json(json!({
2254 "seq": request.seq,
2255 "completed": true,
2256 })))
2257}
2258
2259/// `POST /v1/client-runs/{id}/client-model-intent`: open a model call the
2260/// CLIENT performs.
2261///
2262/// The counterpart of [`model_step`] for a call this server does not make. The
2263/// client is about to call the provider in its OWN process, with its own key
2264/// and its own model configuration; this endpoint records that it is about to,
2265/// so the intent is in the log before the call happens, exactly as the
2266/// write-ahead rule demands of a call salvor performs itself. Requires the
2267/// `X-Drive-Token` header, like every other driving endpoint.
2268///
2269/// # What salvor is trusting, and what it buys
2270///
2271/// [`model_step`] recomputes `request_hash` from the request body it was handed,
2272/// so the client cannot record a hash that does not match what was sent. Here
2273/// it cannot: the request never reaches this server, because this server is not
2274/// the one sending it. The hash is the client's claim over its own request, and
2275/// the recorded response is the client's claim about what came back, in exactly
2276/// the sense a client-performed tool result is (see [`Performer`]). Salvor did
2277/// not witness the call; it is trusting the report.
2278///
2279/// What the trust buys is the whole point of the feature: a resume replays the
2280/// recorded answer instead of paying the provider for it a second time. The
2281/// claim is also self-punishing rather than dangerous to anyone else, which is
2282/// why it is safe to take: the hash is a key into this run's own log, so a
2283/// client that hashes inconsistently diverges against its own history and
2284/// nobody else's.
2285///
2286/// # Replay, mirroring [`client_tool_intent`] exactly
2287///
2288/// A recorded intent at this position whose `request_hash` matches is a replay:
2289/// nothing is written, and the answer carries the recorded completion when one
2290/// exists, so a middleware can short-circuit without a separate log read. A
2291/// different hash there, a non-model event, or an intent the SERVER performed is
2292/// `409 divergence` and nothing is written. A fresh position goes through the
2293/// same [`LogValidator`] guard every other append on this surface uses.
2294pub async fn client_model_intent(
2295 State(state): State<AppState>,
2296 Path(run_id_text): Path<String>,
2297 headers: HeaderMap,
2298 body: Bytes,
2299) -> Result<Json<Value>, ApiError> {
2300 let run_id = parse_run_id(&run_id_text)?;
2301 let lease = authorize_drive(&state, run_id, &headers)?;
2302
2303 if body.len() > MAX_EVENTS_BODY {
2304 return Err(ApiError::PayloadTooLarge(format!(
2305 "client-model-intent body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
2306 body.len()
2307 )));
2308 }
2309 let request: ClientModelIntentRequest = parse_body(&body)?;
2310
2311 let log = state.store().read_log(run_id).await.map_err(store_error)?;
2312 if (request.seq as usize) < log.len() {
2313 // An already-recorded position. Correlation is on the hash alone, the
2314 // identical rule [`plan_model_step`] and `ReplayCursor::model_call`
2315 // use: the body is informational and a log captured with bodies must
2316 // replay the same as one captured without, so a re-post that omits the
2317 // body it once sent is still the same call.
2318 let recorded = &log[request.seq as usize];
2319 let Event::ModelCallRequested {
2320 request_hash: recorded_hash,
2321 performed_by,
2322 ..
2323 } = &recorded.event
2324 else {
2325 return Err(ApiError::Divergence(format!(
2326 "seq {} already holds a non-model event; it is not this client-model intent's \
2327 position",
2328 request.seq
2329 )));
2330 };
2331 if *performed_by != Some(Performer::Client) {
2332 return Err(ApiError::Divergence(format!(
2333 "the model intent at seq {} was performed by this server, not by the client",
2334 request.seq
2335 )));
2336 }
2337 if recorded_hash != &request.request_hash {
2338 return Err(ApiError::Divergence(format!(
2339 "the model intent at seq {} carries a request hash that differs from the recorded \
2340 one",
2341 request.seq
2342 )));
2343 }
2344 return Ok(Json(client_model_intent_body(
2345 request.seq,
2346 recorded_completion(&log, request.seq),
2347 )));
2348 }
2349
2350 // Recorded only when the run was opened with `record_prompts: true`, the
2351 // same rule and the same `Option::then` shape the server-performed step
2352 // reads off the lease. A body sent with recording off is dropped here and
2353 // never written.
2354 let request_body = lease
2355 .record_prompts
2356 .then_some(request.request_body)
2357 .flatten();
2358 let intent = EventEnvelope::new(
2359 run_id,
2360 SequenceNumber::new(request.seq),
2361 state.now(),
2362 Event::ModelCallRequested {
2363 seq: SequenceNumber::new(request.seq),
2364 request_hash: request.request_hash.clone(),
2365 request_body,
2366 // The whole point of the endpoint: the log says who performed this,
2367 // so a later reader can tell a call salvor witnessed from a call it
2368 // was told about.
2369 performed_by: Some(Performer::Client),
2370 },
2371 );
2372
2373 let mut validator = LogValidator::new(log);
2374 validator
2375 .push(intent.clone())
2376 .map_err(|error| ApiError::Divergence(error.to_string()))?;
2377 state.store().append(&intent).await.map_err(append_error)?;
2378 // A freshly-recorded intent can never already be settled: the append above
2379 // just placed it at the log's new end, with nothing after it yet.
2380 Ok(Json(client_model_intent_body(request.seq, None)))
2381}
2382
2383/// The recorded `(response, usage)` for the model intent at `seq`, when its
2384/// completion is already in `log`.
2385///
2386/// The append-guard only ever admits a completion for the same `seq`
2387/// immediately after its intent, so it is enough to check the very next slot,
2388/// the same way [`intent_is_settled`] checks a tool intent's.
2389fn recorded_completion(log: &[EventEnvelope], seq: u64) -> Option<(&Value, TokenUsage)> {
2390 match &log.get(seq as usize + 1)?.event {
2391 Event::ModelCallCompleted {
2392 seq: completed_seq,
2393 response,
2394 usage,
2395 } if completed_seq.get() == seq => Some((response, *usage)),
2396 _ => None,
2397 }
2398}
2399
2400/// The `200` client-model-intent body: the position, whether this position's
2401/// completion is ALREADY recorded, and, when it is, that completion.
2402///
2403/// `settled` is the same flag [`intent_body`] carries for a tool intent, and it
2404/// is here for a sharper version of the same reason. A middleware re-posting an
2405/// intent it believes it already opened cannot otherwise tell "safe to call the
2406/// provider" from "already called, do not pay for it again", and paying twice
2407/// is precisely what recording the call was for. So the recorded completion
2408/// rides along on a settled answer: the middleware short-circuits on the
2409/// response it already has, with no second request.
2410fn client_model_intent_body(seq: u64, completion: Option<(&Value, TokenUsage)>) -> Value {
2411 match completion {
2412 Some((response, usage)) => json!({
2413 "seq": seq,
2414 "settled": true,
2415 "response": response,
2416 "usage": usage,
2417 }),
2418 None => json!({ "seq": seq, "settled": false }),
2419 }
2420}
2421
2422/// `POST /v1/client-runs/{id}/client-model-completion`: record that a
2423/// client-performed model call finished.
2424///
2425/// The client called the provider in its own process and is now reporting the
2426/// response and what it cost. Requires the `X-Drive-Token` header.
2427///
2428/// It refuses, recording nothing, when:
2429///
2430/// - the log does not end at a model intent, or ends at one whose `seq` is not
2431/// the one this request names (`409 divergence`);
2432/// - the pending intent was performed by the SERVER (`403`): a client must not
2433/// close a call salvor made, since salvor holds the real response.
2434///
2435/// That is the whole list, and it is shorter than [`client_tool_completion`]'s
2436/// on purpose. The tool completion's remaining refusals all come from the
2437/// operator's declaration (`trust_completion`, `output_schema`,
2438/// `require_equal`), and a model call has no such declaration to check against:
2439/// its response shape is the provider's, not an operator's. The response is
2440/// recorded verbatim, as the server-performed step records its own.
2441///
2442/// Once recorded, the completion is byte-identical to a server-performed one
2443/// (it goes through the same [`append_completion`] helper), so the fold treats
2444/// the call exactly the same: pending while open, closed by this event, and its
2445/// tokens counted toward every budget the run is held to.
2446pub async fn client_model_completion(
2447 State(state): State<AppState>,
2448 Path(run_id_text): Path<String>,
2449 headers: HeaderMap,
2450 body: Bytes,
2451) -> Result<Json<Value>, ApiError> {
2452 let run_id = parse_run_id(&run_id_text)?;
2453 authorize_drive(&state, run_id, &headers)?;
2454
2455 if body.len() > MAX_EVENTS_BODY {
2456 return Err(ApiError::PayloadTooLarge(format!(
2457 "client-model-completion body is {} bytes, over the {MAX_EVENTS_BODY}-byte cap",
2458 body.len()
2459 )));
2460 }
2461 let request: ClientModelCompletionRequest = parse_body(&body)?;
2462
2463 // A completion settles the log's LAST event, which must be the intent this
2464 // request names. Anything else and the client and the log disagree about
2465 // what is outstanding.
2466 let log = state.store().read_log(run_id).await.map_err(store_error)?;
2467 let pending = log.last().ok_or_else(|| {
2468 ApiError::Divergence(format!(
2469 "run {} has recorded nothing, so it has no client-performed model call to complete",
2470 run_id.as_uuid()
2471 ))
2472 })?;
2473 let Event::ModelCallRequested {
2474 seq: intent_seq,
2475 performed_by,
2476 ..
2477 } = &pending.event
2478 else {
2479 return Err(ApiError::Divergence(format!(
2480 "run {} does not end at a model intent, so there is no model call to complete",
2481 run_id.as_uuid()
2482 )));
2483 };
2484 if intent_seq.get() != request.seq {
2485 return Err(ApiError::Divergence(format!(
2486 "the pending model intent is at seq {}, not the seq {} this completion names",
2487 intent_seq.get(),
2488 request.seq
2489 )));
2490 }
2491 // A client may close only a call a client made. The server-performed
2492 // model-step records its own completion from the response it saw, so a
2493 // client completion there would be overwriting a witnessed fact with a
2494 // claim.
2495 if *performed_by != Some(Performer::Client) {
2496 return Err(ApiError::ClientCompletionRefused(format!(
2497 "the pending model call at seq {} was performed by this server, not by the client, so \
2498 a client may not record its completion",
2499 request.seq
2500 )));
2501 }
2502
2503 // The same guard and the same helper the server-performed model step
2504 // records its own completion with, so the two surfaces write byte-identical
2505 // `ModelCallCompleted` events.
2506 append_completion(
2507 &state,
2508 run_id,
2509 request.seq,
2510 &request.response,
2511 request.usage,
2512 )
2513 .await?;
2514 Ok(Json(json!({
2515 "seq": request.seq,
2516 "completed": true,
2517 })))
2518}
2519
2520/// Refuses a model or tool event on the generic append: those are recorded
2521/// through the server-performed model-step and tool-step endpoints, or, for a
2522/// call the CLIENT performs in its own process, through the client-tool-intent
2523/// and client-tool-completion endpoints, or the client-model-intent and
2524/// client-model-completion pair. All four kinds stay refused here.
2525///
2526/// A client-performed tool call is possible, in other words; it is just not
2527/// possible by hand-appending an event. That is the same rule the server-
2528/// performed steps live under, and for the same reason: the effect class, the
2529/// input check, and the idempotency key are the server's to decide from an
2530/// operator's declaration, and an event submitted whole would carry the caller's
2531/// answers to all three.
2532///
2533/// A client-performed MODEL call is possible on the same terms, and stays
2534/// refused here for a narrower reason: the endpoints are where `performed_by`
2535/// is stamped, where prompt recording is read off the run's lease rather than
2536/// taken from the request, and where a completion is checked against the intent
2537/// it claims to close. An event submitted whole would carry the caller's
2538/// answers to all three, including the ability to write `performed_by: null` on
2539/// a call salvor never made and pass a claim off as a witnessed fact.
2540fn reject_side_effecting_kind(candidate: &EventEnvelope) -> Result<(), ApiError> {
2541 use salvor_core::Event;
2542 let kind = match &candidate.event {
2543 Event::ModelCallRequested { .. } => "ModelCallRequested",
2544 Event::ModelCallCompleted { .. } => "ModelCallCompleted",
2545 Event::ToolCallRequested { .. } => "ToolCallRequested",
2546 Event::ToolCallCompleted { .. } => "ToolCallCompleted",
2547 _ => return Ok(()),
2548 };
2549 Err(ApiError::UnsupportedEventKind(format!(
2550 "the generic append accepts control and context events only; `{kind}` is recorded through \
2551 the model-step or tool-step endpoint, or, for a call the client performs itself, through \
2552 the client-model or client-tool endpoint pair"
2553 )))
2554}
2555
2556/// Whether `log` leaves the run asleep: the last durable-timer event it holds
2557/// is a `SleepStarted` that no `SleepCompleted` has closed.
2558///
2559/// The pure append-guard is lenient about the pair on purpose, mirroring the
2560/// cursor: a run that is still asleep has recorded only the start, so nothing
2561/// may demand the completion. That leniency leaves one shape it cannot refuse,
2562/// a `SleepCompleted` for a run that was never asleep, and on this surface that
2563/// is a real mistake a driver can make, since here the client hand-appends both
2564/// halves itself. Checking it at the endpoint keeps the pair ordered without
2565/// teaching the shared guard a rule the runtime's own cursor does not enforce.
2566fn is_sleeping(log: &[EventEnvelope]) -> bool {
2567 log.iter()
2568 .rev()
2569 .find_map(|envelope| match &envelope.event {
2570 Event::SleepStarted { .. } => Some(true),
2571 Event::SleepCompleted {} => Some(false),
2572 _ => None,
2573 })
2574 .unwrap_or(false)
2575}
2576
2577/// Whether `log` is a client-driven run's own log, on the log's own evidence:
2578/// the `RunStarted` at its head carries `driven_by: client`.
2579///
2580/// This is the durable half of the answer to "who drives this run". The other
2581/// half is [`AppState::is_client_run`], the in-memory lease registry, which is
2582/// authoritative only for runs this process opened and knows nothing after a
2583/// restart. Every surface that must not become a second writer against a
2584/// client's drive token asks both: [`open`] (to adopt rather than refuse a run
2585/// from an earlier process), [`crate::runs::resume`] (to keep refusing it), and
2586/// the wake sweeper (to keep leaving its timer to its client). Asking only the
2587/// registry would make a restart quietly re-arm this server as a driver of runs
2588/// it does not own.
2589///
2590/// The check itself lives in [`salvor_replay::log_is_client_driven`], the
2591/// pure crate both this server and `salvor-cli`'s `wake` sweep depend on, so
2592/// the two processes that must each leave a client-driven run alone read the
2593/// same marker the same way. This wrapper only narrows visibility to the
2594/// crate, matching the narrower one this module used before the check moved.
2595pub(crate) fn log_is_client_driven(log: &[EventEnvelope]) -> bool {
2596 salvor_replay::log_is_client_driven(log)
2597}
2598
2599/// The per-run lease gate shared by every driving endpoint: the run must be a
2600/// client-driven run this server opened, and the request must carry its current
2601/// drive token in the `X-Drive-Token` header. Returns the lease so the caller
2602/// can read `record_prompts`.
2603fn authorize_drive(
2604 state: &AppState,
2605 run_id: RunId,
2606 headers: &HeaderMap,
2607) -> Result<ClientRunLease, ApiError> {
2608 let lease = state
2609 .client_run(run_id)
2610 .ok_or_else(|| unknown_client_run(run_id))?;
2611 let presented = headers
2612 .get(DRIVE_TOKEN_HEADER)
2613 .and_then(|value| value.to_str().ok());
2614 match presented {
2615 None => Err(ApiError::MissingDriveToken(format!(
2616 "run {} requires a drive token in the `{DRIVE_TOKEN_HEADER}` header",
2617 run_id.as_uuid()
2618 ))),
2619 Some(token) if token != lease.drive_token => Err(ApiError::InvalidDriveToken(format!(
2620 "the presented drive token is not the current lease for run {}",
2621 run_id.as_uuid()
2622 ))),
2623 Some(_) => {
2624 // The driver presented its current token: it is alive. Refresh the
2625 // lease's `last_seen` so the liveness evidence on GET /v1/runs reads
2626 // "attached". This is the whole heartbeat: it rides on the real
2627 // guarded operation, never a separate ping.
2628 state.touch_client_run(run_id);
2629 Ok(lease)
2630 }
2631 }
2632}
2633
2634/// Parses a JSON body into `T`, mapping a decode failure to a `400`.
2635fn parse_body<T: for<'de> Deserialize<'de>>(body: &Bytes) -> Result<T, ApiError> {
2636 serde_json::from_slice(body)
2637 .map_err(|error| ApiError::BadRequest(format!("request body is not valid JSON: {error}")))
2638}
2639
2640/// Parses a run id from its UUID string, mapping a bad id to a `400`.
2641fn parse_run_id(text: &str) -> Result<RunId, ApiError> {
2642 Uuid::parse_str(text).map(RunId::from_uuid).map_err(|_| {
2643 ApiError::BadRequest(format!("`{text}` is not a valid run id (expected a UUID)"))
2644 })
2645}
2646
2647/// Whether a run's log says it is over, so no driver could still be working on
2648/// it and a re-open may take it regardless of any lease left behind.
2649///
2650/// This asks the recorded log, not the lease, because a driver that completed a
2651/// run and then vanished leaves a lease that is still current for the rest of
2652/// the TTL. Refusing a re-open on that would make the last minute of every
2653/// finished run needlessly unopenable, and there is nothing to protect: a
2654/// finished run takes no more appends from anyone.
2655fn run_is_finished(log: &[EventEnvelope]) -> bool {
2656 matches!(
2657 derive_state(log).status,
2658 RunStatus::Completed { .. } | RunStatus::Failed { .. } | RunStatus::Abandoned { .. }
2659 )
2660}
2661
2662/// The refusal for a re-open of a run whose driver still holds a current lease,
2663/// naming how long the hold has left, rounded up to whole seconds by
2664/// [`whole_seconds`] so the number is always a time at which retrying works.
2665fn lease_held(run_id: RunId, remaining: Duration) -> ApiError {
2666 let lapses_in_seconds = whole_seconds(remaining);
2667 ApiError::LeaseHeld {
2668 message: format!(
2669 "another driver holds run {}; its lease lapses in {lapses_in_seconds}s if that \
2670 driver goes quiet, and re-opening works then (or as soon as the run finishes)",
2671 run_id.as_uuid()
2672 ),
2673 lapses_in_seconds,
2674 }
2675}
2676
2677/// A lease duration as the whole seconds the wire carries, for the `lease_held`
2678/// refusal and the heartbeat answer alike.
2679///
2680/// Rounded UP, and never below 1. Rounding down would let a hold with a
2681/// fraction of a second left report `0`, and a caller reading that as "try
2682/// again now" would come straight back into the same refusal; rounding up means
2683/// the number is always a time at which retrying can actually work. The same
2684/// reasoning covers a heartbeat interval: a driver told `0` would beat in a
2685/// tight loop.
2686fn whole_seconds(duration: Duration) -> i64 {
2687 i64::try_from(duration.as_nanos().div_ceil(1_000_000_000))
2688 .unwrap_or(i64::MAX)
2689 .max(1)
2690}
2691
2692/// The not-found error for a run that is not a client-driven run here.
2693fn unknown_client_run(run_id: RunId) -> ApiError {
2694 ApiError::UnknownRun(format!(
2695 "no client-driven run {} on this server; open it first",
2696 run_id.as_uuid()
2697 ))
2698}
2699
2700/// Maps a store read error to a `500`.
2701fn store_error(error: salvor_store::StoreError) -> ApiError {
2702 ApiError::Internal(format!("store: {error}"))
2703}
2704
2705/// Maps a store append error: a position taken out from under a validated batch
2706/// (a lost lease race) is a `409` divergence, anything else a `500`.
2707fn append_error(error: salvor_store::StoreError) -> ApiError {
2708 match error {
2709 salvor_store::StoreError::Conflict { seq, .. } => ApiError::Divergence(format!(
2710 "seq {} was taken by another writer before the append landed",
2711 SequenceNumber::get(seq)
2712 )),
2713 other => ApiError::Internal(format!("store: {other}")),
2714 }
2715}