diff --git a/AGENTS.md b/AGENTS.md index e7543d9fa..bc7321e83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,8 +157,8 @@ The runtime client must: ### Keep harness capability discovery public - Public harness metadata belongs in `opensymphony-gateway-schema::capability::HarnessCapability` and the `/api/v1/capabilities` response. -- Use stable harness kind strings such as `openhands_agent_server`, `codex_app_server`, `acp`, and `rust_native`; do not expose private adapter type names to clients. -- Concrete harness adapters should implement the domain `HarnessAdapter` capability boundary and keep OpenHands, Codex, or future in-process protocol details inside their adapter modules. +- Use stable harness kind strings such as `openhands_agent_server`, `codex_app_server`, `devin_cloud_agent`, `acp`, and `rust_native`; do not expose private adapter type names to clients. +- Concrete harness adapters should implement the domain `HarnessAdapter` capability boundary and keep OpenHands, Codex, Devin, ACP, or future in-process protocol details inside their adapter modules. - Future or experimental harnesses may be advertised as unavailable capability entries, but their feature gaps must be explicit. - When changing harness capability discovery, update gateway schema round-trip tests, the gateway capabilities endpoint test, adapter-boundary tests, and `docs/harness-adapter-compatibility.md`. @@ -179,6 +179,57 @@ capability must stay available in normal builds. Do not advertise hosted worker pools, remote routing, or loopback WebSocket as production-ready until those paths have their own hardening evidence. +The remote Devin cloud harness uses the `opensymphony_devin` internal module +boundary and is advertised as an available HTTPS capability against the Devin +API v3 contract (`devin-api-v3`), with explicit feature gaps for the properties +the hosted API does not provide: cursor-paginated polling instead of a push +stream, cancellation as session termination instead of a mid-run interrupt, no +pause/resume or approval flow, and model selection fixed at session creation. +Devin owns its own remote execution workspace: never treat a local issue +workspace path as Devin's working directory. The scheduler-bound issue workspace +holds run manifests, journals, and imported evidence only. + +Devin hardening rules that must hold for any change to this harness: + +- Every client binds to exactly one organization through `GET /v3/self` before + any session call, and session, listing, and message payloads owned by another + organization are rejected rather than journaled. +- Credentials stay environment-variable references in workflow config, + manifests, and logs; the token is never serialized or logged. +- Secrets are injected by organization-scoped Devin `secret_ids` resolved from + the bound organization; raw secret values never enter OpenSymphony. +- Transport is HTTPS-only with TLS 1.2+, no redirects, and no credentials, + query, or fragment in the configured base URL. Certificate pinning is not + implemented; do not claim it. +- Evidence downloads are restricted to the authenticated API origin, bounded by + size and count limits, and written under sanitized names inside the issue + workspace. +- Every abandoned path (poll timeout, transport failure, scheduler interrupt, + worker abort, daemon shutdown) must terminate and archive the remote session + and await the acknowledgement; an orphaned Devin session keeps consuming + ACUs. `Drop` is never the only safeguard: the persisted session binding and + route identity (`devin-route.json`) are the durable fence, and a bound + session is stopped through *its own* persisted origin/organization before + any replacement is created. +- Scheduler abort and daemon shutdown terminate the session first and then + give the route task a bounded window to observe the archived session, + import its evidence, and finish `run.json`; the task is aborted only if it + does not settle in time. Aborting first leaves a `running` manifest with no + evidence for a run the tracker already moved on from. +- Organization-scoped requests require verified tenancy (`bind_tenant`), not + just a configured organization id; a failed `create_session` is reconciled + through the correlation tag before it is retried. +- Parent issues with sub-issues are not routed to remote-only harnesses. +- A quiet, executing session is not a stalled one: the poll loop publishes a + `session.heartbeat` liveness event inside the scheduler stall window so a + long Devin step is never aborted and replaced as a stall. Heartbeats are + orchestrator observations and stay out of the evidence journal. + +Changes to the route or the client must keep the opt-in live suite +(`OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- +--ignored`) passing; it is the only evidence that the hosted contract still +holds. + ### Preserve forward compatibility OpenHands event schemas can evolve. Implement: diff --git a/crates/opensymphony-cli/src/debug_session.rs b/crates/opensymphony-cli/src/debug_session.rs index df8eb572a..ac952abc9 100644 --- a/crates/opensymphony-cli/src/debug_session.rs +++ b/crates/opensymphony-cli/src/debug_session.rs @@ -2309,6 +2309,7 @@ mod tests { updated_at: Utc::now(), last_seen_tracker_refresh_at: None, repository_binding: None, + evidence_only: false, } } diff --git a/crates/opensymphony-cli/src/memory.rs b/crates/opensymphony-cli/src/memory.rs index 797a489ca..7ebea137a 100644 --- a/crates/opensymphony-cli/src/memory.rs +++ b/crates/opensymphony-cli/src/memory.rs @@ -10254,6 +10254,7 @@ mod tests { updated_at: now, last_seen_tracker_refresh_at: None, repository_binding: None, + evidence_only: false, }) .expect("workspace manifest json"), ) @@ -10464,6 +10465,7 @@ mod tests { updated_at: now, last_seen_tracker_refresh_at: None, repository_binding: None, + evidence_only: false, }) .expect("manifest json"), ) @@ -15098,6 +15100,7 @@ Public memory concept. .expect("runtime envelope"); let now = Utc::now(); let run = RunManifest { + terminal_worker_outcome: None, run_id: "run-551".to_string(), issue_id: "issue-551".to_string(), identifier: "COE-551".to_string(), diff --git a/crates/opensymphony-cli/src/orchestrator_run/backends.rs b/crates/opensymphony-cli/src/orchestrator_run/backends.rs index bcdd4874f..799d7b4db 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/backends.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/backends.rs @@ -3,7 +3,7 @@ mod acp; use futures_util::{StreamExt, stream}; -use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; use std::{ collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, @@ -24,6 +24,13 @@ use crate::opensymphony_codex::{ codex_approval_request_from_event, codex_event_summary, normalize_server_notification, turn_status, }; +use crate::opensymphony_devin::{ + DEFAULT_DEVIN_HEARTBEAT_INTERVAL, DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, + DEVIN_EVIDENCE_DIR_NAME, DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, + DevinEvidenceLimits, DevinMode, DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, + DevinRunReport, DevinSessionOptions, DevinSessionRunner, DevinStopOutcome, devin_event_payload, + devin_event_summary, session_create_request, session_created_event, status_event, +}; use crate::opensymphony_domain::{ CanonicalRepositoryId, ConversationId, ConversationMetadata, HarnessInterruptReason, IssueId, IssueIdentifier, IssueState, IssueStateCategory, NormalizedIssue, ParentVerificationEvidence, @@ -53,12 +60,12 @@ use crate::opensymphony_orchestrator::{ use crate::opensymphony_workflow::{Environment, ProcessEnvironment, ResolvedWorkflow}; use crate::opensymphony_workspace::{ AcpRunRoute, CheckoutRepository, CleanupConfig, HookConfig, HookDefinition, IssueDescriptor, - IssueLifecycleState, ParentCheckoutRequest, ParentRuntimeDescriptor, ParentRuntimeEnvelope, - RunDescriptor, RunManifest, RunStatus, TerminalRuntimeEnvelope, WorkspaceError, - WorkspaceHandle, WorkspaceManager, WorkspaceManagerConfig, - checkout_credential_environment_variables, compose_parent_continuation_prompt, - compose_parent_prompt, compose_terminal_prompt, environment_variable_names_equal, - redact_runtime_diagnostic, + IssueLifecycleState, MAX_INSTRUCTION_FILE_BYTES, ParentCheckoutRequest, + ParentRuntimeDescriptor, ParentRuntimeEnvelope, RunDescriptor, RunManifest, RunStatus, + TerminalRuntimeEnvelope, WorkspaceError, WorkspaceHandle, WorkspaceManager, + WorkspaceManagerConfig, checkout_credential_environment_variables, + compose_parent_continuation_prompt, compose_parent_prompt, compose_terminal_prompt, + environment_variable_names_equal, instruction_body, redact_runtime_diagnostic, }; use async_trait::async_trait; use thiserror::Error; @@ -401,6 +408,8 @@ pub(super) enum CliWorkspaceError { OpenHandsLifecycle(String), #[error("conversation lifecycle recovery failed: {0}")] ConversationLifecycle(String), + #[error("Devin lifecycle recovery failed: {0}")] + DevinLifecycle(String), #[error("retry state persistence failed: {0}")] RetryState(String), #[error("workspace cleanup deferred while a durable lease is active")] @@ -486,6 +495,9 @@ pub(super) struct RuntimeWorkspaceBackend { acp_host: Option, retain_failed: bool, retry_state_root: PathBuf, + /// Needed to reach a detached Devin session at terminal cleanup, when no + /// worker task or registry entry is left to interrupt. + devin_config: DevinCloudConfig, } pub(super) struct RuntimeWorkerBackend { @@ -502,6 +514,7 @@ pub(super) struct RuntimeWorkerBackend { codex_bin: String, codex_schema_validators: CodexSchemaValidatorCache, codex_interrupts: CodexInterruptRegistry, + devin_sessions: DevinSessionRegistry, acp_host: Option, acp_active: acp::ActiveSessions, launch_timeout: Duration, @@ -515,6 +528,25 @@ pub(super) struct RuntimeWorkerBackend { type CodexSchemaValidatorCache = Arc>>; type CodexInterruptRegistry = Arc>>>>; type CodexInterruptResponseRegistry = Arc>>>>; +/// Live Devin sessions keyed by session id, so a scheduler interrupt can stop +/// the remote session that a worker task is currently polling. +type DevinSessionRegistry = Arc>>; + +/// A live Devin session owned by an in-process worker task, keyed by session id. +#[derive(Clone)] +struct TrackedDevinSession { + worker_id: String, + client: DevinCloudClient, +} + +/// Upper bound for one awaited remote termination during abort or shutdown. +const DEVIN_TERMINATION_TIMEOUT: Duration = Duration::from_secs(30); +/// Upper bound for letting terminated Devin route tasks settle (observe the +/// archived session, import evidence, finish the run manifest) before abort. +const DEVIN_SETTLE_TIMEOUT: Duration = Duration::from_secs(30); +/// Clock skew tolerated when matching a reconciled session's `created_at` +/// against the local creation attempt. +const DEVIN_CREATION_CLOCK_SKEW_SECS: i64 = 120; struct ActiveWorkerTask { handle: JoinHandle<()>, @@ -4088,6 +4120,7 @@ impl RuntimeWorkspaceBackend { acp_host: None, retain_failed, retry_state_root, + devin_config: devin_cloud_config(workflow), } } @@ -4200,6 +4233,47 @@ impl RuntimeWorkspaceBackend { .read_text_artifact(&handle, &manifest_path) .await?; if let Some(raw_manifest) = raw_conversation_manifest { + if let Ok(manifest) = + serde_json::from_str::(&raw_manifest) + && conversation_manifest_is_devin(&manifest) + { + // Keep the binding and evidence until the remote session + // has stopped. The workspace manager records this workspace + // as evidence-only and skips repository cleanup hooks. + let identity = read_devin_route_identity(&self.manager, &handle).await?; + if let Err(error) = terminate_devin_session_for_cleanup( + &self.devin_config, + identity.as_ref(), + manifest.conversation_id.as_str(), + ) + .await + { + tracing::warn!( + issue = %handle.identifier(), + session_id = %manifest.conversation_id, + %error, + "preserving terminal Devin workspace until its remote session is terminated" + ); + return Err(CliWorkspaceError::DevinLifecycle(error)); + } + if let Some(target) = cleanup_target { + if prepare_only { + self.manager.prepare_cleanup_target(target).await?; + } else { + self.manager.cleanup_target(target).await?; + } + } else if force_remove { + self.manager + .cleanup_failed_terminal_workspace(&handle) + .await?; + } else { + self.manager + .cleanup(&handle, IssueLifecycleState::Terminal) + .await?; + } + self.terminal_cleanup_paths.insert(workspace.path.clone()); + return Ok(()); + } match acp::conversation_view(&raw_manifest) { Ok(manifest) if recovered_harness_kind_from_manifest(&manifest) == acp::KIND => @@ -4475,6 +4549,30 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { }) } + async fn ensure_evidence_workspace( + &mut self, + issue: &NormalizedIssue, + _observed_at: TimestampMs, + ) -> Result { + let ensured = self + .manager + .ensure_evidence_only(&issue_descriptor(issue)) + .await?; + self.terminal_cleanup_paths + .remove(ensured.handle.workspace_path()); + Ok(crate::opensymphony_domain::WorkspaceRecord { + path: ensured.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(ensured.handle.workspace_key().to_string())?, + created_now: ensured.created, + created_at: Some(datetime_to_timestamp_ms(ensured.issue_manifest.created_at)), + updated_at: Some(datetime_to_timestamp_ms(ensured.issue_manifest.updated_at)), + last_seen_tracker_refresh_at: ensured + .issue_manifest + .last_seen_tracker_refresh_at + .map(datetime_to_timestamp_ms), + }) + } + async fn recover_workspaces(&mut self) -> Result, Self::Error> { let mut recoveries = Vec::new(); self.recovered_run_started_at.clear(); @@ -4571,6 +4669,9 @@ impl WorkspaceBackend for RuntimeWorkspaceBackend { .as_ref() .and_then(|run| run.interrupt_reason.as_deref()) .and_then(interrupt_reason_from_manifest), + terminal_worker_outcome: run_manifest + .as_ref() + .and_then(|run| run.terminal_worker_outcome), recovered_run: had_in_flight_run.then_some(recovered_run).flatten(), }); } @@ -5701,6 +5802,7 @@ impl RuntimeWorkerBackend { codex_bin: env::var("OPENSYMPHONY_CODEX_BIN").unwrap_or_else(|_| "codex".into()), codex_schema_validators: Arc::new(AsyncMutex::new(HashMap::new())), codex_interrupts: Arc::new(Mutex::new(HashMap::new())), + devin_sessions: Arc::new(Mutex::new(HashMap::new())), acp_host: None, acp_active: Arc::new(Mutex::new(HashMap::new())), launch_timeout: DEFAULT_WORKER_LAUNCH_TIMEOUT, @@ -5753,6 +5855,121 @@ impl RuntimeWorkerBackend { } } + /// Stops the Devin sessions tracked for `worker_id` (all of them when + /// `None`) and waits for each acknowledgement. + /// + /// Aborting a worker task only drops its future; the remote session would + /// keep executing and billing. A failed or timed-out termination is logged + /// rather than fatal because the session stays bound in the conversation + /// manifest: the pre-creation fence and terminal cleanup reach it later. + async fn terminate_tracked_devin_sessions(&self, worker_id: Option<&str>) { + let tracked: Vec<(String, DevinCloudClient)> = match self.devin_sessions.lock() { + Ok(sessions) => sessions + .iter() + .filter(|(_, tracked)| worker_id.is_none_or(|id| tracked.worker_id == id)) + .map(|(session_id, tracked)| (session_id.clone(), tracked.client.clone())) + .collect(), + Err(_) => Vec::new(), + }; + for (session_id, client) in tracked { + match timeout(DEVIN_TERMINATION_TIMEOUT, client.stop_session(&session_id)).await { + Ok(Ok(_)) => { + tracing::info!( + session_id, + "terminated and archived devin session before releasing its worker" + ); + if let Ok(mut sessions) = self.devin_sessions.lock() { + sessions.remove(&session_id); + } + } + Ok(Err(error)) => tracing::warn!( + session_id, + error = %error, + "devin session termination failed; its binding is retained for cleanup" + ), + Err(_) => tracing::warn!( + session_id, + "devin session termination timed out; its binding is retained for cleanup" + ), + } + } + } + + fn owns_devin_session(&self, worker_id: &str) -> bool { + self.devin_sessions + .lock() + .map(|sessions| { + sessions + .values() + .any(|tracked| tracked.worker_id == worker_id) + }) + .unwrap_or(false) + } + + fn owns_devin_session_any(&self) -> bool { + self.devin_sessions + .lock() + .map(|sessions| !sessions.is_empty()) + .unwrap_or(false) + } + + /// Lets the Devin route tasks whose sessions were just terminated observe + /// the archived session themselves: the next poll settles it, imports the + /// evidence, and finishes the run manifest. Aborting the task straight + /// away would leave `run.json` as `running` with no evidence. Bounded, so + /// a wedged poll still falls through to the task abort; a task that + /// panicked is reported through the normal worker message boundary. + async fn settle_devin_tasks(&mut self, worker_id: Option<&str>, settle_timeout: Duration) { + let worker_ids: Vec = self + .tasks + .keys() + .filter(|id| worker_id.is_none_or(|worker_id| worker_id == id.as_str())) + .cloned() + .collect(); + let deadline = tokio::time::Instant::now() + settle_timeout; + for id in worker_ids { + let Some(mut task) = self.tasks.remove(&id) else { + continue; + }; + match timeout(deadline - tokio::time::Instant::now(), &mut task.handle).await { + Ok(Ok(())) => { + self.worker_issue_ids.remove(&id); + } + Ok(Err(error)) => { + tracing::warn!( + worker_id = %id, + error = %error, + "devin worker task failed while settling its terminated session" + ); + self.worker_issue_ids.remove(&id); + let Ok(worker) = crate::opensymphony_domain::WorkerId::new(&id) else { + continue; + }; + let _ = self.updates_tx.send(WorkerUpdate::Finished { + worker_id: worker, + outcome: WorkerOutcomeRecord::from_run( + &task.run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some( + "devin worker task failed while settling after termination".into(), + ), + Some(error.to_string()), + ) + .with_harness_stopped(), + }); + } + Err(_) => { + tracing::warn!( + worker_id = %id, + "devin worker did not settle its terminated session in time; aborting its task" + ); + self.tasks.insert(id, task); + } + } + } + } + fn abort_all_tracked_tasks(&mut self) { self.worker_issue_ids.clear(); let active_count = self.tasks.len(); @@ -5827,18 +6044,231 @@ impl RuntimeWorkerBackend { let recovered = recovered && matches!( route.harness_kind.as_str(), - OPENHANDS_AGENT_SERVER_KIND | CODEX_APP_SERVER_KIND | acp::KIND + OPENHANDS_AGENT_SERVER_KIND + | CODEX_APP_SERVER_KIND + | DEVIN_CLOUD_AGENT_KIND + | acp::KIND ); let pending_route = route.clone(); let codex_bin = self.codex_bin.clone(); let codex_schema_validators = Arc::clone(&self.codex_schema_validators); let codex_interrupts = Arc::clone(&self.codex_interrupts); + let devin_sessions = Arc::clone(&self.devin_sessions); let acp_host = self.acp_host.get_or_insert_with(acp::new_host).clone(); let acp_active = self.acp_active.clone(); let launch_worker_id = worker_id.clone(); let handle = tokio::spawn(async move { let mut launch_tx = Some(launch_tx); let run_id = format!("run-{launch_worker_id}"); + if route.harness_kind == DEVIN_CLOUD_AGENT_KIND { + // Devin executes in a Devin-owned cloud workspace, so this route + // never prepares a local execution cwd: `ensure_with_run_id` and + // checkout verification below are for local harnesses only. The + // scheduler-bound workspace is used purely to store manifests, + // journals, and evidence imported from the remote session. + let ensured = match workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + { + Ok(ensured) => ensured, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to ensure devin evidence workspace: {error}"), + ); + return; + } + }; + if let Err(detail) = + assert_scheduler_bound_workspace(&run, ensured.handle.workspace_path()).await + { + report_launch_failure(&mut launch_tx, detail); + return; + } + // A restarted worker must reattach to the session its previous + // process created; creating a second one would leave the first + // running and billing with nothing tracking it. + let reattach = if recovered { + match devin_session_binding(&workspace_manager, &ensured.handle).await { + Ok(Some(session_id)) => { + match read_devin_route_identity(&workspace_manager, &ensured.handle) + .await + { + Ok(identity) => Some(DevinReattachTarget { + session_id, + identity, + }), + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to read devin route identity: {error}"), + ); + return; + } + } + } + Ok(None) => None, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to read devin session binding: {error}"), + ); + return; + } + } + } else { + None + }; + let attempt = run.attempt.map(|attempt| attempt.get()).unwrap_or(1); + // The run manifest has to exist before the scheduler is told a + // worker launched: cancellation persists interrupt intent into + // it, and restart recovery reads it to find the live session. + // Hooks are skipped because they operate on a checkout this + // route never materializes. + let mut run_manifest = match workspace_manager + .start_evidence_run( + &ensured.handle, + &RunDescriptor::new(&run_id, attempt) + .with_normal_retry_count(run.normal_retry_count) + .with_repository_binding(run.repository_binding.clone()), + ) + .await + { + Ok(run_manifest) => run_manifest, + Err(error) => { + report_launch_failure( + &mut launch_tx, + format!("failed to prepare devin workspace run: {error}"), + ); + return; + } + }; + // A routing preview must never reach Devin: a created session + // executes and bills immediately, with no dry-run mode of its + // own. + if route.dry_run { + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(dry_run_conversation_metadata(&run, &route)), + started_at: run_manifest.started_at.map(datetime_to_timestamp_ms), + }); + } + run_manifest.status_detail = Some(route.summary()); + let finish_error = workspace_manager + .finish_evidence_run( + &ensured.handle, + &mut run_manifest, + RunStatus::Succeeded, + ) + .await + .err(); + let outcome = WorkerOutcomeRecord::from_run( + &run, + if finish_error.is_some() { + WorkerOutcomeKind::Failed + } else { + WorkerOutcomeKind::Succeeded + }, + now_timestamp(), + Some(match &finish_error { + Some(_) => "routing dry-run workspace finalization failed".into(), + None => route.summary(), + }), + finish_error.map(|error| error.to_string()), + ); + let _ = updates_tx.send(WorkerUpdate::Finished { + worker_id: finished_worker_id.clone(), + outcome, + }); + return; + } + let result = Box::pin(run_devin_cloud_route(DevinRouteContext { + workflow: &workflow, + issue: &issue, + run: &run, + route: &route, + run_id: &run_id, + workspace_manager: &workspace_manager, + workspace: &ensured.handle, + run_manifest: &mut run_manifest, + updates_tx: &updates_tx, + devin_sessions: &devin_sessions, + worker_id: &finished_worker_id, + launch_tx: &mut launch_tx, + reattach, + })) + .await; + let (outcome, run_status) = match result { + Ok(settlement) => { + let run_status = settlement.run_status; + ( + WorkerOutcomeRecord::from_run( + &run, + settlement.outcome, + now_timestamp(), + Some(settlement.summary), + settlement.error, + ), + run_status, + ) + } + Err(detail) => { + report_launch_failure(&mut launch_tx, detail.clone()); + ( + WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some("devin cloud run failed".to_owned()), + Some(detail), + ), + RunStatus::Failed, + ) + } + }; + let non_retrying = non_retrying_devin_outcome(outcome.outcome); + let outcome = match finish_devin_workspace_run( + &workspace_manager, + &ensured.handle, + &mut run_manifest, + run_status, + non_retrying, + ) + .await + { + Ok(()) => outcome, + // A local write failure says nothing about the remote + // session. Downgrading a non-retrying outcome here would + // authorize a replacement session while the original one + // is still alive. + Err(error) if non_retrying.is_some() => WorkerOutcomeRecord::from_run( + &run, + outcome.outcome, + now_timestamp(), + outcome.summary.clone(), + Some(match outcome.error.as_deref() { + Some(previous) => { + format!("{previous}; devin workspace finalization failed: {error}") + } + None => format!("devin workspace finalization failed: {error}"), + }), + ), + Err(error) => WorkerOutcomeRecord::from_run( + &run, + WorkerOutcomeKind::Failed, + now_timestamp(), + Some("devin workspace finalization failed".to_owned()), + Some(format!( + "failed to finish devin workspace run as {run_status}: {error}" + )), + ), + }; + let _ = updates_tx.send(WorkerUpdate::Finished { + worker_id: finished_worker_id.clone(), + outcome, + }); + return; + } let mut workspace_issue = issue_descriptor(&issue); if recovered && let Some(binding) = run.repository_binding.clone() { workspace_issue.repository_binding = @@ -10077,86 +10507,1317 @@ fn transport_port_override(url: &Url) -> Result { }) } -fn report_launch_failure( - launch_tx: &mut Option>, - detail: impl Into, -) { - if let Some(sender) = launch_tx.take() { - let _ = sender.send(LaunchReport::Failed(detail.into())); +/// Devin client configuration resolved from workflow front matter. +/// +/// Only the credential *reference* travels through configuration; the value is +/// read from the worker environment by the Devin client itself. +fn devin_cloud_config(workflow: &ResolvedWorkflow) -> DevinCloudConfig { + let api = &workflow.extensions.devin.api; + let session = &workflow.extensions.devin.session; + DevinCloudConfig { + base_url: api.base_url.clone(), + api_key_env: api.api_key_env.clone(), + org_id: api.org_id.clone(), + org_id_env: api.org_id_env.clone(), + poll_interval: Duration::from_millis(api.poll_interval_ms), + request_timeout: Duration::from_millis(api.request_timeout_ms), + session: DevinSessionOptions { + playbook_id: session.playbook_id.clone(), + knowledge_ids: session.knowledge_ids.clone(), + secret_ids: session.secret_ids.clone(), + max_acu_limit: session.max_acu_limit, + tags: session.tags.clone(), + title: session.title.clone(), + devin_mode: session.devin_mode.as_deref().and_then(DevinMode::parse), + platform: session.platform.clone(), + resumable: session.resumable, + }, } } -fn parent_harness_switch_error(is_parent: bool, switching_harness: bool) -> Option { - (is_parent && switching_harness).then(|| { - "parent integration cannot switch harnesses while its authoritative conversation is bound" - .to_owned() - }) +/// Everything the Devin route needs from the worker task. +struct DevinRouteContext<'context> { + workflow: &'context ResolvedWorkflow, + issue: &'context NormalizedIssue, + run: &'context crate::opensymphony_domain::RunAttempt, + route: &'context crate::opensymphony_orchestrator::HarnessRouteDecision, + run_id: &'context str, + workspace_manager: &'context WorkspaceManager, + workspace: &'context WorkspaceHandle, + run_manifest: &'context mut RunManifest, + updates_tx: &'context mpsc::UnboundedSender, + devin_sessions: &'context DevinSessionRegistry, + worker_id: &'context crate::opensymphony_domain::WorkerId, + launch_tx: &'context mut Option>, + /// Set when a restarted worker inherited a live session binding; the route + /// then follows that session instead of creating a replacement. + reattach: Option, } -fn expected_parent_conversation_error( - expected: Option<&str>, - recovered: Option<&str>, -) -> Option { - let expected = expected?; - match recovered { - Some(actual) if actual == expected => None, - Some(actual) => Some(format!( - "parent integration requires conversation `{expected}`, but the persisted manifest binds `{actual}`" - )), - None => Some(format!( - "parent integration requires conversation `{expected}`, but its persisted manifest is missing" - )), - } +/// A session binding inherited across a restart. +struct DevinReattachTarget { + session_id: String, + identity: Option, } -async fn read_verified_integration_instructions( - instructions: Option<&ResolvedIntegrationInstructions>, -) -> Result, String> { - let Some(instructions) = instructions else { +/// Where a bound Devin session lives: API origin, organization, and the name +/// of the environment variable holding its credential. +/// +/// Persisted beside the conversation manifest so that termination after a +/// restart or a configuration change is addressed to the session's own tenant. +/// Without it, a route pointed at another organization would receive a 404 that +/// looks like proof the original session is gone. The credential value itself +/// is never stored. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct DevinRouteIdentity { + session_id: String, + base_url: String, + org_id: String, + api_key_env: String, +} + +fn devin_route_identity_path(workspace: &WorkspaceHandle) -> PathBuf { + workspace.metadata_dir().join("devin-route.json") +} + +async fn read_devin_route_identity( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, +) -> Result, WorkspaceError> { + let Some(raw) = workspace_manager + .read_text_artifact(workspace, &devin_route_identity_path(workspace)) + .await? + else { return Ok(None); }; - let content = fs::read(&instructions.path).await.map_err(|error| { - format!( - "failed to read project-set integration instructions before harness attach: {error}" - ) - })?; - let mut hasher = Sha256::new(); - hasher.update(&content); - let actual = format!("sha256:{:x}", hasher.finalize()); - if actual != instructions.content_hash { - return Err( - "project-set integration instructions changed before harness attach".to_owned(), - ); - } - Ok(Some(String::from_utf8_lossy(&content).into_owned())) + Ok(serde_json::from_str::(&raw).ok()) } -fn pending_launch_failure_detail(result: &Result) -> String { - match result { - Ok(result) => { - let detail = result - .worker_outcome - .error - .clone() - .or_else(|| result.worker_outcome.summary.clone()) - .unwrap_or_else(|| { - "worker finished before reporting a conversation launch".to_string() - }); - format!("worker finished before reporting a conversation launch: {detail}") - } - Err(error) => format!("worker failed before reporting a conversation launch: {error}"), +/// The client configuration a persisted binding must be addressed with. The +/// current workflow still supplies timeouts and polling cadence; origin, +/// organization, and credential reference come from the binding so that +/// `bind_tenant` refuses a credential from any other organization. +fn devin_config_for_identity( + current: &DevinCloudConfig, + identity: Option<&DevinRouteIdentity>, +) -> DevinCloudConfig { + let Some(identity) = identity else { + return current.clone(); + }; + DevinCloudConfig { + base_url: identity.base_url.clone(), + api_key_env: identity.api_key_env.clone(), + org_id: Some(identity.org_id.clone()), + ..current.clone() } } -impl WorkerBackend for RuntimeWorkerBackend { - type Error = CliWorkerError; +/// How a Devin session settled, in scheduler terms. +struct DevinSettlement { + outcome: WorkerOutcomeKind, + summary: String, + error: Option, + run_status: RunStatus, +} - async fn start_worker( - &mut self, - request: WorkerStartRequest, - ) -> Result { - let pending = self.spawn_worker_task(request, false); - let worker_id = pending.worker_id.clone(); +/// Confirms the worker's workspace is the one the scheduler bound to this run. +async fn assert_scheduler_bound_workspace( + run: &crate::opensymphony_domain::RunAttempt, + ensured_path: &Path, +) -> Result<(), String> { + let scheduler_workspace_path = fs::canonicalize(&run.workspace_path) + .await + .map_err(|error| format!("failed to resolve scheduler-bound workspace: {error}"))?; + let ensured_workspace_path = fs::canonicalize(ensured_path) + .await + .map_err(|error| format!("failed to resolve ensured workspace: {error}"))?; + if ensured_workspace_path != scheduler_workspace_path { + return Err(format!( + "workspace generation changed during worker launch: scheduler bound {}, ensured {}", + run.workspace_path.display(), + ensured_path.display() + )); + } + + Ok(()) +} + +async fn finish_devin_workspace_run( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + run_manifest: &mut RunManifest, + status: RunStatus, + terminal_worker_outcome: Option, +) -> Result<(), WorkspaceError> { + run_manifest.status = status; + run_manifest.status_detail = Some(format!("Devin cloud route ended with {status}")); + // `Paused` and `Failed` are ambiguous across a restart, so the outcome that + // forbids a replacement run is persisted alongside them. + run_manifest.terminal_worker_outcome = terminal_worker_outcome; + workspace_manager + .finish_evidence_run(workspace, run_manifest, status) + .await +} + +/// The Devin outcomes that must never be retried automatically because the +/// remote session is either still alive or of unconfirmed state. +fn non_retrying_devin_outcome(outcome: WorkerOutcomeKind) -> Option { + matches!( + outcome, + WorkerOutcomeKind::Detached | WorkerOutcomeKind::CancelFailed + ) + .then_some(outcome) +} + +/// Terminates and archives a Devin session that only its durable binding still +/// points at, rebuilding a tenant-bound client because no worker task is left. +/// +/// The persisted route identity wins over the current configuration: a 404 is +/// only accepted as "already gone" from the tenant that owns the session. +async fn terminate_devin_session_for_cleanup( + config: &DevinCloudConfig, + identity: Option<&DevinRouteIdentity>, + session_id: &str, +) -> Result<(), String> { + if identity.is_none() { + tracing::warn!( + session_id, + "devin binding has no persisted route identity; stopping it through the current configuration" + ); + } + let config = devin_config_for_identity(config, identity); + let config = &config; + let client = DevinCloudClient::from_environment(config, |name| ProcessEnvironment.get(name)) + .map_err(|error| { + format!("no devin client could be built for session `{session_id}`: {error}") + })? + .bind_tenant() + .await + .map_err(|error| { + format!("devin tenant binding failed while stopping `{session_id}`: {error}") + })?; + client + .stop_session(session_id) + .await + .map(|_| ()) + .map_err(|error| format!("devin session `{session_id}` could not be terminated: {error}")) +} + +fn conversation_manifest_is_devin(manifest: &IssueConversationManifest) -> bool { + manifest.transport_target.as_deref() == Some(DEVIN_CLOUD_AGENT_KIND) +} + +/// Reads the durable Devin session binding for a workspace, if it has one. +async fn devin_session_binding( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, +) -> Result, WorkspaceError> { + let Some(raw) = workspace_manager + .read_text_artifact(workspace, &workspace.conversation_manifest_path()) + .await? + else { + return Ok(None); + }; + let Ok(manifest) = serde_json::from_str::(&raw) else { + return Ok(None); + }; + Ok(conversation_manifest_is_devin(&manifest).then(|| manifest.conversation_id.to_string())) +} + +/// Persists the remote session binding so cancellation and restart recovery can +/// find the live Devin session without the worker task. +async fn write_devin_conversation_manifest( + workspace_manager: &WorkspaceManager, + workspace: &WorkspaceHandle, + issue: &NormalizedIssue, + identity: &DevinRouteIdentity, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + run_manifest: &mut RunManifest, +) -> Result<(), String> { + let session_id = identity.session_id.as_str(); + let base_url = identity.base_url.as_str(); + let now = chrono::Utc::now(); + // The route identity goes first: once the conversation manifest names a + // session, every cleanup path must already know which tenant owns it. + workspace_manager + .write_json_artifact(workspace, &devin_route_identity_path(workspace), identity) + .await + .map_err(|error| format!("failed to persist devin route identity: {error}"))?; + let conversation_id = ConversationId::new(session_id.to_owned()) + .map_err(|error| format!("invalid devin session id for conversation manifest: {error}"))?; + let manifest = IssueConversationManifest { + issue_id: issue.id.clone(), + identifier: issue.identifier.clone(), + conversation_id, + reuse_policy: "per_run".to_string(), + server_base_url: Some(base_url.to_owned()), + transport_target: Some(DEVIN_CLOUD_AGENT_KIND.to_string()), + http_auth_mode: Some("bearer".to_string()), + websocket_auth_mode: None, + websocket_query_param_name: None, + persistence_dir: workspace.metadata_dir(), + created_at: now, + updated_at: now, + last_attached_at: now, + launch_profile: None, + llm_config_fingerprint: None, + fresh_conversation: true, + workflow_prompt_seeded: true, + reset_reason: None, + runtime_contract_version: Some(DEVIN_CLOUD_API_CONTRACT.to_string()), + runtime_envelope: None, + parent_runtime_envelope: None, + codex_archive_state: None, + last_turn_id: None, + active_run_id: Some(run_manifest.run_id.clone()), + prepared_run_id: None, + trigger_pending_run_id: None, + last_prompt_kind: None, + last_prompt_at: Some(now), + last_prompt_path: None, + last_execution_status: None, + last_event_id: None, + last_event_kind: Some("devin.session_created".into()), + last_event_at: Some(now), + last_event_summary: Some(route.summary()), + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + last_token_accumulation_at: None, + }; + workspace_manager + .write_json_artifact_atomically( + workspace, + &pending_conversation_manifest_path(workspace), + &Some(&manifest), + ) + .await + .map_err(|error| format!("failed to persist devin session binding: {error}"))?; + workspace_manager + .write_json_artifact( + workspace, + &workspace.conversation_manifest_path(), + &manifest, + ) + .await + .map_err(|error| format!("failed to persist devin session binding: {error}"))?; + run_manifest.status = RunStatus::Running; + run_manifest.status_detail = Some(format!("devin session {session_id} is running")); + workspace_manager + .write_run_manifest(workspace, run_manifest) + .await + .map_err(|error| format!("failed to persist devin run state: {error}"))?; + Ok(()) +} + +/// Stops the remote session if the worker task is dropped mid-run. +/// +/// Scheduler aborts drop the worker future, which for a local harness kills the +/// child process. Devin's session lives in Devin's cloud, so it has to be +/// stopped explicitly or it would keep burning ACUs unattended. +struct DevinSessionGuard { + client: Option, + session_id: String, + registry: DevinSessionRegistry, +} + +impl DevinSessionGuard { + fn new( + client: DevinCloudClient, + session_id: String, + worker_id: &str, + registry: DevinSessionRegistry, + ) -> Self { + if let Ok(mut sessions) = registry.lock() { + sessions.insert( + session_id.clone(), + TrackedDevinSession { + worker_id: worker_id.to_owned(), + client: client.clone(), + }, + ); + } + Self { + client: Some(client), + session_id, + registry, + } + } + + /// The session settled on its own; nothing left to stop. + fn disarm(&mut self) { + self.client = None; + } +} + +impl Drop for DevinSessionGuard { + fn drop(&mut self) { + // `abort_worker` and `shutdown` untrack a session once they have + // terminated it; a guard dropped after that has nothing left to stop. + let still_tracked = self + .registry + .lock() + .map(|mut sessions| sessions.remove(&self.session_id).is_some()) + .unwrap_or(true); + let Some(client) = self.client.take() else { + return; + }; + if !still_tracked { + return; + } + // `drop` cannot await. The guarantees live elsewhere: `abort_worker` + // and `shutdown` terminate tracked sessions *before* aborting the task, + // and the conversation manifest keeps the binding so the pre-creation + // fence and terminal cleanup stop anything that slipped through. This + // spawn is opportunistic only and may not run during runtime teardown. + let session_id = self.session_id.clone(); + tracing::warn!( + session_id, + "devin worker dropped while its session was live; binding retained for cleanup" + ); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + if let Err(error) = client.stop_session(&session_id).await { + tracing::warn!( + session_id, + error = %error, + "failed to stop devin session after worker drop" + ); + } + }); + } + } +} + +/// Runs one issue on Devin: bind tenancy, create the remote session, stream +/// normalized events to the orchestrator, then import evidence locally. +async fn run_devin_cloud_route(context: DevinRouteContext<'_>) -> Result { + let DevinRouteContext { + workflow, + issue, + run, + route, + run_id, + workspace_manager, + workspace, + run_manifest, + updates_tx, + devin_sessions, + worker_id, + launch_tx, + reattach, + } = context; + + // A reattached session is addressed through the identity persisted with + // its binding, not through whatever the workflow points at today. + let config = devin_config_for_identity( + &devin_cloud_config(workflow), + reattach + .as_ref() + .and_then(|target| target.identity.as_ref()), + ); + let repository = devin_checkout_repository(issue, run, workspace_manager)?; + let binding = devin_remote_binding(issue, run, run_id, &repository)?; + let attempt = run.attempt.map(|attempt| attempt.get()).unwrap_or(1); + let mut repository_instructions = None; + + let client = DevinCloudClient::from_environment(&config, |name| ProcessEnvironment.get(name)) + .map_err(|error| format!("devin client configuration failed: {error}"))?; + // No session traffic before the credential's organization is verified. + let client = client + .bind_tenant() + .await + .map_err(|error| format!("devin tenant binding failed: {error}"))?; + + let tenant_org_id = client + .tenancy() + .map(|tenancy| tenancy.org_id.clone()) + .ok_or_else(|| "devin tenancy was not bound".to_owned())?; + + let (created, reattached) = match reattach { + // A restart inherited a live session: follow it. Creating a second + // session would orphan the first one, which keeps running and billing. + Some(target) => match client.get_session(&target.session_id).await { + Ok(session) => (session, true), + Err(error) => { + return Ok(devin_reattach_failure( + launch_tx, + &target.session_id, + &config.base_url, + route, + &error.to_string(), + )); + } + }, + None => { + // Durable fence: a binding left behind by a dropped or crashed + // worker still names a live session. It is stopped before any + // replacement exists, and replacement is refused when it cannot be. + if let Some(stale_session_id) = devin_session_binding(workspace_manager, workspace) + .await + .map_err(|error| format!("failed to read devin session binding: {error}"))? + { + let stale_identity = read_devin_route_identity(workspace_manager, workspace) + .await + .map_err(|error| format!("failed to read devin route identity: {error}"))?; + if let Err(detail) = terminate_devin_session_for_cleanup( + &config, + stale_identity.as_ref(), + &stale_session_id, + ) + .await + { + return Ok(devin_replacement_refused( + launch_tx, + &stale_session_id, + stale_identity + .as_ref() + .map_or(config.base_url.as_str(), |identity| { + identity.base_url.as_str() + }), + route, + &detail, + )); + } + tracing::info!( + session_id = %stale_session_id, + "terminated the previously bound devin session before creating a replacement" + ); + } + + // Devin has no local checkout to read the repository's instruction + // file from, so it is fetched from the remote at the target branch + // and rendered into the prompt exactly like a local harness sees it. + let instructions = fetch_devin_repository_instructions(&repository).await?; + let task_prompt = devin_task_prompt( + workflow, + issue, + attempt, + route, + instructions.as_ref().map(|found| found.body.as_str()), + )?; + repository_instructions = instructions.map(|found| found.record); + + let mut session_options = config.session.clone(); + if let Some(references) = session_options.secret_ids.as_ref() { + let resolved = client + .resolve_secret_ids(references) + .await + .map_err(|error| format!("devin secret resolution failed: {error}"))?; + session_options.secret_ids = Some(resolved); + } + + let request = session_create_request(&task_prompt, &binding, &session_options); + let not_before = chrono::Utc::now().timestamp() - DEVIN_CREATION_CLOCK_SKEW_SECS; + let created = match client.create_session(&request).await { + Ok(created) => created, + // Devin may have accepted the request even though the response + // was lost or undecodable. A retry is only safe once the + // correlation tag proves no session was created. + Err(create_error) => { + let correlation_tag = binding.correlation_tag(); + match client + .reconcile_created_session(&correlation_tag, not_before) + .await + { + Ok(Some(session)) => { + tracing::warn!( + session_id = %session.session_id, + error = %create_error, + "devin session creation reported an error but the tagged session exists; following it" + ); + session + } + Ok(None) => { + return Err(format!( + "devin session creation failed: {create_error} (no unarchived session tagged `{correlation_tag}` exists, so a retry is safe)" + )); + } + Err(lookup_error) => { + return Ok(devin_unreconciled_creation( + launch_tx, + &correlation_tag, + &config.base_url, + route, + &create_error.to_string(), + &lookup_error.to_string(), + )); + } + } + } + }; + (created, false) + } + }; + let mut guard = DevinSessionGuard::new( + client.clone(), + created.session_id.clone(), + worker_id.as_str(), + Arc::clone(devin_sessions), + ); + let identity = DevinRouteIdentity { + session_id: created.session_id.clone(), + base_url: config.base_url.clone(), + org_id: tenant_org_id, + api_key_env: config.api_key_env.clone(), + }; + // The session binding and run state must be durable before the scheduler + // hears about the launch: cancellation and restart recovery reach the live + // session only through these records, and a failure here leaves the guard + // armed so the remote session is terminated rather than orphaned. + write_devin_conversation_manifest( + workspace_manager, + workspace, + issue, + &identity, + route, + run_manifest, + ) + .await?; + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(devin_conversation_metadata( + &created.session_id, + &config.base_url, + route, + )), + started_at: run_manifest.started_at.map(datetime_to_timestamp_ms), + }); + } + + let mut collector = DevinEvidenceCollector::new( + binding.local_evidence_path.clone(), + DevinEvidenceLimits::default(), + ); + collector.set_repository_instructions(repository_instructions); + let created_event = if reattached { + status_event(&created) + } else { + session_created_event(&created) + }; + collector.record(&created_event); + publish_devin_event(updates_tx, worker_id, &created_event); + + let runner = DevinSessionRunner::new( + client.clone(), + devin_run_options(workflow.config.agent.stall_timeout_ms), + ); + let report = runner + .follow(&created.session_id, |event| { + collector.record(&event); + publish_devin_event(updates_tx, worker_id, &event); + }) + .await; + let report = match report { + Ok(report) => report, + Err(error) => { + // Polling gave up (timeout, transport failure) while the remote + // session is still live, so it has to be stopped explicitly rather + // than left burning ACUs in Devin's cloud. + match client.stop_session(&created.session_id).await { + Ok(_) => { + guard.disarm(); + return Err(format!( + "devin session polling failed: {error} (remote session terminated)" + )); + } + Err(stop_error) => { + // Ownership stays with this run: the session may still be + // executing and billing, so the guard keeps retrying + // termination on drop and the outcome must not authorize a + // replacement session. + tracing::warn!( + session_id = %created.session_id, + error = %stop_error, + "failed to stop devin session after polling failure" + ); + return Ok(DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!( + "Devin session {} could not be terminated after a polling failure", + created.session_id + ), + error: Some(format!( + "devin session polling failed: {error} (remote session termination failed: {stop_error}; session {} may still be running at {})", + created.session_id, created.url + )), + run_status: RunStatus::Failed, + }); + } + } + } + }; + + if matches!(report.outcome, DevinRunOutcome::Suspended) { + // A suspended session still exists remotely; stop it before the + // scheduler is allowed to start a replacement, and before evidence + // import gets the chance to fail with the session still alive. + if let Err(stop_error) = client.stop_session(&created.session_id).await { + return Ok(DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!( + "Devin session {} was suspended and could not be terminated", + created.session_id + ), + error: Some(format!( + "remote session termination failed: {stop_error}; session {} may still be running at {}", + created.session_id, created.url + )), + run_status: RunStatus::Failed, + }); + } + } + + let settlement = devin_settlement(&report); + // A detached session is deliberately left alive and bound: the operator's + // answer belongs to it. Every other outcome keeps the guard armed until the + // local evidence is durable, because a run without evidence is retried and + // the retry must not start beside an unarchived predecessor. + if settlement.outcome == WorkerOutcomeKind::Detached { + guard.disarm(); + } + + let manifest = match collector.persist(&client, &report).await { + Ok(manifest) => manifest, + Err(error) => { + // Losing local evidence must not turn a session that is still + // alive into a retryable failure: the operator's answer belongs to + // the session the conversation manifest still points at. + if non_retrying_devin_outcome(settlement.outcome).is_some() { + return Ok(DevinSettlement { + error: Some(match settlement.error { + Some(previous) => { + format!("{previous}; devin evidence import failed: {error}") + } + None => format!("devin evidence import failed: {error}"), + }), + ..settlement + }); + } + // The settled session is archived (awaited, not left to `drop`) + // before the retryable failure is reported; if even that fails, + // ownership stays with this run. + if let Err(stop_error) = client.stop_session(&created.session_id).await { + return Ok(DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!( + "Devin session {} could not be archived after an evidence import failure", + created.session_id + ), + error: Some(format!( + "devin evidence import failed: {error} (remote session archival failed: {stop_error}; session {} may still exist at {})", + created.session_id, created.url + )), + run_status: RunStatus::Failed, + }); + } + guard.disarm(); + return Err(format!("devin evidence import failed: {error}")); + } + }; + guard.disarm(); + let settlement = if manifest.message_pull_request_urls.is_empty() { + settlement + } else { + DevinSettlement { + summary: format!( + "{}; pull requests reported in messages: {}", + settlement.summary, + manifest.message_pull_request_urls.join(", ") + ), + ..settlement + } + }; + let _ = updates_tx.send(WorkerUpdate::RuntimeEvent { + worker_id: worker_id.clone(), + observed_at: now_timestamp(), + event_id: Some(format!("{}::evidence", report.session_id)), + event_kind: Some("devin.evidence_imported".to_owned()), + summary: Some(format!( + "Imported Devin evidence for {} ({} events, {} attachments)", + report.session_id, + manifest.event_count, + manifest.attachments.len() + )), + payload: serde_json::to_value(&manifest).ok(), + }); + + Ok(settlement) +} + +/// Maps a settled Devin session onto a scheduler outcome. +fn devin_settlement(report: &DevinRunReport) -> DevinSettlement { + let pull_requests = report + .pull_requests + .iter() + .map(|pull_request| pull_request.pr_url.as_str()) + .collect::>() + .join(", "); + let summary = format!( + "Devin session {} {} ({:.2} ACUs){}", + report.session_id, + report.outcome.as_str(), + report.acus_consumed, + if pull_requests.is_empty() { + String::new() + } else { + format!("; pull requests: {pull_requests}") + } + ); + let (outcome, run_status, error) = match report.outcome { + DevinRunOutcome::Finished => (WorkerOutcomeKind::Succeeded, RunStatus::Succeeded, None), + // Devin is waiting for a human and the session stays alive, so the run + // detaches instead of retrying: a retry would start a second session + // while the operator's answer still belongs to the first one, which + // remains reachable through the persisted conversation manifest. + DevinRunOutcome::WaitingOnOperator => ( + WorkerOutcomeKind::Detached, + RunStatus::Paused, + Some(format!( + "devin session {} is waiting on operator input (status `{}`) and stays bound: {}", + report.session_id, + report.status.as_str(), + report.session_url + )), + ), + // Reached only after the route terminated the suspended session. + DevinRunOutcome::Suspended => ( + WorkerOutcomeKind::Stalled, + RunStatus::Failed, + Some(format!( + "devin session {} was suspended and terminated", + report.session_id + )), + ), + DevinRunOutcome::Failed => ( + WorkerOutcomeKind::Failed, + RunStatus::Failed, + Some(format!( + "devin session {} reported status `{}`", + report.session_id, + report.status.as_str() + )), + ), + DevinRunOutcome::Terminated => ( + WorkerOutcomeKind::Cancelled, + RunStatus::Cancelled, + Some(format!( + "devin session {} was terminated", + report.session_id + )), + ), + }; + + DevinSettlement { + outcome, + summary, + error, + run_status, + } +} + +fn publish_devin_event( + updates_tx: &mpsc::UnboundedSender, + worker_id: &crate::opensymphony_domain::WorkerId, + event: &crate::opensymphony_devin::NormalizedDevinEvent, +) { + let _ = updates_tx.send(WorkerUpdate::RuntimeEvent { + worker_id: worker_id.clone(), + observed_at: now_timestamp(), + event_id: event.event_id.clone(), + event_kind: Some(event.event_type.clone()), + summary: Some(devin_event_summary(event)), + payload: Some(devin_event_payload(event)), + }); +} + +/// Resolves the Devin-owned remote workspace binding for an issue. +/// +/// The local path handed to the binding is an evidence directory inside the +/// scheduler-bound issue workspace; it is never Devin's working directory. +/// Checkout policy of the repository the issue is bound to; Devin clones that +/// remote itself, OpenSymphony only needs its identity, branch and credential. +fn devin_checkout_repository( + issue: &NormalizedIssue, + run: &crate::opensymphony_domain::RunAttempt, + workspace_manager: &WorkspaceManager, +) -> Result { + let repository_binding = run + .repository_binding + .as_ref() + .or_else(|| { + issue + .repository_binding + .as_ref() + .and_then(RepositoryBindingOutcome::resolved_binding) + }) + .ok_or_else(|| { + "devin routing requires a resolved repository binding for the issue".to_owned() + })?; + workspace_manager + .checkout_repository(repository_binding.repository_id().as_str()) + .cloned() + .ok_or_else(|| { + format!( + "repository `{}` has no configured checkout policy to bind a devin workspace", + repository_binding.repository_id() + ) + }) +} + +fn devin_remote_binding( + issue: &NormalizedIssue, + run: &crate::opensymphony_domain::RunAttempt, + run_id: &str, + repository: &CheckoutRepository, +) -> Result { + let evidence_path = run + .workspace_path + .join(".opensymphony") + .join(DEVIN_EVIDENCE_DIR_NAME) + .join(run_id); + + DevinRemoteWorkspaceBinding::new( + issue.identifier.to_string(), + evidence_path, + repository.remote.clone(), + Some(repository.target_branch.clone()), + ) + .map_err(|error| format!("devin repository binding rejected: {error}")) +} + +/// Polling policy for a scheduler-owned Devin session. +/// +/// A session that stops to ask its operator a question (`waiting_for_user`, +/// `waiting_for_approval`) settles the run as `Detached` instead of being +/// polled until Devin suspends it for inactivity: that suspension would be +/// reported as a stall and retried with a replacement session even though the +/// original one may already have delivered its work. +/// +/// Devin emits no message or status change while it works through a long +/// step, so the runner publishes a liveness event for an executing session +/// well inside the scheduler's stall window; otherwise that silence would be +/// aborted as a stalled worker and replaced with a fresh session. +fn devin_run_options(stall_timeout_ms: Option) -> DevinRunOptions { + let heartbeat_interval = match stall_timeout_ms { + Some(stall) => Duration::from_millis(stall / 3).min(DEFAULT_DEVIN_HEARTBEAT_INTERVAL), + None => DEFAULT_DEVIN_HEARTBEAT_INTERVAL, + }; + DevinRunOptions { + stop_when_waiting_on_operator: true, + max_duration: None, + collect_attachments: true, + message_page_size: 100, + heartbeat_interval: Some(heartbeat_interval.max(Duration::from_secs(1))), + } +} + +/// Instruction file fetched from the repository remote for a Devin prompt. +#[derive(Debug)] +struct DevinFetchedInstructions { + record: crate::opensymphony_devin::DevinRepositoryInstructions, + body: String, +} + +/// GitHub contents URL of the repository's instruction file at the target +/// branch. Each path component becomes its own segment so names are escaped +/// and the path cannot climb out of the repository. +fn devin_instructions_contents_url( + api_root: &str, + owner: &str, + repository_name: &str, + instructions_path: &Path, + target_branch: &str, +) -> Result { + let mut url = + Url::parse(api_root).map_err(|error| format!("invalid GitHub API root: {error}"))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| "GitHub API root cannot be a base URL".to_owned())?; + segments + .pop_if_empty() + .push("repos") + .push(owner) + .push(repository_name) + .push("contents"); + for component in instructions_path.components() { + match component { + std::path::Component::Normal(name) => { + segments.push(name.to_str().ok_or_else(|| { + format!( + "instruction path `{}` is not valid UTF-8", + instructions_path.display() + ) + })?); + } + std::path::Component::CurDir => {} + _ => { + return Err(format!( + "instruction path `{}` must stay inside the repository", + instructions_path.display() + )); + } + } + } + } + url.query_pairs_mut().append_pair("ref", target_branch); + Ok(url) +} + +/// Reads the repository's configured instruction file from its GitHub remote. +/// +/// Returns `None` when the repository configures no instruction file. A +/// configured file that cannot be fetched is a launch failure, matching the +/// local route, which refuses to start without its verified checkout +/// instructions rather than silently running without them. +async fn fetch_devin_repository_instructions( + repository: &CheckoutRepository, +) -> Result, String> { + if repository.instructions_path.as_os_str().is_empty() { + return Ok(None); + } + if repository.provider != "github" { + return Err(format!( + "repository instructions `{}` cannot be fetched for provider `{}`; devin routing reads instructions from GitHub remotes only", + repository.instructions_path.display(), + repository.provider + )); + } + let (api_root, owner, repository_name) = + github_repository_api(repository).map_err(|error| error.to_string())?; + let url = devin_instructions_contents_url( + &api_root, + &owner, + &repository_name, + &repository.instructions_path, + &repository.target_branch, + )?; + let token = repository + .credential_env + .as_deref() + .or(repository.review_credential_env.as_deref()) + .map(|name| { + env::var(name) + .ok() + .filter(|token| !token.trim().is_empty()) + .ok_or_else(|| { + format!("configured repository credential variable `{name}` is not set") + }) + }) + .transpose()?; + fetch_devin_repository_instructions_at(repository, url, token).await +} + +async fn fetch_devin_repository_instructions_at( + repository: &CheckoutRepository, + url: Url, + token: Option, +) -> Result, String> { + let http = reqwest::Client::builder() + .timeout(GITHUB_ELIGIBILITY_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("GitHub client: {error}"))?; + let mut request = http + .get(url.clone()) + .header(reqwest::header::USER_AGENT, "opensymphony-orchestrator") + .header(reqwest::header::ACCEPT, "application/vnd.github.raw+json"); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(|error| { + format!( + "failed to fetch repository instructions `{}` from {}: {error}", + repository.instructions_path.display(), + repository.remote_locator + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "failed to fetch repository instructions `{}` at `{}` from {}: HTTP {}", + repository.instructions_path.display(), + repository.target_branch, + repository.remote_locator, + status.as_u16() + )); + } + let too_large = || { + format!( + "repository instructions `{}` exceed the {} byte limit", + repository.instructions_path.display(), + MAX_INSTRUCTION_FILE_BYTES + ) + }; + if response + .content_length() + .is_some_and(|length| length > MAX_INSTRUCTION_FILE_BYTES) + { + return Err(too_large()); + } + let mut response = response; + let mut raw: Vec = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("failed to read repository instructions: {error}"))? + { + if (raw.len() as u64).saturating_add(chunk.len() as u64) > MAX_INSTRUCTION_FILE_BYTES { + return Err(too_large()); + } + raw.extend_from_slice(&chunk); + } + let mut hasher = Sha256::new(); + hasher.update(&raw); + let content_hash = format!("sha256:{:x}", hasher.finalize()); + let body = String::from_utf8_lossy(&instruction_body(&repository.instructions_path, &raw)) + .into_owned(); + Ok(Some(DevinFetchedInstructions { + record: crate::opensymphony_devin::DevinRepositoryInstructions { + path: repository.instructions_path.to_string_lossy().into_owned(), + git_ref: repository.target_branch.clone(), + content_hash, + bytes: raw.len() as u64, + }, + body, + })) +} + +/// Renders the workflow prompt plus the issue facts Devin needs. +fn devin_task_prompt( + workflow: &ResolvedWorkflow, + issue: &NormalizedIssue, + attempt: u32, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + repository_instructions: Option<&str>, +) -> Result { + let central_procedure = workflow + .render_prompt(issue, Some(attempt)) + .map_err(|error| format!("failed to render workflow prompt: {error}"))?; + + Ok(compose_terminal_prompt( + ¢ral_procedure, + &format!( + "Issue: {}\nTitle: {}\nAttempt: {}\nDescription:\n{}", + issue.identifier, + issue.title, + attempt, + issue + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + .unwrap_or("No tracker description provided."), + ), + "Work in your own Devin cloud workspace. The OpenSymphony workspace on \ + the orchestrator host stores evidence only and is not reachable from \ + your environment.", + repository_instructions, + &format!( + "harness={} containment={}", + route.harness_kind, + crate::opensymphony_devin::DEVIN_REMOTE_CONTAINMENT + ), + )) +} + +fn devin_conversation_metadata( + session_id: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, +) -> ConversationMetadata { + ConversationMetadata { + harness_capability: None, + conversation_id: ConversationId::new(session_id.to_owned()) + .expect("devin session id should not be empty"), + server_base_url: Some(base_url.to_owned()), + transport_target: Some(route.harness_kind.clone()), + http_auth_mode: Some("bearer".to_owned()), + websocket_auth_mode: None, + websocket_query_param_name: None, + fresh_conversation: true, + runtime_contract_version: Some(DEVIN_CLOUD_API_CONTRACT.to_owned()), + stream_state: RuntimeStreamState::Closed, + last_event_id: None, + last_event_kind: None, + last_event_at: None, + last_event_summary: Some(route.summary()), + recent_activity: Vec::new(), + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + total_tokens: 0, + runtime_seconds: 0, + next_activity_sequence: 0, + } +} + +/// Settle a session that could not be reattached after a restart. +/// +/// The launch has to be reported as successful so the worker registers and the +/// scheduler observes the non-retrying settlement. A launch failure instead +/// becomes a launch error the scheduler retries, which starts exactly the +/// replacement session this outcome exists to prevent. +fn devin_reattach_failure( + launch_tx: &mut Option>, + session_id: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + error: &str, +) -> DevinSettlement { + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(devin_conversation_metadata(session_id, base_url, route)), + started_at: None, + }); + } + DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!("Devin session {session_id} could not be reattached"), + error: Some(format!( + "devin session `{session_id}` could not be reattached after restart: {error}" + )), + run_status: RunStatus::Failed, + } +} + +/// Settle a run whose workspace still binds a session that could not be +/// stopped. The bound session is registered with the scheduler so the run is +/// not retried into a second live session. +fn devin_replacement_refused( + launch_tx: &mut Option>, + session_id: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + error: &str, +) -> DevinSettlement { + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(devin_conversation_metadata(session_id, base_url, route)), + started_at: None, + }); + } + DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!("Devin session {session_id} is still bound and could not be terminated"), + error: Some(format!( + "a replacement devin session was refused because the bound session `{session_id}` could not be terminated: {error}" + )), + run_status: RunStatus::Failed, + } +} + +/// Settle a creation attempt whose outcome is unknown: the create call failed +/// and the correlation-tag lookup failed too, so a session may exist unseen. +/// Retrying could start a second one, so the outcome is non-retrying and the +/// operator is pointed at the tag to search for in Devin. +fn devin_unreconciled_creation( + launch_tx: &mut Option>, + correlation_tag: &str, + base_url: &str, + route: &crate::opensymphony_orchestrator::HarnessRouteDecision, + create_error: &str, + lookup_error: &str, +) -> DevinSettlement { + let placeholder = format!("unreconciled:{correlation_tag}"); + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Conversation { + conversation: Box::new(devin_conversation_metadata(&placeholder, base_url, route)), + started_at: None, + }); + } + DevinSettlement { + outcome: WorkerOutcomeKind::CancelFailed, + summary: format!( + "Devin session creation could not be reconciled; check sessions tagged `{correlation_tag}`" + ), + error: Some(format!( + "devin session creation failed ({create_error}) and the correlation lookup failed ({lookup_error}); a session tagged `{correlation_tag}` may exist and must be checked before this issue is retried" + )), + run_status: RunStatus::Failed, + } +} + +fn report_launch_failure( + launch_tx: &mut Option>, + detail: impl Into, +) { + if let Some(sender) = launch_tx.take() { + let _ = sender.send(LaunchReport::Failed(detail.into())); + } +} + +fn parent_harness_switch_error(is_parent: bool, switching_harness: bool) -> Option { + (is_parent && switching_harness).then(|| { + "parent integration cannot switch harnesses while its authoritative conversation is bound" + .to_owned() + }) +} + +fn expected_parent_conversation_error( + expected: Option<&str>, + recovered: Option<&str>, +) -> Option { + let expected = expected?; + match recovered { + Some(actual) if actual == expected => None, + Some(actual) => Some(format!( + "parent integration requires conversation `{expected}`, but the persisted manifest binds `{actual}`" + )), + None => Some(format!( + "parent integration requires conversation `{expected}`, but its persisted manifest is missing" + )), + } +} + +async fn read_verified_integration_instructions( + instructions: Option<&ResolvedIntegrationInstructions>, +) -> Result, String> { + let Some(instructions) = instructions else { + return Ok(None); + }; + let content = fs::read(&instructions.path).await.map_err(|error| { + format!( + "failed to read project-set integration instructions before harness attach: {error}" + ) + })?; + let mut hasher = Sha256::new(); + hasher.update(&content); + let actual = format!("sha256:{:x}", hasher.finalize()); + if actual != instructions.content_hash { + return Err( + "project-set integration instructions changed before harness attach".to_owned(), + ); + } + Ok(Some(String::from_utf8_lossy(&content).into_owned())) +} + +fn pending_launch_failure_detail(result: &Result) -> String { + match result { + Ok(result) => { + let detail = result + .worker_outcome + .error + .clone() + .or_else(|| result.worker_outcome.summary.clone()) + .unwrap_or_else(|| { + "worker finished before reporting a conversation launch".to_string() + }); + format!("worker finished before reporting a conversation launch: {detail}") + } + Err(error) => format!("worker failed before reporting a conversation launch: {error}"), + } +} + +impl WorkerBackend for RuntimeWorkerBackend { + type Error = CliWorkerError; + + async fn start_worker( + &mut self, + request: WorkerStartRequest, + ) -> Result { + let pending = self.spawn_worker_task(request, false); + let worker_id = pending.worker_id.clone(); let route = pending.route.clone(); let launch_timeout = self.launch_timeout_for_route(&route); self.resolve_launch_result( @@ -10509,6 +12170,22 @@ impl WorkerBackend for RuntimeWorkerBackend { worker_id: &crate::opensymphony_domain::WorkerId, reason: WorkerAbortReason, ) -> Result<(), Self::Error> { + let issue_identifier = self + .worker_issue_ids + .get(worker_id.as_str()) + .cloned() + .or_else(|| { + self.tasks + .get(worker_id.as_str()) + .map(|task| task.run.issue_identifier.to_string()) + }); + let owns_devin_session = self.owns_devin_session(worker_id.as_str()); + self.terminate_tracked_devin_sessions(Some(worker_id.as_str())) + .await; + if owns_devin_session { + self.settle_devin_tasks(Some(worker_id.as_str()), DEVIN_SETTLE_TIMEOUT) + .await; + } let acp_session = self .acp_active .lock() @@ -10572,14 +12249,7 @@ impl WorkerBackend for RuntimeWorkerBackend { } } } - let issue_identifier = self - .worker_issue_ids - .remove(worker_id.as_str()) - .or_else(|| { - self.tasks - .get(worker_id.as_str()) - .map(|task| task.run.issue_identifier.to_string()) - }); + self.worker_issue_ids.remove(worker_id.as_str()); let revoke_after_stop = matches!( reason, WorkerAbortReason::TrackerInactive @@ -10602,6 +12272,15 @@ impl WorkerBackend for RuntimeWorkerBackend { Ok(()) } + async fn shutdown(&mut self) { + let owns_devin_sessions = self.owns_devin_session_any(); + self.terminate_tracked_devin_sessions(None).await; + if owns_devin_sessions { + self.settle_devin_tasks(None, DEVIN_SETTLE_TIMEOUT).await; + } + self.abort_all_tracked_tasks(); + } + async fn interrupt_worker( &mut self, command: crate::opensymphony_domain::HarnessInterruptCommand, @@ -10612,6 +12291,9 @@ impl WorkerBackend for RuntimeWorkerBackend { if command.harness_kind == CODEX_APP_SERVER_KIND { return send_codex_stdio_interrupt(&self.codex_interrupts, &command).await; } + if command.harness_kind == DEVIN_CLOUD_AGENT_KIND { + return stop_devin_session(&self.devin_sessions, &self.workflow, &command).await; + } if command.harness_kind != OPENHANDS_AGENT_SERVER_KIND { return Err(CliWorkerError::InterruptFailed(format!( "harness `{}` does not expose a scheduler-side interrupt channel", @@ -10655,6 +12337,65 @@ impl WorkerBackend for RuntimeWorkerBackend { } } +/// Stops the remote Devin session backing a conversation. +/// +/// Devin has no mid-turn interrupt; the documented control is terminating the +/// session, which is also what keeps a cancelled run from burning ACUs. +async fn stop_devin_session( + registry: &DevinSessionRegistry, + workflow: &ResolvedWorkflow, + command: &crate::opensymphony_domain::HarnessInterruptCommand, +) -> Result { + let session_id = command.conversation_id.as_str(); + let tracked = registry + .lock() + .map_err(|_| { + CliWorkerError::InterruptFailed("Devin session registry lock poisoned".to_string()) + })? + .get(session_id) + .map(|tracked| tracked.client.clone()); + // After a daemon restart the in-process registry is empty, but the session + // binding survives in the conversation manifest and the session is still + // billing. Rebuild a tenant-bound client so cancellation still reaches it. + let client = match tracked { + Some(client) => client, + None => { + let config = devin_cloud_config(workflow); + DevinCloudClient::from_environment(&config, |name| ProcessEnvironment.get(name)) + .map_err(|error| { + CliWorkerError::InterruptFailed(format!( + "devin session `{session_id}` is untracked and no client could be built: {error}" + )) + })? + .bind_tenant() + .await + .map_err(|error| { + CliWorkerError::InterruptFailed(format!( + "devin tenant binding failed while stopping `{session_id}`: {error}" + )) + })? + } + }; + + let outcome = client + .stop_session(session_id) + .await + .map_err(|error| CliWorkerError::InterruptFailed(error.to_string()))?; + Ok(WorkerInterruptAcknowledgement { + accepted: true, + detail: Some(match outcome { + DevinStopOutcome::Terminated => { + format!("devin session `{session_id}` terminated and archived") + } + DevinStopOutcome::AlreadyStopped => { + format!("devin session `{session_id}` had already stopped; archived") + } + DevinStopOutcome::Missing => format!("devin session `{session_id}` no longer exists"), + }), + timed_out: false, + }) +} + fn openhands_execution_stopped(status: &str) -> bool { matches!(status, "paused" | "idle" | "finished" | "error" | "stuck") } @@ -11626,6 +13367,7 @@ mod tests { fn strict_recovery_requires_bound_run_and_conversation_envelopes() { let now = chrono::Utc::now(); let run_manifest = RunManifest { + terminal_worker_outcome: None, run_id: "run-strict-envelope".to_owned(), issue_id: "issue-contract".to_owned(), identifier: "COE-479".to_owned(), @@ -11704,6 +13446,7 @@ mod tests { })) .expect("sample runtime envelope should decode"); let run_manifest = RunManifest { + terminal_worker_outcome: None, run_id: "run-strict-pending".to_owned(), issue_id: "issue-contract".to_owned(), identifier: "COE-479".to_owned(), @@ -11790,6 +13533,7 @@ mod tests { .expect("sample runtime envelope should decode"); runtime_envelope.conversation_binding = Some("conv-unsent-prompt".to_owned()); let run_manifest = RunManifest { + terminal_worker_outcome: None, run_id: "run-unsent-prompt".to_owned(), issue_id: "issue-contract".to_owned(), identifier: "COE-479".to_owned(), @@ -13087,21 +14831,91 @@ mod tests { } #[tokio::test] - async fn runtime_workspace_cleanup_runs_manager_cleanup_for_invalid_manifest() { + async fn runtime_workspace_cleanup_runs_manager_cleanup_for_invalid_manifest() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspaces"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = Arc::new( + WorkspaceManager::new(WorkspaceManagerConfig { + root: workspace_root, + hooks: HookConfig { + before_remove: Some(HookDefinition::shell( + "echo before_remove >> .opensymphony/logs/before_remove.txt", + )), + ..HookConfig::default() + }, + cleanup: CleanupConfig { + remove_terminal_workspaces: false, + }, + }) + .expect("workspace manager should be constructed"), + ); + let issue = sample_terminal_issue(); + let ensured = workspace_manager + .ensure(&issue_descriptor(&issue)) + .await + .expect("workspace should be ensured"); + workspace_manager + .write_text_artifact( + &ensured.handle, + &ensured.handle.conversation_manifest_path(), + "{\"canonical\":\"thread\"}", + ) + .await + .expect("invalid conversation manifest should persist"); + let workspace = crate::opensymphony_domain::WorkspaceRecord { + path: ensured.handle.workspace_path().to_path_buf(), + workspace_key: WorkspaceKey::new(ensured.handle.workspace_key().to_string()) + .expect("workspace key should be valid"), + created_now: false, + created_at: None, + updated_at: None, + last_seen_tracker_refresh_at: None, + }; + let mut backend = RuntimeWorkspaceBackend::new(Arc::clone(&workspace_manager), &workflow); + + backend + .cleanup_workspace(&workspace, true) + .await + .expect("invalid manifest cleanup should still run manager cleanup"); + + assert_eq!( + fs::read_to_string(ensured.handle.logs_dir().join("before_remove.txt")) + .expect("before-remove hook should run for invalid manifest") + .trim(), + "before_remove" + ); + assert!(ensured.handle.conversation_manifest_path().is_file()); + assert!(backend.terminal_cleanup_paths.contains(&workspace.path)); + backend + .cleanup_workspace(&workspace, true) + .await + .expect("repeated terminal cleanup should keep hooks once-only"); + + assert_eq!( + fs::read_to_string(ensured.handle.logs_dir().join("before_remove.txt")) + .expect("before-remove hook should remain once-only") + .trim(), + "before_remove" + ); + } + + #[tokio::test] + async fn terminal_cleanup_retains_a_devin_workspace_until_the_session_is_stopped() { let tempdir = TempDir::new().expect("tempdir should exist"); let workspace_root = tempdir.path().join("workspaces"); - let workflow = sample_workflow(tempdir.path(), &workspace_root); + let mut workflow = sample_workflow(tempdir.path(), &workspace_root); + // An unset credential makes the termination attempt fail deterministically + // without reaching the network, which is the case cleanup must not treat + // as "the remote session is gone". + workflow.extensions.devin.api.api_key_env = + "OPENSYMPHONY_DEVIN_CLEANUP_PROBE_TOKEN".to_string(); let workspace_manager = Arc::new( WorkspaceManager::new(WorkspaceManagerConfig { root: workspace_root, - hooks: HookConfig { - before_remove: Some(HookDefinition::shell( - "echo before_remove >> .opensymphony/logs/before_remove.txt", - )), - ..HookConfig::default() - }, + hooks: HookConfig::default(), cleanup: CleanupConfig { - remove_terminal_workspaces: false, + remove_terminal_workspaces: true, }, }) .expect("workspace manager should be constructed"), @@ -13111,14 +14925,16 @@ mod tests { .ensure(&issue_descriptor(&issue)) .await .expect("workspace should be ensured"); + let mut manifest = sample_conversation_manifest("devin-session-detached"); + manifest.transport_target = Some(DEVIN_CLOUD_AGENT_KIND.to_string()); workspace_manager - .write_text_artifact( + .write_json_artifact( &ensured.handle, &ensured.handle.conversation_manifest_path(), - "{\"canonical\":\"thread\"}", + &manifest, ) .await - .expect("invalid conversation manifest should persist"); + .expect("devin conversation manifest should persist"); let workspace = crate::opensymphony_domain::WorkspaceRecord { path: ensured.handle.workspace_path().to_path_buf(), workspace_key: WorkspaceKey::new(ensured.handle.workspace_key().to_string()) @@ -13130,30 +14946,21 @@ mod tests { }; let mut backend = RuntimeWorkspaceBackend::new(Arc::clone(&workspace_manager), &workflow); - backend + let error = backend .cleanup_workspace(&workspace, true) .await - .expect("invalid manifest cleanup should still run manager cleanup"); + .expect_err("cleanup must fail while the remote session is unconfirmed"); - assert_eq!( - fs::read_to_string(ensured.handle.logs_dir().join("before_remove.txt")) - .expect("before-remove hook should run for invalid manifest") - .trim(), - "before_remove" + assert!( + matches!(error, CliWorkspaceError::DevinLifecycle(_)), + "unexpected cleanup error: {error}" ); - assert!(ensured.handle.conversation_manifest_path().is_file()); - assert!(backend.terminal_cleanup_paths.contains(&workspace.path)); - backend - .cleanup_workspace(&workspace, true) - .await - .expect("repeated terminal cleanup should keep hooks once-only"); - - assert_eq!( - fs::read_to_string(ensured.handle.logs_dir().join("before_remove.txt")) - .expect("before-remove hook should remain once-only") - .trim(), - "before_remove" + assert!( + ensured.handle.conversation_manifest_path().is_file(), + "the devin binding must survive so cleanup can retry termination" ); + assert!(ensured.handle.workspace_path().is_dir()); + assert!(!backend.terminal_cleanup_paths.contains(&workspace.path)); } #[tokio::test] @@ -14375,82 +16182,454 @@ mod tests { async fn routing_dry_run_finishes_workspace_manifest_and_records_one_route_event() { let tempdir = TempDir::new().expect("tempdir should exist"); let workspace_root = tempdir.path().join("workspace-root"); - let workflow = Arc::new(sample_workflow(tempdir.path(), &workspace_root)); - let workspace_manager = Arc::new( - WorkspaceManager::new(build_workspace_manager_config(&workflow)) - .expect("workspace manager should be constructed"), - ); - let mut backend = RuntimeWorkerBackend::new( - OpenHandsClient::new(TransportConfig::new("http://127.0.0.1:1")), - workflow, - Arc::clone(&workspace_manager), - None, - BTreeMap::new(), - ); + let workflow = Arc::new(sample_workflow(tempdir.path(), &workspace_root)); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let mut backend = RuntimeWorkerBackend::new( + OpenHandsClient::new(TransportConfig::new("http://127.0.0.1:1")), + workflow, + Arc::clone(&workspace_manager), + None, + BTreeMap::new(), + ); + let issue = sample_issue(); + let workspace = sample_workspace(&workspace_root); + let run = RunAttempt::new( + WorkerId::new("worker-dry-run").expect("worker id should be valid"), + issue.id.clone(), + issue.identifier.clone(), + workspace.path.clone(), + TimestampMs::new(1), + None, + 8, + ); + + let launch = backend + .start_worker(WorkerStartRequest { + issue: issue.clone(), + workspace, + run, + route: codex_test_route(true), + memory_grant_registry_recovered: false, + expected_parent_conversation_id: None, + parent_repair: None, + }) + .await + .expect("dry-run worker should launch"); + + assert_eq!( + launch.conversation.last_event_kind.as_deref(), + Some("routing.decision") + ); + assert_eq!(launch.conversation.recent_activity.len(), 1); + + let mut saw_finished = false; + for _ in 0..10 { + let updates = backend + .poll_updates() + .await + .expect("dry-run updates should poll"); + saw_finished |= updates + .iter() + .any(|update| matches!(update, WorkerUpdate::Finished { .. })); + if saw_finished { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!(saw_finished, "dry-run worker should finish"); + assert!(backend.tasks.is_empty()); + assert!(backend.worker_issue_ids.is_empty()); + + let ensured = workspace_manager + .ensure(&issue_descriptor(&issue)) + .await + .expect("workspace should still be inspectable"); + let manifest = workspace_manager + .load_run_manifest(&ensured.handle) + .await + .expect("run manifest should load") + .expect("run manifest should exist"); + assert_eq!(manifest.status, RunStatus::Succeeded); + assert!( + manifest + .status_detail + .as_deref() + .is_some_and(|detail| detail.contains("routing dry-run ended")) + ); + } + + #[tokio::test] + async fn devin_dry_run_settles_locally_without_creating_a_remote_session() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = Arc::new(sample_workflow(tempdir.path(), &workspace_root)); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let mut backend = RuntimeWorkerBackend::new( + OpenHandsClient::new(TransportConfig::new("http://127.0.0.1:1")), + workflow, + Arc::clone(&workspace_manager), + None, + BTreeMap::new(), + ); + let issue = sample_issue(); + let workspace = sample_workspace(&workspace_root); + let run = RunAttempt::new( + WorkerId::new("worker-devin-dry-run").expect("worker id should be valid"), + issue.id.clone(), + issue.identifier.clone(), + workspace.path.clone(), + TimestampMs::new(1), + None, + 8, + ); + + let launch = backend + .start_worker(WorkerStartRequest { + issue: issue.clone(), + workspace, + run, + route: devin_test_route(true), + memory_grant_registry_recovered: false, + expected_parent_conversation_id: None, + parent_repair: None, + }) + .await + .expect("devin dry-run worker should launch"); + assert_eq!( + launch.conversation.last_event_kind.as_deref(), + Some("routing.decision") + ); + + let mut outcome = None; + for _ in 0..40 { + for update in backend + .poll_updates() + .await + .expect("devin dry-run updates should poll") + { + if let WorkerUpdate::Finished { outcome: kind, .. } = update { + outcome = Some(kind); + } + } + if outcome.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + + // A dry run never reaches credential resolution or session creation, so + // it succeeds locally even though no Devin credentials are configured. + assert!(matches!( + outcome.map(|record| record.outcome), + Some(WorkerOutcomeKind::Succeeded) + )); + + let ensured = workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + .expect("workspace should still be inspectable"); + let manifest = workspace_manager + .load_run_manifest(&ensured.handle) + .await + .expect("run manifest should load") + .expect("run manifest should exist"); + assert_eq!(manifest.status, RunStatus::Succeeded); + assert!( + workspace_manager + .load_conversation_manifest(&ensured.handle) + .await + .expect("conversation manifest should load") + .is_none(), + "a dry run must not bind a remote Devin session" + ); + } + + #[tokio::test] + async fn devin_route_identity_is_persisted_and_addresses_cleanup() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"); + let issue = sample_issue(); + let ensured = workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + .expect("evidence workspace should be ensured"); + assert_eq!( + read_devin_route_identity(&workspace_manager, &ensured.handle) + .await + .expect("identity lookup should succeed"), + None + ); + + let identity = DevinRouteIdentity { + session_id: "devin-session-bound".into(), + base_url: "https://devin.example.test".into(), + org_id: "org-original".into(), + api_key_env: "ORIGINAL_DEVIN_TOKEN".into(), + }; + let mut run_manifest = workspace_manager + .start_evidence_run(&ensured.handle, &RunDescriptor::new("run-1", 1)) + .await + .expect("run should start"); + write_devin_conversation_manifest( + &workspace_manager, + &ensured.handle, + &issue, + &identity, + &devin_test_route(false), + &mut run_manifest, + ) + .await + .expect("binding should persist"); + + assert_eq!( + devin_session_binding(&workspace_manager, &ensured.handle) + .await + .expect("binding lookup should succeed") + .as_deref(), + Some("devin-session-bound") + ); + assert_eq!( + read_devin_route_identity(&workspace_manager, &ensured.handle) + .await + .expect("identity lookup should succeed"), + Some(identity.clone()) + ); + let raw = + fs::read_to_string(devin_route_identity_path(&ensured.handle)).expect("identity file"); + assert!( + !raw.contains("cog_") && !raw.contains("token_value"), + "only the credential's environment variable name is persisted" + ); + + // Cleanup is addressed through the persisted identity even after the + // workflow has been pointed at a different origin and organization. + let current = DevinCloudConfig { + base_url: "https://api.devin.ai".into(), + api_key_env: "OTHER_TOKEN".into(), + org_id: Some("org-other".into()), + ..DevinCloudConfig::default() + }; + let addressed = devin_config_for_identity(¤t, Some(&identity)); + assert_eq!(addressed.base_url, identity.base_url); + assert_eq!(addressed.org_id.as_deref(), Some("org-original")); + assert_eq!(addressed.api_key_env, "ORIGINAL_DEVIN_TOKEN"); + assert_eq!(addressed.session, current.session); + assert_eq!(devin_config_for_identity(¤t, None), current); + } + + #[test] + fn devin_run_options_settle_when_devin_waits_on_its_operator() { + let options = devin_run_options(None); + assert!(options.stop_when_waiting_on_operator); + assert!(options.collect_attachments); + assert_eq!(options.max_duration, None); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(60))); + } + + #[test] + fn devin_run_options_heartbeat_fits_inside_the_stall_window() { + let options = devin_run_options(Some(300_000)); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(60))); + + let options = devin_run_options(Some(30_000)); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(10))); + + let options = devin_run_options(Some(1_000)); + assert_eq!(options.heartbeat_interval, Some(Duration::from_secs(1))); + } + + #[test] + fn devin_unreconciled_creation_settles_without_a_retryable_launch() { + let (tx, mut rx) = oneshot::channel(); + let mut launch_tx = Some(tx); + let settlement = devin_unreconciled_creation( + &mut launch_tx, + "opensymphony:coe-284", + "https://api.devin.ai", + &devin_test_route(false), + "response body was not valid JSON", + "connection reset while listing sessions", + ); + assert_eq!(settlement.outcome, WorkerOutcomeKind::CancelFailed); + assert_eq!(settlement.run_status, RunStatus::Failed); + assert!( + settlement + .error + .as_deref() + .is_some_and(|error| error.contains("opensymphony:coe-284")), + "the operator must be told which tag to search for" + ); + match rx.try_recv().expect("launch report must be sent") { + LaunchReport::Conversation { conversation, .. } => assert_eq!( + conversation.conversation_id.as_str(), + "unreconciled:opensymphony:coe-284" + ), + LaunchReport::Failed(detail) => { + panic!("an unreconciled creation must not be reported as retryable: {detail}") + } + } + + let (tx, mut rx) = oneshot::channel(); + let mut launch_tx = Some(tx); + let refused = devin_replacement_refused( + &mut launch_tx, + "devin-stale", + "https://api.devin.ai", + &devin_test_route(false), + "tenant mismatch", + ); + assert_eq!(refused.outcome, WorkerOutcomeKind::CancelFailed); + assert!(matches!( + rx.try_recv().expect("launch report must be sent"), + LaunchReport::Conversation { conversation, .. } + if conversation.conversation_id.as_str() == "devin-stale" + )); + } + + #[tokio::test] + async fn devin_recovery_reads_the_persisted_session_binding() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"); let issue = sample_issue(); - let workspace = sample_workspace(&workspace_root); - let run = RunAttempt::new( - WorkerId::new("worker-dry-run").expect("worker id should be valid"), - issue.id.clone(), - issue.identifier.clone(), - workspace.path.clone(), - TimestampMs::new(1), - None, - 8, + let ensured = workspace_manager + .ensure_evidence_only(&issue_descriptor(&issue)) + .await + .expect("evidence workspace should be ensured"); + + assert_eq!( + devin_session_binding(&workspace_manager, &ensured.handle) + .await + .expect("binding lookup should succeed"), + None ); - let launch = backend - .start_worker(WorkerStartRequest { - issue: issue.clone(), - workspace, - run, - route: codex_test_route(true), - memory_grant_registry_recovered: false, - expected_parent_conversation_id: None, - parent_repair: None, - }) + let mut manifest = sample_conversation_manifest("devin-session-recovered"); + manifest.transport_target = Some(CODEX_APP_SERVER_KIND.to_string()); + workspace_manager + .write_json_artifact( + &ensured.handle, + &ensured.handle.conversation_manifest_path(), + &manifest, + ) .await - .expect("dry-run worker should launch"); - + .expect("conversation manifest should persist"); assert_eq!( - launch.conversation.last_event_kind.as_deref(), - Some("routing.decision") + devin_session_binding(&workspace_manager, &ensured.handle) + .await + .expect("binding lookup should succeed"), + None, + "another harness's binding must never be followed as a Devin session" ); - assert_eq!(launch.conversation.recent_activity.len(), 1); - let mut saw_finished = false; - for _ in 0..10 { - let updates = backend - .poll_updates() + manifest.transport_target = Some(DEVIN_CLOUD_AGENT_KIND.to_string()); + workspace_manager + .write_json_artifact( + &ensured.handle, + &ensured.handle.conversation_manifest_path(), + &manifest, + ) + .await + .expect("devin conversation manifest should persist"); + assert_eq!( + devin_session_binding(&workspace_manager, &ensured.handle) .await - .expect("dry-run updates should poll"); - saw_finished |= updates - .iter() - .any(|update| matches!(update, WorkerUpdate::Finished { .. })); - if saw_finished { - break; + .expect("binding lookup should succeed"), + Some("devin-session-recovered".to_string()), + "recovery must reattach to the persisted session instead of creating one" + ); + } + + #[tokio::test] + async fn devin_reattachment_failure_reports_a_launch_that_cannot_be_retried() { + let (launch_sender, launch_receiver) = oneshot::channel(); + let mut launch_tx = Some(launch_sender); + let route = devin_test_route(false); + let settlement = devin_reattach_failure( + &mut launch_tx, + "devin-session-lost", + "https://api.devin.ai", + &route, + "status 404", + ); + + assert_eq!(settlement.outcome, WorkerOutcomeKind::CancelFailed); + assert_eq!(settlement.run_status, RunStatus::Failed); + assert_eq!( + non_retrying_devin_outcome(settlement.outcome), + Some(WorkerOutcomeKind::CancelFailed) + ); + let report = launch_receiver + .await + .expect("a launch report should be sent"); + // `LaunchReport::Failed` becomes a launch error the scheduler retries, + // and the later `Finished` update is then ignored because the worker + // never registered. + match report { + LaunchReport::Conversation { conversation, .. } => assert_eq!( + conversation.conversation_id.as_str(), + "devin-session-lost", + "the launch must register the bound session, not a replacement" + ), + LaunchReport::Failed(detail) => { + panic!("a failed reattachment must not report a retryable launch failure: {detail}") } - tokio::time::sleep(Duration::from_millis(25)).await; } - assert!(saw_finished, "dry-run worker should finish"); - assert!(backend.tasks.is_empty()); - assert!(backend.worker_issue_ids.is_empty()); + } + #[tokio::test] + async fn devin_finalization_persists_the_non_retrying_outcome() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = sample_workflow(tempdir.path(), &workspace_root); + let workspace_manager = WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"); + let issue = sample_issue(); let ensured = workspace_manager - .ensure(&issue_descriptor(&issue)) + .ensure_evidence_only(&issue_descriptor(&issue)) .await - .expect("workspace should still be inspectable"); - let manifest = workspace_manager + .expect("evidence workspace should be ensured"); + let mut run_manifest = workspace_manager + .start_evidence_run(&ensured.handle, &RunDescriptor::new("run-detached", 1)) + .await + .expect("evidence run should start"); + + finish_devin_workspace_run( + &workspace_manager, + &ensured.handle, + &mut run_manifest, + RunStatus::Paused, + non_retrying_devin_outcome(WorkerOutcomeKind::Detached), + ) + .await + .expect("evidence run should finish"); + + let persisted = workspace_manager .load_run_manifest(&ensured.handle) .await .expect("run manifest should load") .expect("run manifest should exist"); - assert_eq!(manifest.status, RunStatus::Succeeded); - assert!( - manifest - .status_detail - .as_deref() - .is_some_and(|detail| detail.contains("routing dry-run ended")) + assert_eq!(persisted.status, RunStatus::Paused); + assert_eq!( + persisted.terminal_worker_outcome, + Some(WorkerOutcomeKind::Detached), + "a paused status alone lets recovery dispatch a replacement session" + ); + assert_eq!( + non_retrying_devin_outcome(WorkerOutcomeKind::Failed), + None, + "ordinary failures must keep their retry behaviour" ); } @@ -14507,6 +16686,94 @@ mod tests { assert!(backend.worker_issue_ids.is_empty()); } + #[tokio::test] + async fn settling_terminated_devin_tasks_waits_for_settled_runs_and_abandons_wedged_ones() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workspace_root = tempdir.path().join("workspace-root"); + let workflow = Arc::new(sample_workflow(tempdir.path(), &workspace_root)); + let workspace_manager = Arc::new( + WorkspaceManager::new(build_workspace_manager_config(&workflow)) + .expect("workspace manager should be constructed"), + ); + let mut backend = RuntimeWorkerBackend::new( + OpenHandsClient::new(TransportConfig::new("http://127.0.0.1:1")), + workflow, + workspace_manager, + None, + BTreeMap::new(), + ); + let workspace = sample_workspace(&workspace_root); + let mut track = |worker_id: &str, handle: JoinHandle<()>| { + let run = RunAttempt::new( + WorkerId::new(worker_id).expect("worker id should be valid"), + IssueId::new(format!("issue-{worker_id}")).expect("issue id should be valid"), + IssueIdentifier::new("COE-373").expect("issue identifier should be valid"), + workspace.path.clone(), + TimestampMs::new(1), + None, + 8, + ); + backend + .worker_issue_ids + .insert(worker_id.to_string(), run.issue_identifier.to_string()); + backend + .tasks + .insert(worker_id.to_string(), ActiveWorkerTask { handle, run }); + }; + // A route that observes the archived session on its next poll and + // finishes its manifest after a short delay. + track( + "devin-settles", + tokio::spawn(async { tokio::time::sleep(Duration::from_millis(50)).await }), + ); + // A route whose poll is wedged for longer than the settle budget. + track( + "devin-wedged", + tokio::spawn(async { tokio::time::sleep(Duration::from_secs(60)).await }), + ); + // A route task that panics while settling. + track( + "devin-panics", + tokio::spawn(async { panic!("settle panic") }), + ); + // Only the named worker is settled. + track( + "devin-other", + tokio::spawn(async { tokio::time::sleep(Duration::from_secs(60)).await }), + ); + + backend + .settle_devin_tasks(Some("devin-settles"), Duration::from_millis(500)) + .await; + assert!(!backend.tasks.contains_key("devin-settles")); + assert!(!backend.worker_issue_ids.contains_key("devin-settles")); + assert!(backend.tasks.contains_key("devin-other")); + + let started = tokio::time::Instant::now(); + backend + .settle_devin_tasks(Some("devin-wedged"), Duration::from_millis(200)) + .await; + assert!(started.elapsed() < Duration::from_secs(5)); + assert!( + backend.tasks.contains_key("devin-wedged"), + "a wedged task stays tracked so the abort path can drop it" + ); + assert!(backend.worker_issue_ids.contains_key("devin-wedged")); + + backend + .settle_devin_tasks(Some("devin-panics"), Duration::from_millis(500)) + .await; + assert!(!backend.tasks.contains_key("devin-panics")); + assert!(!backend.worker_issue_ids.contains_key("devin-panics")); + let updates = backend.poll_updates().await.expect("poll updates"); + assert!(updates.iter().any(|update| matches!(update, + WorkerUpdate::Finished { worker_id, outcome } if worker_id.as_str() == "devin-panics" + && outcome.outcome == WorkerOutcomeKind::Failed && outcome.harness_stopped))); + + backend.abort_all_tracked_tasks(); + assert!(backend.tasks.is_empty()); + } + #[tokio::test] async fn recovered_worker_reuses_running_manifest_without_before_run_hook() { let tempdir = TempDir::new().expect("tempdir should exist"); @@ -16982,6 +19249,243 @@ Run the scheduler. issue } + fn devin_instruction_repository(instructions_path: &str) -> CheckoutRepository { + CheckoutRepository { + provider: "github".into(), + provider_id: None, + remote_locator: "https://github.com/trilogy-group/StackPerf.git".into(), + remote: "https://github.com/trilogy-group/StackPerf.git".into(), + target_branch: "main".into(), + credential_kind: "environment".into(), + credential_reference: None, + credential_env: None, + review_credential_env: None, + instructions_path: instructions_path.into(), + policy_generation: "policy-1".into(), + review_profile: "github".into(), + review_provider: "github".into(), + review_policy_generation: "review-1".into(), + required_checks: false, + required_review: false, + merge_method: None, + } + } + + /// Serves exactly one HTTP response and returns the request head it saw. + async fn serve_one_response( + status: &'static str, + headers: &'static str, + body: Vec, + ) -> (String, JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = socket.read(&mut buffer).await.expect("read request"); + request.extend_from_slice(&buffer[..read]); + if read == 0 || request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let mut response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(&body); + socket.write_all(&response).await.expect("write response"); + socket.shutdown().await.ok(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{address}"), server) + } + + #[test] + fn devin_instructions_contents_url_targets_the_configured_branch() { + let url = devin_instructions_contents_url( + "https://api.github.com", + "trilogy-group", + "StackPerf", + Path::new("docs/agent notes/WORKFLOW.md"), + "release/next", + ) + .expect("contents url"); + assert_eq!( + url.as_str(), + "https://api.github.com/repos/trilogy-group/StackPerf/contents/docs/agent%20notes/WORKFLOW.md?ref=release%2Fnext" + ); + + let enterprise = devin_instructions_contents_url( + "https://github.example.com/api/v3", + "org", + "repo", + Path::new("./AGENTS.md"), + "main", + ) + .expect("enterprise contents url"); + assert_eq!( + enterprise.as_str(), + "https://github.example.com/api/v3/repos/org/repo/contents/AGENTS.md?ref=main" + ); + + let escape = devin_instructions_contents_url( + "https://api.github.com", + "org", + "repo", + Path::new("../other/WORKFLOW.md"), + "main", + ) + .expect_err("parent components must be rejected"); + assert!( + escape.contains("must stay inside the repository"), + "{escape}" + ); + } + + #[tokio::test] + async fn devin_repository_instructions_are_fetched_from_the_remote_and_stripped() { + let raw = b"---\nname: workflow\n---\n# Procedure\n\nAttach the PR.\n".to_vec(); + let (origin, server) = serve_one_response( + "200 OK", + "Content-Type: application/vnd.github.raw+json\r\n", + raw.clone(), + ) + .await; + let repository = devin_instruction_repository("WORKFLOW.md"); + let url = devin_instructions_contents_url( + &origin, + "trilogy-group", + "StackPerf", + &repository.instructions_path, + &repository.target_branch, + ) + .expect("contents url"); + + let fetched = fetch_devin_repository_instructions_at( + &repository, + url, + Some("token-under-test".to_owned()), + ) + .await + .expect("fetch succeeds") + .expect("instructions are configured"); + let request = server.await.expect("server task"); + + assert!( + request.starts_with( + "GET /repos/trilogy-group/StackPerf/contents/WORKFLOW.md?ref=main HTTP/1.1" + ), + "{request}" + ); + assert!( + request.contains("authorization: Bearer token-under-test"), + "{request}" + ); + assert!( + request.contains("accept: application/vnd.github.raw+json"), + "{request}" + ); + assert_eq!(fetched.body, "# Procedure\n\nAttach the PR.\n"); + let mut hasher = Sha256::new(); + hasher.update(&raw); + assert_eq!( + fetched.record, + crate::opensymphony_devin::DevinRepositoryInstructions { + path: "WORKFLOW.md".into(), + git_ref: "main".into(), + content_hash: format!("sha256:{:x}", hasher.finalize()), + bytes: raw.len() as u64, + } + ); + } + + #[tokio::test] + async fn devin_repository_instructions_fetch_failures_are_launch_errors() { + let (origin, server) = serve_one_response("404 Not Found", "", b"{}".to_vec()).await; + let repository = devin_instruction_repository("WORKFLOW.md"); + let url = devin_instructions_contents_url( + &origin, + "trilogy-group", + "StackPerf", + &repository.instructions_path, + &repository.target_branch, + ) + .expect("contents url"); + let error = fetch_devin_repository_instructions_at(&repository, url, None) + .await + .expect_err("missing instruction file fails the launch"); + let request = server.await.expect("server task"); + assert!(!request.contains("authorization:"), "{request}"); + assert!(error.contains("HTTP 404"), "{error}"); + + let oversized = vec![b'#'; (MAX_INSTRUCTION_FILE_BYTES + 1) as usize]; + let (origin, server) = serve_one_response("200 OK", "", oversized).await; + let url = devin_instructions_contents_url( + &origin, + "trilogy-group", + "StackPerf", + &repository.instructions_path, + &repository.target_branch, + ) + .expect("contents url"); + let error = fetch_devin_repository_instructions_at(&repository, url, None) + .await + .expect_err("oversized instructions are rejected"); + server.abort(); + assert!(error.contains("exceed"), "{error}"); + + let unconfigured = devin_instruction_repository(""); + assert!( + fetch_devin_repository_instructions(&unconfigured) + .await + .expect("no instruction file configured") + .is_none() + ); + } + + #[test] + fn devin_task_prompt_renders_repository_instructions() { + let tempdir = TempDir::new().expect("tempdir should exist"); + let workflow = sample_workflow(tempdir.path(), &tempdir.path().join("workspace-root")); + let issue = sample_issue(); + let route = devin_test_route(false); + + let without = devin_task_prompt(&workflow, &issue, 1, &route, None) + .expect("prompt without instructions"); + assert!(without.contains("No repository-specific instructions were selected.")); + + let with = devin_task_prompt( + &workflow, + &issue, + 2, + &route, + Some("# Procedure\n\nAttach the PR URL to the Linear issue.\n"), + ) + .expect("prompt with instructions"); + assert!(with.contains("## Repository Instructions\n\n# Procedure\n\nAttach the PR URL")); + assert!(!with.contains("No repository-specific instructions were selected.")); + assert!(with.contains("Attempt: 2")); + } + + fn devin_test_route(dry_run: bool) -> crate::opensymphony_orchestrator::HarnessRouteDecision { + crate::opensymphony_orchestrator::HarnessRouteDecision { + task_type: "issue_execution".into(), + harness_kind: DEVIN_CLOUD_AGENT_KIND.into(), + harness_profile: None, + model: None, + model_profile: None, + reason: "test devin route".into(), + dry_run, + user_override: false, + } + } + fn codex_test_route(dry_run: bool) -> crate::opensymphony_orchestrator::HarnessRouteDecision { crate::opensymphony_orchestrator::HarnessRouteDecision { task_type: "issue_execution".into(), diff --git a/crates/opensymphony-cli/src/orchestrator_run/config.rs b/crates/opensymphony-cli/src/orchestrator_run/config.rs index b1f7cf5d2..a1fd12f26 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/config.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/config.rs @@ -14,9 +14,9 @@ use crate::opensymphony_domain::{ use crate::opensymphony_memory::DEFAULT_PRIVATE_MEMORY_CONFIG_FILE; use crate::opensymphony_openhands::OpenHandsConversationStorePaths; use crate::opensymphony_workflow::{ - AgentFrontMatter, HooksFrontMatter, IntegerLike, OpenHandsFrontMatter, PollingFrontMatter, - ResolvedWorkflow, RoutingFrontMatter, TrackerFrontMatter, WorkflowDefinition, - WorkflowFrontMatter, WorkspaceFrontMatter, + AgentFrontMatter, DevinFrontMatter, HooksFrontMatter, IntegerLike, OpenHandsFrontMatter, + PollingFrontMatter, ResolvedWorkflow, RoutingFrontMatter, TrackerFrontMatter, + WorkflowDefinition, WorkflowFrontMatter, WorkspaceFrontMatter, }; use crate::opensymphony_workflow::{ DEFAULT_ROUTING_HARNESS_ENV, DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, @@ -91,6 +91,8 @@ struct CentralConfigFile { #[serde(default)] openhands: CentralOpenHandsFile, #[serde(default)] + devin: Option, + #[serde(default)] compatibility: CentralCompatibilityFile, } @@ -767,7 +769,7 @@ const CENTRAL_CONFIG_KEYS: &[&str] = &[ "compatibility", ]; -const VERSIONED_CENTRAL_SHARED_KEYS: &[&str] = &["control_plane", "openhands"]; +const VERSIONED_CENTRAL_SHARED_KEYS: &[&str] = &["control_plane", "openhands", "devin"]; fn has_central_top_level_key(raw: &str) -> bool { raw.lines().any(|line| { @@ -1595,6 +1597,16 @@ fn reject_checkout_credential_env_reuse( "OPENSYMPHONY_CODEX_BIN".to_owned(), "runtime.codex_binary_env", ); + if let Some(devin) = config.devin.as_ref() { + for (field, variable) in [ + ("devin.api.api_key_env", devin.api.api_key_env.as_deref()), + ("devin.api.org_id_env", devin.api.org_id_env.as_deref()), + ] { + if let Some(variable) = variable { + non_checkout_variables.insert(variable.to_owned(), field); + } + } + } if let Some(front_matter) = config.openhands.front_matter.as_ref() { let value = serde_yaml::to_value(front_matter).map_err(|_| { CentralConfigError::InvalidReference { @@ -2067,6 +2079,7 @@ fn central_workflow_front_matter( } openhands }, + devin: config.devin.clone().unwrap_or_default(), ..WorkflowFrontMatter::default() }; Ok(front_matter) @@ -2077,6 +2090,9 @@ fn merge_repository_local_front_matter( local: &WorkflowFrontMatter, ) -> WorkflowFrontMatter { central.codex = local.codex.clone(); + if central.devin == DevinFrontMatter::default() { + central.devin = local.devin.clone(); + } central.logging = local.logging.clone(); central.extensions = local.extensions.clone(); central @@ -4329,13 +4345,18 @@ scheduler: #[test] fn central_runtime_preserves_repository_local_front_matter_extensions() { let local = WorkflowDefinition::parse( - "---\ncodex:\n command: codex app-server\nlogging:\n level: debug\n---\nImplementation instructions\n", + "---\ncodex:\n command: codex app-server\ndevin:\n session:\n max_acu_limit: 25\n tags: [team:core]\nlogging:\n level: debug\n---\nImplementation instructions\n", ) .expect("repository-local workflow should parse") .front_matter; let merged = merge_repository_local_front_matter(WorkflowFrontMatter::default(), &local); assert_eq!(merged.codex, local.codex); + assert_eq!(merged.devin, local.devin); + assert_eq!( + merged.devin.session.tags.as_deref(), + Some(&["team:core".to_owned()][..]) + ); assert_eq!(merged.logging, local.logging); assert!(merged.extensions.is_empty()); } @@ -4473,6 +4494,70 @@ scheduler: ); } + #[test] + fn central_config_carries_devin_session_options_into_front_matter() { + let root = tempfile::tempdir().expect("central config root should exist"); + std::fs::write( + root.path().join("integration.md"), + "integration instructions\n", + ) + .expect("integration instructions should be written"); + let source = format!( + "{}\ndevin:\n api:\n org_id_env: CUSTOM_DEVIN_ORG\n session:\n max_acu_limit: 25\n tags: [team:core]\n", + central_fixture(root.path()).replace( + " harness_env: TEST_HARNESS\n", + " harness: devin_cloud_agent\n harness_env: TEST_HARNESS\n" + ) + ); + let resolved = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect("devin profile should resolve"); + assert_eq!( + resolved.workflow_front_matter.routing.harness.as_deref(), + Some("devin_cloud_agent") + ); + let devin = &resolved.workflow_front_matter.devin; + assert_eq!(devin.api.org_id_env.as_deref(), Some("CUSTOM_DEVIN_ORG")); + assert_eq!(devin.session.max_acu_limit, Some(IntegerLike::Integer(25))); + assert_eq!( + devin.session.tags.as_deref(), + Some(&["team:core".to_owned()][..]) + ); + let local = WorkflowDefinition::parse( + "---\ndevin:\n session:\n max_acu_limit: 5\n---\nInstructions\n", + ) + .expect("repository-local workflow should parse") + .front_matter; + let merged = + merge_repository_local_front_matter(resolved.workflow_front_matter.clone(), &local); + assert_eq!( + merged.devin.session.max_acu_limit, + Some(IntegerLike::Integer(25)) + ); + } + + #[test] + fn central_config_rejects_devin_credential_reuse_as_checkout_credential() { + let root = tempfile::tempdir().expect("central config root should exist"); + let source = format!( + "{}\ndevin:\n api:\n api_key_env: GITHUB_TOKEN\n", + central_fixture(root.path()) + .replace( + "clone: git@github.com:kumanday/OpenSymphony.git", + "clone: https://github.com/kumanday/OpenSymphony.git" + ) + .replace( + " github-ssh:\n kind: ssh-agent", + " github-ssh:\n kind: environment\n variable: GITHUB_TOKEN" + ) + ); + let error = resolve_central_config(&root.path().join("config.yaml"), &source) + .expect_err("devin token must not double as a checkout credential"); + assert!(matches!( + &error, + CentralConfigError::InvalidReference { field } if field == "devin.api.api_key_env" + )); + } + #[test] fn central_config_rejects_literal_openhands_environment_secret() { let root = tempfile::tempdir().expect("central config root should exist"); diff --git a/crates/opensymphony-cli/src/orchestrator_run/mod.rs b/crates/opensymphony-cli/src/orchestrator_run/mod.rs index 3d8c27eb8..d83734193 100644 --- a/crates/opensymphony-cli/src/orchestrator_run/mod.rs +++ b/crates/opensymphony-cli/src/orchestrator_run/mod.rs @@ -1613,6 +1613,9 @@ async fn run_orchestrator(args: RunArgs) -> Result<(), RunCommandError> { } } + // Remote-harness workers (Devin) hold sessions that keep billing after + // their local task is gone; stop them before the runtime tears down. + scheduler.worker_mut().shutdown().await; server_task.abort(); shutdown_task.abort(); shutdown_memory_server(&mut memory_server).await?; diff --git a/crates/opensymphony-devin/contracts/devin-v3-openapi.yaml b/crates/opensymphony-devin/contracts/devin-v3-openapi.yaml new file mode 100644 index 000000000..88ad2e0eb --- /dev/null +++ b/crates/opensymphony-devin/contracts/devin-v3-openapi.yaml @@ -0,0 +1,1800 @@ +# Vendored subset of the published Devin API v3 OpenAPI document. +# Source: https://docs.devin.ai/v3-openapi.yaml (Devin API v3, version 3.0.0). +# Contains only the session and identity operations this harness calls, plus +# every schema they reference. +openapi: 3.1.0 +info: + description: Devin v3 API with Service User authentication and RBAC + title: Devin API v3 + version: 3.0.0 +servers: [] +security: +- bearerAuth: [] +paths: + /v3/organizations/{org_id}/sessions: + get: + description: List sessions. + operationId: handle_get_sessions_v3_organizations__org_id__sessions_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - in: query + name: qs + required: true + schema: + $ref: '#/components/schemas/SessionsQueryParams' + - in: query + name: devin_id + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponse_SessionResponse_' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: List Sessions + tags: + - sessions + post: + description: Create a new session + operationId: handle_create_session_v3_organizations__org_id__sessions_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - in: query + name: devin_id + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionCreateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Create Session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}: + delete: + description: Terminate session + operationId: handle_terminate_session_v3_organizations__org_id__sessions__devin_id__delete + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + - description: Whether to archive the Devin session + in: query + name: archive + required: false + schema: + default: false + description: Whether to archive the Devin session + title: Archive + type: boolean + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Terminate Session + tags: + - sessions + get: + description: Get details of a specific session. + operationId: handle_get_session_v3_organizations__org_id__sessions__devin_id__get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Get Session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/archive: + post: + description: Archive session and put it to sleep if currently running + operationId: handle_archive_session_v3_organizations__org_id__sessions__devin_id__archive_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Archive Session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/attachments: + get: + description: List all attachments for a session. + operationId: handle_get_session_attachments_v3_organizations__org_id__sessions__devin_id__attachments_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + items: + $ref: '#/components/schemas/SessionAttachment' + title: Response Handle Get Session Attachments V3 Organizations Org Id Sessions Devin + Id Attachments Get + type: array + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: List session attachments + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/messages: + get: + description: List all messages for a session with cursor-based pagination, ordered chronologically. + operationId: handle_get_session_messages_v3_organizations__org_id__sessions__devin_id__messages_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + - in: query + name: after + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: After + - in: query + name: first + required: false + schema: + default: 100 + maximum: 200 + minimum: 1 + title: First + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedResponse_SessionMessage_' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: List session messages + tags: + - sessions + post: + description: Send a message to an active session. The session will be automatically resumed if suspended. + operationId: handle_post_message_v3_organizations__org_id__sessions__devin_id__messages_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionMessageCreateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Send a message to a session + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/tags: + get: + description: Get the tags for a specific session. + operationId: handle_get_session_tags_v3_organizations__org_id__sessions__devin_id__tags_get + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Get session tags + tags: + - sessions + post: + description: Append tags to a session (deduplicating with existing tags). + operationId: handle_append_session_tags_v3_organizations__org_id__sessions__devin_id__tags_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsUpdateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Append session tags + tags: + - sessions + put: + description: Replace all tags on a session. + operationId: handle_replace_session_tags_v3_organizations__org_id__sessions__devin_id__tags_put + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsUpdateRequest' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionTagsResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Replace session tags + tags: + - sessions + /v3/organizations/{org_id}/sessions/{devin_id}/unarchive: + post: + description: Unarchive session (does not cascade to child sessions) + operationId: handle_unarchive_session_v3_organizations__org_id__sessions__devin_id__unarchive_post + parameters: + - description: 'Organization ID (prefix: org-)' + in: path + name: org_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: org-abc123def456 + title: Org Id + - description: 'Devin session ID (prefix: devin-)' + in: path + name: devin_id + required: true + schema: + anyOf: + - type: string + - type: 'null' + example: devin-abc123def456 + title: Devin Id + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SessionResponse' + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Unarchive Session + tags: + - sessions + /v3/self: + get: + operationId: handle_get_self_v3_self_get + responses: + '200': + content: + application/json: + schema: + anyOf: + - $ref: '#/components/schemas/ServiceUserSelf' + - $ref: '#/components/schemas/PatUserSelf' + - $ref: '#/components/schemas/DevinBrainUserSelf' + - $ref: '#/components/schemas/WindsurfSessionUserSelf' + title: Response Handle Get Self V3 Self Get + description: Successful Response + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unauthorized + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Forbidden + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Not Found + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Conflict + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Unprocessable Content + '429': + content: + application/json: + schema: + $ref: '#/components/schemas/ProblemDetail' + description: Too Many Requests + summary: Get Self + tags: + - self +components: + schemas: + DevinBrainUserSelf: + description: Identity information for an authenticated Devin brain session. + properties: + creator_service_user_id: + anyOf: + - type: string + - type: 'null' + title: Creator Service User Id + devin_id: + title: Devin Id + type: string + org_id: + title: Org Id + type: string + principal_type: + const: devin_brain + title: Principal Type + type: string + user_id: + title: User Id + type: string + required: + - principal_type + - devin_id + - org_id + - user_id + title: DevinBrainUserSelf + type: object + PaginatedResponse_SessionMessage_: + properties: + end_cursor: + anyOf: + - type: string + - type: 'null' + description: Cursor to fetch the next page, or None if this is the last page. + title: End Cursor + has_next_page: + default: false + description: Whether there are more items available after this page. + title: Has Next Page + type: boolean + items: + items: + $ref: '#/components/schemas/SessionMessage' + title: Items + type: array + total: + anyOf: + - type: integer + - type: 'null' + description: Optional total count (can be omitted for performance). + title: Total + required: + - items + title: PaginatedResponse[SessionMessage] + type: object + PaginatedResponse_SessionResponse_: + properties: + end_cursor: + anyOf: + - type: string + - type: 'null' + description: Cursor to fetch the next page, or None if this is the last page. + title: End Cursor + has_next_page: + default: false + description: Whether there are more items available after this page. + title: Has Next Page + type: boolean + items: + items: + $ref: '#/components/schemas/SessionResponse' + title: Items + type: array + total: + anyOf: + - type: integer + - type: 'null' + description: Optional total count (can be omitted for performance). + title: Total + required: + - items + title: PaginatedResponse[SessionResponse] + type: object + PatUserSelf: + description: Identity information for an authenticated PAT user. + properties: + api_key_id: + title: Api Key Id + type: string + api_key_name: + title: Api Key Name + type: string + org_id: + anyOf: + - type: string + - type: 'null' + title: Org Id + principal_type: + const: pat_user + title: Principal Type + type: string + user_id: + title: User Id + type: string + user_name: + title: User Name + type: string + required: + - principal_type + - user_id + - user_name + - api_key_id + - api_key_name + title: PatUserSelf + type: object + ProblemDetail: + description: 'RFC 9457 application/problem+json error body for the v3 API. + + + detail is retained from the legacy {"detail": ...} body for back-compat; the + + other members are additive. errors carries field-level validation failures + + (422 only).' + properties: + detail: + anyOf: + - type: string + - type: 'null' + description: A human-readable explanation specific to this occurrence. + title: Detail + errors: + anyOf: + - items: + additionalProperties: true + type: object + type: array + - type: 'null' + description: Field-level validation errors (422 responses only). + title: Errors + instance: + anyOf: + - type: string + - type: 'null' + description: A URI reference (the request path) for this occurrence. + title: Instance + status: + description: The HTTP status code. + title: Status + type: integer + title: + description: A short, human-readable summary of the problem type. + title: Title + type: string + type: + default: about:blank + description: A URI reference identifying the problem type. + title: Type + type: string + required: + - title + - status + title: ProblemDetail + type: object + ServiceUserSelf: + description: Identity information for an authenticated service user. + properties: + org_id: + anyOf: + - type: string + - type: 'null' + title: Org Id + principal_type: + const: service_user + title: Principal Type + type: string + service_user_id: + title: Service User Id + type: string + service_user_name: + title: Service User Name + type: string + required: + - principal_type + - service_user_id + - service_user_name + title: ServiceUserSelf + type: object + SessionAttachment: + description: An attachment sent during a session. + properties: + attachment_id: + title: Attachment Id + type: string + content_type: + anyOf: + - type: string + - type: 'null' + title: Content Type + name: + title: Name + type: string + source: + enum: + - devin + - user + title: Source + type: string + url: + title: Url + type: string + required: + - attachment_id + - name + - url + - source + title: SessionAttachment + type: object + SessionCreateRequest: + properties: + attachment_urls: + anyOf: + - items: + format: uri + maxLength: 2083 + minLength: 1 + type: string + type: array + - type: 'null' + title: Attachment Urls + bypass_approval: + anyOf: + - type: boolean + - type: 'null' + title: Bypass Approval + child_playbook_id: + anyOf: + - type: string + - type: 'null' + title: Child Playbook Id + create_as_user_id: + anyOf: + - type: string + - type: 'null' + title: Create As User Id + devin_mode: + anyOf: + - enum: + - normal + - fast + - lite + - ultra + - fusion + type: string + - type: 'null' + description: Override the Devin agent mode for the session. 'normal' uses the default Agent + mode, 'fast' uses Fast mode, 'lite' uses Devin Lite, 'ultra' uses Devin Ultra, and 'fusion' + uses Fusion. Preview modes are subject to the same feature flag and enterprise agent preview + restrictions as the web app. + title: Devin Mode + knowledge_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Knowledge Ids + max_acu_limit: + anyOf: + - type: integer + - type: 'null' + title: Max Acu Limit + platform: + anyOf: + - type: string + - type: 'null' + description: "Override the VM platform for the session (e.g. 'windows'), or the name of an outpost\ + \ (BYOB) pool to run the session on one of your own machines. When omitted (or set to 'inherit'),\ + \ a session created by a parent Devin inherits the parent's placement \u2014 both its platform\ + \ and its outpost pool, so an outpost-placed parent spawns children on the same pool; otherwise\ + \ the organization default is used. Any value must match either a platform configured for\ + \ your organization or an outpost pool name (case-insensitive) \u2014 built-in platforms take\ + \ priority when a name matches both. Unrecognized values are rejected with a 400 whose error\ + \ body lists the available platform labels and outpost pool names for the org." + title: Platform + playbook_id: + anyOf: + - type: string + - type: 'null' + title: Playbook Id + prompt: + title: Prompt + type: string + repos: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Repos + resumable: + default: true + description: Whether to preserve the session's VM state after it stops so the session can be + resumed. Set to false for disposable sessions. + title: Resumable + type: boolean + secret_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Secret Ids + session_links: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Session Links + session_secrets: + anyOf: + - items: + $ref: '#/components/schemas/SessionSecretInput' + type: array + - type: 'null' + title: Session Secrets + structured_output_required: + anyOf: + - type: boolean + - type: 'null' + description: "When true (default), the agent MUST call provide_structured_output with is_final=true\ + \ before its turn ends. When false, the tool is available but not required \u2014 it is not\ + \ guaranteed to be called in a given turn." + title: Structured Output Required + structured_output_schema: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + description: JSON Schema (Draft 7) for validating structured output. Max 64KB. Must be self-contained + (no external $ref). + title: Structured Output Schema + tags: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Tags + title: + anyOf: + - type: string + - type: 'null' + title: Title + required: + - prompt + title: SessionCreateRequest + type: object + SessionMessage: + description: A message in a Devin session. + properties: + created_at: + title: Created At + type: integer + event_id: + title: Event Id + type: string + message: + title: Message + type: string + source: + enum: + - devin + - user + title: Source + type: string + required: + - event_id + - source + - message + - created_at + title: SessionMessage + type: object + SessionMessageCreateRequest: + properties: + attachment_urls: + anyOf: + - items: + format: uri + maxLength: 2083 + minLength: 1 + type: string + type: array + - type: 'null' + title: Attachment Urls + message: + title: Message + type: string + message_as_user_id: + anyOf: + - type: string + - type: 'null' + title: Message As User Id + required: + - message + title: SessionMessageCreateRequest + type: object + SessionPullRequest: + properties: + pr_state: + anyOf: + - type: string + - type: 'null' + title: Pr State + pr_url: + title: Pr Url + type: string + required: + - pr_url + - pr_state + title: SessionPullRequest + type: object + SessionResponse: + properties: + acus_consumed: + title: Acus Consumed + type: number + automation_id: + anyOf: + - type: string + - type: 'null' + description: ID of the automation that created this session, if any. + title: Automation Id + category: + anyOf: + - enum: + - bug_fixing + - ci_cd_and_devops + - code_quality_and_security + - code_review + - code_review_and_analysis + - data_and_automation + - documentation_and_content + - feature_development + - migrations_and_upgrades + - other + - production_investigation + - refactoring_and_optimization + - research_and_exploration + - security + - unit_test_generation + type: string + - type: 'null' + description: The session's assigned use-case category, if categorisation has run. Only populated + on get/list endpoints. + title: Category + child_session_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Child Session Ids + created_at: + title: Created At + type: integer + devin_mode: + anyOf: + - enum: + - normal + - fast + - lite + - ultra + - fusion + type: string + - type: 'null' + description: The Devin agent mode the session was created with, after resolving org/user defaults + and parent-session inheritance. None when the session runs an agent preview that has no mode + equivalent. + title: Devin Mode + is_archived: + default: false + title: Is Archived + type: boolean + org_id: + title: Org Id + type: string + origin: + anyOf: + - enum: + - webapp + - slack + - teams + - api + - linear + - jira + - automation + - cli + - desktop + - code_scan + - other + type: string + - type: 'null' + description: The origin from which the session was created. + title: Origin + parent_session_id: + anyOf: + - type: string + - type: 'null' + title: Parent Session Id + playbook_id: + anyOf: + - type: string + - type: 'null' + title: Playbook Id + pull_requests: + items: + $ref: '#/components/schemas/SessionPullRequest' + title: Pull Requests + type: array + service_user_id: + anyOf: + - type: string + - type: 'null' + title: Service User Id + session_id: + title: Session Id + type: string + status: + enum: + - new + - claimed + - running + - exit + - error + - suspended + - resuming + title: Status + type: string + status_detail: + anyOf: + - enum: + - working + - waiting_for_user + - waiting_for_approval + - finished + - inactivity + - user_request + - usage_limit_exceeded + - out_of_credits + - out_of_quota + - no_quota_allocation + - payment_declined + - org_usage_limit_exceeded + - user_usage_limit_exceeded + - total_session_limit_exceeded + - error + type: string + - type: 'null' + description: 'Additional detail about the session''s current status. When status is ''running'': + ''working'' (actively working), ''waiting_for_user'' (needs user input), ''waiting_for_approval'' + (awaiting action approval in safe mode), or ''finished'' (task complete). When status is ''suspended'': + the reason for suspension such as ''inactivity'', ''user_request'', ''usage_limit_exceeded'', + ''out_of_credits'', ''out_of_quota'', ''no_quota_allocation'', ''payment_declined'', ''org_usage_limit_exceeded'', + ''user_usage_limit_exceeded'', ''total_session_limit_exceeded'', or ''error''. Only populated + on get/list endpoints.' + title: Status Detail + structured_output: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + description: Validated structured output from the session. Only populated on get/list endpoints. + title: Structured Output + subcategory: + anyOf: + - type: string + - type: 'null' + description: The session's assigned subcategory display name. 'Other' when a category is set + but no subcategory was assigned or resolved. Only populated on get/list endpoints. + title: Subcategory + tags: + items: + type: string + title: Tags + type: array + title: + anyOf: + - type: string + - type: 'null' + title: Title + updated_at: + title: Updated At + type: integer + url: + title: Url + type: string + user_id: + anyOf: + - type: string + - type: 'null' + title: User Id + required: + - session_id + - url + - status + - tags + - org_id + - created_at + - updated_at + - acus_consumed + - pull_requests + title: SessionResponse + type: object + SessionSecretInput: + description: Input model for a session secret provided via API. + properties: + key: + maxLength: 256 + minLength: 1 + title: Key + type: string + sensitive: + default: true + title: Sensitive + type: boolean + value: + maxLength: 65536 + title: Value + type: string + required: + - key + - value + title: SessionSecretInput + type: object + SessionTagsResponse: + properties: + tags: + items: + type: string + title: Tags + type: array + required: + - tags + title: SessionTagsResponse + type: object + SessionTagsUpdateRequest: + properties: + tags: + items: + type: string + maxItems: 50 + title: Tags + type: array + required: + - tags + title: SessionTagsUpdateRequest + type: object + SessionsQueryParams: + properties: + after: + anyOf: + - type: string + - type: 'null' + title: After + automation_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + description: Filter to sessions created by any of these automations + title: Automation Ids + category: + anyOf: + - enum: + - bug_fixing + - ci_cd_and_devops + - code_quality_and_security + - code_review + - code_review_and_analysis + - data_and_automation + - documentation_and_content + - feature_development + - migrations_and_upgrades + - other + - production_investigation + - refactoring_and_optimization + - research_and_exploration + - security + - unit_test_generation + type: string + - type: 'null' + title: Category + created_after: + anyOf: + - type: integer + - type: 'null' + title: Created After + created_before: + anyOf: + - type: integer + - type: 'null' + title: Created Before + first: + default: 100 + maximum: 200.0 + minimum: 1.0 + title: First + type: integer + is_archived: + anyOf: + - type: boolean + - type: 'null' + title: Is Archived + origins: + anyOf: + - items: + enum: + - webapp + - slack + - teams + - api + - linear + - jira + - automation + - cli + - desktop + - code_scan + - other + type: string + type: array + - type: 'null' + title: Origins + parent_session_id: + anyOf: + - type: string + - type: 'null' + description: Filter to child sessions spawned by this parent session + title: Parent Session Id + playbook_id: + anyOf: + - type: string + - type: 'null' + title: Playbook Id + repo_names: + anyOf: + - items: + type: string + type: array + - type: 'null' + description: Filter by repository names (e.g., 'owner/repo') + title: Repo Names + schedule_id: + anyOf: + - type: string + - type: 'null' + title: Schedule Id + service_user_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Service User Ids + session_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Session Ids + tags: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Tags + updated_after: + anyOf: + - type: integer + - type: 'null' + title: Updated After + updated_before: + anyOf: + - type: integer + - type: 'null' + title: Updated Before + user_ids: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: User Ids + title: SessionsQueryParams + type: object + WindsurfSessionUserSelf: + description: Identity information for an authenticated Windsurf session user. + properties: + org_id: + title: Org Id + type: string + principal_type: + const: windsurf_session + title: Principal Type + type: string + user_id: + title: User Id + type: string + user_name: + anyOf: + - type: string + - type: 'null' + title: User Name + required: + - principal_type + - user_id + - org_id + title: WindsurfSessionUserSelf + type: object + securitySchemes: + bearerAuth: + description: 'Service User credential (prefix: cog_)' + scheme: bearer + type: http diff --git a/crates/opensymphony-devin/src/lib.rs b/crates/opensymphony-devin/src/lib.rs new file mode 100644 index 000000000..235477ee5 --- /dev/null +++ b/crates/opensymphony-devin/src/lib.rs @@ -0,0 +1,2867 @@ +//! Devin cloud harness client. +//! +//! Devin is a remote implementation agent: the agent runtime, the checkout, and +//! the shell all live in a Devin-owned cloud workspace. OpenSymphony therefore +//! keeps its local sanitized issue workspace for manifests, journals, and +//! evidence only, and binds the repository to the remote workspace through +//! [`DevinRemoteWorkspaceBinding`] instead of handing the local checkout to the +//! harness as an execution cwd. +//! +//! The wire contract implemented here is the published Devin API v3 +//! (`contracts/devin-v3-openapi.yaml`, a session/identity subset mirrored from +//! ). Consequences of the real contract: +//! +//! * Every session route is organization-scoped: +//! `/v3/organizations/{org_id}/sessions/...`, authenticated with a service +//! user credential (`cog_` prefix). The organization ID is not a secret and +//! may be configured directly or discovered through `GET /v3/self`. +//! * There is no run resource and no event stream. A session is created with a +//! prompt, progress is read from cursor-paginated +//! `GET .../sessions/{devin_id}/messages`, and lifecycle state is read from +//! `status` / `status_detail` on the session resource. +//! * Repository binding is a real request field (`repos`), expressed as +//! `owner/name` entries; branch intent still travels in the prompt because +//! the API has no branch field. +//! +//! Hosted-mode posture implemented here: +//! +//! * Transport is HTTPS-only with TLS 1.2 as the floor, redirects refused (the +//! bearer token would otherwise follow a redirect to another host), and the +//! credential read from the environment at request time only. +//! * Tenancy is bound before any session call through `GET /v3/self`: the +//! credential's organization must match the configured one, and every +//! session payload is re-checked against it so a response for another +//! organization is rejected rather than journaled. +//! * Secret injection references organization secrets by id or key; the +//! references are resolved against `GET /v3/organizations/{org_id}/secrets` +//! so a run cannot request a secret outside its tenant, and secret *values* +//! never enter OpenSymphony. +//! * Remote artifacts come back through [`DevinEvidence`]: session summary, +//! normalized event journal, pull requests, and same-origin attachment +//! downloads are written into the local evidence workspace. + +use std::{ + collections::HashSet, + fmt, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use tokio::io::AsyncWriteExt; +use url::Url; + +use crate::{ + opensymphony_domain::HarnessAdapter, + opensymphony_gateway_schema::{ + capability::HarnessCapability, + envelope::EntityRef, + event_journal::{EventActor, EventKind, EventRecord}, + }, +}; + +pub const DEVIN_CLOUD_AGENT_KIND: &str = "devin_cloud_agent"; +/// Wire contract implemented by this module, pinned to the vendored spec. +pub const DEVIN_CLOUD_API_CONTRACT: &str = "devin-api-v3"; +/// Devin API origin. Version-prefixed paths are appended by the request builder. +pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai"; +/// Devin API v3 authenticates with a `cog_` service-user token. +pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "COG_SERVICE_USER_TOKEN"; +pub const DEFAULT_DEVIN_ORG_ID_ENV: &str = "DEVIN_ORG_ID"; +pub const DEFAULT_DEVIN_SESSION_POLL_INTERVAL_MS: u64 = 5_000; +pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; +/// Maximum number of tags Devin accepts on a session. +pub const DEVIN_MAX_SESSION_TAGS: usize = 50; +/// Tags an operator may configure; one slot is reserved for the correlation +/// tag that lets OpenSymphony find its own sessions again. +pub const DEVIN_MAX_OPERATOR_TAGS: usize = DEVIN_MAX_SESSION_TAGS - 1; +/// Maximum page size accepted by the paginated v3 collection endpoints. +pub const DEVIN_MAX_PAGE_SIZE: u32 = 200; +/// Prefix used for the tag that correlates a Devin session with an issue. +pub const DEVIN_CORRELATION_TAG_PREFIX: &str = "opensymphony"; + +/// Synthetic event type recorded when a Devin payload carries no recognized +/// discriminator field, so the payload is preserved rather than dropped. +pub const DEVIN_UNDISCRIMINATED_EVENT_KIND: &str = "devin.undiscriminated_event"; + +/// Effective containment label recorded for runs routed to Devin. +/// +/// The local workspace is never the execution directory for this harness. +pub const DEVIN_REMOTE_CONTAINMENT: &str = "devin_owned_remote_workspace"; + +#[derive(Debug, thiserror::Error)] +pub enum DevinConfigError { + #[error("devin.api.base_url must be an absolute https URL: {0}")] + InvalidBaseUrl(String), + #[error("devin.api.base_url must use the https scheme for remote Devin routing")] + InsecureBaseUrl, + #[error("devin.api.base_url must not embed credentials")] + CredentialsInBaseUrl, + #[error("devin.api.base_url must not carry a query string or fragment")] + DecoratedBaseUrl, + #[error("devin.api.api_key_env must be an environment variable name")] + InvalidApiKeyEnv, + #[error("devin.api.org_id must look like `org-...`")] + InvalidOrgId, + #[error("devin.api.org_id_env must be an environment variable name")] + InvalidOrgIdEnv, + #[error("the organization id read from the environment must look like `org-...`")] + InvalidOrgIdFromEnvironment, + #[error("devin.session.max_acu_limit must be greater than zero")] + InvalidAcuLimit, + #[error( + "devin sessions accept at most {DEVIN_MAX_OPERATOR_TAGS} configured tags; one slot is reserved for the correlation tag" + )] + TooManyTags, + #[error("devin session tags must not be blank")] + BlankTag, + #[error("devin repository binding must be an absolute https git URL: {0}")] + InvalidRepositoryUrl(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum DevinClientError { + #[error("devin api credential `{env}` is not set in the worker environment")] + MissingCredential { env: String }, + #[error( + "devin organization id is not configured and could not be resolved from the credential" + )] + MissingOrgId, + #[error("failed to build devin api url from `{base_url}` and `{path}`: {source}")] + InvalidUrl { + base_url: String, + path: String, + #[source] + source: url::ParseError, + }, + #[error("devin api transport error: {0}")] + Transport(String), + #[error("devin api returned status {status}: {problem}")] + Status { status: u16, problem: String }, + #[error("failed to decode devin api response: {0}")] + Decode(String), + #[error("invalid devin api configuration: {0}")] + InvalidConfig(String), + #[error("devin session `{session_id}` did not settle within {elapsed:?}")] + PollTimeout { + session_id: String, + elapsed: Duration, + }, + #[error( + "devin credential belongs to organization `{credential}` but routing is configured for `{configured}`" + )] + TenantMismatch { + configured: String, + credential: String, + }, + #[error( + "devin returned a payload owned by organization `{observed}` while bound to `{expected}`" + )] + CrossTenantPayload { expected: String, observed: String }, + #[error("devin secret `{reference}` is not available to organization `{org_id}`")] + UnknownSecret { reference: String, org_id: String }, + #[error( + "devin tenancy is unverified: `GET /v3/self` must bind the organization before session requests" + )] + TenantUnbound, + #[error("devin attachment `{name}` is served from `{origin}`, which is not the api origin")] + ForeignAttachmentOrigin { name: String, origin: String }, + #[error("devin attachment `{name}` exceeds the {limit} byte evidence download limit")] + AttachmentTooLarge { name: String, limit: u64 }, + #[error("failed to persist devin evidence at {path}: {source}")] + Evidence { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// API token wrapper that never renders its secret value. +#[derive(Clone, PartialEq, Eq)] +pub struct DevinApiToken(String); + +impl DevinApiToken { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for DevinApiToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("DevinApiToken(redacted)") + } +} + +/// Session creation options that map onto documented `SessionCreateRequest` +/// fields. Everything here is operator configuration, never credential values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinSessionOptions { + pub playbook_id: Option, + pub knowledge_ids: Option>, + /// References to organization secrets Devin injects into the session. The + /// values live in Devin, not in OpenSymphony configuration. + pub secret_ids: Option>, + pub max_acu_limit: Option, + pub tags: Vec, + pub title: Option, + pub devin_mode: Option, + /// VM platform or outpost pool override. + pub platform: Option, + /// Preserve VM state after the session stops so it can be resumed. + pub resumable: bool, +} + +impl Default for DevinSessionOptions { + fn default() -> Self { + Self { + playbook_id: None, + knowledge_ids: None, + secret_ids: None, + max_acu_limit: None, + tags: Vec::new(), + title: None, + devin_mode: None, + platform: None, + // The documented `resumable` default in the v3 contract. + resumable: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinCloudConfig { + pub base_url: String, + pub api_key_env: String, + /// Organization that owns the sessions. Resolved from `GET /v3/self` when + /// unset; this is an identifier, not a credential. + pub org_id: Option, + /// Environment variable consulted for the organization id when `org_id` is + /// unset. + pub org_id_env: String, + pub poll_interval: Duration, + pub request_timeout: Duration, + pub session: DevinSessionOptions, +} + +impl Default for DevinCloudConfig { + fn default() -> Self { + Self { + base_url: DEFAULT_DEVIN_API_BASE_URL.to_owned(), + api_key_env: DEFAULT_DEVIN_API_KEY_ENV.to_owned(), + org_id: None, + org_id_env: DEFAULT_DEVIN_ORG_ID_ENV.to_owned(), + poll_interval: Duration::from_millis(DEFAULT_DEVIN_SESSION_POLL_INTERVAL_MS), + request_timeout: Duration::from_millis(DEFAULT_DEVIN_REQUEST_TIMEOUT_MS), + session: DevinSessionOptions::default(), + } + } +} + +impl DevinCloudConfig { + pub fn validate(&self) -> Result<(), DevinConfigError> { + let parsed = Url::parse(&self.base_url) + .map_err(|error| DevinConfigError::InvalidBaseUrl(error.to_string()))?; + if parsed.scheme() != "https" { + return Err(DevinConfigError::InsecureBaseUrl); + } + if parsed.host().is_none() { + return Err(DevinConfigError::InvalidBaseUrl("missing host".into())); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(DevinConfigError::CredentialsInBaseUrl); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(DevinConfigError::DecoratedBaseUrl); + } + if !is_environment_name(&self.api_key_env) { + return Err(DevinConfigError::InvalidApiKeyEnv); + } + if let Some(org_id) = self.org_id.as_deref() + && !is_org_id(org_id) + { + return Err(DevinConfigError::InvalidOrgId); + } + if !self.org_id_env.is_empty() && !is_environment_name(&self.org_id_env) { + return Err(DevinConfigError::InvalidOrgIdEnv); + } + if self.session.max_acu_limit == Some(0) { + return Err(DevinConfigError::InvalidAcuLimit); + } + if self.session.tags.len() > DEVIN_MAX_OPERATOR_TAGS { + return Err(DevinConfigError::TooManyTags); + } + if self.session.tags.iter().any(|tag| tag.trim().is_empty()) { + return Err(DevinConfigError::BlankTag); + } + + Ok(()) + } +} + +fn is_environment_name(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|character| character == '_' || character.is_ascii_alphanumeric()) + && value + .chars() + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()) +} + +fn is_org_id(value: &str) -> bool { + value.len() > "org-".len() + && value.starts_with("org-") + && value + .chars() + .all(|character| character == '-' || character.is_ascii_alphanumeric()) +} + +/// Repository binding for a Devin-owned remote workspace. +/// +/// `local_evidence_path` is the sanitized OpenSymphony issue workspace. It holds +/// run manifests, journals, and evidence for the run; it is *not* the directory +/// Devin executes in and must never be advertised as such. +/// +/// The v3 create-session request carries repositories as `owner/name` entries, +/// so the binding renders both the request field and the prompt context that +/// communicates branch intent (which the API has no field for). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinRemoteWorkspaceBinding { + pub issue_workspace_key: String, + pub local_evidence_path: PathBuf, + pub repository_url: String, + pub base_branch: Option, +} + +impl DevinRemoteWorkspaceBinding { + pub fn new( + issue_workspace_key: impl Into, + local_evidence_path: impl Into, + repository_url: impl Into, + base_branch: Option, + ) -> Result { + let repository_url = repository_url.into(); + let parsed = Url::parse(&repository_url) + .map_err(|error| DevinConfigError::InvalidRepositoryUrl(error.to_string()))?; + if parsed.scheme() != "https" { + return Err(DevinConfigError::InvalidRepositoryUrl( + "must use the https scheme".into(), + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(DevinConfigError::InvalidRepositoryUrl( + "must not embed credentials".into(), + )); + } + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(DevinConfigError::InvalidRepositoryUrl( + "must not carry a query string or fragment".into(), + )); + } + + Ok(Self { + issue_workspace_key: issue_workspace_key.into(), + local_evidence_path: local_evidence_path.into(), + repository_url, + base_branch, + }) + } + + /// Containment label for the run: Devin owns the execution environment. + pub fn effective_containment(&self) -> &'static str { + DEVIN_REMOTE_CONTAINMENT + } + + /// `owner/name` form expected by the `repos` request field. + pub fn repo_name(&self) -> Option { + let parsed = Url::parse(&self.repository_url).ok()?; + let segments: Vec<&str> = parsed + .path_segments()? + .filter(|segment| !segment.is_empty()) + .collect(); + let [.., owner, name] = segments.as_slice() else { + return None; + }; + Some(format!("{owner}/{}", name.trim_end_matches(".git"))) + } + + /// Tag that correlates a Devin session with the OpenSymphony issue, used to + /// recover an in-flight session through the tag filter on list sessions. + pub fn correlation_tag(&self) -> String { + format!( + "{DEVIN_CORRELATION_TAG_PREFIX}:{}", + self.issue_workspace_key + ) + } + + /// Repository and branch context rendered into the session prompt. The + /// create-session request has no branch field, so branch intent is stated + /// here. + pub fn prompt_context(&self) -> String { + let mut context = format!("Repository: {}\n", self.repository_url); + if let Some(branch) = self.base_branch.as_deref() { + context.push_str(&format!("Base branch: {branch}\n")); + } + context.push_str(&format!( + "OpenSymphony issue workspace: {} (orchestrator-side evidence only; work in your own Devin workspace)\n", + self.issue_workspace_key + )); + context + } + + /// Prompt for a Devin session: repository context followed by the task. + pub fn compose_prompt(&self, task_prompt: &str) -> String { + format!("{}\n{}", self.prompt_context(), task_prompt.trim()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct DevinCloudAdapter { + config: DevinCloudConfig, +} + +impl DevinCloudAdapter { + pub fn new(config: DevinCloudConfig) -> Self { + Self { config } + } + + pub fn config(&self) -> &DevinCloudConfig { + &self.config + } + + /// Remaining feature gaps for hosted Devin routing. + pub fn feature_gaps(&self) -> Vec { + self.capabilities().feature_gaps + } +} + +impl HarnessAdapter for DevinCloudAdapter { + fn harness_kind(&self) -> &'static str { + DEVIN_CLOUD_AGENT_KIND + } + + fn capabilities(&self) -> HarnessCapability { + HarnessCapability::devin_cloud_agent() + } +} + +// --------------------------------------------------------------------------- +// Wire models (Devin API v3) +// --------------------------------------------------------------------------- + +/// `devin_mode` from `SessionCreateRequest`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DevinMode { + Normal, + Fast, + Lite, + Ultra, + Fusion, +} + +impl DevinMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Fast => "fast", + Self::Lite => "lite", + Self::Ultra => "ultra", + Self::Fusion => "fusion", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "normal" => Some(Self::Normal), + "fast" => Some(Self::Fast), + "lite" => Some(Self::Lite), + "ultra" => Some(Self::Ultra), + "fusion" => Some(Self::Fusion), + _ => None, + } + } +} + +/// `SessionCreateRequest` from the Devin v3 OpenAPI document. +/// +/// Fields OpenSymphony does not drive (`session_secrets`, `create_as_user_id`, +/// `bypass_approval`, attachment uploads) are deliberately omitted: they are +/// part of the hosted-security work that still needs hardening evidence. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct SessionCreateRequest { + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub repos: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub playbook_id: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub knowledge_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub secret_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub max_acu_limit: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub devin_mode: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub platform: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub resumable: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub structured_output_schema: Option, +} + +/// `status` from `SessionResponse`. Unknown values are kept so a newer Devin +/// status never fails decoding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DevinSessionStatus { + New, + Claimed, + Running, + Exit, + Error, + Suspended, + Resuming, + #[serde(untagged)] + Other(String), +} + +impl DevinSessionStatus { + pub fn as_str(&self) -> &str { + match self { + Self::New => "new", + Self::Claimed => "claimed", + Self::Running => "running", + Self::Exit => "exit", + Self::Error => "error", + Self::Suspended => "suspended", + Self::Resuming => "resuming", + Self::Other(value) => value, + } + } + + /// Statuses Devin will not leave without operator or platform action. + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Exit | Self::Error | Self::Suspended) + } +} + +/// How [`DevinCloudClient::stop_session`] left a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DevinStopOutcome { + /// The terminate request was accepted; the session is stopped and archived. + Terminated, + /// Devin refused to terminate because the session had already reached a + /// terminal status; it is archived now if it was not before. + AlreadyStopped, + /// The session no longer exists. + Missing, +} + +/// Outcome of a termination Devin rejected, decided from the session's current +/// status: a session that is already terminal counts as stopped, anything else +/// keeps the rejection as an error. +pub fn rejected_termination_outcome(session: &SessionResponse) -> Option { + session + .status + .is_terminal() + .then_some(DevinStopOutcome::AlreadyStopped) +} + +/// `status_detail` from `SessionResponse`. Kept as a string so unlisted detail +/// values survive decoding; the interesting ones are exposed as predicates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DevinStatusDetail(pub String); + +impl DevinStatusDetail { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Devin finished the task but the session is still running. + pub fn is_finished(&self) -> bool { + self.0 == "finished" + } + + /// Devin needs operator input or an action approval before continuing. + pub fn is_waiting_on_operator(&self) -> bool { + matches!(self.0.as_str(), "waiting_for_user" | "waiting_for_approval") + } + + /// Devin put the session to sleep because nobody answered it. + pub fn is_inactivity(&self) -> bool { + self.0 == "inactivity" + } +} + +/// `SessionPullRequest` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPullRequest { + pub pr_url: String, + pub pr_state: Option, +} + +/// `SessionResponse` from the Devin v3 OpenAPI document. Unknown fields are +/// retained so newer Devin payloads replay unchanged. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionResponse { + pub session_id: String, + pub url: String, + pub status: DevinSessionStatus, + #[serde(default)] + pub status_detail: Option, + #[serde(default)] + pub tags: Vec, + pub org_id: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(default)] + pub acus_consumed: f64, + #[serde(default)] + pub pull_requests: Vec, + #[serde(default)] + pub structured_output: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub is_archived: bool, + #[serde(default)] + pub devin_mode: Option, + #[serde(default)] + pub parent_session_id: Option, + #[serde(default)] + pub playbook_id: Option, + #[serde(flatten, default)] + pub extra: Map, +} + +impl SessionResponse { + /// The session is done as far as the orchestrator is concerned: either the + /// VM stopped, or Devin reported the task finished. + pub fn is_settled(&self) -> bool { + self.status.is_terminal() + || self + .status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_finished) + } + + pub fn is_waiting_on_operator(&self) -> bool { + self.status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_waiting_on_operator) + } + + /// The session went to sleep waiting for a reply; it stays resumable and + /// is treated like a session that is still waiting on its operator. + pub fn is_idle_suspended(&self) -> bool { + self.status == DevinSessionStatus::Suspended + && self + .status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_inactivity) + } + + /// Devin is still executing: not settled and not parked on its operator. + pub fn is_executing(&self) -> bool { + !self.is_settled() && !self.is_waiting_on_operator() + } +} + +/// `SessionMessage` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionMessage { + pub event_id: String, + pub source: String, + pub message: String, + pub created_at: i64, + #[serde(flatten, default)] + pub extra: Map, +} + +/// `SessionMessageCreateRequest` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionMessageCreateRequest { + pub message: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub attachment_urls: Option>, +} + +/// `PaginatedResponse[T]` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct PaginatedResponse { + pub items: Vec, + #[serde(default)] + pub end_cursor: Option, + #[serde(default)] + pub has_next_page: bool, + #[serde(default)] + pub total: Option, +} + +/// `SessionAttachment` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionAttachment { + pub attachment_id: String, + pub name: String, + pub url: String, + pub source: String, + #[serde(default)] + pub content_type: Option, +} + +/// `SecretResponse` from the Devin v3 OpenAPI document. +/// +/// Secret *values* are never returned by the API and never enter OpenSymphony: +/// this record only carries the identifiers used to scope injection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SecretResponse { + pub secret_id: String, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub access_type: Option, + #[serde(default)] + pub secret_type: Option, +} + +/// `SessionTagsUpdateRequest` from the Devin v3 OpenAPI document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionTagsUpdateRequest { + pub tags: Vec, +} + +/// Subset of the `GET /v3/self` response that identifies the calling principal. +/// +/// The four documented principal shapes all optionally carry `org_id`, which is +/// the only field this harness needs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinSelfResponse { + #[serde(default)] + pub principal_type: Option, + #[serde(default)] + pub org_id: Option, + #[serde(default)] + pub service_user_id: Option, + #[serde(default)] + pub service_user_name: Option, +} + +/// `ProblemDetail` (RFC 9457) error body returned by every v3 route. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevinProblemDetail { + pub title: String, + pub status: i64, + #[serde(default)] + pub detail: Option, + #[serde(default, rename = "type")] + pub problem_type: Option, + #[serde(default)] + pub instance: Option, + #[serde(default)] + pub errors: Option>>, +} + +impl fmt::Display for DevinProblemDetail { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.detail.as_deref() { + Some(detail) => write!(formatter, "{}: {detail}", self.title), + None => formatter.write_str(&self.title), + } + } +} + +/// Filters for `GET /v3/organizations/{org_id}/sessions`, serialized into the +/// documented `qs` query parameter. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionsQueryParams { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub session_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub repo_names: Option>, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub is_archived: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub first: Option, +} + +impl SessionsQueryParams { + pub fn by_tag(tag: impl Into) -> Self { + Self { + tags: Some(vec![tag.into()]), + ..Self::default() + } + } +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DevinHttpMethod { + Get, + Post, + Put, + Delete, +} + +impl DevinHttpMethod { + pub fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + } + } +} + +/// Documented operations this client uses, named after the OpenAPI `summary` +/// values they map to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DevinOperation { + GetSelf, + CreateSession, + GetSession, + ListSessions, + ListSessionMessages, + SendSessionMessage, + DeleteSession, + ArchiveSession, + ListSessionAttachments, + UpdateSessionTags, + ListSecrets, +} + +impl DevinOperation { + pub fn as_str(self) -> &'static str { + match self { + Self::GetSelf => "get_self", + Self::CreateSession => "create_session", + Self::GetSession => "get_session", + Self::ListSessions => "list_sessions", + Self::ListSessionMessages => "list_session_messages", + Self::SendSessionMessage => "send_session_message", + Self::DeleteSession => "delete_session", + Self::ArchiveSession => "archive_session", + Self::ListSessionAttachments => "list_session_attachments", + Self::UpdateSessionTags => "update_session_tags", + Self::ListSecrets => "list_secrets", + } + } +} + +/// Transport-agnostic description of a Devin cloud API call. +/// +/// Credentials are injected by [`DevinCloudClient`] at send time so request +/// descriptions stay safe to log, snapshot, and unit test. +#[derive(Debug, Clone, PartialEq)] +pub struct DevinRequest { + pub operation: DevinOperation, + pub method: DevinHttpMethod, + pub path: String, + pub body: Option, +} + +/// Builds organization-scoped v3 request descriptions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinRequestBuilder { + base_url: String, + org_id: String, +} + +impl DevinRequestBuilder { + pub fn new(base_url: impl Into, org_id: impl Into) -> Self { + Self { + base_url: base_url.into(), + org_id: org_id.into(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn org_id(&self) -> &str { + &self.org_id + } + + fn sessions_path(&self) -> String { + format!( + "/v3/organizations/{}/sessions", + encode_path_segment(&self.org_id) + ) + } + + fn session_path(&self, devin_id: &str) -> String { + format!("{}/{}", self.sessions_path(), encode_path_segment(devin_id)) + } + + /// `GET /v3/self` + pub fn get_self(&self) -> DevinRequest { + DevinRequest { + operation: DevinOperation::GetSelf, + method: DevinHttpMethod::Get, + path: "/v3/self".into(), + body: None, + } + } + + /// `POST /v3/organizations/{org_id}/sessions` + pub fn create_session(&self, request: &SessionCreateRequest) -> DevinRequest { + DevinRequest { + operation: DevinOperation::CreateSession, + method: DevinHttpMethod::Post, + path: self.sessions_path(), + body: Some(serde_json::to_value(request).unwrap_or_else(|_| json!({}))), + } + } + + /// `GET /v3/organizations/{org_id}/sessions/{devin_id}` + pub fn get_session(&self, devin_id: &str) -> DevinRequest { + DevinRequest { + operation: DevinOperation::GetSession, + method: DevinHttpMethod::Get, + path: self.session_path(devin_id), + body: None, + } + } + + /// `GET /v3/organizations/{org_id}/sessions?qs=` + /// + /// The documented `qs` parameter is a single object-valued query parameter, + /// so the filter set is JSON-encoded into it. + pub fn list_sessions(&self, query: &SessionsQueryParams) -> DevinRequest { + let encoded = serde_json::to_string(query).unwrap_or_else(|_| "{}".to_owned()); + DevinRequest { + operation: DevinOperation::ListSessions, + method: DevinHttpMethod::Get, + path: format!( + "{}?qs={}", + self.sessions_path(), + encode_query_value(&encoded) + ), + body: None, + } + } + + /// `GET /v3/organizations/{org_id}/sessions/{devin_id}/messages` + pub fn list_messages( + &self, + devin_id: &str, + after: Option<&str>, + first: Option, + ) -> DevinRequest { + let mut path = format!("{}/messages", self.session_path(devin_id)); + let mut separator = '?'; + if let Some(after) = after { + path.push_str(&format!("{separator}after={}", encode_query_value(after))); + separator = '&'; + } + if let Some(first) = first { + let first = first.clamp(1, DEVIN_MAX_PAGE_SIZE); + path.push_str(&format!("{separator}first={first}")); + } + + DevinRequest { + operation: DevinOperation::ListSessionMessages, + method: DevinHttpMethod::Get, + path, + body: None, + } + } + + /// `POST /v3/organizations/{org_id}/sessions/{devin_id}/messages` + pub fn send_message(&self, devin_id: &str, message: impl Into) -> DevinRequest { + let body = SessionMessageCreateRequest { + message: message.into(), + attachment_urls: None, + }; + DevinRequest { + operation: DevinOperation::SendSessionMessage, + method: DevinHttpMethod::Post, + path: format!("{}/messages", self.session_path(devin_id)), + body: Some(serde_json::to_value(&body).unwrap_or_else(|_| json!({}))), + } + } + + /// `DELETE /v3/organizations/{org_id}/sessions/{devin_id}` + pub fn delete_session(&self, devin_id: &str, archive: bool) -> DevinRequest { + DevinRequest { + operation: DevinOperation::DeleteSession, + method: DevinHttpMethod::Delete, + path: format!("{}?archive={archive}", self.session_path(devin_id)), + body: None, + } + } + + /// `POST /v3/organizations/{org_id}/sessions/{devin_id}/archive` + pub fn archive_session(&self, devin_id: &str) -> DevinRequest { + DevinRequest { + operation: DevinOperation::ArchiveSession, + method: DevinHttpMethod::Post, + path: format!("{}/archive", self.session_path(devin_id)), + body: None, + } + } + + /// `GET /v3/organizations/{org_id}/sessions/{devin_id}/attachments` + pub fn list_attachments(&self, devin_id: &str) -> DevinRequest { + DevinRequest { + operation: DevinOperation::ListSessionAttachments, + method: DevinHttpMethod::Get, + path: format!("{}/attachments", self.session_path(devin_id)), + body: None, + } + } + + /// `PUT /v3/organizations/{org_id}/sessions/{devin_id}/tags` + pub fn replace_tags(&self, devin_id: &str, tags: Vec) -> DevinRequest { + let body = SessionTagsUpdateRequest { tags }; + DevinRequest { + operation: DevinOperation::UpdateSessionTags, + method: DevinHttpMethod::Put, + path: format!("{}/tags", self.session_path(devin_id)), + body: Some(serde_json::to_value(&body).unwrap_or_else(|_| json!({}))), + } + } + + /// `GET /v3/organizations/{org_id}/secrets` + /// + /// Used to scope secret injection: a run may only reference secrets that + /// exist in its own organization. + pub fn list_secrets(&self, after: Option<&str>, first: Option) -> DevinRequest { + let mut path = format!( + "/v3/organizations/{}/secrets", + encode_path_segment(&self.org_id) + ); + let mut separator = '?'; + if let Some(after) = after { + path.push_str(&format!("{separator}after={}", encode_query_value(after))); + separator = '&'; + } + if let Some(first) = first { + let first = first.clamp(1, DEVIN_MAX_PAGE_SIZE); + path.push_str(&format!("{separator}first={first}")); + } + + DevinRequest { + operation: DevinOperation::ListSecrets, + method: DevinHttpMethod::Get, + path, + body: None, + } + } + + pub fn absolute_url(&self, request: &DevinRequest) -> Result { + let joined = format!("{}{}", self.base_url.trim_end_matches('/'), request.path); + Url::parse(&joined).map_err(|source| DevinClientError::InvalidUrl { + base_url: self.base_url.clone(), + path: request.path.clone(), + source, + }) + } +} + +/// Builds the documented create-session payload for an issue run. +pub fn session_create_request( + task_prompt: &str, + binding: &DevinRemoteWorkspaceBinding, + options: &DevinSessionOptions, +) -> SessionCreateRequest { + // The correlation tag is how an uncertain creation is reconciled and how + // operators find OpenSymphony sessions, so it always survives truncation. + let correlation = binding.correlation_tag(); + let mut tags: Vec = options + .tags + .iter() + .filter(|tag| *tag != &correlation) + .take(DEVIN_MAX_OPERATOR_TAGS) + .cloned() + .collect(); + tags.push(correlation); + + SessionCreateRequest { + prompt: binding.compose_prompt(task_prompt), + repos: binding.repo_name().map(|repo| vec![repo]), + tags: Some(tags), + title: options.title.clone(), + playbook_id: options.playbook_id.clone(), + knowledge_ids: options.knowledge_ids.clone(), + secret_ids: options.secret_ids.clone(), + max_acu_limit: options.max_acu_limit, + devin_mode: options.devin_mode, + platform: options.platform.clone(), + resumable: Some(options.resumable), + structured_output_schema: None, + } +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn encode_query_value(value: &str) -> String { + encode_path_segment(value) +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// Verified identity of the credential driving a Devin run. +/// +/// Produced by [`DevinCloudClient::bind_tenant`], which is the only path that +/// marks a client as usable for session traffic. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinTenancy { + pub org_id: String, + #[serde(default)] + pub principal_type: Option, + #[serde(default)] + pub service_user_id: Option, + #[serde(default)] + pub service_user_name: Option, +} + +/// HTTPS client for the Devin cloud API. +#[derive(Debug, Clone)] +pub struct DevinCloudClient { + http: reqwest::Client, + base_url: String, + org_id: Option, + token: DevinApiToken, + poll_interval: Duration, + tenancy: Option, +} + +impl DevinCloudClient { + /// Builds a client, reading the API token from the configured environment + /// variable. The token is never stored in configuration or manifests. + pub fn from_environment( + config: &DevinCloudConfig, + environment: impl Fn(&str) -> Option, + ) -> Result { + config + .validate() + .map_err(|error| DevinClientError::InvalidConfig(error.to_string()))?; + let token = environment(&config.api_key_env) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| DevinClientError::MissingCredential { + env: config.api_key_env.clone(), + })?; + // The organization identifier is not a credential; it is read from the + // environment only so operators can keep it beside the token. + let org_id = match config.org_id.clone() { + Some(org_id) => Some(org_id), + None if config.org_id_env.is_empty() => None, + None => environment(&config.org_id_env) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()), + }; + if let Some(org_id) = org_id.as_deref() + && !is_org_id(org_id) + { + return Err(DevinClientError::InvalidConfig( + DevinConfigError::InvalidOrgIdFromEnvironment.to_string(), + )); + } + + let http = reqwest::Client::builder() + .timeout(config.request_timeout) + .https_only(true) + .min_tls_version(reqwest::tls::Version::TLS_1_2) + // The bearer token is attached to every request, so a redirect to + // another host would hand the credential to that host. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + + Ok(Self { + http, + base_url: config.base_url.clone(), + org_id, + token: DevinApiToken::new(token.trim()), + poll_interval: config.poll_interval, + tenancy: None, + }) + } + + pub fn org_id(&self) -> Option<&str> { + self.org_id.as_deref() + } + + pub fn poll_interval(&self) -> Duration { + self.poll_interval + } + + /// Request builder bound to the verified organization. + /// + /// Only [`Self::identity`] may run before [`Self::bind_tenant`]: a + /// configured organization alone is an operator claim, not evidence that + /// the credential belongs to it. + pub fn requests(&self) -> Result { + let tenancy = self + .tenancy + .as_ref() + .ok_or(DevinClientError::TenantUnbound)?; + Ok(DevinRequestBuilder::new( + self.base_url.clone(), + tenancy.org_id.as_str(), + )) + } + + /// Adopts a tenancy that was already verified against `GET /v3/self`. + /// + /// This exists for callers that hold the verified identity from another + /// client of the same credential (and for offline fixtures); the + /// configured organization must still agree with it. + pub fn bind_verified_tenancy( + mut self, + tenancy: DevinTenancy, + ) -> Result { + if let Some(configured) = self.org_id.as_deref() + && configured != tenancy.org_id + { + return Err(DevinClientError::TenantMismatch { + configured: configured.to_owned(), + credential: tenancy.org_id, + }); + } + self.org_id = Some(tenancy.org_id.clone()); + self.tenancy = Some(tenancy); + Ok(self) + } + + /// Verified tenancy, once [`Self::bind_tenant`] has run. + pub fn tenancy(&self) -> Option<&DevinTenancy> { + self.tenancy.as_ref() + } + + /// Binds the client to exactly one organization. + /// + /// The credential's own organization is read from `GET /v3/self`. When an + /// organization was configured, the two must agree: a credential minted for + /// another tenant is refused rather than silently driving sessions there. + /// When none was configured, the credential's organization is adopted. + pub async fn bind_tenant(mut self) -> Result { + let identity = self.identity().await?; + let tenancy = reconcile_tenant(self.org_id.as_deref(), identity)?; + self.org_id = Some(tenancy.org_id.clone()); + self.tenancy = Some(tenancy); + + Ok(self) + } + + /// Resolves the organization from the credential when it was not + /// configured, so operators only need to supply the service user key. + pub async fn resolve_org_id(self) -> Result { + self.bind_tenant().await + } + + /// Rejects a session payload owned by a different organization. + fn ensure_tenant(&self, session: &SessionResponse) -> Result<(), DevinClientError> { + let expected = self + .tenancy + .as_ref() + .ok_or(DevinClientError::TenantUnbound)?; + ensure_session_tenant(&expected.org_id, session) + } + + /// Finds the live session a lost `create_session` response may have + /// produced, so a retry never leaves an untracked session billing. + /// + /// Sessions are matched by the correlation tag, must not be archived, and + /// must have been created at or after `not_before`. Every candidate is + /// tenant-checked; the newest one wins. + pub async fn reconcile_created_session( + &self, + correlation_tag: &str, + not_before: i64, + ) -> Result, DevinClientError> { + let mut query = SessionsQueryParams::by_tag(correlation_tag); + query.is_archived = Some(false); + query.first = Some(DEVIN_MAX_PAGE_SIZE); + let page = self.list_sessions(&query).await?; + Ok(select_reconciled_session( + page.items, + correlation_tag, + not_before, + )) + } + + pub async fn identity(&self) -> Result { + self.send_typed(&DevinRequestBuilder::new(self.base_url.clone(), "").get_self()) + .await + } + + pub async fn send(&self, request: &DevinRequest) -> Result { + let url = DevinRequestBuilder::new( + self.base_url.clone(), + self.org_id.clone().unwrap_or_default(), + ) + .absolute_url(request)?; + let mut builder = match request.method { + DevinHttpMethod::Get => self.http.get(url), + DevinHttpMethod::Post => self.http.post(url), + DevinHttpMethod::Put => self.http.put(url), + DevinHttpMethod::Delete => self.http.delete(url), + } + .bearer_auth(self.token.expose()); + if let Some(body) = request.body.as_ref() { + builder = builder.json(body); + } + + let response = builder + .send() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + if !status.is_success() { + return Err(DevinClientError::Status { + status: status.as_u16(), + problem: describe_problem(&body), + }); + } + if body.trim().is_empty() { + return Ok(Value::Null); + } + + serde_json::from_str(&body).map_err(|error| DevinClientError::Decode(error.to_string())) + } + + async fn send_typed( + &self, + request: &DevinRequest, + ) -> Result { + let value = self.send(request).await?; + serde_json::from_value(value).map_err(|error| DevinClientError::Decode(error.to_string())) + } + + pub async fn create_session( + &self, + request: &SessionCreateRequest, + ) -> Result { + let session: SessionResponse = self + .send_typed(&self.requests()?.create_session(request)) + .await?; + self.ensure_tenant(&session)?; + Ok(session) + } + + pub async fn get_session(&self, devin_id: &str) -> Result { + let session: SessionResponse = self + .send_typed(&self.requests()?.get_session(devin_id)) + .await?; + self.ensure_tenant(&session)?; + Ok(session) + } + + pub async fn list_sessions( + &self, + query: &SessionsQueryParams, + ) -> Result, DevinClientError> { + let page: PaginatedResponse = self + .send_typed(&self.requests()?.list_sessions(query)) + .await?; + for session in &page.items { + self.ensure_tenant(session)?; + } + Ok(page) + } + + /// Fetches one page of session messages. `after` is the `end_cursor` of the + /// previous page. + pub async fn list_messages( + &self, + devin_id: &str, + after: Option<&str>, + first: Option, + ) -> Result, DevinClientError> { + self.send_typed(&self.requests()?.list_messages(devin_id, after, first)) + .await + } + + pub async fn send_message( + &self, + devin_id: &str, + message: impl Into, + ) -> Result<(), DevinClientError> { + self.send(&self.requests()?.send_message(devin_id, message)) + .await + .map(|_| ()) + } + + /// Stops a session, optionally archiving it. + pub async fn delete_session( + &self, + devin_id: &str, + archive: bool, + ) -> Result<(), DevinClientError> { + self.send(&self.requests()?.delete_session(devin_id, archive)) + .await + .map(|_| ()) + } + + pub async fn archive_session(&self, devin_id: &str) -> Result<(), DevinClientError> { + self.send(&self.requests()?.archive_session(devin_id)) + .await + .map(|_| ()) + } + + /// Stops a session so that it no longer runs or bills: terminates and + /// archives it, or confirms that it already reached a terminal status. + /// + /// Devin rejects `DELETE` for a session that has already exited + /// (`400 Devin session already exited`), so a rejected termination is + /// re-checked against the session's current status before it counts as + /// a failure; a session that is already terminal is archived if needed. + pub async fn stop_session(&self, devin_id: &str) -> Result { + let rejection = match self.delete_session(devin_id, true).await { + Ok(()) => return Ok(DevinStopOutcome::Terminated), + Err(DevinClientError::Status { status: 404, .. }) => { + return Ok(DevinStopOutcome::Missing); + } + Err(error @ DevinClientError::Status { .. }) => error, + Err(error) => return Err(error), + }; + let session = self.get_session(devin_id).await?; + let Some(outcome) = rejected_termination_outcome(&session) else { + return Err(rejection); + }; + if !session.is_archived { + self.archive_session(devin_id).await?; + } + Ok(outcome) + } + + pub async fn list_attachments( + &self, + devin_id: &str, + ) -> Result, DevinClientError> { + self.send_typed(&self.requests()?.list_attachments(devin_id)) + .await + } + + /// Every secret the bound organization exposes, walked to the last page. + pub async fn list_secrets(&self) -> Result, DevinClientError> { + let requests = self.requests()?; + let mut secrets = Vec::new(); + let mut after: Option = None; + loop { + let page: PaginatedResponse = self + .send_typed(&requests.list_secrets(after.as_deref(), Some(DEVIN_MAX_PAGE_SIZE))) + .await?; + let advanced = page + .end_cursor + .clone() + .filter(|cursor| Some(cursor) != after.as_ref()); + secrets.extend(page.items); + match (page.has_next_page, advanced) { + (true, Some(cursor)) => after = Some(cursor), + // A page that cannot advance its cursor would loop forever. + _ => return Ok(secrets), + } + } + } + + /// Resolves configured secret references to organization secret ids. + /// + /// A reference may be a secret id or a secret key, which keeps workflow + /// files readable. Anything the bound organization does not own is + /// rejected, so a workflow cannot inject another tenant's secret into a + /// session, and secret values never enter OpenSymphony. + pub async fn resolve_secret_ids( + &self, + references: &[String], + ) -> Result, DevinClientError> { + if references.is_empty() { + return Ok(Vec::new()); + } + let org_id = self.org_id.clone().ok_or(DevinClientError::MissingOrgId)?; + let available = self.list_secrets().await?; + resolve_secret_references(&org_id, &available, references) + } + + /// Downloads one session attachment for local evidence, bounded by `limit`. + /// + /// Attachments are only fetched from the API origin the client is already + /// authenticated against: another origin would either receive the bearer + /// token or contribute unauthenticated bytes to run evidence. + pub async fn download_attachment( + &self, + attachment: &SessionAttachment, + limit: u64, + ) -> Result, DevinClientError> { + let url = attachment_download_url(&self.base_url, attachment)?; + + let response = self + .http + .get(url) + .bearer_auth(self.token.expose()) + .send() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(DevinClientError::Status { + status: status.as_u16(), + problem: describe_problem(&body), + }); + } + if response + .content_length() + .is_some_and(|length| length > limit) + { + return Err(DevinClientError::AttachmentTooLarge { + name: attachment.name.clone(), + limit, + }); + } + // `Content-Length` is advisory and absent for chunked responses, so the + // body is read incrementally and abandoned as soon as it exceeds the + // limit rather than being buffered in full first. + let mut response = response; + let mut body = BoundedBody::new(response.content_length(), limit); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| DevinClientError::Transport(error.to_string()))? + { + body.push(&chunk) + .map_err(|()| DevinClientError::AttachmentTooLarge { + name: attachment.name.clone(), + limit, + })?; + } + let body = body.into_inner(); + + Ok(body) + } +} + +/// Accumulates a response body without ever holding more than `limit` bytes. +/// +/// A chunked attachment reports no `Content-Length`, so the ceiling can only be +/// enforced while the body streams in. +struct BoundedBody { + buffer: Vec, + limit: u64, +} + +impl BoundedBody { + fn new(content_length: Option, limit: u64) -> Self { + Self { + buffer: Vec::with_capacity( + usize::try_from(content_length.unwrap_or(0).min(limit)).unwrap_or(0), + ), + limit, + } + } + + fn push(&mut self, chunk: &[u8]) -> Result<(), ()> { + if self.buffer.len() as u64 + chunk.len() as u64 > self.limit { + return Err(()); + } + self.buffer.extend_from_slice(chunk); + Ok(()) + } + + fn into_inner(self) -> Vec { + self.buffer + } +} + +/// Binds a credential identity to exactly one organization. +/// +/// A configured organization must match the credential's own organization: a +/// credential minted for another tenant is refused rather than silently driving +/// sessions there. When none was configured, the credential's organization is +/// adopted. +pub fn reconcile_tenant( + configured: Option<&str>, + identity: DevinSelfResponse, +) -> Result { + let credential_org = identity.org_id.ok_or(DevinClientError::MissingOrgId)?; + if let Some(configured) = configured + && configured != credential_org + { + return Err(DevinClientError::TenantMismatch { + configured: configured.to_owned(), + credential: credential_org, + }); + } + + Ok(DevinTenancy { + org_id: credential_org, + principal_type: identity.principal_type, + service_user_id: identity.service_user_id, + service_user_name: identity.service_user_name, + }) +} + +/// Rejects a session payload owned by a different organization. +pub fn ensure_session_tenant( + expected: &str, + session: &SessionResponse, +) -> Result<(), DevinClientError> { + if session.org_id != expected { + return Err(DevinClientError::CrossTenantPayload { + expected: expected.to_owned(), + observed: session.org_id.clone(), + }); + } + + Ok(()) +} + +/// Picks the session that an uncertain `create_session` most plausibly +/// produced: tagged with this run's correlation tag, not archived, and not +/// older than the creation attempt. Devin reports `created_at` in epoch +/// seconds; a millisecond value is normalized so a unit change cannot make +/// every candidate look too old. +pub fn select_reconciled_session( + sessions: Vec, + correlation_tag: &str, + not_before: i64, +) -> Option { + sessions + .into_iter() + .filter(|session| { + !session.is_archived + && session.tags.iter().any(|tag| tag == correlation_tag) + && epoch_seconds(session.created_at) >= not_before + }) + .max_by_key(|session| epoch_seconds(session.created_at)) +} + +fn epoch_seconds(value: i64) -> i64 { + if value > 100_000_000_000 { + value / 1_000 + } else { + value + } +} + +/// Maps secret references onto ids owned by the bound organization. +/// +/// A reference may be a secret id or a secret key, which keeps workflow files +/// readable. Anything the organization does not own is rejected, so a workflow +/// cannot inject another tenant's secret into a session. +pub fn resolve_secret_references( + org_id: &str, + available: &[SecretResponse], + references: &[String], +) -> Result, DevinClientError> { + let mut resolved: Vec = Vec::with_capacity(references.len()); + for reference in references { + let reference = reference.trim(); + let matched = available + .iter() + .find(|secret| secret.secret_id == reference) + .or_else(|| { + available + .iter() + .find(|secret| secret.key.as_deref() == Some(reference)) + }) + .ok_or_else(|| DevinClientError::UnknownSecret { + reference: reference.to_owned(), + org_id: org_id.to_owned(), + })?; + if !resolved.contains(&matched.secret_id) { + resolved.push(matched.secret_id.clone()); + } + } + + Ok(resolved) +} + +/// Validates that an attachment lives on the authenticated API origin. +/// +/// Another origin would either receive the bearer token or contribute +/// unauthenticated bytes to run evidence. +pub fn attachment_download_url( + base_url: &str, + attachment: &SessionAttachment, +) -> Result { + let url = Url::parse(&attachment.url).map_err(|source| DevinClientError::InvalidUrl { + base_url: base_url.to_owned(), + path: attachment.attachment_id.clone(), + source, + })?; + let base = Url::parse(base_url).map_err(|source| DevinClientError::InvalidUrl { + base_url: base_url.to_owned(), + path: String::new(), + source, + })?; + // Ports are part of the origin: `https://api.devin.ai:8443` is a different + // service from `https://api.devin.ai`, and must never receive the token. + if url.scheme() != base.scheme() + || url.host_str() != base.host_str() + || url.port_or_known_default() != base.port_or_known_default() + { + return Err(DevinClientError::ForeignAttachmentOrigin { + name: attachment.name.clone(), + origin: url.origin().ascii_serialization(), + }); + } + + Ok(url) +} + +/// Renders an error body as a problem description, falling back to the raw body +/// when Devin returns something other than `application/problem+json`. +fn describe_problem(body: &str) -> String { + serde_json::from_str::(body) + .map(|problem| problem.to_string()) + .unwrap_or_else(|_| body.trim().to_owned()) +} + +// --------------------------------------------------------------------------- +// Event normalization +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NormalizedDevinEventKind { + SessionCreated, + DevinMessage, + UserMessage, + StatusChanged, + SessionFinished, + SessionBlocked, + SessionFailed, + SessionSuspended, + SessionTerminated, + /// Orchestrator-side liveness observation: the polled session is still + /// executing but produced no new message or status since the last event. + SessionHeartbeat, + Unknown, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NormalizedDevinEvent { + pub kind: NormalizedDevinEventKind, + pub event_type: String, + pub event_id: Option, + pub session_id: Option, + pub timestamp: Option, + pub message: Option, + pub status: Option, + pub raw: Value, +} + +/// Normalizes one documented `SessionMessage`. +/// +/// Unrecognized sources are retained as [`NormalizedDevinEventKind::Unknown`] +/// with the full raw JSON so future Devin schema additions replay unchanged. +/// `event_type` of a normalized message authored by Devin itself. +pub const DEVIN_MESSAGE_EVENT_KIND: &str = "session.message.devin"; +/// `event_type` of the liveness event emitted while a quiet session executes. +pub const DEVIN_HEARTBEAT_EVENT_KIND: &str = "session.heartbeat"; +/// Default spacing between liveness events for a quiet, executing session. +pub const DEFAULT_DEVIN_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60); + +pub fn normalize_session_message( + session_id: &str, + message: &SessionMessage, +) -> NormalizedDevinEvent { + let kind = match message.source.as_str() { + "devin" => NormalizedDevinEventKind::DevinMessage, + "user" => NormalizedDevinEventKind::UserMessage, + _ => NormalizedDevinEventKind::Unknown, + }; + let raw = serde_json::to_value(message).unwrap_or_else(|_| json!({})); + + NormalizedDevinEvent { + kind, + event_type: format!("session.message.{}", message.source), + event_id: Some(message.event_id.clone()), + session_id: Some(session_id.to_owned()), + timestamp: Some(message.created_at.to_string()), + message: Some(message.message.clone()), + status: None, + raw, + } +} + +/// Normalizes a raw JSON payload that could not be decoded into a +/// [`SessionMessage`], preserving it verbatim. +pub fn normalize_devin_event(raw: Value) -> NormalizedDevinEvent { + let source = first_string(&raw, &["source", "type", "event_type", "kind"]); + let kind = match source.as_deref() { + Some("devin") => NormalizedDevinEventKind::DevinMessage, + Some("user") => NormalizedDevinEventKind::UserMessage, + _ => NormalizedDevinEventKind::Unknown, + }; + + NormalizedDevinEvent { + kind, + event_type: source.unwrap_or_else(|| DEVIN_UNDISCRIMINATED_EVENT_KIND.to_owned()), + event_id: first_string(&raw, &["event_id", "eventId"]), + session_id: first_string(&raw, &["session_id", "sessionId", "devin_id"]), + timestamp: first_string(&raw, &["created_at", "timestamp"]), + message: first_string(&raw, &["message", "text", "content"]), + status: first_string(&raw, &["status", "status_detail"]), + raw, + } +} + +/// Synthesizes the status event Devin does not send: status is session state, +/// not a message, so transitions are derived while polling. +pub fn status_event(session: &SessionResponse) -> NormalizedDevinEvent { + let detail = session.status_detail.as_ref(); + let kind = match &session.status { + DevinSessionStatus::Error => NormalizedDevinEventKind::SessionFailed, + DevinSessionStatus::Suspended => NormalizedDevinEventKind::SessionSuspended, + DevinSessionStatus::Exit => NormalizedDevinEventKind::SessionFinished, + _ if detail.is_some_and(DevinStatusDetail::is_finished) => { + NormalizedDevinEventKind::SessionFinished + } + _ if detail.is_some_and(DevinStatusDetail::is_waiting_on_operator) => { + NormalizedDevinEventKind::SessionBlocked + } + _ => NormalizedDevinEventKind::StatusChanged, + }; + let status = match detail { + Some(detail) => format!("{}:{}", session.status.as_str(), detail.as_str()), + None => session.status.as_str().to_owned(), + }; + + NormalizedDevinEvent { + kind, + event_type: format!("session.status.{status}"), + event_id: None, + session_id: Some(session.session_id.clone()), + timestamp: Some(session.updated_at.to_string()), + message: None, + status: Some(status), + raw: json!({ + "session_id": session.session_id, + "status": session.status.as_str(), + "status_detail": detail.map(DevinStatusDetail::as_str), + }), + } +} + +pub fn session_created_event(session: &SessionResponse) -> NormalizedDevinEvent { + NormalizedDevinEvent { + kind: NormalizedDevinEventKind::SessionCreated, + event_type: "session.created".into(), + event_id: None, + session_id: Some(session.session_id.clone()), + timestamp: Some(session.created_at.to_string()), + message: Some(session.url.clone()), + status: Some(session.status.as_str().to_owned()), + raw: serde_json::to_value(session).unwrap_or_else(|_| json!({})), + } +} + +/// Liveness event for a session that keeps executing without new output. +/// +/// Devin reports neither a message nor a status change while it works through +/// a long tool call, so a polling observer would otherwise go silent for as +/// long as the task takes; the scheduler's activity-based stall policy would +/// read that silence as a hung worker. The raw payload is the session snapshot +/// the observation was made from, not a Devin-authored event. +pub fn heartbeat_event(session: &SessionResponse) -> NormalizedDevinEvent { + let status = match session.status_detail.as_ref() { + Some(detail) => format!("{}:{}", session.status.as_str(), detail.as_str()), + None => session.status.as_str().to_owned(), + }; + NormalizedDevinEvent { + kind: NormalizedDevinEventKind::SessionHeartbeat, + event_type: DEVIN_HEARTBEAT_EVENT_KIND.into(), + event_id: None, + session_id: Some(session.session_id.clone()), + timestamp: Some(session.updated_at.to_string()), + message: None, + status: Some(status), + raw: json!({ + "status": session.status.as_str(), + "status_detail": session.status_detail.as_ref().map(DevinStatusDetail::as_str), + "acus_consumed": session.acus_consumed, + "updated_at": session.updated_at, + }), + } +} + +/// Tracks the message cursor and last observed status for one polled session. +/// +/// Messages are paginated by `end_cursor`, but the cursor is only advanced +/// after the page is consumed and every `event_id` is also remembered, so a +/// replayed or overlapping page cannot emit an event twice. +#[derive(Debug, Clone, Default)] +pub struct DevinMessageCursor { + seen: HashSet, + cursor: Option, + last_status: Option, +} + +impl DevinMessageCursor { + pub fn new() -> Self { + Self::default() + } + + pub fn cursor(&self) -> Option<&str> { + self.cursor.as_deref() + } + + /// Consumes one message page, returning only messages not seen before. + pub fn ingest_messages( + &mut self, + session_id: &str, + page: &PaginatedResponse, + ) -> Vec { + let mut fresh = Vec::new(); + for message in &page.items { + if self.seen.insert(message.event_id.clone()) { + fresh.push(normalize_session_message(session_id, message)); + } + } + if let Some(end_cursor) = page.end_cursor.as_ref() { + self.cursor = Some(end_cursor.clone()); + } + + fresh + } + + /// Emits a status event when the session's status or status detail changed. + pub fn ingest_status(&mut self, session: &SessionResponse) -> Option { + let current = match session.status_detail.as_ref() { + Some(detail) => format!("{}:{}", session.status.as_str(), detail.as_str()), + None => session.status.as_str().to_owned(), + }; + if self.last_status.as_deref() == Some(current.as_str()) { + return None; + } + self.last_status = Some(current); + + Some(status_event(session)) + } + + pub fn last_status(&self) -> Option<&str> { + self.last_status.as_deref() + } +} + +pub fn devin_event_summary(event: &NormalizedDevinEvent) -> String { + match event.kind { + NormalizedDevinEventKind::SessionCreated => match event.message.as_deref() { + Some(url) => format!("Devin session created: {url}"), + None => "Devin session created".into(), + }, + NormalizedDevinEventKind::DevinMessage => event + .message + .clone() + .unwrap_or_else(|| "Devin message".into()), + NormalizedDevinEventKind::UserMessage => "Operator message forwarded to Devin".into(), + NormalizedDevinEventKind::StatusChanged => match event.status.as_deref() { + Some(status) => format!("Devin status changed to {status}"), + None => "Devin status changed".into(), + }, + NormalizedDevinEventKind::SessionFinished => "Devin session finished".into(), + NormalizedDevinEventKind::SessionBlocked => { + "Devin session is waiting on the operator".into() + } + NormalizedDevinEventKind::SessionFailed => "Devin session errored".into(), + NormalizedDevinEventKind::SessionSuspended => "Devin session was suspended".into(), + NormalizedDevinEventKind::SessionTerminated => "Devin session terminated".into(), + NormalizedDevinEventKind::SessionHeartbeat => match event.status.as_deref() { + Some(status) => format!("Devin session still executing ({status})"), + None => "Devin session still executing".into(), + }, + NormalizedDevinEventKind::Unknown => { + format!("Unknown Devin event `{}`", event.event_type) + } + } +} + +/// Extracts pull-request URLs (`.../pull/` or `.../merge_requests/`) +/// from free-form message text. Only absolute `https` URLs are accepted and +/// trailing punctuation is stripped. +pub fn pull_request_urls_in_text(text: &str) -> Vec { + let mut urls = Vec::new(); + for token in text.split(|c: char| { + c.is_whitespace() || matches!(c, '<' | '>' | '(' | ')' | '[' | ']' | '"' | '\'') + }) { + let candidate = token.trim_end_matches(['.', ',', ';', ':', '!', '?']); + if !candidate.starts_with("https://") { + continue; + } + let Ok(parsed) = Url::parse(candidate) else { + continue; + }; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + continue; + } + let Some(segments) = parsed.path_segments() else { + continue; + }; + let segments = segments.collect::>(); + let is_pull_request = segments.windows(2).any(|pair| { + matches!(pair[0], "pull" | "merge_requests") + && !pair[1].is_empty() + && pair[1].chars().all(|c| c.is_ascii_digit()) + }); + if is_pull_request { + let mut normalized = parsed.clone(); + normalized.set_query(None); + normalized.set_fragment(None); + let url = normalized.to_string(); + if !urls.contains(&url) { + urls.push(url); + } + } + } + urls +} + +pub fn devin_event_payload(event: &NormalizedDevinEvent) -> Value { + json!({ + "source_kind": event.event_type, + "session_id": event.session_id, + "event_id": event.event_id, + "timestamp": event.timestamp, + "status": event.status, + "message": event.message, + "raw_payload": event.raw, + }) +} + +pub fn normalized_event_to_journal_record( + run_id: impl Into, + sequence: u64, + event: &NormalizedDevinEvent, +) -> EventRecord { + let run_id = run_id.into(); + let kind = match event.kind { + NormalizedDevinEventKind::SessionCreated => EventKind::RunStarted, + NormalizedDevinEventKind::SessionFinished => EventKind::RunCompleted, + NormalizedDevinEventKind::SessionFailed => EventKind::RunFailed, + NormalizedDevinEventKind::SessionTerminated => EventKind::RunCancelled, + NormalizedDevinEventKind::Unknown => EventKind::Unknown { + raw_kind: event.event_type.clone(), + }, + _ => EventKind::HarnessEventNormalized { + source_kind: event.event_type.clone(), + }, + }; + + EventRecord::builder() + .sequence(sequence) + .actor(EventActor::harness(DEVIN_CLOUD_AGENT_KIND)) + .entity_ref(EntityRef::run(run_id.clone())) + .summary(devin_event_summary(event)) + .kind(kind) + .payload(devin_event_payload(event)) + .raw_payload_ref(format!("devin:{run_id}:{sequence}")) + .build() +} + +fn first_string(value: &Value, keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| value.get(*key).and_then(Value::as_str)) + .map(str::to_owned) +} + +// --------------------------------------------------------------------------- +// Session lifecycle +// --------------------------------------------------------------------------- + +/// How a polled Devin session ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DevinRunOutcome { + Finished, + WaitingOnOperator, + Suspended, + Failed, + Terminated, +} + +impl DevinRunOutcome { + pub fn as_str(self) -> &'static str { + match self { + Self::Finished => "finished", + Self::WaitingOnOperator => "waiting_on_operator", + Self::Suspended => "suspended", + Self::Failed => "failed", + Self::Terminated => "terminated", + } + } +} + +/// Evidence collected from a Devin session, stored in the local issue workspace. +#[derive(Debug, Clone, PartialEq)] +pub struct DevinRunReport { + pub session_id: String, + pub session_url: String, + pub outcome: DevinRunOutcome, + pub status: DevinSessionStatus, + pub status_detail: Option, + pub acus_consumed: f64, + pub pull_requests: Vec, + pub structured_output: Option, + pub attachments: Vec, + pub attachment_listing_error: Option, +} + +/// Options for [`DevinSessionRunner::run`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinRunOptions { + /// Stop polling once Devin asks for operator input instead of waiting. + pub stop_when_waiting_on_operator: bool, + /// Upper bound on total polling time. + pub max_duration: Option, + /// Fetch the session attachment list once the session settles. + pub collect_attachments: bool, + /// Page size for message polling. + pub message_page_size: u32, + /// Emit a [`heartbeat_event`] when an executing session has produced no + /// event for this long; `None` disables liveness events. + pub heartbeat_interval: Option, +} + +impl Default for DevinRunOptions { + fn default() -> Self { + Self { + stop_when_waiting_on_operator: true, + max_duration: None, + collect_attachments: true, + message_page_size: 100, + heartbeat_interval: Some(DEFAULT_DEVIN_HEARTBEAT_INTERVAL), + } + } +} + +/// Drives one Devin session: create, poll, normalize, settle. +/// +/// The API exposes no event stream, so progress is polled from the session +/// resource and its cursor-paginated message collection at the configured +/// interval. +#[derive(Debug, Clone)] +pub struct DevinSessionRunner { + client: DevinCloudClient, + options: DevinRunOptions, +} + +impl DevinSessionRunner { + pub fn new(client: DevinCloudClient, options: DevinRunOptions) -> Self { + Self { client, options } + } + + pub fn client(&self) -> &DevinCloudClient { + &self.client + } + + /// Creates a session and polls it to a settled state, handing every fresh + /// normalized event to `observer` as it is observed. + pub async fn run( + &self, + request: &SessionCreateRequest, + mut observer: impl FnMut(NormalizedDevinEvent), + ) -> Result { + let created = self.client.create_session(request).await?; + observer(session_created_event(&created)); + self.follow(&created.session_id, observer).await + } + + /// Polls an existing session to a settled state. + pub async fn follow( + &self, + devin_id: &str, + mut observer: impl FnMut(NormalizedDevinEvent), + ) -> Result { + let started = Instant::now(); + let mut last_event_at = started; + let mut cursor = DevinMessageCursor::new(); + + loop { + let session = self.client.get_session(devin_id).await?; + let drained = self + .drain_messages(devin_id, &mut cursor, &mut observer) + .await?; + let status_changed = match cursor.ingest_status(&session) { + Some(event) => { + observer(event); + true + } + None => false, + }; + if drained > 0 || status_changed { + last_event_at = Instant::now(); + } + + if let Some(outcome) = self.settled_outcome(&session) { + return self.report(session, outcome).await; + } + + if let Some(interval) = self.options.heartbeat_interval + && session.is_executing() + && last_event_at.elapsed() >= interval + { + observer(heartbeat_event(&session)); + last_event_at = Instant::now(); + } + + if let Some(max_duration) = self.options.max_duration + && started.elapsed() >= max_duration + { + return Err(DevinClientError::PollTimeout { + session_id: devin_id.to_owned(), + elapsed: started.elapsed(), + }); + } + + tokio::time::sleep(self.client.poll_interval()).await; + } + } + + /// Sends operator input to a running session. + pub async fn send_message( + &self, + devin_id: &str, + message: impl Into, + ) -> Result<(), DevinClientError> { + self.client.send_message(devin_id, message).await + } + + /// Stops a session and reports it as terminated. + pub async fn terminate( + &self, + devin_id: &str, + archive: bool, + ) -> Result { + self.client.delete_session(devin_id, archive).await?; + let session = self.client.get_session(devin_id).await?; + self.report(session, DevinRunOutcome::Terminated).await + } + + fn settled_outcome(&self, session: &SessionResponse) -> Option { + match &session.status { + DevinSessionStatus::Error => Some(DevinRunOutcome::Failed), + DevinSessionStatus::Suspended + if self.options.stop_when_waiting_on_operator && session.is_idle_suspended() => + { + Some(DevinRunOutcome::WaitingOnOperator) + } + DevinSessionStatus::Suspended => Some(DevinRunOutcome::Suspended), + // An archived session was closed by someone (a terminate request, + // the console), not by Devin finishing the task. + DevinSessionStatus::Exit if session.is_archived => Some(DevinRunOutcome::Terminated), + DevinSessionStatus::Exit => Some(DevinRunOutcome::Finished), + _ if session + .status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_finished) => + { + Some(DevinRunOutcome::Finished) + } + _ if self.options.stop_when_waiting_on_operator && session.is_waiting_on_operator() => { + Some(DevinRunOutcome::WaitingOnOperator) + } + _ => None, + } + } + + /// Reads every message page produced since the last poll. + async fn drain_messages( + &self, + devin_id: &str, + cursor: &mut DevinMessageCursor, + observer: &mut impl FnMut(NormalizedDevinEvent), + ) -> Result { + let mut emitted = 0; + loop { + let page = self + .client + .list_messages( + devin_id, + cursor.cursor(), + Some(self.options.message_page_size), + ) + .await?; + let previous = cursor.cursor().map(str::to_owned); + for event in cursor.ingest_messages(devin_id, &page) { + emitted += 1; + observer(event); + } + // A page that reports more data but does not advance the cursor + // would otherwise be requested forever. + if !page.has_next_page || cursor.cursor().map(str::to_owned) == previous { + return Ok(emitted); + } + } + } + + async fn report( + &self, + session: SessionResponse, + outcome: DevinRunOutcome, + ) -> Result { + let (attachments, attachment_listing_error) = if self.options.collect_attachments { + match self.client.list_attachments(&session.session_id).await { + Ok(attachments) => (attachments, None), + Err(error) => { + tracing::warn!( + session_id = %session.session_id, + %error, + "Devin session settled but attachment listing failed" + ); + (Vec::new(), Some(error.to_string())) + } + } + } else { + (Vec::new(), None) + }; + + Ok(DevinRunReport { + session_id: session.session_id, + session_url: session.url, + outcome, + status: session.status, + status_detail: session.status_detail, + acus_consumed: session.acus_consumed, + pull_requests: session.pull_requests, + structured_output: session.structured_output, + attachments, + attachment_listing_error, + }) + } +} + +// --------------------------------------------------------------------------- +// Remote evidence retrieval +// --------------------------------------------------------------------------- + +/// Directory, relative to the issue workspace metadata directory, that holds +/// evidence imported from Devin-owned remote workspaces. +pub const DEVIN_EVIDENCE_DIR_NAME: &str = "devin"; +/// Per-attachment ceiling for evidence downloads. +pub const DEVIN_MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; +/// Ceiling on how many attachments one session contributes to evidence. +pub const DEVIN_MAX_EVIDENCE_ATTACHMENTS: usize = 50; + +/// Bounds applied while importing remote artifacts into the local workspace. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DevinEvidenceLimits { + pub max_attachment_bytes: u64, + pub max_attachments: usize, + /// Download attachment bytes, not just their metadata. + pub download_attachments: bool, +} + +impl Default for DevinEvidenceLimits { + fn default() -> Self { + Self { + max_attachment_bytes: DEVIN_MAX_ATTACHMENT_BYTES, + max_attachments: DEVIN_MAX_EVIDENCE_ATTACHMENTS, + download_attachments: true, + } + } +} + +/// One attachment that was imported into the local evidence directory. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinStoredAttachment { + pub attachment_id: String, + pub name: String, + pub source: String, + #[serde(default)] + pub content_type: Option, + /// Path relative to the evidence root. + pub stored_path: String, + pub bytes: u64, +} + +/// One attachment that was deliberately not imported, with the reason. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinSkippedAttachment { + pub attachment_id: String, + pub name: String, + pub reason: String, +} + +/// Repository instructions rendered into the session prompt, fetched from the +/// repository remote because the evidence-only issue workspace has no checkout. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DevinRepositoryInstructions { + /// Repository-relative path of the instruction file. + pub path: String, + /// Git ref the file was read from (the configured target branch). + pub git_ref: String, + /// `sha256:` over the raw file bytes before any front matter is removed. + pub content_hash: String, + /// Size of the raw file. + pub bytes: u64, +} + +/// Index of everything written into the local evidence directory for a run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevinEvidenceManifest { + pub session_id: String, + pub session_url: String, + pub outcome: String, + pub status: String, + #[serde(default)] + pub status_detail: Option, + pub acus_consumed: f64, + /// Pull requests reported by the Devin API (`SessionResponse.pull_requests`). + pub pull_request_urls: Vec, + /// Pull-request URLs Devin mentioned in its messages but that the API did + /// not report, e.g. when Devin opened the PR outside its built-in PR tool. + #[serde(default)] + pub message_pull_request_urls: Vec, + pub event_count: usize, + pub attachments: Vec, + pub skipped_attachments: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attachment_listing_error: Option, + /// Repository instructions the session prompt carried, when the + /// repository configures an instruction file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_instructions: Option, + /// Absolute path of the evidence root inside the local issue workspace. + pub evidence_root: PathBuf, + pub containment: String, +} + +/// Collects normalized events during a run and imports remote artifacts into +/// the local issue workspace when the session settles. +/// +/// The evidence root is orchestrator-owned storage: Devin never executes there, +/// and nothing Devin returns is allowed to escape it (attachment names are +/// sanitized and the resolved path is re-checked against the root). +#[derive(Debug, Clone)] +pub struct DevinEvidenceCollector { + root: PathBuf, + events: Vec, + limits: DevinEvidenceLimits, + repository_instructions: Option, +} + +impl DevinEvidenceCollector { + pub fn new(root: impl Into, limits: DevinEvidenceLimits) -> Self { + Self { + root: root.into(), + events: Vec::new(), + limits, + repository_instructions: None, + } + } + + /// Records which repository instructions the session prompt carried. + pub fn set_repository_instructions( + &mut self, + instructions: Option, + ) { + self.repository_instructions = instructions; + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn event_count(&self) -> usize { + self.events.len() + } + + /// Records one normalized event for the journal written at settle time. + /// Heartbeats are orchestrator-side observations, not remote events, and + /// stay out of the journal. + pub fn record(&mut self, event: &NormalizedDevinEvent) { + if event.kind == NormalizedDevinEventKind::SessionHeartbeat { + return; + } + self.events.push(devin_event_payload(event)); + } + + /// Writes the session summary, event journal, and attachments, returning + /// the manifest that was also written to disk. + pub async fn persist( + &self, + client: &DevinCloudClient, + report: &DevinRunReport, + ) -> Result { + create_dir(&self.root).await?; + + let summary = json!({ + "session_id": report.session_id, + "session_url": report.session_url, + "outcome": report.outcome.as_str(), + "status": report.status.as_str(), + "status_detail": report.status_detail.as_ref().map(DevinStatusDetail::as_str), + "acus_consumed": report.acus_consumed, + "pull_requests": report.pull_requests, + "structured_output": report.structured_output, + "attachments": report.attachments, + "attachment_listing_error": report.attachment_listing_error, + "repository_instructions": self.repository_instructions, + "containment": DEVIN_REMOTE_CONTAINMENT, + }); + write_file(&self.root.join("session.json"), pretty(&summary).as_bytes()).await?; + + let mut journal = String::new(); + for event in &self.events { + journal.push_str(&serde_json::to_string(event).unwrap_or_else(|_| "{}".to_owned())); + journal.push('\n'); + } + write_file(&self.root.join("events.jsonl"), journal.as_bytes()).await?; + + let (attachments, skipped) = self.import_attachments(client, report).await?; + + let pull_request_urls = report + .pull_requests + .iter() + .map(|pull_request| pull_request.pr_url.clone()) + .collect::>(); + let manifest = DevinEvidenceManifest { + session_id: report.session_id.clone(), + session_url: report.session_url.clone(), + outcome: report.outcome.as_str().to_owned(), + status: report.status.as_str().to_owned(), + status_detail: report + .status_detail + .as_ref() + .map(|detail| detail.as_str().to_owned()), + acus_consumed: report.acus_consumed, + pull_request_urls: pull_request_urls.clone(), + message_pull_request_urls: self.message_pull_request_urls(&pull_request_urls), + event_count: self.events.len(), + attachments, + skipped_attachments: skipped, + attachment_listing_error: report.attachment_listing_error.clone(), + repository_instructions: self.repository_instructions.clone(), + evidence_root: self.root.clone(), + containment: DEVIN_REMOTE_CONTAINMENT.to_owned(), + }; + let rendered = serde_json::to_value(&manifest).unwrap_or_else(|_| json!({})); + write_file( + &self.root.join("evidence.json"), + pretty(&rendered).as_bytes(), + ) + .await?; + + Ok(manifest) + } + + fn message_pull_request_urls(&self, reported: &[String]) -> Vec { + let mut urls = Vec::new(); + for event in &self.events { + let is_devin_message = event + .get("source_kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == DEVIN_MESSAGE_EVENT_KIND); + let Some(message) = event.get("message").and_then(Value::as_str) else { + continue; + }; + if !is_devin_message { + continue; + } + for url in pull_request_urls_in_text(message) { + if !reported.contains(&url) && !urls.contains(&url) { + urls.push(url); + } + } + } + urls + } + + /// Downloads attachments into `attachments/`. A single failed download is + /// recorded as skipped rather than failing the run: evidence import must + /// not turn a completed Devin session into a failed OpenSymphony run. + async fn import_attachments( + &self, + client: &DevinCloudClient, + report: &DevinRunReport, + ) -> Result<(Vec, Vec), DevinClientError> { + let mut stored = Vec::new(); + let mut skipped = Vec::new(); + if report.attachments.is_empty() { + return Ok((stored, skipped)); + } + + let directory = self.root.join("attachments"); + if self.limits.download_attachments { + create_dir(&directory).await?; + } + + for (index, attachment) in report.attachments.iter().enumerate() { + if !self.limits.download_attachments { + skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: "attachment download is disabled for this run".to_owned(), + }); + continue; + } + if index >= self.limits.max_attachments { + skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: format!( + "exceeds the {} attachment evidence limit", + self.limits.max_attachments + ), + }); + continue; + } + + let file_name = evidence_file_name(index, attachment); + let path = directory.join(&file_name); + // Sanitization already removes separators; this rejects anything a + // future change could let through. + if path.parent() != Some(directory.as_path()) { + skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: "resolved outside the evidence directory".to_owned(), + }); + continue; + } + + match client + .download_attachment(attachment, self.limits.max_attachment_bytes) + .await + { + Ok(bytes) => { + write_file(&path, &bytes).await?; + stored.push(DevinStoredAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + source: attachment.source.clone(), + content_type: attachment.content_type.clone(), + stored_path: format!("attachments/{file_name}"), + bytes: bytes.len() as u64, + }); + } + Err(error) => skipped.push(DevinSkippedAttachment { + attachment_id: attachment.attachment_id.clone(), + name: attachment.name.clone(), + reason: error.to_string(), + }), + } + } + + Ok((stored, skipped)) + } +} + +/// Local file name for a remote attachment. +/// +/// Remote names are untrusted: only `[A-Za-z0-9._-]` survives, consecutive dots +/// are collapsed so no `..` segment can appear, the name is length-bounded, and +/// the ordinal prefix keeps duplicates distinct. +pub fn evidence_file_name(index: usize, attachment: &SessionAttachment) -> String { + let mut previous_dot = false; + let mut sanitized: String = attachment + .name + .chars() + .map(|character| { + let mapped = + if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') { + character + } else { + '_' + }; + let mapped = if mapped == '.' && previous_dot { + '_' + } else { + mapped + }; + previous_dot = mapped == '.'; + mapped + }) + .collect(); + sanitized = sanitized.trim_matches('.').to_owned(); + sanitized.truncate(96); + if sanitized.is_empty() { + sanitized = "attachment".to_owned(); + } + + format!("{index:03}-{sanitized}") +} + +fn pretty(value: &Value) -> String { + serde_json::to_string_pretty(value).unwrap_or_else(|_| "{}".to_owned()) +} + +fn evidence_error(path: &Path, source: std::io::Error) -> DevinClientError { + DevinClientError::Evidence { + path: path.to_path_buf(), + source, + } +} + +/// Creates `path` and its ancestors without ever following a symlink. +/// +/// The evidence root lives inside an issue workspace that a remote session +/// could influence through imported artifacts, so a symlinked directory on the +/// way down would redirect journal writes anywhere on the host. Every existing +/// component must be a real directory; missing ones are created one at a time. +async fn create_dir(path: &Path) -> Result<(), DevinClientError> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component); + if matches!( + component, + std::path::Component::RootDir | std::path::Component::Prefix(_) + ) { + continue; + } + match tokio::fs::symlink_metadata(¤t).await { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(evidence_error( + ¤t, + std::io::Error::other("evidence path component is a symlink"), + )); + } + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + return Err(evidence_error( + ¤t, + std::io::Error::other("evidence path component is not a directory"), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + match tokio::fs::create_dir(¤t).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + // Raced with another writer; re-check that it is a + // real directory on the next iteration. + let metadata = tokio::fs::symlink_metadata(¤t) + .await + .map_err(|error| evidence_error(¤t, error))?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(evidence_error( + ¤t, + std::io::Error::other( + "evidence path component appeared as a non-directory", + ), + )); + } + } + Err(error) => return Err(evidence_error(¤t, error)), + } + } + Err(error) => return Err(evidence_error(¤t, error)), + } + } + Ok(()) +} + +/// Writes an evidence file without following symlinks at the destination. +/// +/// The parent must already be a verified real directory (see [`create_dir`]). +/// Content is staged under a fresh exclusive name, so a symlink or foreign +/// file planted at the destination is replaced by the rename rather than +/// written through; `rename` never dereferences its target. +async fn write_file(path: &Path, contents: &[u8]) -> Result<(), DevinClientError> { + let parent = path.parent().ok_or_else(|| { + evidence_error(path, std::io::Error::other("evidence file has no parent")) + })?; + let parent_metadata = tokio::fs::symlink_metadata(parent) + .await + .map_err(|error| evidence_error(parent, error))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + return Err(evidence_error( + parent, + std::io::Error::other("evidence directory is not a real directory"), + )); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| evidence_error(path, std::io::Error::other("evidence file has no name")))?; + if let Ok(existing) = tokio::fs::symlink_metadata(path).await + && !existing.is_file() + { + return Err(evidence_error( + path, + std::io::Error::other("evidence destination is not a regular file"), + )); + } + + let staged = parent.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default() + )); + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged) + .await + .map_err(|error| evidence_error(&staged, error))?; + let written = async { + file.write_all(contents).await?; + file.sync_data().await?; + drop(file); + tokio::fs::rename(&staged, path).await + } + .await; + if let Err(error) = written { + let _ = tokio::fs::remove_file(&staged).await; + return Err(evidence_error(path, error)); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::BoundedBody; + + #[test] + fn bounded_body_stops_before_buffering_past_the_limit() { + let mut body = BoundedBody::new(None, 8); + assert!(body.push(&[0u8; 5]).is_ok()); + assert!(body.push(&[0u8; 5]).is_err()); + assert_eq!(body.into_inner().len(), 5); + } + + #[test] + fn bounded_body_preserves_a_body_within_the_limit() { + let mut body = BoundedBody::new(Some(6), 8); + assert!(body.push(b"abc").is_ok()); + assert!(body.push(b"def").is_ok()); + assert_eq!(body.into_inner(), b"abcdef".to_vec()); + } + + #[test] + fn bounded_body_never_preallocates_past_the_limit() { + let body = BoundedBody::new(Some(u64::MAX), 16); + assert!(body.into_inner().capacity() <= 16); + } +} diff --git a/crates/opensymphony-domain/src/state_machine.rs b/crates/opensymphony-domain/src/state_machine.rs index efdb028cd..c55c8b5f0 100644 --- a/crates/opensymphony-domain/src/state_machine.rs +++ b/crates/opensymphony-domain/src/state_machine.rs @@ -705,6 +705,12 @@ impl IssueExecution { super::IssueSnapshot::from(self) } + /// Restores a worker outcome persisted by a previous process so recovery + /// applies the same non-retrying rules the in-process path applies. + pub fn restore_worker_outcome(&mut self, outcome: WorkerOutcomeRecord) { + self.record_outcome(outcome); + } + fn record_outcome(&mut self, outcome: WorkerOutcomeRecord) { if outcome.outcome == super::WorkerOutcomeKind::CancelFailed && let Some(interrupt) = &mut self.interrupt diff --git a/crates/opensymphony-gateway-schema/src/capability.rs b/crates/opensymphony-gateway-schema/src/capability.rs index f78308175..cd70c61ed 100644 --- a/crates/opensymphony-gateway-schema/src/capability.rs +++ b/crates/opensymphony-gateway-schema/src/capability.rs @@ -129,14 +129,16 @@ pub struct HarnessHistoryCapability { pub enum HarnessKind { OpenHandsAgentServer, CodexAppServer, + DevinCloudAgent, RustNative, Acp, } impl HarnessKind { - pub const ALL: [Self; 4] = [ + pub const ALL: [Self; 5] = [ Self::OpenHandsAgentServer, Self::CodexAppServer, + Self::DevinCloudAgent, Self::RustNative, Self::Acp, ]; @@ -145,6 +147,7 @@ impl HarnessKind { match value { "openhands_agent_server" => Some(Self::OpenHandsAgentServer), "codex_app_server" => Some(Self::CodexAppServer), + "devin_cloud_agent" => Some(Self::DevinCloudAgent), "rust_native" => Some(Self::RustNative), "acp" => Some(Self::Acp), _ => None, @@ -155,6 +158,7 @@ impl HarnessKind { match self { Self::OpenHandsAgentServer => "openhands_agent_server", Self::CodexAppServer => "codex_app_server", + Self::DevinCloudAgent => "devin_cloud_agent", Self::RustNative => "rust_native", Self::Acp => "acp", } @@ -168,6 +172,7 @@ impl HarnessKind { match self { Self::OpenHandsAgentServer => HarnessCapability::openhands_agent_server(), Self::CodexAppServer => HarnessCapability::codex_app_server_local(), + Self::DevinCloudAgent => HarnessCapability::devin_cloud_agent(), Self::RustNative => HarnessCapability::rust_native_future(), Self::Acp => HarnessCapability::acp(), } @@ -438,6 +443,92 @@ impl HarnessCapability { } } + /// Remote Devin cloud harness, driven by the Devin API v3 session lifecycle. + /// + /// Devin executes in its own cloud-owned workspace: OpenSymphony creates and + /// polls a remote session over HTTPS and imports evidence back into the local + /// issue workspace, which is never Devin's execution cwd. + pub fn devin_cloud_agent() -> Self { + Self { + kind: "devin_cloud_agent".into(), + display_name: "Devin cloud agent".into(), + available: true, + adapter_contract_version: "harness-adapter-v1".into(), + runtime_contract_version: Some("devin-api-v3".into()), + actions: HarnessActionCapability { + start_run: true, + send_user_message: false, + retry: true, + cancel: true, + pause: false, + resume: false, + approve: false, + reject: false, + comment: false, + }, + event_streams: HarnessEventStreamCapability { + runtime_events: true, + terminal_frames: false, + replay_from_cursor: true, + raw_payload_refs: true, + delivery_modes: vec!["https_polling".into()], + }, + approvals: HarnessApprovalCapability { + tool_approval: false, + human_decision: false, + policy_metadata: false, + }, + model_settings: HarnessModelSettingsCapability { + api_compatible_settings: false, + subscription_credentials: false, + per_run_overrides: false, + credential_reference_kinds: vec!["env".into()], + }, + transport: HarnessTransportCapability { + protocol: "https".into(), + modes: vec!["rest".into()], + local: false, + remote: true, + }, + cancellation: HarnessCancellationCapability { + cancel_run: true, + force_stop: true, + acknowledges_cancel: true, + }, + pause_resume: HarnessPauseResumeCapability { + pause: false, + resume: false, + }, + history: HarnessHistoryCapability { + fetch_history: true, + reconcile_after_ready: true, + reconnect_and_replay: false, + preserve_unknown_events: true, + }, + notes: vec![ + "Remote implementation agent executing in a Devin-owned cloud workspace.".into(), + "OpenSymphony keeps a local issue workspace for manifests, journals, and evidence only; it is never the Devin execution cwd." + .into(), + "Credentials are organization-scoped service-user tokens read from environment references; secrets are injected by Devin secret id, never by value." + .into(), + ], + feature_gaps: vec![ + "Runtime events arrive by cursor-paginated HTTPS polling; there is no push stream, so event latency is bounded by the poll interval." + .into(), + "No mid-run interrupt: cancellation terminates and archives the remote session instead of stopping a single turn." + .into(), + "Pause/resume and approval flows are not exposed by the Devin API v3 session contract." + .into(), + "Model settings are fixed at session creation through `devin_mode`; per-run model overrides are unavailable." + .into(), + "Operator messages are not forwarded to the remote session: a session waiting on input is answered in Devin's own UI while OpenSymphony keeps following it." + .into(), + "Parent issues with sub-issues are not routed to Devin: parent execution roots, shared child worktrees, and final verification need a local checkout." + .into(), + ], + } + } + pub fn rust_native_future() -> Self { Self { kind: "rust_native".into(), diff --git a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs index 6a41b9b05..f110cd229 100644 --- a/crates/opensymphony-gateway-schema/tests/gateway_schema.rs +++ b/crates/opensymphony-gateway-schema/tests/gateway_schema.rs @@ -801,6 +801,7 @@ fn harness_capability_roundtrips_available_and_future_adapters() { let caps = vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), + HarnessCapability::devin_cloud_agent(), HarnessCapability::rust_native_future(), HarnessCapability::acp(), ]; @@ -808,12 +809,12 @@ fn harness_capability_roundtrips_available_and_future_adapters() { let json = must_serialize(&caps); let back: Vec = must_deserialize(&json); - assert_eq!(back.len(), 4); - assert_eq!(back[3].kind, "acp"); - assert!(back[3].available && back[3].actions.start_run); - assert!(back[3].approvals.human_decision && back[3].actions.approve); + assert_eq!(back.len(), 5); + assert_eq!(back[4].kind, "acp"); + assert!(back[4].available && back[4].actions.start_run); + assert!(back[4].approvals.human_decision && back[4].actions.approve); assert!( - back[3] + back[4] .notes .iter() .any(|note| note.contains("choice forms")) @@ -849,8 +850,57 @@ fn harness_capability_roundtrips_available_and_future_adapters() { .iter() .any(|gap| gap.contains("Hosted Codex worker pools")) ); - assert_eq!(back[2].kind, "rust_native"); - assert!(back[2].pause_resume.pause); + assert_eq!(back[2].kind, "devin_cloud_agent"); + assert!(back[2].available); + assert!(back[2].transport.remote); + assert_eq!(back[3].kind, "rust_native"); + assert!(back[3].pause_resume.pause); +} + +#[test] +fn devin_cloud_agent_capability_shape_is_stable() { + let future = HarnessCapability::devin_cloud_agent(); + let json = must_serialize(&future); + let back: HarnessCapability = must_deserialize(&json); + + assert_eq!(back.kind, "devin_cloud_agent"); + assert_eq!(back.display_name, "Devin cloud agent"); + assert!(back.available); + assert_eq!(back.adapter_contract_version, "harness-adapter-v1"); + assert_eq!( + back.runtime_contract_version.as_deref(), + Some("devin-api-v3") + ); + assert_eq!(back.transport.protocol, "https"); + assert!(back.transport.remote); + assert!(!back.transport.local); + assert_eq!(back.transport.modes, vec!["rest"]); + assert_eq!(back.event_streams.delivery_modes, vec!["https_polling"]); + assert!(back.event_streams.replay_from_cursor); + assert!(back.actions.start_run); + assert!(back.actions.cancel); + assert!(!back.actions.approve); + // The route follows a session but has no operator-to-Devin message path, + // so advertising `send_user_message` would be a false promise to clients. + assert!(!back.actions.send_user_message); + assert!(back.cancellation.acknowledges_cancel); + assert!(back.history.preserve_unknown_events); + assert_eq!(back.model_settings.credential_reference_kinds, vec!["env"]); + for expected_gap in [ + "polling", + "No mid-run interrupt", + "Pause/resume", + "per-run model overrides", + "Operator messages are not forwarded", + "Parent issues with sub-issues", + ] { + assert!( + back.feature_gaps + .iter() + .any(|gap| gap.contains(expected_gap)), + "missing devin feature gap for `{expected_gap}`" + ); + } } #[test] diff --git a/crates/opensymphony-gateway/src/lib.rs b/crates/opensymphony-gateway/src/lib.rs index 36780035b..15f7e73e6 100644 --- a/crates/opensymphony-gateway/src/lib.rs +++ b/crates/opensymphony-gateway/src/lib.rs @@ -1139,6 +1139,7 @@ fn build_capabilities() -> GatewayCapabilities { harnesses: vec![ HarnessCapability::openhands_agent_server(), HarnessCapability::codex_app_server_local(), + HarnessCapability::devin_cloud_agent(), HarnessCapability::acp(), HarnessCapability::rust_native_future(), ], diff --git a/crates/opensymphony-gateway/tests/gateway.rs b/crates/opensymphony-gateway/tests/gateway.rs index f38e22eb7..50065a6ad 100644 --- a/crates/opensymphony-gateway/tests/gateway.rs +++ b/crates/opensymphony-gateway/tests/gateway.rs @@ -1381,6 +1381,18 @@ async fn gateway_serves_capabilities_and_dashboard_snapshot() { == Some("codex-app-server-json-rpc-v2") && harness.transport.modes == vec!["stdio"]) ); + assert!( + caps_response + .harnesses + .iter() + .any(|harness| harness.kind == "devin_cloud_agent" + && harness.available + && harness.runtime_contract_version.as_deref() == Some("devin-api-v3") + && harness.transport.protocol == "https" + && harness.transport.remote + && !harness.transport.local + && !harness.feature_gaps.is_empty()) + ); assert!( caps_response .harnesses diff --git a/crates/opensymphony-orchestrator/src/scheduler.rs b/crates/opensymphony-orchestrator/src/scheduler.rs index 925664120..b1894995d 100644 --- a/crates/opensymphony-orchestrator/src/scheduler.rs +++ b/crates/opensymphony-orchestrator/src/scheduler.rs @@ -296,6 +296,9 @@ pub struct RecoveryRecord { pub retry_error: Option, pub harness_kind: Option, pub interrupt_reason: Option, + /// A persisted outcome that forbids a replacement run because the work it + /// describes is still live outside this process. + pub terminal_worker_outcome: Option, pub recovered_run: Option, } @@ -547,6 +550,18 @@ pub trait WorkspaceBackend { observed_at: TimestampMs, ) -> Result; + /// Prepares an issue workspace for a harness that executes remotely. + /// + /// The workspace holds manifests, journals, and imported evidence only, so + /// no local checkout is required and the host needs no repository access. + async fn ensure_evidence_workspace( + &mut self, + issue: &NormalizedIssue, + observed_at: TimestampMs, + ) -> Result { + self.ensure_workspace(issue, observed_at).await + } + async fn recover_workspaces(&mut self) -> Result, Self::Error>; async fn recovered_run_started_at( @@ -824,6 +839,12 @@ pub trait WorkerBackend { timed_out: false, }) } + + /// Releases every worker the backend still tracks before the process + /// exits. Backends whose workers hold remote resources (a Devin session + /// keeps billing after its local task is gone) must stop them here and + /// await the acknowledgement; dropping the backend cannot await anything. + async fn shutdown(&mut self) {} } pub struct OperatorResponseDelivery( @@ -4530,7 +4551,13 @@ where // the live tracker binding, discard the old workspace before // restoring the retry so the replacement is materialized by // the normal dispatch path. - if recovered_run.is_none() && recovered_workspace.is_some() && binding_changed { + // Preserve evidence for a remote session whose persisted + // outcome forbids a replacement run. + if recovered_run.is_none() + && recovered_workspace.is_some() + && binding_changed + && record.terminal_worker_outcome.is_none() + { self.release_stale_binding_leases( &record.issue, recovered_workspace @@ -4548,7 +4575,41 @@ where recovered_workspace = None; } self.upsert_active_execution(normalized.clone(), observed_at, recovered_workspace)?; - if record.had_in_flight_run { + if let Some(terminal_outcome) = record.terminal_worker_outcome { + // `Paused` and `Failed` alone would make this dispatchable + // again. The persisted outcome says the previous run left + // something alive outside this process, so restart must + // reach the same non-retrying release the in-process path + // produces instead of launching a replacement. + tracing::info!( + issue = %issue_id, + outcome = ?terminal_outcome, + "restored a non-retrying worker outcome during recovery" + ); + let worker_id = self.next_worker_id()?; + let mut execution = self + .remove_execution(&issue_id) + .expect("active recovery execution should be present"); + execution.restore_worker_outcome(WorkerOutcomeRecord { + worker_id, + attempt: None, + outcome: terminal_outcome, + started_at: observed_at, + finished_at: observed_at, + turn_count: 0, + summary: Some( + "restored a non-retrying worker outcome from the run manifest" + .to_owned(), + ), + error: None, + harness_stopped: false, + parent_verification: None, + }); + self.insert_execution( + issue_id.clone(), + execution.release(observed_at, ReleaseReason::TrackerInactive, None)?, + ); + } else if record.had_in_flight_run { if recovered_run.is_some() { self.restore_recovered_run( &issue_id, @@ -5303,6 +5364,18 @@ where .with_repository_binding(recovered_run.repository_binding.clone()); let mut execution = IssueExecution::new(record.issue.clone(), observed_at); execution.attach_workspace(record.workspace.clone())?; + if let Some(attempt) = run.attempt { + execution = execution.restore_retry(RetryEntry { + issue_id: record.issue.id.clone(), + identifier: record.issue.identifier.clone(), + attempt, + normal_retry_count: recovered_run.normal_retry_count, + scheduled_at: observed_at, + due_at: observed_at, + reason: RetryReason::Reconciliation, + error: None, + })?; + } execution = execution.claim(run.clone())?.start_running( observed_at, effective_stall_timeout(self.config.stall_timeout_ms), @@ -5400,11 +5473,15 @@ where let replacement_workspace = if retain_workspace { execution.workspace().cloned() } else if retry.is_some() && has_resolved_replacement { - match self - .workspace - .ensure_workspace(&replacement, observed_at) - .await - { + match if harness_executes_remotely(&self.config.routing.harness) { + self.workspace + .ensure_evidence_workspace(&replacement, observed_at) + .await + } else { + self.workspace + .ensure_workspace(&replacement, observed_at) + .await + } { Ok(workspace) => Some(workspace), Err(error) => { if let Some(retry) = retry.as_ref() @@ -6346,11 +6423,30 @@ where } } - let workspace = match self - .workspace - .ensure_workspace(&normalized, observed_at) - .await - { + // Validate the route before materializing a workspace. A remote-only + // harness keeps evidence here but executes in its own environment. + let route = match decide_issue_route(&normalized, &self.config) { + Ok(route) => route, + Err(error) => { + let error = self + .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error) + .await; + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + }; + let workspace_result = if harness_executes_remotely(&route.harness_kind) { + self.workspace + .ensure_evidence_workspace(&normalized, observed_at) + .await + } else { + self.workspace + .ensure_workspace(&normalized, observed_at) + .await + }; + let workspace = match workspace_result { Ok(workspace) => workspace, Err(error) => { let error = self @@ -6494,18 +6590,6 @@ where .and_then(RepositoryBindingOutcome::resolved_binding) .cloned(), ); - let route = match decide_issue_route(&normalized, &self.config) { - Ok(route) => route, - Err(error) => { - let error = self - .clear_parent_dispatch_intent_after_preparation_failure(&issue_id, error) - .await; - if first_error.is_none() { - first_error = Some(error); - } - continue; - } - }; let mut execution = self .remove_execution(&issue_id) @@ -9316,7 +9400,7 @@ impl TrackerSnapshot { } pub fn decide_issue_route( - _issue: &NormalizedIssue, + issue: &NormalizedIssue, config: &SchedulerConfig, ) -> Result { let capability = harness_capability(&config.routing.harness)?; @@ -9328,6 +9412,20 @@ pub fn decide_issue_route( ), }); } + // Parent execution roots, shared child worktrees, repair, merge, and the + // orchestrator-owned final verification all operate on a local checkout. A + // harness that only executes remotely has none, so the parent would settle + // without the verification evidence `enforce_parent_outcome_trust` needs. + if !issue.sub_issues.is_empty() && capability.transport.remote && !capability.transport.local { + return Err(SchedulerError::InvalidConfiguration { + detail: format!( + "selected harness `{}` executes remotely and cannot run parent issue `{}` with {} sub-issue(s); parent integration needs a local checkout", + config.routing.harness, + issue.identifier, + issue.sub_issues.len() + ), + }); + } Ok(HarnessRouteDecision { task_type: ROUTING_TASK_ISSUE_EXECUTION.into(), @@ -9403,6 +9501,16 @@ fn recovered_worker_ordinal(worker_id: &WorkerId) -> Option { .and_then(|value| value.parse::().ok()) } +/// Whether a harness runs the agent outside this host. +/// +/// A remote-only harness owns its execution workspace, so the scheduler +/// prepares an evidence-only local workspace instead of a checkout. +fn harness_executes_remotely(kind: &str) -> bool { + HarnessKind::parse(kind) + .map(HarnessKind::capability) + .is_some_and(|capability| capability.transport.remote && !capability.transport.local) +} + fn harness_capability(kind: &str) -> Result { HarnessKind::parse(kind) .map(HarnessKind::capability) diff --git a/crates/opensymphony-orchestrator/tests/scheduler.rs b/crates/opensymphony-orchestrator/tests/scheduler.rs index 7e7a2ed8d..a340f08aa 100644 --- a/crates/opensymphony-orchestrator/tests/scheduler.rs +++ b/crates/opensymphony-orchestrator/tests/scheduler.rs @@ -243,6 +243,40 @@ fn selected_route_uses_configured_codex_harness_and_model() { assert!(route.reason.contains("workflow routing.harness")); } +#[test] +fn remote_only_harness_refuses_parent_issues_with_sub_issues() { + let mut parent = normalized_issue("lin-parent", "COE-700", "In Progress"); + parent.sub_issues = vec![IssueRef { + id: IssueId::new("lin-child").expect("child id should be valid"), + identifier: IssueIdentifier::new("COE-701").expect("child identifier should be valid"), + state: "Done".to_string(), + }]; + let mut config = scheduler_config(); + config.routing.harness = "devin_cloud_agent".into(); + + // Parent integration (execution roots, shared child worktrees, final + // verification) needs a local checkout that a remote-only harness lacks. + let error = decide_issue_route(&parent, &config).expect_err("parent must be refused"); + assert!( + error.to_string().contains("parent issue `COE-700`"), + "{error}" + ); + assert!(error.to_string().contains("local checkout"), "{error}"); + + // The same harness still routes a leaf issue, and a local harness still + // routes the parent. + let mut leaf = normalized_issue("lin-leaf", "COE-702", "In Progress"); + leaf.sub_issues.clear(); + assert_eq!( + decide_issue_route(&leaf, &config) + .expect("leaf route") + .harness_kind, + "devin_cloud_agent" + ); + config.routing.harness = "openhands_agent_server".into(); + assert!(decide_issue_route(&parent, &config).is_ok()); +} + #[test] fn selected_acp_route_persists_profile_and_model_identity() { let issue = normalized_issue("lin-acp", "COE-611", "In Progress"); @@ -581,6 +615,7 @@ struct FakeWorkspace { retry_pending_recoveries: Vec, cleared_retry_exhaustion: Vec, ensured: Vec, + ensured_evidence_only: Vec, cleaned: Vec<(String, bool)>, failed_cleaned: Vec, removed: Vec, @@ -649,6 +684,16 @@ impl WorkspaceBackend for FakeWorkspace { Ok(record) } + async fn ensure_evidence_workspace( + &mut self, + issue: &NormalizedIssue, + observed_at: TimestampMs, + ) -> Result { + self.ensured_evidence_only + .push(issue.identifier.to_string()); + self.ensure_workspace(issue, observed_at).await + } + async fn recover_workspaces(&mut self) -> Result, Self::Error> { Ok(self.recoveries.clone()) } @@ -3417,6 +3462,7 @@ async fn completed_parent_stays_materialized_across_later_capture_retries() { FakeWorkspace { durable_state: Some(serde_json::to_value(&state).expect("persisted state")), recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( parent_id.as_str(), &format!("COE-PARENT-{suffix}"), @@ -3813,6 +3859,7 @@ async fn failed_subtree_cleanup_retries_only_incomplete_receipts() { FakeWorkspace { durable_state: Some(serde_json::to_value(&failed_state).expect("pending cleanup")), recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: child, workspace: child_workspace, successful_run: true, @@ -4169,6 +4216,7 @@ async fn passed_parent_survives_tracker_refresh_failure_until_terminal_reconcili }, FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: recovered_issue, workspace: recovered_workspace, successful_run: true, @@ -4480,6 +4528,7 @@ async fn recovery_selects_only_the_controller_parent_generation() { .parent_integrations[&parent_id] .hierarchy_generation; let recovery = |generation: u64| RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( parent_id.as_str(), &format!("COE-PARENT-{suffix}"), @@ -4553,6 +4602,7 @@ async fn legacy_in_flight_parent_migrates_controller_before_reattachment() { durable_state.parent_integrations.remove(&parent_id); let recovery = RecoveryRecord { + terminal_worker_outcome: None, issue: launch.issue.clone(), workspace: launch.workspace.clone(), successful_run: false, @@ -4623,6 +4673,7 @@ async fn recovered_parent_is_stopped_and_reinserted_when_attachment_persistence_ .cloned() .expect("parent conversation"); let recovery = RecoveryRecord { + terminal_worker_outcome: None, issue: launch.issue.clone(), workspace: launch.workspace.clone(), successful_run: false, @@ -5469,6 +5520,7 @@ async fn recovered_run_boundary_survives_initial_state_persistence() { let recovered_workspace = workspace_record("COE-BOUNDARY", "/tmp/recovered/COE-BOUNDARY"); let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("recovered-boundary", "COE-BOUNDARY", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -5528,6 +5580,7 @@ async fn terminal_recovery_prunes_run_boundary_after_cleanup() { let recovered_workspace = workspace_record("COE-TERMINAL", "/tmp/recovered/COE-TERMINAL"); let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("terminal-recovery", "COE-TERMINAL", "Done"), workspace: recovered_workspace, successful_run: true, @@ -5669,6 +5722,7 @@ async fn terminal_recovery_does_not_trust_success_before_parent_controller_final let workspace = FakeWorkspace { durable_state: Some(serde_json::to_value(&durable_state).expect("state")), recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("terminal-parent-recovery", "COE-PARENT-TERMINAL", "Done"), workspace: recovered_workspace, successful_run: true, @@ -5739,6 +5793,7 @@ async fn terminal_recovery_does_not_trust_success_before_parent_controller_final serde_json::to_value(&missing_controller_state).expect("missing-controller state"), ), recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("terminal-parent-recovery", "COE-PARENT-TERMINAL", "Done"), workspace: workspace_record( "COE-PARENT-TERMINAL", @@ -6994,6 +7049,7 @@ async fn recovered_human_review_run_uses_restored_harness_kind_for_merging_inter }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-492", "COE-492", "Human Review"), workspace: recovered_workspace.clone(), successful_run: false, @@ -7085,6 +7141,7 @@ async fn recovered_retry_run_restores_retry_state_before_launch() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-494", "COE-494", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -8232,6 +8289,7 @@ async fn same_repository_recovery_supersedes_stale_binding_generations() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-same-repository-generation", "COE-548-SAME-REPOSITORY-GENERATION", @@ -9178,6 +9236,7 @@ async fn recovery_reuses_manifest_workspace_for_active_issue_dispatch() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-272", "COE-272", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -9249,6 +9308,7 @@ async fn recovery_keeps_an_active_cancelled_run_released() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-272-cancelled", "COE-272-CANCELLED", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -9289,6 +9349,176 @@ async fn recovery_keeps_an_active_cancelled_run_released() { )); } +async fn assert_recovered_non_retrying_outcome_is_not_dispatched( + identifier: &str, + outcome: WorkerOutcomeKind, + completed_run: bool, +) { + let issue_key = format!("lin-devin-{identifier}"); + let recovered_workspace = workspace_record(identifier, &format!("/tmp/recovered/{identifier}")); + let tracker = FakeTracker { + active: vec![tracker_issue(&issue_key, identifier, "In Progress", 0)], + ..Default::default() + }; + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + terminal_worker_outcome: Some(outcome), + issue: normalized_issue(&issue_key, identifier, "In Progress"), + workspace: recovered_workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some("devin_cloud_agent".to_string()), + interrupt_reason: None, + recovered_run: None, + }], + records: HashMap::from([(issue_key.clone(), recovered_workspace)]), + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut scheduler = Scheduler::new(tracker, workspace, worker, scheduler_config()); + + scheduler + .tick(ts(100)) + .await + .expect("recovery should succeed"); + scheduler + .tick(ts(200)) + .await + .expect("second tick should not dispatch a replacement"); + + let issue_id = IssueId::new(&issue_key).expect("issue id should be valid"); + assert!( + scheduler.worker().launches.is_empty(), + "a restored {outcome:?} run must not launch a replacement worker" + ); + assert!(matches!( + scheduler + .execution(&issue_id) + .expect("execution should remain visible") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { + reason: ReleaseReason::TrackerInactive, + .. + } + )); +} + +#[tokio::test] +async fn recovery_keeps_a_detached_run_undispatched() { + assert_recovered_non_retrying_outcome_is_not_dispatched( + "COE-DETACHED", + WorkerOutcomeKind::Detached, + false, + ) + .await; +} + +#[tokio::test] +async fn recovery_keeps_a_cancel_failed_run_undispatched() { + assert_recovered_non_retrying_outcome_is_not_dispatched( + "COE-CANCEL-FAILED", + WorkerOutcomeKind::CancelFailed, + true, + ) + .await; +} + +async fn assert_repository_bound_detached_recovery_retains_the_workspace( + identifier: &str, + preserve_binding: bool, +) { + let issue_key = format!("lin-devin-{identifier}"); + let recovered_workspace = workspace_record(identifier, &format!("/tmp/recovered/{identifier}")); + let binding = match repository_routing().resolve( + &["repo:one".to_string()], + Some("project-id"), + None, + false, + ) { + RepositoryBindingOutcome::Resolved(binding) => binding, + outcome => panic!("expected a resolved binding, got {outcome:?}"), + }; + let tracker = FakeTracker { + active: vec![{ + let mut issue = tracker_issue(&issue_key, identifier, "In Progress", 0); + issue.project_id = Some("project-id".to_string()); + issue.labels = vec!["repo:one".to_string()]; + issue + }], + ..Default::default() + }; + let mut recovered_issue = normalized_issue(&issue_key, identifier, "In Progress"); + if preserve_binding { + recovered_issue.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding)); + } + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + terminal_worker_outcome: Some(WorkerOutcomeKind::Detached), + issue: recovered_issue, + workspace: recovered_workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: false, + pending_retry: false, + normal_retry_count: 0, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some("devin_cloud_agent".to_string()), + interrupt_reason: None, + recovered_run: None, + }], + records: HashMap::from([(issue_key.clone(), recovered_workspace)]), + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut config = scheduler_config(); + config.repository_routing = Some(repository_routing()); + let mut scheduler = Scheduler::new(tracker, workspace, worker, config); + + scheduler + .tick(ts(100)) + .await + .expect("detached recovery should succeed"); + + // Removing the workspace here would terminate the waiting remote session + // and delete the evidence it already produced. + assert!( + scheduler.workspace().removed.is_empty(), + "a detached run must keep its evidence workspace" + ); + assert!(scheduler.worker().launches.is_empty()); + assert!(matches!( + scheduler + .execution(&IssueId::new(&issue_key).expect("issue id should be valid")) + .expect("execution should remain visible") + .state(), + crate::opensymphony_orchestrator::SchedulerState::Released { .. } + )); +} + +#[tokio::test] +async fn repository_bound_detached_recovery_keeps_its_evidence_workspace() { + assert_repository_bound_detached_recovery_retains_the_workspace("COE-DETACHED-BOUND", true) + .await; +} + +#[tokio::test] +async fn detached_recovery_without_a_proven_binding_keeps_its_evidence_workspace() { + assert_repository_bound_detached_recovery_retains_the_workspace("COE-DETACHED-UNBOUND", false) + .await; +} + #[tokio::test] async fn recovery_requeues_cancelled_merging_interrupt() { let recovered_workspace = workspace_record("COE-272-MERGING", "/tmp/recovered/COE-272-MERGING"); @@ -9303,6 +9533,7 @@ async fn recovery_requeues_cancelled_merging_interrupt() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-272-merging", "COE-272-MERGING", "Merging"), workspace: recovered_workspace.clone(), successful_run: false, @@ -9361,6 +9592,7 @@ async fn recovery_parks_cancelled_merging_interrupt_with_consumed_count() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-272-merging-exhausted", "COE-272-MERGING-EXHAUSTED", @@ -9408,6 +9640,7 @@ async fn pre_conversation_recovery_honors_retry_limit() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-273", "COE-273", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -9457,6 +9690,7 @@ async fn recovery_advances_consumed_retry_budget_before_dispatch() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-274", "COE-274", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -9504,6 +9738,7 @@ async fn recovery_queues_an_active_successful_run_for_continuation() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-278", "COE-278", "In Progress"), workspace: recovered_workspace.clone(), successful_run: true, @@ -9561,6 +9796,7 @@ async fn recovery_releases_an_active_successful_run_when_retry_limit_reached() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-279", "COE-279", "In Progress"), workspace: recovered_workspace.clone(), successful_run: true, @@ -9613,6 +9849,7 @@ async fn recovery_merges_successful_run_with_pending_retry_exhaustion() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: issue.clone(), workspace: recovered_workspace, successful_run: true, @@ -9669,6 +9906,7 @@ async fn recovery_keeps_successful_run_with_active_tracker_and_exhausted_marker_ }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: issue.clone(), workspace: recovered_workspace, successful_run: true, @@ -9724,6 +9962,7 @@ async fn recovery_dispatches_persisted_pending_retry_before_limit() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-276", "COE-276", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -9773,6 +10012,7 @@ async fn recovery_parks_pending_retry_when_current_limit_is_lowered() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-277", "COE-277", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -9928,6 +10168,7 @@ async fn inactive_retry_exhaustion_retries_failed_workspace_cleanup() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-278-exhausted", "COE-278-EXHAUSTED", "Backlog"), workspace: recovered_workspace, successful_run: false, @@ -9997,6 +10238,7 @@ async fn recovery_restores_exhausted_retry_count_without_dispatching() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-273", "COE-273", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -10050,6 +10292,7 @@ async fn recovery_reopens_exhausted_execution_after_retry_limit_increase() { let workspace = FakeWorkspace { retain_failed: true, recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-279", "COE-279", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -10155,6 +10398,7 @@ async fn terminal_recovery_honors_failed_workspace_retention() { let workspace = FakeWorkspace { retain_failed: true, recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-274", "COE-274", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -10186,6 +10430,60 @@ async fn terminal_recovery_honors_failed_workspace_retention() { assert!(scheduler.workspace().cleaned.is_empty()); } +#[tokio::test] +async fn terminal_recovery_stops_a_retried_in_flight_run_before_workspace_removal() { + let recovered_worker_id = + WorkerId::new("worker-terminal-retried").expect("worker id should be valid"); + let recovered_workspace = workspace_record("COE-275", "/tmp/recovered/COE-275"); + let tracker = FakeTracker { + terminal: vec![tracker_issue("lin-275", "COE-275", "Done", 0)], + ..Default::default() + }; + let workspace = FakeWorkspace { + recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, + issue: normalized_issue("lin-275", "COE-275", "In Progress"), + workspace: recovered_workspace.clone(), + successful_run: false, + cancelled_run: false, + completed_run: false, + had_in_flight_run: true, + pending_retry: false, + normal_retry_count: 2, + retry_scheduled_at: None, + retry_due_at: None, + retry_reason: None, + retry_error: None, + harness_kind: Some("devin_cloud_agent".to_string()), + interrupt_reason: None, + recovered_run: Some(RecoveredRun { + worker_id: recovered_worker_id.clone(), + conversation: conversation(&recovered_worker_id), + normal_retry_count: 2, + repository_binding: None, + }), + }], + records: HashMap::from([("lin-275".to_string(), recovered_workspace)]), + ..Default::default() + }; + let worker = FakeWorker::default(); + let mut scheduler = Scheduler::new(tracker, workspace, worker, scheduler_config()); + + scheduler + .tick(ts(100)) + .await + .expect("a retried in-flight run on a terminal issue must recover"); + + assert_eq!(scheduler.worker().aborted.len(), 1); + assert_eq!(scheduler.worker().aborted[0].0, "worker-terminal-retried"); + assert_eq!( + scheduler.worker().aborted[0].1, + WorkerAbortReason::BindingSuperseded + ); + assert_eq!(scheduler.workspace().cleaned.len(), 1); + assert!(scheduler.worker().launches.is_empty()); +} + #[tokio::test] async fn terminal_recovery_preserves_cancelled_workspace_policy() { let recovered_workspace = workspace_record("COE-275", "/tmp/recovered/COE-275"); @@ -10195,6 +10493,7 @@ async fn terminal_recovery_preserves_cancelled_workspace_policy() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-275", "COE-275", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -10246,6 +10545,7 @@ async fn parked_recovered_issue_redispatches_when_tracker_reactivates() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-532", "COE-532", "Backlog"), workspace: recovered_workspace.clone(), successful_run: false, @@ -10446,6 +10746,7 @@ async fn recovered_in_flight_run_restores_persisted_interrupt_intent() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-493", "COE-493", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -10514,6 +10815,7 @@ async fn recovery_does_not_count_released_issues_as_running_capacity() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-283-a", "COE-283-A", "In Progress"), workspace: recovered_workspace, successful_run: false, @@ -10750,6 +11052,72 @@ async fn repository_binding_outcome_blocks_before_workspace_creation() { assert!(scheduler.worker().launches.is_empty()); } +#[tokio::test] +async fn unavailable_harness_rejects_dispatch_before_workspace_creation() { + let tracker = FakeTracker { + active: vec![tracker_issue( + "lin-native", + "COE-NATIVE-1", + "In Progress", + 0, + )], + ..Default::default() + }; + let workspace = FakeWorkspace::default(); + let worker = FakeWorker::default(); + let mut config = scheduler_config(); + config.routing.harness = "rust_native".into(); + config.stall_timeout_ms = None; + let mut scheduler = Scheduler::new(tracker, workspace, worker, config); + + let error = scheduler + .tick(ts(100)) + .await + .expect_err("unavailable harness routing should reject dispatch"); + + assert!(matches!( + error, + crate::opensymphony_orchestrator::SchedulerError::InvalidConfiguration { .. } + )); + assert!(scheduler.workspace().ensured.is_empty()); + assert!(scheduler.worker().launches.is_empty()); +} + +#[tokio::test] +async fn devin_cloud_routing_dispatches_a_worker() { + let tracker = FakeTracker { + active: vec![tracker_issue("lin-devin", "COE-DEVIN-1", "In Progress", 0)], + ..Default::default() + }; + let mut config = scheduler_config(); + config.routing.harness = "devin_cloud_agent".into(); + config.stall_timeout_ms = None; + let mut scheduler = Scheduler::new( + tracker, + FakeWorkspace::default(), + FakeWorker::default(), + config, + ); + + scheduler.tick(ts(100)).await.expect("devin routing runs"); + + let launch = scheduler + .worker() + .launches + .first() + .expect("devin route should launch a worker"); + assert_eq!(launch.route.harness_kind, "devin_cloud_agent"); + // Devin executes remotely, but the issue workspace is still materialized: + // it is where run manifests, journals, and imported evidence live. It is + // prepared without a local checkout, so a host without repository access + // can still launch the remote session. + assert_eq!(scheduler.workspace().ensured.len(), 1); + assert_eq!( + scheduler.workspace().ensured_evidence_only, + vec!["COE-DEVIN-1".to_string()] + ); +} + #[tokio::test] async fn unlabeled_parent_remains_repository_neutral() { let mut issue = tracker_issue("lin-repo-parent", "COE-548-PARENT", "In Progress", 0); @@ -10989,6 +11357,7 @@ async fn recovery_fences_persisted_binding_before_superseding_it() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue("lin-repo-recovery", "COE-548-RECOVERY", "In Progress"), workspace: recovered_workspace.clone(), successful_run: false, @@ -11086,6 +11455,7 @@ async fn legacy_recovery_backfills_binding_before_drift_comparison() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: recovered_issue, workspace: recovered_workspace.clone(), successful_run: false, @@ -11226,6 +11596,7 @@ async fn external_retry_marker_merges_into_metadata_only_workspace_recovery() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: pending_issue.clone(), workspace: recovered_workspace.clone(), successful_run: false, @@ -11306,6 +11677,7 @@ async fn metadata_only_retry_recovery_rematerializes_after_binding_drift() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: pending_issue.clone(), workspace: recovered_workspace.clone(), successful_run: false, @@ -11493,6 +11865,7 @@ async fn legacy_recovery_rematerializes_without_persisted_binding_proof() { }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-legacy-unproven", "COE-548-LEGACY-UNPROVEN", @@ -11577,6 +11950,7 @@ async fn unproven_recovery_keeps_workspace_and_worker_until_stop_acknowledges() }; let workspace = FakeWorkspace { recoveries: vec![RecoveryRecord { + terminal_worker_outcome: None, issue: normalized_issue( "lin-legacy-stop-pending", "COE-548-LEGACY-STOP-PENDING", diff --git a/crates/opensymphony-workflow/src/lib.rs b/crates/opensymphony-workflow/src/lib.rs index bf22c7432..4aff46cd8 100644 --- a/crates/opensymphony-workflow/src/lib.rs +++ b/crates/opensymphony-workflow/src/lib.rs @@ -16,8 +16,9 @@ pub use error::{PromptTemplateError, WorkflowConfigError, WorkflowLoadError}; pub use model::{ AgentConfig, AgentFrontMatter, DEFAULT_OPENHANDS_LLM_MODEL, DEFAULT_PROMPT_TEMPLATE, DEFAULT_ROUTING_HARNESS_ENV, DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, - DEFAULT_WORKSPACE_ROOT, Environment, HooksConfig, HooksFrontMatter, IntegerLike, - OpenHandsConfig, OpenHandsConfirmationPolicy, OpenHandsConfirmationPolicyFrontMatter, + DEFAULT_WORKSPACE_ROOT, DevinApiFrontMatter, DevinFrontMatter, DevinSessionFrontMatter, + Environment, HooksConfig, HooksFrontMatter, IntegerLike, OpenHandsConfig, + OpenHandsConfirmationPolicy, OpenHandsConfirmationPolicyFrontMatter, OpenHandsConversationAgentConfig, OpenHandsConversationAgentFrontMatter, OpenHandsConversationCondenserConfig, OpenHandsConversationCondenserFrontMatter, OpenHandsConversationConfig, OpenHandsConversationFrontMatter, OpenHandsConversationToolConfig, @@ -113,6 +114,8 @@ mod tests { PromptTemplateError, TrackerKind, WorkflowConfigError, WorkflowDefinition, WorkflowLoadError, model::{ + DEFAULT_DEVIN_API_BASE_URL, DEFAULT_DEVIN_API_KEY_ENV, DEFAULT_DEVIN_ORG_ID_ENV, + DEFAULT_DEVIN_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, DEFAULT_MAX_CONCURRENT_AGENTS, DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_BASE_URL, DEFAULT_OPENHANDS_CONDENSER_KEEP_FIRST, @@ -120,7 +123,7 @@ mod tests { DEFAULT_OPENHANDS_PERSISTENCE_DIR, DEFAULT_OPENHANDS_QUERY_PARAM_NAME, DEFAULT_OPENHANDS_READY_TIMEOUT_MS, DEFAULT_OPENHANDS_RECONNECT_INITIAL_MS, DEFAULT_OPENHANDS_RECONNECT_MAX_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_PROMPT_TEMPLATE, - DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, + DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, DEVIN_MAX_SESSION_TAGS, }, }; @@ -2546,6 +2549,307 @@ openhands: assert!(!resolved.extensions.openhands.local_server.enabled); } + #[test] + fn devin_routing_resolves_defaults_and_overrides() { + let defaults = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse"); + let env = env([("LINEAR_API_KEY", "linear-token")]); + + let resolved = defaults + .resolve(Path::new("/repo"), &env) + .expect("devin routing should resolve"); + + assert_eq!(resolved.config.routing.harness, "devin_cloud_agent"); + assert_eq!( + resolved.extensions.devin.api.base_url, + DEFAULT_DEVIN_API_BASE_URL + ); + assert_eq!( + resolved.extensions.devin.api.api_key_env, + DEFAULT_DEVIN_API_KEY_ENV + ); + assert_eq!( + resolved.extensions.devin.api.poll_interval_ms, + DEFAULT_DEVIN_POLL_INTERVAL_MS + ); + assert_eq!( + resolved.extensions.devin.api.request_timeout_ms, + DEFAULT_DEVIN_REQUEST_TIMEOUT_MS + ); + assert_eq!( + resolved.extensions.devin.api.org_id_env, + DEFAULT_DEVIN_ORG_ID_ENV + ); + assert!(resolved.extensions.devin.api.org_id.is_none()); + assert!(resolved.extensions.devin.session.tags.is_empty()); + // Matches the documented `resumable` default in the v3 contract. + assert!(resolved.extensions.devin.session.resumable); + + let overridden = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + base_url: https://devin.internal.example.com/api + api_key_env: DEVIN_TENANT_API_KEY + org_id: org-tenant123 + org_id_env: DEVIN_TENANT_ORG_ID + poll_interval_ms: 500 + request_timeout_ms: 15000 + session: + playbook_id: playbook-1 + knowledge_ids: + - note-1 + secret_ids: + - secret-1 + max_acu_limit: 25 + tags: + - team:core + title: Implement the issue + devin_mode: fast + platform: linux + resumable: true +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env) + .expect("devin overrides should resolve"); + + assert_eq!( + overridden.extensions.devin.api.base_url, + "https://devin.internal.example.com/api" + ); + assert_eq!( + overridden.extensions.devin.api.api_key_env, + "DEVIN_TENANT_API_KEY" + ); + assert_eq!(overridden.extensions.devin.api.poll_interval_ms, 500); + assert_eq!(overridden.extensions.devin.api.request_timeout_ms, 15_000); + assert_eq!( + overridden.extensions.devin.api.org_id.as_deref(), + Some("org-tenant123") + ); + assert_eq!( + overridden.extensions.devin.api.org_id_env, + "DEVIN_TENANT_ORG_ID" + ); + + let session = &overridden.extensions.devin.session; + assert_eq!(session.playbook_id.as_deref(), Some("playbook-1")); + assert_eq!( + session.knowledge_ids.as_deref(), + Some(&["note-1".to_owned()][..]) + ); + assert_eq!( + session.secret_ids.as_deref(), + Some(&["secret-1".to_owned()][..]) + ); + assert_eq!(session.max_acu_limit, Some(25)); + assert_eq!(session.tags, vec!["team:core".to_owned()]); + assert_eq!(session.title.as_deref(), Some("Implement the issue")); + assert_eq!(session.devin_mode.as_deref(), Some("fast")); + assert_eq!(session.platform.as_deref(), Some("linux")); + assert!(session.resumable); + } + + #[test] + fn devin_session_options_are_validated_against_the_documented_v3_contract() { + let env = env([("LINEAR_API_KEY", "linear-token")]); + let tags = (0..(DEVIN_MAX_SESSION_TAGS + 1)) + .map(|index| format!(" - tag-{index}")) + .collect::>() + .join("\n"); + + for devin_block in [ + " api:\n org_id: acme".to_owned(), + " api:\n org_id_env: devin org".to_owned(), + " session:\n devin_mode: turbo".to_owned(), + " session:\n max_acu_limit: 0".to_owned(), + format!(" session:\n tags:\n{tags}"), + " session:\n tags:\n - ' '".to_owned(), + ] { + let workflow = WorkflowDefinition::parse(&format!( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: +{devin_block} +--- +{{{{ issue.identifier }}}} +"# + )) + .expect("workflow should parse"); + + assert!( + workflow.resolve(Path::new("/repo"), &env).is_err(), + "`{devin_block}` should be rejected" + ); + } + } + + #[test] + fn devin_endpoint_must_be_https_without_embedded_credentials() { + let env = env([("LINEAR_API_KEY", "linear-token")]); + + for base_url in [ + "http://api.devin.ai/v1", + "https://user:secret@api.devin.ai/v1", + "https://api.devin.ai/v1?token=secret", + "not-a-url", + ] { + let workflow = WorkflowDefinition::parse(&format!( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + base_url: {base_url} +--- +{{{{ issue.identifier }}}} +"# + )) + .expect("workflow should parse"); + + assert!( + workflow.resolve(Path::new("/repo"), &env).is_err(), + "`{base_url}` should be rejected as a devin endpoint" + ); + } + } + + #[test] + fn devin_api_key_env_stays_a_literal_variable_name() { + let env = env([ + ("LINEAR_API_KEY", "linear-token"), + ("DEVIN_API_KEY", "devin-secret-token"), + ]); + + let resolved = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + api_key_env: DEVIN_API_KEY +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env) + .expect("devin credential reference should resolve"); + + // The name is kept verbatim; env indirection here would store the token. + assert_eq!(resolved.extensions.devin.api.api_key_env, "DEVIN_API_KEY"); + + let substituted = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: devin_cloud_agent +devin: + api: + api_key_env: ${DEVIN_API_KEY} +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env); + + assert!( + substituted.is_err(), + "a substitution reference must be rejected rather than resolved to the token" + ); + } + + #[test] + fn devin_settings_are_inert_for_other_harnesses() { + let env = env([("LINEAR_API_KEY", "linear-token")]); + + let resolved = WorkflowDefinition::parse( + r#"--- +tracker: + kind: linear + project_slug: sample-project + active_states: + - Todo + terminal_states: + - Done +routing: + harness: codex_app_server +devin: + api: + base_url: http://api.devin.ai/${DEVIN_UNSET_TENANT} +--- +{{ issue.identifier }} +"#, + ) + .expect("workflow should parse") + .resolve(Path::new("/repo"), &env) + .expect("parked devin settings must not fail a codex workflow"); + + assert_eq!( + resolved.extensions.devin.api.base_url, + DEFAULT_DEVIN_API_BASE_URL + ); + assert_eq!( + resolved.extensions.devin.api.api_key_env, + DEFAULT_DEVIN_API_KEY_ENV + ); + } + #[test] fn selected_openhands_model_overrides_conversation_model() { let workflow = WorkflowDefinition::parse( diff --git a/crates/opensymphony-workflow/src/model.rs b/crates/opensymphony-workflow/src/model.rs index 0da7a7919..b0d2e2efe 100644 --- a/crates/opensymphony-workflow/src/model.rs +++ b/crates/opensymphony-workflow/src/model.rs @@ -30,6 +30,14 @@ pub const DEFAULT_OPENHANDS_QUERY_PARAM_NAME: &str = "session_api_key"; pub const DEFAULT_OPENHANDS_LLM_MODEL: &str = "openai/gpt-5.4"; pub const DEFAULT_OPENHANDS_LLM_CREDENTIAL_MODE: &str = "api_key"; pub const DEFAULT_ROUTING_HARNESS: &str = "openhands_agent_server"; +pub const DEFAULT_DEVIN_API_BASE_URL: &str = "https://api.devin.ai"; +/// Devin API v3 authenticates with a `cog_` service-user token. +pub const DEFAULT_DEVIN_API_KEY_ENV: &str = "COG_SERVICE_USER_TOKEN"; +pub const DEFAULT_DEVIN_ORG_ID_ENV: &str = "DEVIN_ORG_ID"; +pub const DEFAULT_DEVIN_POLL_INTERVAL_MS: u64 = 5_000; +/// Maximum number of tags the Devin API accepts on a session. +pub const DEVIN_MAX_SESSION_TAGS: usize = 50; +pub const DEFAULT_DEVIN_REQUEST_TIMEOUT_MS: u64 = 30_000; pub const DEFAULT_ROUTING_HARNESS_ENV: &str = "OPENSYMPHONY_HARNESS"; pub const DEFAULT_ROUTING_MODEL_ENV: &str = "OPENSYMPHONY_MODEL"; pub const DEFAULT_ROUTING_MODEL_PROFILE_ENV: &str = "OPENSYMPHONY_MODEL_PROFILE"; @@ -59,6 +67,8 @@ pub struct WorkflowFrontMatter { #[serde(default)] pub openhands: OpenHandsFrontMatter, #[serde(default)] + pub devin: DevinFrontMatter, + #[serde(default)] pub routing: RoutingFrontMatter, #[serde(default)] pub acp: super::acp::AcpConfig, @@ -130,6 +140,52 @@ pub struct RoutingFrontMatter { pub model_profile_env: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DevinFrontMatter { + #[serde(default)] + pub api: DevinApiFrontMatter, + #[serde(default)] + pub session: DevinSessionFrontMatter, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DevinApiFrontMatter { + pub base_url: Option, + /// Environment variable holding the Devin API token. The token itself is + /// never stored in workflow configuration or run manifests. + pub api_key_env: Option, + /// Organization that owns the sessions (`org-...`). + pub org_id: Option, + /// Environment variable holding the organization id when it is not set + /// inline. When neither is present the client resolves it from + /// `GET /v3/self`. + pub org_id_env: Option, + pub poll_interval_ms: Option, + pub request_timeout_ms: Option, +} + +/// Documented `SessionCreateRequest` options carried into every Devin session +/// this workflow starts. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DevinSessionFrontMatter { + pub playbook_id: Option, + pub knowledge_ids: Option>, + /// References to organization secrets Devin injects remotely. Values live + /// in Devin, never in workflow configuration. + pub secret_ids: Option>, + pub max_acu_limit: Option, + pub tags: Option>, + pub title: Option, + /// `normal`, `fast`, `lite`, `ultra`, or `fusion`. + pub devin_mode: Option, + /// VM platform or outpost pool override. + pub platform: Option, + pub resumable: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct OpenHandsFrontMatter { @@ -276,6 +332,36 @@ pub struct WorkflowConfig { pub struct WorkflowExtensions { pub acp: super::acp::AcpConfig, pub openhands: OpenHandsConfig, + pub devin: DevinConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinConfig { + pub api: DevinApiConfig, + pub session: DevinSessionConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DevinApiConfig { + pub base_url: String, + pub api_key_env: String, + pub org_id: Option, + pub org_id_env: String, + pub poll_interval_ms: u64, + pub request_timeout_ms: u64, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DevinSessionConfig { + pub playbook_id: Option, + pub knowledge_ids: Option>, + pub secret_ids: Option>, + pub max_acu_limit: Option, + pub tags: Vec, + pub title: Option, + pub devin_mode: Option, + pub platform: Option, + pub resumable: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/opensymphony-workflow/src/resolve.rs b/crates/opensymphony-workflow/src/resolve.rs index 346abc0eb..99e7a60d7 100644 --- a/crates/opensymphony-workflow/src/resolve.rs +++ b/crates/opensymphony-workflow/src/resolve.rs @@ -9,19 +9,22 @@ use url::{Host, Url}; use super::{ error::WorkflowConfigError, model::{ - AgentConfig, AgentFrontMatter, DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, - DEFAULT_MAX_CONCURRENT_AGENTS, DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, - DEFAULT_OPENHANDS_AGENT_KIND, DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_AUTH_MODE, - DEFAULT_OPENHANDS_BASE_URL, DEFAULT_OPENHANDS_CONDENSER_KEEP_FIRST, - DEFAULT_OPENHANDS_CONDENSER_MAX_SIZE, DEFAULT_OPENHANDS_CONFIRMATION_POLICY_KIND, - DEFAULT_OPENHANDS_LLM_CREDENTIAL_MODE, DEFAULT_OPENHANDS_LLM_MODEL, - DEFAULT_OPENHANDS_MAX_ITERATIONS, DEFAULT_OPENHANDS_PERSISTENCE_DIR, - DEFAULT_OPENHANDS_QUERY_PARAM_NAME, DEFAULT_OPENHANDS_READINESS_PROBE_PATH, - DEFAULT_OPENHANDS_READY_TIMEOUT_MS, DEFAULT_OPENHANDS_RECONNECT_INITIAL_MS, - DEFAULT_OPENHANDS_RECONNECT_MAX_MS, DEFAULT_OPENHANDS_STARTUP_TIMEOUT_MS, - DEFAULT_POLL_INTERVAL_MS, DEFAULT_ROUTING_HARNESS, DEFAULT_ROUTING_HARNESS_ENV, - DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, DEFAULT_STALL_TIMEOUT_MS, - DEFAULT_WORKSPACE_ROOT, Environment, HooksConfig, HooksFrontMatter, IntegerLike, + AgentConfig, AgentFrontMatter, DEFAULT_DEVIN_API_BASE_URL, DEFAULT_DEVIN_API_KEY_ENV, + DEFAULT_DEVIN_ORG_ID_ENV, DEFAULT_DEVIN_POLL_INTERVAL_MS, DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_LINEAR_ENDPOINT, DEFAULT_MAX_CONCURRENT_AGENTS, + DEFAULT_MAX_RETRY_BACKOFF_MS, DEFAULT_MAX_TURNS, DEFAULT_OPENHANDS_AGENT_KIND, + DEFAULT_OPENHANDS_AGENT_TOOLS, DEFAULT_OPENHANDS_AUTH_MODE, DEFAULT_OPENHANDS_BASE_URL, + DEFAULT_OPENHANDS_CONDENSER_KEEP_FIRST, DEFAULT_OPENHANDS_CONDENSER_MAX_SIZE, + DEFAULT_OPENHANDS_CONFIRMATION_POLICY_KIND, DEFAULT_OPENHANDS_LLM_CREDENTIAL_MODE, + DEFAULT_OPENHANDS_LLM_MODEL, DEFAULT_OPENHANDS_MAX_ITERATIONS, + DEFAULT_OPENHANDS_PERSISTENCE_DIR, DEFAULT_OPENHANDS_QUERY_PARAM_NAME, + DEFAULT_OPENHANDS_READINESS_PROBE_PATH, DEFAULT_OPENHANDS_READY_TIMEOUT_MS, + DEFAULT_OPENHANDS_RECONNECT_INITIAL_MS, DEFAULT_OPENHANDS_RECONNECT_MAX_MS, + DEFAULT_OPENHANDS_STARTUP_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_ROUTING_HARNESS, + DEFAULT_ROUTING_HARNESS_ENV, DEFAULT_ROUTING_MODEL_ENV, DEFAULT_ROUTING_MODEL_PROFILE_ENV, + DEFAULT_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_ROOT, DEVIN_MAX_SESSION_TAGS, DevinApiConfig, + DevinApiFrontMatter, DevinConfig, DevinFrontMatter, DevinSessionConfig, + DevinSessionFrontMatter, Environment, HooksConfig, HooksFrontMatter, IntegerLike, OPENHANDS_LLM_CREDENTIAL_MODE_API_KEY, OPENHANDS_LLM_CREDENTIAL_MODE_OPENAI_SUBSCRIPTION, OpenHandsConfig, OpenHandsConfirmationPolicy, OpenHandsConfirmationPolicyFrontMatter, OpenHandsConversationAgentConfig, OpenHandsConversationAgentFrontMatter, @@ -59,6 +62,11 @@ pub(crate) fn resolve_workflow( } else { default_inactive_openhands_config() }, + devin: if config.routing.harness == HarnessKind::DevinCloudAgent.as_str() { + resolve_devin(&workflow.front_matter.devin, env)? + } else { + default_inactive_devin_config() + }, }; apply_selected_model_to_openhands(&config.routing, &mut extensions.openhands); @@ -553,6 +561,191 @@ fn resolve_openhands( }) } +fn resolve_devin( + devin: &DevinFrontMatter, + env: &E, +) -> Result { + Ok(DevinConfig { + api: resolve_devin_api(&devin.api, env)?, + session: resolve_devin_session(&devin.session)?, + }) +} + +fn resolve_devin_api( + api: &DevinApiFrontMatter, + env: &E, +) -> Result { + let base_url = resolve_string_or_default( + api.base_url.as_deref(), + env, + "devin.api.base_url", + DEFAULT_DEVIN_API_BASE_URL, + )?; + validate_devin_base_url(&base_url)?; + + // The credential reference is a literal variable name: resolving it through + // env indirection would store the token itself in resolved configuration. + let api_key_env = normalize_optional_literal(&api.api_key_env) + .unwrap_or_else(|| DEFAULT_DEVIN_API_KEY_ENV.to_owned()); + validate_env_name(&api_key_env, "devin.api.api_key_env")?; + + // The organization identifier is not a credential, so env indirection is + // allowed here; an unset value is read from `org_id_env` at request time + // and otherwise resolved from `GET /v3/self`. + let org_id = match api.org_id.as_deref().and_then(normalize_optional) { + Some(value) => Some(resolve_string(&value, env, "devin.api.org_id")?), + None => None, + }; + if let Some(org_id) = org_id.as_deref() + && !is_devin_org_id(org_id) + { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.org_id", + message: "must be a devin organization id such as `org-abc123`".to_owned(), + }); + } + + let org_id_env = normalize_optional_literal(&api.org_id_env) + .unwrap_or_else(|| DEFAULT_DEVIN_ORG_ID_ENV.to_owned()); + validate_env_name(&org_id_env, "devin.api.org_id_env")?; + + Ok(DevinApiConfig { + base_url, + api_key_env, + org_id, + org_id_env, + poll_interval_ms: resolve_positive_u64( + api.poll_interval_ms.as_ref(), + "devin.api.poll_interval_ms", + DEFAULT_DEVIN_POLL_INTERVAL_MS, + )?, + request_timeout_ms: resolve_positive_u64( + api.request_timeout_ms.as_ref(), + "devin.api.request_timeout_ms", + DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + )?, + }) +} + +/// Devin is a hosted remote harness, so its endpoint must be https and must not +/// carry credentials, query strings, or fragments. +fn validate_devin_base_url(base_url: &str) -> Result<(), WorkflowConfigError> { + let parsed = Url::parse(base_url).map_err(|error| WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: format!("must be an absolute https URL: {error}"), + })?; + + if parsed.scheme() != "https" { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must use the https scheme for remote Devin routing".to_owned(), + }); + } + + if parsed.host().is_none() { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must include a host".to_owned(), + }); + } + + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must not embed credentials".to_owned(), + }); + } + + if parsed.query().is_some() || parsed.fragment().is_some() { + return Err(WorkflowConfigError::InvalidField { + field: "devin.api.base_url", + message: "must not include query or fragment suffixes".to_owned(), + }); + } + + Ok(()) +} + +fn is_devin_org_id(value: &str) -> bool { + value.len() > "org-".len() + && value.starts_with("org-") + && value + .chars() + .all(|character| character == '-' || character.is_ascii_alphanumeric()) +} + +/// Session options map onto documented `SessionCreateRequest` fields. They are +/// plain identifiers and flags: no credential values are accepted here. +fn resolve_devin_session( + session: &DevinSessionFrontMatter, +) -> Result { + let max_acu_limit = match session.max_acu_limit.as_ref() { + Some(value) => { + let parsed = resolve_positive_u64(Some(value), "devin.session.max_acu_limit", 0)?; + Some( + u32::try_from(parsed).map_err(|_| WorkflowConfigError::InvalidField { + field: "devin.session.max_acu_limit", + message: "must fit in a 32-bit unsigned integer".to_owned(), + })?, + ) + } + None => None, + }; + + let tags = session.tags.clone().unwrap_or_default(); + if tags.len() > DEVIN_MAX_SESSION_TAGS { + return Err(WorkflowConfigError::InvalidField { + field: "devin.session.tags", + message: format!("devin accepts at most {DEVIN_MAX_SESSION_TAGS} session tags"), + }); + } + if tags.iter().any(|tag| tag.trim().is_empty()) { + return Err(WorkflowConfigError::InvalidField { + field: "devin.session.tags", + message: "must not contain blank tags".to_owned(), + }); + } + + let devin_mode = normalize_optional_literal(&session.devin_mode); + if let Some(mode) = devin_mode.as_deref() + && !matches!(mode, "normal" | "fast" | "lite" | "ultra" | "fusion") + { + return Err(WorkflowConfigError::InvalidField { + field: "devin.session.devin_mode", + message: "must be one of `normal`, `fast`, `lite`, `ultra`, or `fusion`".to_owned(), + }); + } + + Ok(DevinSessionConfig { + playbook_id: normalize_optional_literal(&session.playbook_id), + knowledge_ids: session.knowledge_ids.clone(), + secret_ids: session.secret_ids.clone(), + max_acu_limit, + tags, + title: normalize_optional_literal(&session.title), + devin_mode, + platform: normalize_optional_literal(&session.platform), + resumable: session.resumable.unwrap_or(true), + }) +} + +/// Devin settings are only resolved for Devin-routed workflows, so an unset +/// substitution or parked endpoint in `devin.api` cannot reject an OpenHands or +/// Codex workflow. +fn default_inactive_devin_config() -> DevinConfig { + DevinConfig { + api: DevinApiConfig { + base_url: DEFAULT_DEVIN_API_BASE_URL.to_owned(), + api_key_env: DEFAULT_DEVIN_API_KEY_ENV.to_owned(), + org_id: None, + org_id_env: DEFAULT_DEVIN_ORG_ID_ENV.to_owned(), + poll_interval_ms: DEFAULT_DEVIN_POLL_INTERVAL_MS, + request_timeout_ms: DEFAULT_DEVIN_REQUEST_TIMEOUT_MS, + }, + session: DevinSessionConfig::default(), + } +} + fn default_inactive_openhands_config() -> OpenHandsConfig { OpenHandsConfig { transport: OpenHandsTransportConfig { diff --git a/crates/opensymphony-workspace/src/lib.rs b/crates/opensymphony-workspace/src/lib.rs index 2cb3aed62..bfcc6f200 100644 --- a/crates/opensymphony-workspace/src/lib.rs +++ b/crates/opensymphony-workspace/src/lib.rs @@ -11,8 +11,8 @@ pub use environment::environment_variable_names_equal; pub(crate) use environment::{has_environment_name_collision, insert_environment_value}; pub use error::{WorkspaceError, WorkspaceOwnershipConflictDetails}; pub use manager::{ - WorkspaceManager, compose_parent_continuation_prompt, compose_parent_prompt, - compose_terminal_prompt, + MAX_INSTRUCTION_FILE_BYTES, WorkspaceManager, compose_parent_continuation_prompt, + compose_parent_prompt, compose_terminal_prompt, instruction_body, }; pub use models::{ AcpProcessState, AcpRecovery, AcpRunRoute, AcpSessionIdentity, AcpSessionState, diff --git a/crates/opensymphony-workspace/src/manager.rs b/crates/opensymphony-workspace/src/manager.rs index 440ab02bf..9aa6430f1 100644 --- a/crates/opensymphony-workspace/src/manager.rs +++ b/crates/opensymphony-workspace/src/manager.rs @@ -49,7 +49,7 @@ use super::{ }; use crate::opensymphony_domain::{RepositoryBinding, SafeRemoteFingerprint}; -const MAX_INSTRUCTION_FILE_BYTES: u64 = 1024 * 1024; +pub const MAX_INSTRUCTION_FILE_BYTES: u64 = 1024 * 1024; const MAX_TOTAL_INSTRUCTION_BYTES: u64 = 4 * 1024 * 1024; #[derive(Clone)] @@ -66,6 +66,16 @@ struct HookFailure { record: HookExecutionRecord, } +/// Whether workspace lifecycle hooks run while a workspace is materialized. +/// +/// Hooks operate on a local checkout, so an evidence-only workspace for a +/// remote-execution harness skips them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkspaceHooks { + Executed, + Skipped, +} + enum ExistingIssueManifestState { Missing, Owned(IssueManifest), @@ -272,6 +282,14 @@ impl WorkspaceManager { &self.checkout_credential_envs } + /// Configured checkout policy for a canonical repository id. + /// + /// Remote harnesses that never materialize a local checkout still need the + /// configured remote and target branch to bind their own workspace. + pub fn checkout_repository(&self, repository_id: &str) -> Option<&CheckoutRepository> { + self.checkout_repositories.get(repository_id) + } + pub fn workspace_path_for(&self, issue_identifier: &str) -> Result { super::workspace_path_for_root(&self.config.root, issue_identifier) } @@ -594,7 +612,7 @@ impl WorkspaceManager { .await?; self.write_manifest_atomically(&handle, &handle.parent_manifest_path(), &manifest) .await?; - let issue_manifest = self.upsert_issue_manifest(issue, &handle).await?; + let issue_manifest = self.upsert_issue_manifest(issue, &handle, None).await?; debug_assert_eq!(issue_manifest.workspace_path, handle.workspace_path()); self.remove_cleanup_tombstone(&handle, &format!("parent:{hierarchy_generation}")) .await?; @@ -2271,6 +2289,24 @@ impl WorkspaceManager { self.ensure(issue).await } + /// Ensures an issue workspace without preparing a local checkout. + /// + /// A remote-execution harness owns its own workspace, so the local one + /// carries manifests, journals, and imported evidence only. Cloning the + /// repository or running the workspace hooks here would demand repository + /// access and Git on a host that never runs the agent. + pub async fn ensure_evidence_only( + &self, + issue: &IssueDescriptor, + ) -> Result { + // The binding is kept in the issue manifest: it records which + // repository the remote run belongs to, and dropping it makes the + // workspace look unbound to every later binding comparison. Only the + // checkout it would otherwise drive is skipped. + self.ensure_local_workspace(issue, WorkspaceHooks::Skipped) + .await + } + pub async fn ensure_with_run_id( &self, issue: &IssueDescriptor, @@ -2288,6 +2324,15 @@ impl WorkspaceManager { .await; } + self.ensure_local_workspace(issue, WorkspaceHooks::Executed) + .await + } + + async fn ensure_local_workspace( + &self, + issue: &IssueDescriptor, + hooks: WorkspaceHooks, + ) -> Result { self.create_directory(&self.config.root).await?; let canonical_root = self.canonicalize_path(&self.config.root).await?; let workspace_key = sanitize_workspace_key(&issue.identifier)?; @@ -2373,7 +2418,7 @@ impl WorkspaceManager { existing_state, ExistingWorkspaceState::Missing | ExistingWorkspaceState::ForeignArtifact ); - let after_create = if created { + let after_create = if created && hooks == WorkspaceHooks::Executed { match self.execute_hook(HookKind::AfterCreate, &handle).await { Ok(record) => { if record.is_some() { @@ -2387,7 +2432,9 @@ impl WorkspaceManager { None }; self.bootstrap_workspace_layout(&handle).await?; - let issue_manifest = self.upsert_issue_manifest(issue, &handle).await?; + let issue_manifest = self + .upsert_issue_manifest(issue, &handle, Some(hooks == WorkspaceHooks::Skipped)) + .await?; if created { self.remove_cleanup_tombstone(&handle, &format!("workspace:{}", issue.issue_id)) .await?; @@ -2667,7 +2714,7 @@ impl WorkspaceManager { .await; return Err(error); } - let issue_manifest = match self.upsert_issue_manifest(issue, &workspace).await { + let issue_manifest = match self.upsert_issue_manifest(issue, &workspace, None).await { Ok(manifest) => manifest, Err(error) => { self.handle_receipt_owned_checkout_failure(&workspace, &error) @@ -3304,7 +3351,7 @@ impl WorkspaceManager { .await?; continue; } - let issue_manifest = self.upsert_issue_manifest(issue, &handle).await?; + let issue_manifest = self.upsert_issue_manifest(issue, &handle, None).await?; return Ok(Some(EnsureWorkspaceResult { handle, issue_manifest, @@ -4624,6 +4671,38 @@ impl WorkspaceManager { Ok(()) } + /// Starts a run in an evidence-only workspace. + /// + /// The configured `before_run` hook operates on a local checkout, which a + /// remote-execution harness never materializes, so it is not executed. + pub async fn start_evidence_run( + &self, + workspace: &WorkspaceHandle, + run: &RunDescriptor, + ) -> Result { + self.validate_workspace_handle(workspace).await?; + + let mut manifest = RunManifest::new(workspace, run); + manifest.status = RunStatus::Prepared; + self.write_run_manifest(workspace, &manifest).await?; + Ok(manifest) + } + + /// Finishes a run in an evidence-only workspace without the `after_run` + /// hook, which likewise assumes a local checkout. + pub async fn finish_evidence_run( + &self, + workspace: &WorkspaceHandle, + run_manifest: &mut RunManifest, + status: RunStatus, + ) -> Result<(), WorkspaceError> { + self.validate_workspace_handle(workspace).await?; + + run_manifest.status = status; + run_manifest.updated_at = Utc::now(); + self.write_run_manifest(workspace, run_manifest).await + } + pub async fn start_run( &self, workspace: &WorkspaceHandle, @@ -4682,6 +4761,23 @@ impl WorkspaceManager { } } + async fn workspace_hooks( + &self, + workspace: &WorkspaceHandle, + ) -> Result { + Ok( + if self + .load_issue_manifest(workspace) + .await? + .is_some_and(|manifest| manifest.evidence_only) + { + WorkspaceHooks::Skipped + } else { + WorkspaceHooks::Executed + }, + ) + } + pub async fn cleanup( &self, workspace: &WorkspaceHandle, @@ -4700,6 +4796,27 @@ impl WorkspaceManager { .await } + /// Applies terminal cleanup to an evidence-only workspace. The durable + /// issue manifest tells the cleanup transaction to skip repository hooks. + pub async fn cleanup_evidence_only( + &self, + workspace: &WorkspaceHandle, + state: IssueLifecycleState, + force_remove: bool, + ) -> Result { + let generation = self.cleanup_generation(workspace).await?; + self.cleanup_with_request( + workspace, + state, + CleanupRequest { + generation, + outcome: CleanupTerminalOutcome::Succeeded, + remove: force_remove || self.config.cleanup.remove_terminal_workspaces, + }, + ) + .await + } + /// Remove a failed terminal workspace even when ordinary terminal /// workspaces are retained by configuration. Retry exhaustion has its own /// `retain_failed` policy at the scheduler layer. @@ -4894,9 +5011,14 @@ impl WorkspaceManager { }; if decision == CleanupDecision::Retain { - let before_remove = match self.execute_hook(HookKind::BeforeRemove, workspace).await { - Ok(record) => record, - Err(failure) => Some(failure.record), + let before_remove = match self.workspace_hooks(workspace).await? { + WorkspaceHooks::Skipped => None, + WorkspaceHooks::Executed => { + match self.execute_hook(HookKind::BeforeRemove, workspace).await { + Ok(record) => record, + Err(failure) => Some(failure.record), + } + } }; return Ok(CleanupOutcome { decision, @@ -5000,7 +5122,9 @@ impl WorkspaceManager { .as_ref() .and_then(|intent| intent.before_remove.as_ref()) .is_some(); - if !before_remove_recorded { + if !before_remove_recorded + && self.workspace_hooks(workspace).await? == WorkspaceHooks::Executed + { let mut run_before_remove = true; if let Some(hook) = self.hook_definition(HookKind::BeforeRemove).cloned() { let started_at = Utc::now(); @@ -6169,9 +6293,15 @@ impl WorkspaceManager { &self, issue: &IssueDescriptor, workspace: &WorkspaceHandle, + evidence_only: Option, ) -> Result { - self.upsert_issue_manifest_at_path(issue, workspace, workspace.workspace_path()) - .await + self.upsert_issue_manifest_at_path( + issue, + workspace, + workspace.workspace_path(), + evidence_only, + ) + .await } async fn upsert_issue_manifest_at_path( @@ -6179,6 +6309,7 @@ impl WorkspaceManager { issue: &IssueDescriptor, workspace: &WorkspaceHandle, manifest_workspace_path: &Path, + evidence_only: Option, ) -> Result { let existing = match self.inspect_issue_manifest_state(issue, workspace).await? { ExistingIssueManifestState::Owned(manifest) => Some(manifest), @@ -6213,6 +6344,11 @@ impl WorkspaceManager { updated_at: now, last_seen_tracker_refresh_at: issue.last_seen_tracker_refresh_at, repository_binding: issue.repository_binding.clone(), + evidence_only: evidence_only.unwrap_or_else(|| { + existing + .as_ref() + .is_some_and(|manifest| manifest.evidence_only) + }), }; self.write_manifest_atomically(workspace, &workspace.issue_manifest_path(), &manifest) @@ -6558,6 +6694,7 @@ impl WorkspaceManager { repository_binding: receipt .repository_binding .map(crate::opensymphony_domain::RepositoryBindingOutcome::Resolved), + evidence_only: false, })) } @@ -7576,6 +7713,16 @@ async fn read_bounded_instruction_file( Ok((format!("sha256:{:x}", hasher.finalize()), contents)) } +/// Instruction text to present to a harness for `path`: `WORKFLOW.md` keeps +/// only its body, every other instruction file is used verbatim. +pub fn instruction_body(path: &Path, bytes: &[u8]) -> Vec { + if is_workflow_instruction_path(path) { + workflow_body(bytes) + } else { + bytes.to_vec() + } +} + fn workflow_body(bytes: &[u8]) -> Vec { let text = String::from_utf8_lossy(bytes); let Some(rest) = text.strip_prefix("---") else { diff --git a/crates/opensymphony-workspace/src/models.rs b/crates/opensymphony-workspace/src/models.rs index b00a91e47..df96773d9 100644 --- a/crates/opensymphony-workspace/src/models.rs +++ b/crates/opensymphony-workspace/src/models.rs @@ -8,7 +8,9 @@ use std::{ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::opensymphony_domain::{RepositoryBinding, RepositoryBindingOutcome, WorkspaceRecord}; +use crate::opensymphony_domain::{ + RepositoryBinding, RepositoryBindingOutcome, WorkerOutcomeKind, WorkspaceRecord, +}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CheckoutRepository { @@ -1103,6 +1105,8 @@ pub struct IssueManifest { pub last_seen_tracker_refresh_at: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub repository_binding: Option, + #[serde(default)] + pub evidence_only: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -1163,6 +1167,10 @@ pub struct RunManifest { pub retry_error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub interrupt_reason: Option, + /// A worker outcome that must survive restart because retrying it would + /// duplicate work that is still live outside this process. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_worker_outcome: Option, pub status: RunStatus, /// True only when the harness adapter observed a terminal runtime state or /// a reconciled interrupt acknowledgement. A failed transport alone does @@ -1203,6 +1211,7 @@ impl RunManifest { retry_reason: None, retry_error: None, interrupt_reason: None, + terminal_worker_outcome: None, status: RunStatus::Preparing, harness_stopped: false, created_at: now, diff --git a/crates/opensymphony-workspace/tests/workspace_manager.rs b/crates/opensymphony-workspace/tests/workspace_manager.rs index 78515684e..720f06186 100644 --- a/crates/opensymphony-workspace/tests/workspace_manager.rs +++ b/crates/opensymphony-workspace/tests/workspace_manager.rs @@ -1748,6 +1748,107 @@ async fn ensure_creates_reuses_workspace_and_runs_after_create_once() { ); } +#[cfg(unix)] +#[tokio::test] +async fn evidence_only_workspace_runs_no_hooks() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let workspace_root = temp_dir.path().join("workspaces"); + let manager = WorkspaceManager::new(manager_config( + &workspace_root, + HookConfig { + after_create: Some(HookDefinition::shell("echo ran > after_create.txt")), + before_run: Some(HookDefinition::shell("echo ran > before_run.txt")), + after_run: Some(HookDefinition::shell("echo ran > after_run.txt")), + before_remove: Some(HookDefinition::shell("echo ran > before_remove.txt")), + ..HookConfig::default() + }, + CleanupConfig::default(), + )) + .expect("manager should build"); + let mut issue = sample_issue("COE-EVIDENCE"); + let binding = RepositoryBinding { + alias: "source".to_owned(), + repository: RepositoryIdentity { + id: CanonicalRepositoryId::from_remote("github", None, "acme/evidence") + .expect("repository id should be valid"), + safe_remote_fingerprint: SafeRemoteFingerprint::from_remote( + "github", + None, + "acme/evidence", + ) + .expect("fingerprint should be valid"), + }, + config_generation: "config-1".to_owned(), + inventory_generation: "inventory-1".to_owned(), + }; + issue.repository_binding = Some(RepositoryBindingOutcome::Resolved(binding.clone())); + + let ensured = manager + .ensure_evidence_only(&issue) + .await + .expect("evidence-only workspace should be created"); + assert!(ensured.created); + assert!(ensured.after_create.is_none()); + // Skipping the checkout must not erase which repository the remote run + // belongs to: recovery compares this binding against the live one. + assert_eq!( + ensured + .issue_manifest + .repository_binding + .as_ref() + .and_then(|outcome| outcome.repository_id()), + Some(binding.repository_id()), + "an evidence-only workspace must keep its repository identity" + ); + + let mut run_manifest = manager + .start_evidence_run(&ensured.handle, &RunDescriptor::new("run-evidence", 1)) + .await + .expect("evidence run should start"); + assert_eq!(run_manifest.status, RunStatus::Prepared); + assert!(run_manifest.hooks.is_empty()); + + manager + .finish_evidence_run(&ensured.handle, &mut run_manifest, RunStatus::Succeeded) + .await + .expect("evidence run should finish"); + assert_eq!(run_manifest.status, RunStatus::Succeeded); + assert!(run_manifest.hooks.is_empty()); + + let retained = manager + .cleanup_evidence_only(&ensured.handle, IssueLifecycleState::Terminal, false) + .await + .expect("evidence-only cleanup should succeed"); + assert_eq!(retained.decision, CleanupDecision::Retain); + assert!(retained.before_remove.is_none()); + + for marker in [ + "after_create.txt", + "before_run.txt", + "after_run.txt", + "before_remove.txt", + ] { + assert!( + !tokio::fs::try_exists(ensured.handle.workspace_path().join(marker)) + .await + .expect("hook marker lookup should succeed"), + "{marker} must not exist: evidence-only workspaces run no repository hooks" + ); + } + + let removed = manager + .cleanup_evidence_only(&ensured.handle, IssueLifecycleState::Terminal, true) + .await + .expect("evidence-only cleanup should remove the workspace"); + assert_eq!(removed.decision, CleanupDecision::Remove); + assert!(removed.before_remove.is_none()); + assert!( + !tokio::fs::try_exists(ensured.handle.workspace_path()) + .await + .expect("workspace lookup should succeed") + ); +} + #[tokio::test] async fn checkout_timeout_does_not_override_legacy_hook_timeout() { let temp_dir = TempDir::new().expect("temp dir should exist"); diff --git a/docs/configuration.md b/docs/configuration.md index 31578de62..712cd76df 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -746,6 +746,261 @@ OpenSymphony forwards an OpenHands `LLMSummarizingCondenser` that reuses the conversation agent's LLM settings. The condenser is enabled by default with `max_size: 240` and `keep_first: 2`. To disable it, set `enabled: false`. +## Devin Cloud Harness (`devin`) + +The harness targets the **Devin API v3** contract vendored at +`crates/opensymphony-devin/contracts/devin-v3-openapi.yaml` (source: +`https://docs.devin.ai/v3-openapi.yaml`). v3 is organization-scoped: +requests go to `/v3/organizations/{org_id}/sessions...` with a `cog_` +service-user bearer token. + +Selecting `routing.harness: devin_cloud_agent` runs the issue on Devin's hosted +infrastructure. These settings are resolved **only** when Devin is the selected +harness; for OpenHands, Codex, or `rust_native` workflows the block is ignored +entirely and an inert default is used, so a parked or half-configured `devin` +block cannot fail an unrelated workflow. + +Before the first run, export the two credentials the defaults expect (a `cog_` +service-user token and the organization it belongs to): + +```bash +export COG_SERVICE_USER_TOKEN=cog_... +export DEVIN_ORG_ID=org-... +``` + +```yaml +routing: + harness: devin_cloud_agent + +devin: + api: + base_url: https://api.devin.ai + api_key_env: COG_SERVICE_USER_TOKEN + org_id_env: DEVIN_ORG_ID + poll_interval_ms: 5000 + request_timeout_ms: 30000 + session: + playbook_id: playbook-abc123 + knowledge_ids: + - note-abc123 + secret_ids: + - secret-abc123 + max_acu_limit: 25 + tags: + - team:core + title: OpenSymphony run + devin_mode: normal + platform: linux + resumable: true +``` + +`devin.api`: + +| Field | Default | Notes | +| --- | --- | --- | +| `base_url` | `https://api.devin.ai` | Supports `${VAR}` env indirection. Must be an absolute `https` URL with a host, and must not embed credentials, a query string, or a fragment. | +| `api_key_env` | `COG_SERVICE_USER_TOKEN` | **Literal environment-variable name**, never a value. | +| `org_id` | unset | Optional inline organization id (`org-...`). Resolved from `org_id_env`, then `GET /v3/self`, when omitted. | +| `org_id_env` | `DEVIN_ORG_ID` | Literal environment-variable name holding the organization id. | +| `poll_interval_ms` | `5000` | Session/message poll interval. Must be a positive integer. | +| `request_timeout_ms` | `30000` | Must be a positive integer. | + +`devin.session` maps onto documented `SessionCreateRequest` fields: + +| Field | Default | Notes | +| --- | --- | --- | +| `playbook_id` | unset | Devin playbook to run. | +| `knowledge_ids` | unset | Knowledge notes to attach. | +| `secret_ids` | unset | *References* to Devin-held organization secrets; values never live in OpenSymphony config. | +| `max_acu_limit` | unset | Positive ACU cap for the session. | +| `tags` | `[]` | At most 49 tags; the 50th slot is reserved for the `opensymphony:` correlation tag that is always appended. | +| `title` | unset | Session title. | +| `devin_mode` | unset | One of `normal`, `fast`, `lite`, `ultra`, `fusion`. | +| `platform` | unset | VM platform / outpost pool override. | +| `resumable` | `true` | Matches the API default; set `false` for disposable sessions. | + +Repository binding is prompt-carried: v3 takes repositories as `owner/name` +entries in `repos` and has no branch field, so branch and issue context are +rendered into the session prompt. + +`api_key_env` is deliberately not passed through env substitution: writing +`api_key_env: ${COG_SERVICE_USER_TOKEN}` would resolve the token itself into resolved +configuration, manifests, and debug output. The name is validated as an +environment-variable identifier and the token is read from the process +environment only at request time. + +### Running issues on Devin + +The route needs the repository's remote and target branch to bind Devin's +workspace, and those come from the central repository inventory, which is +consumed only in `project_set` routing. A repository-local `WORKFLOW.md` on +its own (or `legacy_single` routing) has no checkout policy to bind, so the +worker fails with `has no configured checkout policy to bind a devin +workspace`. A minimal `~/.opensymphony/config.yaml`: + +```yaml +schema_version: 1 +instance: + id: devin-trial + state_root: ~/.opensymphony/state/devin-trial +routing: + mode: project_set + active_project_set: trial + harness: devin_cloud_agent +tracker_profiles: + linear: + provider: linear + credential: linear-key + active_states: [Todo, In Progress, Rework] + terminal_states: [Done, Canceled] +project_sets: + trial: + tracker_profile: linear + projects: [trial-project] +linear_projects: + trial-project: + provider_project_id: "" + repositories: [target] +repositories: + target: + aliases: [target] + remote: + provider: github + locator: owner/repo + clone: https://github.com/owner/repo.git + target_branch: main + credential: github-https + review_profile: github + instructions: + path: WORKFLOW.md +credentials: + linear-key: + kind: environment + variable: LINEAR_API_KEY + github-https: + kind: environment + variable: GITHUB_TOKEN +review_profiles: + github: + provider: github + credential: github-https +workspace: + root: ~/.opensymphony/workspaces/devin-trial +memory: + catalog_root: ~/.opensymphony/state/devin-trial/memory +scheduler: + max_concurrent_tasks: 1 +devin: + session: + max_acu_limit: 10 + tags: [opensymphony] +``` + +`clone` must be an `https://` URL: Devin clones with its own credentials, and +the binding rejects SSH locators. The `devin:` block takes the same shape as the +`WORKFLOW.md` front matter above; the central copy wins, and a repository-local +block is only consulted when the central file has none. `api_key_env` and +`org_id_env` must not name a checkout-credential variable. Each Linear issue to +dispatch carries the managed `repo:target` label and sits in an active state of +the selected project. + +```bash +export LINEAR_API_KEY=lin_api_... COG_SERVICE_USER_TOKEN=cog_... DEVIN_ORG_ID=org-... +opensymphony run --dry-run # resolves routing, writes run.json, creates no session +opensymphony run # creates real sessions and consumes ACUs +``` + +`project_set` routing reads no repository checkout, so the command can run from +any directory. Nothing loads `.env` for you; export the variables (or +`set -a; . ./.env`). Watch progress with `opensymphony tui`; per-run evidence +lands under `//.opensymphony/devin//`. +Interrupting the orchestrator with Ctrl-C terminates and archives every +session it is still tracking before the process exits, so a stopped daemon +leaves no session consuming ACUs; a session that was already detached +(waiting on its operator) is stopped by terminal cleanup once its issue +reaches a terminal tracker state. A daemon that dies without running its +shutdown path leaves the session alive within `max_acu_limit`; the next +`opensymphony run` reattaches to it through the persisted binding. + +When Devin finishes its turn and waits for a reply (`waiting_for_user` or +`waiting_for_approval`), the run settles as a non-retrying `Detached` outcome: +the session stays alive and bound, its message journal (including any pull +request URL Devin reported) is imported as evidence, and the operator continues +the conversation in the Devin console. The orchestrator does not forward +replies. + +### What a Devin run does + +1. Binds the client to one organization through `GET /v3/self`. A configured + `org_id` the credential does not own fails the run before any session is + created. +2. Resolves `secret_ids` against that organization's Devin secrets, by secret id + or key. Unknown or out-of-organization references fail the run; secret + *values* are never read by OpenSymphony — Devin injects them remotely. +3. Fetches the repository's configured instruction file (`instructions.path`, + usually `WORKFLOW.md`) from the GitHub remote at the target branch through + the contents API, using the repository's checkout credential. The evidence-only + issue workspace has no checkout to read it from, yet Devin must see the same + repository procedure a local harness gets — including the agent-side step + that attaches the PR to the Linear issue. `WORKFLOW.md` front matter is + stripped exactly as for local checkouts, the file is bounded to 1 MiB, and a + configured file that cannot be fetched fails the launch (retryable) rather + than starting a session without instructions. Non-GitHub providers cannot + supply instructions to Devin yet. +4. Creates the session with the rendered issue prompt (central procedure, task + facts, repository instructions), repository, tags, and session options, and + reports the session URL as the run conversation link. +5. Polls session status and cursor-paginated messages, publishing normalized + runtime events onto the run timeline. +6. On settlement, imports evidence into the issue workspace under + `.opensymphony/devin/`: `session.json`, `events.jsonl`, + `evidence.json` (outcome, status, ACU usage, pull-request URLs, structured + output, and `repository_instructions` — the instruction path, git ref, + SHA-256 and size the prompt carried), and downloaded attachments. `pull_request_urls` lists the pull + requests the Devin API reports; `message_pull_request_urls` lists any + further `/pull/` URLs Devin mentioned in its messages (for example when + it opened the PR outside its built-in PR tool), so operators can find the + PR without reading `events.jsonl`. +7. Terminates and archives the remote session on cancellation, poll timeout, or + transport failure, so an abandoned run cannot keep burning ACUs. + +Repository workflows that end with agent-side tracker writes (the repo-local +`linear` skill attaching the PR URL to the issue) and `git push` need their +credentials inside Devin's workspace. Reference them through +`devin.session.secret_ids` (for example `[LINEAR_API_KEY, GH_TOKEN_TG]`, +matching the keys of the organization's Devin secrets); without them the +session can open a PR through Devin's own GitHub integration but cannot run the +repository's Linear helper, and the PR never appears on the issue. + +The local issue workspace is evidence and manifest storage only; it is never +Devin's execution directory, and no local checkout is prepared for the run. + +The client itself is verified against the live API by the opt-in suite in +`tests/devin_cloud_agent_live.rs`. It needs `COG_SERVICE_USER_TOKEN` (and +optionally `DEVIN_ORG_ID`) in the environment, and its lifecycle test creates a +real session, so it consumes ACUs: + +```bash +OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored --nocapture +``` + +See `docs/harness-adapter-compatibility.md` for what the last live run +confirmed and what is still unverified. + +Known limitations: event latency is bounded by `poll_interval_ms` (the v3 +contract has no push stream), cancellation terminates the whole session rather +than interrupting a turn, pause/resume and approvals are unavailable, the model +is fixed at session creation through `devin_mode`, and TLS certificate pinning +is not implemented. + +Devin reports no message or status change while it works through a long step, +so the route publishes a `session.heartbeat` runtime event for a session that is +still executing whenever it has been quiet for one third of +`scheduler.stall_timeout_ms` (at most every 60 seconds). The scheduler's +activity-based stall policy therefore applies to a Devin session that has +genuinely stopped reporting, not to one that is simply busy; heartbeats appear +on the run timeline but are not written to the `events.jsonl` evidence journal. + ## Runtime Config `opensymphony init` also copies a starter `config.yaml` next to the target diff --git a/docs/harness-adapter-compatibility.md b/docs/harness-adapter-compatibility.md index 98f991e93..096b02c1d 100644 --- a/docs/harness-adapter-compatibility.md +++ b/docs/harness-adapter-compatibility.md @@ -101,6 +101,263 @@ Known gaps: tokens in OpenSymphony workspaces or browser payloads. - Hosted Codex worker pools and remote routing remain future work. +## Devin Cloud Agent + +Devin is a remote, vendor-hosted implementation agent. The adapter lives in +`crates/opensymphony-devin` (source-included by the root crate, not a separate +Cargo package) and advertises `devin_cloud_agent` as available over an HTTPS +transport (`remote: true`, `local: false`) with runtime contract +`devin-api-v3`. + +The client targets the **Devin API v3** contract, vendored from +`https://docs.devin.ai/v3-openapi.yaml` into +`crates/opensymphony-devin/contracts/devin-v3-openapi.yaml` and asserted against +in `tests/devin_cloud_agent.rs`. v3 is organization-scoped and authenticated +with a `cog_` service-user bearer token, so every session route is +`/v3/organizations/{org_id}/sessions...` and the organization id is resolved +from config, `DEVIN_ORG_ID`, or `GET /v3/self`. + +The module provides: validated configuration (HTTPS-only endpoint, credential +*environment variable name* rather than a token, `org-` id shape, tag/ACU/mode +limits); a request builder covering `GET /v3/self`, session create/get/list, +cursor-paginated messages, operator message send, delete/archive, attachments, +and tag replacement; a client that reads the token from the configured +environment variable, disables redirects, and injects the bearer only on the +wire; a poll-driven session lifecycle that drains messages by cursor, +deduplicates by `event_id`, emits status/status-detail transitions, and collects +pull requests, ACU usage, structured output, and attachments as run evidence; +and normalization that maps documented message sources and statuses into journal +records while preserving every unknown payload as raw JSON (unknown statuses and +sources decode rather than fail). + +Devin owns its execution workspace. `DevinRemoteWorkspaceBinding` binds an issue +workspace key and repository URL to that remote workspace and keeps the local +OpenSymphony checkout as an evidence-only path; it is never used as Devin's +working directory. The CLI route in +`crates/opensymphony-cli/src/orchestrator_run/backends.rs` skips local checkout +preparation entirely for this harness: it binds tenancy, resolves secret +references, creates the session, streams normalized events as +`WorkerUpdate::RuntimeEvent`, imports evidence, and maps the settled session +onto a scheduler outcome. + +Because nothing is executed locally, the scheduler prepares the issue workspace +through `ensure_evidence_workspace` for any harness whose capability transport +is remote-only. That path materializes the contained issue directory for run +manifests, journals, and evidence without cloning or verifying a checkout and +without running repository hooks, so a host with no local repository access can +still launch a session Devin itself can clone. All four hooks are skipped: +`after_create`, `before_run`, `after_run`, and `before_remove`. The issue manifest +records both the repository binding and the evidence-only workspace policy, so +cleanup also skips checkout hooks after a restart. Recovery compares the +repository identity against the live tracker binding and retains the evidence +for a remote session whose termination is unconfirmed. + +Repository instructions still reach the prompt. Because there is no local +checkout to read `instructions.path` from, the route fetches the configured file +from the GitHub remote at the repository's target branch (`GET +/repos/{owner}/{repo}/contents/{path}?ref=` with the checkout +credential), strips `WORKFLOW.md` front matter through the shared +`instruction_body` helper, bounds it to `MAX_INSTRUCTION_FILE_BYTES`, renders it +into the `## Repository Instructions` section of `compose_terminal_prompt`, and +records path, ref, SHA-256 and size as `repository_instructions` in +`evidence.json`. A configured file that cannot be fetched is a retryable launch +failure, matching the local route. The repository's agent-side steps (attaching +the PR to the Linear issue, pushing) run inside Devin, so the secrets they need +must be referenced through `devin.session.secret_ids`. + +A `--dry-run` route settles before any credential resolution or session +creation, so it neither contacts the API nor consumes ACUs; it writes the local +run manifest and no conversation binding. + +### Tenant isolation and secrets + +Every client binds to exactly one organization before it issues session traffic: +`bind_tenant()` calls `GET /v3/self`, rejects a configured organization that the +credential does not own (`TenantMismatch`), and adopts the credential's +organization when none is configured. Session, listing, and message payloads +carrying a different `org_id` are rejected as `CrossTenantPayload` rather than +journaled. + +Secrets are references, never values. `devin.session.secret_ids` holds Devin +secret ids or keys; they are resolved against `GET /v3/organizations/{org_id}/secrets` +for the bound organization, and anything not owned by it fails the run with +`UnknownSecret`. Only resolved ids are sent to Devin, which injects the values +into its own environment — OpenSymphony never reads them. + +### Transport hardening + +The base URL must be an absolute HTTPS URL with no embedded credentials, query, +or fragment; the client enforces HTTPS, requires TLS 1.2 or newer, disables +redirects so the bearer cannot follow a rewrite off-origin, and reads the token +from the configured environment variable without ever serializing or logging it. +Certificate pinning is **not** implemented. + +### Evidence import + +Settled runs write `session.json`, `events.jsonl`, sanitized `attachments/`, and +`evidence.json` under `/.opensymphony/devin/`. The +manifest records session id and URL, outcome, status and status detail, ACU +usage, pull-request URLs (`pull_request_urls` from the API's +`SessionResponse.pull_requests`, plus `message_pull_request_urls` for +`/pull/` or `/merge_requests/` URLs that only appear in Devin's messages), +normalized event count, stored and skipped attachments, structured output, and +the `devin_owned_remote_workspace` containment marker. Attachment downloads must match the authenticated API +origin, are bounded to 25 MiB each and 50 per run (both `Content-Length` and +decoded body are checked), and land under sanitized names that cannot escape the +evidence directory. A failed download is recorded as a skipped attachment. A +failed attachment-list request is recorded in `attachment_listing_error` while +the session outcome is preserved. + +### Session cleanup + +A Devin session outlives the worker process, so every abandoned path stops it: +poll timeout and transport failure terminate and archive the session before the +route returns, and a scheduler interrupt looks the session up in the +live-session registry and deletes it. Worker abort and daemon shutdown +(`WorkerBackend::abort_worker` / `WorkerBackend::shutdown`) terminate the +sessions tracked for the affected workers, *await* the acknowledgement, and +then give the route task a bounded window (`DEVIN_SETTLE_TIMEOUT`, 30s) to +observe the archived session on its next poll, import the evidence, and finish +`run.json` as cancelled before the task is aborted — a tracker state change +such as `In Progress -> Human Review` releases the worker this way, and the +imported evidence is how the run stays inspectable afterwards; +`DevinSessionGuard::drop` only makes an +opportunistic attempt, because a `Drop` cannot await and a task spawned during +runtime teardown may never run. A scheduler interrupt that finds no tracked +session — the normal state after a daemon restart — rebuilds a tenant-bound +client and stops the session recorded in the conversation manifest. + +The durable fence is the binding itself. Beside the conversation manifest the +route writes `devin-route.json`: the session id, API origin, organization, and +the *name* of the credential environment variable (never its value). Every +later cleanup — terminal issue cleanup, and the pre-creation check that stops +a session a dropped or crashed worker left bound — is addressed through that +persisted identity, so a workflow that has since been pointed at another origin +or organization cannot produce a 404 that looks like proof the original +session is gone; `bind_tenant` refuses the mismatched credential instead. If +the bound session cannot be terminated, the route refuses to create a +replacement and settles as non-retrying `CancelFailed`. + +Session creation is reconciled before it is retried. Devin may accept a +`POST /sessions` whose response is lost or undecodable, so a failed create +first lists unarchived sessions carrying the run's correlation tag (the tag +always survives operator tag truncation; operators get 49 slots). A match +created at or after the attempt is followed as if the create had succeeded; a +confirmed miss is an ordinary retryable failure; a lookup that fails too +settles as `CancelFailed` and names the tag to search for in Devin. + +Ownership is only released once the remote session is provably gone. The route +writes `run.json` and the Devin conversation binding *before* it reports the +launch, so cancellation and restart recovery can always find the live session. +If termination after a polling failure fails, the route keeps the cleanup guard +armed and settles as `CancelFailed`, which does not retry, rather than as an +ordinary failure that would start a second session while the first still bills. +A session that stops to wait for operator input (`waiting_for_user`, +`waiting_for_approval`), or that Devin has already suspended for `inactivity` +while waiting, settles the run as the non-retrying `Detached` outcome: the +session stays alive and bound, its journal (including any pull request URL +Devin reported in a message) is imported as evidence, and the operator answers +in the Devin console. Polling through that state is not an option: Devin +suspends an unanswered session within minutes, and a suspension would otherwise +be reported as a stall and retried with a replacement session that repeats work +the first one may already have delivered. OpenSymphony itself does not forward +operator messages, which is why the capability reports +`send_user_message: false`. An `exit` session that is also archived was closed +by a terminate request or the console rather than by Devin finishing, and +settles as `Cancelled`. For every other outcome the +cleanup guard stays armed until the local evidence is durable: an evidence +import failure archives the settled session (awaited) before the retryable +failure is reported, and keeps `CancelFailed` ownership if even that fails. +Every stop path (interrupt, worker abort, terminal cleanup, shutdown) goes +through `DevinCloudClient::stop_session`: Devin rejects `DELETE` for a session +that has already exited (`400 Devin session already exited`), so a rejected +termination is re-checked against the session's current status, and a session +that is already terminal counts as stopped (and is archived if it was not), +while a missing session (`404`) is treated the same way. Only a session that is +still live after a rejected termination keeps the error, and therefore keeps +the execution or workspace retained. + +Parent issues with sub-issues are not routed to Devin. Parent execution roots, +shared child worktrees, repair/merge, and the orchestrator-owned final +verification all run against a local checkout, so `decide_issue_route` rejects +a parent for any harness whose transport is remote-only; the limitation is +listed in the capability's `feature_gaps`. + +When termination fails, the run manifest persists `CancelFailed` in +`terminal_worker_outcome`. Scheduler recovery restores that outcome before +dispatch, preventing a replacement session. Local finalization or evidence +import failures keep the non-retrying outcome while remote termination remains +unconfirmed. + +Recovery after a daemon restart reattaches instead of creating: the route reads +the Devin session id from the conversation manifest and follows the existing +session, and a session that cannot be looked up settles as a non-retrying +`CancelFailed` rather than launching a second one. When an issue becomes +terminal, workspace cleanup inspects the conversation manifest first, +terminates and archives the remote session, and only then removes the local +binding; if termination fails the workspace is retained so a later cleanup can +retry it. + +Workflow configuration lives under the `devin.api` and `devin.session` +front-matter blocks (see `docs/configuration.md`). Endpoints must be absolute +HTTPS URLs without embedded credentials, query, or fragment; credential values +are never serialized into workflow config, manifests, or logs. + +### Live contract evidence + +`tests/devin_cloud_agent_live.rs` exercises the same client against +`https://api.devin.ai` and is opt-in (`#[ignore]` plus +`OPENSYMPHONY_DEVIN_LIVE=1`) because the lifecycle test creates a real session +and consumes ACUs: + +```bash +OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored --nocapture +``` + +A run on 2026-09-09 against a service-user token confirmed, end to end: +`GET /v3/self` (`principal_type: service_user`, `org-` id), organization-scoped +session listing including the `qs` query document, session create with `repos`, +tags, title, `max_acu_limit` and `resumable`, cursor-paginated message polling, +the `claimed -> running/working` status-detail transitions, operator message +send, attachment listing, and `DELETE ...?archive=true` (the session settled at +`status: exit`, `is_archived: true`, with the `opensymphony:` correlation +tag applied). The normalized event sequence observed was `SessionCreated`, +`UserMessage`, two `StatusChanged`, `DevinMessage`, `SessionBlocked`. + +A second run on 2026-09-09 covered the hardening paths and is what moved the +capability to `available: true`: + +- `devin_live_tenant_binding_and_secret_scope`: `bind_tenant()` records a + `service_user` tenancy; a configured foreign `org-` id is rejected with + `TenantMismatch`; organization secrets list and resolve by id; an unknown + reference fails with `UnknownSecret`. No ACUs. +- `devin_live_session_lifecycle`: create, poll, normalize, message, import + evidence (6 normalized events, manifest and journal written under a temporary + issue workspace), then terminate and archive. +- `devin_live_poll_timeout_terminates_the_remote_session`: an over-budget poll + returns `PollTimeout`, the cleanup path deletes and archives the session, and + a follow-up `GET` confirms `is_archived: true` — the orphaned-session case + that costs money if it regresses. + +Remaining limitations (advertised as capability `feature_gaps`, not blockers): + +- Runtime events arrive by cursor-paginated HTTPS polling; there is no push + stream, so event latency is bounded by `poll_interval_ms`. While an + executing session produces no new message or status the route publishes a + `session.heartbeat` liveness event (every third of the scheduler stall + timeout, at most once a minute) so the stall policy does not abort and + replace a session that is merely busy; heartbeats are scheduler activity, + not journaled evidence. +- There is no mid-run interrupt in the v3 contract: cancellation terminates and + archives the session rather than stopping one turn. +- Pause/resume and approval flows are not exposed by the v3 session contract. +- Model selection is fixed at session creation through `devin_mode`; per-run + model overrides are unavailable. +- TLS certificate pinning is not implemented; transport security relies on the + platform trust store with TLS 1.2+ and redirects disabled. +- Live evidence covers happy-path, waiting-on-operator, and timeout settlement. + Devin-side failure and suspension settlements are covered by fixtures only. + ## ACP live profile matrix [ACP live qualification](acp-live-qualification.md) records the pinned Cursor @@ -109,8 +366,8 @@ tracked-issue outcomes, and optional-feature limits. Both advertise `loadSession` and omit `resumeSession`; profile preflight does not claim either. Cursor alone has a qualified pinned extension registration for observed `cursor/create_plan` and `cursor/update_todos` ID-bearing requests. -Devin requires no custom scheduler path; its before-response session updates -are bound by the common ACP client. The public capabilities response exposes +The Devin ACP profile uses the common ACP scheduler path; its before-response +session updates are bound by the ACP client. The public capabilities response exposes generic adapter support, configured profile readiness, and negotiated run support as distinct states. diff --git a/docs/host-client-architecture.md b/docs/host-client-architecture.md index 5538dc907..5d01900a3 100644 --- a/docs/host-client-architecture.md +++ b/docs/host-client-architecture.md @@ -753,7 +753,7 @@ ModelConfigurationProfile ├─ base_url ├─ model ├─ credential_ref -└─ harnesses: openhands_agent_server | codex_app_server | other +└─ harnesses: openhands_agent_server | codex_app_server | devin_cloud_agent | other ``` The configuration profile records settings that are directly meaningful to the @@ -789,6 +789,38 @@ loopback WebSocket production routing remain future work. See `docs/codex-app-server-harness.md` for the current contract evidence and benchmark procedure. +### 8.5 Devin cloud agent harness shape + +```text +DevinCloudAdapter +├─ transport: https rest, remote only +├─ auth: api key read from a configured environment variable name +├─ workspace: Devin-owned remote workspace, local checkout is evidence only +├─ tenancy: one organization per client, verified by GET /v3/self +├─ entities: session, message, attachment, secret reference, event +├─ streams: cursor-paginated message pages plus session status polling +├─ actions: create session, send message, terminate/archive, list attachments +└─ normalization: Devin event to OpenSymphony event envelope, unknown events + retained as raw JSON +``` + +Unlike the local harnesses, OpenSymphony never owns Devin's execution +directory. `DevinRemoteWorkspaceBinding` maps an issue workspace key and +repository URL onto the remote workspace while the local issue workspace stays +evidence-only, so the workspace manager's local-checkout invariants are not +applied to remote runs. The scheduler still materializes that issue workspace: +run manifests, journals, and the evidence imported from the remote session live +there, under `.opensymphony/devin/`. + +The capability is advertised as available against `devin-api-v3`. Its hosted +posture is organization-bound credentials, secret injection by Devin secret id, +HTTPS-only transport with TLS 1.2+ and redirects disabled, origin- and +size-bounded attachment import, and explicit remote-session termination on every +abandoned path. Cursor polling instead of a push stream, cancellation instead of +a mid-run interrupt, no pause/resume or approvals, fixed per-session model +selection, and the absence of certificate pinning remain advertised feature +gaps. See `docs/harness-adapter-compatibility.md` for the live evidence. + ## 9. Hosted mode architecture ### 9.1 Hosted components diff --git a/docs/workspace-and-lifecycle.md b/docs/workspace-and-lifecycle.md index c2229bc75..39564125e 100644 --- a/docs/workspace-and-lifecycle.md +++ b/docs/workspace-and-lifecycle.md @@ -543,6 +543,11 @@ Use for: Runs before workspace deletion for terminal issues. +Remote-only harnesses use evidence-only workspaces. Their issue manifest records +`evidence_only: true`, and cleanup skips `before_remove` for those workspaces, +including after restart or during generation-bound cleanup. No repository +checkout is present for the hook to operate on. + Use for: - final log or artifact collection diff --git a/packages/gateway-schema/src/model_config.ts b/packages/gateway-schema/src/model_config.ts index d76533996..add21ec86 100644 --- a/packages/gateway-schema/src/model_config.ts +++ b/packages/gateway-schema/src/model_config.ts @@ -16,6 +16,7 @@ export type CredentialStorage = export type ModelHarnessKind = | "openhands_agent_server" | "codex_app_server" + | "devin_cloud_agent" | "rust_native" | (string & {}); diff --git a/packages/state/__tests__/model-profiles.test.ts b/packages/state/__tests__/model-profiles.test.ts index 0ff1834e5..a67954991 100644 --- a/packages/state/__tests__/model-profiles.test.ts +++ b/packages/state/__tests__/model-profiles.test.ts @@ -394,6 +394,32 @@ describe("createModelProfileStore", () => { ])); }); + it("retains the devin_cloud_agent harness kind through a storage round trip", async () => { + const storage = new MemoryStorage(); + const quarantineReasons: string[] = []; + const store = createModelProfileStore({ + storage, + onQuarantine: (reason) => quarantineReasons.push(reason), + }); + const profile = { + ...createModelProfile("api_key"), + id: "devin-profile", + harnesses: ["devin_cloud_agent"], + }; + + await store.storeProfile(profile); + const reloaded = createModelProfileStore({ + storage, + onQuarantine: (reason) => quarantineReasons.push(reason), + }); + const saved = (await reloaded.listProfiles()).find( + (candidate) => candidate.id === "devin-profile", + ); + + expect(saved?.harnesses).toEqual(["devin_cloud_agent"]); + expect(quarantineReasons).toEqual([]); + }); + it("warns and uses defaults when persisted harnesses are not an array", async () => { const storage = new MemoryStorage(); storage.setItem("opensymphony.modelProfiles.v1", JSON.stringify({ diff --git a/packages/state/src/model-profiles.ts b/packages/state/src/model-profiles.ts index f63cd7065..5a51ebfdf 100644 --- a/packages/state/src/model-profiles.ts +++ b/packages/state/src/model-profiles.ts @@ -162,6 +162,7 @@ const authMethods = new Set([ const harnessKinds = new Set([ "openhands_agent_server", "codex_app_server", + "devin_cloud_agent", "rust_native", ]); diff --git a/src/lib.rs b/src/lib.rs index 04f2b78bd..0d7d8fb19 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,8 @@ pub mod opensymphony_code_intel; pub mod opensymphony_codex; #[path = "../crates/opensymphony-control/src/lib.rs"] pub mod opensymphony_control; +#[path = "../crates/opensymphony-devin/src/lib.rs"] +pub mod opensymphony_devin; #[path = "../crates/opensymphony-domain/src/lib.rs"] pub mod opensymphony_domain; #[path = "../crates/opensymphony-gateway/src/lib.rs"] diff --git a/tests/devin_cloud_agent.rs b/tests/devin_cloud_agent.rs new file mode 100644 index 000000000..32e8b42a4 --- /dev/null +++ b/tests/devin_cloud_agent.rs @@ -0,0 +1,1245 @@ +//! Devin cloud harness boundary tests. +//! +//! Request shapes are checked against the vendored Devin API v3 OpenAPI subset +//! in `crates/opensymphony-devin/contracts/devin-v3-openapi.yaml`, and response +//! decoding is exercised with payloads shaped like the documented schemas. +//! Nothing here talks to the live API. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use opensymphony::opensymphony_devin::{ + DEFAULT_DEVIN_HEARTBEAT_INTERVAL, DEVIN_CLOUD_AGENT_KIND, DEVIN_CLOUD_API_CONTRACT, + DEVIN_HEARTBEAT_EVENT_KIND, DEVIN_MAX_OPERATOR_TAGS, DEVIN_MAX_SESSION_TAGS, + DEVIN_REMOTE_CONTAINMENT, DEVIN_UNDISCRIMINATED_EVENT_KIND, DevinClientError, + DevinCloudAdapter, DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, + DevinEvidenceLimits, DevinHttpMethod, DevinMessageCursor, DevinMode, DevinOperation, + DevinProblemDetail, DevinRemoteWorkspaceBinding, DevinRequest, DevinRequestBuilder, + DevinRunOptions, DevinRunOutcome, DevinRunReport, DevinSelfResponse, DevinSessionOptions, + DevinSessionStatus, DevinStatusDetail, DevinStopOutcome, DevinTenancy, + NormalizedDevinEventKind, PaginatedResponse, SecretResponse, SessionAttachment, SessionMessage, + SessionResponse, SessionsQueryParams, attachment_download_url, devin_event_summary, + ensure_session_tenant, evidence_file_name, heartbeat_event, normalize_devin_event, + normalize_session_message, normalized_event_to_journal_record, pull_request_urls_in_text, + reconcile_tenant, rejected_termination_outcome, resolve_secret_references, + select_reconciled_session, session_create_request, session_created_event, +}; +use opensymphony::opensymphony_domain::HarnessAdapter; +use opensymphony::opensymphony_gateway_schema::event_journal::EventKind; +use serde_json::{Value, json}; + +const ORG_ID: &str = "org-abc123"; + +fn binding() -> DevinRemoteWorkspaceBinding { + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + "https://github.com/acme/api", + Some("main".to_owned()), + ) + .expect("binding") +} + +fn builder() -> DevinRequestBuilder { + DevinRequestBuilder::new("https://api.devin.ai", ORG_ID) +} + +fn contract() -> Value { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("crates/opensymphony-devin/contracts/devin-v3-openapi.yaml"); + let text = std::fs::read_to_string(path).expect("vendored devin v3 contract"); + serde_yaml::from_str(&text).expect("contract parses as yaml") +} + +/// Path with the concrete organization and session identifiers replaced by the +/// OpenAPI template variables, and query suffix removed. +fn as_path_template(path: &str) -> String { + path.split('?') + .next() + .unwrap_or(path) + .replace(ORG_ID, "{org_id}") + .replace("devin-1", "{devin_id}") +} + +fn session_json() -> Value { + json!({ + "session_id": "devin-1", + "url": "https://app.devin.ai/sessions/1", + "status": "running", + "status_detail": "working", + "tags": ["opensymphony:acme-123"], + "org_id": ORG_ID, + "created_at": 1_757_000_000, + "updated_at": 1_757_000_600, + "acus_consumed": 1.5, + "pull_requests": [{ "pr_url": "https://github.com/acme/api/pull/7", "pr_state": "open" }], + "structured_output": { "summary": "done" }, + "title": "Fix the flake", + "is_archived": false, + "devin_mode": "fast", + "a_field_devin_added_later": { "nested": true }, + }) +} + +fn session(status: &str, status_detail: Option<&str>) -> SessionResponse { + let mut value = session_json(); + value["status"] = json!(status); + value["status_detail"] = match status_detail { + Some(detail) => json!(detail), + None => Value::Null, + }; + serde_json::from_value(value).expect("session decodes") +} + +fn message_page(messages: &[(&str, &str, &str)], end_cursor: Option<&str>, more: bool) -> Value { + json!({ + "items": messages + .iter() + .map(|(event_id, source, message)| json!({ + "event_id": event_id, + "source": source, + "message": message, + "created_at": 1_757_000_100_i64, + })) + .collect::>(), + "end_cursor": end_cursor, + "has_next_page": more, + "total": messages.len(), + }) +} + +#[test] +fn adapter_exposes_stable_available_remote_capability() { + let adapter = DevinCloudAdapter::default(); + let capability = adapter.capabilities(); + + assert_eq!(adapter.harness_kind(), DEVIN_CLOUD_AGENT_KIND); + assert_eq!(capability.kind, DEVIN_CLOUD_AGENT_KIND); + assert!(capability.available); + assert_eq!( + capability.runtime_contract_version.as_deref(), + Some(DEVIN_CLOUD_API_CONTRACT) + ); + assert_eq!(capability.transport.protocol, "https"); + assert!(capability.transport.remote); + assert!(!capability.transport.local); + assert!(capability.history.preserve_unknown_events); + assert!(capability.event_streams.replay_from_cursor); + assert_eq!(adapter.feature_gaps(), capability.feature_gaps); + assert!( + capability + .feature_gaps + .iter() + .any(|gap| gap.contains("polling")) + ); +} + +#[test] +fn remote_binding_keeps_local_workspace_as_evidence_only() { + let binding = binding(); + + assert_eq!(binding.effective_containment(), DEVIN_REMOTE_CONTAINMENT); + assert_eq!( + binding.local_evidence_path, + PathBuf::from("/workspaces/acme-123") + ); + assert_eq!(binding.repo_name().as_deref(), Some("acme/api")); + assert_eq!(binding.correlation_tag(), "opensymphony:acme-123"); + // The API has no branch field, so branch intent has to reach Devin through + // the prompt. + let prompt = binding.compose_prompt("fix the flake"); + assert!(prompt.contains("Base branch: main")); + assert!(prompt.contains("evidence only")); + assert!(prompt.trim_end().ends_with("fix the flake")); + + for rejected in [ + "https://token@github.com/acme/api", + "git@github.com:acme/api.git", + "https://github.com/acme/api?token=secret", + "https://github.com/acme/api#token=secret", + ] { + assert!( + DevinRemoteWorkspaceBinding::new( + "acme-123", + PathBuf::from("/workspaces/acme-123"), + rejected, + None, + ) + .is_err(), + "{rejected} must be rejected" + ); + } +} + +#[test] +fn config_rejects_insecure_credentialed_or_malformed_settings() { + assert!(DevinCloudConfig::default().validate().is_ok()); + + for invalid in [ + DevinCloudConfig { + base_url: "http://api.devin.ai".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + base_url: "https://user:secret@api.devin.ai".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + base_url: "https://api.devin.ai?token=secret".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + api_key_env: "devin key".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + org_id_env: "devin org".into(), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + org_id: Some("acme".into()), + ..DevinCloudConfig::default() + }, + DevinCloudConfig { + session: DevinSessionOptions { + max_acu_limit: Some(0), + ..DevinSessionOptions::default() + }, + ..DevinCloudConfig::default() + }, + ] { + assert!(invalid.validate().is_err(), "{invalid:?} must be rejected"); + } +} + +#[test] +fn request_paths_match_the_vendored_v3_contract() { + let contract = contract(); + let paths = contract["paths"] + .as_object() + .expect("contract paths") + .keys() + .cloned() + .collect::>(); + let builder = builder(); + + let requests: Vec<(DevinRequest, DevinHttpMethod, DevinOperation)> = vec![ + ( + builder.get_self(), + DevinHttpMethod::Get, + DevinOperation::GetSelf, + ), + ( + builder.create_session(&session_create_request( + "fix the flake", + &binding(), + &DevinSessionOptions::default(), + )), + DevinHttpMethod::Post, + DevinOperation::CreateSession, + ), + ( + builder.get_session("devin-1"), + DevinHttpMethod::Get, + DevinOperation::GetSession, + ), + ( + builder.list_sessions(&SessionsQueryParams::by_tag("opensymphony:acme-123")), + DevinHttpMethod::Get, + DevinOperation::ListSessions, + ), + ( + builder.list_messages("devin-1", Some("cursor-1"), Some(50)), + DevinHttpMethod::Get, + DevinOperation::ListSessionMessages, + ), + ( + builder.send_message("devin-1", "please rerun the tests"), + DevinHttpMethod::Post, + DevinOperation::SendSessionMessage, + ), + ( + builder.delete_session("devin-1", true), + DevinHttpMethod::Delete, + DevinOperation::DeleteSession, + ), + ( + builder.archive_session("devin-1"), + DevinHttpMethod::Post, + DevinOperation::ArchiveSession, + ), + ( + builder.list_attachments("devin-1"), + DevinHttpMethod::Get, + DevinOperation::ListSessionAttachments, + ), + ( + builder.replace_tags("devin-1", vec!["opensymphony:acme-123".into()]), + DevinHttpMethod::Put, + DevinOperation::UpdateSessionTags, + ), + ]; + + for (request, method, operation) in requests { + let template = as_path_template(&request.path); + assert!( + paths.contains(&template), + "`{template}` is not a documented v3 path" + ); + let documented = &contract["paths"][&template][method.as_str().to_lowercase()]; + assert!( + documented.is_object(), + "`{} {template}` is not documented", + method.as_str() + ); + assert_eq!(request.method, method); + assert_eq!(request.operation, operation); + } + + assert_eq!(DEVIN_CLOUD_API_CONTRACT, "devin-api-v3"); +} + +#[test] +fn session_requests_carry_documented_query_and_body_shapes() { + let builder = builder(); + + let messages = builder.list_messages("devin-1", Some("cursor 1"), Some(500)); + assert_eq!( + messages.path, + format!("/v3/organizations/{ORG_ID}/sessions/devin-1/messages?after=cursor%201&first=200") + ); + assert_eq!( + builder + .absolute_url(&messages) + .expect("absolute url") + .as_str(), + format!( + "https://api.devin.ai/v3/organizations/{ORG_ID}/sessions/devin-1/messages?after=cursor%201&first=200" + ) + ); + + let listed = builder.list_sessions(&SessionsQueryParams::by_tag("opensymphony:acme-123")); + let query = listed + .path + .split_once("?qs=") + .map(|(_, encoded)| encoded.to_owned()) + .expect("qs parameter"); + let decoded = percent_decode(&query); + assert_eq!( + serde_json::from_str::(&decoded).expect("qs is json"), + json!({ "tags": ["opensymphony:acme-123"] }) + ); + + let terminate = builder.delete_session("devin-1", false); + assert!(terminate.path.ends_with("?archive=false")); + + assert_eq!( + builder.send_message("devin-1", "rerun").body, + Some(json!({ "message": "rerun" })) + ); + assert_eq!( + builder.replace_tags("devin-1", vec!["a".into()]).body, + Some(json!({ "tags": ["a"] })) + ); +} + +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).expect("hex digits"); + decoded.push(u8::from_str_radix(hex, 16).expect("hex byte")); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).expect("utf-8") +} + +#[test] +fn create_session_request_maps_binding_and_options_onto_v3_fields() { + let options = DevinSessionOptions { + playbook_id: Some("playbook-1".into()), + knowledge_ids: Some(vec!["note-1".into()]), + secret_ids: Some(vec!["secret-1".into()]), + max_acu_limit: Some(20), + tags: vec!["team:core".into()], + title: Some("Fix the flake".into()), + devin_mode: Some(DevinMode::Fast), + platform: Some("windows".into()), + resumable: true, + }; + let request = session_create_request("fix the flake", &binding(), &options); + + assert_eq!(request.repos, Some(vec!["acme/api".to_owned()])); + assert_eq!( + request.tags, + Some(vec![ + "team:core".to_owned(), + "opensymphony:acme-123".to_owned() + ]) + ); + assert_eq!(request.devin_mode, Some(DevinMode::Fast)); + assert_eq!(request.resumable, Some(true)); + assert!( + request + .prompt + .contains("Repository: https://github.com/acme/api") + ); + + // Secret *references* are forwarded; values are never part of the request. + assert_eq!(request.secret_ids, Some(vec!["secret-1".to_owned()])); + + let body = serde_json::to_value(&request).expect("serializes"); + assert!(body.get("structured_output_schema").is_none()); + assert_eq!(body["max_acu_limit"], json!(20)); + + // Operators get at most 49 tags: the 50th slot always carries the + // correlation tag, because it is how an uncertain creation is found again. + let crowded = DevinSessionOptions { + tags: (0..60).map(|index| format!("tag-{index}")).collect(), + ..DevinSessionOptions::default() + }; + let request = session_create_request("fix the flake", &binding(), &crowded); + let tags = request.tags.expect("tags"); + assert_eq!(tags.len(), DEVIN_MAX_SESSION_TAGS); + assert_eq!( + tags.last().map(String::as_str), + Some("opensymphony:acme-123") + ); + assert!( + tags.iter() + .take(DEVIN_MAX_OPERATOR_TAGS) + .all(|tag| tag.starts_with("tag-")) + ); + assert!( + DevinCloudConfig { + session: crowded, + ..DevinCloudConfig::default() + } + .validate() + .is_err(), + "more than {DEVIN_MAX_OPERATOR_TAGS} configured tags must be rejected" + ); + assert!( + DevinCloudConfig { + session: DevinSessionOptions { + tags: (0..DEVIN_MAX_OPERATOR_TAGS) + .map(|index| format!("tag-{index}")) + .collect(), + ..DevinSessionOptions::default() + }, + ..DevinCloudConfig::default() + } + .validate() + .is_ok() + ); +} + +#[test] +fn uncertain_creation_reconciles_only_this_runs_live_session() { + let mut mine = session("working", None); + mine.tags = vec!["opensymphony:acme-123".into()]; + mine.created_at = 1_700_000_100; + let mut newer = mine.clone(); + newer.session_id = "devin-newer".into(); + newer.created_at = 1_700_000_200_000; // milliseconds are normalized + let mut archived = mine.clone(); + archived.session_id = "devin-archived".into(); + archived.is_archived = true; + let mut other_issue = mine.clone(); + other_issue.session_id = "devin-other".into(); + other_issue.tags = vec!["opensymphony:acme-999".into()]; + let mut stale = mine.clone(); + stale.session_id = "devin-stale".into(); + stale.created_at = 1_699_999_000; + + let picked = select_reconciled_session( + vec![stale, archived, other_issue, mine, newer], + "opensymphony:acme-123", + 1_700_000_000, + ) + .expect("the tagged live session is found"); + assert_eq!(picked.session_id, "devin-newer"); + + assert!( + select_reconciled_session(Vec::new(), "opensymphony:acme-123", 1_700_000_000).is_none() + ); +} + +#[test] +fn documented_payloads_decode_and_retain_unknown_fields() { + let session: SessionResponse = serde_json::from_value(session_json()).expect("session decodes"); + + assert_eq!(session.session_id, "devin-1"); + assert_eq!(session.status, DevinSessionStatus::Running); + assert_eq!( + session.status_detail, + Some(DevinStatusDetail("working".into())) + ); + assert_eq!(session.acus_consumed, 1.5); + assert_eq!( + session.pull_requests[0].pr_url, + "https://github.com/acme/api/pull/7" + ); + assert_eq!( + session.extra.get("a_field_devin_added_later"), + Some(&json!({ "nested": true })) + ); + assert!(!session.is_settled()); + + // A status Devin adds later must not fail decoding. + let future: SessionResponse = serde_json::from_value(json!({ + "session_id": "devin-1", + "url": "https://app.devin.ai/sessions/1", + "status": "hibernating", + "org_id": ORG_ID, + "created_at": 1, + "updated_at": 2, + })) + .expect("unknown status decodes"); + assert_eq!( + future.status, + DevinSessionStatus::Other("hibernating".into()) + ); + + let page: PaginatedResponse = serde_json::from_value(message_page( + &[("event-1", "devin", "starting work")], + Some("cursor-1"), + true, + )) + .expect("page decodes"); + assert_eq!(page.items[0].event_id, "event-1"); + assert!(page.has_next_page); + assert_eq!(page.end_cursor.as_deref(), Some("cursor-1")); + + let problem: DevinProblemDetail = serde_json::from_value(json!({ + "title": "Not Found", + "status": 404, + "detail": "session devin-1 does not exist", + })) + .expect("problem decodes"); + assert_eq!( + problem.to_string(), + "Not Found: session devin-1 does not exist" + ); +} + +#[test] +fn message_cursor_deduplicates_replayed_pages_and_tracks_status() { + let mut cursor = DevinMessageCursor::new(); + let first: PaginatedResponse = serde_json::from_value(message_page( + &[ + ("event-1", "devin", "starting work"), + ("event-2", "user", "please rerun"), + ], + Some("cursor-1"), + true, + )) + .expect("page decodes"); + + let events = cursor.ingest_messages("devin-1", &first); + assert_eq!(events.len(), 2); + assert_eq!(events[0].kind, NormalizedDevinEventKind::DevinMessage); + assert_eq!(events[1].kind, NormalizedDevinEventKind::UserMessage); + assert_eq!(cursor.cursor(), Some("cursor-1")); + + // Devin replays the last page plus one new message. + let replayed: PaginatedResponse = serde_json::from_value(message_page( + &[ + ("event-2", "user", "please rerun"), + ("event-3", "devin", "rerunning"), + ], + Some("cursor-2"), + false, + )) + .expect("page decodes"); + let events = cursor.ingest_messages("devin-1", &replayed); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id.as_deref(), Some("event-3")); + assert_eq!(cursor.cursor(), Some("cursor-2")); + + // A source Devin adds later is preserved rather than dropped. + let unknown_source: PaginatedResponse = serde_json::from_value(message_page( + &[("event-4", "system", "vm resized")], + Some("cursor-3"), + false, + )) + .expect("page decodes"); + let events = cursor.ingest_messages("devin-1", &unknown_source); + assert_eq!(events[0].kind, NormalizedDevinEventKind::Unknown); + assert_eq!(events[0].raw["source"], json!("system")); + + assert_eq!( + cursor + .ingest_status(&session("running", Some("working"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::StatusChanged) + ); + assert!( + cursor + .ingest_status(&session("running", Some("working"))) + .is_none(), + "an unchanged status must not re-emit" + ); + assert_eq!( + cursor + .ingest_status(&session("running", Some("waiting_for_user"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionBlocked) + ); + assert_eq!( + cursor + .ingest_status(&session("running", Some("finished"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionFinished) + ); + assert_eq!( + cursor + .ingest_status(&session("error", Some("blocked_error"))) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionFailed) + ); + assert_eq!( + cursor + .ingest_status(&session("suspended", None)) + .map(|event| event.kind), + Some(NormalizedDevinEventKind::SessionSuspended) + ); +} + +#[test] +fn client_requires_credentials_and_a_resolvable_organization() { + let config = DevinCloudConfig { + org_id: Some(ORG_ID.into()), + ..DevinCloudConfig::default() + }; + + assert!(DevinCloudClient::from_environment(&config, |_| None).is_err()); + assert!(DevinCloudClient::from_environment(&config, |_| Some(" ".into())).is_err()); + + let client = DevinCloudClient::from_environment(&config, |name| { + (name == config.api_key_env).then(|| "cog_token".to_owned()) + }) + .expect("client"); + assert_eq!(client.org_id(), Some(ORG_ID)); + assert!(format!("{client:?}").contains("redacted")); + assert!(!format!("{client:?}").contains("cog_token")); + + // The organization can also be supplied beside the credential. + let discovered = + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some(ORG_ID.to_owned()), + _ => None, + }) + .expect("client"); + assert_eq!(discovered.org_id(), Some(ORG_ID)); + + // Without an organization, requests cannot be built until `/v3/self` + // resolves one. + let unresolved = DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| { + (name == "COG_SERVICE_USER_TOKEN").then(|| "cog_token".to_owned()) + }) + .expect("client"); + assert!(unresolved.org_id().is_none()); + assert!(matches!( + unresolved.requests(), + Err(DevinClientError::TenantUnbound) + )); + + // A configured organization is an operator claim, not verified tenancy: + // until `GET /v3/self` binds the credential, no organization-scoped + // request may be built. `identity()` is the only call allowed before that. + assert_eq!(discovered.org_id(), Some(ORG_ID)); + assert!(discovered.tenancy().is_none()); + assert!(matches!( + discovered.requests(), + Err(DevinClientError::TenantUnbound) + )); + let bound = discovered + .bind_verified_tenancy(DevinTenancy { + org_id: ORG_ID.into(), + principal_type: None, + service_user_id: None, + service_user_name: None, + }) + .expect("matching tenancy binds"); + assert!(bound.requests().is_ok()); + let foreign = + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some(ORG_ID.to_owned()), + _ => None, + }) + .expect("client") + .bind_verified_tenancy(DevinTenancy { + org_id: "org-other".into(), + principal_type: None, + service_user_id: None, + service_user_name: None, + }); + assert!(matches!( + foreign, + Err(DevinClientError::TenantMismatch { .. }) + )); + + // A bearer credential is attached to every request, so an invalid endpoint + // must be rejected before the token is read. + let invalid = DevinCloudConfig { + base_url: "http://api.devin.ai".into(), + ..DevinCloudConfig::default() + }; + assert!(DevinCloudClient::from_environment(&invalid, |_| Some("cog_token".into())).is_err()); + + // A malformed organization from the environment is rejected too. + let bad_org = + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some("acme".to_owned()), + _ => None, + }); + assert!(bad_org.is_err()); +} + +#[test] +fn messages_normalize_into_journal_records() { + let session = session("exit", Some("finished")); + let mut cursor = DevinMessageCursor::new(); + let event = cursor.ingest_status(&session).expect("status event"); + + assert_eq!(event.kind, NormalizedDevinEventKind::SessionFinished); + assert_eq!(devin_event_summary(&event), "Devin session finished"); + + let record = normalized_event_to_journal_record("run-9", 7, &event); + assert_eq!(record.kind, EventKind::RunCompleted); + assert_eq!(record.actor.kind_label(), "harness"); + assert_eq!( + record.payload.expect("payload")["session_id"], + json!("devin-1") + ); + assert_eq!(record.raw_payload_ref.as_deref(), Some("devin:run-9:7")); +} + +#[test] +fn undecodable_payloads_are_preserved_as_unknown() { + let raw = json!({ + "source": "devin", + "session_id": "devin-1", + "payload": { "nested": [1, 2, 3] }, + }); + let event = normalize_devin_event(raw.clone()); + assert_eq!(event.kind, NormalizedDevinEventKind::DevinMessage); + assert_eq!(event.raw, raw); + + let undiscriminated = json!({ "session_id": "devin-1", "details": { "nested": true } }); + let event = normalize_devin_event(undiscriminated.clone()); + assert_eq!(event.kind, NormalizedDevinEventKind::Unknown); + assert_eq!(event.event_type, DEVIN_UNDISCRIMINATED_EVENT_KIND); + assert_eq!(event.raw, undiscriminated); + + let record = normalized_event_to_journal_record("run-9", 3, &event); + assert_eq!( + record.kind, + EventKind::Unknown { + raw_kind: DEVIN_UNDISCRIMINATED_EVENT_KIND.to_owned(), + } + ); + assert_eq!( + record.payload.expect("payload")["raw_payload"], + undiscriminated + ); +} + +fn secret(secret_id: &str, key: Option<&str>) -> SecretResponse { + serde_json::from_value(json!({ + "secret_id": secret_id, + "key": key, + "access_type": "organization", + "secret_type": "env", + })) + .expect("secret decodes") +} + +fn attachment(name: &str, url: &str) -> SessionAttachment { + serde_json::from_value(json!({ + "attachment_id": "att-1", + "name": name, + "url": url, + "source": "devin", + "content_type": "text/plain", + })) + .expect("attachment decodes") +} + +fn client() -> DevinCloudClient { + DevinCloudClient::from_environment(&DevinCloudConfig::default(), |name| match name { + "COG_SERVICE_USER_TOKEN" => Some("cog_token".to_owned()), + "DEVIN_ORG_ID" => Some(ORG_ID.to_owned()), + _ => None, + }) + .expect("client") +} + +#[test] +fn tenancy_binding_rejects_a_credential_from_another_organization() { + let identity = |org: &str| -> DevinSelfResponse { + serde_json::from_value(json!({ + "principal_type": "service_user", + "org_id": org, + "service_user_id": "su-1", + "service_user_name": "opensymphony", + })) + .expect("identity decodes") + }; + + // A configured organization the credential does not own is refused instead + // of silently driving sessions in the credential's tenant. + let mismatch = reconcile_tenant(Some(ORG_ID), identity("org-other")); + assert!(matches!( + mismatch, + Err(DevinClientError::TenantMismatch { + ref configured, + ref credential, + }) if configured == ORG_ID && credential == "org-other" + )); + + // Without a configured organization the credential's own tenant is adopted. + let adopted = reconcile_tenant(None, identity(ORG_ID)).expect("tenancy"); + assert_eq!(adopted.org_id, ORG_ID); + assert_eq!(adopted.service_user_name.as_deref(), Some("opensymphony")); + + // A credential without an organization cannot scope any request. + let anonymous: DevinSelfResponse = serde_json::from_value(json!({})).expect("identity"); + assert!(matches!( + reconcile_tenant(None, anonymous), + Err(DevinClientError::MissingOrgId) + )); +} + +#[test] +fn cross_tenant_session_payloads_are_rejected() { + let mut session = session("working", None); + assert!(ensure_session_tenant(ORG_ID, &session).is_ok()); + + // A session id from another tenant must not become run state, even when the + // API returns it for a request scoped to this organization. + session.org_id = "org-other".to_owned(); + assert!(matches!( + ensure_session_tenant(ORG_ID, &session), + Err(DevinClientError::CrossTenantPayload { + ref expected, + ref observed, + }) if expected == ORG_ID && observed == "org-other" + )); +} + +#[test] +fn secret_references_resolve_to_organization_owned_ids_only() { + let available = vec![ + secret("secret-1", Some("GITHUB_TOKEN")), + secret("secret-2", Some("NPM_TOKEN")), + ]; + + // Ids and keys both resolve, and repeats collapse to one injected id. + let resolved = resolve_secret_references( + ORG_ID, + &available, + &[ + "secret-1".to_owned(), + "NPM_TOKEN".to_owned(), + "GITHUB_TOKEN".to_owned(), + ], + ) + .expect("resolved"); + assert_eq!(resolved, vec!["secret-1", "secret-2"]); + + // Anything the bound organization does not own is refused, so a workflow + // cannot name another tenant's secret. + let unknown = + resolve_secret_references(ORG_ID, &available, &["secret-from-other-org".to_owned()]); + assert!(matches!( + unknown, + Err(DevinClientError::UnknownSecret { ref reference, ref org_id }) + if reference == "secret-from-other-org" && org_id == ORG_ID + )); +} + +#[test] +fn attachment_downloads_are_restricted_to_the_authenticated_api_origin() { + let base = "https://api.devin.ai"; + let allowed = attachment("log.txt", "https://api.devin.ai/v3/attachments/att-1"); + assert_eq!( + attachment_download_url(base, &allowed) + .expect("url") + .as_str(), + "https://api.devin.ai/v3/attachments/att-1" + ); + // An explicit default port is the same origin. + assert!( + attachment_download_url( + base, + &attachment("log.txt", "https://api.devin.ai:443/v3/attachments/att-1") + ) + .is_ok() + ); + + // A bearer-authenticated download must never leave the API origin, and the + // origin includes the effective port: `api.devin.ai:8443` is another + // service on the same host. + for foreign in [ + "https://evil.example.com/att-1", + "http://api.devin.ai/v3/attachments/att-1", + "https://api.devin.ai:8443/v3/attachments/att-1", + ] { + assert!(matches!( + attachment_download_url(base, &attachment("log.txt", foreign)), + Err(DevinClientError::ForeignAttachmentOrigin { .. }) + )); + } + + assert!(matches!( + attachment_download_url(base, &attachment("log.txt", "not-a-url")), + Err(DevinClientError::InvalidUrl { .. }) + )); +} + +#[test] +fn remote_attachment_names_stay_inside_the_evidence_directory() { + let traversal = attachment( + "../../etc/passwd", + "https://api.devin.ai/v3/attachments/att-1", + ); + let name = evidence_file_name(0, &traversal); + assert!(!name.contains('/')); + assert!(!name.contains("..")); + assert_eq!( + Path::new("/evidence/attachments").join(&name).parent(), + Some(Path::new("/evidence/attachments")) + ); + + let long = attachment( + &"a".repeat(400), + "https://api.devin.ai/v3/attachments/att-1", + ); + assert!(evidence_file_name(3, &long).len() <= 128); + assert!(evidence_file_name(3, &long).starts_with("003-")); +} + +fn finished_report(session: &SessionResponse) -> DevinRunReport { + DevinRunReport { + session_id: session.session_id.clone(), + session_url: session.url.clone(), + outcome: DevinRunOutcome::Finished, + status: session.status.clone(), + status_detail: session.status_detail.clone(), + acus_consumed: 0.0, + pull_requests: Vec::new(), + structured_output: None, + attachments: Vec::new(), + attachment_listing_error: None, + } +} + +#[cfg(unix)] +#[tokio::test] +async fn evidence_writes_refuse_symlinked_directories() { + let root = tempfile::tempdir().expect("tempdir"); + let outside = tempfile::tempdir().expect("outside"); + // `.opensymphony/devin` points outside the workspace, as an imported + // artifact or a hostile checkout could arrange. + std::fs::create_dir_all(root.path().join(".opensymphony")).expect("metadata dir"); + std::os::unix::fs::symlink(outside.path(), root.path().join(".opensymphony/devin")) + .expect("symlink"); + let evidence_root = root.path().join(".opensymphony/devin/run-1"); + let mut collector = DevinEvidenceCollector::new( + &evidence_root, + DevinEvidenceLimits { + download_attachments: false, + ..DevinEvidenceLimits::default() + }, + ); + let session = session("exit", Some("finished")); + collector.record(&session_created_event(&session)); + + let error = collector + .persist(&client(), &finished_report(&session)) + .await + .expect_err("a symlinked evidence directory must be refused"); + assert!( + matches!(error, DevinClientError::Evidence { .. }), + "{error}" + ); + assert!( + std::fs::read_dir(outside.path()) + .expect("outside dir") + .next() + .is_none(), + "nothing may be written through the symlink" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn evidence_writes_do_not_follow_symlinked_output_files() { + let root = tempfile::tempdir().expect("tempdir"); + let target = root.path().join("victim.txt"); + std::fs::write(&target, "untouched").expect("victim"); + let evidence_root = root.path().join(".opensymphony/devin/run-1"); + std::fs::create_dir_all(&evidence_root).expect("evidence root"); + std::os::unix::fs::symlink(&target, evidence_root.join("events.jsonl")).expect("symlink"); + let mut collector = DevinEvidenceCollector::new( + &evidence_root, + DevinEvidenceLimits { + download_attachments: false, + ..DevinEvidenceLimits::default() + }, + ); + let session = session("exit", Some("finished")); + collector.record(&session_created_event(&session)); + + let error = collector + .persist(&client(), &finished_report(&session)) + .await + .expect_err("a symlinked evidence file must be refused"); + assert!( + matches!(error, DevinClientError::Evidence { .. }), + "{error}" + ); + assert_eq!( + std::fs::read_to_string(&target).expect("victim still readable"), + "untouched", + "the symlink target must not be written through" + ); +} + +#[tokio::test] +async fn evidence_persists_remote_run_artifacts_into_the_local_workspace() { + let root = tempfile::tempdir().expect("tempdir"); + let evidence_root = root.path().join(".opensymphony/devin/run-1"); + let mut collector = DevinEvidenceCollector::new( + &evidence_root, + DevinEvidenceLimits { + download_attachments: false, + ..DevinEvidenceLimits::default() + }, + ); + + let session = session("exit", Some("finished")); + collector.record(&session_created_event(&session)); + collector.record(&normalize_devin_event( + json!({ "session_id": "devin-1", "unrecognized": true }), + )); + let message = |event_id: &str, source: &str, text: &str| { + normalize_session_message( + &session.session_id, + &serde_json::from_value::(json!({ + "event_id": event_id, + "source": source, + "message": text, + "created_at": 1, + })) + .expect("session message"), + ) + }; + // The API-reported PR is not repeated; a PR Devin only mentioned in a + // message is surfaced separately; operator text is never mined. + collector.record(&message( + "event-1", + "devin", + "Opened https://github.com/acme/api/pull/7 and https://github.com/acme/api/pull/8.", + )); + collector.record(&message( + "event-2", + "user", + "Please look at https://github.com/acme/api/pull/9", + )); + + let report = DevinRunReport { + session_id: session.session_id.clone(), + session_url: session.url.clone(), + outcome: DevinRunOutcome::Finished, + status: session.status.clone(), + status_detail: session.status_detail.clone(), + acus_consumed: 1.5, + pull_requests: vec![ + serde_json::from_value(json!({ + "pr_url": "https://github.com/acme/api/pull/7", + "pr_state": "open", + })) + .expect("pull request"), + ], + structured_output: Some(json!({ "summary": "done" })), + attachments: vec![attachment( + "log.txt", + "https://api.devin.ai/v3/attachments/att-1", + )], + attachment_listing_error: None, + }; + + let manifest = collector + .persist(&client(), &report) + .await + .expect("persist"); + + assert_eq!(manifest.event_count, 4); + assert_eq!(manifest.containment, DEVIN_REMOTE_CONTAINMENT); + assert_eq!( + manifest.pull_request_urls, + vec!["https://github.com/acme/api/pull/7"] + ); + assert_eq!( + manifest.message_pull_request_urls, + vec!["https://github.com/acme/api/pull/8"] + ); + let on_disk: Value = serde_json::from_str( + &std::fs::read_to_string(evidence_root.join("evidence.json")).expect("evidence.json"), + ) + .expect("evidence json"); + assert_eq!( + on_disk["message_pull_request_urls"], + json!(["https://github.com/acme/api/pull/8"]) + ); + assert_eq!(manifest.acus_consumed, 1.5); + // Downloads were disabled, so the attachment is reported as skipped rather + // than silently dropped. + assert!(manifest.attachments.is_empty()); + assert_eq!(manifest.skipped_attachments.len(), 1); + + let journal = std::fs::read_to_string(evidence_root.join("events.jsonl")).expect("journal"); + assert_eq!(journal.lines().count(), 4); + // Unknown payloads survive the round trip into local evidence. + assert!(journal.contains("unrecognized")); + + let summary: Value = serde_json::from_str( + &std::fs::read_to_string(evidence_root.join("session.json")).expect("session file"), + ) + .expect("summary"); + assert_eq!(summary["structured_output"]["summary"], json!("done")); + + let written: Value = serde_json::from_str( + &std::fs::read_to_string(evidence_root.join("evidence.json")).expect("manifest file"), + ) + .expect("manifest"); + assert_eq!(written["session_id"], json!("devin-1")); + // Everything Devin returned stays under the orchestrator-owned root. + assert!(evidence_root.starts_with(root.path())); +} + +#[test] +fn idle_suspension_is_treated_as_waiting_on_operator() { + let idle = session("suspended", Some("inactivity")); + assert!(idle.is_idle_suspended()); + assert!(!idle.is_waiting_on_operator()); + assert!( + idle.status_detail + .as_ref() + .is_some_and(DevinStatusDetail::is_inactivity) + ); + + let out_of_quota = session("suspended", Some("out_of_quota")); + assert!(!out_of_quota.is_idle_suspended()); + + let waiting = session("running", Some("waiting_for_user")); + assert!(waiting.is_waiting_on_operator()); + assert!(!waiting.is_idle_suspended()); + assert!(!session("running", Some("inactivity")).is_idle_suspended()); +} + +#[test] +fn rejected_termination_counts_as_stopped_only_for_terminal_sessions() { + assert_eq!( + rejected_termination_outcome(&session("exit", None)), + Some(DevinStopOutcome::AlreadyStopped) + ); + assert_eq!( + rejected_termination_outcome(&session("suspended", Some("inactivity"))), + Some(DevinStopOutcome::AlreadyStopped) + ); + assert_eq!( + rejected_termination_outcome(&session("error", Some("error"))), + Some(DevinStopOutcome::AlreadyStopped) + ); + assert_eq!( + rejected_termination_outcome(&session("running", Some("working"))), + None + ); + assert_eq!(rejected_termination_outcome(&session("new", None)), None); +} + +#[test] +fn quiet_executing_sessions_publish_heartbeats_outside_the_evidence_journal() { + // Only a session that is still doing work is a liveness candidate; settled + // or operator-parked sessions settle the run instead. + assert!(session("running", Some("working")).is_executing()); + assert!(session("claimed", None).is_executing()); + assert!(!session("running", Some("finished")).is_executing()); + assert!(!session("running", Some("waiting_for_user")).is_executing()); + assert!(!session("suspended", Some("inactivity")).is_executing()); + assert!(!session("exit", None).is_executing()); + + let event = heartbeat_event(&session("running", Some("working"))); + assert_eq!(event.kind, NormalizedDevinEventKind::SessionHeartbeat); + assert_eq!(event.event_type, DEVIN_HEARTBEAT_EVENT_KIND); + assert_eq!(event.session_id.as_deref(), Some("devin-1")); + assert_eq!(event.status.as_deref(), Some("running:working")); + assert_eq!(event.raw["acus_consumed"], json!(1.5)); + assert_eq!(event.raw["status_detail"], json!("working")); + assert_eq!( + devin_event_summary(&event), + "Devin session still executing (running:working)" + ); + let record = normalized_event_to_journal_record("run-1", 3, &event); + assert_eq!( + record.kind, + EventKind::HarnessEventNormalized { + source_kind: DEVIN_HEARTBEAT_EVENT_KIND.to_owned(), + } + ); + + // Heartbeats are orchestrator observations, not remote events. + let temp = tempfile::tempdir().expect("tempdir"); + let mut collector = + DevinEvidenceCollector::new(temp.path().join("evidence"), DevinEvidenceLimits::default()); + collector.record(&session_created_event(&session("running", Some("working")))); + collector.record(&event); + assert_eq!(collector.event_count(), 1); + + assert_eq!( + DevinRunOptions::default().heartbeat_interval, + Some(DEFAULT_DEVIN_HEARTBEAT_INTERVAL) + ); +} + +#[test] +fn archived_exit_sessions_decode_as_closed_rather_than_finished() { + let mut value = session_json(); + value["status"] = json!("exit"); + value["status_detail"] = Value::Null; + value["is_archived"] = json!(true); + let closed: SessionResponse = serde_json::from_value(value).expect("session decodes"); + assert!(closed.is_archived); + assert!(closed.status.is_terminal()); + assert!(!closed.is_waiting_on_operator()); +} + +#[test] +fn pull_request_urls_are_extracted_from_message_text_only_when_well_formed() { + let text = "PR is open: https://github.com/acme/api/pull/52.\n\ + See (https://gitlab.com/acme/api/-/merge_requests/3) and \ + ; \ + not https://github.com/acme/api/pull/abc, not https://github.com/acme/api/issues/5, \ + not http://github.com/acme/api/pull/6, not github.com/acme/api/pull/7"; + assert_eq!( + pull_request_urls_in_text(text), + vec![ + "https://github.com/acme/api/pull/52", + "https://gitlab.com/acme/api/-/merge_requests/3", + ] + ); + assert!(pull_request_urls_in_text("no links here").is_empty()); +} diff --git a/tests/devin_cloud_agent_live.rs b/tests/devin_cloud_agent_live.rs new file mode 100644 index 000000000..9ef7186b3 --- /dev/null +++ b/tests/devin_cloud_agent_live.rs @@ -0,0 +1,292 @@ +//! Live Devin API v3 verification. +//! +//! These tests talk to `https://api.devin.ai` with a real service-user token and +//! are opt-in: they are `#[ignore]`d and additionally skip themselves unless +//! `OPENSYMPHONY_DEVIN_LIVE=1` is set along with the credential environment +//! variables. `devin_live_session_lifecycle` creates a real session and +//! therefore consumes ACUs; it terminates and archives the session it creates. +//! +//! ```bash +//! OPENSYMPHONY_DEVIN_LIVE=1 cargo test --test devin_cloud_agent_live -- --ignored --nocapture +//! ``` + +use std::path::PathBuf; +use std::time::Duration; + +use opensymphony::opensymphony_devin::{ + DevinClientError, DevinCloudClient, DevinCloudConfig, DevinEvidenceCollector, + DevinEvidenceLimits, DevinRemoteWorkspaceBinding, DevinRunOptions, DevinRunOutcome, + DevinSessionOptions, DevinSessionRunner, SessionsQueryParams, devin_event_summary, + session_create_request, +}; + +const LIVE_ENV: &str = "OPENSYMPHONY_DEVIN_LIVE"; + +fn live_config() -> Option { + if std::env::var(LIVE_ENV).ok().as_deref() != Some("1") { + eprintln!("skipping: set {LIVE_ENV}=1 to run live Devin API tests"); + return None; + } + Some(DevinCloudConfig::default()) +} + +async fn live_client(config: &DevinCloudConfig) -> DevinCloudClient { + DevinCloudClient::from_environment(config, |name| std::env::var(name).ok()) + .expect("credentials from the environment") + .bind_tenant() + .await + .expect("organization resolves") +} + +/// Verifies the tenancy binding and organization-scoped secret resolution +/// against the live API. No session is created, so this consumes no ACUs. +#[tokio::test] +#[ignore = "live Devin API call"] +async fn devin_live_tenant_binding_and_secret_scope() { + let Some(config) = live_config() else { return }; + let client = live_client(&config).await; + + let tenancy = client.tenancy().expect("bind_tenant records the tenancy"); + assert_eq!(Some(tenancy.org_id.as_str()), client.org_id()); + assert!(tenancy.org_id.starts_with("org-")); + assert_eq!(tenancy.principal_type.as_deref(), Some("service_user")); + + // A credential bound to one organization must not be usable with another. + let mismatched = DevinCloudConfig { + org_id: Some("org-00000000000000000000000000000000".to_owned()), + ..config.clone() + }; + let error = DevinCloudClient::from_environment(&mismatched, |name| std::env::var(name).ok()) + .expect("credentials from the environment") + .bind_tenant() + .await + .expect_err("a foreign organization must be rejected"); + assert!( + matches!(error, DevinClientError::TenantMismatch { .. }), + "unexpected error: {error}" + ); + + let secrets = client.list_secrets().await.expect("GET .../secrets"); + println!("organization exposes {} secret(s)", secrets.len()); + let unknown = client + .resolve_secret_ids(&["secret-does-not-exist".to_owned()]) + .await + .expect_err("unknown secret references must be rejected"); + assert!( + matches!(unknown, DevinClientError::UnknownSecret { .. }), + "unexpected error: {unknown}" + ); + if let Some(secret) = secrets.first() { + let resolved = client + .resolve_secret_ids(std::slice::from_ref(&secret.secret_id)) + .await + .expect("an organization-owned secret id resolves"); + assert_eq!(resolved, vec![secret.secret_id.clone()]); + } +} + +#[tokio::test] +#[ignore = "live Devin API call"] +async fn devin_live_identity_and_session_listing() { + let Some(config) = live_config() else { return }; + let client = live_client(&config).await; + + let identity = client.identity().await.expect("GET /v3/self"); + assert_eq!(identity.principal_type.as_deref(), Some("service_user")); + let org_id = identity.org_id.as_deref().expect("org_id from /v3/self"); + assert!(org_id.starts_with("org-"), "unexpected org id shape"); + assert_eq!(client.org_id(), Some(org_id)); + + // `qs` carries the session query as a JSON document. + let sessions = client + .list_sessions(&SessionsQueryParams::default()) + .await + .expect("GET /v3/organizations/{org_id}/sessions"); + for session in &sessions.items { + assert_eq!(session.org_id, org_id); + assert!(!session.session_id.is_empty()); + assert!(session.url.starts_with("https://")); + } + println!( + "listed {} session(s); has_next_page={}", + sessions.items.len(), + sessions.has_next_page + ); +} + +/// Creates a real session, polls it through the normalization path, sends an +/// operator message, then terminates and archives it. This consumes ACUs. +#[tokio::test] +#[ignore = "live Devin API call that consumes ACUs"] +async fn devin_live_session_lifecycle() { + let Some(mut config) = live_config() else { + return; + }; + config.poll_interval = Duration::from_secs(5); + config.session = DevinSessionOptions { + max_acu_limit: Some(2), + title: Some("OpenSymphony harness live contract check".into()), + tags: vec!["opensymphony-live-check".into()], + resumable: false, + ..DevinSessionOptions::default() + }; + + let client = live_client(&config).await; + let runner = DevinSessionRunner::new( + client, + DevinRunOptions { + stop_when_waiting_on_operator: true, + max_duration: Some(Duration::from_secs(240)), + collect_attachments: true, + message_page_size: 100, + ..DevinRunOptions::default() + }, + ); + + let binding = DevinRemoteWorkspaceBinding::new( + "opensymphony-live-check", + PathBuf::from("/tmp/opensymphony-live-check"), + "https://github.com/kumanday/OpenSymphony", + Some("main".to_owned()), + ) + .expect("binding"); + let request = session_create_request( + "Reply with the single word ACK and stop. Do not read the repository, \ + do not run commands, do not make changes, and do not open a pull request.", + &binding, + &config.session, + ); + + let mut events = Vec::new(); + let report = runner + .run(&request, |event| { + println!("[{:?}] {}", event.kind, devin_event_summary(&event)); + events.push(event); + }) + .await; + + let report = match report { + Ok(report) => report, + Err(error) => panic!("live session run failed: {error}"), + }; + + println!( + "session {} outcome={:?} status={} acus={}", + report.session_id, + report.outcome, + report.status.as_str(), + report.acus_consumed + ); + assert!(!events.is_empty(), "no normalized events were observed"); + assert!(matches!( + report.outcome, + DevinRunOutcome::Finished | DevinRunOutcome::WaitingOnOperator + )); + + runner + .send_message(&report.session_id, "Thanks, that is all.") + .await + .expect("POST .../messages"); + + // Evidence import is the artifact-retrieval path the CLI route runs: the + // manifest and journal must land inside the local issue workspace only. + let workspace = tempfile::tempdir().expect("evidence workspace"); + let evidence_root = workspace.path().join(".opensymphony/devin/live-check"); + let mut collector = + DevinEvidenceCollector::new(evidence_root.clone(), DevinEvidenceLimits::default()); + for event in &events { + collector.record(event); + } + let manifest = collector + .persist(runner.client(), &report) + .await + .expect("evidence import"); + assert_eq!(manifest.session_id, report.session_id); + assert!(manifest.event_count > 0); + assert!(evidence_root.join("evidence.json").exists()); + assert!(evidence_root.join("events.jsonl").exists()); + assert!(manifest.evidence_root.starts_with(workspace.path())); + println!( + "imported evidence: {} event(s), {} attachment(s), {} skipped", + manifest.event_count, + manifest.attachments.len(), + manifest.skipped_attachments.len() + ); + + let terminated = runner + .terminate(&report.session_id, true) + .await + .expect("DELETE .../sessions/{devin_id}?archive=true"); + assert_eq!(terminated.outcome, DevinRunOutcome::Terminated); +} + +/// A session that outlives the poll budget must be stopped, not orphaned. This +/// mirrors the CLI route's timeout cleanup and consumes ACUs. +#[tokio::test] +#[ignore = "live Devin API call that consumes ACUs"] +async fn devin_live_poll_timeout_terminates_the_remote_session() { + let Some(mut config) = live_config() else { + return; + }; + config.poll_interval = Duration::from_secs(3); + config.session = DevinSessionOptions { + max_acu_limit: Some(1), + title: Some("OpenSymphony harness timeout cleanup check".into()), + tags: vec!["opensymphony-live-check".into()], + resumable: false, + ..DevinSessionOptions::default() + }; + + let client = live_client(&config).await; + let runner = DevinSessionRunner::new( + client.clone(), + DevinRunOptions { + stop_when_waiting_on_operator: false, + max_duration: Some(Duration::from_secs(10)), + collect_attachments: false, + message_page_size: 100, + ..DevinRunOptions::default() + }, + ); + + let binding = DevinRemoteWorkspaceBinding::new( + "opensymphony-live-timeout", + PathBuf::from("/tmp/opensymphony-live-timeout"), + "https://github.com/kumanday/OpenSymphony", + Some("main".to_owned()), + ) + .expect("binding"); + let request = session_create_request( + "Wait quietly and do nothing. Do not read the repository, do not run \ + commands, and do not open a pull request.", + &binding, + &config.session, + ); + + let created = client + .create_session(&request) + .await + .expect("POST .../sessions"); + let error = runner + .follow(&created.session_id, |_| {}) + .await + .expect_err("the poll budget must expire"); + assert!( + matches!(error, DevinClientError::PollTimeout { .. }), + "unexpected error: {error}" + ); + + client + .delete_session(&created.session_id, true) + .await + .expect("timed-out sessions are terminated and archived"); + let stopped = client + .get_session(&created.session_id) + .await + .expect("GET .../sessions/{devin_id}"); + assert!( + stopped.is_archived, + "session {} stayed active after cleanup", + stopped.session_id + ); +}