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}