Skip to main content

ao_core/
traits.rs

1use crate::{
2    config::ProjectConfig,
3    error::AoError,
4    error::Result,
5    prompt_builder,
6    scm::{
7        AutomatedComment, CheckRun, CiStatus, CreateIssueInput, Issue, IssueFilters, IssueUpdate,
8        MergeMethod, MergeReadiness, PrState, PrSummary, PullRequest, Review, ReviewComment,
9        ReviewDecision, ScmWebhookEvent, ScmWebhookRequest, ScmWebhookVerificationResult,
10    },
11    scm_transitions::ScmObservation,
12    types::{ActivityState, CostEstimate, Session, WorkspaceCreateConfig},
13};
14use async_trait::async_trait;
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18/// How an agent process is executed (tmux, raw process, docker, ...).
19///
20/// The runtime returns an opaque `handle` string that the caller stores in
21/// `Session::runtime_handle` and passes back to other methods.
22#[async_trait]
23pub trait Runtime: Send + Sync {
24    /// Spawn a new isolated execution context (e.g. tmux session) and run the
25    /// given launch command in it. `launch_command` is a single shell string
26    /// — the runtime is responsible for any escaping/wrapping it needs.
27    async fn create(
28        &self,
29        session_id: &str,
30        cwd: &Path,
31        launch_command: &str,
32        env: &[(String, String)],
33    ) -> Result<String>;
34
35    async fn send_message(&self, handle: &str, msg: &str) -> Result<()>;
36    async fn is_alive(&self, handle: &str) -> Result<bool>;
37    async fn destroy(&self, handle: &str) -> Result<()>;
38}
39
40/// How a session's working directory is materialized (git worktree, full clone, ...).
41#[async_trait]
42pub trait Workspace: Send + Sync {
43    /// Create an isolated copy of the repo on a new branch, returning its path.
44    async fn create(&self, cfg: &WorkspaceCreateConfig) -> Result<PathBuf>;
45    async fn destroy(&self, workspace_path: &Path) -> Result<()>;
46
47    /// Report whether a previously-created workspace is still usable at
48    /// `workspace_path`. Session restore uses this to decide whether the
49    /// session can be brought back up or whether the user has to spawn a
50    /// fresh one.
51    ///
52    /// The default impl treats any directory that exists on disk as
53    /// usable — good enough for plugins that don't have backend-specific
54    /// validation. Plugins backed by git (worktree / clone) override this
55    /// to also verify the directory is still a working tree (catches the
56    /// case where someone `rm -rf`'d `.git` or the repo was corrupted).
57    async fn exists(&self, workspace_path: &Path) -> Result<bool> {
58        Ok(workspace_path.exists())
59    }
60}
61
62/// A specific AI coding tool (Claude Code, Codex, Aider, Cursor, ...).
63///
64/// Mostly a metadata provider (launch command, env, prompt), plus one async
65/// hook — `detect_activity` — which the lifecycle loop polls to learn what
66/// the underlying agent process is currently doing. The TS reference does
67/// this by tailing a JSONL log file the agent writes; Slice 1 Phase C's
68/// stub just returns `Ready` so the polling loop has something to drive.
69#[async_trait]
70pub trait Agent: Send + Sync {
71    /// Single shell string the runtime will run inside its execution context.
72    fn launch_command(&self, session: &Session) -> String;
73    fn environment(&self, session: &Session) -> Vec<(String, String)>;
74    /// First prompt to deliver after the process is up.
75    fn initial_prompt(&self, session: &Session) -> String;
76
77    /// System rules / workflow guidance that must be prepended to the user
78    /// prompt by the caller.
79    ///
80    /// Agents that support a dedicated system-prompt CLI flag (e.g.
81    /// Claude Code's `--append-system-prompt`) inject rules at launch
82    /// time via [`launch_command`](Self::launch_command) and return
83    /// `None` here. Agents without such a flag (e.g. Cursor) return
84    /// `Some(rules)` so callers composing a richer prompt via
85    /// [`build_prompt`](crate::prompt_builder::build_prompt) can prepend
86    /// them before delivery.
87    ///
88    /// Default: `None` — no system prompt to inject separately.
89    fn system_prompt(&self) -> Option<String> {
90        None
91    }
92
93    /// Inspect whatever evidence this agent leaves behind (log files,
94    /// terminal scrollback, pid probes, ...) and report its current
95    /// activity state. Called once per lifecycle tick.
96    ///
97    /// The default impl consults `{workspace}/.ao/activity.jsonl` via
98    /// `activity_log::detect_activity_from_log` and surfaces:
99    /// - `Exited` when the last entry is terminal (no staleness
100    ///   downgrade — exit is a one-way signal).
101    /// - `WaitingInput` / `Blocked` when the last entry is actionable
102    ///   and fresh (within `ACTIVITY_INPUT_STALENESS_SECS`).
103    ///
104    /// Falls back to `Ready` when there's no workspace, no log, or the
105    /// log only carries noisy signals (`Active` / `Ready` / `Idle` /
106    /// stale actionable). Plugins with richer native detection (JSONL
107    /// tailing, git-index mtime, ...) override this entirely.
108    async fn detect_activity(&self, session: &Session) -> Result<ActivityState> {
109        if let Some(ref ws) = session.workspace_path {
110            if let Some(state) = crate::activity_log::detect_activity_from_log(ws) {
111                return Ok(state);
112            }
113        }
114        Ok(ActivityState::Ready)
115    }
116
117    /// Poll current aggregated token usage / cost from the agent's logs.
118    ///
119    /// Called by the lifecycle loop when a session's status changes (not
120    /// every tick). The default impl consults
121    /// `{workspace}/.ao/usage.jsonl` via `cost_log::parse_usage_jsonl`
122    /// and returns the aggregate when entries exist. Returns `None`
123    /// when cost tracking is unavailable — either because there's no
124    /// workspace, the file is missing, or it aggregates to zero tokens.
125    /// Plugins with native cost sources (e.g. `agent-claude-code`
126    /// reading `~/.claude/projects/**`) override this.
127    async fn cost_estimate(&self, session: &Session) -> Result<Option<CostEstimate>> {
128        let Some(ref ws) = session.workspace_path else {
129            return Ok(None);
130        };
131        let ws = ws.clone();
132        let estimate = tokio::task::spawn_blocking(move || crate::cost_log::parse_usage_jsonl(&ws))
133            .await
134            .unwrap_or_else(|e| {
135                tracing::warn!("cost_estimate task failed: {e}");
136                None
137            });
138        Ok(estimate)
139    }
140}
141
142/// Source-code-management plugin — PR lifecycle, CI, reviews.
143///
144/// Slice 2's richest plugin slot. Mirrors the TS `SCM` interface in
145/// `packages/core/src/types.ts` (line ~577), trimmed to the surface the
146/// reaction engine actually needs:
147///
148/// - PR discovery (`detect_pr`) is called once per session per tick.
149/// - CI + review summaries drive status transitions inside
150///   `LifecycleManager::poll_one` (e.g. `working → ci_failed`).
151/// - `pending_comments` feeds the `changes-requested` reaction.
152/// - `mergeability` + `merge` implement the `approved-and-green` flow.
153///
154/// Methods on this trait come in two tiers:
155///
156/// - **Required** — the reaction loop calls these every tick, so every SCM
157///   plugin has to implement them.
158/// - **Optional** — webhook verification/parsing, PR resolve/close/assign/
159///   checkout, bot-comment fetch, PR summary. Each has a default
160///   implementation that either returns an "unsupported" `AoError::Scm`
161///   (for writes) or an empty value (for reads), mirroring the TS
162///   interface's `?:` optional methods. Plugins opt in as their backend
163///   supports the capability; `scm-github` implements all of them.
164#[async_trait]
165pub trait Scm: Send + Sync {
166    /// Human-readable plugin name for logs and CLI output (`"github"`).
167    fn name(&self) -> &str;
168
169    /// Look up the open PR for a session, usually by branch name.
170    /// `None` means "no PR yet" — the lifecycle loop stays in `working`
171    /// until one appears.
172    async fn detect_pr(&self, session: &Session) -> Result<Option<PullRequest>>;
173
174    /// Current open/merged/closed state of the PR.
175    async fn pr_state(&self, pr: &PullRequest) -> Result<PrState>;
176
177    /// Individual CI check results. Used by the reaction engine to
178    /// format a useful `ci-failed` message with which checks broke.
179    async fn ci_checks(&self, pr: &PullRequest) -> Result<Vec<CheckRun>>;
180
181    /// Rolled-up CI status (pending/passing/failing/none).
182    async fn ci_status(&self, pr: &PullRequest) -> Result<CiStatus>;
183
184    /// All reviews on the PR (human + bot).
185    async fn reviews(&self, pr: &PullRequest) -> Result<Vec<Review>>;
186
187    /// Overall review decision, as GitHub shows it on the PR header.
188    async fn review_decision(&self, pr: &PullRequest) -> Result<ReviewDecision>;
189
190    /// Unresolved review comments — forwarded verbatim to the agent by
191    /// the `changes-requested` reaction.
192    async fn pending_comments(&self, pr: &PullRequest) -> Result<Vec<ReviewComment>>;
193
194    /// Can the PR be merged right now, and if not, why?
195    async fn mergeability(&self, pr: &PullRequest) -> Result<MergeReadiness>;
196
197    /// Merge the PR. Called by the `approved-and-green` reaction and by
198    /// `ao-rs merge <id>`. `None` lets the plugin pick its default method.
199    async fn merge(&self, pr: &PullRequest, method: Option<MergeMethod>) -> Result<()>;
200
201    // --- Optional methods (default no-op / unsupported) -------------------
202    //
203    // These map to TS `SCM?.method` optional members. Default impls let
204    // non-GitHub plugins (e.g. `scm-gitlab`) compile against the enriched
205    // trait without immediately implementing every method. Callers that
206    // *rely* on a method must handle the "unsupported" error rather than
207    // assuming universal support.
208
209    /// Verify an inbound webhook delivery (HMAC signature, headers, body
210    /// size). Default returns `ok: false` with an "unsupported" reason so
211    /// a plugin that hasn't opted in can't be mistaken for a verified
212    /// pass-through.
213    async fn verify_webhook(
214        &self,
215        _request: &ScmWebhookRequest,
216        _project: &ProjectConfig,
217    ) -> Result<ScmWebhookVerificationResult> {
218        Ok(ScmWebhookVerificationResult {
219            ok: false,
220            reason: Some("scm plugin does not support webhook verification".into()),
221            ..Default::default()
222        })
223    }
224
225    /// Parse a webhook delivery into a normalised event. `None` means the
226    /// payload was recognised but carries no actionable data for the
227    /// reaction engine (e.g. a `ping` event). Default returns `None`.
228    async fn parse_webhook(
229        &self,
230        _request: &ScmWebhookRequest,
231        _project: &ProjectConfig,
232    ) -> Result<Option<ScmWebhookEvent>> {
233        Ok(None)
234    }
235
236    /// Resolve a PR reference (number like `"42"`, or a full URL) to a
237    /// canonical `PullRequest`. `detect_pr` is branch-based; this one
238    /// answers "give me the PR for this number/URL".
239    async fn resolve_pr(&self, _reference: &str, _project: &ProjectConfig) -> Result<PullRequest> {
240        Err(AoError::Scm(
241            "scm plugin does not support PR resolution".into(),
242        ))
243    }
244
245    /// Assign the PR to the authenticated user. Used by `ao-rs claim-pr`
246    /// so the human picking up a session also owns the PR in GitHub's UI.
247    async fn assign_pr_to_current_user(&self, _pr: &PullRequest) -> Result<()> {
248        Err(AoError::Scm(
249            "scm plugin does not support PR assignment".into(),
250        ))
251    }
252
253    /// Check out `pr.branch` into `workspace_path`. Returns `true` when the
254    /// branch changed, `false` when the workspace was already on the right
255    /// branch. Implementations must refuse to switch if the worktree has
256    /// uncommitted changes — the caller's work is never worth silently
257    /// trashing.
258    async fn checkout_pr(&self, _pr: &PullRequest, _workspace_path: &Path) -> Result<bool> {
259        Err(AoError::Scm(
260            "scm plugin does not support PR checkout".into(),
261        ))
262    }
263
264    /// Top-line PR stats (state + title + additions + deletions) in a
265    /// single round-trip. Cheaper than calling `pr_state` + a diff query
266    /// when all you need is a dashboard row.
267    async fn pr_summary(&self, _pr: &PullRequest) -> Result<PrSummary> {
268        Err(AoError::Scm(
269            "scm plugin does not support PR summary".into(),
270        ))
271    }
272
273    /// Close a PR without merging. Symmetric with `merge`; used when a
274    /// session is abandoned but its PR shouldn't linger open.
275    async fn close_pr(&self, _pr: &PullRequest) -> Result<()> {
276        Err(AoError::Scm(
277            "scm plugin does not support closing PRs".into(),
278        ))
279    }
280
281    /// Fetch review comments from automated bots (Dependabot, linters,
282    /// security scanners). Default returns an empty list — the reaction
283    /// engine treats "no bot comments" as the normal case.
284    async fn automated_comments(&self, _pr: &PullRequest) -> Result<Vec<AutomatedComment>> {
285        Ok(Vec::new())
286    }
287
288    /// Batch-enrich multiple PRs in a single API round-trip.
289    ///
290    /// Returns a map keyed by `"{owner}/{repo}#{number}"`. The lifecycle
291    /// loop calls this once per tick before iterating sessions; individual
292    /// `poll_scm` calls skip their REST fan-out when the cache has a hit.
293    ///
294    /// Default: empty map (no batch support). Plugins that implement
295    /// GraphQL batch enrichment (e.g. GitHub) override this.
296    async fn enrich_prs_batch(
297        &self,
298        _prs: &[PullRequest],
299    ) -> Result<HashMap<String, ScmObservation>> {
300        Ok(HashMap::new())
301    }
302}
303
304/// Issue/task tracker plugin — GitHub Issues, Linear, Jira, ...
305///
306/// Much thinner than `Scm`. The reaction engine doesn't consume tracker
307/// data directly yet; `Tracker` is wired in so `ao-rs spawn --issue` can
308/// pull issue metadata and derive a sensible branch name / initial prompt.
309///
310/// Differences from TS `Tracker`:
311///
312/// - No `project: ProjectConfig` parameter on every method. The plugin
313///   holds any project config it needs via `::new()`, matching how
314///   `Runtime` / `Agent` already work.
315/// - `list_issues`, `update_issue`, `create_issue` are cut. The port
316///   needs exactly `get_issue` + `branch_name` + `generate_prompt` today;
317///   the rest can come back when a real use case demands them.
318#[async_trait]
319pub trait Tracker: Send + Sync {
320    /// Human-readable plugin name for logs (`"github"`, `"linear"`, ...).
321    fn name(&self) -> &str;
322
323    /// Fetch an issue by identifier. `identifier` is whatever the user
324    /// types on the CLI — `#42`, `LIN-1327`, or a full URL. The plugin
325    /// is responsible for understanding its own conventions.
326    async fn get_issue(&self, identifier: &str) -> Result<Issue>;
327
328    /// `true` if the issue is closed / completed / cancelled. Used by
329    /// `ao-rs status` filtering and by future reactions that might
330    /// auto-close an issue when the PR merges.
331    async fn is_completed(&self, identifier: &str) -> Result<bool>;
332
333    /// Canonical URL for the issue. Synchronous because it's usually
334    /// string concatenation — no network needed.
335    fn issue_url(&self, identifier: &str) -> String;
336
337    /// Suggested git branch name for a new session on this issue. The
338    /// plugin decides the format (`issue-42-add-dark-mode`, `LIN-1327`,
339    /// etc.); `ao-rs spawn` prepends its own short-id prefix if needed.
340    fn branch_name(&self, identifier: &str) -> String;
341
342    /// Post a comment to an issue.
343    ///
344    /// Default implementation returns an error so tracker plugins can opt-in
345    /// incrementally (read-only parity first).
346    async fn comment_issue(&self, _identifier: &str, _body: &str) -> Result<()> {
347        Err(AoError::Other(
348            "tracker does not support commenting".to_string(),
349        ))
350    }
351
352    /// Assign an issue (or PR number, on GitHub) to the current authenticated user.
353    ///
354    /// Default implementation returns an error so tracker plugins can opt-in
355    /// incrementally (read-only parity first).
356    async fn assign_to_me(&self, _identifier: &str) -> Result<()> {
357        Err(AoError::Other(
358            "tracker does not support assignment".to_string(),
359        ))
360    }
361
362    /// Format an issue into a structured prompt section suitable for
363    /// inclusion in the agent's initial message.
364    ///
365    /// Default impl uses `prompt_builder::format_issue_context` which
366    /// renders title, URL, labels, assignee, and description. Override
367    /// in tracker plugins that need platform-specific context (e.g.
368    /// Linear cycle info, Jira sprint fields).
369    fn generate_prompt(&self, issue: &Issue) -> String {
370        prompt_builder::format_issue_context(issue)
371    }
372
373    /// List issues matching `filters`. Mirrors TS `Tracker.listIssues?`.
374    ///
375    /// Default returns an error so read-only tracker plugins don't need to
376    /// implement this until a CLI feature requires it.
377    async fn list_issues(&self, _filters: &IssueFilters) -> Result<Vec<Issue>> {
378        Err(AoError::Other(
379            "tracker does not support listing issues".to_string(),
380        ))
381    }
382
383    /// Apply a partial update to an existing issue. Mirrors TS `Tracker.updateIssue?`.
384    ///
385    /// Only `Some` fields in `update` are changed; `None` means "leave unchanged".
386    /// Default returns an error so read-only tracker plugins compile without changes.
387    async fn update_issue(&self, _identifier: &str, _update: &IssueUpdate) -> Result<()> {
388        Err(AoError::Other(
389            "tracker does not support updating issues".to_string(),
390        ))
391    }
392
393    /// Create a new issue and return it. Mirrors TS `Tracker.createIssue?`.
394    ///
395    /// Default returns an error so read-only tracker plugins compile without changes.
396    async fn create_issue(&self, _input: &CreateIssueInput) -> Result<Issue> {
397        Err(AoError::Other(
398            "tracker does not support creating issues".to_string(),
399        ))
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::activity_log::{
407        append_activity_entry, ActivityLogEntry, ACTIVITY_INPUT_STALENESS_SECS,
408    };
409    use crate::cost_log::usage_log_path;
410    use crate::types::{now_ms, SessionId, SessionStatus};
411    use std::io::Write;
412    use std::time::{SystemTime, UNIX_EPOCH};
413
414    /// Minimal `Agent` stub that keeps every default intact. Exists so
415    /// tests can exercise the trait defaults without depending on any
416    /// plugin crate.
417    struct StubAgent;
418
419    #[async_trait]
420    impl Agent for StubAgent {
421        fn launch_command(&self, _session: &Session) -> String {
422            String::new()
423        }
424        fn environment(&self, _session: &Session) -> Vec<(String, String)> {
425            Vec::new()
426        }
427        fn initial_prompt(&self, _session: &Session) -> String {
428            String::new()
429        }
430    }
431
432    fn unique_workspace(label: &str) -> PathBuf {
433        let nanos = SystemTime::now()
434            .duration_since(UNIX_EPOCH)
435            .unwrap()
436            .as_nanos();
437        let p = std::env::temp_dir().join(format!("ao-rs-trait-default-{label}-{nanos}"));
438        std::fs::create_dir_all(&p).unwrap();
439        p
440    }
441
442    fn session_with(workspace: Option<PathBuf>) -> Session {
443        Session {
444            id: SessionId("trait-default".into()),
445            project_id: "demo".into(),
446            status: SessionStatus::Working,
447            agent: "stub".into(),
448            agent_config: None,
449            branch: "feat".into(),
450            task: "t".into(),
451            workspace_path: workspace,
452            runtime_handle: None,
453            runtime: "tmux".into(),
454            activity: None,
455            created_at: now_ms(),
456            cost: None,
457            issue_id: None,
458            issue_url: None,
459            claimed_pr_number: None,
460            claimed_pr_url: None,
461            initial_prompt_override: None,
462            spawned_by: None,
463            last_merge_conflict_dispatched: None,
464            last_review_backlog_fingerprint: None,
465        }
466    }
467
468    #[tokio::test]
469    async fn detect_activity_default_no_workspace_returns_ready() {
470        let agent = StubAgent;
471        let session = session_with(None);
472        assert_eq!(
473            agent.detect_activity(&session).await.unwrap(),
474            ActivityState::Ready
475        );
476    }
477
478    #[tokio::test]
479    async fn detect_activity_default_no_log_returns_ready() {
480        let agent = StubAgent;
481        let ws = unique_workspace("no-log");
482        let session = session_with(Some(ws));
483        assert_eq!(
484            agent.detect_activity(&session).await.unwrap(),
485            ActivityState::Ready
486        );
487    }
488
489    #[tokio::test]
490    async fn detect_activity_default_surfaces_exited_from_log() {
491        let agent = StubAgent;
492        let ws = unique_workspace("exited");
493        append_activity_entry(
494            &ws,
495            &ActivityLogEntry {
496                ts: now_ms().to_string(),
497                state: ActivityState::Exited,
498                source: "terminal".into(),
499                trigger: None,
500            },
501        )
502        .unwrap();
503        let session = session_with(Some(ws));
504        assert_eq!(
505            agent.detect_activity(&session).await.unwrap(),
506            ActivityState::Exited
507        );
508    }
509
510    #[tokio::test]
511    async fn detect_activity_default_surfaces_fresh_waiting_input() {
512        let agent = StubAgent;
513        let ws = unique_workspace("waiting");
514        append_activity_entry(
515            &ws,
516            &ActivityLogEntry {
517                ts: now_ms().to_string(),
518                state: ActivityState::WaitingInput,
519                source: "terminal".into(),
520                trigger: Some("approve?".into()),
521            },
522        )
523        .unwrap();
524        let session = session_with(Some(ws));
525        assert_eq!(
526            agent.detect_activity(&session).await.unwrap(),
527            ActivityState::WaitingInput
528        );
529    }
530
531    #[tokio::test]
532    async fn detect_activity_default_stale_waiting_falls_back_to_ready() {
533        let agent = StubAgent;
534        let ws = unique_workspace("stale-waiting");
535        let stale_ms = now_ms().saturating_sub((ACTIVITY_INPUT_STALENESS_SECS + 60) * 1000);
536        append_activity_entry(
537            &ws,
538            &ActivityLogEntry {
539                ts: stale_ms.to_string(),
540                state: ActivityState::WaitingInput,
541                source: "terminal".into(),
542                trigger: None,
543            },
544        )
545        .unwrap();
546        let session = session_with(Some(ws));
547        assert_eq!(
548            agent.detect_activity(&session).await.unwrap(),
549            ActivityState::Ready
550        );
551    }
552
553    #[tokio::test]
554    async fn cost_estimate_default_no_workspace_returns_none() {
555        let agent = StubAgent;
556        let session = session_with(None);
557        assert!(agent.cost_estimate(&session).await.unwrap().is_none());
558    }
559
560    #[tokio::test]
561    async fn cost_estimate_default_no_log_returns_none() {
562        let agent = StubAgent;
563        let ws = unique_workspace("cost-missing");
564        let session = session_with(Some(ws));
565        assert!(agent.cost_estimate(&session).await.unwrap().is_none());
566    }
567
568    #[tokio::test]
569    async fn cost_estimate_default_reads_usage_log() {
570        let agent = StubAgent;
571        let ws = unique_workspace("cost-present");
572        let path = usage_log_path(&ws);
573        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
574        let mut f = std::fs::File::create(&path).unwrap();
575        writeln!(
576            f,
577            r#"{{"input_tokens":100,"output_tokens":50,"cost_usd":0.5}}"#
578        )
579        .unwrap();
580        writeln!(
581            f,
582            r#"{{"input_tokens":200,"output_tokens":75,"cost_usd":0.25}}"#
583        )
584        .unwrap();
585
586        let session = session_with(Some(ws));
587        let cost = agent.cost_estimate(&session).await.unwrap().expect("some");
588        assert_eq!(cost.input_tokens, 300);
589        assert_eq!(cost.output_tokens, 125);
590        assert!((cost.cost_usd.unwrap() - 0.75).abs() < 1e-9);
591    }
592}