Skip to main content

lex_api/
transform_http.rs

1//! The typed-transform write surface (#837 piece A).
2//!
3//! `POST /v1/transform` lets an agent harness make a *typed* edit — one of
4//! #280's four transforms — straight through the op log, instead of editing
5//! text and running `lex publish`. The same code path backs `lex ws transform`
6//! (an embedded on-disk store, no server): [`apply_transform`] is the single
7//! function both call, so the two cannot diverge.
8//!
9//! # Request
10//!
11//! ```json
12//! {
13//!   "branch": "main",                       // REQUIRED. Never the server's global current branch.
14//!   "intent": {                             // optional; absent => explicitly unattributed (#970)
15//!     "prompt": "why", "model": "provider/name", "session": "s-1", "issue_id": "..."
16//!   },
17//!   "transform": { "kind": "...", ... }     // kind-specific, below
18//! }
19//! ```
20//!
21//! | `kind` | params | ops emitted |
22//! |---|---|---|
23//! | `replace_match_arm` | `from_stage_id`, `match_node`, `arm_index`, `new_body` (CExpr) | `ReplaceMatchArm` |
24//! | `rename_local` | `from_stage_id`, `let_node`, `new_name` | `RenameLocal` |
25//! | `inline_let` | `from_stage_id`, `let_node` | `InlineLet` |
26//! | `extract_function` | `from_stage_id`, `expr_node`, `spec {name, type_params?, params, return_type, effects?}` | `AddFunction` + `ModifyBody` |
27//!
28//! `from_stage_id` must be the stage the branch head currently binds to the
29//! function's signature (stale ids are refused with 409). This is the same
30//! payload `lex repair --apply --transform` takes.
31//!
32//! # Response (200)
33//!
34//! `{ ok, branch, kind, op_id, op_ids, prev_head, new_head, new_stage_id,
35//!    extracted?, intent: { intent_id, session_id, unattributed } }` —
36//! `op_id` is the last op emitted (the new head); `op_ids` lists all of them
37//! (two for `extract_function`); `extracted` is `{ sig_id, stage_id }` of the
38//! new function.
39//!
40//! # Errors
41//!
42//! 400 malformed body / missing `branch` / blank `intent.prompt`; 404 unknown
43//! branch or stage; 409 stale `from_stage_id`, no-op transform, or a function
44//! not on the branch head; 422 the transform did not apply (unknown node,
45//! wrong node kind, ...) or the result fails the write-time gate — with the
46//! diagnostics under `detail.errors`. Every refusal leaves the branch head
47//! unchanged and writes no op and no intent.
48//!
49//! # Intent
50//!
51//! Attribution follows `lex publish` (#970): an absent `intent` is recorded as
52//! an explicitly *unattributed* intent, never as none. A `session` that is not
53//! supplied defaults, like publish's `cli-<pid>-<epoch>`, to a per-process
54//! `http-<pid>-<epoch>` — so **an omitted session makes the OpId
55//! non-reproducible; pin `session` for a deterministic OpId**. The resolved
56//! ids are echoed in the response.
57
58use serde::Deserialize;
59use std::io::Cursor;
60use tiny_http::Response;
61
62use lex_store::{Store, StoreError};
63
64use crate::handlers::{error_response, error_with_detail, json_response, State};
65
66// ---- intent ---------------------------------------------------------------
67
68/// The prompt recorded when a write declares none (#970). Deliberately not a
69/// plausible-looking prompt: it must be impossible to mistake a synthesized
70/// intent for one a caller supplied, and it is a fixed string so it is exactly
71/// matchable (`lex recall --predicate` lists every unattributed op).
72pub const UNATTRIBUTED_PROMPT: &str = "(unattributed: published without --intent-prompt)";
73
74/// `provider/name` → `(provider, name)`. A bare name is attributed to provider
75/// `cli`; `None` → `("cli", "unknown")`. The model ref feeds the content-
76/// addressed IntentId, so the default must be stable, not empty.
77pub fn split_model_ref(m: Option<&str>) -> (String, String) {
78    match m {
79        None => ("cli".to_string(), "unknown".to_string()),
80        Some(s) => match s.split_once('/') {
81            Some((p, n)) if !p.is_empty() && !n.is_empty() => (p.to_string(), n.to_string()),
82            _ => ("cli".to_string(), s.to_string()),
83        },
84    }
85}
86
87/// Build (not record) the Intent for a write from its optional parts. The one
88/// implementation `lex publish`, `lex ws transform` and the HTTP write
89/// endpoints share, so an unattributed write looks the same whichever door it
90/// came through. `default_session` is only called when `session` is `None`.
91pub fn build_intent(
92    prompt: Option<String>,
93    model: Option<String>,
94    session: Option<String>,
95    issue: Option<String>,
96    default_session: impl FnOnce() -> String,
97) -> lex_vcs::Intent {
98    let prompt = prompt.unwrap_or_else(|| UNATTRIBUTED_PROMPT.to_string());
99    let (provider, name) = split_model_ref(model.as_deref());
100    let intent = lex_vcs::Intent::new(
101        prompt,
102        session.unwrap_or_else(default_session),
103        lex_vcs::ModelDescriptor { provider, name, version: None },
104        None,
105    );
106    match issue {
107        Some(id) => intent.with_issue(id),
108        None => intent,
109    }
110}
111
112/// The session id for an HTTP write that gave none: per-process, like
113/// publish's `cli-<pid>-<epoch>`, and for the same reason (a constant default
114/// would collapse every anonymous write into one bogus session).
115pub fn default_http_session() -> String {
116    let started = std::time::SystemTime::now()
117        .duration_since(std::time::UNIX_EPOCH)
118        .map(|d| d.as_secs())
119        .unwrap_or(0);
120    format!("http-{}-{started}", std::process::id())
121}
122
123/// The wire form of an intent: every field optional.
124#[derive(Debug, Default, Clone, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct IntentSpec {
127    pub prompt: Option<String>,
128    /// `provider/name` (bare `name` ⇒ provider `cli`), as `--intent-model`.
129    pub model: Option<String>,
130    pub session: Option<String>,
131    pub issue_id: Option<String>,
132}
133
134impl IntentSpec {
135    /// Resolve to an Intent. A supplied-but-blank prompt is refused: an empty
136    /// string would pass for attribution while saying nothing (#970).
137    pub fn into_intent(
138        self,
139        default_session: impl FnOnce() -> String,
140    ) -> Result<lex_vcs::Intent, String> {
141        if let Some(p) = &self.prompt {
142            if p.trim().is_empty() {
143                return Err("intent.prompt must not be blank (omit it to record the write as unattributed)".into());
144            }
145        }
146        Ok(build_intent(self.prompt, self.model, self.session, self.issue_id, default_session))
147    }
148}
149
150// ---- the transform --------------------------------------------------------
151
152/// A typed transform request; the JSON shape matches `lex repair --transform`.
153#[derive(Debug, Clone, Deserialize)]
154#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
155pub enum TransformSpec {
156    ReplaceMatchArm {
157        from_stage_id: String,
158        match_node: String,
159        arm_index: usize,
160        new_body: lex_ast::CExpr,
161    },
162    RenameLocal {
163        from_stage_id: String,
164        let_node: String,
165        new_name: String,
166    },
167    InlineLet {
168        from_stage_id: String,
169        let_node: String,
170    },
171    ExtractFunction {
172        from_stage_id: String,
173        expr_node: String,
174        spec: ExtractSpec,
175    },
176}
177
178/// Wire form of `lex_ast::ExtractFnSpec`.
179#[derive(Debug, Clone, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct ExtractSpec {
182    pub name: String,
183    #[serde(default)]
184    pub type_params: Vec<String>,
185    pub params: Vec<lex_ast::Param>,
186    pub return_type: lex_ast::TypeExpr,
187    #[serde(default)]
188    pub effects: Vec<lex_ast::Effect>,
189}
190
191impl TransformSpec {
192    pub fn kind(&self) -> &'static str {
193        match self {
194            TransformSpec::ReplaceMatchArm { .. } => "replace_match_arm",
195            TransformSpec::RenameLocal { .. } => "rename_local",
196            TransformSpec::InlineLet { .. } => "inline_let",
197            TransformSpec::ExtractFunction { .. } => "extract_function",
198        }
199    }
200
201    fn source_stage_id(&self) -> &str {
202        match self {
203            TransformSpec::ReplaceMatchArm { from_stage_id, .. }
204            | TransformSpec::RenameLocal { from_stage_id, .. }
205            | TransformSpec::InlineLet { from_stage_id, .. }
206            | TransformSpec::ExtractFunction { from_stage_id, .. } => from_stage_id,
207        }
208    }
209}
210
211/// What a successful transform landed.
212#[derive(Debug, Clone)]
213pub struct Applied {
214    pub branch: String,
215    pub kind: &'static str,
216    pub op_ids: Vec<lex_vcs::OpId>,
217    pub prev_head: Option<lex_vcs::OpId>,
218    pub new_head: Option<lex_vcs::OpId>,
219    /// The rewritten source stage (the head's stage for the source sig).
220    pub new_stage_id: Option<String>,
221    /// `extract_function`: `(sig_id, stage_id)` of the new function.
222    pub extracted: Option<(String, String)>,
223    pub intent_id: String,
224    pub session_id: String,
225    pub unattributed: bool,
226}
227
228impl Applied {
229    /// The JSON both the HTTP response and `lex ws transform` emit.
230    pub fn to_json(&self) -> serde_json::Value {
231        let mut v = serde_json::json!({
232            "ok": true,
233            "branch": self.branch,
234            "kind": self.kind,
235            "op_id": self.op_ids.last(),
236            "op_ids": self.op_ids,
237            "prev_head": self.prev_head,
238            "new_head": self.new_head,
239            "new_stage_id": self.new_stage_id,
240            "intent": {
241                "intent_id": self.intent_id,
242                "session_id": self.session_id,
243                "unattributed": self.unattributed,
244            },
245        });
246        if let Some((sig, stage)) = &self.extracted {
247            v["extracted"] = serde_json::json!({ "sig_id": sig, "stage_id": stage });
248        }
249        v
250    }
251}
252
253/// Apply `spec` to `branch` through the store's gated apply path, attributing
254/// every op to `intent`. The one function `POST /v1/transform` and
255/// `lex ws transform` share.
256///
257/// `branch` is an explicit argument by design — this never consults
258/// [`Store::current_branch`]. On any `Err` the branch head is unchanged and no
259/// op or intent was written (a stage the transform produced may remain in the
260/// content-addressed store; it is unreferenced and idempotent).
261pub fn apply_transform(
262    store: &Store,
263    branch: &str,
264    spec: &TransformSpec,
265    intent: &lex_vcs::Intent,
266) -> Result<Applied, StoreError> {
267    // `list_branches` reads directory entries, so a name is only ever used to
268    // build a path after matching one of them (no traversal through `branch`).
269    if !store.list_branches()?.iter().any(|b| b == branch) {
270        return Err(StoreError::UnknownBranch(branch.to_string()));
271    }
272
273    // The transform must start from the stage the head binds to the sig. The
274    // store only checks the sig is on the head, so an older stage of the same
275    // function would otherwise be transformed and swapped in silently.
276    let from = spec.source_stage_id();
277    let from_ast = store.get_ast(from)?;
278    if let Some(sig) = lex_ast::sig_id(&from_ast) {
279        let head = store.branch_head(branch)?;
280        match head.get(&sig) {
281            Some(cur) if cur != from => {
282                return Err(StoreError::InvalidTransition(format!(
283                    "stale from_stage_id `{from}`: branch `{branch}` head binds sig `{sig}` to `{cur}`"
284                )));
285            }
286            _ => {}
287        }
288    }
289
290    let prev_head = store.get_branch(branch)?.and_then(|b| b.head_op);
291    let node = |s: &str| lex_ast::NodeId(s.to_string());
292    let op_ids = match spec {
293        TransformSpec::ReplaceMatchArm { from_stage_id, match_node, arm_index, new_body } => {
294            vec![store.apply_replace_match_arm_with_intent(
295                branch,
296                from_stage_id,
297                &node(match_node),
298                *arm_index,
299                new_body.clone(),
300                Some(intent),
301            )?]
302        }
303        TransformSpec::RenameLocal { from_stage_id, let_node, new_name } => {
304            vec![store.apply_rename_local_with_intent(
305                branch,
306                from_stage_id,
307                &node(let_node),
308                new_name,
309                Some(intent),
310            )?]
311        }
312        TransformSpec::InlineLet { from_stage_id, let_node } => {
313            vec![store.apply_inline_let_with_intent(
314                branch,
315                from_stage_id,
316                &node(let_node),
317                Some(intent),
318            )?]
319        }
320        TransformSpec::ExtractFunction { from_stage_id, expr_node, spec } => {
321            let (add, modify) = store.apply_extract_function_with_intent(
322                branch,
323                from_stage_id,
324                &node(expr_node),
325                lex_ast::ExtractFnSpec {
326                    name: spec.name.clone(),
327                    type_params: spec.type_params.clone(),
328                    params: spec.params.clone(),
329                    return_type: spec.return_type.clone(),
330                    effects: spec.effects.clone(),
331                },
332                Some(intent),
333            )?;
334            vec![add, modify]
335        }
336    };
337
338    let new_head = store.get_branch(branch)?.and_then(|b| b.head_op);
339    let new_stage_id = lex_ast::sig_id(&from_ast)
340        .and_then(|sig| store.branch_head(branch).ok().and_then(|h| h.get(&sig).cloned()));
341    let extracted = match spec {
342        TransformSpec::ExtractFunction { .. } => {
343            let log = lex_vcs::OpLog::open(store.root())?;
344            match op_ids.first().and_then(|id| log.get(id).ok().flatten()) {
345                Some(rec) => match rec.op.kind {
346                    lex_vcs::OperationKind::AddFunction { sig_id, stage_id, .. } => {
347                        Some((sig_id, stage_id))
348                    }
349                    _ => None,
350                },
351                None => None,
352            }
353        }
354        _ => None,
355    };
356    Ok(Applied {
357        branch: branch.to_string(),
358        kind: spec.kind(),
359        op_ids,
360        prev_head,
361        new_head,
362        new_stage_id,
363        extracted,
364        intent_id: intent.intent_id.clone(),
365        session_id: intent.session_id.clone(),
366        unattributed: intent.prompt == UNATTRIBUTED_PROMPT,
367    })
368}
369
370// ---- HTTP -----------------------------------------------------------------
371
372#[derive(Deserialize)]
373struct TransformReq {
374    branch: Option<String>,
375    #[serde(default)]
376    intent: Option<IntentSpec>,
377    transform: TransformSpec,
378}
379
380/// Map a store error from the transform path to a response. Refusals are
381/// 4xx with the store's own message; everything else falls through to the
382/// shared write-error mapping (409/503 contention, budget, 500).
383pub(crate) fn transform_error_response(err: StoreError) -> Response<Cursor<Vec<u8>>> {
384    match err {
385        StoreError::UnknownBranch(_) | StoreError::UnknownStage(_) | StoreError::UnknownSig(_) => {
386            error_response(404, err.to_string())
387        }
388        StoreError::TransformError(ref e) => error_with_detail(
389            422,
390            format!("transform failed: {e}"),
391            serde_json::json!({ "kind": "transform_error", "message": e.to_string() }),
392        ),
393        StoreError::TypeError(ref errs) => error_with_detail(
394            422,
395            "type errors after transform",
396            serde_json::json!({
397                "kind": "type_errors",
398                "errors": serde_json::to_value(errs).unwrap_or_default(),
399            }),
400        ),
401        StoreError::InvalidTransition(_) => error_response(409, err.to_string()),
402        other => crate::handlers::write_error_response("transform", other),
403    }
404}
405
406/// `POST /v1/transform` — see the module docs.
407pub(crate) fn transform_handler(state: &State, body: &str) -> Response<Cursor<Vec<u8>>> {
408    let req: TransformReq = match serde_json::from_str(body) {
409        Ok(r) => r,
410        Err(e) => return error_response(400, format!("bad request: {e}")),
411    };
412    let Some(branch) = req.branch.filter(|b| !b.is_empty()) else {
413        return error_response(
414            400,
415            "bad request: `branch` is required (the server's current branch is never assumed)",
416        );
417    };
418    let intent = match req.intent.unwrap_or_default().into_intent(default_http_session) {
419        Ok(i) => i,
420        Err(e) => return error_response(400, format!("bad request: {e}")),
421    };
422    let store = state.store.lock().unwrap();
423    match apply_transform(&store, &branch, &req.transform, &intent) {
424        Ok(applied) => json_response(200, &applied.to_json()),
425        Err(e) => transform_error_response(e),
426    }
427}