Skip to main content

orchid/
cli.rs

1use std::path::Path;
2
3use clap::{Args, Parser, Subcommand, ValueEnum};
4use serde_json::{Map, Value};
5
6use crate::core::{emit, json_fail, OrchResult, DEFAULT_STALE_AFTER};
7use crate::orchestration::{
8    self, BlockRequest, CleanupRequest, CloseRequest, CompleteRequest, LeaseRequest, NextRequest,
9    PacketRequest, PacketRoleKind, ReportCheckRequest,
10};
11use crate::paths::root_from_arg;
12use crate::runtime::with_runtime;
13
14#[derive(Parser)]
15#[command(name = "orchid", about = "Task-file orchestration helper")]
16struct Cli {
17    #[arg(long, help = "Repo root; defaults to cwd")]
18    root: Option<String>,
19    #[arg(long, help = "Pretty-print JSON")]
20    pretty: bool,
21    #[command(subcommand)]
22    command: Command,
23}
24
25#[derive(Subcommand)]
26enum Command {
27    Ready(ReadyArgs),
28    Status(StatusArgs),
29    Lease(LeaseArgs),
30    Running,
31    Heartbeat {
32        lease: String,
33    },
34    Stale {
35        #[arg(long, default_value = "30m")]
36        older_than: String,
37    },
38    Release {
39        lease: String,
40        #[arg(long, default_value = "")]
41        reason: String,
42    },
43    Close(CloseArgs),
44    Cleanup(CleanupArgs),
45    Next(NextArgs),
46    #[command(name = "research-path")]
47    ResearchPath(ResearchPathArgs),
48    #[command(name = "research-clean")]
49    ResearchClean {
50        spec: String,
51    },
52    Packet(PacketArgs),
53    #[command(name = "report-check")]
54    ReportCheck {
55        report: String,
56    },
57    #[command(name = "git-status")]
58    GitStatus,
59    #[command(name = "git-touched")]
60    GitTouched {
61        #[arg(long)]
62        lease: String,
63    },
64    #[command(name = "git-stage-plan")]
65    GitStagePlan {
66        #[arg(long)]
67        lease: String,
68    },
69    Complete(CompleteArgs),
70    Block(BlockArgs),
71    Lint,
72}
73
74#[derive(Args)]
75struct ReadyArgs {
76    #[arg(long, action = clap::ArgAction::Append, help = "Restrict ready queue to this spec; repeatable")]
77    spec: Vec<String>,
78    #[arg(
79        long,
80        help = "Select only the first open active spec by numerical prefix"
81    )]
82    all_open: bool,
83    #[arg(long)]
84    explain: bool,
85}
86
87#[derive(Args)]
88struct StatusArgs {
89    #[arg(long, action = clap::ArgAction::Append, help = "Restrict status to this spec; repeatable")]
90    spec: Vec<String>,
91    #[arg(
92        long,
93        help = "Show status for the first open active spec by numerical prefix"
94    )]
95    all_open: bool,
96}
97
98#[derive(Args)]
99#[command(group = clap::ArgGroup::new("mode").args(["serial", "allow_parallel"]).multiple(false))]
100struct LeaseArgs {
101    target: String,
102    task_id: Option<String>,
103    #[arg(long)]
104    owner: String,
105    #[arg(long)]
106    lease_id: Option<String>,
107    #[arg(long, help = "Reject this lease if any other active lease exists")]
108    serial: bool,
109    #[arg(
110        long,
111        help = "Intentionally allow a disjoint lease while other leases are active"
112    )]
113    allow_parallel: bool,
114}
115
116#[derive(Args)]
117struct CloseArgs {
118    #[arg(long)]
119    lease: String,
120    #[arg(long, help = "Close and delete files for an active lease")]
121    force: bool,
122}
123
124#[derive(Args)]
125struct CleanupArgs {
126    #[arg(
127        long,
128        help = "Delete completed/released lease files, packets, and reports"
129    )]
130    completed: bool,
131}
132
133#[derive(Args)]
134struct NextArgs {
135    #[arg(long, action = clap::ArgAction::Append, help = "Restrict next action to this spec; repeatable")]
136    spec: Vec<String>,
137    #[arg(
138        long,
139        help = "Select only the first open active spec by numerical prefix"
140    )]
141    all_open: bool,
142    #[arg(long, default_value = DEFAULT_STALE_AFTER)]
143    older_than: String,
144    #[arg(long)]
145    explain: bool,
146}
147
148#[derive(Args)]
149struct ResearchPathArgs {
150    spec: String,
151    #[arg(long, help = "Create .orchid/spec-research/<spec-id>")]
152    create: bool,
153}
154
155#[derive(Copy, Clone, ValueEnum)]
156enum PacketRole {
157    Worker,
158    Validator,
159    Reviewer,
160    #[value(name = "loop-runner")]
161    LoopRunner,
162}
163
164impl From<PacketRole> for PacketRoleKind {
165    fn from(value: PacketRole) -> Self {
166        match value {
167            PacketRole::Worker => PacketRoleKind::Worker,
168            PacketRole::Validator => PacketRoleKind::Validator,
169            PacketRole::Reviewer => PacketRoleKind::Reviewer,
170            PacketRole::LoopRunner => PacketRoleKind::LoopRunner,
171        }
172    }
173}
174
175#[derive(Args)]
176struct PacketArgs {
177    #[arg(long)]
178    lease: String,
179    #[arg(long, value_enum, default_value = "worker")]
180    role: PacketRole,
181}
182
183#[derive(Args)]
184struct CompleteArgs {
185    #[arg(long)]
186    lease: String,
187    #[arg(long)]
188    verified_by: String,
189    #[arg(long, default_value = "")]
190    implemented_by: String,
191    #[arg(long, default_value = "passed")]
192    verification_status: String,
193    #[arg(long, default_value = "")]
194    report: String,
195    #[arg(long, default_value = "")]
196    commit: String,
197    #[arg(long, default_value = "")]
198    commit_review: String,
199    #[arg(long)]
200    clean_spec_research: bool,
201}
202
203#[derive(Args)]
204struct BlockArgs {
205    target: String,
206    task_id: Option<String>,
207    #[arg(long)]
208    reason: String,
209}
210
211pub fn run() -> i32 {
212    let cli = Cli::parse();
213    let root = match root_from_arg(cli.root.as_deref()) {
214        Ok(root) => root,
215        Err(error) => {
216            let payload = json_fail(&error.message, Some(&error.code));
217            emit(&payload, cli.pretty);
218            return 1;
219        }
220    };
221
222    let result = match run_command(&root, &cli.command) {
223        Ok(payload) => with_runtime(&root, payload),
224        Err(error) => {
225            let mut payload = json_fail(&error.message, Some(&error.code));
226            payload.extend(error.details);
227            with_runtime(&root, payload)
228        }
229    };
230
231    match result {
232        Ok(payload) => {
233            let ok = payload.get("ok").and_then(Value::as_bool).unwrap_or(true);
234            emit(&payload, cli.pretty);
235            if ok {
236                0
237            } else {
238                1
239            }
240        }
241        Err(error) => {
242            let mut payload = json_fail(&error.message, Some(&error.code));
243            payload.extend(error.details);
244            emit(&payload, cli.pretty);
245            1
246        }
247    }
248}
249
250fn run_command(root: &Path, command: &Command) -> OrchResult<Map<String, Value>> {
251    match command {
252        Command::Ready(args) => cmd_ready(root, args),
253        Command::Status(args) => cmd_status(root, args),
254        Command::Lease(args) => cmd_lease(root, args),
255        Command::Running => cmd_running(root),
256        Command::Heartbeat { lease } => cmd_heartbeat(root, lease),
257        Command::Stale { older_than } => cmd_stale(root, older_than),
258        Command::Release { lease, reason } => cmd_release(root, lease, reason),
259        Command::Close(args) => cmd_close(root, args),
260        Command::Cleanup(args) => cmd_cleanup(root, args),
261        Command::Next(args) => cmd_next(root, args),
262        Command::ResearchPath(args) => cmd_research_path(root, args),
263        Command::ResearchClean { spec } => cmd_research_clean(root, spec),
264        Command::Packet(args) => cmd_packet(root, args),
265        Command::ReportCheck { report } => cmd_report_check(root, report),
266        Command::GitStatus => cmd_git_status(root),
267        Command::GitTouched { lease } => cmd_git_touched(root, lease),
268        Command::GitStagePlan { lease } => cmd_git_stage_plan(root, lease),
269        Command::Complete(args) => cmd_complete(root, args),
270        Command::Block(args) => cmd_block(root, args),
271        Command::Lint => cmd_lint(root),
272    }
273}
274
275fn cmd_ready(root: &Path, args: &ReadyArgs) -> OrchResult<Map<String, Value>> {
276    orchestration::ready(
277        root,
278        &orchestration::ReadyRequest {
279            specs: args.spec.clone(),
280            all_open: args.all_open,
281            explain: args.explain,
282        },
283    )
284}
285
286fn cmd_status(root: &Path, args: &StatusArgs) -> OrchResult<Map<String, Value>> {
287    orchestration::status(
288        root,
289        &orchestration::StatusRequest {
290            specs: args.spec.clone(),
291            all_open: args.all_open,
292        },
293    )
294}
295
296fn cmd_lease(root: &Path, args: &LeaseArgs) -> OrchResult<Map<String, Value>> {
297    orchestration::lease(
298        root,
299        &LeaseRequest {
300            target: args.target.clone(),
301            task_id: args.task_id.clone(),
302            owner: args.owner.clone(),
303            lease_id: args.lease_id.clone(),
304            serial: args.serial,
305            allow_parallel: args.allow_parallel,
306        },
307    )
308}
309
310fn cmd_running(root: &Path) -> OrchResult<Map<String, Value>> {
311    orchestration::running(root)
312}
313
314fn cmd_heartbeat(root: &Path, lease_id: &str) -> OrchResult<Map<String, Value>> {
315    orchestration::heartbeat(root, lease_id)
316}
317
318fn cmd_stale(root: &Path, older_than: &str) -> OrchResult<Map<String, Value>> {
319    orchestration::stale(root, older_than)
320}
321
322fn cmd_release(root: &Path, lease_id: &str, reason: &str) -> OrchResult<Map<String, Value>> {
323    orchestration::release(root, lease_id, reason)
324}
325
326fn cmd_close(root: &Path, args: &CloseArgs) -> OrchResult<Map<String, Value>> {
327    orchestration::close(
328        root,
329        &CloseRequest {
330            lease: args.lease.clone(),
331            force: args.force,
332        },
333    )
334}
335
336fn cmd_cleanup(root: &Path, args: &CleanupArgs) -> OrchResult<Map<String, Value>> {
337    orchestration::cleanup(
338        root,
339        &CleanupRequest {
340            completed: args.completed,
341        },
342    )
343}
344
345fn cmd_research_path(root: &Path, args: &ResearchPathArgs) -> OrchResult<Map<String, Value>> {
346    orchestration::research_path(
347        root,
348        &orchestration::ResearchPathRequest {
349            spec: args.spec.clone(),
350            create: args.create,
351        },
352    )
353}
354
355fn cmd_research_clean(root: &Path, spec: &str) -> OrchResult<Map<String, Value>> {
356    orchestration::research_clean(root, spec)
357}
358
359fn cmd_next(root: &Path, args: &NextArgs) -> OrchResult<Map<String, Value>> {
360    orchestration::next(
361        root,
362        &NextRequest {
363            specs: args.spec.clone(),
364            all_open: args.all_open,
365            older_than: args.older_than.clone(),
366            explain: args.explain,
367        },
368    )
369}
370
371fn cmd_packet(root: &Path, args: &PacketArgs) -> OrchResult<Map<String, Value>> {
372    orchestration::packet(
373        root,
374        &PacketRequest {
375            lease: args.lease.clone(),
376            role: args.role.into(),
377        },
378    )
379}
380
381fn cmd_report_check(root: &Path, report: &str) -> OrchResult<Map<String, Value>> {
382    orchestration::report_check(
383        root,
384        &ReportCheckRequest {
385            report: report.to_string(),
386        },
387    )
388}
389
390fn cmd_git_status(root: &Path) -> OrchResult<Map<String, Value>> {
391    orchestration::git_status(root)
392}
393
394fn cmd_git_touched(root: &Path, lease_id: &str) -> OrchResult<Map<String, Value>> {
395    orchestration::git_touched(root, lease_id)
396}
397
398fn cmd_git_stage_plan(root: &Path, lease_id: &str) -> OrchResult<Map<String, Value>> {
399    orchestration::git_stage_plan(root, lease_id)
400}
401
402fn cmd_complete(root: &Path, args: &CompleteArgs) -> OrchResult<Map<String, Value>> {
403    orchestration::complete(
404        root,
405        &CompleteRequest {
406            lease: args.lease.clone(),
407            verified_by: args.verified_by.clone(),
408            implemented_by: args.implemented_by.clone(),
409            verification_status: args.verification_status.clone(),
410            report: args.report.clone(),
411            commit: args.commit.clone(),
412            commit_review: args.commit_review.clone(),
413            clean_spec_research: args.clean_spec_research,
414        },
415    )
416}
417
418fn cmd_block(root: &Path, args: &BlockArgs) -> OrchResult<Map<String, Value>> {
419    orchestration::block(
420        root,
421        &BlockRequest {
422            target: args.target.clone(),
423            task_id: args.task_id.clone(),
424            reason: args.reason.clone(),
425        },
426    )
427}
428
429fn cmd_lint(root: &Path) -> OrchResult<Map<String, Value>> {
430    orchestration::lint(root)
431}