Skip to main content

codex_runtime/domain/artifact/
models.rs

1use std::path::PathBuf;
2
3use crate::runtime::api::ReasoningEffort;
4use crate::runtime::errors::{RpcError, RuntimeError};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sha2::{Digest, Sha256};
8use thiserror::Error;
9
10#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "camelCase")]
12pub struct ArtifactMeta {
13    pub title: String,
14    pub format: String,
15    pub revision: String,
16    pub runtime_thread_id: Option<String>,
17}
18
19#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "camelCase")]
21pub enum ArtifactTaskKind {
22    DocGenerate,
23    DocEdit,
24    Passthrough,
25}
26
27#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct ArtifactTaskSpec {
30    pub artifact_id: String,
31    pub kind: ArtifactTaskKind,
32    pub user_goal: String,
33    pub current_text: Option<String>,
34    pub constraints: Vec<String>,
35    pub examples: Vec<String>,
36    pub model: Option<String>,
37    pub effort: Option<ReasoningEffort>,
38    pub summary: Option<String>,
39    pub output_schema: Value,
40}
41
42#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
43#[serde(rename_all = "camelCase")]
44pub struct DocPatch {
45    pub format: String,
46    pub expected_revision: String,
47    pub edits: Vec<DocEdit>,
48    pub notes: Option<String>,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "camelCase")]
53pub struct DocEdit {
54    pub start_line: usize,
55    pub end_line: usize,
56    pub replacement: String,
57}
58
59#[derive(Clone, Debug, Error, PartialEq, Eq)]
60pub enum PatchConflict {
61    #[error("expected revision mismatch: expected={expected} actual={actual}")]
62    RevisionMismatch { expected: String, actual: String },
63    #[error(
64        "invalid range at edit#{index}: start={start_line} end={end_line} line_count={line_count}"
65    )]
66    InvalidRange {
67        index: usize,
68        start_line: usize,
69        end_line: usize,
70        line_count: usize,
71    },
72    #[error("edits are not sorted at edit#{index}: prev_start={prev_start} start={start}")]
73    NotSorted {
74        index: usize,
75        prev_start: usize,
76        start: usize,
77    },
78    #[error("edits overlap at edit#{index}: prev_end={prev_end} start={start}")]
79    Overlap {
80        index: usize,
81        prev_end: usize,
82        start: usize,
83    },
84}
85
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct ValidatedPatch {
88    pub edits: Vec<DocEdit>,
89}
90
91#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "camelCase")]
93pub struct SaveMeta {
94    pub task_kind: ArtifactTaskKind,
95    pub thread_id: String,
96    pub turn_id: Option<String>,
97    pub previous_revision: Option<String>,
98    pub next_revision: String,
99}
100
101#[derive(Clone, Debug, Error, PartialEq, Eq)]
102pub enum StoreErr {
103    #[error("artifact not found: {0}")]
104    NotFound(String),
105    #[error("store conflict: expected={expected} actual={actual}")]
106    Conflict { expected: String, actual: String },
107    #[error("io error: {0}")]
108    Io(String),
109    #[error("serialize error: {0}")]
110    Serialize(String),
111}
112
113pub trait ArtifactStore: Send + Sync {
114    fn load_text(&self, artifact_id: &str) -> Result<String, StoreErr>;
115    fn save_text(&self, artifact_id: &str, new_text: &str, meta: SaveMeta) -> Result<(), StoreErr>;
116    fn save_text_and_meta(
117        &self,
118        artifact_id: &str,
119        new_text: &str,
120        save_meta: SaveMeta,
121        meta: ArtifactMeta,
122    ) -> Result<(), StoreErr>;
123    fn get_meta(&self, artifact_id: &str) -> Result<ArtifactMeta, StoreErr>;
124    fn set_meta(&self, artifact_id: &str, meta: ArtifactMeta) -> Result<(), StoreErr>;
125}
126
127#[derive(Clone, Debug)]
128pub struct FsArtifactStore {
129    pub(super) root: PathBuf,
130}
131
132#[derive(Clone, Debug, Error, PartialEq)]
133pub enum DomainError {
134    #[error("conflict: expected={expected} actual={actual}")]
135    Conflict { expected: String, actual: String },
136    #[error(
137        "incompatible plugin contract: expected=v{expected_major}.{expected_minor} actual=v{actual_major}.{actual_minor}"
138    )]
139    IncompatibleContract {
140        expected_major: u16,
141        expected_minor: u16,
142        actual_major: u16,
143        actual_minor: u16,
144    },
145    #[error("validation error: {0}")]
146    Validation(String),
147    #[error("parse error: {0}")]
148    Parse(String),
149    #[error("store error: {0}")]
150    Store(StoreErr),
151    #[error("rpc error: {0}")]
152    Rpc(#[from] RpcError),
153    #[error("runtime error: {0}")]
154    Runtime(#[from] RuntimeError),
155}
156
157impl From<StoreErr> for DomainError {
158    fn from(value: StoreErr) -> Self {
159        match value {
160            StoreErr::Conflict { expected, actual } => Self::Conflict { expected, actual },
161            other => Self::Store(other),
162        }
163    }
164}
165
166#[derive(Clone, Debug, PartialEq, Eq)]
167pub struct ArtifactSession {
168    pub artifact_id: String,
169    pub thread_id: String,
170    pub format: String,
171    pub revision: String,
172}
173
174#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
175#[serde(rename_all = "camelCase")]
176pub enum ArtifactTaskResult {
177    DocGenerate {
178        artifact_id: String,
179        thread_id: String,
180        turn_id: Option<String>,
181        title: String,
182        format: String,
183        revision: String,
184        text: String,
185    },
186    DocEdit {
187        artifact_id: String,
188        thread_id: String,
189        turn_id: Option<String>,
190        format: String,
191        revision: String,
192        text: String,
193        notes: Option<String>,
194    },
195    Passthrough {
196        artifact_id: String,
197        thread_id: String,
198        turn_id: Option<String>,
199        output: Value,
200    },
201}
202
203// --- from patch.rs ---
204
205pub(crate) fn map_patch_conflict(conflict: PatchConflict) -> DomainError {
206    match conflict {
207        PatchConflict::RevisionMismatch { expected, actual } => {
208            DomainError::Conflict { expected, actual }
209        }
210        other => DomainError::Validation(other.to_string()),
211    }
212}
213
214pub fn compute_revision(text: &str) -> String {
215    let mut hasher = Sha256::new();
216    hasher.update(text.as_bytes());
217    format!("sha256:{}", hex::encode(hasher.finalize()))
218}
219
220/// Validate structural patch invariants before any mutation.
221/// `end_line` is exclusive.
222/// Allocation: clones only the validated edit list. Complexity: O(e), e = edit count.
223pub fn validate_doc_patch(text: &str, patch: &DocPatch) -> Result<ValidatedPatch, PatchConflict> {
224    let current_revision = compute_revision(text);
225    if current_revision != patch.expected_revision {
226        return Err(PatchConflict::RevisionMismatch {
227            expected: patch.expected_revision.clone(),
228            actual: current_revision,
229        });
230    }
231
232    let line_count = line_count(text);
233    let mut prev_start = 0usize;
234    let mut prev_end = 0usize;
235
236    for (index, edit) in patch.edits.iter().enumerate() {
237        if edit.start_line == 0
238            || edit.start_line > edit.end_line
239            || edit.end_line > line_count.saturating_add(1)
240        {
241            return Err(PatchConflict::InvalidRange {
242                index,
243                start_line: edit.start_line,
244                end_line: edit.end_line,
245                line_count,
246            });
247        }
248
249        if index > 0 {
250            if edit.start_line < prev_start {
251                return Err(PatchConflict::NotSorted {
252                    index,
253                    prev_start,
254                    start: edit.start_line,
255                });
256            }
257            if edit.start_line < prev_end {
258                return Err(PatchConflict::Overlap {
259                    index,
260                    prev_end,
261                    start: edit.start_line,
262                });
263            }
264        }
265
266        prev_start = edit.start_line;
267        prev_end = edit.end_line;
268    }
269
270    Ok(ValidatedPatch {
271        edits: patch.edits.clone(),
272    })
273}
274
275/// Apply validated edits in reverse order to avoid index drift.
276/// Allocation: line buffer + replacement line buffers. Complexity: O(L + R + e).
277pub fn apply_doc_patch(text: &str, validated_patch: &ValidatedPatch) -> String {
278    let mut lines = split_lines(text);
279
280    for edit in validated_patch.edits.iter().rev() {
281        let start_idx = edit.start_line.saturating_sub(1);
282        let end_idx = edit.end_line.saturating_sub(1);
283        let replacement = split_lines(&edit.replacement);
284        lines.splice(start_idx..end_idx, replacement);
285    }
286
287    lines.concat()
288}
289
290fn line_count(text: &str) -> usize {
291    split_lines(text).len()
292}
293
294fn split_lines(text: &str) -> Vec<String> {
295    if text.is_empty() {
296        return Vec::new();
297    }
298    text.split_inclusive('\n').map(ToOwned::to_owned).collect()
299}