Skip to main content

ralph/contracts/
machine.rs

1//! Versioned machine-contract documents for app/CLI integration.
2//!
3//! Responsibilities:
4//! - Define the stable JSON documents consumed by the macOS app via `ralph machine ...`.
5//! - Centralize machine-only request/response and event envelope types.
6//! - Provide schema-friendly wrappers around queue/config/task/run data.
7//!
8//! Not handled here:
9//! - Command execution or clap wiring.
10//! - Human CLI rendering.
11//! - Queue/task/run business logic.
12//!
13//! Invariants/assumptions:
14//! - Every machine document includes an explicit `version`.
15//! - Breaking wire changes require version bumps.
16//! - Run events are emitted as NDJSON envelopes ordered by occurrence.
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use serde_json::Value as JsonValue;
21
22use super::{
23    BlockingState, CliSpec, Config, GitPublishMode, GitRevertMode, QueueFile, RunnerApprovalMode,
24    Task,
25};
26
27pub const MACHINE_SYSTEM_INFO_VERSION: u32 = 1;
28pub const MACHINE_QUEUE_READ_VERSION: u32 = 1;
29pub const MACHINE_QUEUE_VALIDATE_VERSION: u32 = 1;
30pub const MACHINE_QUEUE_REPAIR_VERSION: u32 = 1;
31pub const MACHINE_QUEUE_UNDO_VERSION: u32 = 1;
32pub const MACHINE_CONFIG_RESOLVE_VERSION: u32 = 3;
33pub const MACHINE_WORKSPACE_OVERVIEW_VERSION: u32 = 1;
34pub const MACHINE_TASK_CREATE_VERSION: u32 = 1;
35pub const MACHINE_TASK_MUTATION_VERSION: u32 = 2;
36pub const MACHINE_GRAPH_READ_VERSION: u32 = 1;
37pub const MACHINE_DASHBOARD_READ_VERSION: u32 = 1;
38pub const MACHINE_DECOMPOSE_VERSION: u32 = 2;
39pub const MACHINE_RUN_EVENT_VERSION: u32 = 3;
40pub const MACHINE_RUN_SUMMARY_VERSION: u32 = 2;
41pub const MACHINE_DOCTOR_REPORT_VERSION: u32 = 2;
42pub const MACHINE_PARALLEL_STATUS_VERSION: u32 = 3;
43pub const MACHINE_CLI_SPEC_VERSION: u32 = 2;
44pub const MACHINE_ERROR_VERSION: u32 = 1;
45pub const MACHINE_QUEUE_UNLOCK_INSPECT_VERSION: u32 = 1;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum MachineErrorCode {
50    CliUnavailable,
51    PermissionDenied,
52    ConfigIncompatible,
53    ParseError,
54    NetworkError,
55    QueueCorrupted,
56    ResourceBusy,
57    VersionMismatch,
58    TaskMutationConflict,
59    Unknown,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct MachineErrorDocument {
65    pub version: u32,
66    pub code: MachineErrorCode,
67    pub message: String,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub detail: Option<String>,
70    pub retryable: bool,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct MachineSystemInfoDocument {
76    pub version: u32,
77    pub cli_version: String,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81#[serde(deny_unknown_fields)]
82pub struct MachineQueuePaths {
83    pub repo_root: String,
84    pub queue_path: String,
85    pub done_path: String,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub project_config_path: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub global_config_path: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
93#[serde(deny_unknown_fields)]
94pub struct MachineQueueReadDocument {
95    pub version: u32,
96    pub paths: MachineQueuePaths,
97    pub active: QueueFile,
98    pub done: QueueFile,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub next_runnable_task_id: Option<String>,
101    #[schemars(schema_with = "json_value_schema")]
102    pub runnability: JsonValue,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106#[serde(deny_unknown_fields)]
107pub struct MachineContinuationAction {
108    pub title: String,
109    pub command: String,
110    pub detail: String,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
114#[serde(deny_unknown_fields)]
115pub struct MachineContinuationSummary {
116    pub headline: String,
117    pub detail: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub blocking: Option<BlockingState>,
120    #[serde(default)]
121    pub next_steps: Vec<MachineContinuationAction>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125#[serde(deny_unknown_fields)]
126pub struct MachineValidationWarning {
127    pub task_id: String,
128    pub message: String,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132#[serde(deny_unknown_fields)]
133pub struct MachineQueueValidateDocument {
134    pub version: u32,
135    pub valid: bool,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub blocking: Option<BlockingState>,
138    #[serde(default)]
139    pub warnings: Vec<MachineValidationWarning>,
140    pub continuation: MachineContinuationSummary,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
144#[serde(deny_unknown_fields)]
145pub struct MachineQueueRepairDocument {
146    pub version: u32,
147    pub dry_run: bool,
148    pub changed: bool,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub blocking: Option<BlockingState>,
151    #[schemars(schema_with = "json_value_schema")]
152    pub report: JsonValue,
153    pub continuation: MachineContinuationSummary,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157#[serde(deny_unknown_fields)]
158pub struct MachineQueueUndoDocument {
159    pub version: u32,
160    pub dry_run: bool,
161    pub restored: bool,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub blocking: Option<BlockingState>,
164    #[schemars(schema_with = "option_json_value_schema")]
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub result: Option<JsonValue>,
167    pub continuation: MachineContinuationSummary,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
171#[serde(rename_all = "snake_case")]
172pub enum MachineQueueUnlockCondition {
173    Clear,
174    Live,
175    Stale,
176    OwnerMissing,
177    OwnerUnreadable,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
181#[serde(deny_unknown_fields)]
182pub struct MachineQueueUnlockInspectDocument {
183    pub version: u32,
184    pub condition: MachineQueueUnlockCondition,
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub blocking: Option<BlockingState>,
187    pub unlock_allowed: bool,
188    pub continuation: MachineContinuationSummary,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
192#[serde(deny_unknown_fields)]
193pub struct MachineResumeDecision {
194    pub status: String,
195    pub scope: String,
196    pub reason: String,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub task_id: Option<String>,
199    pub message: String,
200    pub detail: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct MachineConfigResolveDocument {
206    pub version: u32,
207    pub paths: MachineQueuePaths,
208    pub safety: MachineConfigSafetySummary,
209    pub config: Config,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub resume_preview: Option<MachineResumeDecision>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
215#[serde(deny_unknown_fields)]
216pub struct MachineWorkspaceOverviewDocument {
217    pub version: u32,
218    pub queue: MachineQueueReadDocument,
219    pub config: MachineConfigResolveDocument,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224pub struct MachineConfigSafetySummary {
225    pub repo_trusted: bool,
226    pub dirty_repo: bool,
227    pub git_publish_mode: GitPublishMode,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub approval_mode: Option<RunnerApprovalMode>,
230    pub ci_gate_enabled: bool,
231    pub git_revert_mode: GitRevertMode,
232    pub parallel_configured: bool,
233    pub execution_interactivity: String,
234    pub interactive_approval_supported: bool,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
238#[serde(deny_unknown_fields)]
239pub struct MachineCliSpecDocument {
240    pub version: u32,
241    pub spec: CliSpec,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
245#[serde(deny_unknown_fields)]
246pub struct MachineTaskCreateRequest {
247    pub version: u32,
248    pub title: String,
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub description: Option<String>,
251    pub priority: String,
252    #[serde(default)]
253    pub tags: Vec<String>,
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub scope: Vec<String>,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub template: Option<String>,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub target: Option<String>,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
263#[serde(deny_unknown_fields)]
264pub struct MachineTaskCreateDocument {
265    pub version: u32,
266    pub task: Task,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
270#[serde(deny_unknown_fields)]
271pub struct MachineTaskMutationDocument {
272    pub version: u32,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub blocking: Option<BlockingState>,
275    #[schemars(schema_with = "json_value_schema")]
276    pub report: JsonValue,
277    pub continuation: MachineContinuationSummary,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
281#[serde(deny_unknown_fields)]
282pub struct MachineGraphReadDocument {
283    pub version: u32,
284    #[schemars(schema_with = "json_value_schema")]
285    pub graph: JsonValue,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
289#[serde(deny_unknown_fields)]
290pub struct MachineDashboardReadDocument {
291    pub version: u32,
292    #[schemars(schema_with = "json_value_schema")]
293    pub dashboard: JsonValue,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297#[serde(deny_unknown_fields)]
298pub struct MachineDecomposeDocument {
299    pub version: u32,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub blocking: Option<BlockingState>,
302    #[schemars(schema_with = "json_value_schema")]
303    pub result: JsonValue,
304    pub continuation: MachineContinuationSummary,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
308#[serde(deny_unknown_fields)]
309pub struct MachineDoctorReportDocument {
310    pub version: u32,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub blocking: Option<BlockingState>,
313    #[schemars(schema_with = "json_value_schema")]
314    pub report: JsonValue,
315}
316
317/// Worker counts by lifecycle for `machine run parallel-status` (document v3+).
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
319#[serde(deny_unknown_fields)]
320pub struct MachineParallelLifecycleCounts {
321    pub running: u32,
322    pub integrating: u32,
323    pub completed: u32,
324    pub failed: u32,
325    pub blocked: u32,
326    pub total: u32,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
330#[serde(deny_unknown_fields)]
331pub struct MachineParallelStatusDocument {
332    pub version: u32,
333    pub lifecycle_counts: MachineParallelLifecycleCounts,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub blocking: Option<BlockingState>,
336    pub continuation: MachineContinuationSummary,
337    #[schemars(schema_with = "json_value_schema")]
338    pub status: JsonValue,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
342#[serde(rename_all = "snake_case")]
343pub enum MachineRunEventKind {
344    RunStarted,
345    QueueSnapshot,
346    ConfigResolved,
347    ResumeDecision,
348    TaskSelected,
349    PhaseEntered,
350    PhaseCompleted,
351    RunnerOutput,
352    BlockedStateChanged,
353    BlockedStateCleared,
354    Warning,
355    RunFinished,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
359#[serde(deny_unknown_fields)]
360pub struct MachineRunEventEnvelope {
361    pub version: u32,
362    pub kind: MachineRunEventKind,
363    pub timestamp: String,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    pub run_mode: Option<String>,
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub task_id: Option<String>,
368    #[serde(skip_serializing_if = "Option::is_none")]
369    pub phase: Option<String>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub exit_code: Option<i32>,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub message: Option<String>,
374    #[serde(skip_serializing_if = "Option::is_none")]
375    pub stream: Option<String>,
376    #[schemars(schema_with = "option_json_value_schema")]
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub payload: Option<JsonValue>,
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
382#[serde(deny_unknown_fields)]
383pub struct MachineRunSummaryDocument {
384    pub version: u32,
385    pub task_id: Option<String>,
386    pub exit_code: i32,
387    pub outcome: String,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub blocking: Option<BlockingState>,
390}
391
392fn json_value_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
393    <JsonValue as JsonSchema>::json_schema(generator)
394}
395
396fn option_json_value_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
397    <Option<JsonValue> as JsonSchema>::json_schema(generator)
398}