Skip to main content

cli/
operation_id.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Client-supplied operation-id resolution.
3//!
4//! Commands that advertise `supports_op_id: true` in the command
5//! catalog accept `--op-id <UUID>` or `HEDDLE_OPERATION_ID`. The local
6//! CLI reserves the id before dispatch, records stdout/stderr/exit
7//! status, and replays that recorded result for the same command body.
8//! Reusing the id with different arguments fails with a typed conflict.
9//!
10//! Commands that advertise `persists_op_id: true` may additionally
11//! generate and save an op-id for interrupted retry loops. Current
12//! explicit replay support does not imply generated persistence.
13
14use std::{io::Write, path::PathBuf, process::Command, str::FromStr, sync::Arc};
15
16use anyhow::{Context, Result, anyhow};
17use objects::object::OperationId;
18use repo::{
19    Repository,
20    operation_dedup::{
21        DedupOutcome, OperationDedupStore, hash_request_body, reserve_operation_id_eager,
22    },
23};
24use serde::{Deserialize, Serialize};
25
26use crate::cli::{
27    cli_args::{Cli, OutputMode},
28    commands::RecoveryAdvice,
29};
30
31const LOCAL_OP_ID_CHILD_ENV: &str = "HEDDLE_LOCAL_OP_ID_CHILD";
32
33/// Canonical helper used by every state-changing dispatch arm in
34/// `main.rs`. Validates the `--op-id` format eagerly so a malformed
35/// value fails before the verb starts work.
36///
37/// The `op_id_coverage` build-time test grep-asserts a call to this
38/// function in every state-changing arm — keep the name stable.
39pub fn resolve_operation_id(cli: &Cli) -> Result<Option<OperationId>> {
40    let Some(raw) = cli.op_id.as_deref() else {
41        return Ok(None);
42    };
43    if raw.trim().is_empty() {
44        return Ok(None);
45    }
46    OperationId::from_str(raw)
47        .map(Some)
48        .map_err(|err| anyhow!(RecoveryAdvice::op_id_invalid(raw, err)))
49}
50
51pub fn supports_local_op_id(command_name: &str) -> bool {
52    crate::cli::commands::command_runtime_contract(command_name)
53        .map(|contract| contract.supports_op_id)
54        .unwrap_or(false)
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct LocalOpIdResponse {
59    status_code: i32,
60    stdout: Vec<u8>,
61    stderr: Vec<u8>,
62}
63
64pub fn run_local_idempotency_if_requested(
65    cli: &Cli,
66    command_name: &str,
67    command_supports_op_id: bool,
68) -> Result<bool> {
69    let Some(op_id) = resolve_operation_id(cli)? else {
70        return Ok(false);
71    };
72    if std::env::var_os(LOCAL_OP_ID_CHILD_ENV).is_some() {
73        return Ok(false);
74    }
75    if !command_supports_op_id {
76        return Err(anyhow!(RecoveryAdvice::op_id_unsupported(command_name)));
77    }
78
79    let bootstrap_store = uses_bootstrap_op_id_store(command_name);
80    let normalized_args = normalized_argv_for_op_id();
81    let bootstrap_scope = if bootstrap_store {
82        Some(bootstrap_op_id_scope(cli)?)
83    } else {
84        None
85    };
86    let request_hash = request_hash_for_op_id(
87        &normalized_args,
88        bootstrap_scope
89            .as_ref()
90            .map(|scope| scope.hash_material.as_str()),
91    )?;
92    let repo_for_eager;
93    let store = if bootstrap_store {
94        let scope = bootstrap_scope
95            .as_ref()
96            .expect("bootstrap scope should be present for bootstrap store");
97        repo_for_eager = None;
98        Arc::new(
99            OperationDedupStore::open(bootstrap_op_id_store_dir(scope))
100                .context("open bootstrap op-id dedup store")?,
101        )
102    } else {
103        let repo = cli.open_repo()?;
104        let bootstrap_scope = bootstrap_op_id_scope_for_root(repo.root().to_path_buf())?;
105        let bootstrap_store =
106            OperationDedupStore::open(bootstrap_op_id_store_dir(&bootstrap_scope))
107                .context("open bootstrap op-id dedup store")?;
108        if let Some(existing) = bootstrap_store.metadata_for(op_id, command_name) {
109            return Err(anyhow!(RecoveryAdvice::op_id_conflict(
110                command_name,
111                &bootstrap_scope.label,
112                &normalized_args,
113                request_hash,
114                Some(existing),
115            )));
116        }
117        let store = Arc::new(
118            OperationDedupStore::open(repo.heddle_dir()).context("open op-id dedup store")?,
119        );
120        repo_for_eager = Some(repo);
121        store
122    };
123    let json_mode = json_display_mode(cli);
124
125    let reserve_outcome = if let Some(repo) = repo_for_eager.as_ref() {
126        reserve_operation_id_eager(repo, Arc::clone(&store), op_id, command_name, request_hash)?
127    } else {
128        store.reserve(op_id, command_name, request_hash)?
129    };
130
131    match reserve_outcome {
132        DedupOutcome::Replay { response } => {
133            let replay: LocalOpIdResponse =
134                serde_json::from_slice(&response).context("decode cached op-id response")?;
135            replay_response(
136                &replay,
137                json_mode.map(|mode| OpIdDisplayContext {
138                    op_id: &op_id,
139                    command_name,
140                    status: "replayed",
141                    replayed: true,
142                    child_succeeded: replay.status_code == 0,
143                    mode,
144                }),
145            )?;
146            if replay.status_code != 0 {
147                std::process::exit(replay.status_code);
148            }
149            Ok(true)
150        }
151        DedupOutcome::Conflict => Err(anyhow!(RecoveryAdvice::op_id_conflict(
152            command_name,
153            bootstrap_scope
154                .as_ref()
155                .map(|scope| scope.label.as_str())
156                .unwrap_or("repository-local .heddle"),
157            &normalized_args,
158            request_hash,
159            store.metadata_for(op_id, command_name),
160        ))),
161        DedupOutcome::InFlight => Err(anyhow!(RecoveryAdvice::op_id_in_flight())),
162        DedupOutcome::Reserved => {
163            let output = Command::new(std::env::current_exe()?)
164                .args(std::env::args_os().skip(1))
165                .env(LOCAL_OP_ID_CHILD_ENV, "1")
166                .output();
167            let output = match output {
168                Ok(output) => output,
169                Err(error) => {
170                    let _ = store.cancel(op_id, command_name);
171                    return Err(error).context("run op-id child process");
172                }
173            };
174            let response = LocalOpIdResponse {
175                status_code: output.status.code().unwrap_or(1),
176                stdout: output.stdout,
177                stderr: output.stderr,
178            };
179            let encoded = serde_json::to_vec(&response).context("encode cached op-id response")?;
180            store.record(op_id, command_name, request_hash, encoded)?;
181            replay_response(
182                &response,
183                json_mode.map(|mode| OpIdDisplayContext {
184                    op_id: &op_id,
185                    command_name,
186                    status: "executed",
187                    replayed: false,
188                    child_succeeded: response.status_code == 0,
189                    mode,
190                }),
191            )?;
192            if response.status_code != 0 {
193                std::process::exit(response.status_code);
194            }
195            Ok(true)
196        }
197    }
198}
199
200#[derive(Clone, Copy)]
201struct OpIdDisplayContext<'a> {
202    op_id: &'a OperationId,
203    command_name: &'a str,
204    status: &'a str,
205    replayed: bool,
206    child_succeeded: bool,
207    mode: JsonDisplayMode,
208}
209
210fn replay_response(
211    response: &LocalOpIdResponse,
212    context: Option<OpIdDisplayContext>,
213) -> Result<()> {
214    let stdout = context
215        .map(|context| decorate_json_stream(&response.stdout, context))
216        .transpose()?
217        .unwrap_or_else(|| response.stdout.clone());
218    let stderr = context
219        .map(|context| decorate_json_stream(&response.stderr, context))
220        .transpose()?
221        .unwrap_or_else(|| response.stderr.clone());
222    std::io::stdout().write_all(&stdout)?;
223    std::io::stderr().write_all(&stderr)?;
224    Ok(())
225}
226
227#[derive(Clone, Copy)]
228enum JsonDisplayMode {
229    Full,
230    Compact,
231}
232
233fn json_display_mode(cli: &Cli) -> Option<JsonDisplayMode> {
234    match cli.output_mode() {
235        Some(OutputMode::Json) => Some(JsonDisplayMode::Full),
236        Some(OutputMode::JsonCompact) => Some(JsonDisplayMode::Compact),
237        _ => None,
238    }
239}
240
241fn decorate_json_stream(bytes: &[u8], context: OpIdDisplayContext) -> Result<Vec<u8>> {
242    if bytes.is_empty() {
243        return Ok(Vec::new());
244    }
245    if matches!(context.mode, JsonDisplayMode::Compact) && !context.child_succeeded {
246        return Ok(bytes.to_vec());
247    }
248    let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
249        return Ok(bytes.to_vec());
250    };
251    let Some(object) = value.as_object_mut() else {
252        return Ok(bytes.to_vec());
253    };
254    if matches!(context.mode, JsonDisplayMode::Compact) {
255        crate::cli::commands::compact::retain_compact_surface_fields(&mut value);
256        let mut compact = serde_json::to_vec(&value)?;
257        compact.push(b'\n');
258        return Ok(compact);
259    }
260    let op_id = context.op_id.to_string();
261    object.insert(
262        "op_id".to_string(),
263        serde_json::Value::String(op_id.clone()),
264    );
265    object.insert(
266        "idempotency_status".to_string(),
267        serde_json::Value::String(context.status.to_string()),
268    );
269    object.insert(
270        "replayed".to_string(),
271        serde_json::Value::Bool(context.replayed),
272    );
273    object.insert(
274        "operation_record".to_string(),
275        serde_json::json!({
276            "op_id": op_id,
277            "command": context.command_name,
278            "idempotency_status": context.status,
279            "replayed": context.replayed,
280        }),
281    );
282    let mut decorated = serde_json::to_vec(&value)?;
283    decorated.push(b'\n');
284    Ok(decorated)
285}
286
287fn normalized_argv_for_op_id() -> Vec<String> {
288    let mut normalized = Vec::new();
289    let mut args = std::env::args().skip(1).peekable();
290    while let Some(arg) = args.next() {
291        if arg == "--op-id" {
292            let _ = args.next();
293            continue;
294        }
295        if arg.starts_with("--op-id=") {
296            continue;
297        }
298        normalized.push(arg);
299    }
300    normalized
301}
302
303fn request_hash_for_op_id(
304    normalized_args: &[String],
305    invocation_context: Option<&str>,
306) -> Result<[u8; 32]> {
307    let mut body = normalized_args.join("\0").into_bytes();
308    if let Some(context) = invocation_context {
309        body.extend_from_slice(b"\0context\0");
310        body.extend_from_slice(context.as_bytes());
311    }
312    Ok(hash_request_body(&body))
313}
314
315fn uses_bootstrap_op_id_store(command_name: &str) -> bool {
316    crate::cli::commands::command_uses_bootstrap_op_id_store(command_name)
317}
318
319struct BootstrapOpIdScope {
320    id: String,
321    label: String,
322    hash_material: String,
323}
324
325fn bootstrap_op_id_scope(cli: &Cli) -> Result<BootstrapOpIdScope> {
326    let root = match &cli.command {
327        crate::cli::Commands::Init(args) => args.path.clone().or_else(|| cli.repo.clone()),
328        crate::cli::Commands::Adopt(args) => args.path.clone().or_else(|| cli.repo.clone()),
329        // Clone destinations normally don't exist yet, so feeding the
330        // raw string into the hasher (and relying on the canonicalize
331        // fallback) lets two different cwds with `./repo` collide in
332        // the bootstrap cache. Resolve the destination against the
333        // current directory up front so the scope is cwd-specific.
334        crate::cli::Commands::Clone(args) => {
335            let cwd = std::env::current_dir()
336                .context("resolve current directory for clone op-id scope")?;
337            Some(absolutize_clone_destination(&args.local, &cwd))
338        }
339        _ => cli.repo.clone(),
340    }
341    .unwrap_or(std::env::current_dir().context("resolve current directory for op-id scope")?);
342    bootstrap_op_id_scope_for_root(root)
343}
344
345/// Anchor a (possibly relative, possibly non-existent) clone destination
346/// to an absolute path that's stable across cwds. `canonicalize` only
347/// works on paths that exist, so for the typical `heddle clone ./repo`
348/// case we walk up to the longest existing prefix, canonicalize it, then
349/// re-attach the remainder verbatim.
350fn absolutize_clone_destination(dest: &str, cwd: &std::path::Path) -> PathBuf {
351    let path = PathBuf::from(dest);
352    let absolute = if path.is_absolute() {
353        path
354    } else {
355        cwd.join(path)
356    };
357    let mut existing = absolute.clone();
358    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
359    while !existing.exists() {
360        let Some(name) = existing.file_name().map(|s| s.to_os_string()) else {
361            break;
362        };
363        if !existing.pop() {
364            break;
365        }
366        remainder.push(name);
367    }
368    let mut result = std::fs::canonicalize(&existing).unwrap_or(existing);
369    for component in remainder.into_iter().rev() {
370        result.push(component);
371    }
372    result
373}
374
375fn bootstrap_op_id_scope_for_root(root: PathBuf) -> Result<BootstrapOpIdScope> {
376    use sha2::{Digest, Sha256};
377
378    let canonical = std::fs::canonicalize(&root).unwrap_or(root);
379    let label = canonical.display().to_string();
380    let hash_material = format!("bootstrap-repo-root\0{label}");
381    let mut hasher = Sha256::new();
382    hasher.update(hash_material.as_bytes());
383    let digest = hex::encode(hasher.finalize());
384    Ok(BootstrapOpIdScope {
385        id: digest[..16.min(digest.len())].to_string(),
386        label,
387        hash_material,
388    })
389}
390
391fn bootstrap_op_id_store_dir(scope: &BootstrapOpIdScope) -> PathBuf {
392    repo::identity::heddle_home_dir()
393        .join("bootstrap-op-id")
394        .join(&scope.id)
395}
396
397/// Same as [`resolve_operation_id`] but returns the wire-string form
398/// expected by hosted requests. `""` means "no idempotency for this call".
399pub fn wire(cli: &Cli) -> String {
400    cli.op_id.clone().unwrap_or_default()
401}
402
403/// Per-repo session directory under `<heddle-home>/session/<repo-id>`.
404/// `<repo-id>` is a 16-char SHA-256 of the canonical repo root so two
405/// worktrees of the same repo don't collide.
406fn session_dir_for(repo: &Repository) -> PathBuf {
407    use sha2::{Digest, Sha256};
408    let canonical =
409        std::fs::canonicalize(repo.root()).unwrap_or_else(|_| repo.root().to_path_buf());
410    let mut hasher = Sha256::new();
411    hasher.update(canonical.to_string_lossy().as_bytes());
412    let digest = hex::encode(hasher.finalize());
413    let repo_id = &digest[..16.min(digest.len())];
414    repo::identity::heddle_home_dir()
415        .join("session")
416        .join(repo_id)
417}
418
419fn last_op_id_path(repo: &Repository) -> PathBuf {
420    session_dir_for(repo).join("last_op_id.toml")
421}
422
423#[derive(serde::Serialize, serde::Deserialize, Default)]
424struct LastOpIdFile {
425    /// Per-verb most-recent op-id. Verbs whose command contract does
426    /// not opt into generated op-id persistence are never read or
427    /// written here.
428    #[serde(default)]
429    by_verb: std::collections::BTreeMap<String, String>,
430}
431
432/// Resolve the op-id for a verb that opts into `^C → re-run`
433/// persistence. Order:
434///   1. Caller passed `--op-id` / `HEDDLE_OPERATION_ID` → use it.
435///   2. The command contract opts into op-id persistence AND a recent
436///      saved id exists for that verb → use it (don't persist; we're
437///      reusing).
438///   3. Otherwise generate a fresh id, persist it for the verb, return.
439///
440/// Call [`clear_persisted_op_id`] after the verb completes
441/// successfully so the next run gets a fresh id.
442pub fn resolve_or_persist_for_verb(
443    cli: &Cli,
444    repo: &Repository,
445    verb: &str,
446) -> Result<OperationId> {
447    if let Some(explicit) = resolve_operation_id(cli)? {
448        return Ok(explicit);
449    }
450    if !crate::cli::commands::command_runtime_contract(verb)
451        .map(|contract| contract.persists_op_id)
452        .unwrap_or(false)
453    {
454        return Ok(OperationId::new());
455    }
456    let path = last_op_id_path(repo);
457    if let Ok(bytes) = std::fs::read(&path)
458        && let Ok(decoded) = toml::from_str::<LastOpIdFile>(&String::from_utf8_lossy(&bytes))
459        && let Some(saved) = decoded.by_verb.get(verb)
460        && let Ok(parsed) = OperationId::from_str(saved)
461    {
462        return Ok(parsed);
463    }
464    let fresh = OperationId::new();
465    persist_op_id(&path, verb, &fresh).context("persist last op id")?;
466    Ok(fresh)
467}
468
469/// Drop the persisted op-id for `verb`. Called after a successful
470/// response — releases the slot so the next run gets a fresh id
471/// rather than replaying.
472pub fn clear_persisted_op_id(repo: &Repository, verb: &str) -> Result<()> {
473    let path = last_op_id_path(repo);
474    let mut file: LastOpIdFile = match std::fs::read(&path) {
475        Ok(bytes) => toml::from_str(&String::from_utf8_lossy(&bytes)).unwrap_or_default(),
476        Err(_) => return Ok(()),
477    };
478    if file.by_verb.remove(verb).is_none() {
479        return Ok(());
480    }
481    if file.by_verb.is_empty() {
482        let _ = std::fs::remove_file(&path);
483        return Ok(());
484    }
485    let serialized = toml::to_string(&file).context("serialize last_op_id.toml")?;
486    if let Some(parent) = path.parent() {
487        std::fs::create_dir_all(parent)?;
488    }
489    std::fs::write(&path, serialized)?;
490    Ok(())
491}
492
493fn persist_op_id(path: &std::path::Path, verb: &str, op_id: &OperationId) -> Result<()> {
494    let mut file: LastOpIdFile = match std::fs::read(path) {
495        Ok(bytes) => toml::from_str(&String::from_utf8_lossy(&bytes)).unwrap_or_default(),
496        Err(_) => LastOpIdFile::default(),
497    };
498    file.by_verb.insert(verb.to_string(), op_id.to_string());
499    let serialized = toml::to_string(&file).context("serialize last_op_id.toml")?;
500    if let Some(parent) = path.parent() {
501        std::fs::create_dir_all(parent)?;
502    }
503    std::fs::write(path, serialized)?;
504    Ok(())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn cli_with(op_id: Option<&str>) -> Cli {
512        let mut cli: Cli = clap::Parser::parse_from(["heddle", "status"]);
513        cli.op_id = op_id.map(|s| s.to_string());
514        cli
515    }
516
517    #[test]
518    fn resolve_none_when_unset() {
519        let cli = cli_with(None);
520        assert!(resolve_operation_id(&cli).unwrap().is_none());
521    }
522
523    #[test]
524    fn resolve_parses_uuid() {
525        let id = OperationId::new();
526        let cli = cli_with(Some(&id.to_string()));
527        assert_eq!(resolve_operation_id(&cli).unwrap(), Some(id));
528    }
529
530    #[test]
531    fn resolve_rejects_garbage() {
532        let cli = cli_with(Some("not-a-uuid"));
533        assert!(resolve_operation_id(&cli).is_err());
534    }
535
536    #[test]
537    fn wire_is_empty_when_unset() {
538        let cli = cli_with(None);
539        assert_eq!(wire(&cli), "");
540    }
541
542    #[test]
543    fn wire_returns_string_when_set() {
544        let id = OperationId::new();
545        let cli = cli_with(Some(&id.to_string()));
546        assert_eq!(wire(&cli), id.to_string());
547    }
548
549    /// Two clones with the same `--op-id` and same relative destination
550    /// but launched from different working directories must hash to
551    /// different bootstrap scopes, otherwise the cache replays the
552    /// wrong checkout.
553    #[test]
554    fn clone_destination_resolves_relative_to_supplied_cwd() {
555        let cwd_a = tempfile::tempdir().expect("tempdir a");
556        let cwd_b = tempfile::tempdir().expect("tempdir b");
557
558        let from_a = super::absolutize_clone_destination("./repo", cwd_a.path());
559        let from_b = super::absolutize_clone_destination("./repo", cwd_b.path());
560
561        assert_ne!(
562            from_a, from_b,
563            "same relative dest from different cwds must absolutize differently"
564        );
565        assert!(from_a.ends_with("repo"));
566        assert!(from_b.ends_with("repo"));
567
568        // Same cwd + different relative leaves also stay distinct.
569        let leaf_x = super::absolutize_clone_destination("./repo-x", cwd_a.path());
570        let leaf_y = super::absolutize_clone_destination("./repo-y", cwd_a.path());
571        assert_ne!(leaf_x, leaf_y);
572    }
573
574    #[test]
575    fn clone_destination_preserves_absolute_paths() {
576        let cwd = tempfile::tempdir().expect("tempdir");
577        let resolved =
578            super::absolutize_clone_destination("/var/empty/heddle-clone-target", cwd.path());
579        assert!(resolved.is_absolute());
580        assert!(resolved.ends_with("heddle-clone-target"));
581    }
582
583    /// Bootstrap scopes derived from the absolutized destination must
584    /// also disagree, since the hasher consumes the canonical path
585    /// label. Confirms the fix flows through to `BootstrapOpIdScope`.
586    #[test]
587    fn bootstrap_scope_for_relative_clone_dest_is_cwd_specific() {
588        let cwd_a = tempfile::tempdir().expect("tempdir a");
589        let cwd_b = tempfile::tempdir().expect("tempdir b");
590
591        let dest_a = super::absolutize_clone_destination("./repo", cwd_a.path());
592        let dest_b = super::absolutize_clone_destination("./repo", cwd_b.path());
593        let scope_a = super::bootstrap_op_id_scope_for_root(dest_a).expect("scope a");
594        let scope_b = super::bootstrap_op_id_scope_for_root(dest_b).expect("scope b");
595
596        assert_ne!(scope_a.id, scope_b.id);
597        assert_ne!(scope_a.hash_material, scope_b.hash_material);
598    }
599}