mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
feat(runtime-api): native-client routes for jobs, context, secrets, git, diagnostics, targets, LSP, and voice
GPUI's terminals, usage panel, settings, review, and diagnostics modules
named HTTP contracts that did not exist beyond the workspace file/artifact
routes landed in 1758b78a68. This slice implements them against existing
Core authorities rather than standing up parallel backends.
Jobs: GET/POST /v1/threads/{id}/jobs, per-job status, non-consuming
cursor-based output reads (stream/cursor/max_bytes/wait_ms/format, with
exact dropped-byte accounting and stale-job tail snapshots), stdin writes,
and kill, plus a flat GET /v1/jobs. Each thread now owns one shared
ShellManager handed to its engine on load, so the API and the model see
one job set and background work survives engine LRU eviction; the entry
drops with the thread. API-created jobs inherit the same sandbox posture
projection a turn applies.
Commands: GET /v1/commands projects the live command registry (aliases,
usage, localized description, discovery flags, argument requirements) at
the server's resolved locale.
Context: GET /v1/threads/{id}/context answers via a new engine Op
(GetContextBudget, with a wire-op twin) so the live estimate, billed
input tokens, route window, ceilings, and pressure come from the engine
itself; live:false with static route info when the engine is unavailable.
Secrets: PUT /v1/providers/{id}/key is write-only — the credential write
path moved to codewhale-config (same transactional store+metadata logic
the CLI uses), the response carries backend/configPath/credentialState
metadata only, and the in-memory config mirrors the persisted auth marker
so readiness readback reflects the write immediately. 4 KiB key cap,
~5 KiB body limit, no echo of key or length anywhere.
Git: GET /v1/git, /v1/changes, /v1/diff, /v1/workspace/diff,
/v1/git/graph, and POST /v1/git/{stage,unstage,discard,commit,push,branch}
over the existing hardened git wrapper and workspace confinement;
bounded outputs, refreshed status in every mutation response.
Diagnostics: GET /v1/logs{,/{name}}, /v1/crashes{,/{name}} (both the
.codewhale and legacy .deepseek crash roots), and /v1/process — bounded,
basename-validated reads; no telemetry upload surface.
Targets/remote: GET /v1/targets reports this runtime as its own sole
target; POST /v1/targets{,/switch} 501 (client-owned registry).
GET /v1/remote reports bind posture; POST /v1/remote/connect probes a
candidate's unauthenticated /v1/runtime/info, refuses URLs with
credentials, and never forwards tokens. GET /v1/ssh and /v1/cloud answer
supported:false owned by the control plane; their POSTs 501.
LSP: GET /v1/lsp plus /v1/diagnostics, /v1/definition, /v1/references,
/v1/symbols over one lazily-built API-owned LspManager confined to the
server workspace; normal no-server/timeout states return 200 with
ok:false and a machine-readable reason.
Voice: GET /v1/voice capability plus POST /v1/voice/{dictate,send,control}
delegating to the existing TUI record-to-ASR pipeline through a headless
dictate_once (same whisper/Groq/provider dispatch, same send-suffix and
voice-control behavior, serialized mic access). CODEWHALE_DISABLE_VOICE
is an operator kill-switch that makes every voice surface fail closed.
docs/RUNTIME_API.md now documents these families with their actual
shapes, limits, status codes, and ownership boundaries.
Gates: cargo fmt --all -- --check clean; runtime_api::tests under
scripts/with-hermetic-test-home.sh + scripts/dev-cargo.sh: 250 passed,
0 failed; codewhale-config lib tests 704 passed 0 failed; codewhale-cli
lib tests 390 passed 0 failed. No provider calls, no remote actions.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1758b78a68
commit
060711b2f1
25 files changed
+4789
-150
No files matched your search
+13
-139
@@ -21,6 +21,9 @@ use codewhale_app_server::daemon_socket::{DaemonSocketOptions, run_daemon_socket
|
||||
use codewhale_app_server::{
|
||||
AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
|
||||
};
|
||||
use codewhale_config::credentials::{
|
||||
clear_provider_api_key_from_config, provider_slot, set_provider_api_key,
|
||||
};
|
||||
use codewhale_config::route::{ProvidersExport, parse_route_kind};
|
||||
use codewhale_config::{
|
||||
CliRuntimeOverrides, ConfigApiKeyValueKind, ConfigStore, ConfigToml, ProviderKind,
|
||||
@@ -2619,40 +2622,6 @@ fn clear_account_session(profile: Option<&str>) -> Result<(), String> {
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Map [`ProviderKind`] to the canonical provider credential slot.
|
||||
fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
// Shared-account families (SiliconFlow China, the four Model Studio
|
||||
// variants) collapse onto one slot; see ProviderKind::secret_store_slot.
|
||||
provider.secret_store_slot()
|
||||
}
|
||||
|
||||
/// Resolve the store for credential-adjacent writes: provider selection,
|
||||
/// `auth_mode` markers, and the plaintext-free metadata that accompanies a
|
||||
/// saved key.
|
||||
///
|
||||
/// Credentials and their metadata are user-global — a key saved while
|
||||
/// working in one repo must be visible from every other repo, and the secret
|
||||
/// store already is (#5045). When the ambient config path is a
|
||||
/// workspace-scoped document (`<repo>/.codewhale/config.toml`), login and
|
||||
/// `auth set` must not bind the provider or write auth markers there: the
|
||||
/// binding would be invisible from every other repo and would invite
|
||||
/// plaintext keys into a committable repo file (#5198). Returns a store
|
||||
/// loaded on the user-global document in that case, or `None` when the
|
||||
/// ambient store is already correctly scoped, so key + provider binding +
|
||||
/// auth markers share one user-global scope by default.
|
||||
fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
|
||||
if !codewhale_config::config_path_is_workspace_scoped(store.path()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let global = codewhale_config::default_config_path()?;
|
||||
eprintln!(
|
||||
"ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
|
||||
codewhale_config::quote_os_path(store.path()),
|
||||
codewhale_config::quote_os_path(&global),
|
||||
);
|
||||
ConfigStore::load(Some(global)).map(Some)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn no_keyring_secrets() -> Secrets {
|
||||
Secrets::new(std::sync::Arc::new(
|
||||
@@ -2660,102 +2629,6 @@ fn no_keyring_secrets() -> Secrets {
|
||||
))
|
||||
}
|
||||
|
||||
fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
|
||||
store.config.auth_mode = Some("api_key".to_string());
|
||||
let provider_config = store.config.providers.for_provider_mut(provider);
|
||||
provider_config.auth_mode = Some("api_key".to_string());
|
||||
provider_config.external_credentials = None;
|
||||
if provider == ProviderKind::Xai {
|
||||
provider_config.oauth_credential_generation = None;
|
||||
}
|
||||
if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
|
||||
store.config.default_text_model = Some(
|
||||
store
|
||||
.config
|
||||
.providers
|
||||
.deepseek
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-v4-pro".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a provider credential to the durable secret store without silently
|
||||
/// downgrading a backend failure to plaintext config storage.
|
||||
fn persist_provider_api_key(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
provider: ProviderKind,
|
||||
api_key: &str,
|
||||
) -> Result<bool> {
|
||||
if provider == ProviderKind::Xai {
|
||||
return codewhale_config::with_xai_oauth_revocation_transaction(|| {
|
||||
persist_provider_api_key_unlocked(store, secrets, provider, api_key)
|
||||
});
|
||||
}
|
||||
persist_provider_api_key_unlocked(store, secrets, provider, api_key)
|
||||
}
|
||||
|
||||
fn persist_provider_api_key_unlocked(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
provider: ProviderKind,
|
||||
api_key: &str,
|
||||
) -> Result<bool> {
|
||||
let original_config = store.config.clone();
|
||||
prepare_provider_api_key_metadata(store, provider);
|
||||
let slot = provider_slot(provider);
|
||||
// A readable prior value is required before a secret-store write so a
|
||||
// later config failure can restore the exact prior state. If the backend
|
||||
// cannot provide that snapshot, fail before changing the config file.
|
||||
let prior_secret = secrets.get(slot);
|
||||
let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
|
||||
Ok(_) => match secrets.set(slot, api_key) {
|
||||
Ok(()) => {
|
||||
clear_provider_api_key_from_config(store, provider);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
store.config = original_config;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
|
||||
codewhale_config::quote_os_path(store.path())
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
store.config = original_config;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
|
||||
codewhale_config::quote_os_path(store.path())
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) = store.save() {
|
||||
store.config = original_config;
|
||||
if secret_store_saved {
|
||||
let current = secrets
|
||||
.get(slot)
|
||||
.map_err(|rollback| anyhow::anyhow!(
|
||||
"{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
|
||||
))?;
|
||||
if current.as_deref() == Some(api_key) {
|
||||
match prior_secret.expect("snapshot succeeded before secret write") {
|
||||
Some(previous) => secrets.set(slot, &previous),
|
||||
None => secrets.delete(slot),
|
||||
}
|
||||
.map_err(|rollback| anyhow::anyhow!(
|
||||
"{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
|
||||
))?;
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
codewhale_config::scrub_plaintext_api_keys_from_config_backup(store.path())?;
|
||||
Ok(secret_store_saved)
|
||||
}
|
||||
|
||||
fn clear_auth_provider(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
@@ -2852,13 +2725,6 @@ fn clear_legacy_antigravity_config(store: &mut ConfigStore, secrets: &Secrets) -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
|
||||
store.config.providers.for_provider_mut(provider).api_key = None;
|
||||
if provider == ProviderKind::Deepseek {
|
||||
store.config.api_key = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_env_set(provider: ProviderKind) -> bool {
|
||||
provider_env_value(provider).is_some()
|
||||
}
|
||||
@@ -4362,9 +4228,17 @@ fn run_auth_command_with_secrets_and_runtime(
|
||||
(None, true) => read_api_key_from_stdin()?,
|
||||
(None, false) => prompt_api_key(slot)?,
|
||||
};
|
||||
let mut credential_store = credential_metadata_store(store)?;
|
||||
let mut credential_store =
|
||||
codewhale_config::credentials::credential_metadata_store(store)?;
|
||||
if let Some(redirected) = credential_store.as_ref() {
|
||||
eprintln!(
|
||||
"ambient config {} is workspace-scoped; writing credential metadata to the user-global {} instead",
|
||||
codewhale_config::quote_os_path(store.path()),
|
||||
codewhale_config::quote_os_path(redirected.path()),
|
||||
);
|
||||
}
|
||||
let store = credential_store.as_mut().unwrap_or(store);
|
||||
let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?;
|
||||
let secret_store_saved = set_provider_api_key(store, secrets, provider, &api_key)?;
|
||||
// Don't print the key. Don't echo length.
|
||||
if secret_store_saved {
|
||||
println!(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Canonical provider-credential writes shared by the CLI (`auth set`),
|
||||
//! the runtime API secret route, and any future host. Owning this here keeps
|
||||
//! every writer on the same transactional discipline: snapshot the prior
|
||||
//! secret, write the durable backend, refuse plaintext config fallback, and
|
||||
//! roll both stores back if either leg fails.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::provider_kind::ProviderKind;
|
||||
use crate::{ConfigStore, Secrets};
|
||||
|
||||
/// Resolve the store for credential-adjacent writes: provider selection,
|
||||
/// `auth_mode` markers, and the plaintext-free metadata that accompanies a
|
||||
/// saved key.
|
||||
///
|
||||
/// Credentials and their metadata are user-global — a key saved while
|
||||
/// working in one repo must be visible from every other repo, and the secret
|
||||
/// store already is. When the ambient config path is a workspace-scoped
|
||||
/// document (`<repo>/.codewhale/config.toml`), credential writes must not
|
||||
/// bind the provider or write auth markers there: the binding would be
|
||||
/// invisible from every other repo and would invite plaintext keys into a
|
||||
/// committable repo file. Returns a store loaded on the user-global document
|
||||
/// in that case, or `None` when the ambient store is already correctly
|
||||
/// scoped, so key + provider binding + auth markers share one user-global
|
||||
/// scope by default.
|
||||
pub fn credential_metadata_store(store: &ConfigStore) -> Result<Option<ConfigStore>> {
|
||||
if !crate::config_path_is_workspace_scoped(store.path()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let global = crate::default_config_path()?;
|
||||
ConfigStore::load(Some(global)).map(Some)
|
||||
}
|
||||
|
||||
/// The secret-store slot a provider's key occupies. Shared-account families
|
||||
/// (SiliconFlow China, the Model Studio variants) collapse onto one slot;
|
||||
/// see [`ProviderKind::secret_store_slot`].
|
||||
#[must_use]
|
||||
pub fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
provider.secret_store_slot()
|
||||
}
|
||||
|
||||
/// Remove any plaintext `api_key` left in the config for `provider`.
|
||||
pub fn clear_provider_api_key_from_config(store: &mut ConfigStore, provider: ProviderKind) {
|
||||
store.config.providers.for_provider_mut(provider).api_key = None;
|
||||
if provider == ProviderKind::Deepseek {
|
||||
store.config.api_key = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Plaintext-free metadata that accompanies a saved key.
|
||||
pub fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
|
||||
store.config.auth_mode = Some("api_key".to_string());
|
||||
let provider_config = store.config.providers.for_provider_mut(provider);
|
||||
provider_config.auth_mode = Some("api_key".to_string());
|
||||
provider_config.external_credentials = None;
|
||||
if provider == ProviderKind::Xai {
|
||||
provider_config.oauth_credential_generation = None;
|
||||
}
|
||||
if provider == ProviderKind::Deepseek && store.config.default_text_model.is_none() {
|
||||
store.config.default_text_model = Some(
|
||||
store
|
||||
.config
|
||||
.providers
|
||||
.deepseek
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "deepseek-v4-pro".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a provider credential to the durable secret store without silently
|
||||
/// downgrading a backend failure to plaintext config storage.
|
||||
///
|
||||
/// Returns `true` when the key landed in the secret store (config then holds
|
||||
/// metadata only). Callers must not print or echo `api_key`.
|
||||
pub fn set_provider_api_key(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
provider: ProviderKind,
|
||||
api_key: &str,
|
||||
) -> Result<bool> {
|
||||
if provider == ProviderKind::Xai {
|
||||
return crate::with_xai_oauth_revocation_transaction(|| {
|
||||
set_provider_api_key_unlocked(store, secrets, provider, api_key)
|
||||
});
|
||||
}
|
||||
set_provider_api_key_unlocked(store, secrets, provider, api_key)
|
||||
}
|
||||
|
||||
fn set_provider_api_key_unlocked(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
provider: ProviderKind,
|
||||
api_key: &str,
|
||||
) -> Result<bool> {
|
||||
let original_config = store.config.clone();
|
||||
prepare_provider_api_key_metadata(store, provider);
|
||||
let slot = provider_slot(provider);
|
||||
// A readable prior value is required before a secret-store write so a
|
||||
// later config failure can restore the exact prior state. If the backend
|
||||
// cannot provide that snapshot, fail before changing the config file.
|
||||
let prior_secret = secrets.get(slot);
|
||||
let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
|
||||
Ok(_) => match secrets.set(slot, api_key) {
|
||||
Ok(()) => {
|
||||
clear_provider_api_key_from_config(store, provider);
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
store.config = original_config;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
|
||||
crate::quote_os_path(store.path())
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
store.config = original_config;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
|
||||
crate::quote_os_path(store.path())
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) = store.save() {
|
||||
store.config = original_config;
|
||||
if secret_store_saved {
|
||||
let current = secrets
|
||||
.get(slot)
|
||||
.map_err(|rollback| anyhow::anyhow!(
|
||||
"{error}; additionally could not verify secret-store rollback for {slot}: {rollback}"
|
||||
))?;
|
||||
if current.as_deref() == Some(api_key) {
|
||||
match prior_secret.expect("snapshot succeeded before secret write") {
|
||||
Some(previous) => secrets.set(slot, &previous),
|
||||
None => secrets.delete(slot),
|
||||
}
|
||||
.map_err(|rollback| anyhow::anyhow!(
|
||||
"{error}; additionally failed to restore prior secret-store state for {slot}: {rollback}"
|
||||
))?;
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
crate::scrub_plaintext_api_keys_from_config_backup(store.path())
|
||||
.context("failed to scrub plaintext API keys from config backup")?;
|
||||
Ok(secret_store_saved)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod auto_model;
|
||||
pub mod catalog;
|
||||
pub mod cloud_facts;
|
||||
mod config_document;
|
||||
pub mod credentials;
|
||||
pub mod descriptors;
|
||||
pub mod device_code;
|
||||
pub mod external_credentials;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! What is deliberately stripped at this boundary:
|
||||
//!
|
||||
//! - `mpsc` / `oneshot` reply channels (`GetSubAgentSettlement`, `GetSessionSnapshot`,
|
||||
//! `GetProviderRuntimeStatus`, `BootstrapMcp`, `RetryMcpServer`,
|
||||
//! `GetContextBudget`, `GetProviderRuntimeStatus`, `BootstrapMcp`, `RetryMcpServer`,
|
||||
//! `ReloadMcp`). Over the wire the reply is an `EventMsg` or a response
|
||||
//! frame, not a channel.
|
||||
//! - `Arc<HookExecutor>` on `SendMessage`: hooks are host configuration, not
|
||||
@@ -388,6 +388,9 @@ pub enum Op {
|
||||
|
||||
/// Request a session snapshot; the reply travels out-of-band.
|
||||
GetSessionSnapshot,
|
||||
/// Request the live context-window budget for the session's route; the
|
||||
/// reply travels out-of-band.
|
||||
GetContextBudget,
|
||||
/// Request provider concurrency state; the reply travels out-of-band.
|
||||
GetProviderRuntimeStatus,
|
||||
/// Populate the engine-owned MCP pool once at boot; reply out-of-band.
|
||||
@@ -494,6 +497,7 @@ impl Op {
|
||||
Self::CompactContext { .. } => "compact_context",
|
||||
Self::CancelCompaction { .. } => "cancel_compaction",
|
||||
Self::GetSessionSnapshot => "get_session_snapshot",
|
||||
Self::GetContextBudget => "get_context_budget",
|
||||
Self::GetProviderRuntimeStatus => "get_provider_runtime_status",
|
||||
Self::BootstrapMcp => "bootstrap_mcp",
|
||||
Self::RetryMcpServer { .. } => "retry_mcp_server",
|
||||
@@ -675,6 +679,7 @@ mod tests {
|
||||
},
|
||||
Op::CancelCompaction { id: "cmp-1".into() },
|
||||
Op::GetSessionSnapshot,
|
||||
Op::GetContextBudget,
|
||||
Op::GetProviderRuntimeStatus,
|
||||
Op::BootstrapMcp,
|
||||
Op::RetryMcpServer { name: "fs".into() },
|
||||
|
||||
@@ -108,6 +108,12 @@ struct Recorder {
|
||||
}
|
||||
|
||||
fn detect_recorder() -> Option<Recorder> {
|
||||
// Operator kill-switch: a headless `serve --http` host has no business
|
||||
// opening a microphone; disabling voice here makes `GET /v1/voice`
|
||||
// report `available: false` and every dictate call fail closed.
|
||||
if std::env::var_os("CODEWHALE_DISABLE_VOICE").is_some() {
|
||||
return None;
|
||||
}
|
||||
let candidates: &[Recorder] = if cfg!(target_os = "macos") {
|
||||
&[
|
||||
Recorder {
|
||||
@@ -194,7 +200,7 @@ fn encode_wav(samples: &[i16]) -> Vec<u8> {
|
||||
// --- Recording -------------------------------------------------------------
|
||||
|
||||
/// Maximum recording duration in seconds before auto-stopping.
|
||||
const MAX_RECORD_SECS: u64 = 10;
|
||||
pub const MAX_RECORD_SECS: u64 = 10;
|
||||
/// Minimum segment duration in seconds to consider as valid speech.
|
||||
const MIN_SEGMENT_SECS: f64 = 0.3;
|
||||
|
||||
@@ -285,6 +291,10 @@ fn record_audio() -> Option<(Vec<i16>, Duration)> {
|
||||
|
||||
// --- Auto-send suffix ------------------------------------------------------
|
||||
|
||||
/// Trailing phrases that mean "submit this" — the human-readable form of
|
||||
/// `SEND_SUFFIX_RE`; keep in sync with the regex when either changes.
|
||||
pub const SEND_PHRASES: &[&str] = &["send it", "发送", "發送"];
|
||||
|
||||
/// Matches an explicit send instruction at the end of transcribed text:
|
||||
/// "send it" (any spacing/case) or 发送/發送, with trailing punctuation.
|
||||
static SEND_SUFFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
@@ -731,6 +741,179 @@ pub async fn capture_and_transcribe(
|
||||
Ok(VoiceCaptureOutcome::Insert(clean.to_string()))
|
||||
}
|
||||
|
||||
// --- Headless capture (HTTP/native-client path) ----------------------------
|
||||
|
||||
/// What a headless dictation should do with the finished transcript.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DictateMode {
|
||||
/// Transcribe and return the text for insertion into the composer.
|
||||
Insert,
|
||||
/// Transcribe, then apply the "send it" / 发送 suffix contract. The
|
||||
/// outcome's `send` flag tells the client to submit; a bare send
|
||||
/// instruction yields empty `text` so the client submits its own draft.
|
||||
Send,
|
||||
/// AI-assisted dictation that sees the client's composer text — the
|
||||
/// `/voice-control` pipeline. Only provider ASR can see context; free
|
||||
/// ASR kinds degrade to plain transcription with `assisted: false`.
|
||||
Control(String),
|
||||
}
|
||||
|
||||
/// Machine-readable failure for the headless path so HTTP clients can
|
||||
/// localize by `reason` rather than parsing message text.
|
||||
#[derive(Debug)]
|
||||
pub enum DictateError {
|
||||
/// No supported recorder binary on this host.
|
||||
NoRecorder,
|
||||
/// Recording produced no usable speech segment.
|
||||
NoSpeech,
|
||||
/// The selected/fallback ASR needs a provider key that isn't configured.
|
||||
NoProviderAuth,
|
||||
/// ASR request or transcription failed.
|
||||
Transcription(String),
|
||||
}
|
||||
|
||||
impl DictateError {
|
||||
pub fn reason(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NoRecorder => "no_recorder",
|
||||
Self::NoSpeech => "no_speech",
|
||||
Self::NoProviderAuth => "no_provider_auth",
|
||||
Self::Transcription(_) => "transcription_failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DictateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoRecorder => write!(f, "no supported voice recorder on this host"),
|
||||
Self::NoSpeech => write!(f, "no speech detected"),
|
||||
Self::NoProviderAuth => {
|
||||
write!(f, "provider ASR requires a configured API key")
|
||||
}
|
||||
Self::Transcription(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of one headless record→transcribe cycle.
|
||||
#[derive(Debug)]
|
||||
pub struct DictationOutcome {
|
||||
/// Final transcript (send suffix already stripped for `Send` mode).
|
||||
pub text: String,
|
||||
/// `Send` mode only: the transcript ended with an explicit send phrase.
|
||||
pub send: bool,
|
||||
/// `Control` mode only: the composer context reached the model. False
|
||||
/// when a free ASR kind handled the audio and never saw the context.
|
||||
pub assisted: bool,
|
||||
/// Which ASR backend was selected for this capture.
|
||||
pub asr_kind: String,
|
||||
pub asr_model: String,
|
||||
}
|
||||
|
||||
/// Detected recorder binary name, if any (`"sox"`, `"arecord"`, `"rec"`).
|
||||
pub fn recorder_command() -> Option<&'static str> {
|
||||
detect_recorder().map(|r| r.cmd)
|
||||
}
|
||||
|
||||
/// Resolved ASR selection (`kind`, `model`) for capability reporting.
|
||||
pub fn asr_choice(config: &Config) -> (String, String) {
|
||||
resolve_asr_choice(config)
|
||||
}
|
||||
|
||||
/// One record→transcribe cycle with no UI surface: the HTTP/native-client
|
||||
/// equivalent of [`capture_and_transcribe`]. Recording runs on a blocking
|
||||
/// thread; transcription follows the same ASR dispatch as the TUI —
|
||||
/// explicit `CODEWHALE_ASR_MODEL` > local whisper > Groq > provider —
|
||||
/// but resolves the provider key lazily so free ASR kinds work without
|
||||
/// provider auth.
|
||||
pub async fn dictate_once(
|
||||
config: &Config,
|
||||
mode: DictateMode,
|
||||
) -> Result<DictationOutcome, DictateError> {
|
||||
if !is_available() {
|
||||
return Err(DictateError::NoRecorder);
|
||||
}
|
||||
let (samples, _duration) = tokio::task::spawn_blocking(record_audio)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or(DictateError::NoSpeech)?;
|
||||
|
||||
let (asr_kind, asr_model) = resolve_asr_choice(config);
|
||||
let base_url = config.deepseek_base_url();
|
||||
let openrouter_vendor = config
|
||||
.openrouter_vendor()
|
||||
.map_err(|e| DictateError::Transcription(e.to_string()))?;
|
||||
let provider_key = || {
|
||||
config
|
||||
.deepseek_api_key()
|
||||
.map_err(|_| DictateError::NoProviderAuth)
|
||||
};
|
||||
|
||||
let mut assisted = false;
|
||||
let text = match asr_kind.as_str() {
|
||||
"local-whisper" => match transcribe_local_whisper(&samples).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => transcribe(
|
||||
&provider_key()?,
|
||||
&base_url,
|
||||
&samples,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
},
|
||||
"groq" => match transcribe_groq(&samples).await {
|
||||
Ok(v) => v,
|
||||
Err(_) => transcribe(
|
||||
&provider_key()?,
|
||||
&base_url,
|
||||
&samples,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
},
|
||||
_ => {
|
||||
let api_key = provider_key()?;
|
||||
match &mode {
|
||||
DictateMode::Control(composer) => {
|
||||
assisted = true;
|
||||
process_voice_control(
|
||||
&api_key,
|
||||
&base_url,
|
||||
&samples,
|
||||
composer,
|
||||
openrouter_vendor.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?
|
||||
}
|
||||
_ => transcribe(&api_key, &base_url, &samples, openrouter_vendor.as_deref())
|
||||
.await
|
||||
.map_err(DictateError::Transcription)?,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let clean = text.trim().to_string();
|
||||
let (text, send) = match mode {
|
||||
DictateMode::Send => {
|
||||
let (remainder, wants_send) = split_send_suffix(&clean);
|
||||
(remainder.to_string(), wants_send)
|
||||
}
|
||||
_ => (clean, false),
|
||||
};
|
||||
Ok(DictationOutcome {
|
||||
text,
|
||||
send,
|
||||
assisted,
|
||||
asr_kind,
|
||||
asr_model,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Command handlers ------------------------------------------------------
|
||||
|
||||
/// Handle the `/voice` command: toggle voice input. Toggling on requests a
|
||||
|
||||
@@ -11842,12 +11842,12 @@ fn plaintext_credential_fallback_refused(
|
||||
/// isolated `CODEWHALE_HOME` and an explicit backend, so unit tests can never
|
||||
/// touch the developer's real credential store.
|
||||
#[cfg(not(test))]
|
||||
fn credential_secret_store() -> Option<codewhale_secrets::Secrets> {
|
||||
pub(crate) fn credential_secret_store() -> Option<codewhale_secrets::Secrets> {
|
||||
Some(codewhale_secrets::Secrets::auto_detect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn credential_secret_store() -> Option<codewhale_secrets::Secrets> {
|
||||
pub(crate) fn credential_secret_store() -> Option<codewhale_secrets::Secrets> {
|
||||
let isolated_home = codewhale_paths::codewhale_home_is_explicit();
|
||||
let explicit_backend = std::env::var_os("CODEWHALE_SECRET_BACKEND")
|
||||
.or_else(|| std::env::var_os("DEEPSEEK_SECRET_BACKEND"))
|
||||
|
||||
@@ -79,8 +79,8 @@ use super::authority::{
|
||||
};
|
||||
use super::events::{Event, TurnOutcomeStatus, TurnRoute};
|
||||
use super::ops::{
|
||||
McpManagerUpdate, Op, ProviderRuntimeStatus, SessionSnapshot, USER_SHELL_TOOL_ID_PREFIX,
|
||||
UserInputProvenance,
|
||||
McpManagerUpdate, Op, ProviderRuntimeStatus, SessionContextBudget, SessionSnapshot,
|
||||
USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance,
|
||||
};
|
||||
use super::session::Session;
|
||||
use super::tool_parser;
|
||||
@@ -3160,6 +3160,35 @@ impl Engine {
|
||||
let _ = tx.send(snapshot);
|
||||
}
|
||||
}
|
||||
Op::GetContextBudget { tx } => {
|
||||
let input_tokens = self.estimated_input_tokens() as u64;
|
||||
let budget = route_context_budget_for_route(
|
||||
self.api_provider,
|
||||
&self.session.model,
|
||||
self.active_route_limits,
|
||||
usize::try_from(input_tokens).unwrap_or(usize::MAX),
|
||||
);
|
||||
let snapshot = budget.map(|budget| SessionContextBudget {
|
||||
window_tokens: budget.window_tokens,
|
||||
input_tokens,
|
||||
billed_input_tokens: self
|
||||
.session
|
||||
.latest_parent_input_tokens
|
||||
.map(u64::from),
|
||||
output_cap_tokens: budget.output_cap_tokens,
|
||||
input_budget_ceiling: budget.input_budget_ceiling,
|
||||
available_input_tokens: budget.available_input_tokens,
|
||||
compaction_trigger_tokens: budget.compaction_trigger_tokens,
|
||||
usage_percent: budget.usage_percent(),
|
||||
pressure: budget.pressure.label(),
|
||||
model: self.session.model.clone(),
|
||||
provider: self.api_provider.as_str().to_string(),
|
||||
model_provider_id: self.api_provider_id.clone(),
|
||||
});
|
||||
if let Some(tx) = tx.lock().ok().and_then(|mut g| g.take()) {
|
||||
let _ = tx.send(snapshot);
|
||||
}
|
||||
}
|
||||
Op::GetProviderRuntimeStatus { tx } => {
|
||||
let status = if let Some(client) = self.deepseek_client.as_ref() {
|
||||
ProviderRuntimeStatus {
|
||||
|
||||
@@ -414,6 +414,20 @@ impl EngineHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Request the live context-window budget for this session's route.
|
||||
/// `None` means the route cannot express a bounded window (e.g. an
|
||||
/// unknown model with no catalog or configured limits) — callers should
|
||||
/// surface "unavailable" rather than inventing a number.
|
||||
pub async fn get_context_budget(
|
||||
&self,
|
||||
) -> Result<Option<crate::core::ops::SessionContextBudget>> {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx)));
|
||||
self.send(Op::GetContextBudget { tx }).await?;
|
||||
rx.await
|
||||
.map_err(|_| anyhow::anyhow!("Engine dropped context budget oneshot"))
|
||||
}
|
||||
|
||||
/// Request a snapshot of the current session state.
|
||||
/// Returns the snapshot directly via a oneshot channel, avoiding
|
||||
/// competition with the SSE event stream on the mpsc receiver.
|
||||
|
||||
@@ -32,6 +32,41 @@ pub struct SessionSnapshot {
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
/// Live context-window posture for one thread, computed where the session
|
||||
/// state actually lives. Returned by `Op::GetContextBudget` via a oneshot
|
||||
/// channel so HTTP clients (GPUI usage panel) never re-derive the engine's
|
||||
/// token math or route limits at the API boundary.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionContextBudget {
|
||||
/// Total context window for the active route (input + output), in tokens.
|
||||
pub window_tokens: u64,
|
||||
/// Estimated input tokens on the same basis the visible context meter
|
||||
/// uses (`estimate_input_tokens_conservative`, including its safety
|
||||
/// inflation). This is the number a "context filling up" indicator shows.
|
||||
pub input_tokens: u64,
|
||||
/// Provider-billed prompt tokens from the most recent parent-route
|
||||
/// request that still describes the live message list. `None` when no
|
||||
/// provider count exists yet (fresh session) — never a fabricated zero.
|
||||
pub billed_input_tokens: Option<u64>,
|
||||
/// Output tokens reserved for the turn after route clamps.
|
||||
pub output_cap_tokens: u64,
|
||||
/// Spendable input ceiling (`window - output_cap - headroom`, intersected
|
||||
/// with any provider-published hard input limit).
|
||||
pub input_budget_ceiling: u64,
|
||||
/// Input tokens still available before the reserved boundary.
|
||||
pub available_input_tokens: u64,
|
||||
/// Input level at which compaction is suggested.
|
||||
pub compaction_trigger_tokens: u64,
|
||||
/// `input_tokens / window_tokens` as a percentage (0..=100).
|
||||
pub usage_percent: f64,
|
||||
/// Coarse pressure label (`low`/`moderate`/`high`/`critical`).
|
||||
pub pressure: &'static str,
|
||||
/// Route identity the budget was computed for.
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
pub model_provider_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Provider request runtime state surfaced by `/provider`.
|
||||
/// Returned by `Op::GetProviderRuntimeStatus` via a oneshot channel.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -336,6 +371,15 @@ pub enum Op {
|
||||
tx: std::sync::Arc<std::sync::Mutex<Option<tokio::sync::oneshot::Sender<SessionSnapshot>>>>,
|
||||
},
|
||||
|
||||
/// Get the live context-window budget for this session's route. Computed
|
||||
/// on the engine so `active_route_limits`, the memoized token estimate,
|
||||
/// and the last billed prompt size all come from one authority.
|
||||
GetContextBudget {
|
||||
tx: std::sync::Arc<
|
||||
std::sync::Mutex<Option<tokio::sync::oneshot::Sender<Option<SessionContextBudget>>>>,
|
||||
>,
|
||||
},
|
||||
|
||||
/// Get active provider request concurrency state for readiness surfaces.
|
||||
GetProviderRuntimeStatus {
|
||||
tx: std::sync::Arc<
|
||||
|
||||
@@ -1157,6 +1157,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op {
|
||||
Op::CancelCompaction { id } => wire_op::Op::CancelCompaction { id: id.clone() },
|
||||
// Reply channels never cross the wire: the answer is a frame.
|
||||
Op::GetSessionSnapshot { tx: _ } => wire_op::Op::GetSessionSnapshot,
|
||||
Op::GetContextBudget { tx: _ } => wire_op::Op::GetContextBudget,
|
||||
Op::GetProviderRuntimeStatus { tx: _ } => wire_op::Op::GetProviderRuntimeStatus,
|
||||
Op::BootstrapMcp { tx: _ } => wire_op::Op::BootstrapMcp,
|
||||
Op::RetryMcpServer { name, tx: _ } => wire_op::Op::RetryMcpServer { name: name.clone() },
|
||||
@@ -1555,6 +1556,9 @@ mod tests {
|
||||
Op::GetSessionSnapshot {
|
||||
tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
},
|
||||
Op::GetContextBudget {
|
||||
tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
},
|
||||
Op::GetProviderRuntimeStatus {
|
||||
tx: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
},
|
||||
|
||||
@@ -93,9 +93,18 @@ use codewhale_protocol::fleet::{
|
||||
};
|
||||
|
||||
mod auth;
|
||||
mod commands;
|
||||
mod context;
|
||||
mod diagnostics;
|
||||
mod git;
|
||||
mod jobs;
|
||||
mod lsp;
|
||||
mod mobile;
|
||||
mod plugins;
|
||||
mod secrets;
|
||||
mod sessions;
|
||||
mod targets;
|
||||
mod voice;
|
||||
mod web;
|
||||
mod workspace;
|
||||
#[cfg(test)]
|
||||
@@ -193,6 +202,11 @@ pub struct RuntimeApiState {
|
||||
/// lazily-initialized slot; slow per-pool work (connect_all) runs under
|
||||
/// the inner handle so it cannot block slot reads.
|
||||
mcp_pool: Arc<Mutex<Option<Arc<Mutex<McpPool>>>>>,
|
||||
/// Workspace-level LSP client for the HTTP surface (APPS-93): diagnostics
|
||||
/// and semantic queries on files a client views. Engines keep their own
|
||||
/// per-thread managers; this one serves the file view and is built lazily
|
||||
/// so a server without LSP use never spawns a language server.
|
||||
lsp_manager: Arc<std::sync::OnceLock<Arc<crate::lsp::LspManager>>>,
|
||||
#[cfg(test)]
|
||||
compat_stream_test_hook: Option<tokio::sync::mpsc::UnboundedSender<CompatStreamTestPoint>>,
|
||||
}
|
||||
@@ -946,6 +960,7 @@ pub async fn run_http_server(
|
||||
web,
|
||||
fleet_codewhale_binary: configured_codewhale_binary(),
|
||||
mcp_pool: Arc::new(Mutex::new(None)),
|
||||
lsp_manager: Arc::new(std::sync::OnceLock::new()),
|
||||
#[cfg(test)]
|
||||
compat_stream_test_hook: None,
|
||||
};
|
||||
@@ -1077,6 +1092,7 @@ fn fallback_sessions_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
diagnostics::mark_server_started();
|
||||
let api_routes = Router::new()
|
||||
.route(
|
||||
"/v1/sessions",
|
||||
@@ -1159,9 +1175,71 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES,
|
||||
)),
|
||||
)
|
||||
.route("/v1/commands", get(commands::list_commands))
|
||||
.route("/v1/git", get(git::git_status_detail))
|
||||
.route("/v1/changes", get(git::git_changes))
|
||||
.route("/v1/diff", get(git::git_diff))
|
||||
.route("/v1/workspace/diff", get(git::workspace_diff))
|
||||
.route("/v1/git/graph", get(git::git_graph))
|
||||
.route("/v1/git/stage", post(git::git_stage))
|
||||
.route("/v1/git/unstage", post(git::git_unstage))
|
||||
.route("/v1/git/discard", post(git::git_discard))
|
||||
.route("/v1/git/commit", post(git::git_commit))
|
||||
.route("/v1/git/push", post(git::git_push))
|
||||
.route("/v1/git/branch", post(git::git_branch))
|
||||
.route("/v1/logs", get(diagnostics::list_logs))
|
||||
.route("/v1/logs/{name}", get(diagnostics::read_log))
|
||||
.route("/v1/crashes", get(diagnostics::list_crashes))
|
||||
.route("/v1/crashes/{name}", get(diagnostics::read_crash))
|
||||
.route("/v1/process", get(diagnostics::process_info))
|
||||
.route("/v1/jobs", get(jobs::list_jobs))
|
||||
.route("/v1/threads", get(list_threads).post(create_thread))
|
||||
.route("/v1/threads/summary", get(list_threads_summary))
|
||||
.route("/v1/threads/{id}", get(get_thread).patch(update_thread))
|
||||
.route(
|
||||
"/v1/threads/{id}/jobs",
|
||||
get(jobs::list_thread_jobs).post(jobs::create_thread_job),
|
||||
)
|
||||
.route("/v1/threads/{id}/jobs/{job_id}", get(jobs::get_thread_job))
|
||||
.route(
|
||||
"/v1/threads/{id}/jobs/{job_id}/output",
|
||||
get(jobs::get_thread_job_output),
|
||||
)
|
||||
.route(
|
||||
"/v1/threads/{id}/jobs/{job_id}/stdin",
|
||||
post(jobs::write_thread_job_stdin),
|
||||
)
|
||||
.route(
|
||||
"/v1/threads/{id}/jobs/{job_id}/kill",
|
||||
post(jobs::kill_thread_job),
|
||||
)
|
||||
.route("/v1/threads/{id}/context", get(context::get_thread_context))
|
||||
.route(
|
||||
"/v1/targets",
|
||||
get(targets::list_targets).post(targets::create_target),
|
||||
)
|
||||
.route("/v1/targets/switch", post(targets::switch_target))
|
||||
.route("/v1/remote", get(targets::remote_status))
|
||||
.route("/v1/remote/connect", post(targets::remote_connect))
|
||||
.route(
|
||||
"/v1/ssh",
|
||||
get(targets::ssh_status).post(targets::ssh_connect),
|
||||
)
|
||||
.route("/v1/ssh/connect", post(targets::ssh_connect))
|
||||
.route(
|
||||
"/v1/cloud",
|
||||
get(targets::cloud_status).post(targets::cloud_attach),
|
||||
)
|
||||
.route("/v1/cloud/attach", post(targets::cloud_attach))
|
||||
.route("/v1/lsp", get(lsp::lsp_status))
|
||||
.route("/v1/diagnostics", get(lsp::lsp_diagnostics))
|
||||
.route("/v1/definition", get(lsp::lsp_definition))
|
||||
.route("/v1/references", get(lsp::lsp_references))
|
||||
.route("/v1/symbols", get(lsp::lsp_symbols))
|
||||
.route("/v1/voice", get(voice::voice_status))
|
||||
.route("/v1/voice/dictate", post(voice::voice_dictate))
|
||||
.route("/v1/voice/send", post(voice::voice_send))
|
||||
.route("/v1/voice/control", post(voice::voice_control))
|
||||
.route("/v1/threads/{id}/resume", post(resume_thread))
|
||||
.route("/v1/threads/{id}/fork", post(fork_thread))
|
||||
.route("/v1/threads/{id}/undo", post(undo_thread_turn))
|
||||
@@ -1324,6 +1402,12 @@ pub fn build_router(state: RuntimeApiState) -> Router {
|
||||
.route("/v1/providers", get(list_providers))
|
||||
.route("/v1/providers/{id}/models", get(list_provider_models))
|
||||
.route("/v1/providers/{id}/switch", post(switch_provider))
|
||||
.route(
|
||||
"/v1/providers/{id}/key",
|
||||
put(secrets::set_provider_key).layer(DefaultBodyLimit::max(
|
||||
secrets::PROVIDER_KEY_BODY_LIMIT_BYTES,
|
||||
)),
|
||||
)
|
||||
.route("/v1/config", get(get_config).post(set_config))
|
||||
.route("/v1/config/reload", post(reload_config))
|
||||
.route(
|
||||
@@ -8302,6 +8386,7 @@ base_url = "http://127.0.0.1:9/v1"
|
||||
web: None,
|
||||
fleet_codewhale_binary: "unused-test-binary".to_string(),
|
||||
mcp_pool: Arc::new(Mutex::new(None)),
|
||||
lsp_manager: Arc::new(std::sync::OnceLock::new()),
|
||||
compat_stream_test_hook: None,
|
||||
};
|
||||
let router = build_router(state.clone());
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct CommandCatalogEntry {
|
||||
name: &'static str,
|
||||
aliases: Vec<&'static str>,
|
||||
usage: &'static str,
|
||||
description: String,
|
||||
subcommands: Vec<&'static str>,
|
||||
discovery: &'static str,
|
||||
requires_argument: bool,
|
||||
requires_required_argument: bool,
|
||||
composer_wants_trailing_space: bool,
|
||||
palette_runs_directly: bool,
|
||||
show_in_empty_discovery: bool,
|
||||
unlisted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct CommandCatalogResponse {
|
||||
commands: Vec<CommandCatalogEntry>,
|
||||
locale: String,
|
||||
}
|
||||
|
||||
/// Typed projection of the built-in slash-command registry for native-client
|
||||
/// composers and palettes. The registry in `crate::commands` stays the single
|
||||
/// authority: this handler only serializes the same `CommandInfo` the TUI
|
||||
/// consumes, including the discovery tiers and argument-shape hints the
|
||||
/// composer needs to reproduce slash completion without re-parsing `usage`.
|
||||
///
|
||||
/// Descriptions are localized with the runtime's configured locale; `locale`
|
||||
/// in the response names the resolved pack so a client can detect fallback.
|
||||
/// User-registered commands are intentionally absent — they are per-session
|
||||
/// state, and a session-scoped projection can layer them on later without
|
||||
/// changing this contract.
|
||||
pub(super) async fn list_commands(
|
||||
State(_state): State<RuntimeApiState>,
|
||||
) -> Result<Json<CommandCatalogResponse>, ApiError> {
|
||||
let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
|
||||
let locale = codewhale_localization::resolve_locale(&settings.locale);
|
||||
let commands = crate::commands::command_infos()
|
||||
.into_iter()
|
||||
.map(|info| CommandCatalogEntry {
|
||||
name: info.name,
|
||||
aliases: info.aliases.to_vec(),
|
||||
usage: info.usage,
|
||||
description: info.description_for(locale).into_owned(),
|
||||
subcommands: info.subcommands(),
|
||||
discovery: match info.discovery() {
|
||||
crate::commands::traits::CommandDiscovery::Primary => "primary",
|
||||
crate::commands::traits::CommandDiscovery::Advanced => "advanced",
|
||||
crate::commands::traits::CommandDiscovery::Compatibility => "compatibility",
|
||||
},
|
||||
requires_argument: info.requires_argument(),
|
||||
requires_required_argument: info.requires_required_argument(),
|
||||
composer_wants_trailing_space: info.composer_wants_trailing_space(),
|
||||
palette_runs_directly: info.palette_runs_directly(),
|
||||
show_in_empty_discovery: info.show_in_empty_discovery(),
|
||||
unlisted: info.is_unlisted(),
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(CommandCatalogResponse {
|
||||
commands,
|
||||
locale: locale.tag().to_string(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ApiError, RuntimeApiState, map_thread_err};
|
||||
|
||||
/// Live context-window posture for one thread — the facts the GPUI usage
|
||||
/// panel cannot reconstruct from turn receipts. `input_tokens` is the same
|
||||
/// conservative estimate the in-app context meter shows; `billed_input_tokens`
|
||||
/// is the last provider-counted prompt size when one exists.
|
||||
///
|
||||
/// All numeric fields are nullable: a route that cannot express a bounded
|
||||
/// window (unknown model, no catalog or configured limits) reports `null`
|
||||
/// rather than an invented number, and `live: false` marks responses where
|
||||
/// the engine could not be loaded and only the static route window resolved.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct ThreadContextResponse {
|
||||
thread_id: String,
|
||||
model: String,
|
||||
provider: Option<String>,
|
||||
model_provider_id: Option<String>,
|
||||
/// `true` when the numbers came from the loaded engine (live estimate +
|
||||
/// route limits); `false` when only the store-recorded route resolved.
|
||||
live: bool,
|
||||
window_tokens: Option<u64>,
|
||||
input_tokens: Option<u64>,
|
||||
billed_input_tokens: Option<u64>,
|
||||
output_cap_tokens: Option<u64>,
|
||||
input_budget_ceiling: Option<u64>,
|
||||
available_input_tokens: Option<u64>,
|
||||
compaction_trigger_tokens: Option<u64>,
|
||||
usage_percent: Option<f64>,
|
||||
pressure: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub(super) async fn get_thread_context(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(thread_id): Path<String>,
|
||||
) -> Result<Json<ThreadContextResponse>, ApiError> {
|
||||
let thread = state
|
||||
.runtime_threads
|
||||
.get_thread(&thread_id)
|
||||
.await
|
||||
.map_err(map_thread_err)?;
|
||||
|
||||
if let Ok(engine) = state.runtime_threads.get_engine(&thread_id).await
|
||||
&& let Ok(Some(snapshot)) = engine.get_context_budget().await
|
||||
{
|
||||
return Ok(Json(ThreadContextResponse {
|
||||
thread_id,
|
||||
model: snapshot.model,
|
||||
provider: Some(snapshot.provider),
|
||||
model_provider_id: snapshot.model_provider_id,
|
||||
live: true,
|
||||
window_tokens: Some(snapshot.window_tokens),
|
||||
input_tokens: Some(snapshot.input_tokens),
|
||||
billed_input_tokens: snapshot.billed_input_tokens,
|
||||
output_cap_tokens: Some(snapshot.output_cap_tokens),
|
||||
input_budget_ceiling: Some(snapshot.input_budget_ceiling),
|
||||
available_input_tokens: Some(snapshot.available_input_tokens),
|
||||
compaction_trigger_tokens: Some(snapshot.compaction_trigger_tokens),
|
||||
usage_percent: Some(snapshot.usage_percent),
|
||||
pressure: Some(snapshot.pressure),
|
||||
}));
|
||||
}
|
||||
|
||||
// Engine unavailable or the route cannot bound a window: still answer
|
||||
// with whatever the thread record and the static route catalog can prove.
|
||||
let provider = thread
|
||||
.model_provider
|
||||
.as_deref()
|
||||
.and_then(crate::config::ApiProvider::parse);
|
||||
let window_tokens = provider.map(|provider| {
|
||||
u64::from(crate::route_budget::route_context_window_tokens(
|
||||
provider,
|
||||
&thread.model,
|
||||
None,
|
||||
))
|
||||
});
|
||||
Ok(Json(ThreadContextResponse {
|
||||
thread_id,
|
||||
model: thread.model,
|
||||
provider: thread.model_provider,
|
||||
model_provider_id: thread.model_provider_id,
|
||||
live: false,
|
||||
window_tokens,
|
||||
input_tokens: None,
|
||||
billed_input_tokens: None,
|
||||
output_cap_tokens: None,
|
||||
input_budget_ceiling: None,
|
||||
available_input_tokens: None,
|
||||
compaction_trigger_tokens: None,
|
||||
usage_percent: None,
|
||||
pressure: None,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Crash/log inspection for native clients (APPS-103).
|
||||
//!
|
||||
//! This is a read surface, not a telemetry store: it lists and serves files
|
||||
//! the runtime already writes to disk — `logs/` rolling logs, `audit.log`,
|
||||
//! and `crashes/*.log` panic dumps — so a client (including a remote or
|
||||
//! headless one that cannot see the disk) can package an export locally.
|
||||
//! There is deliberately no upload route and no second log store.
|
||||
//!
|
||||
//! Routes:
|
||||
//! GET /v1/logs — recent log/audit entries (name, size, modified)
|
||||
//! GET /v1/logs/{name} — bounded window of one file (?offset, ?limit, ?tail)
|
||||
//! GET /v1/crashes — crash-dump entries
|
||||
//! GET /v1/crashes/{name} — bounded window of one dump
|
||||
//! GET /v1/process — pid, start time, uptime, version, RSS (Linux)
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Instant, SystemTime};
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::workspace::encode_window;
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
|
||||
/// Default read window for a file entry.
|
||||
const READ_LIMIT_DEFAULT: usize = 256 * 1024;
|
||||
const READ_LIMIT_MAX: usize = 4 * 1024 * 1024;
|
||||
/// Listing caps — newest first, bounded so a long-lived install cannot
|
||||
/// produce an unbounded response.
|
||||
const LOG_LIST_CAP: usize = 64;
|
||||
const CRASH_LIST_CAP: usize = 64;
|
||||
|
||||
/// When this API server came up. `build_router` stamps it once so process
|
||||
/// facts describe the serving process, not first-call time.
|
||||
static SERVER_STARTED: OnceLock<(SystemTime, Instant)> = OnceLock::new();
|
||||
|
||||
pub(super) fn mark_server_started() {
|
||||
let _ = SERVER_STARTED.set((SystemTime::now(), Instant::now()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared listing + bounded read
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FileEntry {
|
||||
name: String,
|
||||
size: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
modified: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct FileReadQuery {
|
||||
offset: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
/// Convenience tail read: last N bytes of the file.
|
||||
tail: Option<u64>,
|
||||
}
|
||||
|
||||
fn rfc3339(time: SystemTime) -> String {
|
||||
chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()
|
||||
}
|
||||
|
||||
/// Basenames only — a `{name}` path segment must never reach outside the
|
||||
/// listing directory. Refuse anything that is not a plain file name.
|
||||
fn safe_entry_name(raw: &str) -> Result<String, ApiError> {
|
||||
let name = raw.trim();
|
||||
if name.is_empty()
|
||||
|| name.len() > 255
|
||||
|| name.contains('/')
|
||||
|| name.contains('\\')
|
||||
|| name.contains('\0')
|
||||
|| name == "."
|
||||
|| name == ".."
|
||||
{
|
||||
return Err(ApiError::bad_request("name must be a file name"));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
fn list_files(dir: &FsPath, cap: usize) -> Vec<FileEntry> {
|
||||
let mut entries: Vec<FileEntry> = Vec::new();
|
||||
if let Ok(read_dir) = std::fs::read_dir(dir) {
|
||||
for entry in read_dir.flatten() {
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(metadata) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
entries.push(FileEntry {
|
||||
name,
|
||||
size: metadata.len(),
|
||||
modified: metadata.modified().ok().map(rfc3339),
|
||||
});
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b.modified
|
||||
.cmp(&a.modified)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
entries.truncate(cap);
|
||||
entries
|
||||
}
|
||||
|
||||
/// Read `[offset, offset + limit)` of a named file inside `dir` without
|
||||
/// loading the whole file. Symlinks are never followed.
|
||||
fn read_named_window(dir: &FsPath, name: &str, query: FileReadQuery) -> Result<Value, ApiError> {
|
||||
let path = dir.join(name);
|
||||
let metadata = std::fs::symlink_metadata(&path).map_err(|error| match error.kind() {
|
||||
std::io::ErrorKind::NotFound => ApiError::not_found("file not found"),
|
||||
_ => ApiError::internal(format!("file access failed: {error}")),
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(ApiError::forbidden("not a regular file"));
|
||||
}
|
||||
let size = metadata.len();
|
||||
let limit = query.limit.unwrap_or(READ_LIMIT_DEFAULT);
|
||||
if !(1..=READ_LIMIT_MAX).contains(&limit) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"limit must be between 1 and {READ_LIMIT_MAX} bytes"
|
||||
)));
|
||||
}
|
||||
let offset = match (query.offset, query.tail) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(ApiError::bad_request(
|
||||
"offset and tail are mutually exclusive",
|
||||
));
|
||||
}
|
||||
(Some(offset), None) => offset.min(size),
|
||||
(None, Some(tail)) => size.saturating_sub(tail.min(size)),
|
||||
(None, None) => 0,
|
||||
};
|
||||
let mut file = File::open(&path)
|
||||
.map_err(|error| ApiError::internal(format!("file open failed: {error}")))?;
|
||||
file.seek(SeekFrom::Start(offset))
|
||||
.map_err(|error| ApiError::internal(format!("file seek failed: {error}")))?;
|
||||
let mut window = Vec::with_capacity(limit.min(64 * 1024));
|
||||
file.take(limit as u64)
|
||||
.read_to_end(&mut window)
|
||||
.map_err(|error| ApiError::internal(format!("file read failed: {error}")))?;
|
||||
let truncated = offset as usize + window.len() < size as usize;
|
||||
let (encoding, content) = encode_window(&window);
|
||||
Ok(json!({
|
||||
"name": name,
|
||||
"size": size,
|
||||
"modified": metadata.modified().ok().map(rfc3339),
|
||||
"offset": offset,
|
||||
"bytes": window.len(),
|
||||
"truncated": truncated,
|
||||
"encoding": encoding,
|
||||
"content": content,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Log files live under `runtime_log::log_directory()`; the audit trail sits
|
||||
/// beside them at `<codewhale home>/audit.log[.1]` and is listed as extra
|
||||
/// entries so one listing covers every text log the runtime writes.
|
||||
fn log_sources() -> Vec<(PathBuf, Vec<PathBuf>)> {
|
||||
let mut sources = Vec::new();
|
||||
if let Some(dir) = crate::runtime_log::log_directory() {
|
||||
let mut singles = Vec::new();
|
||||
if let Ok(home) = codewhale_config::codewhale_home() {
|
||||
for name in ["audit.log", "audit.log.1"] {
|
||||
let path = home.join(name);
|
||||
if path.is_file() {
|
||||
singles.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
sources.push((dir, singles));
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
/// Panic dumps prefer `<home>/.codewhale/crashes` and fall back to the legacy
|
||||
/// `.deepseek` directory — mirror the writer's preference order and merge
|
||||
/// every directory that exists.
|
||||
fn crash_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(home) = crate::config::effective_home_dir() {
|
||||
for base in [".codewhale", ".deepseek"] {
|
||||
let dir = home.join(base).join("crashes");
|
||||
if dir.is_dir() && !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) async fn list_logs(State(_state): State<RuntimeApiState>) -> Json<Value> {
|
||||
let mut sources = Vec::new();
|
||||
for (dir, singles) in log_sources() {
|
||||
let mut entries = list_files(&dir, LOG_LIST_CAP);
|
||||
for path in singles {
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&path)
|
||||
&& metadata.is_file()
|
||||
&& !metadata.file_type().is_symlink()
|
||||
&& let Some(name) = path.file_name().and_then(|name| name.to_str())
|
||||
{
|
||||
entries.push(FileEntry {
|
||||
name: name.to_string(),
|
||||
size: metadata.len(),
|
||||
modified: metadata.modified().ok().map(rfc3339),
|
||||
});
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b.modified
|
||||
.cmp(&a.modified)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
entries.truncate(LOG_LIST_CAP);
|
||||
sources.push(json!({
|
||||
"dir": dir,
|
||||
"files": entries,
|
||||
}));
|
||||
}
|
||||
Json(json!({ "sources": sources }))
|
||||
}
|
||||
|
||||
pub(super) async fn read_log(
|
||||
State(_state): State<RuntimeApiState>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<FileReadQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let name = safe_entry_name(&name)?;
|
||||
let body = tokio::task::spawn_blocking(move || {
|
||||
// The audit trail is listed beside the log dir; resolve it from the
|
||||
// codewhale home rather than the log directory.
|
||||
if name == "audit.log" || name == "audit.log.1" {
|
||||
let home = codewhale_config::codewhale_home()
|
||||
.map_err(|error| ApiError::internal(format!("home unavailable: {error}")))?;
|
||||
return read_named_window(&home, &name, query);
|
||||
}
|
||||
let dir = crate::runtime_log::log_directory()
|
||||
.ok_or_else(|| ApiError::not_found("no log directory"))?;
|
||||
read_named_window(&dir, &name, query)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("log read failed"))??;
|
||||
Ok(Json(body))
|
||||
}
|
||||
|
||||
pub(super) async fn list_crashes(State(_state): State<RuntimeApiState>) -> Json<Value> {
|
||||
let mut sources = Vec::new();
|
||||
for dir in crash_dirs() {
|
||||
sources.push(json!({
|
||||
"dir": dir,
|
||||
"files": list_files(&dir, CRASH_LIST_CAP),
|
||||
}));
|
||||
}
|
||||
Json(json!({ "sources": sources }))
|
||||
}
|
||||
|
||||
pub(super) async fn read_crash(
|
||||
State(_state): State<RuntimeApiState>,
|
||||
Path(name): Path<String>,
|
||||
Query(query): Query<FileReadQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let name = safe_entry_name(&name)?;
|
||||
let body = tokio::task::spawn_blocking(move || {
|
||||
for dir in crash_dirs() {
|
||||
let candidate = dir.join(&name);
|
||||
if std::fs::symlink_metadata(&candidate)
|
||||
.map(|m| m.is_file() && !m.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return read_named_window(&dir, &name, query);
|
||||
}
|
||||
}
|
||||
Err(ApiError::not_found("crash capture not found"))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("crash read failed"))??;
|
||||
Ok(Json(body))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn rss_bytes() -> Option<u64> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
let line = status.lines().find(|line| line.starts_with("VmRSS:"))?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
Some(kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn rss_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) async fn process_info(State(_state): State<RuntimeApiState>) -> Json<Value> {
|
||||
let (started_at, uptime_secs) = match SERVER_STARTED.get() {
|
||||
Some((system, instant)) => (Some(rfc3339(*system)), Some(instant.elapsed().as_secs())),
|
||||
None => (None, None),
|
||||
};
|
||||
Json(json!({
|
||||
"pid": std::process::id(),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"commit": option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown"),
|
||||
"started_at": started_at,
|
||||
"uptime_seconds": uptime_secs,
|
||||
"executable": std::env::current_exe().ok(),
|
||||
"rss_bytes": rss_bytes(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
//! Workspace git surface for native clients (APPS-106).
|
||||
//!
|
||||
//! One authority: these routes run `git` against the server's configured
|
||||
//! workspace through the same hardened primitives the agent tools use —
|
||||
//! reads go through [`Git::review_command`] (filters, fsmonitor, hooks,
|
||||
//! lazy fetches and replace-objects neutralized), writes run through
|
||||
//! [`Git::tokio_command`] with interactive prompts disabled so a credential
|
||||
//! or host-key prompt can never hang an HTTP request. There is no second
|
||||
//! index, cache, or diff store here; every response is computed live from
|
||||
//! the repository.
|
||||
//!
|
||||
//! Routes (workspace-scoped, matching the client contract):
|
||||
//! GET /v1/git — branch/head/ahead-behind, per-file porcelain
|
||||
//! entries, local branches and remotes
|
||||
//! GET /v1/changes — the file-change inventory only (status rows)
|
||||
//! GET /v1/diff?path= — unified diff for one workspace-relative file
|
||||
//! GET /v1/workspace/diff — bounded whole-tree diff + per-file numstat
|
||||
//! GET /v1/git/graph — recent commit graph rows (bounded)
|
||||
//! POST /v1/git/stage — `{ "paths": [...] }` or `{ "all": true }`
|
||||
//! POST /v1/git/unstage — `{ "paths": [...] }` or `{ "all": true }`
|
||||
//! POST /v1/git/discard — `{ "paths": [...] }` (tracked only; no `all`)
|
||||
//! POST /v1/git/commit — `{ "message": "…", "all": false }`
|
||||
//! POST /v1/git/push — `{ "remote"?, "set_upstream"?: bool }`
|
||||
//! POST /v1/git/branch — `{ "name": "…", "create"?: bool }`
|
||||
//!
|
||||
//! Mutations answer with the command output tail plus a refreshed workspace
|
||||
//! status so the client re-renders in one round trip. The caller holds the
|
||||
//! operator token; the repository's own hooks run for `commit` exactly as
|
||||
//! they would for the user's terminal.
|
||||
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::dependencies::{ExternalTool as _, Git};
|
||||
|
||||
use super::workspace::{canonical_workspace, collect_workspace_status, relative_request_path};
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
|
||||
/// Generous bound for local git work on very large repositories.
|
||||
const GIT_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Pushes cross the network; still bounded so a dead remote cannot pin a
|
||||
/// handler forever.
|
||||
const GIT_WRITE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Output tail carried back to the client for mutations.
|
||||
const MAX_OUTPUT_TAIL: usize = 8 * 1024;
|
||||
/// Commit-graph row bounds.
|
||||
const GRAPH_LIMIT_DEFAULT: usize = 100;
|
||||
const GRAPH_LIMIT_MAX: usize = 500;
|
||||
/// Unified-diff response caps: a single file gets a generous window; the
|
||||
/// whole-tree surface defaults smaller and always reports `truncated`.
|
||||
const FILE_DIFF_MAX_BYTES: usize = 512 * 1024;
|
||||
const WORKSPACE_DIFF_DEFAULT_BYTES: usize = 256 * 1024;
|
||||
const WORKSPACE_DIFF_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
/// The empty tree — diff base for repositories whose HEAD is unborn.
|
||||
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
/// Branch/commit message caps — generous for real messages, hostile to
|
||||
/// accidental binary paste.
|
||||
const MAX_COMMIT_MESSAGE_BYTES: usize = 64 * 1024;
|
||||
const MAX_BRANCH_NAME_BYTES: usize = 256;
|
||||
const MAX_PATH_ARGS: usize = 512;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// git invocation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct GitRun {
|
||||
status_success: bool,
|
||||
exit_code: Option<i32>,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// Hardened read path: the same primitive review/tooling uses, so fsmonitor,
|
||||
/// content filters, hooks, lazy fetches and replace-objects cannot run inside
|
||||
/// an HTTP read either.
|
||||
async fn git_read(workspace: &FsPath, args: &[&str]) -> Result<GitRun, ApiError> {
|
||||
let workspace = workspace.to_path_buf();
|
||||
let command = tokio::task::spawn_blocking(move || Git::review_command(&workspace))
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("git read setup failed"))?
|
||||
.map_err(|error| ApiError::internal(format!("git is unavailable: {error}")))?;
|
||||
let mut command = tokio::process::Command::from(command);
|
||||
command.args(args).kill_on_drop(true);
|
||||
finish_git(command.output(), GIT_READ_TIMEOUT).await
|
||||
}
|
||||
|
||||
/// Write path for operator-driven mutations. Non-interactive by contract:
|
||||
/// no terminal prompt, no pager, and BatchMode ssh (unless the user already
|
||||
/// pins their own `GIT_SSH_COMMAND`) so a key prompt can never hang the
|
||||
/// request. Hooks and filters run exactly as they do for the user's own
|
||||
/// `git` — a Review-sheet commit is the user's commit.
|
||||
async fn git_write(workspace: &FsPath, args: Vec<String>) -> Result<GitRun, ApiError> {
|
||||
let mut command = Git::tokio_command()
|
||||
.ok_or_else(|| ApiError::internal("git is not installed or not in PATH"))?;
|
||||
command
|
||||
.args(&args)
|
||||
.current_dir(workspace)
|
||||
.stdin(Stdio::null())
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_PAGER", "")
|
||||
.kill_on_drop(true);
|
||||
if std::env::var_os("GIT_SSH_COMMAND").is_none() {
|
||||
command.env("GIT_SSH_COMMAND", "ssh -o BatchMode=yes");
|
||||
}
|
||||
finish_git(command.output(), GIT_WRITE_TIMEOUT).await
|
||||
}
|
||||
|
||||
async fn finish_git(
|
||||
output: impl std::future::Future<Output = std::io::Result<std::process::Output>>,
|
||||
timeout: Duration,
|
||||
) -> Result<GitRun, ApiError> {
|
||||
let output = tokio::time::timeout(timeout, output)
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("git operation timed out"))?
|
||||
.map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?;
|
||||
Ok(GitRun {
|
||||
status_success: output.status.success(),
|
||||
exit_code: output.status.code(),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn output_tail(run: &GitRun) -> String {
|
||||
let mut text = String::new();
|
||||
for part in [run.stdout.trim(), run.stderr.trim()] {
|
||||
if !part.is_empty() {
|
||||
if !text.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(part);
|
||||
}
|
||||
}
|
||||
if text.len() > MAX_OUTPUT_TAIL {
|
||||
let mut boundary = text.len() - MAX_OUTPUT_TAIL;
|
||||
while !text.is_char_boundary(boundary) {
|
||||
boundary -= 1;
|
||||
}
|
||||
text = text[boundary..].to_string();
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// A non-repo workspace is a 404, not a 500: `rev-parse --is-inside-work-tree`
|
||||
/// exits 128 there, so probe directly rather than through the erroring helper.
|
||||
fn require_repo(workspace: &FsPath) -> Result<(), ApiError> {
|
||||
match Git::output(&["rev-parse", "--is-inside-work-tree"], workspace) {
|
||||
Ok(output)
|
||||
if output.status.success()
|
||||
&& String::from_utf8_lossy(&output.stdout).trim() == "true" =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => Err(ApiError::not_found("workspace is not a git repository")),
|
||||
Err(error) => Err(ApiError::internal(format!("failed to run git: {error}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap one-shot read used only for repo probes where the hardened review
|
||||
/// command's filter dance would be wasted work.
|
||||
fn run_git_sync(workspace: &FsPath, args: &[&str]) -> Result<String, ApiError> {
|
||||
let output = Git::output(args, workspace)
|
||||
.map_err(|error| ApiError::internal(format!("failed to run git: {error}")))?;
|
||||
if !output.status.success() {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git {} failed: {}",
|
||||
args.first().copied().unwrap_or(""),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/git — status detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GitFileEntry {
|
||||
path: String,
|
||||
/// Raw porcelain v1 index (X) and worktree (Y) columns.
|
||||
index: String,
|
||||
worktree: String,
|
||||
/// True when the index column records a change.
|
||||
staged: bool,
|
||||
/// Leading human state: modified / added / deleted / renamed /
|
||||
/// typechange / untracked / conflicted / ignored.
|
||||
status: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
old_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct GitStatusDetailResponse {
|
||||
git_repo: bool,
|
||||
workspace: PathBuf,
|
||||
branch: Option<String>,
|
||||
detached: bool,
|
||||
head: Option<String>,
|
||||
ahead: Option<u32>,
|
||||
behind: Option<u32>,
|
||||
staged: usize,
|
||||
unstaged: usize,
|
||||
untracked: usize,
|
||||
files: Vec<GitFileEntry>,
|
||||
branches: Vec<String>,
|
||||
remotes: Vec<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn git_status_detail(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<GitStatusDetailResponse>, ApiError> {
|
||||
let workspace = state.workspace.clone();
|
||||
tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace))
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("git status failed"))?
|
||||
.map(Json)
|
||||
}
|
||||
|
||||
fn collect_git_status_detail(workspace: &FsPath) -> Result<GitStatusDetailResponse, ApiError> {
|
||||
let status = collect_workspace_status(workspace);
|
||||
let mut detail = GitStatusDetailResponse {
|
||||
git_repo: status.git_repo,
|
||||
workspace: workspace.to_path_buf(),
|
||||
branch: status.branch.clone(),
|
||||
detached: false,
|
||||
head: status.head,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
staged: status.staged,
|
||||
unstaged: status.unstaged,
|
||||
untracked: status.untracked,
|
||||
files: Vec::new(),
|
||||
branches: Vec::new(),
|
||||
remotes: Vec::new(),
|
||||
};
|
||||
if !status.git_repo {
|
||||
return Ok(detail);
|
||||
}
|
||||
detail.detached = status.branch.is_none()
|
||||
|| status
|
||||
.branch
|
||||
.as_deref()
|
||||
.is_some_and(|branch| branch.starts_with("detached@"));
|
||||
|
||||
// `-z` keeps paths verbatim: one NUL-terminated `XY <path>` record each,
|
||||
// with renames/copies carrying the source path in the following record.
|
||||
if let Ok(porcelain) = run_git_sync(workspace, &["status", "--porcelain=v1", "-z"]) {
|
||||
detail.files = parse_porcelain(&porcelain);
|
||||
}
|
||||
if let Ok(branches) = run_git_sync(workspace, &["branch", "--format=%(refname:short)"]) {
|
||||
detail.branches = branches
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
}
|
||||
if let Ok(remotes) = run_git_sync(workspace, &["remote"]) {
|
||||
detail.remotes = remotes
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
}
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
fn parse_porcelain(porcelain: &str) -> Vec<GitFileEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut records = porcelain.split('\0').peekable();
|
||||
while let Some(record) = records.next() {
|
||||
if record.is_empty() || record.starts_with("## ") {
|
||||
continue;
|
||||
}
|
||||
if record.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let index = record.as_bytes()[0] as char;
|
||||
let worktree = record.as_bytes()[1] as char;
|
||||
let path = record[3..].to_string();
|
||||
let mut old_path = None;
|
||||
if matches!(index, 'R' | 'C') {
|
||||
old_path = records.next().map(str::to_string);
|
||||
}
|
||||
entries.push(GitFileEntry {
|
||||
path,
|
||||
index: index.to_string(),
|
||||
worktree: worktree.to_string(),
|
||||
staged: !matches!(index, ' ' | '?' | '!'),
|
||||
status: porcelain_status(index, worktree),
|
||||
old_path,
|
||||
});
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn porcelain_status(index: char, worktree: char) -> &'static str {
|
||||
match (index, worktree) {
|
||||
('?', '?') => "untracked",
|
||||
('!', '!') => "ignored",
|
||||
('U', _) | (_, 'U') | ('D', 'D') | ('A', 'A') => "conflicted",
|
||||
('R', _) | (_, 'R') => "renamed",
|
||||
('C', _) | (_, 'C') => "copied",
|
||||
('A', _) | (_, 'A') => "added",
|
||||
('D', _) | (_, 'D') => "deleted",
|
||||
('T', _) | (_, 'T') => "typechange",
|
||||
('M', _) | (_, 'M') => "modified",
|
||||
_ => "unchanged",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/changes — the file-change inventory only
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The Review sheet's change list: the same porcelain projection as
|
||||
/// `GET /v1/git` minus repo chrome (branches/remotes). One authority — a
|
||||
/// client that loaded both cannot see them disagree.
|
||||
pub(super) async fn git_changes(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state.workspace.clone();
|
||||
let detail = tokio::task::spawn_blocking(move || collect_git_status_detail(&workspace))
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("git status failed"))??;
|
||||
Ok(Json(json!({
|
||||
"git_repo": detail.git_repo,
|
||||
"branch": detail.branch,
|
||||
"staged": detail.staged,
|
||||
"unstaged": detail.unstaged,
|
||||
"untracked": detail.untracked,
|
||||
"files": detail.files,
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/diff + /v1/workspace/diff — unified diffs against HEAD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `git diff <base>` compares the worktree to <base>, covering staged and
|
||||
/// unstaged changes in one output. On an unborn branch the base is the
|
||||
/// empty tree, which reads every staged/tracked file as new — the honest
|
||||
/// "everything changed" picture for a repo with no commits.
|
||||
async fn diff_base(workspace: &FsPath) -> Result<String, ApiError> {
|
||||
let head = git_read(workspace, &["rev-parse", "--verify", "HEAD"]).await?;
|
||||
Ok(if head.status_success {
|
||||
"HEAD".to_string()
|
||||
} else {
|
||||
EMPTY_TREE.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Byte-bounded cut at a char boundary; reports whether bytes were dropped.
|
||||
fn bounded_patch(text: &str, limit: usize) -> (String, bool) {
|
||||
if text.len() <= limit {
|
||||
return (text.to_string(), false);
|
||||
}
|
||||
let mut end = limit;
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
(text[..end].to_string(), true)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct GitDiffQuery {
|
||||
/// Workspace-relative file; may name a deleted file (the diff survives).
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// `GET /v1/diff?path=` — one file's unified diff against HEAD (or the empty
|
||||
/// tree on an unborn branch). An untracked file has no diff by definition:
|
||||
/// the response says `untracked: true` with an empty `diff` so the client
|
||||
/// reads the file itself instead of mistaking it for unchanged.
|
||||
pub(super) async fn git_diff(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<GitDiffQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let path = relative_request_path(&query.path, false)?;
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let base = diff_base(&workspace).await?;
|
||||
let path_arg = path.to_string_lossy().into_owned();
|
||||
let run = git_read(
|
||||
&workspace,
|
||||
&[
|
||||
"diff",
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
&base,
|
||||
"--",
|
||||
&path_arg,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if !run.status_success {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git diff failed: {}",
|
||||
run.stderr.trim()
|
||||
)));
|
||||
}
|
||||
let (diff, truncated) = bounded_patch(&run.stdout, FILE_DIFF_MAX_BYTES);
|
||||
let untracked = if diff.is_empty() {
|
||||
let status = git_read(
|
||||
&workspace,
|
||||
&["status", "--porcelain=v1", "-z", "--", &path_arg],
|
||||
)
|
||||
.await?;
|
||||
status
|
||||
.stdout
|
||||
.split('\0')
|
||||
.any(|record| record.starts_with("??"))
|
||||
} else {
|
||||
false
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"path": path_arg,
|
||||
"base": base,
|
||||
"untracked": untracked,
|
||||
"diff": diff,
|
||||
"truncated": truncated,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct WorkspaceDiffQuery {
|
||||
/// Byte cap on the returned patch (default 256 KiB, max 4 MiB).
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// `GET /v1/workspace/diff?limit=` — the whole tree's diff against HEAD plus
|
||||
/// a complete `--numstat` inventory, so a client renders every changed file
|
||||
/// row even when the patch body is truncated.
|
||||
pub(super) async fn workspace_diff(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<WorkspaceDiffQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let limit = query.limit.unwrap_or(WORKSPACE_DIFF_DEFAULT_BYTES);
|
||||
if !(1024..=WORKSPACE_DIFF_MAX_BYTES).contains(&limit) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"limit must be between 1024 and {WORKSPACE_DIFF_MAX_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let base = diff_base(&workspace).await?;
|
||||
|
||||
let numstat = git_read(&workspace, &["diff", "--numstat", &base]).await?;
|
||||
if !numstat.status_success {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git diff --numstat failed: {}",
|
||||
numstat.stderr.trim()
|
||||
)));
|
||||
}
|
||||
let files: Vec<Value> = numstat
|
||||
.stdout
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let mut fields = line.splitn(3, '\t');
|
||||
let added = fields.next()?;
|
||||
let deleted = fields.next()?;
|
||||
let path = fields.next()?;
|
||||
Some(json!({
|
||||
"path": path,
|
||||
// Binary files report "-" rather than a count.
|
||||
"added": added.parse::<u64>().ok(),
|
||||
"deleted": deleted.parse::<u64>().ok(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let run = git_read(&workspace, &["diff", "--no-color", "--no-ext-diff", &base]).await?;
|
||||
if !run.status_success {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git diff failed: {}",
|
||||
run.stderr.trim()
|
||||
)));
|
||||
}
|
||||
let (diff, truncated) = bounded_patch(&run.stdout, limit);
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"git_repo": true,
|
||||
"base": base,
|
||||
"files": files,
|
||||
"diff": diff,
|
||||
"truncated": truncated,
|
||||
})))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/git/graph — bounded commit graph rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct GitGraphQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
const GRAPH_FIELD: char = '\u{1f}';
|
||||
const GRAPH_RECORD: char = '\u{1e}';
|
||||
|
||||
pub(super) async fn git_graph(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<GitGraphQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let limit = query.limit.unwrap_or(GRAPH_LIMIT_DEFAULT);
|
||||
if !(1..=GRAPH_LIMIT_MAX).contains(&limit) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"limit must be between 1 and {GRAPH_LIMIT_MAX}"
|
||||
)));
|
||||
}
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let limit_arg = format!("-n{limit}");
|
||||
let format = format!(
|
||||
"%H{GRAPH_FIELD}%h{GRAPH_FIELD}%P{GRAPH_FIELD}%an{GRAPH_FIELD}%ae{GRAPH_FIELD}%aI{GRAPH_FIELD}%D{GRAPH_FIELD}%s{GRAPH_RECORD}"
|
||||
);
|
||||
let format_arg = format!("--format={format}");
|
||||
let run = git_read(&workspace, &["log", &limit_arg, &format_arg]).await?;
|
||||
if !run.status_success {
|
||||
// `git log` exits non-zero on an unborn branch; that is a valid empty
|
||||
// graph, not a failure. Distinguish with a HEAD probe instead of
|
||||
// trusting stderr text.
|
||||
let head = git_read(&workspace, &["rev-parse", "--verify", "HEAD"]).await?;
|
||||
if head.status_success {
|
||||
return Err(ApiError::internal(format!(
|
||||
"git log failed: {}",
|
||||
run.stderr.trim()
|
||||
)));
|
||||
}
|
||||
return Ok(Json(json!({ "commits": [], "truncated": false })));
|
||||
}
|
||||
let mut commits = Vec::new();
|
||||
for record in run.stdout.split(GRAPH_RECORD) {
|
||||
let record = record.trim_matches('\n');
|
||||
if record.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut fields = record.split(GRAPH_FIELD);
|
||||
let (
|
||||
Some(id),
|
||||
Some(short),
|
||||
Some(parents),
|
||||
Some(name),
|
||||
Some(email),
|
||||
Some(timestamp),
|
||||
Some(refs),
|
||||
Some(subject),
|
||||
) = (
|
||||
fields.next(),
|
||||
fields.next(),
|
||||
fields.next(),
|
||||
fields.next(),
|
||||
fields.next(),
|
||||
fields.next(),
|
||||
fields.next(),
|
||||
fields.next(),
|
||||
)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
commits.push(json!({
|
||||
"id": id,
|
||||
"short": short,
|
||||
"parents": parents.split_whitespace().collect::<Vec<_>>(),
|
||||
"author": { "name": name, "email": email },
|
||||
"timestamp": timestamp,
|
||||
"refs": refs
|
||||
.split(", ")
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.collect::<Vec<_>>(),
|
||||
"subject": subject,
|
||||
}));
|
||||
}
|
||||
let truncated = commits.len() >= limit;
|
||||
Ok(Json(json!({ "commits": commits, "truncated": truncated })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct GitPathsRequest {
|
||||
#[serde(default)]
|
||||
paths: Vec<String>,
|
||||
/// Stage/unstage may take the whole tree; discard cannot.
|
||||
#[serde(default)]
|
||||
all: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct GitCommitRequest {
|
||||
message: String,
|
||||
/// Also stage tracked modifications (`git commit -a`).
|
||||
#[serde(default)]
|
||||
all: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct GitPushRequest {
|
||||
remote: Option<String>,
|
||||
#[serde(default)]
|
||||
set_upstream: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct GitBranchRequest {
|
||||
name: String,
|
||||
/// Create and switch (`git switch -c`); default is switch to existing.
|
||||
#[serde(default)]
|
||||
create: bool,
|
||||
}
|
||||
|
||||
fn mutation_response(workspace: &FsPath, run: GitRun) -> Result<Json<Value>, ApiError> {
|
||||
if !run.status_success {
|
||||
let tail = output_tail(&run);
|
||||
return Err(ApiError::bad_request(if tail.is_empty() {
|
||||
format!("git exited {}", run.exit_code.unwrap_or(-1))
|
||||
} else {
|
||||
tail
|
||||
}));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"output": output_tail(&run),
|
||||
"status": collect_workspace_status(workspace),
|
||||
})))
|
||||
}
|
||||
|
||||
/// Validate and collect pathspecs. Every path is workspace-relative with no
|
||||
/// `.`/`..`/`.git` components and is passed after `--`, so it can never be
|
||||
/// read as an option.
|
||||
fn validated_paths(request: &GitPathsRequest, allow_all: bool) -> Result<Vec<String>, ApiError> {
|
||||
if request.all && !allow_all {
|
||||
return Err(ApiError::bad_request(
|
||||
"discard requires explicit paths; refusing to discard the whole tree",
|
||||
));
|
||||
}
|
||||
if request.all && !request.paths.is_empty() {
|
||||
return Err(ApiError::bad_request(
|
||||
"all and paths are mutually exclusive",
|
||||
));
|
||||
}
|
||||
if !request.all && request.paths.is_empty() {
|
||||
return Err(ApiError::bad_request("paths is required (or all: true)"));
|
||||
}
|
||||
if request.paths.len() > MAX_PATH_ARGS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"at most {MAX_PATH_ARGS} paths per request"
|
||||
)));
|
||||
}
|
||||
request
|
||||
.paths
|
||||
.iter()
|
||||
.map(|raw| {
|
||||
relative_request_path(raw, false).map(|path| path.to_string_lossy().into_owned())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) async fn git_stage(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitPathsRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let paths = validated_paths(&request, true)?;
|
||||
let mut args = vec!["add".to_string()];
|
||||
if request.all {
|
||||
args.push("--all".to_string());
|
||||
}
|
||||
if !paths.is_empty() {
|
||||
args.push("--".to_string());
|
||||
args.extend(paths);
|
||||
}
|
||||
mutation_response(&workspace, git_write(&workspace, args).await?)
|
||||
}
|
||||
|
||||
pub(super) async fn git_unstage(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitPathsRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let paths = validated_paths(&request, true)?;
|
||||
let mut args = vec!["restore".to_string(), "--staged".to_string()];
|
||||
if request.all {
|
||||
args.push(":/".to_string());
|
||||
}
|
||||
if !paths.is_empty() {
|
||||
args.push("--".to_string());
|
||||
args.extend(paths);
|
||||
}
|
||||
mutation_response(&workspace, git_write(&workspace, args).await?)
|
||||
}
|
||||
|
||||
pub(super) async fn git_discard(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitPathsRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let paths = validated_paths(&request, false)?;
|
||||
let mut args = vec!["checkout".to_string(), "--".to_string()];
|
||||
args.extend(paths);
|
||||
mutation_response(&workspace, git_write(&workspace, args).await?)
|
||||
}
|
||||
|
||||
pub(super) async fn git_commit(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitCommitRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let message = request.message.trim();
|
||||
if message.is_empty() {
|
||||
return Err(ApiError::bad_request("message is required"));
|
||||
}
|
||||
if message.len() > MAX_COMMIT_MESSAGE_BYTES {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"message must be at most {MAX_COMMIT_MESSAGE_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let mut args = vec!["commit".to_string()];
|
||||
if request.all {
|
||||
args.push("--all".to_string());
|
||||
}
|
||||
args.push("--message".to_string());
|
||||
args.push(message.to_string());
|
||||
mutation_response(&workspace, git_write(&workspace, args).await?)
|
||||
}
|
||||
|
||||
pub(super) async fn git_push(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitPushRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
let remote = request
|
||||
.remote
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|remote| !remote.is_empty())
|
||||
.map(str::to_string);
|
||||
if let Some(remote) = &remote
|
||||
&& !remote
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/'))
|
||||
{
|
||||
return Err(ApiError::bad_request("remote must be a remote name"));
|
||||
}
|
||||
let mut args = vec!["push".to_string()];
|
||||
if request.set_upstream {
|
||||
let branch = run_git_sync(&workspace, &["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||
let branch = branch.trim();
|
||||
if branch.is_empty() || branch == "HEAD" {
|
||||
return Err(ApiError::bad_request(
|
||||
"cannot set upstream from a detached HEAD",
|
||||
));
|
||||
}
|
||||
args.push("--set-upstream".to_string());
|
||||
args.push(remote.unwrap_or_else(|| "origin".to_string()));
|
||||
args.push(branch.to_string());
|
||||
} else if let Some(remote) = remote {
|
||||
args.push(remote);
|
||||
}
|
||||
mutation_response(&workspace, git_write(&workspace, args).await?)
|
||||
}
|
||||
|
||||
pub(super) async fn git_branch(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Json(request): Json<GitBranchRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let name = request.name.trim();
|
||||
if name.is_empty() || name.len() > MAX_BRANCH_NAME_BYTES {
|
||||
return Err(ApiError::bad_request("name is required"));
|
||||
}
|
||||
let workspace = canonical_workspace(&state.workspace)?;
|
||||
require_repo(&workspace)?;
|
||||
// `check-ref-format --branch` is the ref authority — it rejects option
|
||||
// lookalikes, `..`, `@{`, control bytes, and every other unsafe name.
|
||||
let check = git_write(
|
||||
&workspace,
|
||||
vec![
|
||||
"check-ref-format".to_string(),
|
||||
"--branch".to_string(),
|
||||
name.to_string(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if !check.status_success {
|
||||
return Err(ApiError::bad_request("name is not a valid branch"));
|
||||
}
|
||||
let mut args = vec!["switch".to_string()];
|
||||
if request.create {
|
||||
args.push("--create".to_string());
|
||||
}
|
||||
args.push(name.to_string());
|
||||
mutation_response(&workspace, git_write(&workspace, args).await?)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! `/v1/jobs` — the client-facing shell job surface.
|
||||
//!
|
||||
//! One authority: every job lives on the thread's shared `ShellManager`, the
|
||||
//! same manager the thread's engine uses for model-launched shell work. Jobs
|
||||
//! created here carry an `api:{thread_id}` owner scope so an engine's
|
||||
//! per-session completion drain never claims client-launched work as model
|
||||
//! evidence — and the model's jobs never appear "client-owned" here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::tools::shell::{
|
||||
ShellJobSnapshot, ShellOutputChunk, ShellOutputStream, ShellResult, ShellStatus,
|
||||
};
|
||||
|
||||
use super::{ApiError, RuntimeApiState, map_thread_err};
|
||||
|
||||
/// `owner_session_id` prefix for jobs launched through this API. A real
|
||||
/// engine session id can never carry it, so `*_for_session` drains stay
|
||||
/// model-owned and API listings can tell the two apart.
|
||||
const API_JOB_SCOPE_PREFIX: &str = "api:";
|
||||
|
||||
const COMMAND_MAX_BYTES: usize = 32 * 1024;
|
||||
const ENV_MAX_ENTRIES: usize = 64;
|
||||
const ENV_KEY_MAX_BYTES: usize = 128;
|
||||
const ENV_VALUE_MAX_BYTES: usize = 8 * 1024;
|
||||
const OUTPUT_CHUNK_DEFAULT: usize = 64 * 1024;
|
||||
const OUTPUT_CHUNK_MAX: usize = 512 * 1024;
|
||||
const OUTPUT_WAIT_MAX_MS: u64 = 30_000;
|
||||
const STDIN_MAX_BYTES: usize = 64 * 1024;
|
||||
const JOB_ID_MAX_BYTES: usize = 128;
|
||||
|
||||
fn api_job_scope(thread_id: &str) -> String {
|
||||
format!("{API_JOB_SCOPE_PREFIX}{thread_id}")
|
||||
}
|
||||
|
||||
fn job_owner(snapshot: &ShellJobSnapshot) -> &'static str {
|
||||
if snapshot.owner_agent_id.is_some() {
|
||||
"subagent"
|
||||
} else if snapshot.owner_session_id.starts_with(API_JOB_SCOPE_PREFIX) {
|
||||
"client"
|
||||
} else {
|
||||
"agent"
|
||||
}
|
||||
}
|
||||
|
||||
fn map_job_err(error: anyhow::Error) -> ApiError {
|
||||
let message = error.to_string();
|
||||
if message.ends_with("not found") {
|
||||
ApiError::not_found(message)
|
||||
} else {
|
||||
ApiError::internal(message)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct JobView {
|
||||
#[serde(flatten)]
|
||||
snapshot: ShellJobSnapshot,
|
||||
thread_id: String,
|
||||
/// `client` = launched through this API, `agent` = launched by the model's
|
||||
/// shell tool, `subagent` = owned by a delegated agent.
|
||||
owner: &'static str,
|
||||
}
|
||||
|
||||
impl JobView {
|
||||
fn new(snapshot: ShellJobSnapshot, thread_id: String) -> Self {
|
||||
let owner = job_owner(&snapshot);
|
||||
Self {
|
||||
snapshot,
|
||||
thread_id,
|
||||
owner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct JobListResponse {
|
||||
jobs: Vec<JobView>,
|
||||
}
|
||||
|
||||
/// `GET /v1/jobs` — every live and known-stale job across all threads.
|
||||
pub(super) async fn list_jobs(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<JobListResponse>, ApiError> {
|
||||
let managers = state.runtime_threads.shell_managers_snapshot().await;
|
||||
let jobs = tokio::task::spawn_blocking(move || {
|
||||
let mut jobs = Vec::new();
|
||||
for (thread_id, manager) in managers {
|
||||
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
|
||||
jobs.extend(
|
||||
guard
|
||||
.list_jobs()
|
||||
.into_iter()
|
||||
.map(|snapshot| JobView::new(snapshot, thread_id.clone())),
|
||||
);
|
||||
}
|
||||
jobs
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("job listing failed"))?;
|
||||
Ok(Json(JobListResponse { jobs }))
|
||||
}
|
||||
|
||||
async fn thread_manager(
|
||||
state: &RuntimeApiState,
|
||||
thread_id: &str,
|
||||
create: bool,
|
||||
) -> Result<crate::tools::shell::SharedShellManager, ApiError> {
|
||||
state
|
||||
.runtime_threads
|
||||
.thread_shell_manager(thread_id, create)
|
||||
.await
|
||||
.map_err(map_thread_err)?
|
||||
.ok_or_else(|| ApiError::not_found(format!("thread {thread_id} has no jobs")))
|
||||
}
|
||||
|
||||
/// `GET /v1/threads/{id}/jobs` — all jobs owned by one thread's manager:
|
||||
/// model-launched, subagent-launched, and client-launched together.
|
||||
pub(super) async fn list_thread_jobs(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(thread_id): Path<String>,
|
||||
) -> Result<Json<JobListResponse>, ApiError> {
|
||||
let Some(manager) = state
|
||||
.runtime_threads
|
||||
.thread_shell_manager(&thread_id, false)
|
||||
.await
|
||||
.map_err(map_thread_err)?
|
||||
else {
|
||||
return Ok(Json(JobListResponse { jobs: Vec::new() }));
|
||||
};
|
||||
let jobs = tokio::task::spawn_blocking(move || {
|
||||
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.list_jobs()
|
||||
.into_iter()
|
||||
.map(|snapshot| JobView::new(snapshot, thread_id.clone()))
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("job listing failed"))?;
|
||||
Ok(Json(JobListResponse { jobs }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct CreateJobRequest {
|
||||
command: String,
|
||||
/// Working directory. Omitted = the thread's workspace.
|
||||
cwd: Option<String>,
|
||||
/// Bounds the foreground-wait contract inside the manager; background
|
||||
/// jobs are never killed at timeout.
|
||||
timeout_ms: Option<u64>,
|
||||
/// Run under a PTY: stderr merges into stdout and the command sees a
|
||||
/// terminal. Required for interactive programs.
|
||||
#[serde(default)]
|
||||
tty: bool,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct CreateJobResponse {
|
||||
job: JobView,
|
||||
}
|
||||
|
||||
/// `POST /v1/threads/{id}/jobs` — launch a client-owned background job under
|
||||
/// the thread's own sandbox policy. The client asking is the approval; the
|
||||
/// thread's posture still bounds what the job may touch.
|
||||
pub(super) async fn create_thread_job(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(thread_id): Path<String>,
|
||||
Json(request): Json<CreateJobRequest>,
|
||||
) -> Result<(StatusCode, Json<CreateJobResponse>), ApiError> {
|
||||
let command = request.command.trim();
|
||||
if command.is_empty() {
|
||||
return Err(ApiError::bad_request("command is required"));
|
||||
}
|
||||
if command.len() > COMMAND_MAX_BYTES {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"command must be at most {COMMAND_MAX_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if request.env.len() > ENV_MAX_ENTRIES {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"env may carry at most {ENV_MAX_ENTRIES} entries"
|
||||
)));
|
||||
}
|
||||
for (key, value) in &request.env {
|
||||
if key.len() > ENV_KEY_MAX_BYTES || key.contains(['=', '\0']) {
|
||||
return Err(ApiError::bad_request("invalid env key"));
|
||||
}
|
||||
if value.len() > ENV_VALUE_MAX_BYTES || value.contains('\0') {
|
||||
return Err(ApiError::bad_request("invalid env value"));
|
||||
}
|
||||
}
|
||||
|
||||
let thread = state
|
||||
.runtime_threads
|
||||
.get_thread(&thread_id)
|
||||
.await
|
||||
.map_err(map_thread_err)?;
|
||||
if !thread.allow_shell {
|
||||
return Err(ApiError::forbidden(
|
||||
"this thread does not allow shell commands",
|
||||
));
|
||||
}
|
||||
if let Some(cwd) = request.cwd.as_deref() {
|
||||
let resolved = std::path::Path::new(cwd);
|
||||
let resolved = if resolved.is_absolute() {
|
||||
resolved.to_path_buf()
|
||||
} else {
|
||||
thread.workspace.join(resolved)
|
||||
};
|
||||
if !resolved.is_dir() {
|
||||
return Err(ApiError::bad_request("cwd must be an existing directory"));
|
||||
}
|
||||
}
|
||||
let policy = state
|
||||
.runtime_threads
|
||||
.thread_job_sandbox_policy(&thread)
|
||||
.await;
|
||||
let manager = thread_manager(&state, &thread_id, true).await?;
|
||||
let scope = api_job_scope(&thread_id);
|
||||
let request_timeout = request.timeout_ms;
|
||||
let request_tty = request.tty;
|
||||
let request_env = request.env;
|
||||
let request_cwd = request.cwd;
|
||||
let command = command.to_string();
|
||||
let job = tokio::task::spawn_blocking(move || -> Result<JobView, ApiError> {
|
||||
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let result = guard
|
||||
.execute_with_options_env_for_session(
|
||||
&command,
|
||||
request_cwd.as_deref(),
|
||||
request_timeout.unwrap_or(120_000),
|
||||
true,
|
||||
None,
|
||||
request_tty,
|
||||
Some(policy),
|
||||
request_env,
|
||||
&scope,
|
||||
)
|
||||
.map_err(|error| ApiError::internal(format!("job launch failed: {error}")))?;
|
||||
let task_id = result.task_id.clone().unwrap_or_default();
|
||||
let snapshot = guard
|
||||
.inspect_job(&task_id)
|
||||
.map_err(|error| {
|
||||
ApiError::internal(format!("job launched but is not tracked: {error}"))
|
||||
})?
|
||||
.snapshot;
|
||||
Ok(JobView::new(snapshot, thread_id))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("job launch failed"))??;
|
||||
Ok((StatusCode::CREATED, Json(CreateJobResponse { job })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct JobDetailResponse {
|
||||
job: JobView,
|
||||
stdout_tail: String,
|
||||
stderr_tail: String,
|
||||
}
|
||||
|
||||
/// `GET /v1/threads/{id}/jobs/{job_id}` — snapshot plus the retained output
|
||||
/// tails. For the full stream, follow `output` with a cursor instead.
|
||||
pub(super) async fn get_thread_job(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path((thread_id, job_id)): Path<(String, String)>,
|
||||
) -> Result<Json<JobDetailResponse>, ApiError> {
|
||||
if job_id.len() > JOB_ID_MAX_BYTES {
|
||||
return Err(ApiError::not_found("job not found"));
|
||||
}
|
||||
let manager = thread_manager(&state, &thread_id, false).await?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let detail = guard.inspect_job(&job_id).map_err(map_job_err)?;
|
||||
Ok(Json(JobDetailResponse {
|
||||
job: JobView::new(detail.snapshot, thread_id),
|
||||
stdout_tail: detail.stdout,
|
||||
stderr_tail: detail.stderr,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("job inspect failed"))?
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct JobOutputQuery {
|
||||
/// `stdout` (default) or `stderr`; PTY jobs merge stderr into stdout.
|
||||
#[serde(default)]
|
||||
stream: Option<String>,
|
||||
/// Absolute byte offset into the stream's lifetime output.
|
||||
#[serde(default)]
|
||||
cursor: Option<usize>,
|
||||
/// Per-request byte ceiling, default 64 KiB, max 512 KiB.
|
||||
#[serde(default)]
|
||||
max_bytes: Option<usize>,
|
||||
/// Long-poll bound for new bytes on a running job, max 30s.
|
||||
#[serde(default)]
|
||||
wait_ms: Option<u64>,
|
||||
/// `base64` (default, exact bytes) or `text` (lossy UTF-8).
|
||||
#[serde(default)]
|
||||
format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct JobOutputResponse {
|
||||
job_id: String,
|
||||
stream: &'static str,
|
||||
/// Absolute offset of `data[0]`; exceeds `cursor` when the bounded buffer
|
||||
/// already discarded that prefix (`dropped` reports the cutoff).
|
||||
offset: usize,
|
||||
/// Next cursor: pass it back to continue the stream.
|
||||
next_cursor: usize,
|
||||
/// Total bytes the stream has produced, including discarded bytes.
|
||||
total: usize,
|
||||
/// Leading bytes permanently discarded by the in-flight bound.
|
||||
dropped: usize,
|
||||
encoding: &'static str,
|
||||
data: String,
|
||||
status: ShellStatus,
|
||||
exit_code: Option<i64>,
|
||||
/// Terminal status and no bytes remain past `next_cursor`.
|
||||
done: bool,
|
||||
}
|
||||
|
||||
/// `GET /v1/threads/{id}/jobs/{job_id}/output` — the resumable byte stream.
|
||||
/// Reads are non-consuming: several clients may hold independent cursors, and
|
||||
/// polling here never steals output from the engine's own delta consumer.
|
||||
pub(super) async fn get_thread_job_output(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path((thread_id, job_id)): Path<(String, String)>,
|
||||
Query(query): Query<JobOutputQuery>,
|
||||
) -> Result<Json<JobOutputResponse>, ApiError> {
|
||||
if job_id.len() > JOB_ID_MAX_BYTES {
|
||||
return Err(ApiError::not_found("job not found"));
|
||||
}
|
||||
let (stream, stream_name) = match query.stream.as_deref().unwrap_or("stdout") {
|
||||
"stdout" => (ShellOutputStream::Stdout, "stdout"),
|
||||
"stderr" => (ShellOutputStream::Stderr, "stderr"),
|
||||
_ => return Err(ApiError::bad_request("stream must be stdout or stderr")),
|
||||
};
|
||||
let cursor = query.cursor.unwrap_or(0);
|
||||
let max_bytes = query.max_bytes.unwrap_or(OUTPUT_CHUNK_DEFAULT);
|
||||
if !(1..=OUTPUT_CHUNK_MAX).contains(&max_bytes) {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"max_bytes must be between 1 and {OUTPUT_CHUNK_MAX}"
|
||||
)));
|
||||
}
|
||||
let wait_ms = query.wait_ms.unwrap_or(0).min(OUTPUT_WAIT_MAX_MS);
|
||||
let format = query.format.as_deref().unwrap_or("base64");
|
||||
if !matches!(format, "base64" | "text") {
|
||||
return Err(ApiError::bad_request("format must be base64 or text"));
|
||||
}
|
||||
let manager = thread_manager(&state, &thread_id, false).await?;
|
||||
let chunk = tokio::task::spawn_blocking({
|
||||
let job_id = job_id.clone();
|
||||
move || {
|
||||
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.read_output_chunk(&job_id, stream, cursor, max_bytes, wait_ms)
|
||||
.map_err(map_job_err)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("job output read failed"))??;
|
||||
Ok(Json(encode_chunk(&job_id, stream_name, chunk, format)))
|
||||
}
|
||||
|
||||
fn encode_chunk(
|
||||
job_id: &str,
|
||||
stream_name: &'static str,
|
||||
chunk: ShellOutputChunk,
|
||||
format: &str,
|
||||
) -> JobOutputResponse {
|
||||
let (encoding, data) = match format {
|
||||
"text" => ("utf-8", String::from_utf8_lossy(&chunk.bytes).into_owned()),
|
||||
_ => (
|
||||
"base64",
|
||||
base64::engine::general_purpose::STANDARD.encode(&chunk.bytes),
|
||||
),
|
||||
};
|
||||
let done = chunk.status != ShellStatus::Running && chunk.next_offset >= chunk.total;
|
||||
JobOutputResponse {
|
||||
job_id: job_id.to_string(),
|
||||
stream: stream_name,
|
||||
offset: chunk.offset,
|
||||
next_cursor: chunk.next_offset,
|
||||
total: chunk.total,
|
||||
dropped: chunk.dropped,
|
||||
encoding,
|
||||
data,
|
||||
status: chunk.status,
|
||||
exit_code: chunk.exit_code,
|
||||
done,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct JobStdinRequest {
|
||||
/// UTF-8 text (default) or base64 for arbitrary bytes.
|
||||
data: String,
|
||||
#[serde(default)]
|
||||
encoding: Option<String>,
|
||||
/// Close stdin after writing (EOF).
|
||||
#[serde(default)]
|
||||
close: bool,
|
||||
}
|
||||
|
||||
/// `POST /v1/threads/{id}/jobs/{job_id}/stdin` — write to a running job's
|
||||
/// stdin. Works for PTY and piped jobs alike.
|
||||
pub(super) async fn write_thread_job_stdin(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path((thread_id, job_id)): Path<(String, String)>,
|
||||
Json(request): Json<JobStdinRequest>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
if job_id.len() > JOB_ID_MAX_BYTES {
|
||||
return Err(ApiError::not_found("job not found"));
|
||||
}
|
||||
let input = match request.encoding.as_deref().unwrap_or("utf-8") {
|
||||
"utf-8" => {
|
||||
if request.data.len() > STDIN_MAX_BYTES {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"data must be at most {STDIN_MAX_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
request.data
|
||||
}
|
||||
"base64" => {
|
||||
if request.data.len() > STDIN_MAX_BYTES * 2 {
|
||||
return Err(ApiError::bad_request("data exceeds the stdin limit"));
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(&request.data)
|
||||
.map_err(|_| ApiError::bad_request("data is not valid base64"))?;
|
||||
if bytes.len() > STDIN_MAX_BYTES {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"data must be at most {STDIN_MAX_BYTES} decoded bytes"
|
||||
)));
|
||||
}
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|_| ApiError::bad_request("stdin data must be valid UTF-8"))?
|
||||
}
|
||||
_ => return Err(ApiError::bad_request("encoding must be utf-8 or base64")),
|
||||
};
|
||||
let close = request.close;
|
||||
let manager = thread_manager(&state, &thread_id, false).await?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.write_stdin(&job_id, &input, close)
|
||||
.map_err(map_job_err)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("job stdin write failed"))??;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct KillJobResponse {
|
||||
job: JobView,
|
||||
result: ShellResult,
|
||||
}
|
||||
|
||||
/// `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL
|
||||
/// escalation on the whole process group; the final snapshot rides along.
|
||||
pub(super) async fn kill_thread_job(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path((thread_id, job_id)): Path<(String, String)>,
|
||||
) -> Result<Json<KillJobResponse>, ApiError> {
|
||||
if job_id.len() > JOB_ID_MAX_BYTES {
|
||||
return Err(ApiError::not_found("job not found"));
|
||||
}
|
||||
let manager = thread_manager(&state, &thread_id, false).await?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let result = guard.kill(&job_id).map_err(map_job_err)?;
|
||||
let snapshot = guard.inspect_job(&job_id).map_err(map_job_err)?.snapshot;
|
||||
Ok(Json(KillJobResponse {
|
||||
job: JobView::new(snapshot, thread_id),
|
||||
result,
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("job kill failed"))?
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! LSP-over-HTTP for native clients (APPS-93).
|
||||
//!
|
||||
//! A native file view needs live diagnostics and semantic references, not just
|
||||
//! receipt-projected ones. These routes serve the *server workspace* through
|
||||
//! one lazily-built [`LspManager`]; engine threads keep their own per-thread
|
||||
//! managers for the post-edit hook. Language servers spawn on first use only —
|
||||
//! a server that never serves an LSP route never pays for one.
|
||||
//!
|
||||
//! Fail-closed as data: a file with no language server, a disabled `[lsp]`
|
||||
//! config, or an LSP timeout all answer `200` with `ok: false` + a machine-
|
||||
//! readable `reason`. Only malformed input (bad path, missing `line`) is an
|
||||
//! HTTP error — a file without a server is a normal state, not a failure.
|
||||
//!
|
||||
//! Routes:
|
||||
//! GET /v1/lsp — capability: enabled, languages, operations
|
||||
//! GET /v1/diagnostics — ?path= (workspace-relative)
|
||||
//! GET /v1/definition — ?path=&line=&character= (1-based)
|
||||
//! GET /v1/references — ?path=&line=&character= (1-based)
|
||||
//! GET /v1/symbols — ?path=&query= (query empty → document symbols)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::{Query, State};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::workspace::{canonical_workspace, precheck_file_target, relative_request_path};
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
use crate::lsp::LspManager;
|
||||
use crate::lsp::registry::{self, Language};
|
||||
|
||||
const LSP_OPERATIONS: &[&str] = &["diagnostics", "symbols", "definition", "references"];
|
||||
const LANGUAGES: &[Language] = &[
|
||||
Language::Rust,
|
||||
Language::Go,
|
||||
Language::Python,
|
||||
Language::TypeScript,
|
||||
Language::JavaScript,
|
||||
Language::Java,
|
||||
Language::Php,
|
||||
Language::Vue,
|
||||
Language::C,
|
||||
Language::Cpp,
|
||||
];
|
||||
|
||||
/// The shared workspace manager: built once, from the live config's `[lsp]`
|
||||
/// table and the canonical workspace root.
|
||||
fn lsp_manager(state: &RuntimeApiState) -> Result<Arc<LspManager>, ApiError> {
|
||||
if let Some(manager) = state.lsp_manager.get() {
|
||||
return Ok(manager.clone());
|
||||
}
|
||||
let workspace = state
|
||||
.workspace
|
||||
.canonicalize()
|
||||
.map_err(|_| ApiError::internal("workspace is unavailable"))?;
|
||||
let config = state
|
||||
.config
|
||||
.read()
|
||||
.lsp
|
||||
.clone()
|
||||
.map(|toml| toml.into_runtime())
|
||||
.unwrap_or_default();
|
||||
Ok(state
|
||||
.lsp_manager
|
||||
.get_or_init(|| Arc::new(LspManager::new(config, workspace)))
|
||||
.clone())
|
||||
}
|
||||
|
||||
/// Resolve a workspace-relative `path` to an absolute file inside the
|
||||
/// workspace, refusing traversal, `.git`, links, and missing files — the same
|
||||
/// confinement the file routes apply.
|
||||
fn resolve_workspace_file(state: &RuntimeApiState, raw: &str) -> Result<PathBuf, ApiError> {
|
||||
let relative = relative_request_path(raw, false)?;
|
||||
let root = canonical_workspace(&state.workspace)?;
|
||||
precheck_file_target(&root, &relative)?.ok_or_else(|| ApiError::not_found("file not found"))?;
|
||||
Ok(root.join(&relative))
|
||||
}
|
||||
|
||||
/// `intelligence` reports ordinary states (disabled, no server) as error
|
||||
/// strings; split them back into the honest machine-readable reasons.
|
||||
fn lsp_failure(error: String) -> Value {
|
||||
let reason = if error.contains("no LSP server") {
|
||||
"no_server"
|
||||
} else if error.contains("disabled") {
|
||||
"lsp_disabled"
|
||||
} else {
|
||||
"lsp_error"
|
||||
};
|
||||
json!({ "ok": false, "reason": reason, "detail": error })
|
||||
}
|
||||
|
||||
async fn run_intelligence(
|
||||
state: &RuntimeApiState,
|
||||
operation: &str,
|
||||
file: PathBuf,
|
||||
line: Option<u32>,
|
||||
character: Option<u32>,
|
||||
query: Option<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let manager = lsp_manager(state)?;
|
||||
if !manager.config().enabled {
|
||||
return Ok(Json(
|
||||
json!({ "ok": false, "reason": "lsp_disabled", "enabled": false }),
|
||||
));
|
||||
}
|
||||
let result = manager
|
||||
.intelligence(operation, &file, line, character, query.as_deref())
|
||||
.await;
|
||||
match result {
|
||||
Ok(mut value) => {
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
object.insert("ok".to_string(), Value::Bool(true));
|
||||
}
|
||||
Ok(Json(value))
|
||||
}
|
||||
Err(error) => Ok(Json(lsp_failure(error))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct LspFileQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct LspPositionQuery {
|
||||
path: String,
|
||||
/// 1-based line; required by definition/references.
|
||||
line: Option<u32>,
|
||||
/// 1-based column; defaults to 1.
|
||||
character: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct LspSymbolsQuery {
|
||||
path: String,
|
||||
query: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /v1/lsp` — capability report: what this runtime can serve without
|
||||
/// probing or spawning anything.
|
||||
pub(super) async fn lsp_status(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let manager = lsp_manager(&state)?;
|
||||
let config = manager.config();
|
||||
let languages: Vec<Value> = LANGUAGES
|
||||
.iter()
|
||||
.filter_map(|language| {
|
||||
registry::server_for(*language)
|
||||
.map(|(command, _)| json!({ "language": language.as_key(), "server": command }))
|
||||
})
|
||||
.collect();
|
||||
let custom: Vec<Value> = config
|
||||
.custom
|
||||
.iter()
|
||||
.map(|(extension, def)| {
|
||||
json!({
|
||||
"extension": extension,
|
||||
"language_id": def.language_id,
|
||||
"server": def.command,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(json!({
|
||||
"enabled": config.enabled,
|
||||
"workspace": state.workspace.display().to_string(),
|
||||
"operations": LSP_OPERATIONS,
|
||||
"languages": languages,
|
||||
"custom_languages": custom,
|
||||
"poll_after_edit_ms": config.poll_after_edit_ms,
|
||||
"max_diagnostics_per_file": config.max_diagnostics_per_file,
|
||||
"include_warnings": config.include_warnings,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn lsp_diagnostics(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<LspFileQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file = resolve_workspace_file(&state, &query.path)?;
|
||||
run_intelligence(&state, "diagnostics", file, None, None, None).await
|
||||
}
|
||||
|
||||
pub(super) async fn lsp_definition(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<LspPositionQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let line = query
|
||||
.line
|
||||
.ok_or_else(|| ApiError::bad_request("definition requires line (1-based)"))?;
|
||||
let file = resolve_workspace_file(&state, &query.path)?;
|
||||
run_intelligence(
|
||||
&state,
|
||||
"definition",
|
||||
file,
|
||||
Some(line),
|
||||
query.character,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn lsp_references(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<LspPositionQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let line = query
|
||||
.line
|
||||
.ok_or_else(|| ApiError::bad_request("references requires line (1-based)"))?;
|
||||
let file = resolve_workspace_file(&state, &query.path)?;
|
||||
run_intelligence(
|
||||
&state,
|
||||
"references",
|
||||
file,
|
||||
Some(line),
|
||||
query.character,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn lsp_symbols(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Query(query): Query<LspSymbolsQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let file = resolve_workspace_file(&state, &query.path)?;
|
||||
run_intelligence(&state, "symbols", file, None, None, query.query).await
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use axum::Json;
|
||||
use axum::extract::{Path, State};
|
||||
use codewhale_config::ConfigStore;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::ApiProvider;
|
||||
|
||||
use super::{ApiError, ProviderCredentialState, RuntimeApiState};
|
||||
|
||||
/// Largest accepted credential payload. Provider keys are single-line
|
||||
/// tokens; anything larger is a mistake, not a longer secret.
|
||||
const MAX_KEY_BYTES: usize = 4 * 1024;
|
||||
|
||||
/// Request body cap for the key route — the key plus JSON framing.
|
||||
pub(super) const PROVIDER_KEY_BODY_LIMIT_BYTES: usize = MAX_KEY_BYTES + 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct SetProviderKeyRequest {
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// Write-only credential entry for native clients (APPS-48).
|
||||
///
|
||||
/// `PUT /v1/providers/{id}/key` accepts `{ "key": "…" }`, persists it through
|
||||
/// the same transactional path as `codewhale auth set` (secret backend plus
|
||||
/// plaintext-free config metadata, rolled back together on failure), and
|
||||
/// answers with the redacted receipt: which backend holds the secret and the
|
||||
/// post-write `credential_state` readback. The key itself — and even its
|
||||
/// length — never appears in the response, in errors, or in logs.
|
||||
///
|
||||
/// There is deliberately no GET: a route that can return a secret can leak
|
||||
/// one. Clients needing assurance re-read `credential_state` here or on
|
||||
/// `GET /v1/providers`.
|
||||
pub(super) async fn set_provider_key(
|
||||
State(state): State<RuntimeApiState>,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<SetProviderKeyRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let provider = ApiProvider::parse(&id)
|
||||
.ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?;
|
||||
if provider == ApiProvider::DeepseekCN {
|
||||
return Err(ApiError::bad_request(
|
||||
"provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead",
|
||||
));
|
||||
}
|
||||
let kind = provider
|
||||
.kind()
|
||||
.ok_or_else(|| ApiError::bad_request("provider has no credential slot"))?;
|
||||
|
||||
let key = request.key;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(ApiError::bad_request("key must not be empty"));
|
||||
}
|
||||
if key.len() > MAX_KEY_BYTES || key.chars().any(char::is_control) {
|
||||
return Err(ApiError::bad_request(
|
||||
"key must be a single-line credential at most 4 KiB",
|
||||
));
|
||||
}
|
||||
|
||||
let secrets = crate::config::credential_secret_store().ok_or_else(|| {
|
||||
ApiError::internal("no credential store is available in this environment")
|
||||
})?;
|
||||
|
||||
let store_path = state.config_path.clone();
|
||||
let kind_owned = kind;
|
||||
let key_owned = key.to_string();
|
||||
let provider_owned = provider;
|
||||
let (backend, saved_config_path) = tokio::task::spawn_blocking(move || {
|
||||
let mut store = ConfigStore::load(store_path)
|
||||
.map_err(|error| ApiError::internal(format!("config store unavailable: {error}")))?;
|
||||
let mut credential_store = codewhale_config::credentials::credential_metadata_store(&store)
|
||||
.map_err(|error| ApiError::internal(format!("credential store: {error}")))?;
|
||||
let target = credential_store.as_mut().unwrap_or(&mut store);
|
||||
let slot = codewhale_config::credentials::provider_slot(kind_owned);
|
||||
crate::credentials::store::with_provider_write_lock(slot, || {
|
||||
codewhale_config::credentials::set_provider_api_key(
|
||||
target, &secrets, kind_owned, &key_owned,
|
||||
)
|
||||
})
|
||||
.map_err(|error| {
|
||||
// The credential-write errors name paths and backends only — the
|
||||
// key material is never embedded in the message.
|
||||
ApiError::internal(format!("credential write failed: {error}"))
|
||||
})?;
|
||||
Ok::<_, ApiError>((
|
||||
secrets.backend_name().to_string(),
|
||||
target.path().to_path_buf(),
|
||||
))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError::internal("credential write task failed"))??;
|
||||
|
||||
// Mirror the persisted credential markers into the live config. The
|
||||
// durable write may have landed on the user-global document while this
|
||||
// server's ambient config is workspace-scoped, and `credential_state`
|
||||
// only probes the secret store for an inactive provider when the
|
||||
// `auth_mode` save marker is visible — without this mirror
|
||||
// `GET /v1/providers` would keep reporting the provider as missing its
|
||||
// credential until the next process start. Only marker fields are
|
||||
// mirrored; the key itself never enters the runtime config.
|
||||
{
|
||||
let mut config = state.config.write();
|
||||
config.auth_mode = Some("api_key".to_string());
|
||||
{
|
||||
let entry = config.provider_config_for_mut(provider_owned);
|
||||
entry.auth_mode = Some("api_key".to_string());
|
||||
entry.external_credentials = None;
|
||||
entry.api_key = None;
|
||||
if provider_owned == ApiProvider::Xai {
|
||||
entry.oauth_credential_generation = None;
|
||||
}
|
||||
}
|
||||
if provider_owned == ApiProvider::Deepseek {
|
||||
config.api_key = None;
|
||||
if config.default_text_model.is_none() {
|
||||
config.default_text_model = config
|
||||
.provider_config_for(ApiProvider::Deepseek)
|
||||
.and_then(|entry| entry.model.clone())
|
||||
.or_else(|| Some("deepseek-v4-pro".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let credential_state: ProviderCredentialState =
|
||||
crate::provider_readiness::credential_state_for_provider(
|
||||
&state.config.read(),
|
||||
provider_owned,
|
||||
)
|
||||
.into();
|
||||
|
||||
Ok(Json(json!({
|
||||
"provider": provider_owned.as_str(),
|
||||
"stored": true,
|
||||
"backend": backend,
|
||||
"credentialState": credential_state,
|
||||
"configPath": saved_config_path,
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Remote / cloud attach surface for native clients (APPS-50).
|
||||
//!
|
||||
//! A "target" is a Codewhale runtime a client talks to. This server is always
|
||||
//! the local target; remote targets are other `codewhale serve --http`
|
||||
//! endpoints, and the *client* owns that registry — a runtime never persists
|
||||
//! peer endpoints, relays sessions, or mints a second process owner. SSH
|
||||
//! workspaces and hosted cloud computers are control-plane features, so those
|
||||
//! routes answer honestly (`supported: false`) and refuse writes with 501
|
||||
//! instead of 404 — a client can render real disabled states with reasons.
|
||||
//!
|
||||
//! Routes:
|
||||
//! GET /v1/targets — this runtime's target record
|
||||
//! POST /v1/targets — 501: target registry is client-owned
|
||||
//! POST /v1/targets/switch — 501: client-owned; never mid-turn server-side
|
||||
//! GET /v1/remote — this runtime's reachability posture
|
||||
//! POST /v1/remote/connect — probe a candidate remote runtime endpoint
|
||||
//! GET /v1/ssh — SSH workspaces: control-plane owned
|
||||
//! POST /v1/ssh/connect — 501
|
||||
//! GET /v1/cloud — hosted computers: control-plane owned
|
||||
//! POST /v1/cloud/attach — 501
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PROBE_MAX_BYTES: usize = 64 * 1024;
|
||||
const CONTROL_PLANE_OWNER: &str = "codewhale-control-plane";
|
||||
|
||||
fn self_target(state: &RuntimeApiState) -> Value {
|
||||
json!({
|
||||
"id": "local",
|
||||
"kind": "local",
|
||||
"current": true,
|
||||
"endpoint": format!("http://{}:{}", display_host(&state.bind_host), state.bind_port),
|
||||
"auth_required": state.auth_required,
|
||||
"service": "codewhale-runtime-api",
|
||||
"codewhale_version": env!("CARGO_PKG_VERSION"),
|
||||
"state": "ready",
|
||||
})
|
||||
}
|
||||
|
||||
fn display_host(host: &str) -> String {
|
||||
if host.parse::<IpAddr>().is_ok_and(|ip| ip.is_ipv6()) {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_surface(feature: &str) -> Value {
|
||||
json!({
|
||||
"supported": false,
|
||||
"owner": CONTROL_PLANE_OWNER,
|
||||
"reason": format!(
|
||||
"{feature} is owned by the Codewhale control plane (managed apps); this runtime does not broker it"
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn list_targets(State(state): State<RuntimeApiState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"targets": [self_target(&state)],
|
||||
// Remote attach means the client points at another runtime's /v1 API;
|
||||
// nothing server-side needs to (or may) switch.
|
||||
"remote": {
|
||||
"supported": true,
|
||||
"attach": "client",
|
||||
"probe": "POST /v1/remote/connect",
|
||||
},
|
||||
"ssh": unsupported_surface("SSH remote workspaces"),
|
||||
"cloud": unsupported_surface("Hosted cloud computers"),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) async fn create_target(State(_state): State<RuntimeApiState>) -> ApiError {
|
||||
ApiError::not_implemented(
|
||||
"target registry is client-owned; attach by pointing the client at a runtime endpoint",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn switch_target(State(_state): State<RuntimeApiState>) -> ApiError {
|
||||
ApiError::not_implemented(
|
||||
"target switching is client-owned; a switch must never move a running task or replay input",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn remote_status(State(state): State<RuntimeApiState>) -> Json<Value> {
|
||||
let loopback_only = super::is_loopback_bind_host(&state.bind_host);
|
||||
Json(json!({
|
||||
"bind_host": state.bind_host,
|
||||
"port": state.bind_port,
|
||||
"loopback_only": loopback_only,
|
||||
"reachable_from_lan": !loopback_only,
|
||||
"auth_required": state.auth_required,
|
||||
"mobile": state.mobile_enabled,
|
||||
// The runtime API has no TLS terminator; non-loopback reachability
|
||||
// assumes a verified overlay (VPN/mesh), never plain LAN trust.
|
||||
"tls": false,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct RemoteConnectRequest {
|
||||
/// Base URL of the candidate runtime, e.g. `http://192.168.1.5:7878`.
|
||||
/// Credentials in the URL are refused — runtime tokens never travel
|
||||
/// through this server.
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
/// Probe a candidate remote endpoint's `/v1/runtime/info`. The probe is
|
||||
/// unauthenticated against the remote on purpose: this route must never be a
|
||||
/// bearer-token forwarding primitive, and `runtime/info` answers signed-out
|
||||
/// identity + `auth_required` without one. A negative reachability verdict is
|
||||
/// data (`ok: false`), not a server error — the probe itself succeeded.
|
||||
pub(super) async fn remote_connect(
|
||||
State(_state): State<RuntimeApiState>,
|
||||
Json(req): Json<RemoteConnectRequest>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let url = reqwest::Url::parse(req.endpoint.trim())
|
||||
.map_err(|_| ApiError::bad_request("endpoint must be an http(s) URL"))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(ApiError::bad_request(
|
||||
"endpoint scheme must be http or https",
|
||||
));
|
||||
}
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(ApiError::bad_request(
|
||||
"credentials in endpoints are refused; send the remote's runtime token from the client",
|
||||
));
|
||||
}
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| ApiError::bad_request("endpoint must name a host"))?;
|
||||
// Origin only — the runtime API always serves at /v1 regardless of the
|
||||
// path a user pasted.
|
||||
let origin = match url.port() {
|
||||
Some(port) => format!("{}://{host}:{port}", url.scheme()),
|
||||
None => format!("{}://{host}", url.scheme()),
|
||||
};
|
||||
let probe_url = format!("{origin}/v1/runtime/info");
|
||||
|
||||
let client = codewhale_release::tls::reqwest_client_builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(PROBE_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|error| ApiError::internal(format!("probe client unavailable: {error}")))?;
|
||||
let response = match client.get(&probe_url).send().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
return Ok(Json(json!({
|
||||
"ok": false,
|
||||
"endpoint": origin,
|
||||
"reason": "unreachable",
|
||||
"detail": error.to_string(),
|
||||
})));
|
||||
}
|
||||
};
|
||||
let status = response.status();
|
||||
let bytes = response.bytes().await.unwrap_or_default();
|
||||
if bytes.len() > PROBE_MAX_BYTES {
|
||||
return Ok(Json(json!({
|
||||
"ok": false,
|
||||
"endpoint": origin,
|
||||
"reason": "response too large to be a runtime identity",
|
||||
})));
|
||||
}
|
||||
let body: Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return Ok(Json(json!({
|
||||
"ok": false,
|
||||
"endpoint": origin,
|
||||
"status": status.as_u16(),
|
||||
"reason": "not a Codewhale runtime",
|
||||
})));
|
||||
}
|
||||
};
|
||||
if body.get("service").and_then(Value::as_str) != Some("codewhale-runtime-api") {
|
||||
return Ok(Json(json!({
|
||||
"ok": false,
|
||||
"endpoint": origin,
|
||||
"status": status.as_u16(),
|
||||
"reason": "not a Codewhale runtime",
|
||||
})));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"endpoint": origin,
|
||||
"remote": {
|
||||
"kind": "remote",
|
||||
"endpoint": origin,
|
||||
"service": "codewhale-runtime-api",
|
||||
"runtime_api_version": body.get("runtime_api_version").cloned().unwrap_or(Value::Null),
|
||||
"codewhale_version": body.get("codewhale_version").cloned().unwrap_or(Value::Null),
|
||||
"auth_required": body.get("auth_required").cloned().unwrap_or(Value::Null),
|
||||
"bind_host": body.get("bind_host").cloned().unwrap_or(Value::Null),
|
||||
"port": body.get("port").cloned().unwrap_or(Value::Null),
|
||||
},
|
||||
// The verdict is all this server knows: attaching means the client
|
||||
// re-targets its own transport at `endpoint` with that runtime's token.
|
||||
"attach": "client",
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn ssh_status(State(_state): State<RuntimeApiState>) -> Json<Value> {
|
||||
Json(unsupported_surface("SSH remote workspaces"))
|
||||
}
|
||||
|
||||
pub(super) async fn ssh_connect(State(_state): State<RuntimeApiState>) -> ApiError {
|
||||
ApiError::not_implemented(
|
||||
"SSH remote workspaces are owned by the Codewhale control plane; this runtime does not open SSH transports",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn cloud_status(State(_state): State<RuntimeApiState>) -> Json<Value> {
|
||||
Json(unsupported_surface("Hosted cloud computers"))
|
||||
}
|
||||
|
||||
pub(super) async fn cloud_attach(State(_state): State<RuntimeApiState>) -> ApiError {
|
||||
ApiError::not_implemented(
|
||||
"hosted cloud computers are owned by the Codewhale control plane; this runtime does not provision them",
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,112 @@
|
||||
//! Voice/dictation HTTP for native clients (APPS-98).
|
||||
//!
|
||||
//! The runtime owns the host microphone and the ASR dispatch — recording and
|
||||
//! transcription are the same implementation the TUI's `/voice` commands run,
|
||||
//! exposed headlessly so a desktop client gets text back instead of driving a
|
||||
//! terminal. There is deliberately no second speech stack and no audio upload
|
||||
//! path: dictate means "record on the host this runtime runs on".
|
||||
//!
|
||||
//! Fail-closed as data: no recorder, no speech, missing provider auth, or an
|
||||
//! ASR failure all answer `200` with `ok: false` + a machine-readable
|
||||
//! `reason` — a desktop client reads `GET /v1/voice` first and disables its
|
||||
//! dictation affordance when `available` is false.
|
||||
//!
|
||||
//! Routes:
|
||||
//! GET /v1/voice — capability: recorder, ASR selection, send phrases
|
||||
//! POST /v1/voice/dictate — record + transcribe → insert text
|
||||
//! POST /v1/voice/send — record + transcribe + send-suffix detection
|
||||
//! POST /v1/voice/control — record + assisted dictation; body {"composer"}
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{ApiError, RuntimeApiState};
|
||||
use crate::commands::voice as voice_core;
|
||||
use voice_core::{DictateError, DictateMode};
|
||||
|
||||
/// One mic per host — serialize captures so concurrent dictate requests get
|
||||
/// an honest "no speech" for the loser rather than fighting over the device.
|
||||
static DICTATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// `GET /v1/voice` — what this host can do: whether a recorder exists, which
|
||||
/// ASR backend the live config resolves to, and the send-suffix contract.
|
||||
pub(super) async fn voice_status(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let (asr_kind, asr_model) = {
|
||||
let config = state.config.read();
|
||||
voice_core::asr_choice(&config)
|
||||
};
|
||||
Ok(Json(json!({
|
||||
"available": voice_core::is_available(),
|
||||
"recorder": voice_core::recorder_command(),
|
||||
"asr": { "kind": asr_kind, "model": asr_model },
|
||||
"modes": ["insert", "send", "control"],
|
||||
"send_phrases": voice_core::SEND_PHRASES,
|
||||
"max_record_seconds": voice_core::MAX_RECORD_SECS,
|
||||
})))
|
||||
}
|
||||
|
||||
fn dictate_failure(error: DictateError) -> Json<Value> {
|
||||
Json(json!({
|
||||
"ok": false,
|
||||
"reason": error.reason(),
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn dictate(state: &RuntimeApiState, mode: DictateMode) -> Result<Json<Value>, ApiError> {
|
||||
// Clone before recording: holding the config lock across a ~10s capture
|
||||
// would stall unrelated config writes for the duration.
|
||||
let config = state.config.read().clone();
|
||||
let _permit = DICTATE_LOCK.lock().await;
|
||||
match voice_core::dictate_once(&config, mode).await {
|
||||
Ok(outcome) => Ok(Json(json!({
|
||||
"ok": true,
|
||||
"text": outcome.text,
|
||||
"send": outcome.send,
|
||||
"assisted": outcome.assisted,
|
||||
"asr": { "kind": outcome.asr_kind, "model": outcome.asr_model },
|
||||
}))),
|
||||
Err(error) => Ok(dictate_failure(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /v1/voice/dictate` — record + transcribe → text to insert.
|
||||
pub(super) async fn voice_dictate(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
dictate(&state, DictateMode::Insert).await
|
||||
}
|
||||
|
||||
/// `POST /v1/voice/send` — dictate with send-suffix detection; the client
|
||||
/// submits when `send` is true (empty `text` = submit the current draft).
|
||||
pub(super) async fn voice_send(
|
||||
State(state): State<RuntimeApiState>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
dictate(&state, DictateMode::Send).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct VoiceControlRequest {
|
||||
/// The client's current composer text — the model sees it for
|
||||
/// AI-assisted dictation (the `/voice-control` pipeline).
|
||||
#[serde(default)]
|
||||
composer: String,
|
||||
}
|
||||
|
||||
/// `POST /v1/voice/control` — assisted dictation with composer context.
|
||||
/// `assisted: false` in the response means a free ASR kind handled the audio
|
||||
/// and the composer text was never seen.
|
||||
pub(super) async fn voice_control(
|
||||
State(state): State<RuntimeApiState>,
|
||||
body: Option<Json<VoiceControlRequest>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let composer = body
|
||||
.map(|Json(request)| request.composer)
|
||||
.unwrap_or_default();
|
||||
dictate(&state, DictateMode::Control(composer)).await
|
||||
}
|
||||
@@ -383,7 +383,7 @@ pub(super) fn map_fs_error(error: std::io::Error, what: &str) -> ApiError {
|
||||
|
||||
/// A workspace-relative request path. Empty and `.` mean the root, which only
|
||||
/// listing accepts. Absolute paths, `..`, backslashes and `.git` are refused.
|
||||
fn relative_request_path(raw: &str, allow_root: bool) -> Result<PathBuf, ApiError> {
|
||||
pub(super) fn relative_request_path(raw: &str, allow_root: bool) -> Result<PathBuf, ApiError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.len() > FILE_PATH_MAX_BYTES {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
@@ -419,7 +419,7 @@ fn relative_request_path(raw: &str, allow_root: bool) -> Result<PathBuf, ApiErro
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn canonical_workspace(workspace: &FsPath) -> Result<PathBuf, ApiError> {
|
||||
pub(super) fn canonical_workspace(workspace: &FsPath) -> Result<PathBuf, ApiError> {
|
||||
workspace
|
||||
.canonicalize()
|
||||
.map_err(|_| ApiError::internal("workspace is unavailable"))
|
||||
@@ -588,7 +588,7 @@ pub(super) fn read_confined_bytes(
|
||||
|
||||
/// Refuse links and non-files before the confined opener runs, so a client
|
||||
/// sees a precise status instead of a generic confinement error.
|
||||
fn precheck_file_target(
|
||||
pub(super) fn precheck_file_target(
|
||||
root: &FsPath,
|
||||
relative: &FsPath,
|
||||
) -> Result<Option<std::fs::Metadata>, ApiError> {
|
||||
|
||||
@@ -50,6 +50,7 @@ use crate::route_runtime::{
|
||||
};
|
||||
use crate::runtime_policy::RuntimePolicyProjection;
|
||||
use crate::tools::plan::new_shared_plan_state;
|
||||
use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
|
||||
use crate::tools::subagent::SubAgentStatus;
|
||||
use crate::tools::todo::new_shared_todo_list;
|
||||
#[cfg(test)]
|
||||
@@ -4034,6 +4035,11 @@ struct ActiveThreadState {
|
||||
struct ActiveThreads {
|
||||
engines: HashMap<String, ActiveThreadState>,
|
||||
lru: VecDeque<String>,
|
||||
/// Per-thread shell/job authority shared with the thread's loaded engine.
|
||||
/// Entries outlive engine LRU eviction so background jobs keep running and
|
||||
/// stay reachable through the jobs API; an entry is removed only when the
|
||||
/// thread itself is removed, which drops the manager and kills its jobs.
|
||||
shell_managers: HashMap<String, SharedShellManager>,
|
||||
}
|
||||
|
||||
pub(crate) struct PreparedThreadFork {
|
||||
@@ -6935,6 +6941,7 @@ impl RuntimeThreadManager {
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.active.lock().await.shell_managers.remove(&thread.id);
|
||||
let _ = self.store.remove_thread(&thread.id);
|
||||
return Err(error);
|
||||
}
|
||||
@@ -6942,7 +6949,7 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
|
||||
pub(crate) async fn discard_empty_thread(&self, thread_id: &str) -> Result<()> {
|
||||
let active = self.active.lock().await;
|
||||
let mut active = self.active.lock().await;
|
||||
if active.engines.contains_key(thread_id) {
|
||||
bail!("cannot discard a loaded Runtime thread");
|
||||
}
|
||||
@@ -6951,6 +6958,10 @@ impl RuntimeThreadManager {
|
||||
if thread.latest_turn_id.is_some() {
|
||||
bail!("cannot discard a Runtime thread that owns turns");
|
||||
}
|
||||
// Drop the thread's shell authority with it: the manager owns any
|
||||
// API-created jobs, and dropping the last handle kills them.
|
||||
active.shell_managers.remove(thread_id);
|
||||
drop(active);
|
||||
self.store.remove_thread(thread_id)
|
||||
}
|
||||
|
||||
@@ -10039,6 +10050,23 @@ impl RuntimeThreadManager {
|
||||
crate::tools::goal::new_shared_goal_state(),
|
||||
),
|
||||
};
|
||||
// One shell/job authority per thread, shared with the engine so
|
||||
// `GET /v1/jobs` sees the same jobs the model sees and background
|
||||
// work survives engine LRU eviction. The engine applies its
|
||||
// per-thread sandbox settings to the manager on construction.
|
||||
let shell_manager = {
|
||||
let mut active = self.active.lock().await;
|
||||
let manager = active
|
||||
.shell_managers
|
||||
.entry(thread.id.clone())
|
||||
.or_insert_with(|| new_shared_shell_manager(thread.workspace.clone()))
|
||||
.clone();
|
||||
drop(active);
|
||||
if let Ok(mut guard) = manager.lock() {
|
||||
guard.set_default_workspace(thread.workspace.clone());
|
||||
}
|
||||
manager
|
||||
};
|
||||
let engine_cfg = EngineConfig {
|
||||
model: route_model.clone(),
|
||||
active_route_limits: route_limits,
|
||||
@@ -10100,7 +10128,7 @@ impl RuntimeThreadManager {
|
||||
Some(Arc::new(self.clone()))
|
||||
},
|
||||
work: None,
|
||||
shell_manager: None,
|
||||
shell_manager: Some(shell_manager),
|
||||
persist_services_enabled: false,
|
||||
hook_executor: None,
|
||||
handle_store: crate::tools::handle::new_shared_handle_store(),
|
||||
@@ -10278,6 +10306,74 @@ impl RuntimeThreadManager {
|
||||
self.ensure_engine_loaded(&thread).await
|
||||
}
|
||||
|
||||
/// The thread's shared shell/job authority — the same manager its engine
|
||||
/// uses — so `/v1/jobs` and the model see one job set. With `create`, an
|
||||
/// API-created job works before the thread's first engine load; without
|
||||
/// it, `None` means the thread has never run shell work.
|
||||
pub async fn thread_shell_manager(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
create: bool,
|
||||
) -> Result<Option<SharedShellManager>> {
|
||||
let thread = self.get_thread(thread_id).await?;
|
||||
let mut active = self.active.lock().await;
|
||||
let manager = match active.shell_managers.entry(thread_id.to_string()) {
|
||||
std::collections::hash_map::Entry::Occupied(entry) => Some(entry.get().clone()),
|
||||
std::collections::hash_map::Entry::Vacant(entry) => create.then(|| {
|
||||
entry
|
||||
.insert(new_shared_shell_manager(thread.workspace.clone()))
|
||||
.clone()
|
||||
}),
|
||||
};
|
||||
drop(active);
|
||||
if let Some(manager) = &manager
|
||||
&& let Ok(mut guard) = manager.lock()
|
||||
{
|
||||
guard.set_default_workspace(thread.workspace.clone());
|
||||
}
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
/// Every live (thread_id, manager) pair, for the flat `GET /v1/jobs`.
|
||||
pub async fn shell_managers_snapshot(&self) -> Vec<(String, SharedShellManager)> {
|
||||
self.active
|
||||
.lock()
|
||||
.await
|
||||
.shell_managers
|
||||
.iter()
|
||||
.map(|(thread_id, manager)| (thread_id.clone(), manager.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The sandbox policy an API-created job inherits — the same posture
|
||||
/// projection a turn applies to its shell calls, so a client terminal
|
||||
/// cannot run looser than the thread's own tools would.
|
||||
pub(crate) async fn thread_job_sandbox_policy(
|
||||
&self,
|
||||
thread: &ThreadRecord,
|
||||
) -> crate::sandbox::SandboxPolicy {
|
||||
let policy = RuntimePolicyProjection::from_persisted(
|
||||
&thread.mode,
|
||||
thread.permission_posture.as_deref(),
|
||||
thread.auto_approve,
|
||||
);
|
||||
let authority = crate::core::authority::TurnAuthority::from_effective_fields(
|
||||
policy.mode,
|
||||
thread.allow_shell,
|
||||
thread.trust_mode,
|
||||
policy.auto_approve(),
|
||||
policy.permission,
|
||||
);
|
||||
let config = self.read_config();
|
||||
authority.sandbox_policy(
|
||||
&thread.workspace,
|
||||
config.sandbox_mode.as_deref(),
|
||||
crate::core::authority::SandboxNetworkAccess::from_config(
|
||||
config.sandbox_network_access,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn restore_thread_messages(&self, thread: &ThreadRecord) -> Result<Vec<Message>> {
|
||||
let turns = self.store.list_turns_for_thread(&thread.id)?;
|
||||
let (mut messages, covered) = self
|
||||
|
||||
@@ -371,6 +371,35 @@ pub struct ShellDeltaResult {
|
||||
pub stderr_total_len: usize,
|
||||
}
|
||||
|
||||
/// Which of a job's raw output streams to read. Stderr is a separate stream
|
||||
/// only for piped jobs; PTY and merged modes fold it into stdout.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShellOutputStream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// A non-consuming window of a job's raw output stream at absolute byte
|
||||
/// offsets. Unlike [`ShellManager::get_output_delta`], reading a chunk never
|
||||
/// advances anyone else's cursor, so several HTTP clients can follow the same
|
||||
/// job without splitting the stream.
|
||||
pub struct ShellOutputChunk {
|
||||
/// Absolute offset of `bytes[0]`. Exceeds the requested cursor when the
|
||||
/// bounded buffer already discarded that prefix — the gap is reported via
|
||||
/// `dropped`, never silently re-sent.
|
||||
pub offset: usize,
|
||||
/// Raw stream bytes. Output is arbitrary bytes, not guaranteed UTF-8.
|
||||
pub bytes: Vec<u8>,
|
||||
/// Absolute offset just past the last returned byte; the next cursor.
|
||||
pub next_offset: usize,
|
||||
/// Total bytes this stream has produced, including discarded bytes.
|
||||
pub total: usize,
|
||||
/// Leading bytes permanently discarded by the in-flight bound.
|
||||
pub dropped: usize,
|
||||
pub status: ShellStatus,
|
||||
pub exit_code: Option<i64>,
|
||||
}
|
||||
|
||||
enum ShellChild {
|
||||
Process(Child),
|
||||
#[cfg(not(target_env = "ohos"))]
|
||||
@@ -1848,6 +1877,13 @@ impl ShellManager {
|
||||
self.sandbox_manager.set_prefer_bwrap(prefer);
|
||||
}
|
||||
|
||||
/// Move the fallback working directory. Callers that pass an explicit
|
||||
/// `working_dir` are unaffected; this only keeps `None` honest when a
|
||||
/// thread's workspace changes while its jobs are still tracked here.
|
||||
pub fn set_default_workspace(&mut self, workspace: PathBuf) {
|
||||
self.default_workspace = workspace;
|
||||
}
|
||||
|
||||
/// Set user-configured bwrap mount extensions (#5410): extra read-only
|
||||
/// roots and writable device nodes such as `/dev/null`.
|
||||
pub fn set_bwrap_extensions(&mut self, extensions: crate::sandbox::BwrapMountExtensions) {
|
||||
@@ -3053,6 +3089,92 @@ impl ShellManager {
|
||||
self.get_output_delta(task_id, wait, timeout_ms)
|
||||
}
|
||||
|
||||
/// Read a job's raw stream at an absolute byte offset without consuming
|
||||
/// anything. This is the `/v1/jobs` byte-stream contract: HTTP clients hold
|
||||
/// the cursor, so reads must not disturb the engine's own delta consumer.
|
||||
///
|
||||
/// `cursor` is a byte offset into the stream's lifetime output (matching
|
||||
/// `total`). When the bounded buffer has already discarded `[0, dropped)`,
|
||||
/// the window starts at `dropped` instead and the caller sees the gap in
|
||||
/// the response rather than a replayed tail. With `wait_ms > 0` on a
|
||||
/// running job, polls up to that bound for new bytes past `cursor` before
|
||||
/// answering — long-poll instead of a hot loop.
|
||||
pub fn read_output_chunk(
|
||||
&mut self,
|
||||
task_id: &str,
|
||||
stream: ShellOutputStream,
|
||||
cursor: usize,
|
||||
max_bytes: usize,
|
||||
wait_ms: u64,
|
||||
) -> Result<ShellOutputChunk> {
|
||||
let Some(shell) = self.processes.get_mut(task_id) else {
|
||||
// Evicted jobs retain only their snapshot tails. Serve that tail as
|
||||
// the final retained window so a late reader still gets the ending
|
||||
// of the stream instead of a bare not-found.
|
||||
let snapshot = self
|
||||
.stale_jobs
|
||||
.get(task_id)
|
||||
.ok_or_else(|| anyhow!("Job {task_id} not found"))?;
|
||||
let (tail, total) = match stream {
|
||||
ShellOutputStream::Stdout => (&snapshot.stdout_tail, snapshot.stdout_len),
|
||||
ShellOutputStream::Stderr => (&snapshot.stderr_tail, snapshot.stderr_len),
|
||||
};
|
||||
let tail_start = total.saturating_sub(tail.len());
|
||||
let offset = cursor.max(tail_start).min(total);
|
||||
let next_offset = offset.saturating_add(max_bytes).min(total);
|
||||
return Ok(ShellOutputChunk {
|
||||
offset,
|
||||
bytes: tail.as_bytes()[offset - tail_start..next_offset - tail_start].to_vec(),
|
||||
next_offset,
|
||||
total,
|
||||
dropped: tail_start,
|
||||
status: snapshot.status.clone(),
|
||||
exit_code: snapshot.exit_code,
|
||||
});
|
||||
};
|
||||
let buffer = match stream {
|
||||
ShellOutputStream::Stdout => shell.stdout_buffer.clone(),
|
||||
ShellOutputStream::Stderr => shell
|
||||
.stderr_buffer
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("Job {task_id} merges stderr into stdout"))?,
|
||||
};
|
||||
|
||||
let wait_deadline = (wait_ms > 0 && shell.status == ShellStatus::Running)
|
||||
.then(|| Instant::now() + Duration::from_millis(wait_ms.clamp(50, 30_000)));
|
||||
loop {
|
||||
shell.poll();
|
||||
let total = buffer.lock().map(|guard| guard.total_len()).unwrap_or(0);
|
||||
let done_waiting = total > cursor
|
||||
|| shell.status != ShellStatus::Running
|
||||
|| wait_deadline.is_none_or(|deadline| Instant::now() >= deadline);
|
||||
if done_waiting {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
let (bytes, offset, next_offset, total, dropped) = {
|
||||
let guard = buffer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let total = guard.total_len();
|
||||
let dropped = guard.dropped();
|
||||
let offset = cursor.max(dropped).min(total);
|
||||
let next_offset = offset.saturating_add(max_bytes).min(total);
|
||||
let retained = guard.retained();
|
||||
let bytes = retained[offset - dropped..next_offset - dropped].to_vec();
|
||||
(bytes, offset, next_offset, total, dropped)
|
||||
};
|
||||
Ok(ShellOutputChunk {
|
||||
offset,
|
||||
bytes,
|
||||
next_offset,
|
||||
total,
|
||||
dropped,
|
||||
status: shell.status.clone(),
|
||||
exit_code: shell.exit_code,
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach durable task context to a live shell job.
|
||||
pub fn tag_linked_task(&mut self, task_id: &str, linked_task_id: Option<String>) -> Result<()> {
|
||||
let shell = self
|
||||
|
||||
@@ -1177,6 +1177,202 @@ tokens but `0.0` cost. Added in v0.8.10 (#564).
|
||||
}
|
||||
```
|
||||
|
||||
### Native client routes (GPUI desktop)
|
||||
|
||||
These families serve the GPUI desktop client over the same bearer-token
|
||||
transport. They reuse the runtime's existing authorities — the engine's shell
|
||||
manager, the durable thread store, the workspace confinement layer, the
|
||||
config's credential plumbing — and add no second runtime, session store,
|
||||
scheduler, or credential store.
|
||||
|
||||
**Jobs** (operator-scoped shell jobs; the terminal surface)
|
||||
- `GET /v1/jobs` — every live and known-stale job across all threads
|
||||
- `GET /v1/threads/{id}/jobs` — jobs owned by one thread's manager:
|
||||
model-launched, subagent-launched, and client-launched together
|
||||
- `POST /v1/threads/{id}/jobs` — `{ "command", "cwd"?, "timeout_ms"?,
|
||||
"tty"?, "env"? }` → `201 { "job" }`; runs as a background shell under the
|
||||
thread's projected sandbox policy. `tty: true` merges stderr into stdout
|
||||
and gives the command a terminal (required for interactive programs);
|
||||
background jobs are never killed at `timeout_ms`
|
||||
- `GET /v1/threads/{id}/jobs/{job_id}` — one job's status + metadata
|
||||
- `GET /v1/threads/{id}/jobs/{job_id}/output?stream=<stdout|stderr>&cursor=
|
||||
<bytes>&max_bytes=<1-512KiB>&wait_ms=<0-30s>&format=<base64|text>` — the
|
||||
resumable byte stream. `{job_id, stream, offset, next_cursor, total,
|
||||
dropped, encoding, data, status, exit_code, done}`: pass `next_cursor`
|
||||
back to continue; `wait_ms` long-polls for new bytes on a running job;
|
||||
`done` means a terminal status and nothing left past the cursor
|
||||
- `POST /v1/threads/{id}/jobs/{job_id}/stdin` — `{ "data", "encoding"?,
|
||||
"close"? }`: `data` is UTF-8 text by default or `base64`, `close: true`
|
||||
sends EOF; works for PTY and piped jobs → `204`
|
||||
- `POST /v1/threads/{id}/jobs/{job_id}/kill` — bounded SIGTERM → SIGKILL
|
||||
escalation on the process group → `{ "job", "result" }` with the final
|
||||
snapshot
|
||||
|
||||
Reads are non-consuming: several clients may hold independent cursors, and
|
||||
polling never steals output from the engine's own delta consumer. The
|
||||
buffer is bounded with exact drop accounting — a reader whose `cursor`
|
||||
falls behind the retained window gets `offset` past it and `dropped > 0`,
|
||||
and must re-anchor. Evicted jobs keep a tail snapshot which the output
|
||||
route serves as the final retained window. Jobs are scoped to the thread
|
||||
that created them and are killed when that thread is removed; the engine's
|
||||
background commands use the same per-thread manager, so `GET /v1/jobs` is
|
||||
also how a client sees model-spawned work.
|
||||
|
||||
**Commands** (typed command catalog, APPS-28)
|
||||
- `GET /v1/commands` — `{commands, locale}`: every registered slash command
|
||||
with `name`, `aliases`, `usage`, `description` (localized to the runtime's
|
||||
configured locale — `locale` echoes the resolved pack so a client can
|
||||
detect fallback), `subcommands`, `discovery` (`primary` / `advanced` /
|
||||
`compatibility`), and the composer hints (`requires_argument`,
|
||||
`requires_required_argument`, `composer_wants_trailing_space`,
|
||||
`palette_runs_directly`, `show_in_empty_discovery`, `unlisted`). The same
|
||||
registry the TUI palette reads — a desktop palette cannot drift from the
|
||||
terminal's. User-registered commands are intentionally absent: they are
|
||||
per-session state, not catalog.
|
||||
|
||||
**Context** (per-thread context pressure, APPS-90)
|
||||
- `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live
|
||||
estimate the visible meter uses), `billed_input_tokens` (last
|
||||
provider-counted prompt size when one exists), `window_tokens`,
|
||||
`output_cap_tokens`, `input_budget_ceiling`, `available_input_tokens`,
|
||||
`compaction_trigger_tokens`, `usage_percent` and `pressure`. Served by the
|
||||
live engine via `Op::GetContextBudget`. Every numeric field is nullable —
|
||||
a route that cannot express a bounded window reports `null` rather than an
|
||||
invented number — and `live: false` marks responses where the engine
|
||||
could not be loaded and only the store-recorded route's static window
|
||||
resolved.
|
||||
|
||||
**Git** (workspace repository operations, APPS-106)
|
||||
- `GET /v1/git` — status detail: `git_repo`, `branch`, `head`,
|
||||
`ahead`/`behind`, counts, per-file porcelain `files[]`
|
||||
(`{path, index, worktree, staged, status, old_path?}`), `branches`,
|
||||
`remotes`
|
||||
- `GET /v1/changes` — the same porcelain `files[]` projection minus repo
|
||||
chrome (branches/remotes): one authority, so the change list can never
|
||||
disagree with the status read
|
||||
- `GET /v1/diff?path=` — one file's unified `diff` against `base` (`HEAD`,
|
||||
or the empty tree on an unborn branch — which reads staged adds as new
|
||||
files). Covers staged+unstaged in one patch; `truncated` reports the
|
||||
512 KiB cap. An untracked file answers `untracked: true` with an empty
|
||||
diff — the client reads the file itself rather than mistaking it for
|
||||
unchanged
|
||||
- `GET /v1/workspace/diff?limit=` — whole-tree patch (default 256 KiB,
|
||||
max 4 MiB) plus a complete `--numstat` `files[]` inventory
|
||||
(`{path, added, deleted}`) so every changed row renders even when the
|
||||
patch is truncated
|
||||
- `GET /v1/git/graph?limit=` — bounded commit rows (`id`, `short`,
|
||||
`parents`, `author`, `timestamp`, `refs`, `subject`); an unborn branch is
|
||||
an empty graph, not an error
|
||||
- `POST /v1/git/stage` `{ "paths": [...] }` or `{ "all": true }`;
|
||||
`POST /v1/git/unstage` same; `POST /v1/git/discard` `{ "paths": [...] }`
|
||||
(tracked paths only — no `all`, an untracked path fails closed);
|
||||
`POST /v1/git/commit` `{ "message", "all"? }`; `POST /v1/git/push`
|
||||
`{ "remote"?, "set_upstream"? }`; `POST /v1/git/branch`
|
||||
`{ "name", "create"? }`
|
||||
|
||||
Reads run through the hardened review command (filters, fsmonitor, hooks,
|
||||
lazy fetches and replace-objects neutralized); writes run through the
|
||||
non-interactive command path (`GIT_TERMINAL_PROMPT=0`, BatchMode ssh) so a
|
||||
credential or host-key prompt can never hang a request. Path lists are
|
||||
workspace-relative under the same confinement as the file routes (traversal
|
||||
→ 400, `.git` → 403), passed after `--` with literal pathspecs. Mutations
|
||||
answer `{ok, output, status}` with the refreshed status, so a client
|
||||
re-reads nothing after an operation. A workspace that is not a repository
|
||||
answers `404`.
|
||||
|
||||
**Diagnostics** (read-only logs, crashes, process — APPS-103)
|
||||
- `GET /v1/logs` → `{sources: [{dir, files: [{name, size, modified}]}]}` —
|
||||
the runtime's log directory plus `audit.log[.1]` from the codewhale home,
|
||||
newest first, capped
|
||||
- `GET /v1/logs/{name}?offset=<bytes>&limit=<bytes>&tail=<bytes>` →
|
||||
`{name, size, modified, offset, bytes, truncated, encoding, content}` —
|
||||
one bounded window; `tail` reads from the end and is mutually exclusive
|
||||
with `offset`; `truncated` means bytes remain after the returned window
|
||||
(a tail read at EOF is `false`), `encoding` is `utf-8` or `base64`
|
||||
- `GET /v1/crashes`, `GET /v1/crashes/{name}` — the same list/read contract
|
||||
over the crash-dump directories (`~/.codewhale/crashes`, legacy
|
||||
`~/.deepseek/crashes` merged)
|
||||
- `GET /v1/process` → `{pid, version, commit, started_at, uptime_seconds,
|
||||
executable, rss_bytes}` — `rss_bytes` only where the platform reports it
|
||||
(Linux `/proc`); absent rather than fabricated elsewhere
|
||||
|
||||
These routes package what already exists on disk for a client-side export;
|
||||
there is no telemetry upload route and no second log store. Names are
|
||||
basename-validated (no separators, no `..`), listings are capped, reads are
|
||||
bounded windows, and symlinks are never followed — a client bundles the
|
||||
files itself.
|
||||
|
||||
**Targets and remote posture** (APPS-50)
|
||||
- `GET /v1/targets` → `{targets: [self], remote: {supported: true,
|
||||
attach: "client", probe: "POST /v1/remote/connect"}, ssh: {…},
|
||||
cloud: {…}}` — this runtime's own record as the attachable target plus
|
||||
per-surface ownership; the runtime keeps no persistent target registry,
|
||||
so `POST /v1/targets` and `POST /v1/targets/switch` answer
|
||||
`501 Not Implemented` — target selection is client-owned and a switch
|
||||
must never move a running task server-side
|
||||
- `GET /v1/remote` → `{bind_host, port, loopback_only, reachable_from_lan,
|
||||
auth_required, mobile, tls}` — this listener's reachability posture.
|
||||
`tls` is always `false`: the API has no TLS terminator, so non-loopback
|
||||
reachability assumes a verified overlay (VPN/mesh), never plain LAN trust
|
||||
- `POST /v1/remote/connect` `{ "endpoint": "http://host:port" }` — probes a
|
||||
candidate remote's unauthenticated `GET /v1/runtime/info` (origin only;
|
||||
any pasted path is discarded). Answers `{ok, remote: {endpoint,
|
||||
runtime_api_version, codewhale_version, auth_required, …}, attach:
|
||||
"client"}` on success, and `{ok: false, reason: "unreachable" |
|
||||
"not a Codewhale runtime" | …}` as data on failure. URLs carrying
|
||||
credentials are refused with 400 — the remote's token is configured
|
||||
client-side, and a connect route that forwarded one would be an
|
||||
exfiltration primitive
|
||||
- `GET /v1/ssh`, `GET /v1/cloud` → `{supported: false, owner:
|
||||
"codewhale-control-plane", reason}`; `POST /v1/ssh/connect` and
|
||||
`POST /v1/cloud/attach` → `501`: SSH workspace provisioning and hosted
|
||||
cloud computers belong to the Apps control plane (ASCII Box for Managed
|
||||
Computer), not to a second authority inside Core
|
||||
|
||||
A remote Codewhale is a `serve --http` runtime with a token — that is the
|
||||
whole attach model. These routes describe and probe it; they never execute
|
||||
a remote request on the local machine.
|
||||
|
||||
**LSP** (workspace language intelligence, APPS-93)
|
||||
- `GET /v1/lsp` — capability: `enabled`, supported `languages` with their
|
||||
server commands, `custom_languages`, operations, poll and diagnostic caps
|
||||
- `GET /v1/diagnostics?path=` — file diagnostics
|
||||
- `GET /v1/definition?path=&line=&character=` (1-based)
|
||||
- `GET /v1/references?path=&line=&character=` (1-based)
|
||||
- `GET /v1/symbols?path=&query=` — empty query returns document symbols
|
||||
|
||||
One lazily-built workspace-level `LspManager` serves these; engine threads
|
||||
keep their own per-thread managers for the post-edit hook, and a server that
|
||||
never serves an LSP route never spawns a language server. `path` is
|
||||
workspace-relative under the same confinement as the file routes. Normal
|
||||
absence is data: no language server, a disabled `[lsp]` config, or a timeout
|
||||
answers `200` with `ok: false` and a machine-readable `reason`
|
||||
(`no_server`, `lsp_disabled`, `lsp_error`); malformed input is a 400 and a
|
||||
missing file a 404.
|
||||
|
||||
**Voice** (host dictation, APPS-98)
|
||||
- `GET /v1/voice` — capability: `available`, detected `recorder` command,
|
||||
resolved `asr` `{kind, model}`, `modes`, `send_phrases`,
|
||||
`max_record_seconds`
|
||||
- `POST /v1/voice/dictate` — record then transcribe → `{ ok, text }`
|
||||
- `POST /v1/voice/send` — same capture with the "send it" / 发送/發送
|
||||
suffix contract: `send: true` tells the client to submit (empty `text`
|
||||
with `send: true` means submit the client's current draft)
|
||||
- `POST /v1/voice/control` `{ "composer": "draft text" }` — assisted
|
||||
dictation that shows the model the composer text; `assisted: false` in the
|
||||
response means a free ASR backend (local whisper/Groq) handled the audio
|
||||
and the composer context was never seen
|
||||
|
||||
The runtime owns the host microphone and the ASR dispatch — the same
|
||||
implementation the TUI's `/voice` commands run, headless. Recording is one
|
||||
blocking capture per host (requests serialize; the loser gets
|
||||
`ok:false`/`no_speech`, not a fought-over device). Provider ASR resolves its
|
||||
key lazily so local-whisper and Groq paths work without provider auth.
|
||||
Failure is data: `no_recorder`, `no_speech`, `no_provider_auth`,
|
||||
`transcription_failed`. `CODEWHALE_DISABLE_VOICE=1` is an operator
|
||||
kill-switch — a headless `serve --http` host reports `available: false` and
|
||||
every dictate call fails closed.
|
||||
|
||||
## Provider and model selection
|
||||
|
||||
These three routes are how a GUI renders a model picker whose contents are true
|
||||
@@ -1311,6 +1507,36 @@ alongside the selected model. Omit `model_provider_id` when it is null:
|
||||
This creates one thread on the exact named custom route without changing the
|
||||
Runtime's provider or model defaults.
|
||||
|
||||
### `PUT /v1/providers/{id}/key` — write-only credential
|
||||
|
||||
```json
|
||||
// request
|
||||
{ "key": "sk-…" }
|
||||
|
||||
// response
|
||||
{ "provider": "openai-codex", "stored": true, "backend": "keychain",
|
||||
"credentialState": "configured", "configPath": "/…/config.toml" }
|
||||
```
|
||||
|
||||
Stores a provider API key through the same transactional write as
|
||||
`codewhale auth set --provider <id> --api-key-stdin`: the secret store under
|
||||
the provider write lock, plus the `[providers.<id>] auth_mode` metadata
|
||||
marker persisted to the config document and mirrored into the live runtime
|
||||
config so `GET /v1/providers` reports the new state immediately. `backend`
|
||||
names which secret backend holds the key and `configPath` which config
|
||||
document carries the marker (the user-global file when the ambient config
|
||||
is workspace-scoped).
|
||||
|
||||
The key is never returned — there is no read route for credential material,
|
||||
and neither the key nor its length appears in the response, errors, or
|
||||
logs; the response carries only the readiness projection
|
||||
(`credentialState`). An unknown provider id, the `deepseek-cn` legacy
|
||||
alias, an empty key, a key over 4 KiB, or one containing control characters
|
||||
is `400`. `credentialState: "local"` after a successful write is honest
|
||||
output for a keyless local route: the key is stored, but the route
|
||||
classifies as not needing one. Deleting a key remains a CLI/operator
|
||||
action — there is deliberately no `DELETE` here.
|
||||
|
||||
### `POST /v1/providers/{id}/switch`
|
||||
|
||||
```json
|
||||
|
||||
Reference in new issue
Block a user