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}