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
203pub(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
220pub 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
275pub 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}