progit_plugin_sdk/traits/jobs.rs
1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2026 Markus Maiwald
3
4//! Async job execution contract
5//!
6//! Host-managed background jobs that plugins can spawn and monitor.
7//! The plugin never owns the thread — it requests work from the host,
8//! receives progress events, and may cancel jobs it owns.
9//!
10//! This preserves Doctrine 4 (trait firewall): plugins cannot spawn raw
11//! threads, open arbitrary processes, or block the TUI render loop.
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Opaque job identifier. Plugins treat this as an opaque string.
17pub type JobId = String;
18
19/// What the plugin wants the host to run.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct JobSpawnRequest {
22 /// Human label shown in TUI job lists.
23 pub label: String,
24 /// Command argv[0] — must be a binary the host already trusts
25 /// (e.g. `citadel`, not an arbitrary path).
26 pub binary: String,
27 /// Arguments passed to the binary.
28 pub args: Vec<String>,
29 /// Working directory. Empty = repo root.
30 #[serde(default)]
31 pub cwd: Option<String>,
32 /// Environment variables injected for this job.
33 #[serde(default)]
34 pub env: HashMap<String, String>,
35 /// Max runtime before host auto-cancels (seconds). 0 = host default.
36 #[serde(default)]
37 pub timeout_secs: u64,
38}
39
40/// Events the host emits about a job's lifecycle.
41///
42/// Serialised as `{"type": "...", "job_id": "...", "data": {...}}`
43/// so Lua receives ergonomic tables.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "snake_case")]
46pub enum JobEvent {
47 /// Job has started on a host background thread.
48 JobStarted {
49 job_id: JobId,
50 #[serde(flatten)]
51 data: JobStartedData,
52 },
53 /// Incremental progress (0–100 or arbitrary units).
54 JobProgress {
55 job_id: JobId,
56 #[serde(flatten)]
57 data: JobProgressData,
58 },
59 /// One line of stdout/stderr from the job process.
60 JobLogLine {
61 job_id: JobId,
62 #[serde(flatten)]
63 data: JobLogLineData,
64 },
65 /// Raw output chunk (for non-line-oriented binaries).
66 JobOutputChunk {
67 job_id: JobId,
68 #[serde(flatten)]
69 data: JobOutputChunkData,
70 },
71 /// Job was cancelled by user or timeout.
72 JobCancelled {
73 job_id: JobId,
74 #[serde(flatten)]
75 data: JobCancelledData,
76 },
77 /// Job finished successfully.
78 JobCompleted {
79 job_id: JobId,
80 #[serde(flatten)]
81 data: JobCompletedData,
82 },
83 /// Job failed (non-zero exit, panic, or unhandled error).
84 JobFailed {
85 job_id: JobId,
86 #[serde(flatten)]
87 data: JobFailedData,
88 },
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct JobStartedData {
93 pub pid: Option<u32>,
94 pub started_at: String, // ISO 8601
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct JobProgressData {
99 /// 0–100 when known, otherwise arbitrary step counter.
100 pub percent: Option<u8>,
101 /// Human step description (e.g. "Compiling module 3/7").
102 pub step: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct JobLogLineData {
107 pub line: String,
108 /// `stdout` | `stderr` | `system`
109 pub stream: String,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct JobOutputChunkData {
114 pub bytes: Vec<u8>,
115 /// `stdout` | `stderr`
116 pub stream: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct JobCancelledData {
121 pub reason: String,
122 pub cancelled_at: String,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct JobCompletedData {
127 pub exit_code: i32,
128 pub completed_at: String,
129 /// Truncated stdout tail (host decides limit, e.g. last 4 KB).
130 pub output_tail: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct JobFailedData {
135 pub exit_code: Option<i32>,
136 pub error: String,
137 pub failed_at: String,
138}
139
140/// Host-side trait for job orchestration.
141///
142/// The ProGit TUI implements this and injects it into the plugin runtime.
143/// Plugins receive a `Box<dyn JobHost>` (or Lua equivalent) and call
144/// `spawn`, `cancel`, and `status` — never `std::process::Command` directly.
145pub trait JobHost: Send + Sync {
146 /// Spawn a background job. Returns the opaque `JobId`.
147 fn spawn(&self, req: JobSpawnRequest) -> JobId;
148
149 /// Cancel a job the plugin previously spawned.
150 /// Returns true if cancellation was sent; false if job already finished.
151 fn cancel(&self, job_id: &JobId) -> bool;
152
153 /// Poll the current status of a job.
154 fn status(&self, job_id: &JobId) -> Option<JobStatus>;
155
156 /// Drain events for jobs owned by this plugin.
157 /// Non-blocking: returns events accumulated since last call.
158 fn drain_events(&self) -> Vec<JobEvent>;
159}
160
161/// Lightweight status snapshot for UI lists.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub enum JobStatus {
164 Pending,
165 Running {
166 started_at: String,
167 percent: Option<u8>,
168 },
169 Cancelled {
170 reason: String,
171 },
172 Completed {
173 exit_code: i32,
174 },
175 Failed {
176 exit_code: Option<i32>,
177 error: String,
178 },
179}