Skip to main content

presolve_cli/
workspace_commands.rs

1//! L9-F explicit single-project workspace requests over the L7 service API.
2
3#![allow(
4    clippy::missing_errors_doc,
5    clippy::missing_panics_doc,
6    clippy::too_many_lines
7)]
8
9use std::collections::BTreeMap;
10use std::fmt;
11use std::path::Path;
12
13use presolve_compiler::persistent_cache::CacheReportSelector;
14use presolve_compiler::platform::{
15    derive_workspace_id_v1, ContractVersion, RequestedCompilationMode, WorkspaceInput,
16    WorkspaceSnapshot, WorkspaceSource,
17};
18use presolve_compiler::service::{
19    CompileRequest, CompilerServiceHost, CompleteSource, IncrementalReportSelector,
20    WatchCandidateV1, WatchChangeBatchV1, WorkspaceCompileRequestV1,
21    WorkspacePackageCompileRequestV1,
22};
23use presolve_compiler::watch::{
24    ObservedChangeV1, WatchDebounceV1, WatchSessionConfigurationV1, WATCH_CHANGE_BATCH_V1_SCHEMA,
25    WATCH_SESSION_CONFIGURATION_V1_SCHEMA,
26};
27use presolve_compiler::workspace::{
28    WorkspaceManifestV1, WorkspacePackageDescriptorV1, WorkspacePolicyV1,
29    WORKSPACE_MANIFEST_V1_SCHEMA,
30};
31
32use crate::{
33    load_explicit_project_envelope_v1, load_explicit_source_inputs_v1, CliExplicitSourceSpecV1,
34};
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CliWorkspaceResultV1 {
38    pub workspace_id: String,
39    pub status: String,
40    pub manifest_identity: String,
41    pub graph_identity: String,
42    pub plan_identity: String,
43    pub package_snapshot_id: Option<String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct CliWorkspaceErrorV1 {
48    pub code: &'static str,
49    pub message: String,
50}
51
52impl fmt::Display for CliWorkspaceErrorV1 {
53    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
54        write!(output, "{}: {}", self.code, self.message)
55    }
56}
57
58impl std::error::Error for CliWorkspaceErrorV1 {}
59
60fn compiler_contract() -> ContractVersion {
61    ContractVersion::new(format!("presolve-compiler:{}", env!("CARGO_PKG_VERSION")))
62}
63
64/// Executes one complete, explicitly supplied project as a one-package L7
65/// workspace. Package discovery is intentionally not part of this command.
66pub fn run_explicit_workspace_v1(
67    configuration_path: &Path,
68    source_specs: &[CliExplicitSourceSpecV1],
69    verify_clean_equivalence: bool,
70) -> Result<CliWorkspaceResultV1, CliWorkspaceErrorV1> {
71    let project_root = configuration_path.parent().ok_or(CliWorkspaceErrorV1 {
72        code: "L9F001_CONFIGURATION_PATH_INVALID",
73        message: "configuration path must have a parent directory".into(),
74    })?;
75    let envelope =
76        load_explicit_project_envelope_v1(project_root, configuration_path).map_err(|error| {
77            CliWorkspaceErrorV1 {
78                code: error.code,
79                message: error.message,
80            }
81        })?;
82    let sources =
83        load_explicit_source_inputs_v1(&envelope.project_root, source_specs).map_err(|error| {
84            CliWorkspaceErrorV1 {
85                code: error.code,
86                message: error.message,
87            }
88        })?;
89    let contract = compiler_contract();
90    let workspace_id =
91        derive_workspace_id_v1(&envelope.configuration).map_err(|error| CliWorkspaceErrorV1 {
92            code: "L9F002_INVALID_CONFIGURATION",
93            message: error.message,
94        })?;
95    let snapshot = WorkspaceSnapshot::from_input(&WorkspaceInput {
96        configuration: envelope.configuration.clone(),
97        sources: sources
98            .iter()
99            .map(|source| WorkspaceSource {
100                path: source.logical_path.clone(),
101                source: source.content.clone(),
102                language: None,
103            })
104            .collect(),
105        compiler_contract: contract.clone(),
106    })
107    .map_err(|error| CliWorkspaceErrorV1 {
108        code: "L9F003_INVALID_COMPLETE_CANDIDATE",
109        message: error.message,
110    })?;
111    let mut service = CompilerServiceHost::start(envelope.project_root.join(".presolve"), contract)
112        .map_err(|error| CliWorkspaceErrorV1 {
113            code: error.code,
114            message: error.message,
115        })?;
116    let session_id = service
117        .open_session(envelope.configuration.clone(), &workspace_id)
118        .map_err(|error| CliWorkspaceErrorV1 {
119            code: error.code,
120            message: error.message,
121        })?;
122    let manifest = WorkspaceManifestV1 {
123        schema: WORKSPACE_MANIFEST_V1_SCHEMA.into(),
124        version: 1,
125        workspace_id: workspace_id.to_string(),
126        packages: vec![WorkspacePackageDescriptorV1 {
127            package_id: "project".into(),
128            session_id: session_id.clone(),
129            display_name: Some("project".into()),
130            configuration_identity_hint: None,
131            metadata: BTreeMap::new(),
132        }],
133        dependencies: Vec::new(),
134        policy: WorkspacePolicyV1 {
135            failure_mode: "fail_fast".into(),
136            execution_mode: "deterministic_serial".into(),
137            result_detail: "summary".into(),
138        },
139    };
140    let request = CompileRequest {
141        configuration: envelope.configuration,
142        candidate_snapshot: snapshot,
143        sources: sources
144            .into_iter()
145            .map(|source| CompleteSource {
146                path: source.logical_path,
147                source: source.content,
148                language: None,
149            })
150            .collect(),
151        mode: RequestedCompilationMode::Automatic,
152        incremental_report: IncrementalReportSelector::Summary,
153        verify_exact_equivalence: verify_clean_equivalence,
154        cache_report: CacheReportSelector::Summary,
155    };
156    let result = service
157        .compile_workspace_v1(WorkspaceCompileRequestV1 {
158            manifest,
159            packages: vec![WorkspacePackageCompileRequestV1 {
160                package_id: "project".into(),
161                expected_commit_sequence: 0,
162                request,
163            }],
164            operation_id: "presolve-cli-workspace-v1".into(),
165        })
166        .map_err(|error| CliWorkspaceErrorV1 {
167            code: error.code,
168            message: error.message,
169        })?;
170    service
171        .verify_workspace(&result.workspace_id)
172        .map_err(|error| CliWorkspaceErrorV1 {
173            code: error.code,
174            message: error.message,
175        })?;
176    service
177        .close_session(&session_id)
178        .map_err(|error| CliWorkspaceErrorV1 {
179            code: error.code,
180            message: error.message,
181        })?;
182    Ok(CliWorkspaceResultV1 {
183        workspace_id: result.workspace_id,
184        status: result.status,
185        manifest_identity: result.manifest_identity,
186        graph_identity: result.graph_identity,
187        plan_identity: result.plan_identity,
188        package_snapshot_id: result
189            .package_results
190            .into_iter()
191            .next()
192            .and_then(|item| item.snapshot_id),
193    })
194}
195
196/// Sends one fully reconstructed caller-observed candidate to L8 and runs a
197/// single scheduler turn. This intentionally leaves debounce/coalescing to L8.
198pub fn run_explicit_watch_once_v1(
199    configuration_path: &Path,
200    source_specs: &[CliExplicitSourceSpecV1],
201) -> Result<String, CliWorkspaceErrorV1> {
202    let project_root = configuration_path.parent().ok_or(CliWorkspaceErrorV1 {
203        code: "L9F001_CONFIGURATION_PATH_INVALID",
204        message: "configuration path must have a parent directory".into(),
205    })?;
206    let envelope =
207        load_explicit_project_envelope_v1(project_root, configuration_path).map_err(|error| {
208            CliWorkspaceErrorV1 {
209                code: error.code,
210                message: error.message,
211            }
212        })?;
213    let sources =
214        load_explicit_source_inputs_v1(&envelope.project_root, source_specs).map_err(|error| {
215            CliWorkspaceErrorV1 {
216                code: error.code,
217                message: error.message,
218            }
219        })?;
220    let contract = compiler_contract();
221    let workspace_id =
222        derive_workspace_id_v1(&envelope.configuration).map_err(|error| CliWorkspaceErrorV1 {
223            code: "L9F002_INVALID_CONFIGURATION",
224            message: error.message,
225        })?;
226    let snapshot = WorkspaceSnapshot::from_input(&WorkspaceInput {
227        configuration: envelope.configuration.clone(),
228        sources: sources
229            .iter()
230            .map(|source| WorkspaceSource {
231                path: source.logical_path.clone(),
232                source: source.content.clone(),
233                language: None,
234            })
235            .collect(),
236        compiler_contract: contract.clone(),
237    })
238    .map_err(|error| CliWorkspaceErrorV1 {
239        code: "L9F003_INVALID_COMPLETE_CANDIDATE",
240        message: error.message,
241    })?;
242    let mut service = CompilerServiceHost::start(envelope.project_root.join(".presolve"), contract)
243        .map_err(|error| CliWorkspaceErrorV1 {
244            code: error.code,
245            message: error.message,
246        })?;
247    let session_id = service
248        .open_session(envelope.configuration.clone(), &workspace_id)
249        .map_err(|error| CliWorkspaceErrorV1 {
250            code: error.code,
251            message: error.message,
252        })?;
253    let watch_session_id = format!("cli-watch:{workspace_id}");
254    service
255        .create_watch_session(WatchSessionConfigurationV1 {
256            schema: WATCH_SESSION_CONFIGURATION_V1_SCHEMA.into(),
257            version: 1,
258            watch_session_id: watch_session_id.clone(),
259            workspace_id: workspace_id.to_string(),
260            debounce: WatchDebounceV1 {
261                quiet_period_milliseconds: 0,
262                maximum_delay_milliseconds: 0,
263            },
264            supersession_policy: "cancel_obsolete".into(),
265            event_detail: "summary".into(),
266            event_journal_capacity: 16,
267        })
268        .map_err(|error| CliWorkspaceErrorV1 {
269            code: error.code,
270            message: error.message,
271        })?;
272    let manifest = WorkspaceManifestV1 {
273        schema: WORKSPACE_MANIFEST_V1_SCHEMA.into(),
274        version: 1,
275        workspace_id: workspace_id.to_string(),
276        packages: vec![WorkspacePackageDescriptorV1 {
277            package_id: "project".into(),
278            session_id: session_id.clone(),
279            display_name: Some("project".into()),
280            configuration_identity_hint: None,
281            metadata: BTreeMap::new(),
282        }],
283        dependencies: Vec::new(),
284        policy: WorkspacePolicyV1 {
285            failure_mode: "fail_fast".into(),
286            execution_mode: "deterministic_serial".into(),
287            result_detail: "summary".into(),
288        },
289    };
290    service
291        .submit_watch_change_batch(
292            WatchChangeBatchV1 {
293                schema: WATCH_CHANGE_BATCH_V1_SCHEMA.into(),
294                version: 1,
295                watch_session_id: watch_session_id.clone(),
296                sequence: 1,
297                observed_changes: sources
298                    .iter()
299                    .map(|source| ObservedChangeV1 {
300                        kind: "modified".into(),
301                        logical_path: source.logical_path.clone(),
302                        previous_logical_path: None,
303                    })
304                    .collect(),
305                candidate: WatchCandidateV1 {
306                    candidate_id: "initial-complete-candidate".into(),
307                    fingerprint_hint: None,
308                    verify_exact_equivalence: false,
309                    workspace_request: WorkspaceCompileRequestV1 {
310                        manifest,
311                        packages: vec![WorkspacePackageCompileRequestV1 {
312                            package_id: "project".into(),
313                            expected_commit_sequence: 0,
314                            request: CompileRequest {
315                                configuration: envelope.configuration,
316                                candidate_snapshot: snapshot,
317                                sources: sources
318                                    .into_iter()
319                                    .map(|source| CompleteSource {
320                                        path: source.logical_path,
321                                        source: source.content,
322                                        language: None,
323                                    })
324                                    .collect(),
325                                mode: RequestedCompilationMode::Automatic,
326                                incremental_report: IncrementalReportSelector::Summary,
327                                verify_exact_equivalence: false,
328                                cache_report: CacheReportSelector::Summary,
329                            },
330                        }],
331                        operation_id: "presolve-cli-watch-once-v1".into(),
332                    },
333                },
334            },
335            0,
336        )
337        .map_err(|error| CliWorkspaceErrorV1 {
338            code: error.code,
339            message: error.message,
340        })?;
341    let report = service
342        .run_watch_scheduler_turn(&watch_session_id, 0)
343        .map_err(|error| CliWorkspaceErrorV1 {
344            code: error.code,
345            message: error.message,
346        })?
347        .ok_or(CliWorkspaceErrorV1 {
348            code: "L9F004_WATCH_SCHEDULER_DID_NOT_RUN",
349            message: "L8 did not schedule candidate".into(),
350        })?;
351    service
352        .stop_watch_session(&watch_session_id)
353        .map_err(|error| CliWorkspaceErrorV1 {
354            code: error.code,
355            message: error.message,
356        })?;
357    service
358        .close_session(&session_id)
359        .map_err(|error| CliWorkspaceErrorV1 {
360            code: error.code,
361            message: error.message,
362        })?;
363    Ok(report.outcome)
364}
365
366#[cfg(test)]
367mod tests {
368    use std::fs;
369    use std::sync::atomic::{AtomicU64, Ordering};
370
371    use super::*;
372    use crate::parse_explicit_source_spec_v1;
373
374    static NEXT_ROOT: AtomicU64 = AtomicU64::new(1);
375
376    #[test]
377    fn l9f_delegates_one_explicit_package_to_l7_and_verifies_the_result() {
378        let root = std::env::temp_dir().join(format!(
379            "presolve-l9f-{}",
380            NEXT_ROOT.fetch_add(1, Ordering::Relaxed)
381        ));
382        fs::create_dir_all(root.join("src")).unwrap();
383        let config = root.join("presolve.json");
384        fs::write(
385            &config,
386            include_bytes!("../fixtures/configuration/minimum-cli-v1.json"),
387        )
388        .unwrap();
389        fs::write(root.join("src/main.ts"), "export const value = 1;\n").unwrap();
390        let result = run_explicit_workspace_v1(
391            &config,
392            &[parse_explicit_source_spec_v1("src/main.ts=src/main.ts").unwrap()],
393            true,
394        )
395        .unwrap();
396        assert_eq!(result.status, "succeeded");
397        assert!(result.package_snapshot_id.unwrap().starts_with("snapshot:"));
398        fs::remove_dir_all(root).unwrap();
399    }
400}