diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3151d6241..294bb655f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -18,6 +18,7 @@ Describe the user-visible change. - [ ] Relevant desktop build: `flutter build macos`, `flutter build windows`, or `flutter build linux` - [ ] Landing checks, if applicable: `cd landing && bun run check` - [ ] Added or updated tests that would catch regressions, or explained why tests were not needed +- [ ] CLI skills (`skills/`) and MCP skills (`edge/skills/`) reviewed for any changed command, runtime verb, or MCP tool, with versions bumped and `bun tool/skill_catalog.ts` rerun when they changed (see `AGENTS.md`, Agent And MCP Skills) ## AI Review Report diff --git a/AGENTS.md b/AGENTS.md index b58e7a4c0..fd4754967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,7 +226,7 @@ When planning is needed, use a spec-driven development flow. Do not jump straigh ## Pull Request Watch -- GitHub Watch and Fix execution belongs to the runtime when `pullRequestWatchExecutionV1` is advertised. Clients use the shared persisted watch and `pullRequestWatchChanged`; they MUST NOT also dispatch or merge locally. Keep the capability in the control file, `status.get`, and `mobile.hello`. See `docs/pull-request-watch.md` for execution and compatibility boundaries. +- Watch and Fix execution belongs to the runtime for GitHub when `pullRequestWatchExecutionV1` is advertised, and for GitHub, GitLab, and Azure DevOps when `pullRequestWatchExecutionV2` is. Clients use the shared persisted watch and `pullRequestWatchChanged`; they MUST NOT also dispatch or merge locally for a forge the runtime owns. Keep both capabilities, `pullRequestForgesV1`, `pullRequestAgentDispatchV1`, and `pullRequestStacksV1` in the control file and `status.get` (and the first three in `mobile.hello`). Forge work in the runtime goes through `ForgeProvider` (`rust/alera-cli/src/terminal_host/server/pull_request_forges/`), ported from the desktop providers; keep the shared fixtures in `test/fixtures/forges/` passing in both `forge_shared_fixtures_test.dart` and the Rust `fixture_tests.rs` when either side changes. See `docs/pull-request-watch.md` for execution and compatibility boundaries. ## Cloud Accounts And Mobile Push @@ -278,10 +278,53 @@ When planning is needed, use a spec-driven development flow. Do not jump straigh ## Documentation Maintenance -- After every feature, refactor, fix, or infrastructure change, explicitly consider whether `AGENTS.md`, nested `AGENTS.md` files, `readme.md`, `docs/`, `.github/CONTRIBUTING.md`, `SECURITY.md`, or release documentation need updates. +- After every feature, refactor, fix, or infrastructure change, explicitly consider whether `AGENTS.md`, nested `AGENTS.md` files, `readme.md`, `docs/`, the agent skills in `skills/` and `edge/skills/`, `.github/CONTRIBUTING.md`, `SECURITY.md`, or release documentation need updates. - If documentation does not need updates, mention that decision in the final summary or PR notes when the change is user-visible, architectural, process-related, release-related, or contributor-facing. - Keep documentation aligned with implemented behavior. Do not document planned behavior as active behavior. +## Agent And MCP Skills + +Alera ships two sets of skills that teach agents how to use it. + +- **CLI skills:** + - They live in `skills/`: `alera-cli`, `alera-orchestration`, `alera-automations`, and `alera-agent-profiles`. + - They are for coding agents running in Alera terminals, and they name `alera` commands. + - `skills add` installs them at the runtime's build commit. +- **MCP sister skills:** + - They live in `edge/skills/`: `alera-mcp`, `alera-mcp-orchestration`, `alera-mcp-automations`, and `alera-mcp-agent-profiles`. + - They are for MCP clients, and they name MCP tools instead of commands. + - Only the edge serves them, through `list_skills` and `read_skill`. Runtimes MUST NOT bundle or serve them. + +### When To Update Them + +- Review both sets in the same change whenever a change touches a CLI command, runtime verb, or MCP tool. That means adding, removing, or renaming one, or changing its behavior, arguments, defaults, limits, or errors. Update every skill and reference the change makes stale. +- When a capability exists as both a CLI command and an MCP tool, update the CLI skill and its MCP sister together, so the two do not drift apart. + +### Regenerating The MCP Catalog + +- After editing `edge/skills/`, regenerate `edge/src/mcp/skill_catalog.json` by running `bun tool/skill_catalog.ts` from `edge/`. +- `edge/test/mcp_skills.test.ts` fails in any of these cases: + - the generated catalog is stale; + - a tool in the MCP catalog is not explained by any MCP skill; + - a skill names a tool that does not exist; + - a link inside a skill does not resolve. +- Adding an MCP tool therefore requires documenting it in an MCP skill. + +### Bumping Versions + +- **CLI skills:** changing any file under `skills//` MUST bump three things together: + 1. the `metadata.version` in that skill's `SKILL.md`; + 2. the matching constant in `rust/alera-cli/src/terminal_host/protocol.rs` (`CLI_SKILL_VERSION`, `ORCHESTRATION_SKILL_VERSION`, `AUTOMATIONS_SKILL_VERSION`, or `AGENT_PROFILES_SKILL_VERSION`); + 3. the version and digest in `rust/alera-cli/tests/skill_version_matches_binary.rs`. The test prints the digest it expects. +- **Why it matters for CLI skills:** `alera skill status` and `check_agent_skills` decide whether an installed copy is current by its version. +- **MCP skills:** bump an MCP skill's `metadata.version` when its guidance changes in substance. + +### Writing Them + +- Skills describe implemented behavior only. +- Keep `SKILL.md` short and route to references, one workflow per reference, so agents load only what they need. +- Say in the PR summary which skills were reviewed and whether they changed. + ## Nested Instructions - `landing/AGENTS.md` applies under `landing/`. diff --git a/cloud/.env.example b/cloud/.env.example index a5cc893da..9691b3b86 100644 --- a/cloud/.env.example +++ b/cloud/.env.example @@ -28,6 +28,16 @@ ALERA_WEB_GITHUB_CLIENT_SECRET= ALERA_FCM_MODE=disabled ALERA_FCM_PROJECT_ID=replace-me ALERA_PUSH_DELIVERY_ENABLED=true +# Webhooks and MCP Events. Base64 of 32 random bytes; without it nothing is created or sent. +ALERA_WEBHOOK_SECRET_KEY= +# Set only while rotating ALERA_WEBHOOK_SECRET_KEY; decrypts existing secrets. +ALERA_WEBHOOK_PREVIOUS_SECRET_KEY= +# OpenAI MCP Events subscriptions. Defaults to false. +ALERA_MCP_EVENTS_ENABLED=false +# Background fan-out and delivery loop. Defaults to true. +ALERA_EVENT_WORKER_ENABLED=true +# Development only: allow webhook callbacks on HTTPS ports other than 443. +ALERA_WEBHOOK_ALLOW_ANY_PORT=false ALERA_TOMBSTONE_PEPPER=replace-me-with-at-least-32-random-characters ALERA_MAX_RUNTIMES=10 ALERA_MAX_MOBILE_DEVICES=5 diff --git a/cloud/Cargo.lock b/cloud/Cargo.lock index 24c7c27d6..4811de646 100644 --- a/cloud/Cargo.lock +++ b/cloud/Cargo.lock @@ -17,6 +17,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "aws-lc-rs", "axum", "base64 0.23.1", "chrono", diff --git a/cloud/Cargo.toml b/cloud/Cargo.toml index a58190318..de47c92ad 100644 --- a/cloud/Cargo.toml +++ b/cloud/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.104" async-trait = "0.1.92" +aws-lc-rs = { version = "1.18.0", default-features = false, features = ["aws-lc-sys"] } axum = { version = "0.8.9", features = ["http1", "json", "tokio"] } base64 = "0.23.1" chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde", "std"] } diff --git a/cloud/migrations/0024_mcp_admin.sql b/cloud/migrations/0024_mcp_admin.sql new file mode 100644 index 000000000..7576f20ef --- /dev/null +++ b/cloud/migrations/0024_mcp_admin.sql @@ -0,0 +1,9 @@ +ALTER TABLE runtimes + DROP CONSTRAINT runtimes_mcp_access_check, + ADD CONSTRAINT runtimes_mcp_access_check + CHECK (mcp_access IN ('off', 'read', 'full', 'admin')); + +ALTER TABLE mcp_calls + DROP CONSTRAINT mcp_calls_access_check, + ADD CONSTRAINT mcp_calls_access_check + CHECK (access IN ('read', 'execute', 'admin')); diff --git a/cloud/migrations/0025_domain_events.sql b/cloud/migrations/0025_domain_events.sql new file mode 100644 index 000000000..c5ef63263 --- /dev/null +++ b/cloud/migrations/0025_domain_events.sql @@ -0,0 +1,70 @@ +-- Runtime domain events forwarded for webhooks and MCP Events. Events are kept for 24 hours. +CREATE TABLE domain_events ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + runtime_id TEXT NOT NULL, + event_id TEXT NOT NULL, + seq BIGINT NOT NULL, + kind TEXT NOT NULL, + workspace_id TEXT, + project_id TEXT, + data JSONB NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL, + fanned_out_at TIMESTAMPTZ, + CONSTRAINT domain_events_runtime_event_key UNIQUE (runtime_id, event_id) +); +CREATE INDEX domain_events_received_idx ON domain_events (received_at); +CREATE INDEX domain_events_fanout_idx ON domain_events (received_at) WHERE fanned_out_at IS NULL; +CREATE INDEX domain_events_account_idx ON domain_events (account_id, received_at); + +-- Webhooks created by a signed-in runtime and MCP Events subscriptions bound to an MCP grant. +CREATE TABLE event_subscriptions ( + id TEXT PRIMARY KEY, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL CONSTRAINT event_subscriptions_target_kind_check + CHECK (target_kind IN ('webhook', 'mcp_events')), + callback_url TEXT NOT NULL, + secret_ciphertext TEXT NOT NULL, + previous_secret_ciphertext TEXT, + previous_secret_until TIMESTAMPTZ, + kinds TEXT[] NOT NULL, + runtime_ids TEXT[] NOT NULL DEFAULT '{}', + all_runtimes BOOLEAN NOT NULL, + status TEXT NOT NULL CONSTRAINT event_subscriptions_status_check + CHECK (status IN ('active', 'stopped', 'revoked', 'expired')), + refresh_before TIMESTAMPTZ, + owner_grant_id UUID REFERENCES mcp_grants(id) ON DELETE CASCADE, + filter JSONB NOT NULL DEFAULT '{}'::jsonb, + created_by_runtime_id TEXT, + verified_at TIMESTAMPTZ, + last_delivery_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + CONSTRAINT event_subscriptions_owner_check + CHECK (target_kind = 'webhook' OR owner_grant_id IS NOT NULL) +); +CREATE INDEX event_subscriptions_account_idx ON event_subscriptions (account_id, status); +CREATE INDEX event_subscriptions_grant_idx ON event_subscriptions (owner_grant_id); + +-- One delivery per subscription and event. `next_attempt_at` doubles as the lease +-- deadline while a worker holds the row in `sending`. +CREATE TABLE event_deliveries ( + id UUID PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES event_subscriptions(id) ON DELETE CASCADE, + event_id UUID NOT NULL REFERENCES domain_events(id) ON DELETE CASCADE, + status TEXT NOT NULL CONSTRAINT event_deliveries_status_check + CHECK (status IN ('pending', 'sending', 'delivered', 'stopped', 'dead')), + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL, + lease_until TIMESTAMPTZ, + last_status INTEGER, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL, + delivered_at TIMESTAMPTZ, + CONSTRAINT event_deliveries_subscription_event_key UNIQUE (subscription_id, event_id) +); +CREATE INDEX event_deliveries_due_idx ON event_deliveries (next_attempt_at) + WHERE status IN ('pending', 'sending'); +CREATE INDEX event_deliveries_event_idx ON event_deliveries (event_id); diff --git a/cloud/readme.md b/cloud/readme.md index 8ba7c5ef8..a0765b653 100644 --- a/cloud/readme.md +++ b/cloud/readme.md @@ -18,7 +18,7 @@ cargo run Local mode uses a deterministic Ed25519 development seed and disables FCM. Neither setting is acceptable in production. -The service applies required schema migrations 0001-0004 and 0021-0023 before opening its listener, then starts the explicitly allowlisted performance-index migrations 0005-0020 in a bounded background task after `/health` is available. The online phase takes a non-blocking session lock, builds one index at a time with a generous per-index deadline, logs a failure, and retries on the next startup or guarded operator phase. A dirty SQLx migration row remains an operator error and is never cleared automatically. Retention cleanup runs once after bind and repeats every six hours while an instance is active. +The service applies required schema migrations 0001-0004 and 0021-0024 before opening its listener, then starts the explicitly allowlisted performance-index migrations 0005-0020 in a bounded background task after `/health` is available. The online phase takes a non-blocking session lock, builds one index at a time with a generous per-index deadline, logs a failure, and retries on the next startup or guarded operator phase. A dirty SQLx migration row remains an operator error and is never cleared automatically. Retention cleanup runs once after bind and repeats every six hours while an instance is active. ## HTTP Contract @@ -55,6 +55,15 @@ All JSON uses camelCase. Every route except `GET /health` requires the `x-alera- | `GET` | `/v1/mcp/runtimes` | Bearer MCP | Runtimes the MCP grant reaches | | `POST` | `/v1/mcp/calls` | Bearer MCP | Resolve a runtime, audit the call, and sign a 120-second call grant | | `POST` | `/v1/mcp/calls/{id}/outcome` | Bearer MCP | Record the call outcome once | +| `POST` | `/v1/runtime/domain-events` | Bearer runtime (`events:send` or `push:send`) | Store up to 100 idempotent domain events for webhooks and MCP Events | +| `GET` | `/v1/runtime/event-subscriptions` | Bearer runtime | Count active webhooks and MCP Events subscriptions for the runtime | +| `GET`, `POST` | `/v1/webhooks` | Bearer runtime | List or create the account's webhooks; creation returns the `whsec_` secret once | +| `DELETE` | `/v1/webhooks/{id}` | Bearer runtime | Delete a webhook | +| `POST` | `/v1/webhooks/{id}/test` | Bearer runtime | Queue a signed `alera.test` delivery | +| `POST` | `/v1/mcp/event-subscriptions` | Bearer MCP (edge only) | Verify a callback and store an MCP Events subscription bound to the grant | +| `POST` | `/v1/mcp/event-subscriptions/unsubscribe` | Bearer MCP (edge only) | Remove a subscription by identity; idempotent | +| `DELETE` | `/v1/mcp/event-subscriptions/{id}` | Bearer MCP (edge only) | Remove a subscription by id; idempotent | +| `POST` | `/v1/internal/event-deliveries/pump` | Edge only | Run one bounded fan-out and delivery drain (edge cron) | The OAuth 2.1 authorization server for Remote MCP (`/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`, `/oauth/*`, and the `/device` page) follows [`../docs/remote-mcp.md`](../docs/remote-mcp.md). Its endpoints use the standard snake_case OAuth field names. `ALERA_MCP_ENABLED=false` removes the authorization server and gateway routes; device sign-in and grant management stay available. @@ -90,7 +99,7 @@ The relay sees connection metadata, frame sizes, and timing only. The runtime an Provider access tokens and authorization codes are used only during exchange and are never stored. PostgreSQL stores account emails and provider ids, hashed refresh tokens, runtime and device metadata, relay public keys, FCM tokens required for delivery, subscriptions, event payloads, and delivery outcomes. It does not store relay frames or private identity keys. Relay grants are short-lived JWTs and are not persisted. -Cleanup removes expired OAuth transactions and enrollment codes after one day, runtime events and delivery attempts after 30 days, hourly and burst quota rows after seven days, daily quota rows after 90 days, expired or revoked sessions after their retention window, and tombstones when they expire. Cloud Run with zero minimum instances performs this work after service activity, so an entirely inactive database may retain expired operational rows until the next startup. +Domain events and their webhook deliveries are kept for 24 hours; webhook and MCP Events signing secrets are encrypted with AES-256-GCM under `ALERA_WEBHOOK_SECRET_KEY` (see [`../docs/remote-mcp.md`](../docs/remote-mcp.md#events-and-webhooks)). Cleanup removes expired OAuth transactions and enrollment codes after one day, runtime events and delivery attempts after 30 days, hourly and burst quota rows after seven days, daily quota rows after 90 days, expired or revoked sessions after their retention window, and tombstones when they expire. Cloud Run with zero minimum instances performs this work after service activity, so an entirely inactive database may retain expired operational rows until the next startup. Account deletion removes active account data transactionally. It keeps only HMAC-protected provider-identity tombstones for 90 days to prevent immediate quota or ban reset. diff --git a/cloud/src/api.rs b/cloud/src/api.rs index c2924f2a4..6f3f5e546 100644 --- a/cloud/src/api.rs +++ b/cloud/src/api.rs @@ -1,7 +1,7 @@ use axum::{ body::Body, extract::{DefaultBodyLimit, Request, State}, - http::{HeaderValue, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post, put}, @@ -66,6 +66,7 @@ pub fn router(state: AppState) -> Router { .route("/v1/relay/grants", post(relay::create_grant)) .route("/v1/mobile/runtimes", get(relay::discover_runtimes)) .merge(crate::mcp_oauth::router(state.config.mcp.enabled)) + .merge(crate::events::router()) .layer(DefaultBodyLimit::max(64 * 1024)) .layer(TraceLayer::new_for_http().make_span_with(request_span)) .layer(middleware::from_fn_with_state( @@ -98,26 +99,34 @@ async fn require_edge_origin( if request.uri().path() == "/health" || state.config.allow_direct_origin { return Ok(next.run(request).await); } - let provided = request - .headers() - .get("x-alera-origin-auth") - .and_then(header_text); - let valid = provided.is_some_and(|provided| { - token_matches( - provided, - state.config.edge_origin_token.as_deref(), - state.config.edge_previous_origin_token.as_deref(), - ) - }); - if !valid { - return Err(ApiError::unauthorized( - "invalid_origin", - "The request did not arrive through the Alera edge.", - )); - } + require_origin_token(request.headers(), &state)?; Ok(next.run(request).await) } +/// Requires the edge origin token (current or previous) on `headers`, whatever +/// `ALLOW_DIRECT_ORIGIN` says. Internal routes call it themselves so they stay closed +/// when direct origin access is allowed; with no token configured they are unreachable. +pub(crate) fn require_origin_token(headers: &HeaderMap, state: &AppState) -> Result<(), ApiError> { + if origin_token_valid( + headers, + state.config.edge_origin_token.as_deref(), + state.config.edge_previous_origin_token.as_deref(), + ) { + return Ok(()); + } + Err(ApiError::unauthorized( + "invalid_origin", + "The request did not arrive through the Alera edge.", + )) +} + +fn origin_token_valid(headers: &HeaderMap, current: Option<&str>, previous: Option<&str>) -> bool { + headers + .get("x-alera-origin-auth") + .and_then(header_text) + .is_some_and(|provided| token_matches(provided, current, previous)) +} + fn header_text(value: &HeaderValue) -> Option<&str> { value.to_str().ok().filter(|value| !value.is_empty()) } @@ -137,7 +146,9 @@ fn token_matches(provided: &str, current: Option<&str>, previous: Option<&str>) #[cfg(test)] mod tests { - use super::{request_span, token_matches}; + use axum::http::{HeaderMap, HeaderValue}; + + use super::{origin_token_valid, request_span, token_matches}; #[derive(Clone, Default)] struct Captured(std::sync::Arc>>); @@ -189,4 +200,15 @@ mod tests { assert!(token_matches("old", Some("new"), Some("old"))); assert!(!token_matches("other", Some("new"), Some("old"))); } + + #[test] + fn origin_token_is_required_and_never_matches_when_unset() { + let mut headers = HeaderMap::new(); + assert!(!origin_token_valid(&headers, Some("new"), None)); + headers.insert("x-alera-origin-auth", HeaderValue::from_static("new")); + assert!(origin_token_valid(&headers, Some("new"), None)); + assert!(!origin_token_valid(&headers, None, None)); + headers.insert("x-alera-origin-auth", HeaderValue::from_static("")); + assert!(!origin_token_valid(&headers, Some(""), None)); + } } diff --git a/cloud/src/api_models.rs b/cloud/src/api_models.rs index 5b67741cc..fd45ec5e8 100644 --- a/cloud/src/api_models.rs +++ b/cloud/src/api_models.rs @@ -68,6 +68,7 @@ impl ClientKind { "configuration:read".to_owned(), "configuration:write".to_owned(), "enrollment:write".to_owned(), + "events:send".to_owned(), "push:send".to_owned(), "runtime:write".to_owned(), "relay:identity".to_owned(), diff --git a/cloud/src/auth/mcp_tokens.rs b/cloud/src/auth/mcp_tokens.rs index fe20dca5f..7b80ce89e 100644 --- a/cloud/src/auth/mcp_tokens.rs +++ b/cloud/src/auth/mcp_tokens.rs @@ -118,6 +118,7 @@ mod tests { use crate::{ api_models::ClientKind, auth::tokens::tests::{decode_claims, test_service}, + mcp_models::ToolAccess, }; use super::{McpAccessInput, McpCallGrantInput}; @@ -172,8 +173,8 @@ mod tests { grant_id: Uuid::now_v7(), client_id: "mcp_client", client_name: "Claude", - tool: "workspace_list", - access: "read", + tool: "update_runtime_settings", + access: ToolAccess::Admin.as_str(), }) .await .unwrap_or_default(); @@ -189,6 +190,7 @@ mod tests { assert_eq!(claims["jti"], call_id.to_string()); assert_eq!(claims["clientName"], "Claude"); assert_eq!(claims["runtimeId"], "runtime-1"); + assert_eq!(claims["access"], "admin"); let lifetime = claims["exp"].as_i64().unwrap_or_default() - claims["iat"].as_i64().unwrap_or_default(); assert_eq!(lifetime, 120); diff --git a/cloud/src/auth/mod.rs b/cloud/src/auth/mod.rs index b31e20f60..6df10748b 100644 --- a/cloud/src/auth/mod.rs +++ b/cloud/src/auth/mod.rs @@ -51,6 +51,16 @@ pub async fn authenticate( headers: &HeaderMap, state: &AppState, scope: &str, +) -> Result { + authenticate_any(headers, state, &[scope]).await +} + +/// Authenticates a session that holds at least one of `scopes`, so a route can accept a +/// new scope while tokens issued before it existed keep working. +pub async fn authenticate_any( + headers: &HeaderMap, + state: &AppState, + scopes: &[&str], ) -> Result { let token = bearer_token(headers)?; let claims = state.tokens.verify(token)?; @@ -106,7 +116,15 @@ pub async fn authenticate( .map(ToOwned::to_owned) .collect(), }; - context.require_scope(scope)?; + if !scopes + .iter() + .any(|scope| context.scopes.iter().any(|held| held == scope)) + { + return Err(ApiError::forbidden( + "insufficient_scope", + "The session is not allowed to perform this action.", + )); + } Ok(context) } diff --git a/cloud/src/config.rs b/cloud/src/config.rs index 33fa8d4f5..959dc1569 100644 --- a/cloud/src/config.rs +++ b/cloud/src/config.rs @@ -23,6 +23,73 @@ pub struct AppConfig { pub http_timeout: Duration, pub limits: LimitsConfig, pub mcp: McpConfig, + pub events: EventsConfig, +} + +/// Runtime domain events, generic webhooks, and MCP Events. +#[derive(Clone, Debug)] +pub struct EventsConfig { + /// Encrypts webhook signing secrets at rest (`ALERA_WEBHOOK_SECRET_KEY`). Without it + /// webhooks and MCP Events subscriptions cannot be created and nothing is delivered. + pub secret_key: Option, + /// The key being rotated out (`ALERA_WEBHOOK_PREVIOUS_SECRET_KEY`); only decrypts. + pub previous_secret_key: Option, + /// OpenAI MCP Events subscriptions (`ALERA_MCP_EVENTS_ENABLED`, default off). + pub mcp_events_enabled: bool, + /// Runs the background fan-out and delivery loop (`ALERA_EVENT_WORKER_ENABLED`). + pub worker_enabled: bool, + pub callbacks: CallbackPolicy, +} + +impl Default for EventsConfig { + fn default() -> Self { + Self { + secret_key: None, + previous_secret_key: None, + mcp_events_enabled: false, + worker_enabled: true, + callbacks: CallbackPolicy::default(), + } + } +} + +/// Which callback URLs webhooks and MCP Events may target. Production keeps both off: +/// HTTPS on port 443 to public addresses only. +#[derive(Clone, Copy, Debug, Default)] +pub struct CallbackPolicy { + /// Development only (`ALERA_WEBHOOK_ALLOW_ANY_PORT`): HTTPS on any port. + pub allow_any_port: bool, + /// Tests only, never read from the environment: plain HTTP and private addresses. + pub allow_private_targets: bool, +} + +/// A 32-byte AES-256-GCM key whose bytes never appear in `Debug` output. +#[derive(Clone)] +pub struct SecretKey(pub [u8; 32]); + +impl std::fmt::Debug for SecretKey { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("SecretKey(redacted)") + } +} + +impl SecretKey { + /// Parses standard or URL-safe base64, padded or not, that decodes to exactly 32 bytes. + pub fn parse(value: &str) -> anyhow::Result { + use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, + }; + let trimmed = value.trim(); + let bytes = STANDARD + .decode(trimmed) + .or_else(|_| URL_SAFE_NO_PAD.decode(trimmed.trim_end_matches('='))) + .context("the key is not base64")?; + let key: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("the key must decode to 32 bytes"))?; + Ok(Self(key)) + } } #[derive(Clone, Debug)] @@ -146,6 +213,7 @@ impl AppConfig { push_burst: env_number("ALERA_PUSH_BURST_LIMIT", 10)?, }, mcp, + events: events_config()?, }; config.validate()?; Ok(config) @@ -274,6 +342,24 @@ fn web_credentials( Ok(config) } +fn events_config() -> anyhow::Result { + let key = |name: &str| { + optional(name) + .map(|value| SecretKey::parse(&value).with_context(|| format!("invalid {name}"))) + .transpose() + }; + Ok(EventsConfig { + secret_key: key("ALERA_WEBHOOK_SECRET_KEY")?, + previous_secret_key: key("ALERA_WEBHOOK_PREVIOUS_SECRET_KEY")?, + mcp_events_enabled: optional_bool("ALERA_MCP_EVENTS_ENABLED", false)?, + worker_enabled: optional_bool("ALERA_EVENT_WORKER_ENABLED", true)?, + callbacks: CallbackPolicy { + allow_any_port: optional_bool("ALERA_WEBHOOK_ALLOW_ANY_PORT", false)?, + allow_private_targets: false, + }, + }) +} + fn fcm_config() -> anyhow::Result { match env::var("ALERA_FCM_MODE").unwrap_or_else(|_| "disabled".to_owned()).as_str() { "disabled" => Ok(FcmConfig::Disabled), diff --git a/cloud/src/events/callback.rs b/cloud/src/events/callback.rs new file mode 100644 index 000000000..a4b9cfa81 --- /dev/null +++ b/cloud/src/events/callback.rs @@ -0,0 +1,350 @@ +//! Outbound webhook egress: HTTPS only, public addresses only, the checked address pinned +//! for the connection, no proxy, no redirects, 10 seconds per attempt, and a 16 KiB +//! response cap. + +use std::{net::SocketAddr, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use reqwest::{redirect::Policy, Client}; +use url::{Host, Url}; + +use crate::{config::CallbackPolicy, mcp_oauth::cimd::is_forbidden_address}; + +pub const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(10); +pub const MAX_RESPONSE_BYTES: usize = 16 * 1024; +pub const MAX_PAYLOAD_BYTES: usize = 256 * 1024; +pub const MAX_URL_BYTES: usize = 2048; + +/// Why a callback could not be reached. `reason()` is the stable code clients see. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CallbackError { + InvalidUrl, + DnsFailed, + NonPublicDestination, + Timeout, + ConnectionFailed, + ResponseTooLarge, + PayloadTooLarge, +} + +impl CallbackError { + pub fn reason(self) -> &'static str { + match self { + Self::InvalidUrl => "invalid_url", + Self::DnsFailed => "dns_failed", + Self::NonPublicDestination => "non_public_destination", + Self::Timeout => "timeout", + Self::ConnectionFailed => "connection_failed", + Self::ResponseTooLarge => "response_too_large", + Self::PayloadTooLarge => "payload_too_large", + } + } + + /// Errors that a retry cannot fix. + pub fn is_permanent(self) -> bool { + matches!( + self, + Self::InvalidUrl | Self::NonPublicDestination | Self::PayloadTooLarge + ) + } +} + +/// Resolves callback hosts. Tests inject answers so they never depend on real DNS. +#[async_trait] +pub trait CallbackResolver: Send + Sync { + async fn resolve(&self, host: &str, port: u16) -> std::io::Result>; +} + +pub struct SystemResolver; + +#[async_trait] +impl CallbackResolver for SystemResolver { + async fn resolve(&self, host: &str, port: u16) -> std::io::Result> { + Ok(tokio::net::lookup_host((host, port)).await?.collect()) + } +} + +pub struct Reply { + pub status: u16, + pub body: Vec, +} + +#[derive(Clone)] +pub struct CallbackClient { + resolver: Arc, + policy: CallbackPolicy, + timeout: Duration, +} + +impl CallbackClient { + pub fn new(policy: CallbackPolicy) -> Self { + Self { + resolver: Arc::new(SystemResolver), + policy, + timeout: ATTEMPT_TIMEOUT, + } + } + + pub fn with_resolver(mut self, resolver: Arc) -> Self { + self.resolver = resolver; + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Parses and checks a callback URL without contacting it. + pub fn validate_url(&self, text: &str) -> Result { + validate_url(text, self.policy) + } + + /// Resolves the host and returns the one address the connection must use. + pub async fn destination(&self, url: &Url) -> Result { + let port = url + .port_or_known_default() + .ok_or(CallbackError::InvalidUrl)?; + let addresses = match url.host() { + Some(Host::Ipv4(ip)) => vec![SocketAddr::new(ip.into(), port)], + Some(Host::Ipv6(ip)) => vec![SocketAddr::new(ip.into(), port)], + Some(Host::Domain(host)) => self + .resolver + .resolve(host, port) + .await + .map_err(|_| CallbackError::DnsFailed)?, + None => return Err(CallbackError::InvalidUrl), + }; + let first = *addresses.first().ok_or(CallbackError::DnsFailed)?; + if !self.policy.allow_private_targets + && addresses + .iter() + .any(|address| is_forbidden_address(address.ip())) + { + return Err(CallbackError::NonPublicDestination); + } + Ok(first) + } + + /// Posts `body` once. Every status is returned as a reply; only transport failures + /// are errors. Redirects are never followed. + pub async fn post( + &self, + url_text: &str, + headers: &[(String, String)], + body: Vec, + ) -> Result { + if body.len() > MAX_PAYLOAD_BYTES { + return Err(CallbackError::PayloadTooLarge); + } + let url = self.validate_url(url_text)?; + tokio::time::timeout(self.timeout, self.send(url, headers, body)) + .await + .map_err(|_| CallbackError::Timeout)? + } + + async fn send( + &self, + url: Url, + headers: &[(String, String)], + body: Vec, + ) -> Result { + let address = self.destination(&url).await?; + // No proxy: a proxy would resolve the hostname itself (CONNECT host:port) and + // could reach a private address, bypassing the check and the pinned address. + let mut builder = Client::builder() + .no_proxy() + .timeout(self.timeout) + .redirect(Policy::none()); + if let Some(Host::Domain(host)) = url.host() { + // Pinning the checked address keeps a second DNS answer from reaching a private host. + builder = builder.resolve(host, address); + } + let client = builder + .build() + .map_err(|_| CallbackError::ConnectionFailed)?; + let mut request = client.post(url).body(body); + for (name, value) in headers { + request = request.header(name.as_str(), value.as_str()); + } + let mut response = request.send().await.map_err(transport_error)?; + let status = response.status().as_u16(); + let mut received = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(transport_error)? { + if received.len() + chunk.len() > MAX_RESPONSE_BYTES { + return Err(CallbackError::ResponseTooLarge); + } + received.extend_from_slice(&chunk); + } + Ok(Reply { + status, + body: received, + }) + } +} + +fn transport_error(error: reqwest::Error) -> CallbackError { + if error.is_timeout() { + CallbackError::Timeout + } else { + CallbackError::ConnectionFailed + } +} + +/// HTTPS on port 443 without credentials or fragments. The policy can admit other ports +/// (development) or plain HTTP (tests only). +pub fn validate_url(text: &str, policy: CallbackPolicy) -> Result { + if text.is_empty() + || text.len() > MAX_URL_BYTES + || text.bytes().any(|byte| !(0x21..=0x7e).contains(&byte)) + || text.contains('\\') + { + return Err(CallbackError::InvalidUrl); + } + let url = Url::parse(text).map_err(|_| CallbackError::InvalidUrl)?; + let scheme_allowed = + url.scheme() == "https" || (policy.allow_private_targets && url.scheme() == "http"); + let port_allowed = url.port().is_none() + || url.port() == Some(443) + || policy.allow_any_port + || policy.allow_private_targets; + if !scheme_allowed + || !port_allowed + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(CallbackError::InvalidUrl); + } + Ok(url) +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + use super::*; + + struct FakeResolver(Vec<&'static str>); + + #[async_trait] + impl CallbackResolver for FakeResolver { + async fn resolve(&self, _host: &str, port: u16) -> std::io::Result> { + self.0 + .iter() + .map(|value| { + value + .parse::() + .map(|ip| SocketAddr::new(ip, port)) + .map_err(|_| std::io::Error::other("bad test address")) + }) + .collect() + } + } + + fn client(answers: Vec<&'static str>, policy: CallbackPolicy) -> CallbackClient { + CallbackClient::new(policy).with_resolver(Arc::new(FakeResolver(answers))) + } + + async fn destination(client: &CallbackClient, text: &str) -> Result { + let url = client.validate_url(text)?; + client.destination(&url).await + } + + #[test] + fn accepts_only_https_on_port_443_by_default() { + let policy = CallbackPolicy::default(); + assert!(validate_url("https://hooks.example.com/alera?x=1", policy).is_ok()); + assert!(validate_url("https://hooks.example.com:443/alera", policy).is_ok()); + for bad in [ + "http://hooks.example.com/alera", + "https://hooks.example.com:8443/alera", + "https://user:pass@hooks.example.com/", + "https://hooks.example.com/#fragment", + "https://hooks.example.com/a b", + "ftp://hooks.example.com/", + "https://", + "", + ] { + assert_eq!( + validate_url(bad, policy).err(), + Some(CallbackError::InvalidUrl), + "{bad}" + ); + } + let long = format!("https://hooks.example.com/{}", "a".repeat(MAX_URL_BYTES)); + assert!(validate_url(&long, policy).is_err()); + let dev = CallbackPolicy { + allow_any_port: true, + allow_private_targets: false, + }; + assert!(validate_url("https://hooks.example.com:8443/alera", dev).is_ok()); + assert!(validate_url("http://hooks.example.com/alera", dev).is_err()); + } + + #[tokio::test] + async fn blocks_private_answers_and_pins_the_public_one() { + let policy = CallbackPolicy::default(); + for answers in [ + vec!["127.0.0.1"], + vec!["10.0.0.8"], + vec!["169.254.169.254"], + vec!["::1"], + vec!["fe80::1"], + vec!["224.0.0.1"], + vec!["0.0.0.0"], + vec!["93.184.216.34", "192.168.1.1"], + ] { + let result = destination( + &client(answers.clone(), policy), + "https://hooks.example.com/", + ) + .await; + assert_eq!( + result.err(), + Some(CallbackError::NonPublicDestination), + "{answers:?}" + ); + } + let empty = destination(&client(Vec::new(), policy), "https://hooks.example.com/").await; + assert_eq!(empty.err(), Some(CallbackError::DnsFailed)); + let public = destination( + &client(vec!["93.184.216.34", "93.184.216.35"], policy), + "https://hooks.example.com/", + ) + .await; + assert_eq!(public.ok(), "93.184.216.34:443".parse().ok()); + for literal in ["https://127.0.0.1/", "https://[::1]/", "https://10.1.2.3/"] { + let result = destination(&client(Vec::new(), policy), literal).await; + assert_eq!( + result.err(), + Some(CallbackError::NonPublicDestination), + "{literal}" + ); + } + } + + #[tokio::test] + async fn the_test_policy_admits_loopback_http() { + let policy = CallbackPolicy { + allow_any_port: false, + allow_private_targets: true, + }; + let result = destination(&client(Vec::new(), policy), "http://127.0.0.1:9/hook").await; + assert_eq!(result.ok(), "127.0.0.1:9".parse().ok()); + } + + #[tokio::test] + async fn refuses_oversized_payloads_before_connecting() { + let client = client(vec!["93.184.216.34"], CallbackPolicy::default()); + let result = client + .post( + "https://hooks.example.com/", + &[], + vec![0_u8; MAX_PAYLOAD_BYTES + 1], + ) + .await; + assert_eq!(result.err(), Some(CallbackError::PayloadTooLarge)); + } +} diff --git a/cloud/src/events/catalog.rs b/cloud/src/events/catalog.rs new file mode 100644 index 000000000..b534e43ec --- /dev/null +++ b/cloud/src/events/catalog.rs @@ -0,0 +1,339 @@ +//! The v1 runtime event catalog and its payload policy: events carry ids and states only, +//! never prompts, terminal output, code, commands, or message text. + +use serde_json::{Map, Value}; + +/// The synthetic event a webhook test sends. Never accepted from a runtime. +pub const TEST_EVENT_KIND: &str = "alera.test"; + +/// One catalog entry: the event name and the `data` keys it may carry besides the +/// envelope fields (`runtimeId`, `workspaceId`, `projectId`, `seq`). +pub struct EventKind { + pub name: &'static str, + pub description: &'static str, + pub keys: &'static [&'static str], + /// Optional MCP Events filter arguments besides `runtime` and `workspaceId`. + pub filters: &'static [&'static str], +} + +/// Display names the policy allows on every event, as in mobile push payloads. +pub const NAME_KEYS: [&str; 2] = ["workspaceName", "projectName"]; + +pub const EVENT_KINDS: [EventKind; 11] = [ + EventKind { + name: "inbox.reply", + description: "An agent replied to a question in an Alera inbox. Read the thread with show_inbox_thread or wait_for_reply.", + keys: &["inbox", "threadId", "questionId", "messageId", "originClientId"], + filters: &["threadId", "questionId"], + }, + EventKind { + name: "inbox.question.status", + description: "A question in an Alera inbox changed status (delivered, answered, expired, cancelled).", + keys: &["questionId", "threadId", "status"], + filters: &["threadId", "questionId"], + }, + EventKind { + name: "agent.status", + description: "An agent in a workspace tab is waiting, blocked, or done.", + keys: &["workspaceId", "tabId", "sessionId", "state"], + filters: &["tabId"], + }, + EventKind { + name: "terminal.exit", + description: "A terminal session exited.", + keys: &["workspaceId", "tabId", "sessionId", "exitCode"], + filters: &["tabId"], + }, + EventKind { + name: "orchestration.task.state", + description: "An orchestration task changed state.", + keys: &["taskId", "runId", "state"], + filters: &["taskId", "runId"], + }, + EventKind { + name: "orchestration.gate.created", + description: "An orchestration decision gate was created and waits for a person in Alera.", + keys: &["gateId", "taskId", "runId"], + filters: &["taskId", "runId"], + }, + EventKind { + name: "orchestration.escalation", + description: "An orchestration task escalated.", + keys: &["taskId", "runId"], + filters: &["taskId", "runId"], + }, + EventKind { + name: "automation.run.state", + description: "An automation run changed status.", + keys: &["automationId", "runId", "status"], + filters: &["automationId", "runId"], + }, + EventKind { + name: "workspace.start.state", + description: "A New Workspace from Prompt operation changed phase or status.", + keys: &["operationId", "status", "phase", "workspaceId"], + filters: &["operationId"], + }, + EventKind { + name: "workspace.lifecycle", + description: "A workspace was created, archived, unarchived, slept, woken, or removed.", + keys: &["workspaceId", "action"], + filters: &[], + }, + EventKind { + name: "pullRequest.watch", + description: "Watch and Fix dispatched a fix, merged, or stopped for a pull request.", + keys: &["workspaceId", "number", "action"], + filters: &[], + }, +]; + +pub const MAX_DATA_KEYS: usize = 20; +pub const MAX_DATA_TEXT: usize = 512; + +pub fn kind(name: &str) -> Option<&'static EventKind> { + EVENT_KINDS.iter().find(|kind| kind.name == name) +} + +/// Key fragments that may never name an event field, compared case-insensitively. +const SENSITIVE_FRAGMENTS: [&str; 9] = [ + "prompt", + "body", + "text", + "output", + "command", + "subject", + "scrollback", + "terminalbytes", + "content", +]; + +pub fn sensitive_key(key: &str) -> bool { + let normalized = key.to_ascii_lowercase(); + SENSITIVE_FRAGMENTS + .iter() + .any(|fragment| normalized.contains(fragment)) +} + +/// Why a runtime's event `data` was refused. +#[derive(Debug, PartialEq, Eq)] +pub enum DataViolation { + NotObject, + TooManyKeys, + InvalidKey, + SensitiveKey, + NotScalar, + InvalidText, +} + +/// Checks the generic payload policy: a flat object of at most 20 scalar fields whose +/// names are short identifiers that never suggest free text. +pub fn check_data(data: &Value) -> Result<(), DataViolation> { + let object = data.as_object().ok_or(DataViolation::NotObject)?; + if object.len() > MAX_DATA_KEYS { + return Err(DataViolation::TooManyKeys); + } + for (key, value) in object { + let valid_key = !key.is_empty() + && key.len() <= 64 + && key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'.'); + if !valid_key { + return Err(DataViolation::InvalidKey); + } + if sensitive_key(key) { + return Err(DataViolation::SensitiveKey); + } + match value { + Value::String(text) => { + if text.len() > MAX_DATA_TEXT || text.chars().any(char::is_control) { + return Err(DataViolation::InvalidText); + } + } + Value::Bool(_) | Value::Number(_) | Value::Null => {} + Value::Array(_) | Value::Object(_) => return Err(DataViolation::NotScalar), + } + } + Ok(()) +} + +/// Keeps only the keys the catalog lists for this kind (plus display names). Anything +/// else is dropped before storage, so a new runtime field never leaks by default. +pub fn project_data(kind: &EventKind, data: &Value) -> Value { + let mut projected = Map::new(); + if let Some(object) = data.as_object() { + for (key, value) in object { + let allowed = kind.keys.contains(&key.as_str()) || NAME_KEYS.contains(&key.as_str()); + if allowed && !value.is_null() { + projected.insert(key.clone(), value.clone()); + } + } + } + Value::Object(projected) +} + +/// JSON Schema for an MCP Events subscription's arguments. +pub fn input_schema(kind: &EventKind) -> Value { + let mut properties = Map::new(); + properties.insert( + "runtime".to_owned(), + string_property( + "Runtime name or id. Omit to follow every runtime this connection reaches.", + ), + ); + properties.insert( + "workspaceId".to_owned(), + string_property("Only events for this workspace."), + ); + for filter in kind.filters { + properties.insert( + (*filter).to_owned(), + string_property("Only events whose data carries this value."), + ); + } + serde_json::json!({ + "type": "object", + "properties": properties, + "additionalProperties": false, + }) +} + +/// JSON Schema for the `data` object of a delivered event. +pub fn payload_schema(kind: &EventKind) -> Value { + let mut properties = Map::new(); + properties.insert( + "runtimeId".to_owned(), + serde_json::json!({"type": "string"}), + ); + properties.insert( + "workspaceId".to_owned(), + serde_json::json!({"type": "string"}), + ); + properties.insert( + "projectId".to_owned(), + serde_json::json!({"type": "string"}), + ); + properties.insert("seq".to_owned(), serde_json::json!({"type": "integer"})); + for key in kind.keys.iter().chain(NAME_KEYS.iter()) { + let schema = match *key { + "exitCode" | "number" => serde_json::json!({"type": "integer"}), + _ => serde_json::json!({"type": "string"}), + }; + properties.entry((*key).to_owned()).or_insert(schema); + } + serde_json::json!({ + "type": "object", + "properties": properties, + "required": ["runtimeId", "seq"], + "additionalProperties": false, + }) +} + +fn string_property(description: &str) -> Value { + serde_json::json!({"type": "string", "minLength": 1, "maxLength": 128, "description": description}) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn every_kind_is_unique_and_lists_only_safe_keys() { + let mut names: Vec<&str> = EVENT_KINDS.iter().map(|kind| kind.name).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), EVENT_KINDS.len()); + for kind in &EVENT_KINDS { + for key in kind.keys.iter().chain(kind.filters).chain(NAME_KEYS.iter()) { + assert!( + !sensitive_key(key), + "{} lists sensitive key {key}", + kind.name + ); + } + for filter in kind.filters { + assert!(kind.keys.contains(filter), "{} filter {filter}", kind.name); + } + } + } + + #[test] + fn rejects_free_text_and_nested_fields() { + for key in [ + "prompt", + "messageBody", + "replyText", + "terminalOutput", + "command", + "subject", + "Scrollback", + ] { + assert_eq!( + check_data(&json!({key: "x"})), + Err(DataViolation::SensitiveKey), + "{key}" + ); + } + assert_eq!( + check_data(&json!({"ids": ["a"]})), + Err(DataViolation::NotScalar) + ); + assert_eq!( + check_data(&json!({"meta": {"a": 1}})), + Err(DataViolation::NotScalar) + ); + assert_eq!(check_data(&json!(["a"])), Err(DataViolation::NotObject)); + assert_eq!( + check_data(&json!({"bad key": 1})), + Err(DataViolation::InvalidKey) + ); + assert_eq!( + check_data(&json!({"state": "x".repeat(513)})), + Err(DataViolation::InvalidText) + ); + let many: Map = (0..21) + .map(|index| (format!("k{index}"), json!(index))) + .collect(); + assert_eq!( + check_data(&Value::Object(many)), + Err(DataViolation::TooManyKeys) + ); + assert_eq!( + check_data(&json!({"threadId": "t", "exitCode": 1, "done": true, "x": null})), + Ok(()) + ); + } + + #[test] + fn projection_keeps_only_catalog_keys() { + let Some(reply) = kind("inbox.reply") else { + panic!("inbox.reply must exist"); + }; + let data = json!({"threadId": "t1", "questionId": "q1", "workspaceName": "Alpha", "unknown": "x", "inbox": null}); + assert_eq!( + project_data(reply, &data), + json!({"threadId": "t1", "questionId": "q1", "workspaceName": "Alpha"}) + ); + } + + #[test] + fn schemas_name_every_filter_and_key() { + let Some(start) = kind("workspace.start.state") else { + panic!("workspace.start.state must exist"); + }; + let input = input_schema(start); + assert!(input["properties"]["operationId"].is_object()); + assert!(input["properties"]["runtime"].is_object()); + assert_eq!(input["additionalProperties"], json!(false)); + let payload = payload_schema(start); + assert_eq!(payload["properties"]["phase"]["type"], "string"); + assert_eq!(payload["properties"]["seq"]["type"], "integer"); + } +} + +#[cfg(test)] +#[path = "catalog_edge_tests.rs"] +mod edge_tests; diff --git a/cloud/src/events/catalog_edge_tests.rs b/cloud/src/events/catalog_edge_tests.rs new file mode 100644 index 000000000..8da206f5b --- /dev/null +++ b/cloud/src/events/catalog_edge_tests.rs @@ -0,0 +1,43 @@ +use std::path::PathBuf; + +use serde_json::{json, Value}; + +use super::{input_schema, payload_schema, EVENT_KINDS}; + +/// The `events/list` entries the edge serves. +pub fn edge_catalog() -> Value { + let events: Vec = EVENT_KINDS + .iter() + .map(|kind| { + json!({ + "name": kind.name, + "description": kind.description, + "delivery": ["webhook"], + "inputSchema": input_schema(kind), + "payloadSchema": payload_schema(kind), + }) + }) + .collect(); + json!({"version": 1, "events": events}) +} + +/// The edge serves a copy of the event catalog. Set `ALERA_UPDATE_EVENT_CATALOG=1` to +/// rewrite it after changing an event. +#[test] +fn event_catalog_matches_edge_copy() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../edge/src/mcp/event_catalog.json"); + let expected = edge_catalog(); + if std::env::var_os("ALERA_UPDATE_EVENT_CATALOG").is_some() { + let text = serde_json::to_string_pretty(&expected).unwrap_or_default() + "\n"; + if let Err(error) = std::fs::write(&path, text) { + panic!("write {}: {error}", path.display()); + } + return; + } + let actual: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap_or_default()) + .unwrap_or_default(); + assert_eq!( + actual, expected, + "edge/src/mcp/event_catalog.json is stale; run with ALERA_UPDATE_EVENT_CATALOG=1" + ); +} diff --git a/cloud/src/events/cursor.rs b/cloud/src/events/cursor.rs new file mode 100644 index 000000000..19a6883e5 --- /dev/null +++ b/cloud/src/events/cursor.rs @@ -0,0 +1,54 @@ +//! Opaque replay cursors. A cursor is a retention anchor: the receive time of the oldest +//! event that may still be undelivered, so resuming from it never skips a pending event. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +const MAX_CURSOR_BYTES: usize = 256; + +#[derive(Serialize, Deserialize)] +struct Anchor { + v: u8, + since: i64, +} + +pub fn encode(since: DateTime) -> String { + let anchor = Anchor { + v: 1, + since: since.timestamp_millis(), + }; + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&anchor).unwrap_or_default()) +} + +/// Decodes a cursor this server issued. Anchors in the future are refused. +pub fn decode(cursor: &str, now: DateTime) -> Option> { + if cursor.is_empty() || cursor.len() > MAX_CURSOR_BYTES { + return None; + } + let bytes = URL_SAFE_NO_PAD.decode(cursor).ok()?; + let anchor: Anchor = serde_json::from_slice(&bytes).ok()?; + let since = DateTime::from_timestamp_millis(anchor.since)?; + (anchor.v == 1 && since <= now + chrono::TimeDelta::minutes(1)).then_some(since) +} + +#[cfg(test)] +mod tests { + use chrono::{TimeDelta, Utc}; + + use super::{decode, encode}; + + #[test] + fn round_trips_and_refuses_foreign_cursors() { + let now = Utc::now(); + let since = now - TimeDelta::hours(3); + let cursor = encode(since); + assert_eq!( + decode(&cursor, now).map(|value| value.timestamp_millis()), + Some(since.timestamp_millis()) + ); + assert!(decode("not-a-cursor", now).is_none()); + assert!(decode(&encode(now + TimeDelta::hours(1)), now).is_none()); + assert!(decode(&"a".repeat(300), now).is_none()); + } +} diff --git a/cloud/src/events/delivery.rs b/cloud/src/events/delivery.rs new file mode 100644 index 000000000..25573e1c2 --- /dev/null +++ b/cloud/src/events/delivery.rs @@ -0,0 +1,478 @@ +//! Signed webhook delivery: claim with a 45-second lease, recheck authorization, sign with +//! Standard Webhooks, send, and settle with the retry policy. + +use std::time::Duration; + +use chrono::{DateTime, TimeDelta, Utc}; +use serde::Serialize; +use serde_json::{Map, Value}; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::{error::ApiError, state::AppState}; + +use super::{ + callback::CallbackError, catalog::TEST_EVENT_KIND, cursor, fanout::RETENTION, + secrets::signing_key, +}; + +pub use super::wire::{event_body, signed_headers}; + +pub const LEASE: TimeDelta = TimeDelta::seconds(45); +/// Why a delivery stopped when its subscription passed `refreshBefore`. A refresh with a +/// cursor queues these again; deliveries stopped for any other reason stay stopped. +pub const SUBSCRIPTION_EXPIRED: &str = "subscription_expired"; +const SUBSCRIPTION_INACTIVE: &str = "subscription_inactive"; +pub const MAX_ATTEMPTS: i32 = 12; +const CLAIM_BATCH: i64 = 16; +const FIRST_RETRY: Duration = Duration::from_secs(5); +const MAX_RETRY: Duration = Duration::from_secs(15 * 60); + +/// The wait after `attempts` failed attempts: 5 s doubling up to 15 minutes. +pub fn retry_delay(attempts: i32) -> Duration { + let exponent = attempts.saturating_sub(1).clamp(0, 16) as u32; + FIRST_RETRY + .saturating_mul(2_u32.saturating_pow(exponent)) + .min(MAX_RETRY) +} + +/// How one attempt settles. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Delivered(u16), + /// Transient: 5xx, 408, 425, 429, timeouts, and connection or DNS failures. + Retry(String), + /// Definitive: 413, redirects, other 4xx, and callbacks that can never be valid. + Dead(String), + /// `410 Gone`: the receiver ended the subscription. + Gone, +} + +pub fn classify(result: &Result) -> Outcome { + match result { + Ok(status) if (200..300).contains(status) => Outcome::Delivered(*status), + Ok(410) => Outcome::Gone, + Ok(status @ (408 | 425 | 429)) => Outcome::Retry(format!("http_{status}")), + Ok(status) if *status >= 500 => Outcome::Retry(format!("http_{status}")), + Ok(status) => Outcome::Dead(format!("http_{status}")), + Err(error) if error.is_permanent() => Outcome::Dead(error.reason().to_owned()), + Err(error) => Outcome::Retry(error.reason().to_owned()), + } +} + +#[derive(Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PassSummary { + pub fanned_out: u64, + pub claimed: usize, + pub delivered: usize, +} + +#[derive(FromRow)] +struct Claimed { + id: Uuid, + attempts: i32, + subscription_id: String, + target_kind: String, + callback_url: String, + secret_ciphertext: String, + previous_secret_ciphertext: Option, + previous_secret_until: Option>, + status: String, + refresh_before: Option>, + authorized: bool, + /// False for an MCP Events delivery whose runtime has MCP Control off. + runtime_allowed: bool, + event_id: String, + kind: String, + runtime_id: String, + workspace_id: Option, + project_id: Option, + seq: i64, + data: Value, + occurred_at: DateTime, + received_at: DateTime, + cursor_anchor: Option>, +} + +/// One worker pass: fan out new events, then claim and send due deliveries. +pub async fn run_pass(state: &AppState) -> Result { + let mut summary = PassSummary { + fanned_out: super::fanout::fan_out_pending(state).await?, + ..PassSummary::default() + }; + if state.events.secrets.is_none() { + return Ok(summary); + } + let claimed = claim(state).await?; + summary.claimed = claimed.len(); + let mut tasks = tokio::task::JoinSet::new(); + for delivery in claimed { + let state = state.clone(); + tasks.spawn(async move { deliver(&state, delivery).await }); + } + while let Some(result) = tasks.join_next().await { + match result { + Ok(Ok(true)) => summary.delivered += 1, + Ok(Ok(false)) => {} + Ok(Err(error)) => tracing::warn!(error = %error, "event delivery failed"), + Err(error) => tracing::warn!(error = %error, "event delivery task failed"), + } + } + Ok(summary) +} + +/// Claims due deliveries. While the MCP Events switch is off (`$4`), MCP Events deliveries +/// are not claimed at all: they stay queued, untouched, until the switch comes back on. +async fn claim(state: &AppState) -> Result, ApiError> { + let now = Utc::now(); + let ids = sqlx::query_scalar::<_, Uuid>( + r#" + WITH due AS ( + SELECT d.id FROM event_deliveries d + WHERE d.status IN ('pending', 'sending') AND d.next_attempt_at <= $1 + AND ($4 OR NOT EXISTS ( + SELECT 1 FROM event_subscriptions s + WHERE s.id = d.subscription_id AND s.target_kind = 'mcp_events' + )) + ORDER BY d.next_attempt_at + LIMIT $2 + FOR UPDATE OF d SKIP LOCKED + ) + UPDATE event_deliveries d + SET status = 'sending', attempts = d.attempts + 1, lease_until = $3, next_attempt_at = $3 + FROM due WHERE d.id = due.id + RETURNING d.id + "#, + ) + .bind(now) + .bind(CLAIM_BATCH) + .bind(now + LEASE) + .bind(state.config.events.mcp_events_enabled) + .fetch_all(&state.pool) + .await?; + if ids.is_empty() { + return Ok(Vec::new()); + } + Ok(sqlx::query_as::<_, Claimed>( + r#" + SELECT d.id, d.attempts, s.id AS subscription_id, s.target_kind, s.callback_url, + s.secret_ciphertext, s.previous_secret_ciphertext, s.previous_secret_until, + s.status, s.refresh_before, + (s.target_kind = 'webhook' OR EXISTS ( + SELECT 1 FROM runtimes r + WHERE r.id = e.runtime_id AND r.mcp_access <> 'off' + )) AS runtime_allowed, + (s.target_kind = 'webhook' OR EXISTS ( + SELECT 1 FROM mcp_grants g + WHERE g.id = s.owner_grant_id AND g.revoked_at IS NULL + AND (g.all_runtimes OR EXISTS ( + SELECT 1 FROM mcp_grant_runtimes gr + WHERE gr.grant_id = g.id AND gr.runtime_id = e.runtime_id + )) + )) AS authorized, + e.event_id, e.kind, e.runtime_id, e.workspace_id, e.project_id, e.seq, e.data, + e.occurred_at, e.received_at, + (SELECT MIN(e2.received_at) + FROM event_deliveries d2 JOIN domain_events e2 ON e2.id = d2.event_id + WHERE d2.subscription_id = s.id AND d2.status IN ('pending', 'sending') + ) AS cursor_anchor + FROM event_deliveries d + JOIN event_subscriptions s ON s.id = d.subscription_id + JOIN domain_events e ON e.id = d.event_id + WHERE d.id = ANY($1) + "#, + ) + .bind(&ids) + .fetch_all(&state.pool) + .await?) +} + +/// The decrypted signing keys: the current one, plus the previous one while it rotates out. +fn signing_keys(state: &AppState, delivery: &Claimed, now: DateTime) -> Option>> { + let secrets = state.events.secrets.as_ref()?; + let id = &delivery.subscription_id; + let mut keys = vec![signing_key( + &secrets.decrypt(&delivery.secret_ciphertext, id).ok()?, + )?]; + if let (Some(previous), Some(until)) = ( + &delivery.previous_secret_ciphertext, + delivery.previous_secret_until, + ) { + if until > now { + if let Some(key) = secrets + .decrypt(previous, id) + .ok() + .and_then(|secret| signing_key(&secret)) + { + keys.push(key); + } + } + } + Some(keys) +} + +async fn deliver(state: &AppState, delivery: Claimed) -> Result { + let now = Utc::now(); + if delivery.received_at + RETENTION <= now { + settle(state, &delivery, Outcome::Dead("expired".to_owned())).await?; + return Ok(false); + } + let stop = if delivery.status == "expired" { + Some((None, SUBSCRIPTION_EXPIRED)) + } else if delivery.status != "active" { + Some((None, SUBSCRIPTION_INACTIVE)) + } else if delivery.refresh_before.is_some_and(|at| at <= now) { + Some((Some("expired"), SUBSCRIPTION_EXPIRED)) + } else if !delivery.authorized { + Some((Some("revoked"), SUBSCRIPTION_INACTIVE)) + } else { + None + }; + if stop.is_none() && !delivery.runtime_allowed { + // MCP Control is off on this runtime: drop the event, keep the subscription, + // which may cover other runtimes or resume when MCP Control comes back on. + finish( + state, + &delivery, + "stopped", + None, + Some("runtime_mcp_disabled"), + None, + ) + .await?; + return Ok(false); + } + if let Some((new_status, reason)) = stop { + if let Some(new_status) = new_status { + stop_subscription(state, &delivery.subscription_id, new_status, None).await?; + } + finish(state, &delivery, "stopped", None, Some(reason), None).await?; + return Ok(false); + } + let Some(keys) = signing_keys(state, &delivery, now) else { + settle( + state, + &delivery, + Outcome::Dead("secret_unavailable".to_owned()), + ) + .await?; + return Ok(false); + }; + let anchor = delivery.cursor_anchor.unwrap_or(delivery.received_at); + let data = if delivery.kind == TEST_EVENT_KIND { + Value::Object(Map::new()) + } else { + delivery.data.clone() + }; + let body = event_body( + &delivery.event_id, + &delivery.kind, + delivery.occurred_at, + ( + &delivery.runtime_id, + delivery.workspace_id.as_deref(), + delivery.project_id.as_deref(), + delivery.seq, + ), + &data, + &cursor::encode(anchor), + ); + let body = serde_json::to_vec(&body).map_err(ApiError::internal)?; + let header = if delivery.target_kind == "mcp_events" { + "x-mcp-subscription-id" + } else { + "x-alera-webhook-id" + }; + let headers = signed_headers( + &delivery.event_id, + (header, &delivery.subscription_id), + &keys, + &body, + ); + let result = state + .events + .callbacks + .post(&delivery.callback_url, &headers, body) + .await + .map(|reply| reply.status); + let outcome = classify(&result); + let delivered = matches!(outcome, Outcome::Delivered(_)); + settle(state, &delivery, outcome).await?; + Ok(delivered) +} + +async fn settle(state: &AppState, delivery: &Claimed, outcome: Outcome) -> Result<(), ApiError> { + let now = Utc::now(); + match outcome { + Outcome::Delivered(status) => { + finish( + state, + delivery, + "delivered", + Some(i32::from(status)), + None, + None, + ) + .await?; + sqlx::query( + "UPDATE event_subscriptions SET last_delivery_at = $2, last_error = NULL, updated_at = $2 WHERE id = $1", + ) + .bind(&delivery.subscription_id) + .bind(now) + .execute(&state.pool) + .await?; + } + Outcome::Gone => { + stop_subscription( + state, + &delivery.subscription_id, + "stopped", + Some("http_410"), + ) + .await?; + finish( + state, + delivery, + "stopped", + Some(410), + Some("http_410"), + None, + ) + .await?; + } + Outcome::Dead(reason) => { + record_error(state, &delivery.subscription_id, &reason).await?; + finish( + state, + delivery, + "dead", + status_of(&reason), + Some(&reason), + None, + ) + .await?; + } + Outcome::Retry(reason) => { + record_error(state, &delivery.subscription_id, &reason).await?; + let delay = TimeDelta::from_std(retry_delay(delivery.attempts)).unwrap_or(LEASE); + let next = now + delay; + if delivery.attempts >= MAX_ATTEMPTS || next >= delivery.received_at + RETENTION { + finish( + state, + delivery, + "dead", + status_of(&reason), + Some(&reason), + None, + ) + .await?; + } else { + finish( + state, + delivery, + "pending", + status_of(&reason), + Some(&reason), + Some(next), + ) + .await?; + } + } + } + Ok(()) +} + +fn status_of(reason: &str) -> Option { + reason + .strip_prefix("http_") + .and_then(|code| code.parse().ok()) +} + +/// Settles the claim only if this worker still holds it (same attempt, still sending). +async fn finish( + state: &AppState, + delivery: &Claimed, + status: &str, + http_status: Option, + error: Option<&str>, + next_attempt_at: Option>, +) -> Result<(), ApiError> { + let now = Utc::now(); + sqlx::query( + r#" + UPDATE event_deliveries + SET status = $3, last_status = COALESCE($4, last_status), last_error = $5, + next_attempt_at = COALESCE($6, next_attempt_at), lease_until = NULL, + delivered_at = CASE WHEN $3 = 'delivered' THEN $7 ELSE delivered_at END + WHERE id = $1 AND attempts = $2 AND status = 'sending' + "#, + ) + .bind(delivery.id) + .bind(delivery.attempts) + .bind(status) + .bind(http_status) + .bind(error) + .bind(next_attempt_at) + .bind(now) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn record_error( + state: &AppState, + subscription_id: &str, + reason: &str, +) -> Result<(), ApiError> { + sqlx::query("UPDATE event_subscriptions SET last_error = $2, updated_at = $3 WHERE id = $1") + .bind(subscription_id) + .bind(reason) + .bind(Utc::now()) + .execute(&state.pool) + .await?; + Ok(()) +} + +/// Ends a subscription and drops the deliveries still waiting for it. Deliveries of an +/// expired subscription are marked `subscription_expired` so a refresh can resume them. +pub async fn stop_subscription( + state: &AppState, + subscription_id: &str, + status: &str, + error: Option<&str>, +) -> Result<(), ApiError> { + let now = Utc::now(); + let mut transaction = state.pool.begin().await?; + sqlx::query( + "UPDATE event_subscriptions SET status = $2, last_error = COALESCE($3, last_error), updated_at = $4 WHERE id = $1 AND status = 'active'", + ) + .bind(subscription_id) + .bind(status) + .bind(error) + .bind(now) + .execute(&mut *transaction) + .await?; + sqlx::query( + r#" + UPDATE event_deliveries + SET status = 'stopped', lease_until = NULL, + last_error = CASE + WHEN (SELECT status FROM event_subscriptions WHERE id = $1) = 'expired' THEN $2 + ELSE $3 + END + WHERE subscription_id = $1 AND status = 'pending' + "#, + ) + .bind(subscription_id) + .bind(SUBSCRIPTION_EXPIRED) + .bind(SUBSCRIPTION_INACTIVE) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) +} + +#[cfg(test)] +#[path = "delivery_tests.rs"] +mod tests; diff --git a/cloud/src/events/delivery_tests.rs b/cloud/src/events/delivery_tests.rs new file mode 100644 index 000000000..0dd016b8d --- /dev/null +++ b/cloud/src/events/delivery_tests.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use chrono::{TimeZone, Utc}; +use serde_json::json; + +use super::{classify, event_body, retry_delay, signed_headers, Outcome, MAX_ATTEMPTS}; +use crate::events::{ + callback::CallbackError, + secrets::{sign, signing_key}, +}; + +#[test] +fn retries_back_off_from_five_seconds_to_fifteen_minutes() { + let delays: Vec = (1..MAX_ATTEMPTS) + .map(|attempt| retry_delay(attempt).as_secs()) + .collect(); + assert_eq!( + delays, + vec![5, 10, 20, 40, 80, 160, 320, 640, 900, 900, 900] + ); + assert_eq!(retry_delay(0), Duration::from_secs(5)); + assert_eq!(retry_delay(i32::MAX), Duration::from_secs(900)); + let total: u64 = delays.iter().sum(); + assert!(total < 24 * 60 * 60, "every attempt fits inside retention"); +} + +#[test] +fn classifies_receiver_answers() { + assert_eq!(classify(&Ok(200)), Outcome::Delivered(200)); + assert_eq!(classify(&Ok(204)), Outcome::Delivered(204)); + assert_eq!(classify(&Ok(410)), Outcome::Gone); + assert_eq!(classify(&Ok(413)), Outcome::Dead("http_413".to_owned())); + assert_eq!(classify(&Ok(400)), Outcome::Dead("http_400".to_owned())); + assert_eq!(classify(&Ok(301)), Outcome::Dead("http_301".to_owned())); + for transient in [408, 425, 429, 500, 502, 503] { + assert_eq!( + classify(&Ok(transient)), + Outcome::Retry(format!("http_{transient}")) + ); + } + assert_eq!( + classify(&Err(CallbackError::Timeout)), + Outcome::Retry("timeout".to_owned()) + ); + assert_eq!( + classify(&Err(CallbackError::DnsFailed)), + Outcome::Retry("dns_failed".to_owned()) + ); + assert_eq!( + classify(&Err(CallbackError::NonPublicDestination)), + Outcome::Dead("non_public_destination".to_owned()) + ); +} + +#[test] +fn builds_the_mcp_events_body_with_envelope_fields() { + let occurred = Utc + .with_ymd_and_hms(2026, 10, 10, 12, 0, 0) + .single() + .unwrap_or_default(); + let body = event_body( + "0190f1f2-7a1b-7c3d-8e4f-1234567890ab", + "inbox.reply", + occurred, + ("runtime-1", Some("workspace-1"), None, 42), + &json!({"threadId": "t1", "questionId": "q1", "workspaceId": "spoofed"}), + "cursor-1", + ); + assert_eq!( + body, + json!({ + "eventId": "0190f1f2-7a1b-7c3d-8e4f-1234567890ab", + "name": "inbox.reply", + "timestamp": "2026-10-10T12:00:00.000Z", + "data": { + "threadId": "t1", + "questionId": "q1", + "runtimeId": "runtime-1", + "workspaceId": "workspace-1", + "seq": 42, + }, + "cursor": "cursor-1", + }) + ); +} + +#[test] +fn signs_each_attempt_over_the_exact_body() { + // The Standard Webhooks test vector, prefixed at runtime for secret scanners. + let secret = format!("whsec_{}", "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"); + let Some(key) = signing_key(&secret) else { + panic!("secret must decode"); + }; + let body = br#"{"eventId":"e1"}"#; + let headers = signed_headers( + "e1", + ("x-mcp-subscription-id", "sub_1"), + std::slice::from_ref(&key), + body, + ); + let value = |name: &str| { + headers + .iter() + .find(|(header, _)| header == name) + .map(|(_, value)| value.clone()) + .unwrap_or_default() + }; + assert_eq!(value("webhook-id"), "e1"); + assert_eq!(value("x-mcp-subscription-id"), "sub_1"); + assert_eq!(value("content-type"), "application/json"); + let timestamp: i64 = value("webhook-timestamp").parse().unwrap_or_default(); + assert!((Utc::now().timestamp() - timestamp).abs() < 5); + assert_eq!( + value("webhook-signature"), + sign(&key, "e1", timestamp, body) + ); +} diff --git a/cloud/src/events/fanout.rs b/cloud/src/events/fanout.rs new file mode 100644 index 000000000..8a1a617ff --- /dev/null +++ b/cloud/src/events/fanout.rs @@ -0,0 +1,127 @@ +//! Fan-out: one `event_deliveries` row per new event and matching subscription. + +use chrono::{DateTime, TimeDelta, Utc}; + +use crate::{error::ApiError, state::AppState}; + +use super::delivery::SUBSCRIPTION_EXPIRED; + +/// Events and their deliveries live for 24 hours after the cloud receives them. +pub const RETENTION: TimeDelta = TimeDelta::hours(24); +const FAN_OUT_BATCH: i64 = 500; + +/// Matches event `m` to subscription `s`. `$1` is now, `$2` the MCP Events switch. +/// MCP Events subscriptions also need MCP Control on the event's runtime (not `off`) and +/// a live grant that still reaches it. Webhooks belong to the account owner and do not +/// depend on MCP Control. +const MATCH: &str = r#" + s.account_id = m.account_id + AND s.status = 'active' + AND (s.refresh_before IS NULL OR s.refresh_before > $1) + AND m.kind = ANY(s.kinds) + AND (s.all_runtimes OR m.runtime_id = ANY(s.runtime_ids)) + AND NOT EXISTS ( + SELECT 1 FROM jsonb_each_text(s.filter) f + WHERE f.value IS DISTINCT FROM ( + CASE f.key + WHEN 'workspaceId' THEN m.workspace_id + WHEN 'projectId' THEN m.project_id + ELSE m.data ->> f.key + END + ) + ) + AND ( + s.target_kind = 'webhook' + OR ($2 AND EXISTS ( + SELECT 1 FROM runtimes r + WHERE r.id = m.runtime_id AND r.mcp_access <> 'off' + ) AND EXISTS ( + SELECT 1 FROM mcp_grants g + WHERE g.id = s.owner_grant_id + AND g.revoked_at IS NULL + AND (g.all_runtimes OR EXISTS ( + SELECT 1 FROM mcp_grant_runtimes gr + WHERE gr.grant_id = g.id AND gr.runtime_id = m.runtime_id + )) + )) + ) +"#; + +/// Fans out events that have not been fanned out yet. Concurrent workers skip each +/// other's rows. Returns the number of deliveries created. +pub async fn fan_out_pending(state: &AppState) -> Result { + let now = Utc::now(); + let created = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!( + r#" + WITH claimed AS ( + SELECT id FROM domain_events + WHERE fanned_out_at IS NULL + ORDER BY received_at + LIMIT $3 + FOR UPDATE SKIP LOCKED + ), + marked AS ( + UPDATE domain_events e SET fanned_out_at = $1 + FROM claimed c WHERE e.id = c.id + RETURNING e.* + ), + inserted AS ( + INSERT INTO event_deliveries ( + id, subscription_id, event_id, status, attempts, next_attempt_at, created_at + ) + SELECT gen_random_uuid(), s.id, m.id, 'pending', 0, $1, $1 + FROM marked m + JOIN event_subscriptions s ON {MATCH} + WHERE m.received_at > $4 + ON CONFLICT (subscription_id, event_id) DO NOTHING + RETURNING 1 + ) + SELECT COUNT(*) FROM inserted + "# + ))) + .bind(now) + .bind(state.config.events.mcp_events_enabled) + .bind(FAN_OUT_BATCH) + .bind(now - RETENTION) + .fetch_one(&state.pool) + .await?; + Ok(created.max(0) as u64) +} + +/// Queues every retained event since `since` for one subscription (MCP Events replay). +/// Deliveries that already exist are kept, so acknowledged events are not sent again, +/// except those stopped because the subscription expired: the refresh queues them again +/// with a fresh retry budget. Deliveries stopped for other reasons (revoked grant, +/// MCP Control off, `410 Gone`) stay stopped. +pub async fn backfill( + state: &AppState, + subscription_id: &str, + since: DateTime, +) -> Result { + let now = Utc::now(); + let since = since.max(now - RETENTION); + let created = sqlx::query(sqlx::AssertSqlSafe(format!( + r#" + INSERT INTO event_deliveries ( + id, subscription_id, event_id, status, attempts, next_attempt_at, created_at + ) + SELECT gen_random_uuid(), s.id, m.id, 'pending', 0, $1, $1 + FROM domain_events m + JOIN event_subscriptions s ON s.id = $3 AND {MATCH} + WHERE m.received_at >= $4 + ON CONFLICT (subscription_id, event_id) DO UPDATE SET + status = 'pending', attempts = 0, next_attempt_at = $1, lease_until = NULL, + last_status = NULL, last_error = NULL + WHERE event_deliveries.status = 'stopped' AND event_deliveries.last_error = $5 + "# + ))) + .bind(now) + .bind(state.config.events.mcp_events_enabled) + .bind(subscription_id) + .bind(since) + .bind(SUBSCRIPTION_EXPIRED) + .execute(&state.pool) + .await? + .rows_affected(); + Ok(created) +} diff --git a/cloud/src/events/ingest.rs b/cloud/src/events/ingest.rs new file mode 100644 index 000000000..45615f550 --- /dev/null +++ b/cloud/src/events/ingest.rs @@ -0,0 +1,382 @@ +//! Runtime side: `POST /v1/runtime/domain-events` and `GET /v1/runtime/event-subscriptions`. + +use std::collections::HashSet; + +use axum::{extract::State, http::HeaderMap, Json}; +use chrono::{TimeDelta, Utc}; +use uuid::Uuid; + +use crate::{ + api_models::ClientKind, + auth::{authenticate_any, AuthContext}, + error::ApiError, + state::AppState, +}; + +use super::{ + catalog::{self, DataViolation}, + models::{ + DomainEventBatch, DomainEventBatchResponse, DomainEventInput, EventSubscriptionCount, + RejectedEvent, + }, +}; + +pub const MAX_BATCH_EVENTS: usize = 100; +/// Tokens issued before `events:send` existed carry `push:send`. +pub const RUNTIME_EVENT_SCOPES: [&str; 2] = ["events:send", "push:send"]; + +/// Authenticates the runtime itself and returns its session. +pub async fn authenticate_runtime( + headers: &HeaderMap, + state: &AppState, +) -> Result { + let auth = authenticate_any(headers, state, &RUNTIME_EVENT_SCOPES).await?; + if auth.client_kind != ClientKind::Runtime { + return Err(ApiError::forbidden( + "runtime_session_required", + "A runtime session is required.", + )); + } + Ok(auth) +} + +pub async fn post_domain_events( + State(state): State, + headers: HeaderMap, + Json(batch): Json, +) -> Result, ApiError> { + let auth = authenticate_runtime(&headers, &state).await?; + if auth.client_id != batch.runtime_id { + return Err(not_owned()); + } + let (valid, rejected) = validate_batch(&batch)?; + let owned = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM runtimes + WHERE id = $1 AND account_id = $2 AND transferred_at IS NULL + ) + "#, + ) + .bind(&batch.runtime_id) + .bind(auth.account_id) + .fetch_one(&state.pool) + .await?; + if !owned { + return Err(not_owned()); + } + let mut seen = HashSet::new(); + let unique: Vec<&DomainEventInput> = valid + .iter() + .copied() + .filter(|event| seen.insert(event.event_id.to_ascii_lowercase())) + .collect(); + let repeated = valid.len() - unique.len(); + let accepted = insert_events(&state, &auth, &batch.runtime_id, &unique).await?; + if accepted > 0 { + super::fanout::fan_out_pending(&state).await?; + state.events.wake.notify_one(); + } + let active_subscriptions = + active_subscription_count(&state, auth.account_id, &batch.runtime_id).await?; + Ok(Json(DomainEventBatchResponse { + accepted, + duplicate: unique.len() - accepted + repeated, + active_subscriptions, + rejected, + })) +} + +pub async fn get_event_subscriptions( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let auth = authenticate_runtime(&headers, &state).await?; + let active_subscriptions = + active_subscription_count(&state, auth.account_id, &auth.client_id).await?; + Ok(Json(EventSubscriptionCount { + active_subscriptions, + })) +} + +async fn insert_events( + state: &AppState, + auth: &AuthContext, + runtime_id: &str, + events: &[&DomainEventInput], +) -> Result { + if events.is_empty() { + return Ok(0); + } + let now = Utc::now(); + let mut ids = Vec::with_capacity(events.len()); + let mut event_ids = Vec::with_capacity(events.len()); + let mut seqs = Vec::with_capacity(events.len()); + let mut kinds = Vec::with_capacity(events.len()); + let mut workspaces = Vec::with_capacity(events.len()); + let mut projects = Vec::with_capacity(events.len()); + let mut data = Vec::with_capacity(events.len()); + let mut occurred = Vec::with_capacity(events.len()); + for event in events { + let Some(kind) = catalog::kind(&event.kind) else { + return Err(invalid("kind")); + }; + ids.push(Uuid::now_v7()); + event_ids.push(event.event_id.to_ascii_lowercase()); + seqs.push(event.seq); + kinds.push(event.kind.clone()); + let projected = catalog::project_data(kind, &event.data); + // Kinds that list workspaceId in data also fill the envelope column filters use. + let workspace = event.workspace_id.clone().or_else(|| { + projected + .get("workspaceId") + .and_then(serde_json::Value::as_str) + .filter(|value| valid_id(value)) + .map(ToOwned::to_owned) + }); + workspaces.push(workspace); + projects.push(event.project_id.clone()); + data.push(projected); + occurred.push(event.occurred_at); + } + let inserted = sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO domain_events ( + id, account_id, runtime_id, event_id, seq, kind, workspace_id, project_id, + data, occurred_at, received_at + ) + SELECT e.id, $1, $2, e.event_id, e.seq, e.kind, e.workspace_id, e.project_id, + e.data, e.occurred_at, $3 + FROM UNNEST( + $4::uuid[], $5::text[], $6::bigint[], $7::text[], $8::text[], $9::text[], + $10::jsonb[], $11::timestamptz[] + ) AS e(id, event_id, seq, kind, workspace_id, project_id, data, occurred_at) + ON CONFLICT (runtime_id, event_id) DO NOTHING + RETURNING id + "#, + ) + .bind(auth.account_id) + .bind(runtime_id) + .bind(now) + .bind(&ids) + .bind(&event_ids) + .bind(&seqs) + .bind(&kinds) + .bind(&workspaces) + .bind(&projects) + .bind(&data) + .bind(&occurred) + .fetch_all(&state.pool) + .await?; + Ok(inserted.len()) +} + +/// Splits a batch into the events that pass the payload policy and the ones that do not. +/// Only a malformed batch as a whole (empty or oversized) fails the request: a runtime +/// retries a failed request with the same batch, so one bad event must not fail it. +fn validate_batch( + batch: &DomainEventBatch, +) -> Result<(Vec<&DomainEventInput>, Vec), ApiError> { + if batch.events.is_empty() || batch.events.len() > MAX_BATCH_EVENTS { + return Err(ApiError::bad_request( + "invalid_event_batch", + format!("A batch carries 1 to {MAX_BATCH_EVENTS} events."), + )); + } + let latest = Utc::now() + TimeDelta::minutes(5); + let mut valid = Vec::new(); + let mut rejected = Vec::new(); + for event in &batch.events { + match validate_event(event, latest) { + Ok(()) => valid.push(event), + Err(error) => rejected.push(RejectedEvent { + event_id: truncated(&event.event_id), + code: error_code(&error), + }), + } + } + Ok((valid, rejected)) +} + +fn error_code(error: &ApiError) -> &'static str { + match error { + ApiError::Request { code, .. } => code, + _ => "invalid_event", + } +} + +pub(crate) fn validate_event( + event: &DomainEventInput, + latest: chrono::DateTime, +) -> Result<(), ApiError> { + if Uuid::parse_str(&event.event_id).is_err() { + return Err(invalid("eventId")); + } + if event.seq < 0 { + return Err(invalid("seq")); + } + if catalog::kind(&event.kind).is_none() { + return Err(ApiError::bad_request( + "unknown_event_kind", + format!("Unknown event kind {}.", truncated(&event.kind)), + )); + } + for (value, field) in [ + (&event.workspace_id, "workspaceId"), + (&event.project_id, "projectId"), + ] { + if let Some(value) = value { + if !valid_id(value) { + return Err(invalid(field)); + } + } + } + if event.occurred_at > latest { + return Err(ApiError::bad_request( + "invalid_event_time", + "The event timestamp is too far in the future.", + )); + } + catalog::check_data(&event.data).map_err(|violation| match violation { + DataViolation::SensitiveKey => ApiError::bad_request( + "sensitive_event_data", + "Events carry ids and states only; prompts, message text, commands, and output are refused.", + ), + DataViolation::TooManyKeys => ApiError::bad_request( + "event_data_too_large", + "Event data has too many fields.", + ), + _ => ApiError::bad_request( + "invalid_event_data", + "Event data must be a flat object of short scalar values.", + ), + }) +} + +pub(crate) fn valid_id(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 128 && !value.chars().any(char::is_control) +} + +fn truncated(value: &str) -> String { + value.chars().take(64).collect() +} + +fn invalid(field: &str) -> ApiError { + ApiError::bad_request("invalid_event_field", format!("{field} is invalid.")) +} + +fn not_owned() -> ApiError { + ApiError::forbidden( + "runtime_event_not_owned", + "The runtime events do not belong to this account session.", + ) +} + +/// Active webhooks and MCP Events subscriptions that would receive this runtime's events. +/// MCP Events subscriptions count only while MCP Control on the runtime is not `off`. +pub async fn active_subscription_count( + state: &AppState, + account_id: Uuid, + runtime_id: &str, +) -> Result { + let count = sqlx::query_scalar::<_, i64>( + r#" + SELECT COUNT(*) + FROM event_subscriptions s + WHERE s.account_id = $1 + AND s.status = 'active' + AND (s.refresh_before IS NULL OR s.refresh_before > $3) + AND (s.all_runtimes OR $2 = ANY(s.runtime_ids)) + AND ( + s.target_kind = 'webhook' + OR ($4 AND EXISTS ( + SELECT 1 FROM runtimes r WHERE r.id = $2 AND r.mcp_access <> 'off' + ) AND EXISTS ( + SELECT 1 FROM mcp_grants g + WHERE g.id = s.owner_grant_id + AND g.revoked_at IS NULL + AND (g.all_runtimes OR EXISTS ( + SELECT 1 FROM mcp_grant_runtimes gr + WHERE gr.grant_id = g.id AND gr.runtime_id = $2 + )) + )) + ) + "#, + ) + .bind(account_id) + .bind(runtime_id) + .bind(Utc::now()) + .bind(state.config.events.mcp_events_enabled) + .fetch_one(&state.pool) + .await?; + Ok(count.max(0) as usize) +} + +#[cfg(test)] +mod tests { + use chrono::{TimeDelta, Utc}; + use serde_json::json; + + use super::{validate_event, DomainEventInput}; + + fn event(kind: &str, data: serde_json::Value) -> DomainEventInput { + DomainEventInput { + event_id: "0190f1f2-7a1b-7c3d-8e4f-1234567890ab".to_owned(), + seq: 4, + kind: kind.to_owned(), + workspace_id: Some("workspace-1".to_owned()), + project_id: None, + data, + occurred_at: Utc::now(), + } + } + + fn code(result: Result<(), crate::error::ApiError>) -> &'static str { + match result { + Ok(()) => "ok", + Err(crate::error::ApiError::Request { code, .. }) => code, + Err(_) => "other", + } + } + + #[test] + fn validates_the_event_envelope_and_payload_policy() { + let latest = Utc::now() + TimeDelta::minutes(5); + assert_eq!( + code(validate_event( + &event("inbox.reply", json!({"threadId": "t"})), + latest + )), + "ok" + ); + assert_eq!( + code(validate_event(&event("alera.test", json!({})), latest)), + "unknown_event_kind" + ); + assert_eq!( + code(validate_event( + &event("inbox.reply", json!({"replyText": "hi"})), + latest + )), + "sensitive_event_data" + ); + let mut bad_id = event("agent.status", json!({})); + bad_id.event_id = "not-a-uuid".to_owned(); + assert_eq!(code(validate_event(&bad_id, latest)), "invalid_event_field"); + let mut negative = event("agent.status", json!({})); + negative.seq = -1; + assert_eq!( + code(validate_event(&negative, latest)), + "invalid_event_field" + ); + let mut future = event("agent.status", json!({})); + future.occurred_at = Utc::now() + TimeDelta::hours(1); + assert_eq!(code(validate_event(&future, latest)), "invalid_event_time"); + let mut workspace = event("agent.status", json!({})); + workspace.workspace_id = Some("w".repeat(129)); + assert_eq!( + code(validate_event(&workspace, latest)), + "invalid_event_field" + ); + } +} diff --git a/cloud/src/events/mcp_subscriptions.rs b/cloud/src/events/mcp_subscriptions.rs new file mode 100644 index 000000000..f22728770 --- /dev/null +++ b/cloud/src/events/mcp_subscriptions.rs @@ -0,0 +1,321 @@ +//! OpenAI MCP Events subscriptions, called by the edge with the MCP client's token. +//! Each subscription is bound to the grant that created it and stops when it is revoked. +//! It receives events only from runtimes whose MCP Control is not `off`; naming an `off` +//! runtime is refused with `runtime_mcp_disabled`. + +use std::collections::BTreeMap; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + Json, +}; +use chrono::{SecondsFormat, TimeDelta, Utc}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use crate::{ + auth::{authenticate_mcp, validation::random_secret, McpAuthContext}, + error::ApiError, + mcp_gateway::{reachable_runtimes, resolve_runtime}, + mcp_models::{McpAccess, SCOPE_READ}, + state::AppState, +}; + +use super::{ + callback::CallbackError, + catalog::{self, EventKind}, + cursor, + delivery::signed_headers, + fanout::{backfill, RETENTION}, + ingest::valid_id, + models::{McpDelivery, McpSubscribeRequest, McpSubscribeResponse, McpUnsubscribeRequest}, + secrets::signing_key, + webhooks::{check_callback, require_secrets}, +}; + +const MIN_LIFETIME_SECONDS: i64 = 60; +const VERIFICATION_REUSE: TimeDelta = TimeDelta::minutes(5); +const ROTATION_WINDOW: TimeDelta = TimeDelta::minutes(5); + +struct Subscription<'a> { + kind: &'static EventKind, + arguments: BTreeMap, + url: &'a str, +} + +fn invalid(message: impl Into) -> ApiError { + ApiError::bad_request("invalid_event_subscription", message) +} + +/// A callback that failed verification. The edge turns it into JSON-RPC `-32015`. +fn callback_error(reason: &str) -> ApiError { + ApiError::Request { + status: StatusCode::UNPROCESSABLE_ENTITY, + code: "callback_endpoint_error", + message: reason.to_owned(), + } +} + +async fn authorize(headers: &HeaderMap, state: &AppState) -> Result { + if !state.config.events.mcp_events_enabled { + return Err(ApiError::not_found( + "mcp_events_disabled", + "MCP Events are not enabled.", + )); + } + let auth = authenticate_mcp(headers, state).await?; + auth.require_scope(SCOPE_READ)?; + Ok(auth) +} + +fn parse<'a>( + name: &str, + arguments: &serde_json::Map, + delivery: &'a McpDelivery, +) -> Result, ApiError> { + let kind = catalog::kind(name).ok_or_else(|| invalid(format!("Unknown event {name}.")))?; + if delivery.mode != "webhook" { + return Err(invalid("Only webhook delivery is supported.")); + } + let mut parsed = BTreeMap::new(); + for (key, value) in arguments { + let allowed = + key == "runtime" || key == "workspaceId" || kind.filters.contains(&key.as_str()); + let value = value.as_str().filter(|value| valid_id(value)); + match (allowed, value) { + (true, Some(value)) => { + parsed.insert(key.clone(), value.to_owned()); + } + _ => return Err(invalid(format!("Invalid argument {key} for {name}."))), + } + } + Ok(Subscription { + kind, + arguments: parsed, + url: &delivery.url, + }) +} + +/// Deterministic id from the grant, callback URL, event name, and canonical arguments. +fn subscription_id(auth: &McpAuthContext, subscription: &Subscription<'_>) -> String { + let canonical = json!([ + auth.account_id, + auth.grant_id, + subscription.url, + subscription.kind.name, + subscription.arguments, + ]); + format!( + "sub_{}", + hex::encode(Sha256::digest(canonical.to_string().as_bytes())) + ) +} + +/// Sends the signed verification challenge and requires the receiver to echo it. +async fn verify(state: &AppState, url: &str, id: &str, key: &[u8]) -> Result<(), ApiError> { + let challenge = random_secret(""); + let body = serde_json::to_vec(&json!({"type": "verification", "challenge": challenge})) + .map_err(ApiError::internal)?; + let message_id = format!("msg_verification_{}", &random_secret("")[..16]); + let headers = signed_headers( + &message_id, + ("x-mcp-subscription-id", id), + &[key.to_vec()], + &body, + ); + let reply = state + .events + .callbacks + .post(url, &headers, body) + .await + .map_err(|error: CallbackError| callback_error(error.reason()))?; + let echoed = serde_json::from_slice::(&reply.body) + .ok() + .and_then(|value| { + value + .get("challenge") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + }); + let matches = + echoed.is_some_and(|echoed| bool::from(echoed.as_bytes().ct_eq(challenge.as_bytes()))); + if (200..300).contains(&reply.status) && matches { + Ok(()) + } else { + Err(callback_error("challenge_failed")) + } +} + +#[derive(sqlx::FromRow)] +struct Existing { + secret_ciphertext: String, + status: String, + verified_at: Option>, +} + +pub async fn subscribe( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let auth = authorize(&headers, &state).await?; + let subscription = parse(&request.name, &request.arguments, &request.delivery)?; + let secret = request.delivery.secret.as_deref().unwrap_or_default(); + let key = signing_key(secret) + .ok_or_else(|| invalid("delivery.secret must be whsec_ base64 of 24 to 64 bytes."))?; + let secrets = require_secrets(&state)?; + let now = Utc::now(); + let (runtime_ids, all_runtimes) = match subscription.arguments.get("runtime") { + Some(runtime) => { + let runtimes = reachable_runtimes(&state, &auth).await?; + let chosen = resolve_runtime(&runtimes, Some(runtime), now)?; + if chosen.mcp_access.parse().unwrap_or(McpAccess::Off) == McpAccess::Off { + return Err(ApiError::conflict( + "runtime_mcp_disabled", + format!( + "MCP Control is off on runtime {}. Turn it on to subscribe to its events.", + chosen.name + ), + )); + } + (vec![chosen.id.clone()], false) + } + None => (Vec::new(), true), + }; + let filter: serde_json::Map = subscription + .arguments + .iter() + .filter(|(key, _)| key.as_str() != "runtime") + .map(|(key, value)| (key.clone(), Value::from(value.as_str()))) + .collect(); + let (anchor, truncated) = match request.cursor.as_deref() { + Some(text) => { + let since = + cursor::decode(text, now).ok_or_else(|| invalid("The cursor is invalid."))?; + let floor = now - RETENTION; + (since.max(floor), since < floor) + } + None => (now, false), + }; + check_callback(&state, subscription.url) + .await + .map_err(|error| callback_error(error.reason()))?; + let id = subscription_id(&auth, &subscription); + let existing = sqlx::query_as::<_, Existing>( + "SELECT secret_ciphertext, status, verified_at FROM event_subscriptions WHERE id = $1 AND account_id = $2", + ) + .bind(&id) + .bind(auth.account_id) + .fetch_optional(&state.pool) + .await?; + let previous_secret = existing + .as_ref() + .and_then(|row| secrets.decrypt(&row.secret_ciphertext, &id).ok()); + let secret_changed = existing.is_some() && previous_secret.as_deref() != Some(secret); + let reuse_verification = existing.as_ref().is_some_and(|row| { + row.status == "active" + && !secret_changed + && row + .verified_at + .is_some_and(|at| at > now - VERIFICATION_REUSE) + }); + if !reuse_verification { + verify(&state, subscription.url, &id, &key).await?; + } + let lifetime = request + .ttl_ms + .map(|ms| ms / 1000) + .unwrap_or(RETENTION.num_seconds()) + .clamp(MIN_LIFETIME_SECONDS, RETENTION.num_seconds()); + let refresh_before = now + TimeDelta::seconds(lifetime); + let ciphertext = secrets.encrypt(secret, &id).map_err(ApiError::internal)?; + sqlx::query( + r#" + INSERT INTO event_subscriptions ( + id, account_id, target_kind, callback_url, secret_ciphertext, kinds, runtime_ids, + all_runtimes, status, refresh_before, owner_grant_id, filter, verified_at, + created_at, updated_at + ) VALUES ($1, $2, 'mcp_events', $3, $4, $5, $6, $7, 'active', $8, $9, $10, $11, $12, $12) + ON CONFLICT (id) DO UPDATE SET + previous_secret_ciphertext = CASE WHEN $13 + THEN event_subscriptions.secret_ciphertext + ELSE event_subscriptions.previous_secret_ciphertext END, + previous_secret_until = CASE WHEN $13 + THEN $14 ELSE event_subscriptions.previous_secret_until END, + secret_ciphertext = EXCLUDED.secret_ciphertext, + runtime_ids = EXCLUDED.runtime_ids, + all_runtimes = EXCLUDED.all_runtimes, + filter = EXCLUDED.filter, + status = 'active', + refresh_before = EXCLUDED.refresh_before, + verified_at = COALESCE(EXCLUDED.verified_at, event_subscriptions.verified_at), + last_error = NULL, + updated_at = EXCLUDED.updated_at + "#, + ) + .bind(&id) + .bind(auth.account_id) + .bind(subscription.url) + .bind(&ciphertext) + .bind(vec![subscription.kind.name.to_owned()]) + .bind(&runtime_ids) + .bind(all_runtimes) + .bind(refresh_before) + .bind(auth.grant_id) + .bind(Value::Object(filter)) + .bind((!reuse_verification).then_some(now)) + .bind(now) + .bind(secret_changed) + .bind(now + ROTATION_WINDOW) + .execute(&state.pool) + .await?; + if request.cursor.is_some() { + backfill(&state, &id, anchor).await?; + state.events.wake.notify_one(); + } + Ok(Json(McpSubscribeResponse { + id, + refresh_before: refresh_before.to_rfc3339_opts(SecondsFormat::Secs, true), + cursor: cursor::encode(anchor), + truncated, + })) +} + +/// Idempotent: unknown or already removed subscriptions also answer `204`. +pub async fn unsubscribe( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + let auth = authorize(&headers, &state).await?; + let subscription = parse(&request.name, &request.arguments, &request.delivery)?; + let id = subscription_id(&auth, &subscription); + delete_owned(&state, &auth, &id).await +} + +pub async fn delete_subscription( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let auth = authorize(&headers, &state).await?; + delete_owned(&state, &auth, &id).await +} + +async fn delete_owned( + state: &AppState, + auth: &McpAuthContext, + id: &str, +) -> Result { + sqlx::query( + "DELETE FROM event_subscriptions WHERE id = $1 AND account_id = $2 AND owner_grant_id = $3", + ) + .bind(id) + .bind(auth.account_id) + .bind(auth.grant_id) + .execute(&state.pool) + .await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/cloud/src/events/mod.rs b/cloud/src/events/mod.rs new file mode 100644 index 000000000..6531c6bff --- /dev/null +++ b/cloud/src/events/mod.rs @@ -0,0 +1,89 @@ +//! Runtime domain events delivered to generic webhooks and OpenAI MCP Events +//! subscriptions. See `docs/remote-mcp.md` (Events). + +pub mod callback; +pub mod catalog; +pub mod cursor; +pub mod delivery; +pub mod fanout; +pub mod ingest; +pub mod mcp_subscriptions; +pub mod models; +pub mod secrets; +pub mod webhooks; +pub mod wire; +pub mod worker; + +use std::sync::Arc; + +use axum::{ + extract::DefaultBodyLimit, + routing::{delete, get, post}, + Router, +}; +use tokio::sync::Notify; + +use crate::{config::EventsConfig, state::AppState}; + +use self::{callback::CallbackClient, secrets::SecretBox}; + +/// Shared event delivery dependencies. +#[derive(Clone)] +pub struct EventsContext { + /// `None` when `ALERA_WEBHOOK_SECRET_KEY` is unset: nothing can be created or sent. + pub secrets: Option>, + pub callbacks: CallbackClient, + /// Wakes the delivery loop when new deliveries are queued. + pub wake: Arc, +} + +impl EventsContext { + pub fn from_config(config: &EventsConfig) -> Self { + let secrets = config.secret_key.as_ref().and_then(|key| { + match SecretBox::new(key, config.previous_secret_key.as_ref()) { + Ok(secrets) => Some(Arc::new(secrets)), + Err(error) => { + tracing::error!(error = %error, "webhook secret key is unusable"); + None + } + } + }); + Self { + secrets, + callbacks: CallbackClient::new(config.callbacks), + wake: Arc::new(Notify::new()), + } + } +} + +/// Routes for runtimes (events and webhooks), the edge (MCP Events), and the pump. +pub fn router() -> Router { + Router::new() + .route( + "/v1/runtime/domain-events", + post(ingest::post_domain_events).layer(DefaultBodyLimit::max(512 * 1024)), + ) + .route( + "/v1/runtime/event-subscriptions", + get(ingest::get_event_subscriptions), + ) + .route( + "/v1/webhooks", + get(webhooks::list_webhooks).post(webhooks::create_webhook), + ) + .route("/v1/webhooks/{id}", delete(webhooks::delete_webhook)) + .route("/v1/webhooks/{id}/test", post(webhooks::test_webhook)) + .route( + "/v1/mcp/event-subscriptions", + post(mcp_subscriptions::subscribe), + ) + .route( + "/v1/mcp/event-subscriptions/unsubscribe", + post(mcp_subscriptions::unsubscribe), + ) + .route( + "/v1/mcp/event-subscriptions/{id}", + delete(mcp_subscriptions::delete_subscription), + ) + .route("/v1/internal/event-deliveries/pump", post(worker::pump)) +} diff --git a/cloud/src/events/models.rs b/cloud/src/events/models.rs new file mode 100644 index 000000000..d64764d61 --- /dev/null +++ b/cloud/src/events/models.rs @@ -0,0 +1,143 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use uuid::Uuid; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainEventBatch { + pub runtime_id: String, + pub events: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainEventInput { + pub event_id: String, + pub seq: i64, + pub kind: String, + #[serde(default)] + pub workspace_id: Option, + #[serde(default)] + pub project_id: Option, + #[serde(default = "empty_object")] + pub data: Value, + pub occurred_at: DateTime, +} + +fn empty_object() -> Value { + Value::Object(Map::new()) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DomainEventBatchResponse { + /// Events stored by this request. + pub accepted: usize, + /// Events already stored earlier (same runtime and event id) or repeated in the batch. + pub duplicate: usize, + pub active_subscriptions: usize, + /// Events refused by the payload policy. The rest of the batch is still stored, so + /// one bad event never blocks the runtime's journal. Omitted when empty. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub rejected: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RejectedEvent { + /// As sent, cut to 64 characters. + pub event_id: String, + /// The error code the event would have produced on its own, such as `invalid_event_time`. + pub code: &'static str, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EventSubscriptionCount { + pub active_subscriptions: usize, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateWebhookRequest { + pub url: String, + pub kinds: Vec, + #[serde(default)] + pub runtime_ids: Option>, + #[serde(default)] + pub all_runtimes: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookSummary { + pub id: String, + pub url: String, + pub kinds: Vec, + pub runtime_ids: Vec, + pub all_runtimes: bool, + pub status: String, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_delivery_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +#[derive(Debug, Serialize)] +pub struct WebhookList { + pub webhooks: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CreatedWebhook { + pub webhook: WebhookSummary, + /// The `whsec_` signing secret. Returned only once. + pub secret: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TestDelivery { + pub delivery_id: Uuid, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct McpDelivery { + pub mode: String, + pub url: String, + #[serde(default)] + pub secret: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpSubscribeRequest { + pub name: String, + #[serde(default)] + pub arguments: Map, + pub delivery: McpDelivery, + #[serde(default)] + pub cursor: Option, + /// Absent or null both get the 24-hour maximum. + #[serde(default)] + pub ttl_ms: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct McpUnsubscribeRequest { + pub name: String, + #[serde(default)] + pub arguments: Map, + pub delivery: McpDelivery, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpSubscribeResponse { + pub id: String, + pub refresh_before: String, + pub cursor: String, + pub truncated: bool, +} diff --git a/cloud/src/events/secrets.rs b/cloud/src/events/secrets.rs new file mode 100644 index 000000000..d3c981cf0 --- /dev/null +++ b/cloud/src/events/secrets.rs @@ -0,0 +1,204 @@ +//! Standard Webhooks signing secrets: validation, generation, signing, and encryption at rest. + +use aws_lc_rs::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN}; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine, +}; +use hmac::{Hmac, KeyInit, Mac}; +use rand::{rand_core::UnwrapErr, rngs::SysRng, Rng}; +use sha2::{Digest, Sha256}; + +use crate::config::SecretKey; + +pub const SECRET_PREFIX: &str = "whsec_"; +const CIPHERTEXT_VERSION: &str = "v1"; + +/// Decodes a `whsec_` secret whose base64 key is 24 to 64 bytes, as OpenAI MCP Events +/// requires. Returns the raw HMAC key. +pub fn signing_key(secret: &str) -> Option> { + let encoded = secret.strip_prefix(SECRET_PREFIX)?; + let key = STANDARD.decode(encoded).ok()?; + (24..=64).contains(&key.len()).then_some(key) +} + +/// A new random 32-byte signing secret in `whsec_` form. +pub fn generate_secret() -> String { + let mut bytes = [0_u8; 32]; + UnwrapErr(SysRng).fill_bytes(&mut bytes); + format!("{SECRET_PREFIX}{}", STANDARD.encode(bytes)) +} + +/// The Standard Webhooks `v1` signature of `{id}.{timestamp}.{body}`. +pub fn sign(key: &[u8], message_id: &str, timestamp: i64, body: &[u8]) -> String { + // HMAC accepts keys of any length, so construction cannot fail. + let mut mac = match Hmac::::new_from_slice(key) { + Ok(mac) => mac, + Err(_) => return String::new(), + }; + mac.update(message_id.as_bytes()); + mac.update(b"."); + mac.update(timestamp.to_string().as_bytes()); + mac.update(b"."); + mac.update(body); + format!("v1,{}", STANDARD.encode(mac.finalize().into_bytes())) +} + +/// The `webhook-signature` header: one signature per key, separated by spaces. +pub fn signature_header(keys: &[Vec], message_id: &str, timestamp: i64, body: &[u8]) -> String { + keys.iter() + .map(|key| sign(key, message_id, timestamp, body)) + .collect::>() + .join(" ") +} + +/// Encrypts signing secrets with AES-256-GCM. The subscription id is the associated +/// data, so a ciphertext copied to another subscription does not decrypt. +pub struct SecretBox { + current: (String, LessSafeKey), + previous: Option<(String, LessSafeKey)>, +} + +#[derive(Debug, thiserror::Error)] +#[error("webhook secret encryption failed")] +pub struct SecretBoxError; + +fn key_id(key: &SecretKey) -> String { + hex::encode(&Sha256::digest(key.0)[..4]) +} + +fn aead_key(key: &SecretKey) -> Result { + UnboundKey::new(&AES_256_GCM, &key.0) + .map(LessSafeKey::new) + .map_err(|_| SecretBoxError) +} + +impl SecretBox { + pub fn new(current: &SecretKey, previous: Option<&SecretKey>) -> Result { + Ok(Self { + current: (key_id(current), aead_key(current)?), + previous: previous + .map(|key| Ok::<_, SecretBoxError>((key_id(key), aead_key(key)?))) + .transpose()?, + }) + } + + /// Returns `v1::`. + pub fn encrypt( + &self, + plaintext: &str, + subscription_id: &str, + ) -> Result { + let mut nonce = [0_u8; NONCE_LEN]; + UnwrapErr(SysRng).fill_bytes(&mut nonce); + let mut sealed = plaintext.as_bytes().to_vec(); + self.current + .1 + .seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce), + Aad::from(subscription_id.as_bytes()), + &mut sealed, + ) + .map_err(|_| SecretBoxError)?; + let mut payload = nonce.to_vec(); + payload.extend_from_slice(&sealed); + Ok(format!( + "{CIPHERTEXT_VERSION}:{}:{}", + self.current.0, + URL_SAFE_NO_PAD.encode(payload) + )) + } + + pub fn decrypt( + &self, + ciphertext: &str, + subscription_id: &str, + ) -> Result { + let mut parts = ciphertext.splitn(3, ':'); + let (Some(CIPHERTEXT_VERSION), Some(id), Some(encoded)) = + (parts.next(), parts.next(), parts.next()) + else { + return Err(SecretBoxError); + }; + let key = if self.current.0 == id { + &self.current.1 + } else { + match &self.previous { + Some((previous_id, key)) if previous_id == id => key, + _ => return Err(SecretBoxError), + } + }; + let payload = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| SecretBoxError)?; + if payload.len() < NONCE_LEN { + return Err(SecretBoxError); + } + let (nonce, sealed) = payload.split_at(NONCE_LEN); + let nonce = Nonce::try_assume_unique_for_key(nonce).map_err(|_| SecretBoxError)?; + let mut sealed = sealed.to_vec(); + let plaintext = key + .open_in_place(nonce, Aad::from(subscription_id.as_bytes()), &mut sealed) + .map_err(|_| SecretBoxError)?; + String::from_utf8(plaintext.to_vec()).map_err(|_| SecretBoxError) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test vector published by the Standard Webhooks specification. The prefix is + // added at runtime so secret scanners do not read the public vector as a leak. + const SPEC_KEY: &str = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + const SPEC_ID: &str = "msg_p5jXN8AQM9LWM0D4loKWxJek"; + const SPEC_TIMESTAMP: i64 = 1_614_265_330; + const SPEC_BODY: &str = r#"{"test": 2432232314}"#; + const SPEC_SIGNATURE: &str = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="; + + #[test] + fn signs_the_standard_webhooks_test_vector() { + let Some(key) = signing_key(&format!("{SECRET_PREFIX}{SPEC_KEY}")) else { + panic!("the spec secret must decode"); + }; + assert_eq!( + sign(&key, SPEC_ID, SPEC_TIMESTAMP, SPEC_BODY.as_bytes()), + SPEC_SIGNATURE + ); + let Some(other) = signing_key(&generate_secret()) else { + panic!("a generated secret must decode"); + }; + let header = signature_header(&[key, other], SPEC_ID, SPEC_TIMESTAMP, SPEC_BODY.as_bytes()); + assert!(header.starts_with(&format!("{SPEC_SIGNATURE} v1,"))); + assert_eq!(header.split(' ').count(), 2); + } + + #[test] + fn validates_secret_format_and_length() { + assert!(signing_key(&generate_secret()).is_some()); + assert!(signing_key(&format!("whsec_{}", STANDARD.encode([1_u8; 24]))).is_some()); + assert!(signing_key(&format!("whsec_{}", STANDARD.encode([1_u8; 64]))).is_some()); + assert!(signing_key(&format!("whsec_{}", STANDARD.encode([1_u8; 23]))).is_none()); + assert!(signing_key(&format!("whsec_{}", STANDARD.encode([1_u8; 65]))).is_none()); + assert!(signing_key(&STANDARD.encode([1_u8; 32])).is_none()); + assert!(signing_key("whsec_not base64!").is_none()); + } + + #[test] + fn encrypts_bound_to_the_subscription_and_rotates_keys() -> Result<(), SecretBoxError> { + let old = SecretKey([1_u8; 32]); + let new = SecretKey([2_u8; 32]); + let before = SecretBox::new(&old, None)?; + let sealed_old = before.encrypt("whsec_secret", "sub-1")?; + assert!(!sealed_old.contains("whsec_secret")); + assert_eq!(before.decrypt(&sealed_old, "sub-1")?, "whsec_secret"); + assert!(before.decrypt(&sealed_old, "sub-2").is_err()); + let rotated = SecretBox::new(&new, Some(&old))?; + assert_eq!(rotated.decrypt(&sealed_old, "sub-1")?, "whsec_secret"); + let sealed_new = rotated.encrypt("whsec_secret", "sub-1")?; + assert_ne!(sealed_new, sealed_old); + assert!(before.decrypt(&sealed_new, "sub-1").is_err()); + assert!(rotated.decrypt("v1:00000000:AAAA", "sub-1").is_err()); + Ok(()) + } +} diff --git a/cloud/src/events/webhooks.rs b/cloud/src/events/webhooks.rs new file mode 100644 index 000000000..65de7718a --- /dev/null +++ b/cloud/src/events/webhooks.rs @@ -0,0 +1,296 @@ +//! Generic webhooks, managed by a signed-in runtime on behalf of its account. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + Json, +}; +use chrono::{DateTime, Utc}; +use sqlx::FromRow; +use uuid::Uuid; + +use crate::{error::ApiError, state::AppState}; + +use super::{ + callback::CallbackError, + catalog::{self, TEST_EVENT_KIND}, + ingest::{authenticate_runtime, valid_id}, + models::{CreateWebhookRequest, CreatedWebhook, TestDelivery, WebhookList, WebhookSummary}, + secrets::{generate_secret, SecretBox}, +}; + +pub const MAX_WEBHOOKS_PER_ACCOUNT: i64 = 20; +const MAX_RUNTIME_IDS: usize = 20; + +#[derive(FromRow)] +struct WebhookRow { + id: String, + callback_url: String, + kinds: Vec, + runtime_ids: Vec, + all_runtimes: bool, + status: String, + created_at: DateTime, + last_delivery_at: Option>, + last_error: Option, +} + +impl From for WebhookSummary { + fn from(row: WebhookRow) -> Self { + Self { + id: row.id, + url: row.callback_url, + kinds: row.kinds, + runtime_ids: row.runtime_ids, + all_runtimes: row.all_runtimes, + status: row.status, + created_at: row.created_at, + last_delivery_at: row.last_delivery_at, + last_error: row.last_error, + } + } +} + +const WEBHOOK_COLUMNS: &str = "id, callback_url, kinds, runtime_ids, all_runtimes, status, created_at, last_delivery_at, last_error"; + +/// The secret box, or `503` when the cloud has no webhook encryption key. +pub fn require_secrets(state: &AppState) -> Result, ApiError> { + state.events.secrets.clone().ok_or_else(|| { + ApiError::unavailable( + "webhooks_not_configured", + "Webhooks are not configured on this Alera cloud (ALERA_WEBHOOK_SECRET_KEY is missing).", + ) + }) +} + +/// Checks the URL and its DNS answers before anything is stored. +pub async fn check_callback(state: &AppState, url: &str) -> Result<(), CallbackError> { + let parsed = state.events.callbacks.validate_url(url)?; + state + .events + .callbacks + .destination(&parsed) + .await + .map(|_| ()) +} + +pub async fn list_webhooks( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + let auth = authenticate_runtime(&headers, &state).await?; + let rows = sqlx::query_as::<_, WebhookRow>(sqlx::AssertSqlSafe(format!( + "SELECT {WEBHOOK_COLUMNS} FROM event_subscriptions WHERE account_id = $1 AND target_kind = 'webhook' ORDER BY created_at, id" + ))) + .bind(auth.account_id) + .fetch_all(&state.pool) + .await?; + Ok(Json(WebhookList { + webhooks: rows.into_iter().map(WebhookSummary::from).collect(), + })) +} + +pub async fn create_webhook( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result, ApiError> { + let auth = authenticate_runtime(&headers, &state).await?; + let secrets = require_secrets(&state)?; + let kinds = validate_kinds(&request.kinds)?; + let runtime_ids = if request.all_runtimes { + Vec::new() + } else { + let mut ids = request + .runtime_ids + .clone() + .unwrap_or_else(|| vec![auth.client_id.clone()]); + ids.sort(); + ids.dedup(); + if ids.is_empty() || ids.len() > MAX_RUNTIME_IDS || !ids.iter().all(|id| valid_id(id)) { + return Err(ApiError::bad_request( + "invalid_webhook_runtimes", + "Name 1 to 20 runtimes, or set allRuntimes.", + )); + } + let owned = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM runtimes WHERE account_id = $1 AND transferred_at IS NULL AND id = ANY($2)", + ) + .bind(auth.account_id) + .bind(&ids) + .fetch_one(&state.pool) + .await?; + if owned != ids.len() as i64 { + return Err(ApiError::not_found( + "runtime_not_found", + "A webhook runtime does not belong to this account.", + )); + } + ids + }; + check_callback(&state, &request.url) + .await + .map_err(|error| { + ApiError::bad_request( + "invalid_webhook_url", + format!( + "Webhook URLs must use HTTPS on port 443 and resolve to public addresses ({}).", + error.reason() + ), + ) + })?; + let mut transaction = state.pool.begin().await?; + // Locking the account row serializes creations so concurrent requests cannot pass + // the limit together. + sqlx::query("SELECT id FROM accounts WHERE id = $1 FOR UPDATE") + .bind(auth.account_id) + .fetch_optional(&mut *transaction) + .await?; + let count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM event_subscriptions WHERE account_id = $1 AND target_kind = 'webhook'", + ) + .bind(auth.account_id) + .fetch_one(&mut *transaction) + .await?; + if count >= MAX_WEBHOOKS_PER_ACCOUNT { + return Err(ApiError::conflict( + "webhook_limit_reached", + format!("An account can have at most {MAX_WEBHOOKS_PER_ACCOUNT} webhooks."), + )); + } + let id = Uuid::now_v7().to_string(); + let secret = generate_secret(); + let ciphertext = secrets.encrypt(&secret, &id).map_err(ApiError::internal)?; + let now = Utc::now(); + let row = sqlx::query_as::<_, WebhookRow>(sqlx::AssertSqlSafe(format!( + r#" + INSERT INTO event_subscriptions ( + id, account_id, target_kind, callback_url, secret_ciphertext, kinds, runtime_ids, + all_runtimes, status, filter, created_by_runtime_id, created_at, updated_at + ) VALUES ($1, $2, 'webhook', $3, $4, $5, $6, $7, 'active', '{{}}'::jsonb, $8, $9, $9) + RETURNING {WEBHOOK_COLUMNS} + "# + ))) + .bind(&id) + .bind(auth.account_id) + .bind(&request.url) + .bind(&ciphertext) + .bind(&kinds) + .bind(&runtime_ids) + .bind(request.all_runtimes) + .bind(&auth.client_id) + .bind(now) + .fetch_one(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(Json(CreatedWebhook { + webhook: row.into(), + secret, + })) +} + +pub async fn delete_webhook( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result { + let auth = authenticate_runtime(&headers, &state).await?; + let deleted = sqlx::query( + "DELETE FROM event_subscriptions WHERE id = $1 AND account_id = $2 AND target_kind = 'webhook'", + ) + .bind(&id) + .bind(auth.account_id) + .execute(&state.pool) + .await? + .rows_affected(); + if deleted == 0 { + return Err(webhook_not_found()); + } + Ok(StatusCode::NO_CONTENT) +} + +/// Queues a signed `alera.test` delivery to the webhook. +pub async fn test_webhook( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, ApiError> { + let auth = authenticate_runtime(&headers, &state).await?; + require_secrets(&state)?; + let status = sqlx::query_scalar::<_, String>( + "SELECT status FROM event_subscriptions WHERE id = $1 AND account_id = $2 AND target_kind = 'webhook'", + ) + .bind(&id) + .bind(auth.account_id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(webhook_not_found)?; + if status != "active" { + return Err(ApiError::conflict( + "webhook_inactive", + format!("The webhook is {status}; create it again to resume deliveries."), + )); + } + let now = Utc::now(); + let event_id = Uuid::now_v7(); + let delivery_id = Uuid::now_v7(); + let mut transaction = state.pool.begin().await?; + sqlx::query( + r#" + INSERT INTO domain_events ( + id, account_id, runtime_id, event_id, seq, kind, data, occurred_at, received_at, + fanned_out_at + ) VALUES ($1, $2, $3, $4, 0, $5, '{}'::jsonb, $6, $6, $6) + "#, + ) + .bind(event_id) + .bind(auth.account_id) + .bind(&auth.client_id) + .bind(event_id.to_string()) + .bind(TEST_EVENT_KIND) + .bind(now) + .execute(&mut *transaction) + .await?; + sqlx::query( + r#" + INSERT INTO event_deliveries ( + id, subscription_id, event_id, status, attempts, next_attempt_at, created_at + ) VALUES ($1, $2, $3, 'pending', 0, $4, $4) + "#, + ) + .bind(delivery_id) + .bind(&id) + .bind(event_id) + .bind(now) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + state.events.wake.notify_one(); + Ok(Json(TestDelivery { delivery_id })) +} + +fn validate_kinds(kinds: &[String]) -> Result, ApiError> { + let mut kinds = kinds.to_vec(); + kinds.sort(); + kinds.dedup(); + if kinds.is_empty() || !kinds.iter().all(|kind| catalog::kind(kind).is_some()) { + return Err(ApiError::bad_request( + "invalid_webhook_kinds", + format!( + "Name one or more event kinds: {}.", + catalog::EVENT_KINDS + .iter() + .map(|kind| kind.name) + .collect::>() + .join(", ") + ), + )); + } + Ok(kinds) +} + +fn webhook_not_found() -> ApiError { + ApiError::not_found("webhook_not_found", "The webhook does not exist.") +} diff --git a/cloud/src/events/wire.rs b/cloud/src/events/wire.rs new file mode 100644 index 000000000..60ad05d58 --- /dev/null +++ b/cloud/src/events/wire.rs @@ -0,0 +1,61 @@ +//! The webhook wire format shared by event deliveries and verification challenges. + +use chrono::{DateTime, SecondsFormat, Utc}; +use serde_json::{Map, Value}; + +use super::secrets::signature_header; + +/// The webhook body: `{ eventId, name, timestamp, data, cursor }`. +pub fn event_body( + event_id: &str, + kind: &str, + occurred_at: DateTime, + envelope: (&str, Option<&str>, Option<&str>, i64), + data: &Value, + cursor: &str, +) -> Value { + let (runtime_id, workspace_id, project_id, seq) = envelope; + let mut payload = Map::new(); + if let Some(object) = data.as_object() { + payload.extend(object.clone()); + } + payload.insert("runtimeId".to_owned(), Value::from(runtime_id)); + if let Some(workspace_id) = workspace_id { + payload.insert("workspaceId".to_owned(), Value::from(workspace_id)); + } + if let Some(project_id) = project_id { + payload.insert("projectId".to_owned(), Value::from(project_id)); + } + payload.insert("seq".to_owned(), Value::from(seq)); + serde_json::json!({ + "eventId": event_id, + "name": kind, + "timestamp": occurred_at.to_rfc3339_opts(SecondsFormat::Millis, true), + "data": payload, + "cursor": cursor, + }) +} + +/// Signed request headers for one attempt; each attempt gets a new timestamp. +pub fn signed_headers( + message_id: &str, + subscription_header: (&str, &str), + keys: &[Vec], + body: &[u8], +) -> Vec<(String, String)> { + let timestamp = Utc::now().timestamp(); + vec![ + ("content-type".to_owned(), "application/json".to_owned()), + ("user-agent".to_owned(), "Alera-Webhooks/1".to_owned()), + ("webhook-id".to_owned(), message_id.to_owned()), + ("webhook-timestamp".to_owned(), timestamp.to_string()), + ( + "webhook-signature".to_owned(), + signature_header(keys, message_id, timestamp, body), + ), + ( + subscription_header.0.to_owned(), + subscription_header.1.to_owned(), + ), + ] +} diff --git a/cloud/src/events/worker.rs b/cloud/src/events/worker.rs new file mode 100644 index 000000000..b3c3e0f60 --- /dev/null +++ b/cloud/src/events/worker.rs @@ -0,0 +1,54 @@ +//! The delivery loop and the internal pump the edge cron can call when Cloud Run +//! throttles CPU between requests. + +use std::time::Duration; + +use axum::{extract::State, http::HeaderMap, Json}; +use tokio::time::Instant; + +use crate::{error::ApiError, state::AppState}; + +use super::delivery::{run_pass, PassSummary}; + +const IDLE_POLL: Duration = Duration::from_secs(2); +const PUMP_BUDGET: Duration = Duration::from_secs(20); + +/// Runs passes until one finds nothing to do, bounded by `budget`. +pub async fn drain(state: &AppState, budget: Duration) -> Result { + let deadline = Instant::now() + budget; + let mut total = PassSummary::default(); + loop { + let pass = run_pass(state).await?; + total.fanned_out += pass.fanned_out; + total.claimed += pass.claimed; + total.delivered += pass.delivered; + if pass.claimed == 0 && pass.fanned_out == 0 || Instant::now() >= deadline { + return Ok(total); + } + } +} + +pub fn spawn(state: AppState) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + loop { + if let Err(error) = drain(&state, Duration::from_secs(60)).await { + tracing::warn!(error = %error, "event delivery pass failed"); + } + tokio::select! { + _ = state.events.wake.notified() => {} + _ = tokio::time::sleep(IDLE_POLL) => {} + } + } + }) +} + +/// `POST /v1/internal/event-deliveries/pump`: one bounded drain. The edge never +/// forwards this path from the internet; its cron calls the origin directly with the +/// origin token, which this handler requires even when direct origin access is allowed. +pub async fn pump( + State(state): State, + headers: HeaderMap, +) -> Result, ApiError> { + crate::api::require_origin_token(&headers, &state)?; + Ok(Json(drain(&state, PUMP_BUDGET).await?)) +} diff --git a/cloud/src/lib.rs b/cloud/src/lib.rs index 057be73a1..2a66ab6ad 100644 --- a/cloud/src/lib.rs +++ b/cloud/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod configuration; pub mod device_auth; pub mod error; +pub mod events; pub mod fcm; pub mod google_credentials; pub mod google_oidc; diff --git a/cloud/src/main.rs b/cloud/src/main.rs index bedca3683..cf462e9b1 100644 --- a/cloud/src/main.rs +++ b/cloud/src/main.rs @@ -1,4 +1,4 @@ -use alera_cloud::{maintenance, migrations, router, AppConfig, AppState}; +use alera_cloud::{events, maintenance, migrations, router, AppConfig, AppState}; use anyhow::Context; use sqlx::postgres::PgPoolOptions; use tracing_subscriber::EnvFilter; @@ -23,6 +23,11 @@ async fn main() -> anyhow::Result<()> { .with_context(|| format!("bind {bind}"))?; let online_migrations_task = migrations::spawn_online(pool.clone()); let maintenance_task = maintenance::spawn(pool.clone()); + let event_worker = state + .config + .events + .worker_enabled + .then(|| events::worker::spawn(state.clone())); tracing::info!(address = %bind, "Alera cloud backend listening"); let serve_result = axum::serve(listener, router(state)) .with_graceful_shutdown(shutdown_signal()) @@ -30,6 +35,9 @@ async fn main() -> anyhow::Result<()> { .context("serve HTTP"); online_migrations_task.abort(); maintenance_task.abort(); + if let Some(worker) = event_worker { + worker.abort(); + } pool.close().await; serve_result?; Ok(()) diff --git a/cloud/src/maintenance.rs b/cloud/src/maintenance.rs index 3f7d33f58..4a5cec35b 100644 --- a/cloud/src/maintenance.rs +++ b/cloud/src/maintenance.rs @@ -65,6 +65,22 @@ pub async fn run_once(pool: &PgPool) -> Result<(), sqlx::Error> { .bind(now - TimeDelta::days(90)) .execute(&mut *transaction) .await?; + // Deliveries go with their events; inactive subscriptions are kept a week for listing. + sqlx::query("DELETE FROM domain_events WHERE received_at < $1") + .bind(now - TimeDelta::hours(24)) + .execute(&mut *transaction) + .await?; + sqlx::query( + r#" + DELETE FROM event_subscriptions + WHERE (target_kind = 'mcp_events' AND (status <> 'active' OR refresh_before < $1)) + AND updated_at < $2 + "#, + ) + .bind(now) + .bind(now - TimeDelta::days(7)) + .execute(&mut *transaction) + .await?; sqlx::query("DELETE FROM runtime_events WHERE created_at < $1") .bind(now - TimeDelta::days(30)) .execute(&mut *transaction) diff --git a/cloud/src/mcp_gateway.rs b/cloud/src/mcp_gateway.rs index 15d17728a..98a77a7f9 100644 --- a/cloud/src/mcp_gateway.rs +++ b/cloud/src/mcp_gateway.rs @@ -12,7 +12,7 @@ use crate::{ error::ApiError, mcp_models::{ CreateMcpCallRequest, CreateMcpCallResponse, McpAccess, McpCallOutcomeRequest, - McpRuntimeList, McpRuntimeSummary, ToolAccess, SCOPE_EXECUTE, SCOPE_READ, + McpRuntimeList, McpRuntimeSummary, ToolAccess, SCOPE_ADMIN, SCOPE_READ, }, relay::ACTIVE_RUNTIME_SECONDS, state::AppState, @@ -45,7 +45,7 @@ impl RuntimeCandidate { /// Runtimes this grant reaches: owned by the account, not transferred, and either named /// by the grant or covered by an all-runtimes grant. -async fn reachable_runtimes( +pub(crate) async fn reachable_runtimes( state: &AppState, auth: &McpAuthContext, ) -> Result, ApiError> { @@ -189,21 +189,42 @@ fn validate_tool(tool: &str) -> Result<(), ApiError> { } } +/// The grant must hold the scope of the tool's class. Read is checked for every call. +fn check_tool_scope(auth: &McpAuthContext, access: ToolAccess) -> Result<(), ApiError> { + match access { + ToolAccess::Read => Ok(()), + ToolAccess::Execute => auth.require_scope(access.scope()), + ToolAccess::Admin if auth.has_scope(SCOPE_ADMIN) => Ok(()), + ToolAccess::Admin => Err(ApiError::forbidden( + "insufficient_scope", + "This connection was not granted administrative tools (mcp:admin). Reconnect Alera and allow administrative tools.", + )), + } +} + fn check_runtime_access(runtime: &RuntimeCandidate, access: ToolAccess) -> Result<(), ApiError> { - match (runtime.access(), access) { - (McpAccess::Off, _) => Err(ApiError::conflict( + let level = runtime.access(); + if level == McpAccess::Off { + return Err(ApiError::conflict( "runtime_mcp_disabled", format!("MCP Control is off on runtime {}.", runtime.name), - )), - (McpAccess::Read, ToolAccess::Execute) => { - let message = format!( - "Runtime {} allows only read tools. Turn on full MCP Control to run this tool.", - runtime.name - ); - Err(ApiError::forbidden("runtime_read_only", message)) - } - _ => Ok(()), + )); + } + if level >= access.minimum_runtime_access() { + return Ok(()); } + if access == ToolAccess::Admin { + let message = format!( + "MCP Control on this runtime does not allow administrative tools. Set MCP Control on {} to Admin to run this tool.", + runtime.name + ); + return Err(ApiError::forbidden("runtime_not_admin", message)); + } + let message = format!( + "Runtime {} allows only read tools. Turn on full MCP Control to run this tool.", + runtime.name + ); + Err(ApiError::forbidden("runtime_read_only", message)) } pub async fn create_call( @@ -214,9 +235,7 @@ pub async fn create_call( let auth = authenticate_mcp(&headers, &state).await?; auth.require_scope(SCOPE_READ)?; validate_tool(&request.tool)?; - if request.access == ToolAccess::Execute { - auth.require_scope(SCOPE_EXECUTE)?; - } + check_tool_scope(&auth, request.access)?; let now = Utc::now(); let runtimes = reachable_runtimes(&state, &auth).await?; let runtime = resolve_runtime(&runtimes, request.runtime.as_deref(), now)?; @@ -317,77 +336,5 @@ pub async fn complete_call( } #[cfg(test)] -mod tests { - use chrono::{Duration, Utc}; - - use super::{resolve_runtime, RuntimeCandidate}; - - fn runtime(id: &str, name: &str, access: &str, online: bool) -> RuntimeCandidate { - let now = Utc::now(); - RuntimeCandidate { - id: id.to_owned(), - name: name.to_owned(), - mcp_access: access.to_owned(), - last_seen_at: now, - relay_granted_at: Some(if online { - now - } else { - now - Duration::hours(1) - }), - } - } - - fn code(result: Result<&RuntimeCandidate, crate::error::ApiError>) -> String { - match result { - Ok(runtime) => runtime.id.clone(), - Err(crate::error::ApiError::Request { code, .. }) => code.to_owned(), - Err(other) => other.to_string(), - } - } - - #[test] - fn resolves_by_id_then_case_insensitive_name() { - let runtimes = vec![ - runtime("r1", "Laptop", "full", true), - runtime("r2", "laptop", "read", false), - runtime("r3", "Server", "full", true), - ]; - let now = Utc::now(); - assert_eq!(code(resolve_runtime(&runtimes, Some("r2"), now)), "r2"); - assert_eq!(code(resolve_runtime(&runtimes, Some("SERVER"), now)), "r3"); - assert_eq!( - code(resolve_runtime(&runtimes, Some("LAPTOP"), now)), - "runtime_ambiguous" - ); - assert_eq!( - code(resolve_runtime(&runtimes, Some("missing"), now)), - "runtime_not_found" - ); - assert_eq!( - code(resolve_runtime(&runtimes, None, now)), - "runtime_required" - ); - } - - #[test] - fn omitted_runtime_needs_exactly_one_connected() { - let now = Utc::now(); - let one = vec![ - runtime("r1", "Laptop", "full", true), - runtime("r2", "Server", "off", true), - runtime("r3", "Old", "full", false), - ]; - assert_eq!(code(resolve_runtime(&one, None, now)), "r1"); - let none = vec![runtime("r3", "Old", "full", false)]; - let error = resolve_runtime(&none, None, now); - assert!(matches!( - error, - Err(crate::error::ApiError::Request { ref message, .. }) if message.contains("Old") - )); - assert_eq!(code(error), "no_runtime_available"); - assert_eq!( - code(resolve_runtime(&[], None, now)), - "no_runtime_available" - ); - } -} +#[path = "mcp_gateway_tests.rs"] +mod tests; diff --git a/cloud/src/mcp_gateway_tests.rs b/cloud/src/mcp_gateway_tests.rs new file mode 100644 index 000000000..cc93b4cd6 --- /dev/null +++ b/cloud/src/mcp_gateway_tests.rs @@ -0,0 +1,152 @@ +use chrono::{Duration, Utc}; +use uuid::Uuid; + +use super::{check_runtime_access, check_tool_scope, resolve_runtime, RuntimeCandidate}; +use crate::{auth::McpAuthContext, mcp_models::ToolAccess}; + +fn runtime(id: &str, name: &str, access: &str, online: bool) -> RuntimeCandidate { + let now = Utc::now(); + RuntimeCandidate { + id: id.to_owned(), + name: name.to_owned(), + mcp_access: access.to_owned(), + last_seen_at: now, + relay_granted_at: Some(if online { + now + } else { + now - Duration::hours(1) + }), + } +} + +fn error_code(result: Result) -> String { + match result { + Ok(_) => "ok".to_owned(), + Err(crate::error::ApiError::Request { code, .. }) => code.to_owned(), + Err(other) => other.to_string(), + } +} + +fn grant(scopes: &[&str]) -> McpAuthContext { + McpAuthContext { + account_id: Uuid::nil(), + family_id: Uuid::nil(), + grant_id: Uuid::nil(), + client_id: "client".to_owned(), + client_name: "Client".to_owned(), + all_runtimes: true, + scopes: scopes.iter().map(|scope| (*scope).to_owned()).collect(), + } +} + +#[test] +fn tool_classes_need_their_scope() { + let full = grant(&["mcp:read", "mcp:execute"]); + let admin = grant(&["mcp:read", "mcp:execute", "mcp:admin"]); + let read = grant(&["mcp:read"]); + assert_eq!(error_code(check_tool_scope(&read, ToolAccess::Read)), "ok"); + assert_eq!( + error_code(check_tool_scope(&read, ToolAccess::Execute)), + "insufficient_scope" + ); + assert_eq!( + error_code(check_tool_scope(&full, ToolAccess::Execute)), + "ok" + ); + let refused = check_tool_scope(&full, ToolAccess::Admin); + assert!(matches!( + refused, + Err(crate::error::ApiError::Request { ref message, .. }) + if message.contains("mcp:admin") && message.contains("allow administrative tools") + )); + assert_eq!(error_code(refused), "insufficient_scope"); + assert_eq!( + error_code(check_tool_scope(&admin, ToolAccess::Admin)), + "ok" + ); +} + +#[test] +fn runtime_levels_gate_tool_classes() { + let cases = [ + ("off", ToolAccess::Read, "runtime_mcp_disabled"), + ("read", ToolAccess::Read, "ok"), + ("read", ToolAccess::Execute, "runtime_read_only"), + ("read", ToolAccess::Admin, "runtime_not_admin"), + ("full", ToolAccess::Execute, "ok"), + ("full", ToolAccess::Admin, "runtime_not_admin"), + ("admin", ToolAccess::Read, "ok"), + ("admin", ToolAccess::Execute, "ok"), + ("admin", ToolAccess::Admin, "ok"), + ("unknown", ToolAccess::Read, "runtime_mcp_disabled"), + ]; + for (level, access, expected) in cases { + let candidate = runtime("r1", "Laptop", level, true); + assert_eq!( + error_code(check_runtime_access(&candidate, access)), + expected, + "{level} {access:?}" + ); + } + let refused = check_runtime_access(&runtime("r1", "Laptop", "full", true), ToolAccess::Admin); + assert!(matches!( + refused, + Err(crate::error::ApiError::Request { status, ref message, .. }) + if status == axum::http::StatusCode::FORBIDDEN + && message.starts_with("MCP Control on this runtime does not allow administrative tools.") + )); +} + +fn code(result: Result<&RuntimeCandidate, crate::error::ApiError>) -> String { + match result { + Ok(runtime) => runtime.id.clone(), + Err(crate::error::ApiError::Request { code, .. }) => code.to_owned(), + Err(other) => other.to_string(), + } +} + +#[test] +fn resolves_by_id_then_case_insensitive_name() { + let runtimes = vec![ + runtime("r1", "Laptop", "full", true), + runtime("r2", "laptop", "read", false), + runtime("r3", "Server", "full", true), + ]; + let now = Utc::now(); + assert_eq!(code(resolve_runtime(&runtimes, Some("r2"), now)), "r2"); + assert_eq!(code(resolve_runtime(&runtimes, Some("SERVER"), now)), "r3"); + assert_eq!( + code(resolve_runtime(&runtimes, Some("LAPTOP"), now)), + "runtime_ambiguous" + ); + assert_eq!( + code(resolve_runtime(&runtimes, Some("missing"), now)), + "runtime_not_found" + ); + assert_eq!( + code(resolve_runtime(&runtimes, None, now)), + "runtime_required" + ); +} + +#[test] +fn omitted_runtime_needs_exactly_one_connected() { + let now = Utc::now(); + let one = vec![ + runtime("r1", "Laptop", "full", true), + runtime("r2", "Server", "off", true), + runtime("r3", "Old", "full", false), + ]; + assert_eq!(code(resolve_runtime(&one, None, now)), "r1"); + let none = vec![runtime("r3", "Old", "full", false)]; + let error = resolve_runtime(&none, None, now); + assert!(matches!( + error, + Err(crate::error::ApiError::Request { ref message, .. }) if message.contains("Old") + )); + assert_eq!(code(error), "no_runtime_available"); + assert_eq!( + code(resolve_runtime(&[], None, now)), + "no_runtime_available" + ); +} diff --git a/cloud/src/mcp_models.rs b/cloud/src/mcp_models.rs index 7d7e751c9..80bd4c816 100644 --- a/cloud/src/mcp_models.rs +++ b/cloud/src/mcp_models.rs @@ -8,14 +8,22 @@ use crate::{api_models::ClientKind, error::ApiError}; pub const SCOPE_READ: &str = "mcp:read"; pub const SCOPE_EXECUTE: &str = "mcp:execute"; +/// Administrative tools. Never part of a default scope: a grant holds it only when the +/// client asked for it and the person ticked the administrative tools box on consent. +pub const SCOPE_ADMIN: &str = "mcp:admin"; +/// Every MCP scope, in the order discovery documents advertise them. +pub const SCOPES_SUPPORTED: [&str; 3] = [SCOPE_READ, SCOPE_EXECUTE, SCOPE_ADMIN]; -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +/// The MCP Control level a runtime reports. Levels are ordered: each one allows +/// everything the previous one does. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[serde(rename_all = "lowercase")] pub enum McpAccess { #[default] Off, Read, Full, + Admin, } impl McpAccess { @@ -24,6 +32,7 @@ impl McpAccess { Self::Off => "off", Self::Read => "read", Self::Full => "full", + Self::Admin => "admin", } } } @@ -36,6 +45,7 @@ impl FromStr for McpAccess { "off" => Ok(Self::Off), "read" => Ok(Self::Read), "full" => Ok(Self::Full), + "admin" => Ok(Self::Admin), _ => Err(ApiError::bad_request( "invalid_mcp_access", "The MCP access level is invalid.", @@ -49,6 +59,7 @@ impl FromStr for McpAccess { pub enum ToolAccess { Read, Execute, + Admin, } impl ToolAccess { @@ -56,6 +67,25 @@ impl ToolAccess { match self { Self::Read => "read", Self::Execute => "execute", + Self::Admin => "admin", + } + } + + /// The OAuth scope a grant needs to call a tool of this class. + pub fn scope(self) -> &'static str { + match self { + Self::Read => SCOPE_READ, + Self::Execute => SCOPE_EXECUTE, + Self::Admin => SCOPE_ADMIN, + } + } + + /// The lowest runtime MCP Control level that runs a tool of this class. + pub fn minimum_runtime_access(self) -> McpAccess { + match self { + Self::Read => McpAccess::Read, + Self::Execute => McpAccess::Full, + Self::Admin => McpAccess::Admin, } } } @@ -193,3 +223,41 @@ pub struct DeviceTokenRequest { pub fn scope_list(scopes: &str) -> Vec { scopes.split_whitespace().map(ToOwned::to_owned).collect() } + +#[cfg(test)] +mod tests { + use super::{McpAccess, ToolAccess, SCOPES_SUPPORTED}; + + #[test] + fn access_levels_are_ordered_and_parse_admin() { + assert!(McpAccess::Off < McpAccess::Read); + assert!(McpAccess::Read < McpAccess::Full); + assert!(McpAccess::Full < McpAccess::Admin); + for level in [ + McpAccess::Off, + McpAccess::Read, + McpAccess::Full, + McpAccess::Admin, + ] { + assert_eq!(level.as_str().parse::().ok(), Some(level)); + } + assert!("owner".parse::().is_err()); + let reported: McpAccess = serde_json::from_str("\"admin\"").unwrap_or_default(); + assert_eq!(reported, McpAccess::Admin); + let tool: Option = serde_json::from_str("\"admin\"").ok(); + assert_eq!(tool, Some(ToolAccess::Admin)); + } + + #[test] + fn tool_classes_map_to_scope_and_runtime_level() { + assert_eq!(ToolAccess::Read.scope(), "mcp:read"); + assert_eq!(ToolAccess::Execute.scope(), "mcp:execute"); + assert_eq!(ToolAccess::Admin.scope(), "mcp:admin"); + assert_eq!( + ToolAccess::Execute.minimum_runtime_access(), + McpAccess::Full + ); + assert_eq!(ToolAccess::Admin.minimum_runtime_access(), McpAccess::Admin); + assert_eq!(SCOPES_SUPPORTED, ["mcp:read", "mcp:execute", "mcp:admin"]); + } +} diff --git a/cloud/src/mcp_oauth/authorize.rs b/cloud/src/mcp_oauth/authorize.rs index 186158f7d..e79f83b90 100644 --- a/cloud/src/mcp_oauth/authorize.rs +++ b/cloud/src/mcp_oauth/authorize.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::{ auth::validation::validate_code_challenge, - mcp_models::{SCOPE_EXECUTE, SCOPE_READ}, + mcp_models::{SCOPE_ADMIN, SCOPE_EXECUTE, SCOPE_READ}, state::AppState, }; @@ -50,17 +50,24 @@ impl ClientRedirect<'_> { } /// Normalizes the requested scope. Unknown scopes are ignored, `mcp:read` is always -/// included, and an absent or empty scope requests both MCP scopes. +/// included, and an absent or empty scope requests `mcp:read` and `mcp:execute`. +/// `mcp:admin` is kept only when the client names it, and then also implies +/// `mcp:execute`; it is never added on its own, and consent still has to grant it. pub fn requested_scopes(scope: Option<&str>) -> Vec<&'static str> { let requested: Vec<&str> = scope.unwrap_or_default().split_whitespace().collect(); - let known = requested - .iter() - .any(|value| *value == SCOPE_READ || *value == SCOPE_EXECUTE); - if !known || requested.contains(&SCOPE_EXECUTE) { - vec![SCOPE_READ, SCOPE_EXECUTE] - } else { - vec![SCOPE_READ] + let admin = requested.contains(&SCOPE_ADMIN); + let known = admin + || requested + .iter() + .any(|value| *value == SCOPE_READ || *value == SCOPE_EXECUTE); + let mut scopes = vec![SCOPE_READ]; + if !known || admin || requested.contains(&SCOPE_EXECUTE) { + scopes.push(SCOPE_EXECUTE); } + if admin { + scopes.push(SCOPE_ADMIN); + } + scopes } pub async fn authorize(State(state): State, RawQuery(query): RawQuery) -> Response { @@ -199,6 +206,32 @@ mod tests { ); } + #[test] + fn admin_is_kept_only_when_requested() { + for scope in [ + None, + Some(""), + Some("mcp:read"), + Some("mcp:execute"), + Some("mcp:read mcp:execute"), + Some("admin mcp:admins openid"), + ] { + assert!(!requested_scopes(scope).contains(&"mcp:admin"), "{scope:?}"); + } + assert_eq!( + requested_scopes(Some("mcp:admin")), + vec!["mcp:read", "mcp:execute", "mcp:admin"] + ); + assert_eq!( + requested_scopes(Some("mcp:read mcp:admin")), + vec!["mcp:read", "mcp:execute", "mcp:admin"] + ); + assert_eq!( + requested_scopes(Some("mcp:read mcp:execute mcp:admin")), + vec!["mcp:read", "mcp:execute", "mcp:admin"] + ); + } + #[test] fn redirects_carry_state_and_issuer() { let redirect = ClientRedirect { diff --git a/cloud/src/mcp_oauth/clients.rs b/cloud/src/mcp_oauth/clients.rs index fc69e2b85..8131b461b 100644 --- a/cloud/src/mcp_oauth/clients.rs +++ b/cloud/src/mcp_oauth/clients.rs @@ -11,7 +11,7 @@ use serde_json::json; use sqlx::FromRow; use url::Url; -use crate::{auth::validation::random_secret, state::AppState}; +use crate::{auth::validation::random_secret, mcp_models::SCOPES_SUPPORTED, state::AppState}; use super::{ client_authentication::{self, ClientAuthentication}, @@ -241,7 +241,7 @@ async fn register_client(state: &AppState, body: &[u8]) -> Result "MCP Control off", McpAccess::Read => "MCP read only", McpAccess::Full => "MCP full access", + McpAccess::Admin => "MCP admin access", }; runtime_rows.push_str(&format!( "", @@ -134,15 +135,7 @@ async fn render( if runtimes.is_empty() { runtime_rows.push_str("

No runtimes are signed in to this account yet. Choose all runtimes to include the ones you add later.

"); } - let execute = if view - .scope - .split_whitespace() - .any(|scope| scope == SCOPE_EXECUTE) - { - "
Permissions
" - } else { - "" - }; + let permissions = permission_fields(view.scope); let error = error .map(|message| format!("

{}

", escape(message))) .unwrap_or_default(); @@ -152,7 +145,7 @@ async fn render(
{request}{token}\
Runtimes\ \ -{runtime_rows}
{execute}\ +{runtime_rows}{permissions}\

MCP Control must also be on in each runtime. You will return to {host}.

\
\
", @@ -248,6 +241,7 @@ async fn submit_consent(state: &AppState, form: &FormFields) -> Result Result scopes, + (Err(message), _) | (Ok(_), Some(message)) => { + transaction.rollback().await?; + let account = load_account_summary(&state.pool, account_id).await?; + let view = ConsentView { + request_id, + consent_token, + client_id: &request.client_id, + client_name: &request.client_name, + redirect_uri: &request.redirect_uri, + scope: &request.scope, + account: &account, + }; + return render(state, &view, Some(message)).await; + } }; let runtime_ids = if all_runtimes { Vec::new() @@ -313,6 +300,39 @@ async fn submit_consent(state: &AppState, form: &FormFields) -> Result String { + let requested = |wanted: &str| scope.split_whitespace().any(|value| value == wanted); + if !requested(SCOPE_EXECUTE) { + return String::new(); + } + let admin = if requested(SCOPE_ADMIN) { + "" + } else { + "" + }; + format!( + "
Permissions{admin}
" + ) +} + +/// The scopes a consent grants: only what the client requested and the person ticked. +/// `mcp:admin` never comes from a default, and it builds on `mcp:execute`. +fn consent_scopes(requested: &str, execute: bool, admin: bool) -> Result { + let requested = |wanted: &str| requested.split_whitespace().any(|value| value == wanted); + let execute = execute && requested(SCOPE_EXECUTE); + let admin = admin && requested(SCOPE_ADMIN); + match (execute, admin) { + (false, true) => Err( + "Administrative tools also need permission to run commands. Allow both, or clear administrative tools.", + ), + (true, true) => Ok(format!("{SCOPE_READ} {SCOPE_EXECUTE} {SCOPE_ADMIN}")), + (true, false) => Ok(format!("{SCOPE_READ} {SCOPE_EXECUTE}")), + (false, false) => Ok(SCOPE_READ.to_owned()), + } +} + async fn mark_completed( transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, request_id: Uuid, @@ -352,3 +372,39 @@ fn client_origin_notice(client_id: &str, redirect_uri: &str) -> String { ), } } + +#[cfg(test)] +mod tests { + use super::{consent_scopes, permission_fields}; + + #[test] + fn admin_needs_the_request_and_an_explicit_tick() { + let full = "mcp:read mcp:execute"; + let admin = "mcp:read mcp:execute mcp:admin"; + assert_eq!(consent_scopes(full, true, false).as_deref(), Ok(full)); + assert_eq!(consent_scopes(full, true, true).as_deref(), Ok(full)); + assert_eq!(consent_scopes(admin, true, false).as_deref(), Ok(full)); + assert_eq!(consent_scopes(admin, true, true).as_deref(), Ok(admin)); + assert_eq!( + consent_scopes(admin, false, false).as_deref(), + Ok("mcp:read") + ); + assert!(consent_scopes(admin, false, true).is_err()); + assert_eq!( + consent_scopes("mcp:read", true, true).as_deref(), + Ok("mcp:read") + ); + } + + #[test] + fn the_admin_box_is_offered_unchecked_only_when_requested() { + assert!(permission_fields("mcp:read").is_empty()); + let full = permission_fields("mcp:read mcp:execute"); + assert!(full.contains("name=\"execute\" value=\"1\" checked")); + assert!(!full.contains("name=\"admin\"")); + let admin = permission_fields("mcp:read mcp:execute mcp:admin"); + assert!(admin.contains("")); + assert!(!admin.contains("name=\"admin\" value=\"1\" checked")); + assert!(admin.contains("Allow administrative tools")); + } +} diff --git a/cloud/src/mcp_oauth/metadata.rs b/cloud/src/mcp_oauth/metadata.rs index 110213dc2..1facb9d0c 100644 --- a/cloud/src/mcp_oauth/metadata.rs +++ b/cloud/src/mcp_oauth/metadata.rs @@ -1,10 +1,7 @@ use axum::{extract::State, response::Response, Json}; use serde_json::{json, Value}; -use crate::{ - mcp_models::{SCOPE_EXECUTE, SCOPE_READ}, - state::AppState, -}; +use crate::{mcp_models::SCOPES_SUPPORTED, state::AppState}; use super::{ client_authentication::{ @@ -45,7 +42,7 @@ pub fn authorization_server_document(state: &AppState) -> Value { "token_endpoint_auth_signing_alg_values_supported": signing_algs, "revocation_endpoint_auth_methods_supported": ["none"], "code_challenge_methods_supported": ["S256"], - "scopes_supported": [SCOPE_READ, SCOPE_EXECUTE], + "scopes_supported": SCOPES_SUPPORTED, "client_id_metadata_document_supported": true, "authorization_response_iss_parameter_supported": true, }) @@ -55,7 +52,7 @@ pub fn protected_resource_document(state: &AppState) -> Value { json!({ "resource": state.config.mcp.resource, "authorization_servers": [state.config.issuer], - "scopes_supported": [SCOPE_READ, SCOPE_EXECUTE], + "scopes_supported": SCOPES_SUPPORTED, "bearer_methods_supported": ["header"], "resource_name": "Alera", }) diff --git a/cloud/src/migrations.rs b/cloud/src/migrations.rs index 9c4cbe7aa..95534c664 100644 --- a/cloud/src/migrations.rs +++ b/cloud/src/migrations.rs @@ -10,8 +10,8 @@ const MIGRATION_LOCK_KEY: i64 = 0x41_6c_65_72_61_53_78; const REQUIRED_MIGRATION_DEADLINE: Duration = Duration::from_secs(30); const MIGRATION_LOCK_DEADLINE: Duration = Duration::from_secs(30); const ONLINE_MIGRATION_STEP_DEADLINE: Duration = Duration::from_secs(15 * 60); -const LATEST_MIGRATION_VERSION: i64 = 23; -const REQUIRED_SCHEMA_MIGRATION_VERSIONS: &[i64] = &[1, 2, 3, 4, 21, 22, 23]; +const LATEST_MIGRATION_VERSION: i64 = 25; +const REQUIRED_SCHEMA_MIGRATION_VERSIONS: &[i64] = &[1, 2, 3, 4, 21, 22, 23, 24, 25]; const ONLINE_MIGRATION_VERSIONS: &[i64] = &[5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; const ONLINE_INDEXES: &[(&str, &str)] = &[ diff --git a/cloud/src/state.rs b/cloud/src/state.rs index 7d436e3c2..bd6168573 100644 --- a/cloud/src/state.rs +++ b/cloud/src/state.rs @@ -5,6 +5,10 @@ use sqlx::PgPool; use crate::{ api_models::ProviderKind, config::{AppConfig, FcmConfig, SigningConfig}, + events::{ + callback::{CallbackClient, CallbackResolver}, + EventsContext, + }, fcm::{DisabledFcmSender, FcmSender, HttpFcmSender}, google_credentials::MetadataAccessTokenProvider, mcp_oauth::{ @@ -28,6 +32,7 @@ pub struct AppState { pub fcm: Arc, pub client_metadata: Arc, pub client_jwks: Arc, + pub events: EventsContext, } impl AppState { @@ -40,6 +45,7 @@ impl AppState { ) -> Self { let tokens = TokenService::new(signer, config.issuer.clone(), config.audience.clone()) .with_mcp_resource(config.mcp.resource.clone()); + let events = EventsContext::from_config(&config.events); Self { pool, config: Arc::new(config), @@ -49,9 +55,21 @@ impl AppState { fcm, client_metadata: Arc::new(HttpClientMetadataFetcher::default()), client_jwks: Arc::new(ClientJwksCache::default()), + events, } } + /// Replaces how callback hosts resolve, so tests never depend on real DNS. + pub fn with_callback_resolver(mut self, resolver: Arc) -> Self { + self.events.callbacks = self.events.callbacks.clone().with_resolver(resolver); + self + } + + pub fn with_callback_client(mut self, client: CallbackClient) -> Self { + self.events.callbacks = client; + self + } + pub fn with_web_oauth(mut self, web_oauth: OAuthProviderRegistry) -> Self { self.web_oauth = web_oauth; self diff --git a/cloud/tests/api_contract.rs b/cloud/tests/api_contract.rs index a1fcb107d..8b386784b 100644 --- a/cloud/tests/api_contract.rs +++ b/cloud/tests/api_contract.rs @@ -27,15 +27,36 @@ mod mcp_contract; #[path = "contracts/mcp_gateway_contract.rs"] mod mcp_gateway_contract; +#[path = "contracts/mcp_admin_contract.rs"] +mod mcp_admin_contract; + #[path = "contracts/mcp_client_auth_contract.rs"] mod mcp_client_auth_contract; #[path = "contracts/device_contract.rs"] mod device_contract; +#[path = "support/webhook_receiver.rs"] +mod webhook_receiver; + +#[path = "contracts/webhook_contract.rs"] +mod webhook_contract; + +#[path = "contracts/mcp_events_contract.rs"] +mod mcp_events_contract; + +#[path = "contracts/events_access_contract.rs"] +mod events_access_contract; + +#[path = "contracts/events_resume_contract.rs"] +mod events_resume_contract; + use alera_cloud::{ api_models::{ProviderKind, ProviderKind::Github}, - config::{FcmConfig, LimitsConfig, McpConfig, OAuthProviderConfig, SigningConfig}, + config::{ + CallbackPolicy, EventsConfig, FcmConfig, LimitsConfig, McpConfig, OAuthProviderConfig, + SecretKey, SigningConfig, + }, fcm::{FcmMessage, FcmReceipt, FcmSender}, migrations, oauth::{ @@ -246,6 +267,17 @@ fn test_config(database_url: String) -> anyhow::Result { push_burst: 10, }, mcp, + events: EventsConfig { + secret_key: Some(SecretKey([29_u8; 32])), + previous_secret_key: None, + mcp_events_enabled: true, + worker_enabled: false, + // Contract tests deliver to a loopback HTTP receiver. + callbacks: CallbackPolicy { + allow_any_port: false, + allow_private_targets: true, + }, + }, }) } diff --git a/cloud/tests/callback_proxy.rs b/cloud/tests/callback_proxy.rs new file mode 100644 index 000000000..0fb02f676 --- /dev/null +++ b/cloud/tests/callback_proxy.rs @@ -0,0 +1,105 @@ +//! Callback egress ignores proxy environment variables. A proxy would resolve the callback +//! host itself and bypass the pinned, checked address. This binary holds a single test +//! because it changes process-wide proxy variables. + +use std::{ + io::{Read, Write}, + net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + sync::Arc, + thread, +}; + +use alera_cloud::{ + config::CallbackPolicy, + events::callback::{CallbackClient, CallbackResolver}, +}; +use async_trait::async_trait; + +/// Answers every lookup with loopback, as a DNS answer for a public-looking name would. +struct Loopback; + +#[async_trait] +impl CallbackResolver for Loopback { + async fn resolve(&self, _host: &str, port: u16) -> std::io::Result> { + Ok(vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)]) + } +} + +/// Accepts one request and answers `204 No Content`. +fn serve_once(listener: TcpListener) -> thread::JoinHandle> { + thread::spawn(move || { + let (mut stream, _) = listener.accept()?; + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let read = stream.read(&mut chunk)?; + request.extend_from_slice(&chunk[..read]); + let text = String::from_utf8_lossy(&request).to_string(); + if let Some(end) = text.find("\r\n\r\n") { + let length = text[..end] + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + if read == 0 || request.len() >= end + 4 + length { + break; + } + } else if read == 0 { + break; + } + } + stream.write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + )?; + Ok(String::from_utf8_lossy(&request).to_string()) + }) +} + +#[tokio::test] +async fn callbacks_connect_directly_even_when_a_proxy_is_configured() -> anyhow::Result<()> { + // A proxy address that refuses connections: a client that honoured it would fail. + let dead_proxy = TcpListener::bind("127.0.0.1:0")?.local_addr()?; + let proxy = format!("http://{dead_proxy}"); + for name in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] { + std::env::set_var(name, &proxy); + } + for name in ["NO_PROXY", "no_proxy", "REQUEST_METHOD"] { + std::env::remove_var(name); + } + + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + let server = serve_once(listener); + let client = CallbackClient::new(CallbackPolicy { + allow_any_port: false, + allow_private_targets: true, + }) + .with_resolver(Arc::new(Loopback)); + let reply = client + .post( + &format!("http://hooks.example.test:{port}/hook"), + &[], + b"{}".to_vec(), + ) + .await + .map_err(|error| anyhow::anyhow!("callback failed: {}", error.reason()))?; + assert_eq!(reply.status, 204); + let request = server + .join() + .map_err(|_| anyhow::anyhow!("server thread panicked"))??; + assert!( + request.starts_with("POST /hook HTTP/1.1"), + "the pinned server got an origin-form request, not a proxy request: {request}" + ); + Ok(()) +} diff --git a/cloud/tests/contracts/events_access_contract.rs b/cloud/tests/contracts/events_access_contract.rs new file mode 100644 index 000000000..905933e60 --- /dev/null +++ b/cloud/tests/contracts/events_access_contract.rs @@ -0,0 +1,379 @@ +use alera_cloud::events::{delivery::run_pass, fanout::fan_out_pending}; + +use super::mcp_contract::delete_account; +use super::mcp_http::*; +use super::webhook_contract::{deliver_all, event, post_events}; +use super::webhook_receiver::Receiver; +use super::*; + +fn mcp_subscription(url: &str, arguments: Value) -> Value { + json!({ + "name": "agent.status", + "arguments": arguments, + "delivery": { + "mode": "webhook", + "url": url, + "secret": format!("whsec_{}", base64::engine::general_purpose::STANDARD.encode([7_u8; 32])), + }, + }) +} + +async fn active_count(app: &Router, token: &str) -> anyhow::Result { + Ok(get(app, "/v1/runtime/event-subscriptions", Some(token)) + .await? + .json()["activeSubscriptions"] + .clone()) +} + +fn paths(receiver: &Receiver) -> Vec { + receiver + .events() + .into_iter() + .map(|item| item.path) + .collect() +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn mcp_events_follow_the_runtime_mcp_control_but_webhooks_do_not() -> anyhow::Result<()> { + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(6) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let email = format!("{}@example.test", Uuid::now_v7()); + let state = test_state( + pool.clone(), + url.clone(), + email, + true, + Arc::new(AtomicUsize::new(0)), + )?; + let app = router(state.clone()); + let runtime = format!("runtime-{}", Uuid::now_v7()); + let session = sign_in(&app, "google", &runtime).await?; + let token = session["accessToken"] + .as_str() + .unwrap_or_default() + .to_owned(); + report_runtime(&app, &token, &runtime, "full", true).await?; + let client_id = register_client(&app, "Events Access Client").await?; + let (access, _, _) = authorize_runtimes(&app, &client_id, &[&runtime], false).await?; + let receiver = Receiver::start().await?; + let path = "/v1/mcp/event-subscriptions"; + + // One webhook and one all-runtimes MCP Events subscription for the same kind. + let hook = post_json( + &app, + "/v1/webhooks", + Some(&token), + json!({"url": receiver.url("/hook"), "kinds": ["agent.status"]}), + ) + .await?; + assert_eq!(hook.status, StatusCode::OK, "{}", hook.text()); + let subscribed = post_json( + &app, + path, + Some(&access), + mcp_subscription(&receiver.url("/echo"), json!({})), + ) + .await?; + assert_eq!(subscribed.status, StatusCode::OK, "{}", subscribed.text()); + let subscription_id = subscribed.json()["id"] + .as_str() + .unwrap_or_default() + .to_owned(); + assert_eq!(active_count(&app, &token).await?, 2); + post_events( + &app, + &token, + &runtime, + vec![event("agent.status", json!({}))], + ) + .await?; + deliver_all(&state).await?; + let mut delivered = paths(&receiver); + delivered.sort(); + assert_eq!(delivered, ["/echo", "/hook"]); + + // Off: fan-out leaves MCP Events out, and a delivery queued before the switch (inserted + // directly, so a concurrent worker cannot send it first) is dropped on the recheck. + // The subscription stays active for when MCP Control comes back on. + report_runtime(&app, &token, &runtime, "off", true).await?; + let queued = event("agent.status", json!({"state": "waiting"})); + post_events(&app, &token, &runtime, vec![queued.clone()]).await?; + fan_out_pending(&state).await?; + sqlx::query( + r#" + INSERT INTO event_deliveries ( + id, subscription_id, event_id, status, attempts, next_attempt_at, created_at + ) + SELECT gen_random_uuid(), $2, e.id, 'pending', 0, now(), now() + FROM domain_events e WHERE e.event_id = $1 + "#, + ) + .bind(queued["eventId"].as_str().unwrap_or_default()) + .bind(&subscription_id) + .execute(&pool) + .await?; + deliver_all(&state).await?; + assert_eq!(paths(&receiver).len(), 3, "the webhook still receives"); + assert_eq!( + paths(&receiver) + .iter() + .filter(|path| *path == "/echo") + .count(), + 1, + "MCP Events receives nothing while MCP Control is off" + ); + let dropped: (String, Option) = sqlx::query_as( + r#" + SELECT d.status, d.last_error + FROM event_deliveries d JOIN domain_events e ON e.id = d.event_id + WHERE e.event_id = $1 AND d.subscription_id = $2 + "#, + ) + .bind(queued["eventId"].as_str().unwrap_or_default()) + .bind(&subscription_id) + .fetch_one(&pool) + .await?; + assert_eq!( + (dropped.0.as_str(), dropped.1.as_deref()), + ("stopped", Some("runtime_mcp_disabled")) + ); + let status: String = sqlx::query_scalar("SELECT status FROM event_subscriptions WHERE id = $1") + .bind(&subscription_id) + .fetch_one(&pool) + .await?; + assert_eq!(status, "active"); + + // While off: the count leaves MCP Events out, fan-out skips them, and naming the + // runtime in a new subscription is refused with a clear error. + assert_eq!(active_count(&app, &token).await?, 1); + post_events( + &app, + &token, + &runtime, + vec![event("agent.status", json!({}))], + ) + .await?; + deliver_all(&state).await?; + let fanned: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM event_deliveries WHERE subscription_id = $1") + .bind(&subscription_id) + .fetch_one(&pool) + .await?; + assert_eq!(fanned, 2, "no new MCP Events delivery while off"); + assert_eq!(paths(&receiver).len(), 4); + let refused = post_json( + &app, + path, + Some(&access), + mcp_subscription(&receiver.url("/echo"), json!({"runtime": runtime})), + ) + .await?; + assert_eq!(refused.status, StatusCode::CONFLICT, "{}", refused.text()); + assert_eq!(refused.error_code(), "runtime_mcp_disabled"); + assert!(refused.text().contains("MCP Control is off")); + + // Back on: MCP Events resume. + report_runtime(&app, &token, &runtime, "read", true).await?; + assert_eq!(active_count(&app, &token).await?, 2); + post_events( + &app, + &token, + &runtime, + vec![event("agent.status", json!({}))], + ) + .await?; + deliver_all(&state).await?; + assert_eq!( + paths(&receiver) + .iter() + .filter(|path| *path == "/echo") + .count(), + 2 + ); + delete_account(&pool, session["account"]["id"].as_str()).await?; + pool.close().await; + Ok(()) +} + +/// The status, attempts, and error of the delivery of `event_id` to `subscription_id`. +async fn delivery_row( + pool: &sqlx::PgPool, + event_id: &str, + subscription_id: &str, +) -> anyhow::Result<(String, i32, Option)> { + Ok(sqlx::query_as( + r#" + SELECT d.status, d.attempts, d.last_error + FROM event_deliveries d JOIN domain_events e ON e.id = d.event_id + WHERE e.event_id = $1 AND d.subscription_id = $2 + "#, + ) + .bind(event_id) + .bind(subscription_id) + .fetch_one(pool) + .await?) +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn the_mcp_events_switch_pauses_deliveries_without_revoking() -> anyhow::Result<()> { + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(6) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let email = format!("{}@example.test", Uuid::now_v7()); + let state = test_state( + pool.clone(), + url.clone(), + email, + true, + Arc::new(AtomicUsize::new(0)), + )?; + let app = router(state.clone()); + let runtime = format!("runtime-{}", Uuid::now_v7()); + let session = sign_in(&app, "google", &runtime).await?; + let token = session["accessToken"] + .as_str() + .unwrap_or_default() + .to_owned(); + report_runtime(&app, &token, &runtime, "full", true).await?; + let client_id = register_client(&app, "Events Pause Client").await?; + let (access, _, _) = authorize_runtimes(&app, &client_id, &[&runtime], false).await?; + let receiver = Receiver::start().await?; + let subscribed = post_json( + &app, + "/v1/mcp/event-subscriptions", + Some(&access), + mcp_subscription(&receiver.url("/echo"), json!({})), + ) + .await?; + assert_eq!(subscribed.status, StatusCode::OK, "{}", subscribed.text()); + let subscription_id = subscribed.json()["id"] + .as_str() + .unwrap_or_default() + .to_owned(); + + // Switch off: a delivery queued for a valid grant is neither attempted nor stopped, + // and the subscription is not revoked. + let mut config = test_config(url.clone())?; + config.events.mcp_events_enabled = false; + let paused = AppState::from_dependencies( + pool.clone(), + config, + state.oauth.clone(), + Arc::new(LocalEd25519Signer::from_seed_b64url( + "api-contract".to_owned(), + &URL_SAFE_NO_PAD.encode([23_u8; 32]), + )?), + state.fcm.clone(), + ); + let paused_app = router(paused.clone()); + let queued = event("agent.status", json!({"state": "waiting"})); + let queued_id = queued["eventId"].as_str().unwrap_or_default().to_owned(); + post_events(&paused_app, &token, &runtime, vec![queued]).await?; + fan_out_pending(&paused).await?; + sqlx::query( + r#" + INSERT INTO event_deliveries ( + id, subscription_id, event_id, status, attempts, next_attempt_at, created_at + ) + SELECT gen_random_uuid(), $2, e.id, 'pending', 0, now(), now() + FROM domain_events e WHERE e.event_id = $1 + "#, + ) + .bind(&queued_id) + .bind(&subscription_id) + .execute(&pool) + .await?; + for _ in 0..3 { + run_pass(&paused).await?; + } + let row = delivery_row(&pool, &queued_id, &subscription_id).await?; + assert_eq!((row.0.as_str(), row.1, row.2), ("pending", 0, None)); + let status: String = sqlx::query_scalar("SELECT status FROM event_subscriptions WHERE id = $1") + .bind(&subscription_id) + .fetch_one(&pool) + .await?; + assert_eq!(status, "active"); + assert!(paths(&receiver).is_empty(), "nothing is sent while paused"); + + // Switch back on: the queued delivery goes out. + deliver_all(&state).await?; + let row = delivery_row(&pool, &queued_id, &subscription_id).await?; + assert_eq!(row.0, "delivered"); + let delivered = receiver.events(); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].json()["eventId"], queued_id.as_str()); + delete_account(&pool, session["account"]["id"].as_str()).await?; + pool.close().await; + Ok(()) +} + +async fn pump(app: &Router, origin_token: Option<&str>) -> anyhow::Result { + let mut builder = Request::builder() + .method(Method::POST) + .uri("/v1/internal/event-deliveries/pump"); + if let Some(origin_token) = origin_token { + builder = builder.header("x-alera-origin-auth", origin_token); + } + let response = app.clone().oneshot(builder.body(Body::empty())?).await?; + Ok(response.status()) +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn the_delivery_pump_requires_the_origin_token_even_with_direct_origin() -> anyhow::Result<()> +{ + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(4) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let state = test_state( + pool.clone(), + url.clone(), + format!("{}@example.test", Uuid::now_v7()), + true, + Arc::new(AtomicUsize::new(0)), + )?; + // Direct origin access with no token configured: the pump is closed. + let open = router(state.clone()); + assert!(state.config.allow_direct_origin); + assert_eq!(pump(&open, None).await?, StatusCode::UNAUTHORIZED); + assert_eq!( + pump(&open, Some("anything")).await?, + StatusCode::UNAUTHORIZED + ); + + // With a token: only the current or previous token runs a drain. + let mut config = test_config(url.clone())?; + config.edge_origin_token = Some("pump-current".to_owned()); + config.edge_previous_origin_token = Some("pump-previous".to_owned()); + let guarded = router(AppState::from_dependencies( + pool.clone(), + config, + state.oauth.clone(), + Arc::new(LocalEd25519Signer::from_seed_b64url( + "api-contract".to_owned(), + &URL_SAFE_NO_PAD.encode([23_u8; 32]), + )?), + state.fcm.clone(), + )); + assert_eq!(pump(&guarded, None).await?, StatusCode::UNAUTHORIZED); + assert_eq!( + pump(&guarded, Some("wrong")).await?, + StatusCode::UNAUTHORIZED + ); + assert_eq!(pump(&guarded, Some("pump-current")).await?, StatusCode::OK); + assert_eq!(pump(&guarded, Some("pump-previous")).await?, StatusCode::OK); + pool.close().await; + Ok(()) +} diff --git a/cloud/tests/contracts/events_resume_contract.rs b/cloud/tests/contracts/events_resume_contract.rs new file mode 100644 index 000000000..47096c895 --- /dev/null +++ b/cloud/tests/contracts/events_resume_contract.rs @@ -0,0 +1,327 @@ +use alera_cloud::events::{fanout::fan_out_pending, webhooks::MAX_WEBHOOKS_PER_ACCOUNT}; + +use super::mcp_contract::delete_account; +use super::mcp_http::*; +use super::webhook_contract::{deliver_all, event, post_events}; +use super::webhook_receiver::Receiver; +use super::*; + +fn subscribe_body(url: &str, cursor: Value) -> Value { + json!({ + "name": "agent.status", + "arguments": {}, + "delivery": { + "mode": "webhook", + "url": url, + "secret": format!("whsec_{}", base64::engine::general_purpose::STANDARD.encode([9_u8; 32])), + }, + "cursor": cursor, + }) +} + +/// Status, attempts, and error of the delivery of `event` to `subscription_id`. +async fn row( + pool: &sqlx::PgPool, + event: &Value, + subscription_id: &str, +) -> anyhow::Result<(String, i32, Option)> { + Ok(sqlx::query_as( + r#" + SELECT d.status, d.attempts, d.last_error + FROM event_deliveries d JOIN domain_events e ON e.id = d.event_id + WHERE e.event_id = $1 AND d.subscription_id = $2 + "#, + ) + .bind(event["eventId"].as_str().unwrap_or_default()) + .bind(subscription_id) + .fetch_one(pool) + .await?) +} + +/// Records a stopped delivery directly, as a past stop for `reason` would have left it. +async fn stopped_delivery( + pool: &sqlx::PgPool, + event: &Value, + subscription_id: &str, + reason: &str, +) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO event_deliveries ( + id, subscription_id, event_id, status, attempts, next_attempt_at, created_at, + last_error + ) + SELECT gen_random_uuid(), $2, e.id, 'stopped', 1, now(), now(), $3 + FROM domain_events e WHERE e.event_id = $1 + "#, + ) + .bind(event["eventId"].as_str().unwrap_or_default()) + .bind(subscription_id) + .bind(reason) + .execute(pool) + .await?; + Ok(()) +} + +fn delivered_ids(receiver: &Receiver) -> Vec { + receiver + .events() + .into_iter() + .filter_map(|item| item.json()["eventId"].as_str().map(ToOwned::to_owned)) + .collect() +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn refreshing_an_expired_subscription_resumes_its_stopped_deliveries() -> anyhow::Result<()> { + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(6) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let email = format!("{}@example.test", Uuid::now_v7()); + let state = test_state( + pool.clone(), + url.clone(), + email, + true, + Arc::new(AtomicUsize::new(0)), + )?; + let app = router(state.clone()); + let runtime = format!("runtime-{}", Uuid::now_v7()); + let session = sign_in(&app, "google", &runtime).await?; + let token = session["accessToken"] + .as_str() + .unwrap_or_default() + .to_owned(); + report_runtime(&app, &token, &runtime, "full", true).await?; + let client_id = register_client(&app, "Events Resume Client").await?; + let (access, _, _) = authorize_runtimes(&app, &client_id, &[&runtime], false).await?; + let receiver = Receiver::start().await?; + let path = "/v1/mcp/event-subscriptions"; + let echo = receiver.url("/echo"); + let created = post_json( + &app, + path, + Some(&access), + subscribe_body(&echo, Value::Null), + ) + .await?; + assert_eq!(created.status, StatusCode::OK, "{}", created.text()); + let created = created.json(); + let id = created["id"].as_str().unwrap_or_default().to_owned(); + let cursor = created["cursor"].clone(); + + // One acknowledged event, one failing with 503, and one still waiting. + let acknowledged = event("agent.status", json!({"state": "idle"})); + post_events(&app, &token, &runtime, vec![acknowledged.clone()]).await?; + deliver_all(&state).await?; + receiver.respond_with(&[503]); + let failing = event("agent.status", json!({"state": "blocked"})); + let waiting = event("agent.status", json!({"state": "waiting"})); + post_events( + &app, + &token, + &runtime, + vec![failing.clone(), waiting.clone()], + ) + .await?; + fan_out_pending(&state).await?; + sqlx::query( + "UPDATE event_deliveries SET next_attempt_at = now() + interval '1 hour' WHERE subscription_id = $1 AND event_id = (SELECT id FROM domain_events WHERE event_id = $2)", + ) + .bind(&id) + .bind(waiting["eventId"].as_str().unwrap_or_default()) + .execute(&pool) + .await?; + deliver_all(&state).await?; + assert_eq!( + row(&pool, &failing, &id).await?, + ("pending".to_owned(), 1, Some("http_503".to_owned())) + ); + assert_eq!( + row(&pool, &waiting, &id).await?, + ("pending".to_owned(), 0, None) + ); + + // `refreshBefore` passes while the callback is failing: the retried delivery stops the + // subscription, and the waiting one is stopped with it. + sqlx::query( + "UPDATE event_subscriptions SET refresh_before = now() - interval '1 second' WHERE id = $1", + ) + .bind(&id) + .execute(&pool) + .await?; + sqlx::query( + "UPDATE event_deliveries SET next_attempt_at = now() WHERE subscription_id = $1 AND event_id = (SELECT id FROM domain_events WHERE event_id = $2)", + ) + .bind(&id) + .bind(failing["eventId"].as_str().unwrap_or_default()) + .execute(&pool) + .await?; + deliver_all(&state).await?; + let status: String = sqlx::query_scalar("SELECT status FROM event_subscriptions WHERE id = $1") + .bind(&id) + .fetch_one(&pool) + .await?; + assert_eq!(status, "expired"); + for stopped in [&failing, &waiting] { + let (status, _, error) = row(&pool, stopped, &id).await?; + assert_eq!( + (status.as_str(), error.as_deref()), + ("stopped", Some("subscription_expired")) + ); + } + + // Deliveries stopped for other reasons, and one event fanned out to nobody while expired. + let disabled = event("agent.status", json!({"state": "disabled"})); + let revoked = event("agent.status", json!({"state": "revoked"})); + let missed = event("agent.status", json!({"state": "missed"})); + post_events( + &app, + &token, + &runtime, + vec![disabled.clone(), revoked.clone(), missed.clone()], + ) + .await?; + deliver_all(&state).await?; + stopped_delivery(&pool, &disabled, &id, "runtime_mcp_disabled").await?; + stopped_delivery(&pool, &revoked, &id, "subscription_inactive").await?; + // The acknowledged delivery and the 503 attempt; nothing is sent while expired. + let before = delivered_ids(&receiver).len(); + assert_eq!(before, 2, "nothing is sent while expired"); + + // Refresh with the same identity and cursor: the expired deliveries resume with a fresh + // retry budget, the missed event is queued, and nothing else is sent. + let refreshed = post_json(&app, path, Some(&access), subscribe_body(&echo, cursor)).await?; + assert_eq!(refreshed.status, StatusCode::OK, "{}", refreshed.text()); + assert_eq!(refreshed.json()["id"], id.as_str()); + deliver_all(&state).await?; + let mut sent = delivered_ids(&receiver).split_off(before); + sent.sort(); + let mut expected: Vec = [&failing, &waiting, &missed] + .iter() + .map(|item| item["eventId"].as_str().unwrap_or_default().to_owned()) + .collect(); + expected.sort(); + assert_eq!( + sent, expected, + "the expired deliveries and the missed event go out; nothing else does" + ); + for resumed in [&failing, &waiting, &missed] { + assert_eq!( + row(&pool, resumed, &id).await?, + ("delivered".to_owned(), 1, None) + ); + } + assert_eq!(row(&pool, &acknowledged, &id).await?.0, "delivered"); + for (kept, reason) in [ + (&disabled, "runtime_mcp_disabled"), + (&revoked, "subscription_inactive"), + ] { + let (status, _, error) = row(&pool, kept, &id).await?; + assert_eq!( + (status.as_str(), error.as_deref()), + ("stopped", Some(reason)) + ); + } + delete_account(&pool, session["account"]["id"].as_str()).await?; + pool.close().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn concurrent_webhook_creations_never_pass_the_limit() -> anyhow::Result<()> { + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(20) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let email = format!("{}@example.test", Uuid::now_v7()); + let state = test_state( + pool.clone(), + url.clone(), + email, + true, + Arc::new(AtomicUsize::new(0)), + )?; + let app = router(state.clone()); + let runtime = format!("runtime-{}", Uuid::now_v7()); + let session = sign_in(&app, "google", &runtime).await?; + let token = session["accessToken"] + .as_str() + .unwrap_or_default() + .to_owned(); + let account_id: Uuid = session["account"]["id"] + .as_str() + .unwrap_or_default() + .parse()?; + sqlx::query( + r#" + INSERT INTO event_subscriptions ( + id, account_id, target_kind, callback_url, secret_ciphertext, kinds, all_runtimes, + status, created_at, updated_at + ) + SELECT gen_random_uuid()::text, $1, 'webhook', 'https://hooks.example.test/', 'x', + ARRAY['agent.status'], true, 'active', now(), now() + FROM generate_series(1, $2) + "#, + ) + .bind(account_id) + .bind((MAX_WEBHOOKS_PER_ACCOUNT - 1) as i32) + .execute(&pool) + .await?; + let receiver = Receiver::start().await?; + // Several rounds at 19 webhooks, with the pool warmed so the requests really overlap. + for round in 0..5 { + let mut warm = Vec::new(); + for _ in 0..16 { + warm.push(pool.acquire().await?); + } + drop(warm); + let mut tasks = tokio::task::JoinSet::new(); + for _ in 0..12 { + let app = app.clone(); + let token = token.clone(); + let body = json!({"url": receiver.url("/hook"), "kinds": ["agent.status"]}); + tasks.spawn(async move { post_json(&app, "/v1/webhooks", Some(&token), body).await }); + } + let mut created = 0; + while let Some(reply) = tasks.join_next().await { + let reply = reply??; + if reply.status == StatusCode::OK { + created += 1; + } else { + assert_eq!( + reply.error_code(), + "webhook_limit_reached", + "{}", + reply.text() + ); + } + } + assert_eq!( + created, 1, + "round {round}: one creation fits under the limit" + ); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM event_subscriptions WHERE account_id = $1 AND target_kind = 'webhook'", + ) + .bind(account_id) + .fetch_one(&pool) + .await?; + assert_eq!(count, MAX_WEBHOOKS_PER_ACCOUNT); + sqlx::query( + "DELETE FROM event_subscriptions WHERE account_id = $1 AND callback_url <> 'https://hooks.example.test/'", + ) + .bind(account_id) + .execute(&pool) + .await?; + } + delete_account(&pool, Some(&account_id.to_string())).await?; + pool.close().await; + Ok(()) +} diff --git a/cloud/tests/contracts/mcp_admin_contract.rs b/cloud/tests/contracts/mcp_admin_contract.rs new file mode 100644 index 000000000..b35b79808 --- /dev/null +++ b/cloud/tests/contracts/mcp_admin_contract.rs @@ -0,0 +1,205 @@ +use super::mcp_contract::delete_account; +use super::mcp_http::*; +use super::*; + +const ADMIN_SCOPES: &str = "mcp:read mcp:execute mcp:admin"; + +/// Runs consent for `scope` with the extra form fields and returns the reply. +async fn consent( + app: &Router, + client_id: &str, + scope: &str, + runtime_id: &str, + extra: &[(&str, &str)], +) -> anyhow::Result<(Reply, Reply)> { + let (page, request, token) = reach_consent(app, client_id, scope).await?; + let mut fields = vec![ + ("request", request.as_str()), + ("consent_token", token.as_str()), + ("runtime", runtime_id), + ("action", "approve"), + ]; + fields.extend_from_slice(extra); + let reply = post_form(app, "/oauth/consent", &fields).await?; + Ok((page, reply)) +} + +/// Exchanges an approved consent redirect for its token response. +async fn tokens(app: &Router, client_id: &str, approved: &Reply) -> anyhow::Result { + anyhow::ensure!( + approved.status == StatusCode::SEE_OTHER, + "{}", + approved.text() + ); + let code = query_value(&approved.location()?, "code").unwrap_or_default(); + let reply = exchange_code(app, client_id, &code).await?; + anyhow::ensure!(reply.status == StatusCode::OK, "{}", reply.text()); + Ok(reply.json()) +} + +async fn admin_call(app: &Router, access: &str, runtime: &str) -> anyhow::Result { + post_json( + app, + "/v1/mcp/calls", + Some(access), + json!({"runtime": runtime, "tool": "update_runtime_settings", "access": "admin"}), + ) + .await +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn mcp_admin_scope_needs_explicit_consent_and_an_admin_runtime() -> anyhow::Result<()> { + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(6) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let app = test_app( + pool.clone(), + url.clone(), + format!("{}@example.test", Uuid::now_v7()), + true, + Arc::new(AtomicUsize::new(0)), + )?; + let runtime = format!("runtime-{}", Uuid::now_v7()); + let session = sign_in(&app, "google", &runtime).await?; + let runtime_token = session["accessToken"].as_str().unwrap_or_default(); + + for path in [ + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-protected-resource/v1/mcp", + ] { + let metadata = get(&app, path, None).await?.json(); + assert_eq!( + metadata["scopes_supported"], + json!(ADMIN_SCOPES.split(' ').collect::>()) + ); + } + let registered = post_json( + &app, + "/oauth/register", + None, + json!({"redirect_uris": [REDIRECT_URI], "client_name": "Admin Client"}), + ) + .await?; + assert_eq!( + registered.status, + StatusCode::CREATED, + "{}", + registered.text() + ); + assert_eq!(registered.json()["scope"], ADMIN_SCOPES); + let client_id = registered.json()["client_id"] + .as_str() + .unwrap_or_default() + .to_owned(); + + let claims = report_runtime(&app, runtime_token, &runtime, "admin", true).await?; + assert_eq!(claims["mcpAccess"], "admin"); + let reported = send( + &app, + Method::PUT, + "/v1/runtime/capabilities", + Some(runtime_token), + Some("application/json"), + serde_json::to_vec(&json!({"mcpAccess": "admin", "mobileAccess": true}))?, + ) + .await?; + assert_eq!( + reported.status, + StatusCode::NO_CONTENT, + "{}", + reported.text() + ); + + // The default request never offers administrative tools. + let (page, _, _) = reach_consent(&app, &client_id, "mcp:read mcp:execute").await?; + assert!(page.text().contains("MCP admin access")); + assert!(!page.text().contains("name=\"admin\"")); + let (access, _, _) = authorize_runtimes(&app, &client_id, &[runtime.as_str()], false).await?; + let refused = admin_call(&app, &access, &runtime).await?; + assert_eq!(refused.status, StatusCode::FORBIDDEN); + assert_eq!(refused.error_code(), "insufficient_scope"); + assert!(refused.json()["error"]["message"] + .as_str() + .unwrap_or_default() + .contains("allow administrative tools")); + + // Requested but left unticked: the grant stays at read and execute. + let (page, approved) = consent( + &app, + &client_id, + ADMIN_SCOPES, + &runtime, + &[("execute", "1")], + ) + .await?; + assert!(page.text().contains( + "Allow administrative tools" + )); + assert_eq!( + tokens(&app, &client_id, &approved).await?["scope"], + "mcp:read mcp:execute" + ); + + // Administrative tools without execute is refused on the page. + let (_, inconsistent) = + consent(&app, &client_id, ADMIN_SCOPES, &runtime, &[("admin", "1")]).await?; + assert_eq!(inconsistent.status, StatusCode::OK); + assert!(inconsistent + .text() + .contains("Administrative tools also need permission")); + + let (_, approved) = consent( + &app, + &client_id, + ADMIN_SCOPES, + &runtime, + &[("execute", "1"), ("admin", "1")], + ) + .await?; + let granted = tokens(&app, &client_id, &approved).await?; + assert_eq!(granted["scope"], ADMIN_SCOPES); + let admin_access = granted["access_token"].as_str().unwrap_or_default(); + let created = admin_call(&app, admin_access, &runtime).await?; + assert_eq!(created.status, StatusCode::OK, "{}", created.text()); + let call_id = created.json()["callId"] + .as_str() + .unwrap_or_default() + .to_owned(); + let grant_claims = jwt_claims(created.json()["grant"].as_str().unwrap_or_default())?; + assert_eq!(grant_claims["access"], "admin"); + let audit = sqlx::query_scalar::<_, String>("SELECT access FROM mcp_calls WHERE id = $1") + .bind(Uuid::parse_str(&call_id)?) + .fetch_one(&pool) + .await?; + assert_eq!(audit, "admin"); + let grants = get(&app, "/v1/mcp/grants", Some(runtime_token)) + .await? + .json(); + assert!(grants["grants"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .any(|grant| grant["scopes"] == json!(["mcp:read", "mcp:execute", "mcp:admin"]))); + + report_runtime(&app, runtime_token, &runtime, "full", true).await?; + let not_admin = admin_call(&app, admin_access, &runtime).await?; + assert_eq!(not_admin.status, StatusCode::FORBIDDEN); + assert_eq!(not_admin.error_code(), "runtime_not_admin"); + let execute = post_json( + &app, + "/v1/mcp/calls", + Some(admin_access), + json!({"runtime": runtime, "tool": "terminal_send", "access": "execute"}), + ) + .await?; + assert_eq!(execute.status, StatusCode::OK, "{}", execute.text()); + + delete_account(&pool, session["account"]["id"].as_str()).await?; + pool.close().await; + Ok(()) +} diff --git a/cloud/tests/contracts/mcp_events_contract.rs b/cloud/tests/contracts/mcp_events_contract.rs new file mode 100644 index 000000000..219bf25bd --- /dev/null +++ b/cloud/tests/contracts/mcp_events_contract.rs @@ -0,0 +1,345 @@ +use alera_cloud::events::{cursor, fanout::fan_out_pending}; + +use super::mcp_contract::delete_account; +use super::mcp_http::*; +use super::webhook_contract::{deliver_all, event, post_events, verify_signature}; +use super::webhook_receiver::Receiver; +use super::*; + +fn secret(byte: u8) -> String { + format!( + "whsec_{}", + base64::engine::general_purpose::STANDARD.encode([byte; 32]) + ) +} + +fn subscribe_body(url: &str, secret: &str, arguments: Value, cursor: Value) -> Value { + json!({ + "name": "inbox.reply", + "arguments": arguments, + "delivery": {"mode": "webhook", "url": url, "secret": secret}, + "cursor": cursor, + }) +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn mcp_events_subscriptions_follow_the_grant() -> anyhow::Result<()> { + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(6) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let email = format!("{}@example.test", Uuid::now_v7()); + let state = test_state( + pool.clone(), + url.clone(), + email, + true, + Arc::new(AtomicUsize::new(0)), + )?; + let app = router(state.clone()); + let runtime = format!("runtime-{}", Uuid::now_v7()); + let session = sign_in(&app, "google", &runtime).await?; + let runtime_token = session["accessToken"] + .as_str() + .unwrap_or_default() + .to_owned(); + report_runtime(&app, &runtime_token, &runtime, "full", true).await?; + let client_id = register_client(&app, "Events Client").await?; + let (access, _, _) = authorize_runtimes(&app, &client_id, &[&runtime], false).await?; + let receiver = Receiver::start().await?; + let path = "/v1/mcp/event-subscriptions"; + let secret_one = secret(1); + + // A callback that does not echo the challenge is refused with its reason. + let refused = post_json( + &app, + path, + Some(&access), + subscribe_body( + &receiver.url("/noecho"), + &secret_one, + json!({}), + Value::Null, + ), + ) + .await?; + assert_eq!( + refused.status, + StatusCode::UNPROCESSABLE_ENTITY, + "{}", + refused.text() + ); + assert_eq!(refused.error_code(), "callback_endpoint_error"); + assert_eq!(refused.json()["error"]["message"], "challenge_failed"); + for (body, code) in [ + ( + subscribe_body( + &receiver.url("/echo"), + "whsec_c2hvcnQ=", + json!({}), + Value::Null, + ), + "invalid_event_subscription", + ), + ( + subscribe_body( + &receiver.url("/echo"), + &secret_one, + json!({"prompt": "x"}), + Value::Null, + ), + "invalid_event_subscription", + ), + ( + subscribe_body( + &receiver.url("/echo"), + &secret_one, + json!({"runtime": "elsewhere"}), + Value::Null, + ), + "runtime_not_found", + ), + ( + subscribe_body( + &receiver.url("/echo"), + &secret_one, + json!({}), + json!("bogus"), + ), + "invalid_event_subscription", + ), + ] { + let reply = post_json(&app, path, Some(&access), body).await?; + assert_eq!(reply.error_code(), code, "{}", reply.text()); + } + + // Subscribe: verified challenge, deterministic id, 24-hour cap, opaque cursor. + let arguments = json!({"runtime": runtime, "threadId": "t1"}); + let echo = receiver.url("/echo"); + let created = post_json( + &app, + path, + Some(&access), + subscribe_body(&echo, &secret_one, arguments.clone(), Value::Null), + ) + .await?; + assert_eq!(created.status, StatusCode::OK, "{}", created.text()); + let created = created.json(); + let id = created["id"].as_str().unwrap_or_default().to_owned(); + assert!(id.starts_with("sub_")); + assert_eq!(created["truncated"], false); + let first_cursor = created["cursor"].as_str().unwrap_or_default().to_owned(); + let refresh = chrono::DateTime::parse_from_rfc3339( + created["refreshBefore"].as_str().unwrap_or_default(), + )?; + let hours = (refresh.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_minutes(); + assert!( + (1430..=1440).contains(&hours), + "default lifetime is 24 h, got {hours} min" + ); + let challenge = receiver + .received() + .into_iter() + .find(|item| item.path == "/echo") + .ok_or_else(|| anyhow::anyhow!("no challenge"))?; + assert!(verify_signature(&challenge, &secret_one)); + assert_eq!(challenge.header("x-mcp-subscription-id"), id); + assert_eq!( + get( + &app, + "/v1/runtime/event-subscriptions", + Some(&runtime_token) + ) + .await? + .json()["activeSubscriptions"], + 1 + ); + + // Delivery honours the filter and carries the subscription header. + let wanted = event("inbox.reply", json!({"threadId": "t1", "questionId": "q1"})); + let other = event("inbox.reply", json!({"threadId": "t2", "questionId": "q2"})); + post_events(&app, &runtime_token, &runtime, vec![wanted.clone(), other]).await?; + deliver_all(&state).await?; + let delivered = receiver.events(); + assert_eq!(delivered.len(), 1); + assert_eq!(delivered[0].header("x-mcp-subscription-id"), id); + assert!(verify_signature(&delivered[0], &secret_one)); + let body = delivered[0].json(); + assert_eq!(body["eventId"], wanted["eventId"]); + assert_eq!(body["name"], "inbox.reply"); + assert_eq!(body["data"]["threadId"], "t1"); + assert!(body["cursor"].is_string()); + + // Refresh with the same identity and cursor: same id, no new challenge, no replay of + // the acknowledged event. A rotated secret is challenged again. + let challenges = receiver.challenges(); + let refreshed = post_json( + &app, + path, + Some(&access), + json!({ + "name": "inbox.reply", "arguments": arguments, "cursor": first_cursor, "ttlMs": 120_000, + "delivery": {"mode": "webhook", "url": echo, "secret": secret_one}, + }), + ) + .await? + .json(); + assert_eq!(refreshed["id"], id.as_str()); + assert_eq!(refreshed["truncated"], false); + let refresh = chrono::DateTime::parse_from_rfc3339( + refreshed["refreshBefore"].as_str().unwrap_or_default(), + )?; + assert!((refresh.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_seconds() <= 120); + assert_eq!(receiver.challenges(), challenges); + deliver_all(&state).await?; + assert_eq!( + receiver.events().len(), + 1, + "acknowledged events are not replayed" + ); + let old_cursor = cursor::encode(chrono::Utc::now() - chrono::TimeDelta::hours(25)); + let rotated = post_json( + &app, + path, + Some(&access), + subscribe_body(&echo, &secret(2), arguments.clone(), json!(old_cursor)), + ) + .await? + .json(); + assert_eq!(rotated["id"], id.as_str()); + assert_eq!(rotated["truncated"], true); + assert_eq!(receiver.challenges(), challenges + 1); + + // Unsubscribe is idempotent. + let second = post_json( + &app, + path, + Some(&access), + json!({ + "name": "agent.status", "arguments": {}, "cursor": null, + "delivery": {"mode": "webhook", "url": echo, "secret": secret_one}, + }), + ) + .await?; + assert_eq!(second.status, StatusCode::OK, "{}", second.text()); + let unsubscribe = json!({"name": "agent.status", "arguments": {}, "delivery": {"mode": "webhook", "url": echo}}); + for _ in 0..2 { + let reply = post_json( + &app, + &format!("{path}/unsubscribe"), + Some(&access), + unsubscribe.clone(), + ) + .await?; + assert_eq!(reply.status, StatusCode::NO_CONTENT, "{}", reply.text()); + } + let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM event_subscriptions WHERE account_id = (SELECT account_id FROM runtimes WHERE id = $1)") + .bind(&runtime) + .fetch_one(&pool) + .await?; + assert_eq!(left, 1); + + // Revoking the grant stops queued deliveries and ends the subscription. The queued + // delivery is inserted after the revoke, so a concurrent test's worker cannot send it + // before the revoke lands. + let grants = get(&app, "/v1/mcp/grants", Some(&runtime_token)) + .await? + .json(); + let grant_id = grants["grants"][0]["id"] + .as_str() + .unwrap_or_default() + .to_owned(); + let revoked = send( + &app, + Method::DELETE, + &format!("/v1/mcp/grants/{grant_id}"), + Some(&runtime_token), + None, + Vec::new(), + ) + .await?; + assert_eq!(revoked.status, StatusCode::NO_CONTENT); + let queued = event("inbox.reply", json!({"threadId": "t1"})); + post_events(&app, &runtime_token, &runtime, vec![queued.clone()]).await?; + fan_out_pending(&state).await?; + sqlx::query( + r#" + INSERT INTO event_deliveries ( + id, subscription_id, event_id, status, attempts, next_attempt_at, created_at + ) + SELECT gen_random_uuid(), $2, e.id, 'pending', 0, now(), now() + FROM domain_events e WHERE e.event_id = $1 + ON CONFLICT (subscription_id, event_id) DO NOTHING + "#, + ) + .bind(queued["eventId"].as_str().unwrap_or_default()) + .bind(&id) + .execute(&pool) + .await?; + let before = receiver.events().len(); + deliver_all(&state).await?; + post_events( + &app, + &runtime_token, + &runtime, + vec![event("inbox.reply", json!({"threadId": "t1"}))], + ) + .await?; + deliver_all(&state).await?; + assert_eq!( + receiver.events().len(), + before, + "a revoked grant receives nothing" + ); + let status: String = sqlx::query_scalar("SELECT status FROM event_subscriptions WHERE id = $1") + .bind(&id) + .fetch_one(&pool) + .await?; + assert_eq!(status, "revoked"); + assert_eq!( + get( + &app, + "/v1/runtime/event-subscriptions", + Some(&runtime_token) + ) + .await? + .json()["activeSubscriptions"], + 0 + ); + let after_revoke = post_json( + &app, + path, + Some(&access), + subscribe_body(&echo, &secret_one, json!({}), Value::Null), + ) + .await?; + assert_eq!(after_revoke.status, StatusCode::UNAUTHORIZED); + + // The switch hides the endpoints. + let mut disabled = test_config(url.clone())?; + disabled.events.mcp_events_enabled = false; + let disabled_app = router(AppState::from_dependencies( + pool.clone(), + disabled, + state.oauth.clone(), + Arc::new(LocalEd25519Signer::from_seed_b64url( + "api-contract".to_owned(), + &URL_SAFE_NO_PAD.encode([23_u8; 32]), + )?), + state.fcm.clone(), + )); + let hidden = post_json( + &disabled_app, + path, + Some(&access), + subscribe_body(&echo, &secret_one, json!({}), Value::Null), + ) + .await?; + assert_eq!(hidden.error_code(), "mcp_events_disabled"); + delete_account(&pool, session["account"]["id"].as_str()).await?; + pool.close().await; + Ok(()) +} diff --git a/cloud/tests/contracts/webhook_contract.rs b/cloud/tests/contracts/webhook_contract.rs new file mode 100644 index 000000000..43e6ee464 --- /dev/null +++ b/cloud/tests/contracts/webhook_contract.rs @@ -0,0 +1,403 @@ +use alera_cloud::events::{ + delivery::run_pass, + secrets::{sign, signing_key}, +}; + +use super::mcp_contract::delete_account; +use super::mcp_http::*; +use super::webhook_receiver::{Received, Receiver}; +use super::*; + +/// One domain event in the runtime wire format. +pub fn event(kind: &str, data: Value) -> Value { + json!({ + "eventId": Uuid::new_v4().to_string(), + "seq": 1, + "kind": kind, + "workspaceId": "workspace-1", + "data": data, + "occurredAt": chrono::Utc::now().to_rfc3339(), + }) +} + +pub async fn post_events( + app: &Router, + token: &str, + runtime: &str, + events: Vec, +) -> anyhow::Result { + post_json( + app, + "/v1/runtime/domain-events", + Some(token), + json!({"runtimeId": runtime, "events": events}), + ) + .await +} + +/// Runs worker passes until no delivery is due or in flight. Contract tests share the +/// database, so a pass may also send another test's deliveries; wait for those too. +pub async fn deliver_all(state: &AppState) -> anyhow::Result<()> { + for _ in 0..100 { + run_pass(state).await?; + let busy: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM event_deliveries WHERE status = 'sending' OR (status = 'pending' AND next_attempt_at <= now())", + ) + .fetch_one(&state.pool) + .await?; + if busy == 0 { + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + anyhow::bail!("deliveries did not settle") +} + +pub fn verify_signature(item: &Received, secret: &str) -> bool { + let Some(key) = signing_key(secret) else { + return false; + }; + let timestamp: i64 = item.header("webhook-timestamp").parse().unwrap_or_default(); + let expected = sign(&key, &item.header("webhook-id"), timestamp, &item.body); + item.header("webhook-signature") + .split(' ') + .any(|signature| signature == expected) +} + +async fn delivery_row( + pool: &sqlx::PgPool, + event_id: &str, +) -> anyhow::Result<(String, i32, Option)> { + Ok(sqlx::query_as::<_, (String, i32, Option)>( + r#" + SELECT d.status, d.attempts, d.last_error + FROM event_deliveries d JOIN domain_events e ON e.id = d.event_id + WHERE e.event_id = $1 + "#, + ) + .bind(event_id) + .fetch_one(pool) + .await?) +} + +async fn active_count(app: &Router, token: &str) -> anyhow::Result { + Ok(get(app, "/v1/runtime/event-subscriptions", Some(token)) + .await? + .json()["activeSubscriptions"] + .clone()) +} + +#[tokio::test] +#[ignore = "requires TEST_DATABASE_URL pointing to an isolated PostgreSQL database"] +async fn runtime_events_reach_signed_webhooks_with_retries() -> anyhow::Result<()> { + let url = std::env::var("TEST_DATABASE_URL")?; + let pool = PgPoolOptions::new() + .max_connections(6) + .connect(&url) + .await?; + migrations::run(&pool).await?; + let email = format!("{}@example.test", Uuid::now_v7()); + let sent = Arc::new(AtomicUsize::new(0)); + let state = test_state(pool.clone(), url.clone(), email.clone(), true, sent.clone())?; + let app = router(state.clone()); + let runtime = format!("runtime-{}", Uuid::now_v7()); + let session = sign_in(&app, "google", &runtime).await?; + let token = session["accessToken"] + .as_str() + .unwrap_or_default() + .to_owned(); + assert!(jwt_claims(&token)?["scope"] + .as_str() + .unwrap_or_default() + .contains("events:send")); + let receiver = Receiver::start().await?; + assert_eq!(active_count(&app, &token).await?, 0); + + let created = post_json( + &app, + "/v1/webhooks", + Some(&token), + json!({"url": receiver.url("/hook"), "kinds": ["inbox.reply", "agent.status", "inbox.reply"]}), + ) + .await?; + assert_eq!(created.status, StatusCode::OK, "{}", created.text()); + let created = created.json(); + let secret = created["secret"].as_str().unwrap_or_default().to_owned(); + assert!(secret.starts_with("whsec_")); + let webhook_id = created["webhook"]["id"] + .as_str() + .unwrap_or_default() + .to_owned(); + assert_eq!( + created["webhook"]["kinds"], + json!(["agent.status", "inbox.reply"]) + ); + assert_eq!(created["webhook"]["runtimeIds"], json!([runtime])); + assert_eq!(created["webhook"]["status"], "active"); + let listed = get(&app, "/v1/webhooks", Some(&token)).await?.json(); + assert_eq!(listed["webhooks"][0]["id"], webhook_id.as_str()); + assert!(listed["webhooks"][0].get("secret").is_none()); + let stored: String = + sqlx::query_scalar("SELECT secret_ciphertext FROM event_subscriptions WHERE id = $1") + .bind(&webhook_id) + .fetch_one(&pool) + .await?; + assert!( + !stored.contains(&secret[6..]), + "secrets are encrypted at rest" + ); + assert_eq!(active_count(&app, &token).await?, 1); + + for (body, code) in [ + ( + json!({"url": receiver.url("/hook"), "kinds": ["unknown.kind"]}), + "invalid_webhook_kinds", + ), + ( + json!({"url": receiver.url("/hook"), "kinds": ["inbox.reply"], "runtimeIds": ["other-runtime"]}), + "runtime_not_found", + ), + ( + json!({"url": "ftp://example.test/hook", "kinds": ["inbox.reply"]}), + "invalid_webhook_url", + ), + ] { + let refused = post_json(&app, "/v1/webhooks", Some(&token), body).await?; + assert_eq!(refused.error_code(), code, "{}", refused.text()); + } + + // Ingest: idempotent by event id, payload policy enforced, runtime ownership checked. + let reply = event( + "inbox.reply", + json!({"threadId": "t1", "questionId": "q1", "extra": "dropped"}), + ); + let waiting = event( + "agent.status", + json!({"tabId": "tab-1", "state": "waiting"}), + ); + let exited = event("terminal.exit", json!({"tabId": "tab-1", "exitCode": 0})); + let batch = vec![reply.clone(), waiting.clone(), exited, reply.clone()]; + let first = post_events(&app, &token, &runtime, batch.clone()).await?; + assert_eq!(first.status, StatusCode::OK, "{}", first.text()); + assert_eq!( + first.json(), + json!({"accepted": 3, "duplicate": 1, "activeSubscriptions": 1}) + ); + let again = post_events(&app, &token, &runtime, batch).await?.json(); + assert_eq!(again["accepted"], 0); + assert_eq!(again["duplicate"], 4); + let foreign = post_events( + &app, + &token, + "another-runtime", + vec![event("agent.status", json!({}))], + ) + .await?; + assert_eq!(foreign.status, StatusCode::FORBIDDEN); + // One bad event is rejected on its own; the rest of the batch is stored. + let mut skewed = event("agent.status", json!({"state": "idle"})); + skewed["occurredAt"] = json!((chrono::Utc::now() + chrono::TimeDelta::hours(1)).to_rfc3339()); + let fine = event("terminal.exit", json!({"tabId": "tab-2", "exitCode": 1})); + let mixed = post_events( + &app, + &token, + &runtime, + vec![ + event("inbox.reply", json!({"replyText": "secret"})), + event("alera.test", json!({})), + skewed.clone(), + fine, + ], + ) + .await?; + assert_eq!(mixed.status, StatusCode::OK, "{}", mixed.text()); + let mixed = mixed.json(); + assert_eq!(mixed["accepted"], 1); + let codes: Vec<&str> = mixed["rejected"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item["code"].as_str()) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + codes, + [ + "sensitive_event_data", + "unknown_event_kind", + "invalid_event_time" + ] + ); + assert_eq!(mixed["rejected"][2]["eventId"], skewed["eventId"]); + let too_many: Vec = (0..101).map(|_| event("agent.status", json!({}))).collect(); + let oversized = post_events(&app, &token, &runtime, too_many).await?; + assert_eq!(oversized.error_code(), "invalid_event_batch"); + + // Delivery: only subscribed kinds, signed, catalog keys only, envelope fields added. + deliver_all(&state).await?; + let events = receiver.events(); + assert_eq!(events.len(), 2); + for item in &events { + assert!(verify_signature(item, &secret), "signature must verify"); + assert_eq!(item.header("x-alera-webhook-id"), webhook_id); + assert_eq!(item.header("content-type"), "application/json"); + assert_eq!( + item.header("webhook-id"), + item.json()["eventId"].as_str().unwrap_or_default() + ); + } + let delivered_reply = events + .iter() + .map(Received::json) + .find(|body| body["name"] == "inbox.reply") + .unwrap_or(Value::Null); + assert_eq!(delivered_reply["eventId"], reply["eventId"]); + assert_eq!( + delivered_reply["data"], + json!({"threadId": "t1", "questionId": "q1", "runtimeId": runtime, "workspaceId": "workspace-1", "seq": 1}) + ); + assert!(delivered_reply["cursor"].is_string()); + deliver_all(&state).await?; + assert_eq!(receiver.events().len(), 2, "nothing is sent twice"); + + // 503 retries after five seconds; 413 is dead; both keep the webhook active. + receiver.respond_with(&[503]); + let retried = event("agent.status", json!({"state": "blocked"})); + post_events(&app, &token, &runtime, vec![retried.clone()]).await?; + deliver_all(&state).await?; + let retried_id = retried["eventId"].as_str().unwrap_or_default(); + assert_eq!( + delivery_row(&pool, retried_id).await?, + ("pending".to_owned(), 1, Some("http_503".to_owned())) + ); + let wait: f64 = sqlx::query_scalar( + "SELECT EXTRACT(EPOCH FROM d.next_attempt_at - now())::float8 FROM event_deliveries d JOIN domain_events e ON e.id = d.event_id WHERE e.event_id = $1", + ) + .bind(retried_id) + .fetch_one(&pool) + .await?; + assert!( + (2.0..=6.0).contains(&wait), + "first retry waits about 5 s, got {wait}" + ); + sqlx::query("UPDATE event_deliveries SET next_attempt_at = now() WHERE status = 'pending' AND event_id IN (SELECT id FROM domain_events WHERE runtime_id = $1)") + .bind(&runtime) + .execute(&pool) + .await?; + deliver_all(&state).await?; + assert_eq!(delivery_row(&pool, retried_id).await?.0, "delivered"); + receiver.respond_with(&[413]); + let too_large = event("agent.status", json!({"state": "done"})); + post_events(&app, &token, &runtime, vec![too_large.clone()]).await?; + deliver_all(&state).await?; + let dead = delivery_row(&pool, too_large["eventId"].as_str().unwrap_or_default()).await?; + assert_eq!( + (dead.0.as_str(), dead.2.as_deref()), + ("dead", Some("http_413")) + ); + + // The test endpoint sends a signed alera.test delivery. + let test = post_json( + &app, + &format!("/v1/webhooks/{webhook_id}/test"), + Some(&token), + json!({}), + ) + .await?; + assert_eq!(test.status, StatusCode::OK, "{}", test.text()); + assert!(test.json()["deliveryId"].is_string()); + deliver_all(&state).await?; + let last = receiver + .events() + .pop() + .map(|item| item.json()) + .unwrap_or(Value::Null); + assert_eq!(last["name"], "alera.test"); + assert_eq!(last["data"], json!({"runtimeId": runtime, "seq": 0})); + + // 410 stops the webhook and its queue. + receiver.respond_with(&[410]); + post_events( + &app, + &token, + &runtime, + vec![event("agent.status", json!({}))], + ) + .await?; + deliver_all(&state).await?; + let listed = get(&app, "/v1/webhooks", Some(&token)).await?.json(); + assert_eq!(listed["webhooks"][0]["status"], "stopped"); + assert_eq!(listed["webhooks"][0]["lastError"], "http_410"); + assert!(listed["webhooks"][0]["lastDeliveryAt"].is_string()); + assert_eq!(active_count(&app, &token).await?, 0); + let stopped_test = post_json( + &app, + &format!("/v1/webhooks/{webhook_id}/test"), + Some(&token), + json!({}), + ) + .await?; + assert_eq!(stopped_test.error_code(), "webhook_inactive"); + let removed = send( + &app, + Method::DELETE, + &format!("/v1/webhooks/{webhook_id}"), + Some(&token), + None, + Vec::new(), + ) + .await?; + assert_eq!(removed.status, StatusCode::NO_CONTENT); + let missing = send( + &app, + Method::DELETE, + &format!("/v1/webhooks/{webhook_id}"), + Some(&token), + None, + Vec::new(), + ) + .await?; + assert_eq!(missing.status, StatusCode::NOT_FOUND); + + // Without the encryption key, nothing can be created. + let mut unconfigured = test_config(url.clone())?; + unconfigured.events.secret_key = None; + let unconfigured_app = router(AppState::from_dependencies( + pool.clone(), + unconfigured, + state.oauth.clone(), + Arc::new(LocalEd25519Signer::from_seed_b64url( + "api-contract".to_owned(), + &URL_SAFE_NO_PAD.encode([23_u8; 32]), + )?), + state.fcm.clone(), + )); + let refused = post_json( + &unconfigured_app, + "/v1/webhooks", + Some(&token), + json!({"url": receiver.url("/hook"), "kinds": ["inbox.reply"]}), + ) + .await?; + assert_eq!(refused.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(refused.error_code(), "webhooks_not_configured"); + + // Retention: maintenance removes events after 24 hours with their deliveries. + sqlx::query( + "UPDATE domain_events SET received_at = now() - interval '25 hours' WHERE runtime_id = $1", + ) + .bind(&runtime) + .execute(&pool) + .await?; + alera_cloud::maintenance::run_once(&pool).await?; + let left: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM domain_events WHERE runtime_id = $1") + .bind(&runtime) + .fetch_one(&pool) + .await?; + assert_eq!(left, 0); + delete_account(&pool, session["account"]["id"].as_str()).await?; + pool.close().await; + Ok(()) +} diff --git a/cloud/tests/postgres_contract.rs b/cloud/tests/postgres_contract.rs index 09f3e5f5a..14ce7f4f6 100644 --- a/cloud/tests/postgres_contract.rs +++ b/cloud/tests/postgres_contract.rs @@ -37,7 +37,7 @@ async fn migrations_and_refresh_replay_contract() -> anyhow::Result<()> { blocker.close().await; migrations::run(&pool).await?; sqlx::query( - "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (24, 'future-schema', true, decode('00', 'hex'), 0)", + "INSERT INTO _sqlx_migrations (version, description, success, checksum, execution_time) VALUES (26, 'future-schema', true, decode('00', 'hex'), 0)", ) .execute(&pool) .await?; @@ -46,7 +46,7 @@ async fn migrations_and_refresh_replay_contract() -> anyhow::Result<()> { Err(error) => error, }; assert!(format!("{unclassified:#}").contains("not classified")); - sqlx::query("DELETE FROM _sqlx_migrations WHERE version = 24") + sqlx::query("DELETE FROM _sqlx_migrations WHERE version = 26") .execute(&pool) .await?; migrations::run_required(&pool).await?; diff --git a/cloud/tests/support/webhook_receiver.rs b/cloud/tests/support/webhook_receiver.rs new file mode 100644 index 000000000..189926e1c --- /dev/null +++ b/cloud/tests/support/webhook_receiver.rs @@ -0,0 +1,135 @@ +//! A loopback HTTP receiver for webhook contract tests. `/echo` answers verification +//! challenges; `/noecho` never does. Queued statuses answer event deliveries in order. + +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + +use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::post, + Json, Router, +}; +use serde_json::{json, Value}; + +#[derive(Clone, Debug)] +pub struct Received { + pub path: String, + pub headers: HeaderMap, + pub body: Vec, +} + +impl Received { + pub fn json(&self) -> Value { + serde_json::from_slice(&self.body).unwrap_or(Value::Null) + } + + pub fn header(&self, name: &str) -> String { + self.headers + .get(name) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned() + } +} + +#[derive(Clone, Default)] +struct Shared { + received: Arc>>, + statuses: Arc>>, +} + +pub struct Receiver { + pub base: String, + shared: Shared, + task: tokio::task::JoinHandle<()>, +} + +impl Receiver { + pub async fn start() -> anyhow::Result { + let shared = Shared::default(); + let app = Router::new() + .route("/{*path}", post(receive)) + .with_state(shared.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let base = format!("http://{}", listener.local_addr()?); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + Ok(Self { base, shared, task }) + } + + pub fn url(&self, path: &str) -> String { + format!("{}{path}", self.base) + } + + /// Answers the next event deliveries with these statuses, then 200. + pub fn respond_with(&self, statuses: &[u16]) { + if let Ok(mut queue) = self.shared.statuses.lock() { + queue.extend(statuses); + } + } + + pub fn received(&self) -> Vec { + self.shared + .received + .lock() + .map(|items| items.clone()) + .unwrap_or_default() + } + + /// Event deliveries only, without verification challenges. + pub fn events(&self) -> Vec { + self.received() + .into_iter() + .filter(|item| item.json()["type"] != "verification") + .collect() + } + + pub fn challenges(&self) -> usize { + self.received().len() - self.events().len() + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + self.task.abort(); + } +} + +async fn receive( + State(shared): State, + uri: Uri, + headers: HeaderMap, + body: Bytes, +) -> Response { + let item = Received { + path: uri.path().to_owned(), + headers, + body: body.to_vec(), + }; + let value = item.json(); + if let Ok(mut received) = shared.received.lock() { + received.push(item.clone()); + } + if value["type"] == "verification" { + return if item.path == "/echo" { + Json(json!({"challenge": value["challenge"]})).into_response() + } else { + Json(json!({"ok": true})).into_response() + }; + } + let status = shared + .statuses + .lock() + .ok() + .and_then(|mut queue| queue.pop_front()) + .unwrap_or(200); + StatusCode::from_u16(status) + .unwrap_or(StatusCode::OK) + .into_response() +} diff --git a/docs/mcp-capability-gap-audit.md b/docs/mcp-capability-gap-audit.md new file mode 100644 index 000000000..ffbddffb4 --- /dev/null +++ b/docs/mcp-capability-gap-audit.md @@ -0,0 +1,546 @@ +# Auditoría de brechas: GUI y CLI de Alera frente a su MCP + +Fecha: 2026-10-10. Base: `c8b3d3984` (release v0.104.1). Alcance: código fuente real de la GUI desktop (`lib/`), la app mobile (`mobile/lib`), el CLI (`rust/alera-cli`), el runtime host (`rust/alera-cli/src/terminal_host`) y el edge (`edge/src/mcp`). Solo investigación: no se implementó ni se modificó código. + +## 1. Resumen ejecutivo + +- El MCP de Alera expone **31 herramientas** (21 de lectura, incluidas 3 esperas acotadas, y 10 de ejecución), más `list_runtimes`, que solo existe en el edge. El CLI tiene unos **200 subcomandos** y el runtime host atiende **más de 400 métodos RPC**. La cobertura del MCP es intencionalmente estrecha: se centra en descubrir, lanzar agentes, conversar con ellos y orquestar. +- Cada herramienta MCP es una sola invocación `alera --json ` en un proceso hijo (`rust/alera-cli/src/mcp_tools/executor.rs:69`). Exponer un comando CLI existente cuesta poco: hace falta una entrada de catálogo y regenerar `edge/src/mcp/tool_catalog.json`. Lo que solo existe en la GUI (git, PRs, archivos, búsqueda, config de proyecto, workflows) necesita antes un comando CLI o una vía RPC nueva. +- Brechas más valiosas: gestión de secciones, tags y relaciones de workspaces, renombrar, archivar o eliminar workspaces, issue enlazado, Watch and Fix, detalle y control de automations, cancelar preguntas del inbox, ramas del proyecto y lectura del estado de git y PR. +- Hallazgo principal sobre **New Workspace from Prompt**: la orquestación está implementada **tres veces**, en Dart desktop, Dart mobile y Rust del CLI. Solo la generación de identidad (nombre, rama y sección con fallback "Others") es un servicio backend reutilizable. `start_agent_workspace` diverge de la UI en seis puntos (sección automática, colisiones, rama origen por defecto, setup diferido, idempotencia y timeout). Además, **ningún flujo infiere el proyecto desde el prompt**. Detalle en la sección 6. +- Riesgos destacados: posible workspace a medio crear cuando `start_agent_workspace` agota el timeout del MCP mientras genera la identidad o corre el setup en línea, falta de clave de idempotencia en los lanzamientos vía MCP, `write_terminal` equivale a ejecución arbitraria, y `alera mcp serve` local ignora el nivel de acceso configurado en MCP Control. +- Comunicación bidireccional (sección 11): el servidor MCP solo admite tools, en request/response sin estado. No hay notificaciones, recursos, SSE, sesiones ni elicitation, así que hoy la única vía es el polling acotado de `wait_*`. Ningún mecanismo estándar de MCP despierta a ChatGPT: lo único que lo consigue son las MCP Events de OpenAI (webhooks firmados, no estándar). EducUp las implementó, pero la continuación real en ChatGPT sigue sin demostrarse. + +## 2. Superficie MCP actual + +Catálogo: `rust/alera-cli/src/mcp_tools/catalog_read.rs` y `catalog_execute.rs`. Copia generada para el edge: `edge/src/mcp/tool_catalog.json` (sincronizada: 31 = 31). + +| Herramienta MCP | Acceso | Invocación CLI | +|---|---|---| +| `runtime_status` | read | `runtime status` | +| `list_projects` | read | `project list` | +| `list_workspaces` | read | `workspace list [--project-id] [--host-id] [--all]` | +| `list_tabs` | read | `tab list --workspace-id` | +| `list_agent_profiles` | read | `agent-profile list` | +| `list_terminals` / `show_terminal` / `read_terminal` | read | `terminal list/show/read` | +| `wait_for_terminal` | read | `terminal wait` (≤50 s) | +| `list_tasks` / `show_task` / `wait_for_task` | read | `orchestration task-list/task-show/task-wait` | +| `list_runs` / `orchestration_status` / `list_messages` | read | `orchestration run-list/status/inbox` | +| `list_inbox_targets` / `list_inbox_threads` / `show_inbox_thread` / `wait_for_reply` | read | `inbox targets/threads/show/wait` con `--inbox ext:mcp` | +| `list_automations` / `list_automation_runs` | read | `automation list/runs` | +| `create_workspace` | execute | `workspace add` | +| `start_agent_workspace` | execute | `workspace start --prompt-stdin --no-parent` | +| `launch_agent` | execute | `agent-profile launch --prompt-stdin` | +| `delegate_task` | execute | `orchestration delegate --spec-stdin --keep-on-failure` | +| `write_terminal` | execute | `terminal write --stdin [--submit/--enter]` | +| `send_message` | execute | `orchestration send` | +| `ask_agent` | execute | `inbox ask --inbox ext:mcp` | +| `cancel_task` | execute (destructive) | `orchestration task-cancel --force` | +| `sleep_workspace` | execute (destructive) | `workspace sleep` | +| `run_automation` | execute | `automation run-now` | + +Control de acceso: + +- **Remoto** (edge y relay): el token necesita el scope `mcp:execute` para las herramientas de ejecución (`edge/src/mcp/tool_call.ts:190`). El runtime vuelve a comprobar el grant y `settings.mcp.access` (off, read o full) en `terminal_host/relay_mcp.rs:218-234`. +- **Local** (`alera mcp serve`): solo filtra con `--read-only` (`mcp_commands.rs:52`, `mcp_tools/stdio_server.rs:144`). No consulta `settings.mcp.access`. Ver riesgo R3. + +## 3. Leyenda + +- **Cubierta**: el MCP ofrece la acción con semántica equivalente. +- **Parcial**: el MCP la ofrece, pero faltan opciones o la semántica difiere de la GUI o el CLI. +- **Brecha**: no hay equivalente MCP. +- **Por diseño**: no debería exponerse, o solo con salvaguardas (seguridad, decisiones humanas, gestión del propio acceso). +- Prioridad: **P1** alto valor para agentes y clientes MCP con riesgo bajo; **P2** valor medio, o alto valor con salvaguardas; **P3** valor bajo o nicho; **—** no exponer. +- Columna GUI: D = desktop, M = mobile. Columna CLI: comando o "—". + +## 4. Matriz de brechas + +### 4.1 Runtime y host + +| Acción | GUI | CLI | MCP | Brecha | Prioridad | +|---|---|---|---|---|---| +| Estado del runtime | D (panel runtime host) | `runtime status` | `runtime_status` | Cubierta | — | +| Versiones y contratos (CLI, host, skills) | — | `version` | — | Brecha. Permite que un cliente detecte incompatibilidades antes de llamar | P2 | +| Iniciar, detener o reiniciar el runtime | D, M (`host.restart`) | `runtime start/stop` | — | Por diseño: detenerlo corta la propia sesión MCP | — | +| Borrar el estado del runtime | — | `runtime clear` | — | Por diseño: destructivo | — | +| Integraciones de agentes (hooks) | D (Settings › Agents) | `runtime agents status/enable/disable` | — | Brecha. Solo `status` tiene sentido exponer | P3 | +| Nombre del runtime | D (MCP Control) | `runtime rename` | — | Brecha | P3 | +| Snapshot de recursos (CPU y memoria) y matar sesiones | D (status bar) | — | — | Brecha, solo GUI (`resources.snapshot`). Útil para diagnosticar agentes colgados | P2 (lectura) | +| Cuotas de agentes | D, M | — | — | Brecha, solo GUI (`agentQuota.snapshot`). Ayuda a elegir perfil antes de lanzar | P2 | + +### 4.2 Proyectos + +| Acción | GUI | CLI | MCP | Brecha | Prioridad | +|---|---|---|---|---|---| +| Listar proyectos | D, M | `project list` | `list_projects` | Cubierta | — | +| Registrar proyecto local | D, M | `project add` | — | Brecha | P2 | +| Clonar proyecto desde URL | D, M (`project.clone.*`) | Solo `project add-remote` (SSH) | — | Brecha. El clon local solo existe en la GUI | P2 | +| Registrar proyecto remoto o checkout SSH | D | `project add-remote`, `project register-checkout` | — | Brecha | P3 | +| Renombrar proyecto | D, M (`project.rename`) | — | — | Brecha, solo GUI | P3 | +| Eliminar proyecto | D, M | `project remove` | — | Por diseño (destructivo), o como mucho con confirmación explícita | — | +| Hosts del proyecto | D | `project hosts list/add/remove` | — | Brecha. `list` es lectura útil | P2 (list) / P3 | +| Ramas del proyecto (catálogo) | D, M (`project.branches.list`) | — | — | Brecha, solo GUI. Hoy un cliente MCP no puede saber qué `sourceBranch` pasar | **P1** | +| Configuración efectiva del proyecto (New Workspace, setup, copy rules) | D, M (`projectConfig.*`) | — | — | Brecha, solo GUI. Leerla da la rama origen preferida | P2 (lectura) / P3 (escritura) | + +### 4.3 Workspaces + +| Acción | GUI | CLI | MCP | Brecha | Prioridad | +|---|---|---|---|---|---| +| Listar workspaces | D, M | `workspace list` | `list_workspaces` | Parcial: filtra solo por proyecto y host; sin filtros de sección, tag o archivado (el campo `isArchived` sí viene) | P3 | +| Crear workspace | D, M | `workspace add` | `create_workspace` | Parcial: no expone `sectionId`, `parentWorkspaceId`, `reuseExistingBranch`, `path`, `workspaceRoot` ni `id`; la sección solo va por nombre | P2 | +| Crear desde prompt y lanzar agente | D, M | `workspace start` | `start_agent_workspace` | Parcial con divergencias graves; ver sección 6 | **P1** | +| Renombrar | D, M | `workspace rename` | — | Brecha | **P1** | +| Asignar, quitar, crear o borrar sección; listar secciones | D, M | `workspace section list/set/clear/create/remove` | — | Brecha (solo `section` por nombre al crear) | **P1** (list/set/clear) / P2 (create/remove) | +| Tags: asignar, quitar, listar, crear, borrar | D, M | `workspace tag/untag`, `tag list/upsert/remove` | — | Brecha | P2 | +| Relación padre/hijo | D, M | `workspace link/unlink` | — | Brecha (`start_agent_workspace` fuerza `--no-parent`) | P2 | +| Fijar o desfijar en el sidebar | D, M | `workspace pin/unpin` | — | Brecha | P3 | +| Archivar o desarchivar | D, M | `workspace archive/unarchive` | — | Brecha | P2 | +| Dormir | D, M | `workspace sleep` | `sleep_workspace` | Cubierta | — | +| Despertar | D, M (implícito al abrir) | — (implícito) | — | Brecha: no hay verbo explícito; ni la GUI ni el CLI tienen uno | P3 | +| Eliminar workspace (y su worktree) | D, M | `workspace remove` | — | Brecha. Exponer solo con `destructiveHint`, preflight `removalDependencies` y sin `--delete-branch` por defecto | P2 | +| Hand Off / Hand On | D, M | `workspace hand-off/hand-on` | — | Brecha | P2 | +| Setup y recovery del worktree | D, M | `workspace setup`, `workspace recovery` | — | Brecha | P3 | +| Enfocar en la app desktop | — | `workspace focus` | — | Brecha (exige desktop abierto) | P3 | +| Registrar o desregistrar (reparación de metadatos) | — | `workspace register/unregister` | — | Por diseño: reparación de bajo nivel | — | +| Vista previa de cascada | — | `workspace cascade-preview` | — | Brecha | P3 | +| Issue enlazado: ver, enlazar, desenlazar | D, M | `workspace issue show/link/unlink` | Solo `issueUrl` al crear | Parcial | **P1** (show) / P2 (link/unlink) | +| Leer issue sin workspace | D | `issue show` | — | Brecha | P2 | +| Watch and Fix del PR | D, M | `workspace pr-watch show/start/stop` | — | Brecha | **P1** | +| Opciones de vista del sidebar | D, M (`workbenchViewPrefs.*`) | — | — | Por diseño: preferencia de UI | — | + +### 4.4 Tabs, terminales y agentes + +| Acción | GUI | CLI | MCP | Brecha | Prioridad | +|---|---|---|---|---|---| +| Listar tabs | D, M | `tab list` | `list_tabs` | Cubierta (ver riesgo R5) | — | +| Crear tab de terminal o comando | D, M | `tab create [--command] [--spawn]` | — | Brecha (`write_terminal` exige que la terminal ya exista) | P2 | +| Cerrar tab o terminar terminal | D, M | `tab remove` | — | Brecha. Hoy un cliente MCP no puede cerrar un agente que lanzó | P2 | +| Renombrar tab | D, M (`tab.rename`) | — | — | Brecha, solo GUI | P3 | +| Reiniciar terminal | D, M (`terminal.restart`) | — | — | Brecha, solo GUI | P2 | +| Re-vincular agente a tab | — | `tab link-agent` | — | Brecha (pensado para ejecutarse dentro del agente) | P3 | +| Listar, ver, leer y esperar terminales | D, M | `terminal list/show/read/wait` | 4 herramientas | Cubierta | — | +| Escribir en terminal | D, M | `terminal write` | `write_terminal` | Parcial: sin `--file`. Ver riesgo R4 | — | +| Purgar sesiones detenidas | — | `terminal prune` | — | Brecha | P3 | +| Terminal Pulse | D | — | — | Brecha, solo GUI | P3 | +| Recuperar control desde mobile | D (`terminal.reclaim`) | — | — | Por diseño | — | +| Listar perfiles de agente | D, M | `agent-profile list` | `list_agent_profiles` | Cubierta | — | +| Ver un perfil | D | `agent-profile show` | — | Brecha (solo lectura) | P2 | +| Crear, actualizar, borrar o reordenar perfiles | D | `agent-profile create/update/remove/reorder/removal-impact` | — | Brecha. Un perfil define comandos ejecutables: tratarlo como ejecución privilegiada | P3 | +| Lanzar perfil en workspace existente | D, M | `agent-profile launch` | `launch_agent` | Parcial: sin `clientMutationId` (idempotencia), sin reanudar sesión (`resumeSessionId` existe en RPC y no en CLI) y sin adjuntos | P2 | +| Generar título de agente o tab | D, M (`aiText.agentTitle.generate`) | — | — | Brecha, solo GUI | P3 | + +### 4.5 Orquestación y workflows + +| Acción | GUI | CLI | MCP | Brecha | Prioridad | +|---|---|---|---|---|---| +| Tareas: listar, ver, esperar | D (Run Board) | `orchestration task-list/task-show/task-wait` | 3 herramientas | Cubierta | — | +| Delegar tarea | — | `orchestration delegate` | `delegate_task` | Parcial: fuerza `--keep-on-failure` y no expone `parentWorkspaceId`, `workspaceRoot` ni `path` | P3 | +| Cancelar tarea | D | `orchestration task-cancel` | `cancel_task` | Cubierta, como cancelación administrativa con `--force` | — | +| Crear tarea sin agente; dispatch manual | — | `orchestration task-create/dispatch` | — | Brecha | P3 | +| Mensajes: enviar, listar | — | `orchestration send/inbox` | `send_message`, `list_messages` | Cubierta (`send` sin `--payload`, `--task-id` ni `--files-modified`) | — | +| check, reply, context, heartbeat, escalate, complete, worker-done | — | `orchestration …` | — | Por diseño: ciclo de vida del worker, necesita identidad de terminal | — | +| Decision gates: listar | D | `orchestration gate-list` | — | Brecha (lectura) | P2 | +| Decision gates: crear o resolver | D | `orchestration gate-create/gate-resolve` | — | Por diseño, o solo con confirmación humana explícita: son decisiones humanas | — | +| Run policy: proponer, ver, aprobar, rechazar | D | `orchestration run-policy-*` | — | `show` es brecha de lectura (P2); aprobar o rechazar es por diseño | P2 / — | +| Coordinador: iniciar, detener, ver run | D | `orchestration run/run-stop/run-show` | `list_runs`, `orchestration_status` | Parcial: faltan `run-show`, iniciar y detener | P2 | +| Recuperar tarea, transferir coordinador, reset | — | `orchestration task-recover/transfer-coordinator/reset` | — | Por diseño: administrativo o destructivo (`reset`) | — | +| Recetas: listar, ver, validar, guardar | D (Settings › Workflows) | `orchestration recipes …` | — | Brecha (lectura y validación) | P3 | +| Planes, propuestas, aprobación y revisión de workflows | D (`workflows.*`, firma con FRB) | Parcial: `orchestration plans …`, `orchestration workspaces …` | — | Por diseño en las decisiones (firmadas y "desktop-only" según la ayuda del CLI); la lectura es brecha P3 | P3 / — | +| Limpieza de recursos de workflows | D | — | — | Brecha, solo GUI | P3 | + +### 4.6 Inbox + +| Acción | GUI | CLI | MCP | Brecha | Prioridad | +|---|---|---|---|---|---| +| Preguntar a un agente; destinos; hilos; esperar respuesta | D, M | `inbox ask/targets/threads/show/wait` | 5 herramientas | Cubierta, aislada en el inbox `ext:mcp` (no ve las preguntas del usuario) | — | +| Cancelar pregunta no entregada | D, M | `inbox cancel` | — | Brecha | **P1** | +| Marcar respuestas como leídas | D, M | `inbox read` | — | Brecha | P2 | +| Conversaciones entre agentes (lectura) | D, M | `inbox conversations/conversation` | — | Brecha | P2 | +| Listar inboxes; purgar | D, M | `inbox list/purge` | — | Brecha (`purge` destructivo) | P3 | + +### 4.7 Automations + +| Acción | GUI | CLI | MCP | Brecha | Prioridad | +|---|---|---|---|---|---| +| Listar automations | D, M | `automation list` | `list_automations` | Parcial: faltan los filtros `--include-trashed`, `--profile-id`, `--tag`, `--workspace-id`, `--section-id`, `--host-id` y `--bucket` | P3 | +| Ver automation (con runs y auditoría) | D, M | `automation show` | — | Brecha | **P1** | +| Listar runs | D, M | `automation runs` | `list_automation_runs` | Cubierta | — | +| Ver un run | D, M | `automation run-show` | — | Brecha | **P1** | +| Ejecutar ahora | D, M | `automation run-now` | `run_automation` | Parcial: sin `--skip-precheck`, `--overlap` (en cola o en paralelo) ni `--continue-from-run` | P2 | +| Cancelar run | D, M | `automation cancel` | — | Brecha | **P1** | +| Pausar o reanudar | D, M | `automation pause/resume` | — | Brecha | P2 | +| Crear o editar; readiness; vista previa de cron | D, M | `automation create/edit/readiness/preview-schedule` | — | Brecha (readiness y preview son lectura pura) | P2 | +| Papelera o restaurar; purgar | D, M | `automation trash/restore/purge` | — | Brecha; `purge` por diseño | P3 / — | +| Reanudar run en espera o extender plazo | D, M | `automation wait/extend` | — | Brecha | P2 | +| Tomar el control del terminal de un run | D, M (`automation.takeOver`) | — | — | Brecha, solo GUI | P3 | +| Plantillas, tags, importar o exportar catálogo | D, M | `automation templates/tags/import/export` | — | Brecha | P3 | +| context, heartbeat, complete | — | `automation context/heartbeat/complete` | — | Por diseño: ciclo de vida del agente dentro del run | — | + +### 4.8 Capacidades solo GUI (sin CLI ni MCP) + +Estas capacidades no tienen comando CLI. Exponerlas por MCP exige primero un comando CLI (por la arquitectura de `mcp_tools`, una invocación CLI por herramienta) o cambiar el ejecutor para llamar RPC directamente. + +| Área | Acciones GUI (D, M) | RPC existente | Brecha MCP | Prioridad | +|---|---|---|---|---| +| Git (lectura) | Status, diff, historial, ramas, stashes | `git.*` (solo local), `mobile.git.status/diff/branches` | Brecha. Clave para clientes web (ChatGPT, Claude.ai) que no tienen shell | P2 | +| Git (escritura) | Stage, unstage, discard, commit, amend, fetch, pull, push, sync, stash, checkout, crear rama | `git.*`, `mobile.git.*` | Brecha. Preferible delegar a un agente; si se expone, separado y con `destructiveHint` en discard | P3 | +| Pull requests (lectura) | Snapshot, checks, conversación | `mobile.pullRequest.snapshot/summaries` | Brecha | P2 | +| Pull requests (escritura) | Crear, enlazar, comentar, merge, draft, cerrar, Ship, stacks, restack | `mobile.pullRequest.*`, `linkedReview.*` y CLI de forjas (`gh`, `glab`, `az`) | Brecha. Merge y Ship son de alto impacto | P3 | +| Archivos | Listar o leer, editar, crear, renombrar, mover, borrar | `mobile.workspaceExplorer.list`, `mobile.workspaceFile.read`, `workspace.files.*` | Brecha. Lectura útil; expone código fuente al cliente cloud | P2 (lectura) / P3 | +| Búsqueda y reemplazo; quick open | Buscar o reemplazar en archivos; Mod+P | `mobile.workspaceSearch.*`, `mobile.workspaceQuickOpen.*` | Brecha | P2 (buscar) / P3 (reemplazar) | +| Texto con IA | Mensaje de commit, detalles de PR, títulos | `aiText.*.generate` | Brecha | P3 | +| Comentarios para agentes | Comentar líneas y despachar a un agente | `write`, `agentProfile.launch` | Cubierta en esencia por `write_terminal` y `launch_agent` | — | +| Layout, splits, paneles, vista | Dividir, mover tabs, paneles de contexto | `layout.upsert`, `workbenchViewPrefs.*` | Por diseño: estado de UI | — | +| Settings | AI Assist, Terminal, Editor, Keyboard, Voice, Dictation, Text Actions | `configuration.settings.*`, `runtimeSettings.*` | Por diseño: preferencias del usuario | — | +| Configuration Sync | Revisar y aplicar sincronización en la nube | `configuration.cloud.*`, `configuration.transfer.*` | Por diseño | — | +| Updater, skills, registro del CLI | Actualizar app, instalar skills | `cliRegistration.*`, `agentSkill.install` | Por diseño, salvo las skills: cubiertas desde la rama `feat/mcp-parity` por `check_agent_skills` e `install_agent_skills` (plan §13.2) | — | +| Reading Diff, dictado, text actions | — | FRB local | Por diseño / no aplica | — | + +### 4.9 Seguridad, cuentas y conectividad (por diseño fuera del MCP) + +| Acción | GUI | CLI | MCP | Comentario | Prioridad | +|---|---|---|---|---|---| +| SSH targets: listar o ver estado | D | `ssh-target list/status` | — | Brecha de lectura razonable | P2 | +| SSH targets: alta, baja, bootstrap, link | D | `ssh-target add/remove/bootstrap*/link` | — | Por diseño: credenciales e instalación remota | — | +| Mobile: habilitar, emparejar, dispositivos | D | `mobile …` | — | Por diseño: escalada de acceso | — | +| Cuenta Alera: login, logout, borrar, transferir | D | `account …` | — | Por diseño | — | +| MCP Control: habilitar, apps, revocar | D | `mcp status/enable/disable/apps/revoke` | — | Por diseño: un cliente MCP no debe gestionar su propio acceso. `mcp status` podría exponerse en solo lectura | — | +| Voz: hablar al humano | D, M | `voice speak` | — | Brecha útil para avisar al humano | P3 | +| Voz: estado o carpeta home | D, M | `voice status/ensure` | — | Brecha | P3 | + +## 5. Capacidades parciales: detalle + +1. `start_agent_workspace`. Ver la sección 6. +2. `create_workspace`: no permite padre, `reuseExistingBranch`, sección por id ni ruta. La GUI manual sí los ofrece (`lib/src/features/workbench/presentation/create_workspace_dialog_*.dart`). +3. `launch_agent`: el CLI acepta `--client-mutation-id` (`agent-profile launch`) y la GUI siempre usa `agentProfile.launchIdempotent`, pero el MCP no pasa ninguna clave. Un reintento del cliente tras un timeout puede lanzar dos agentes. +4. `delegate_task`: siempre `--keep-on-failure` y `--timeout-ms` ≤ 50 s. Como `delegate` espera a que el agente acepte, en perfiles lentos el MCP devuelve timeout aunque la tarea siga en curso. +5. `run_automation`: no expone las variantes de la GUI (sin precheck, en cola, en paralelo, continuar desde un run). +6. `list_inbox_threads`: siempre `--inbox ext:mcp`. Es intencional, pero un cliente MCP no puede consultar las preguntas que el usuario hizo desde la GUI. +7. `list_automations`: solo `state`, `projectId` y `search`. +8. `write_terminal`: sin `--file`. No es crítico, porque el texto ya viaja por stdin con un límite de 64 KiB. + +## 6. New Workspace from Prompt: análisis y propuesta MCP + +### 6.1 Cómo lo implementa la UI desktop + +Puntos de entrada (los tres abren `showCreateWorkspaceFlow`): + +- Atajo `Mod+Shift+N` (`KeyboardActionId.createWorkspace`): `lib/src/features/keyboard/application/keyboard_command_dispatcher.dart:73`. El proyecto inicial es el proyecto activo. +- Sidebar (botón y menú del proyecto, "New Workspace"): `lib/src/features/workbench/presentation/project_workbench_sidebar_actions.dart:41`. +- Dashboard de bienvenida: `lib/src/features/workbench/presentation/welcome_dashboard_columns.dart:89`. +- El reintento de un job fallido reabre el formulario con el snapshot guardado: `background_setup_job_host.dart:72-83`. + +Flujo (`workbench_dialog_launchers_create_workspace.dart:32-250`, `prompt_workspace_dialog.dart`, `application/background_setup_jobs.dart:100-260`, `application/prompt_workspace_pipeline.dart`): + +1. **Proyecto**: no se infiere. Se usa `initialProject` (el proyecto activo, o el de la fila del sidebar) o, si falta, el primero de `sortProjectsForSelection` (`prompt_workspace_dialog.dart:208`). El usuario puede cambiarlo. +2. **Perfil**: `settings.agents.defaultAgentProfileId`, guardado en el runtime como `runtimeSettings.defaultAgentProfileId` (`runtime_settings_repository.dart:63`); si no hay, el primero de la lista (`_defaultAgentProfile`, `prompt_workspace_dialog.dart:195`). +3. **Modo**: por defecto en la carpeta del proyecto (`initialUseProjectCheckout: … ?? true`, línea 197). Si el proyecto no es repo Git, fuerza la carpeta del proyecto. +4. **Rama origen** (solo worktree): `projectConfig.newWorkspace.preferredSourceBranch` y luego `pickDefaultSourceBranch` sobre el catálogo de ramas del host (`prompt_workspace_dialog_branch_loading.dart:28-42`). +5. **Sección automática**: activada por defecto (`initialAutoAssignSection: … ?? true`) cuando el runtime soporta secciones y existe al menos una (`hasWorkspaceSections`). +6. **Pipeline** (`PromptWorkspacePipeline.run`), que se ejecuta como job en segundo plano: + 1. `aiText.workspaceIdentity.generate` con `projectId`, `prompt` y `autoAssignSection`. Timeout de 11 min. + 2. Comprueba colisiones de la rama generada contra las ramas de workspaces activos y `git.branchExists` o el catálogo del host. Si choca, repite **una vez** pidiendo "a different workspace name and branch". + 3. `workspace.createShared` o `workspace.createManaged` con `deferSetup: true`, sin abrir terminal (`createWorkspaceForPrompt`, `workbench_controller_workspace_creation.dart:45`). Un error con aspecto de colisión también provoca un reintento. + 4. Si hay `sectionId`, `workspaceSection.setForWorkspace`, en modo best-effort. + 5. `agentProfile.launchIdempotent` con `clientMutationId`; usa `agentProfile.launch` si el host es antiguo (`infra/prompt_workspace_runtime_client.dart:68-125`). + 6. `completePromptWorkspaceCreation`: siembra el panel, abre la tab **Setup** diferida y activa la tab del agente. + 7. Si el lanzamiento falla, el snapshot conserva `created` y `clientMutationId` para reintentar el lanzamiento sin crear otro workspace. + +Mobile repite el mismo pipeline en Dart (`mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart`, mismos dos intentos y la misma lógica de colisión). El setup diferido arranca con `terminal.create` (`deferred_workspace_setup_launcher.dart`). + +### 6.2 Qué está en backend y qué solo en UI + +| Paso | Dónde vive | ¿Reutilizable desde MCP? | +|---|---|---| +| Inferir el proyecto desde el prompt | **No existe** en ningún sitio | No | +| Nombre y rama del workspace | Runtime host: `terminal_host/server/ai_assist_requests.rs:50` y `ai_assist_workspace_identity.rs:35-169` (prompt al agente de AI Assist) | Sí (RPC `aiText.workspaceIdentity.generate`, capability `aiAssistWorkspaceIdentity`) | +| Sección más adecuada con fallback "Others" | Mismo servicio: con `autoAssignSection=true` incluye la lista de secciones; si la respuesta es "Others", desconocida o vacía, no devuelve `sectionId` (`ai_assist_workspace_identity.rs:151-166`) | Sí, pero **el CLI no envía `autoAssignSection`** (`workspace_start.rs:307-316`) | +| Perfil por defecto | Runtime (`runtimeSettings.defaultAgentProfileId`; ya lo usa `voice_home_agent.rs:234`) | Sí, aunque ni el CLI ni el MCP lo usan: el perfil es obligatorio | +| Rama origen preferida | Runtime: `worktree_setup::preferred_source_branch` se aplica en `managed_workspace.rs:139-141` cuando falta `sourceBranch` | Sí, pero **el CLI rechaza la falta de `--source-branch` antes de llegar al host** (`workspace_start.rs:214-220`) | +| Colisiones y reintento de identidad | **Duplicado** en Dart desktop y Dart mobile | No (el CLI no reintenta) | +| Setup diferido en una tab Setup | **Solo UI** (desktop y mobile abren la terminal) | No: el CLI no envía `deferSetup`, así que el setup corre **en línea** (`managed_workspace.rs:49-54`, el comentario lo confirma) | +| Asignar sección | RPC `workspaceSection.setForWorkspace` | Sí (el CLI lo hace solo con `--section` explícito) | +| Lanzamiento idempotente | RPC `agentProfile.launchIdempotent` | Sí, el CLI acepta `--client-mutation-id`, pero el MCP no lo pasa | + +Conclusión: la orquestación de New Workspace from Prompt **no es un servicio backend**. Es lógica de cliente duplicada en desktop (Dart), mobile (Dart) y CLI (Rust, `workspace_start.rs`). Solo la generación de identidad y sección, el perfil por defecto y la rama preferida están en el runtime y se pueden reutilizar tal cual. + +### 6.3 Divergencias de `start_agent_workspace` frente a la UI + +| # | Aspecto | UI (desktop y mobile) | `start_agent_workspace` (`workspace start`) | Impacto | +|---|---|---|---|---| +| D1 | Proyecto | Proyecto activo o el que elige el usuario | `projectId` obligatorio (o `workspaceId` para inferirlo) | El cliente MCP tiene que decidir el proyecto; nadie lo infiere | +| D2 | Sección | Automática por IA con fallback a "Others" (activada por defecto) | Solo `section` explícita por nombre; nunca envía `autoAssignSection` | No replica la UI | +| D3 | Perfil | Perfil por defecto del runtime | `profile` obligatorio | El cliente tiene que elegirlo | +| D4 | Rama origen | `preferredSourceBranch` del proyecto o rama por defecto del catálogo | Obligatoria con `worktree` y `projectId`; el CLI falla antes de que el host aplique su propio default | Fricción y errores evitables | +| D5 | Colisiones | Comprueba la rama y reintenta la identidad una vez | Sin comprobación previa; `createManaged` falla con "already exists" | Fallo donde la UI se recupera sola | +| D6 | Setup del worktree | Diferido a una tab Setup visible, después del lanzamiento | En línea y bloqueante **antes** de lanzar el agente | Más latencia; riesgo de timeout (R1) | +| D7 | Idempotencia | `launchIdempotent` con `clientMutationId` y reintento del lanzamiento sin recrear | El MCP no pasa `--client-mutation-id`; un fallo de lanzamiento deja el workspace creado y devuelve error | Duplicados al reintentar | +| D8 | Padre | Elegible | Siempre `--no-parent` | Falta jerarquía | +| D9 | Modo por defecto | Carpeta del proyecto (`useProjectCheckout = true`) | Carpeta del proyecto salvo `worktree: true` | Igual | +| D10 | Timeout | Job en segundo plano sin límite práctico (identidad hasta 11 min) | 58 s por llamada MCP; el ejecutor mata el CLI hijo (`kill_on_drop`) | Workspace a medio crear posible (R1) | + +### 6.4 Propuesta técnica (no implementada) + +Objetivo: una herramienta MCP, por ejemplo `start_workspace_from_prompt`, que reciba solo `prompt` y, opcionalmente, `projectId`, `profile`, `worktree`, `sourceBranch`, `section` y `hostId`. Debe reproducir exactamente el flujo de la UI sin que el cliente replique lógica, devolver enseguida un `operationId` y permitir seguir el progreso con una herramienta de espera. + +**Recomendación: mover la orquestación al runtime host como un servicio con estado.** + +1. **Nuevo RPC `workspace.startFromPrompt` en el runtime host** (Rust), como operación diferida igual que `aiText.workspaceIdentity.generate`, con registro de operaciones y cancelación. Pasos: + 1. Resolver el proyecto (ver punto 2). + 2. Resolver el perfil: el explícito o `defaultAgentProfileId`; si no hay, el primero. + 3. Resolver la rama origen: la explícita, `preferred_source_branch` o la rama por defecto del repo. + 4. Generar la identidad con `autoAssignSection=true` reutilizando `generate_workspace_identity`. + 5. Comprobar colisiones con el mismo criterio que la UI y reintentar una vez con el sufijo de reintento. + 6. `createManaged` o `createShared` con `deferSetup: true`. + 7. Asignar la sección en modo best-effort. + 8. Lanzar con `launchIdempotent` usando un `clientMutationId` derivado del `operationId`. + 9. Persistir un registro con `created`, `clientMutationId` y la fase, para reanudar o reintentar el lanzamiento sin recrear. + + El setup diferido queda como tab Setup con su comando, que la app abre al sincronizar. Para un cliente sin UI hace falta decidir si el host arranca la terminal de setup por sí mismo (como hace mobile con `terminal.create`), lo cual es recomendable para que el workspace quede igual que desde la UI. +2. **Inferencia de proyecto** (nueva y opcional cuando falta `projectId`): ampliar el prompt de identidad con la lista de proyectos (nombre y ruta, acotados como se hace hoy con las secciones) y un campo `project` validado contra esa lista sin distinguir mayúsculas. Si falla, usar el **proyecto del workspace más reciente** (`workspaceActivity`) o, si hay un solo proyecto, ese. Si sigue siendo ambiguo, devolver error con los candidatos en vez de adivinar. Coste: una sola llamada de IA, porque proyecto, nombre, rama y sección se generan juntos. El proyecto debe resolverse antes de calcular colisiones y rama origen, así que la respuesta se valida en ese orden. +3. **CLI**: añadir `alera workspace start-from-prompt` (o `workspace start --auto` y `--auto-section`), que llame al RPC y espere con `--wait` o devuelva el `operationId`. Además, `workspace start` debería dejar de exigir `--source-branch` y delegar el default al host (corrige D4 también para el MCP actual). +4. **MCP**: dos herramientas: + - `start_workspace_from_prompt` (execute): devuelve `operationId` y la fase inicial en menos de 50 s. + - `wait_for_workspace_start` (read): espera hasta 50 s y devuelve la fase, el `workspaceId` o el `tabId`, o el error. + + Así se cumple el contrato de esperas acotadas del catálogo (`MAX_WAIT_SECONDS`) y se elimina R1. Hay que regenerar `edge/src/mcp/tool_catalog.json`. +5. **GUI desktop y mobile**: migrar progresivamente `PromptWorkspacePipeline` a un cliente fino del nuevo RPC, conservando los fallbacks para hosts antiguos (detección por capability, como hoy con `launchIdempotent`). Así hay una sola implementación y el MCP queda idéntico a la UI por construcción. + +**Alternativa de menor coste (paso intermedio)**: dejar la orquestación en el CLI y corregir `workspace_start.rs`: +- enviar `autoAssignSection` cuando no haya `--section` explícita y asignar el `sectionId` devuelto +- reintentar la identidad ante colisión +- no exigir `--source-branch` +- usar `deferSetup: true` y arrancar la terminal de setup +- el MCP pasaría un `--client-mutation-id` +- el perfil sería opcional, tomando el del runtime + +Resuelve D2 a D5, D7 y parte de D6, pero no D10 (sigue siendo síncrono y limitado a 58 s) ni D1, y añade una tercera copia de la lógica de colisiones. Recomendable solo como mitigación rápida. + +**Prioridad recomendada**: + +1. **P1**: corregir los defaults de `workspace start` y la idempotencia del MCP. Es pequeño y reduce fallos ya. +2. **P1**: RPC `workspace.startFromPrompt` con operación asíncrona, más las dos herramientas MCP. +3. **P2**: inferencia de proyecto en el mismo prompt de identidad. +4. **P2**: migrar desktop y mobile al RPC para eliminar la duplicación. + +Pruebas a añadir en cualquier implementación: + +- paridad de sección ("Others" o una desconocida deja el workspace sin sección) +- colisión con reintento +- reintento del lanzamiento sin recrear el workspace +- timeout del cliente MCP sin dejar un workspace huérfano +- host antiguo sin capability + +## 7. Riesgos + +| # | Riesgo | Evidencia | Severidad | Mitigación sugerida | +|---|---|---|---|---| +| R1 | `start_agent_workspace` puede agotar su timeout de 58 s mientras genera la identidad con IA (el CLI le da hasta 11 min) o mientras corre el setup en línea. El ejecutor mata el CLI hijo, y el workspace puede quedar creado sin agente o la creación seguir en el host sin que el cliente lo sepa. **Es una inferencia del código; no la reproduje.** | `mcp_tools/catalog_execute.rs:11` (`LAUNCH_TIMEOUT` = 58), `executor.rs:92,122-130`, `workspace_start.rs:13`, `managed_workspace.rs:49-54` | Alta | Operación asíncrona con `operationId` (6.4), o como mínimo `deferSetup` e idempotencia | +| R2 | El MCP no envía claves de idempotencia en `start_agent_workspace` ni en `launch_agent`; un reintento tras timeout o error duplica workspaces o agentes | `catalog_execute.rs` (no usa `--client-mutation-id`) | Media-alta | Derivar un `clientMutationId` del id de la llamada MCP o aceptarlo como argumento | +| R3 | `alera mcp serve` local expone todas las herramientas de ejecución salvo `--read-only` e ignora `settings.mcp.access`, aunque MCP Control esté en "Off" | `mcp_commands.rs:52`, `stdio_server.rs:144` | Media. Es coherente con que el usuario local ya tiene el CLI, pero sorprende | Documentarlo, o respetar `access` también en local con un flag de override | +| R4 | `write_terminal` permite escribir en **cualquier** terminal, incluidas las shells del usuario: en acceso full equivale a ejecutar comandos arbitrarios | `catalog_execute.rs:209` | Alta (por diseño) | Opción para restringirlo a terminales con agente, y avisarlo en la UI de MCP Control | +| R5 | `list_tabs` y `show_terminal` devuelven el payload completo de lanzamiento (argv del perfil, por ejemplo `--permission-mode bypassPermissions`, ids de sesión) a clientes cloud. En la muestra no encontré claves ni tokens | Salida de `alera tab --json list` | Baja-media | Filtrar el payload en la proyección MCP (`omit_fields`) | +| R6 | Desfase entre el catálogo estático del edge y la versión del runtime: una herramienta nueva aparece en el edge y falla con `tool_unavailable` en runtimes antiguos | `relay_mcp.rs:218-223`, `edge/src/mcp/tools.ts` | Baja | Ya mitigado con un mensaje claro; versionar el catálogo por runtime si crece | +| R7 | `cancel_task` siempre cancela de forma administrativa (`--force`) | `catalog_execute.rs:300` | Baja (auditado con el prefijo `[mcp]`) | Mantenerlo marcado como destructivo | +| R8 | `delegate_task` espera la aceptación hasta 50 s; con perfiles lentos devuelve error aunque la tarea siga viva (`--keep-on-failure`) | `catalog_execute.rs:158` | Media | Devolver la tarea creada aunque no haya aceptación y seguirla con `wait_for_task` | +| R9 | Exponer lectura de archivos, git o PRs (P2) envía código fuente a clientes cloud | — | Media | Herramientas separadas, solo en acceso full o con un scope dedicado | + +## 8. Hallazgos colaterales (fuera del MCP) + +1. **Bug probable en mobile**: Recovery llama a `workspace.runSetup` (`mobile/lib/src/features/runtime/infra/mobile_runtime_recovery_client.dart:111`), pero ese método **no está** en `mobile_request_allowed` (`terminal_host/server/mobile_gateway_surface.rs:180-182` solo incluye `prepareRelocationSetup`, `recoverRelocationSetup` y `cancelRelocationSetup`), y el handler lo comprueba (`deferred_workspace_setup.rs:19`). Desde el teléfono, "Run Saved Setup" debería fallar con "Mobile clients cannot call terminal host request". El From Prompt de mobile no se ve afectado, porque usa `terminal.create`. +2. `codex.*` aparece en la barrera de mutaciones (`runtime_mutation_barrier.rs:99`) pero no tiene handler: es código muerto. +3. Varios verbos están permitidos para mobile pero la app no los usa (`aiAssist.complete`, `workspace.storageImpact`, `automation.purge`, entre otros): es superficie expuesta sin uso. + +## 9. Recomendación priorizada + +**P1** + +1. Corregir los defaults de `workspace start` y la idempotencia del MCP; después, el RPC `workspace.startFromPrompt` asíncrono con `start_workspace_from_prompt` y `wait_for_workspace_start` (sección 6.4). Resuelve R1 y R2. +2. Secciones: `list_sections`, `set_workspace_section` y `clear_workspace_section`. +3. `rename_workspace`. +4. `list_project_branches` (requiere un comando CLI nuevo sobre `project.branches.list`). +5. `show_workspace_issue` y Watch and Fix: `show_pr_watch`, `start_pr_watch`, `stop_pr_watch`. +6. Automations: `show_automation`, `show_automation_run`, `cancel_automation_run`. +7. `cancel_question` del inbox. + +**P2** + +8. Ciclo de vida de workspaces: `archive/unarchive`, `remove_workspace` (destructivo, con preflight), `hand_off/hand_on`, tags y relaciones. +9. Tabs y terminales: `create_tab`, `close_tab`, `restart_terminal`. +10. Lectura de git, PR, archivos y búsqueda (requiere CLI nuevo; ver R9). +11. `version`, cuotas de agentes, snapshot de recursos y `ssh-target list/status`. +12. Ampliar los parámetros de `create_workspace`, `launch_agent`, `run_automation` y `list_automations`. +13. Decision gates y run policy en solo lectura; gestión de automations (pausar, reanudar, crear o editar, readiness, preview). + +**P3**: el resto de la sección 4. + +**No exponer**: cuenta, mobile pairing, gestión de MCP Control, alta y bootstrap de SSH, `runtime clear`/`stop`, `orchestration reset`, purgas y decisiones humanas (gates, run policy, aprobación de workflows), salvo con un mecanismo explícito de confirmación humana. + +## 10. Método + +- Catálogo MCP: lectura completa de `rust/alera-cli/src/mcp_tools/*`, `mcp_commands.rs`, `mcp_settings.rs`, `terminal_host/relay_mcp.rs` y `edge/src/mcp/*`. Comprobé que `alera mcp --json tools` y `edge/src/mcp/tool_catalog.json` tienen las mismas 31 herramientas. +- CLI: recorrido recursivo de `alera --help` (unos 200 subcomandos) y de las opciones de cada comando que el MCP envuelve. +- GUI: inventario de acciones de `lib/` (registro `KeyboardActionId`, menús, diálogos, controladores y repositorios) y de `mobile/lib`, más el despachador RPC del host (`terminal_host/server/*`) y su allowlist mobile. +- New Workspace from Prompt: lectura directa de los archivos citados en la sección 6. +- Limitaciones: es un análisis estático y no ejecuté ninguna herramienta MCP. R1 es una inferencia del código. Los números de línea corresponden a `c8b3d3984`. + +## 11. Comunicación bidireccional y eventos con clientes MCP externos + +Esta sección se añadió a pedido de un cliente MCP externo. La pregunta: cómo hacer más fluida la coordinación (ask_agent, avisos de respuesta, estados de entrega y lectura, push en lugar de polling), qué soporta hoy el servidor, qué pueden recibir de verdad los clientes y cómo lo resuelve EducUp con "MCP Events". Es solo investigación. + +### 11.1 Qué soporta hoy el servidor MCP de Alera (verificado en código) + +| Capacidad MCP | `alera mcp serve` (stdio) | Edge `/v1/mcp` (remoto) | Evidencia | +|---|---|---|---| +| Versiones de protocolo | `2025-11-25`, `2025-06-18`, `2025-03-26` | Las mismas | `mcp_tools/stdio_server.rs:15`, `edge/src/mcp/protocol.ts` | +| Capabilities anunciadas | Solo `tools: { listChanged: false }` | Igual | `stdio_server.rs:165`, `edge/src/mcp/endpoint.ts:121` | +| Métodos | `initialize`, `ping`, `tools/list`, `tools/call` | Igual | `stdio_server.rs:65-125`, `endpoint.ts:117-137` | +| Notificaciones cliente → servidor | Solo `notifications/cancelled` (mata el proceso hijo) | Toda notificación responde `202` y se descarta; la cancelación real ocurre al abortarse el HTTP (`mcp.cancel` al runtime) | `stdio_server.rs:56-63`, `endpoint.ts:195`, `relay_calls.ts:128-131,154-162` | +| Notificaciones servidor → cliente (`progress`, `resources/updated`, `tools/list_changed`, `message`) | Ninguna | Ninguna | No existe el código | +| Peticiones servidor → cliente (elicitation, sampling, roots) | Ninguna | Ninguna | No existe el código | +| Resources, prompts, `resources/subscribe` | No | No | `-32601 Method not found` | +| Streaming (SSE en POST o GET), `Mcp-Session-Id`, reanudación con `Last-Event-ID` | No; JSON línea a línea | No: "stateless Streamable HTTP", `GET` y `DELETE` responden `405`, solo `application/json` | `endpoint.ts:35,146-148`, `docs/remote-mcp.md` (Edge MCP Endpoint) | +| Llamadas largas | Esperas de 50 s como máximo (`MAX_WAIT_SECONDS`); el cliente vuelve a llamar | Igual, y la llamada al DO tiene tope de 10 min | `mcp_tools/mod.rs:28`, `relay_calls.ts:7` | +| `structuredContent` | No; el resultado es texto JSON | Solo en `list_runtimes` (`toolJson`); las herramientas del runtime devuelven texto | `executor.rs` (`to_mcp`), `protocol.ts` | + +Conclusión: es un servidor de **solo tools, request/response y sin estado**. La única forma de "esperar" eventos es el polling acotado (`wait_for_reply`, `wait_for_task`, `wait_for_terminal`). El diseño es deliberado: `docs/remote-mcp.md` dice que el edge "is stateless: it builds no MCP session" y que las esperas se limitan porque los clientes hospedados abandonan una llamada HTTP al cabo de un minuto. + +### 11.2 Ciclo de vida de `ask_agent` y correlación de IDs + +- **Alta**: `ask_agent` ejecuta `inbox ask --inbox ext:mcp`. La pregunta se guarda como mensaje de orquestación con prioridad `high` por defecto. Caduca a las 5 h si nunca se entrega (configurable entre 60 s y 7 días) y cada destinatario admite como mucho 20 preguntas sin entregar (`alera-core/src/runtime/inbox_models.rs:9-13`). +- **Entrega**: se inyecta en el terminal del agente cuando termina su turno, como un bloque "Orchestration Messages" con la instrucción `alera orchestration reply --id …`. Al entregarse queda `delivered_at`. +- **Estados**, por orden de precedencia (`inbox_queries.rs:243-258`): + - `cancelled` + - `answered`: llegó un `reply` con `reply_to_id` + - `expired`: caducó sin entregarse + - `delivered`: pegada en el terminal + - `received`: marcada como leída antes de pegarse, por ejemplo con `orchestration check` + - `pending` +- **Correlación**: + - `questionId` es el id del mensaje y `threadId` el id de la pregunta raíz. + - Las repreguntas usan `threadId` y las respuestas llevan `reply_to_id`. + - `wait_for_reply` devuelve el hilo completo, los mensajes nuevos posteriores a `after` y un `cursor` (secuencia monotónica). + - Resultados posibles: `answered`, `message`, `cancelled`, `expired`, `purged` y `timeout` (`terminal_host/server/inbox_wait.rs`). +- **Fiabilidad de la espera**: es **reanudable sin pérdidas**. El estado vive en la base del runtime y el cursor es una secuencia. Si una respuesta HTTP se pierde, repetir la llamada con el mismo `after` devuelve lo mismo. Al vencer el plazo se relee la base, así que no se pierde una respuesta que llegue en el límite. +- **Lectura**: no hay acuse explícito desde MCP. `wait` y `show` marcan como leído lo que devuelven (`mark_returned_read`), lo que actualiza `unread_reply_count`. +- **Avisos que ya existen**: + - Cada respuesta a un inbox externo encola un push para el móvil del usuario (`PushEvent::inbox_reply`, `push_delivery.rs:110-124`). + - Los clientes locales reciben el evento `inboxChanged` con una revisión (`inbox_requests.rs:72-75`). + - Ninguno de los dos llega a un cliente MCP. + +Brechas detectadas: + +| # | Brecha | Efecto | Prioridad | +|---|---|---|---| +| C1 | Todos los clientes MCP de la cuenta comparten el inbox `ext:mcp`, y `external_meta.origin` no guarda el cliente OAuth, el grant ni la conversación (el CLI hijo corre como cliente local y `executor.rs` borra el contexto) | ChatGPT puede listar y leer los hilos de Claude.ai y viceversa; el agente ve "from ext:mcp" sin saber quién pregunta | P1 | +| C2 | El RPC `inbox.wait` admite esperar un inbox entero, pero el MCP solo expone la espera por `questionId` | Con varias preguntas abiertas, el cliente tiene que sondear cada una | P1 | +| C3 | No hay `cancel_question` ni acuse de lectura explícito | Sin forma de retirar una pregunta o confirmar que se procesó | P1 | +| C4 | `ask_agent` no admite clave de idempotencia | Un reintento tras un timeout duplica la pregunta | P1 | +| C5 | Una pregunta `delivered` sin respuesta no caduca; solo caducan las no entregadas, y solo cuenta como respuesta un `orchestration reply` | Si el agente la ignora, el cliente espera indefinidamente | P2 | +| C6 | Resultados como texto, sin `structuredContent` | Encadenar `cursor` y `questionId` depende de que el modelo lea bien el JSON | P2 | +| C7 | Cada sondeo pasa por edge, nube, DO y runtime, y lanza un proceso CLI | Coste y límite de tasa por token (`MCP_LIMITER`) | P3 | + +### 11.3 Qué pueden recibir los clientes (soporte de cliente) + +- **Especificación MCP**: + - Hasta `2025-11-25`, las notificaciones servidor → cliente (`notifications/resources/updated` tras `resources/subscribe`, `progress`, `list_changed`) solo llegan por una conexión o stream abierto (stdio, o SSE en Streamable HTTP con sesión). + - La versión `2026-07-28` [elimina las sesiones a nivel de protocolo](https://blog.modelcontextprotocol.io/posts/2026-07-28/), añade `server/discover` y mueve las notificaciones de cambio a un único stream `subscriptions/listen`. + - También reemplaza la elicitation iniciada por el servidor por Multi Round-Trip Requests (`resultType: "input_required"`), pasa Tasks a la extensión `io.modelcontextprotocol/tasks` (con `tasks/get` por polling) y depreca Roots, Sampling y Logging. + - **Una notificación MCP no inicia por sí misma un turno del modelo.** Que el modelo "despierte" depende de cada cliente. +- **ChatGPT**: + - Consume tools y MCP Apps. + - Según la documentación de un gateway externo, "doesn't currently consume arbitrary MCP prompts, roots, sampling, or elicitation" ([Zuplo](https://zuplo.com/docs/mcp-gateway/connect-clients/chatgpt.md)). + - Una respuesta en la [comunidad de OpenAI](https://community.openai.com/t/does-chatgpt-apps-support-reading-standard-mcp-resources/1384027) indica que los resources estándar no llegan al agente. + - No encontré ninguna fuente que confirme soporte de `resources/subscribe`, de notificaciones o de `progress`. + - Para avisar a ChatGPT de forma asíncrona está [OpenAI MCP Events](https://developers.openai.com/plugins/build/mcp-events): es **solo webhook** (polling, streaming y SSE "are not part of this integration"), exige MCP `2026-07-28`, un plugin y almacenamiento persistente de suscripciones, y funciona en Work chats. Según OpenAI se basa en un "draft MCP Events design sketch", así que no es parte del estándar. ChatGPT procesa el evento de forma asíncrona como una ejecución de tarea en el chat suscrito. +- **Otros clientes** (Claude.ai, Claude Desktop, Claude Code, Cursor): no verifiqué en esta investigación si reaccionan a notificaciones o suscripciones. Hay que tratarlo como desconocido hasta probarlo. +- Conclusión: **hoy ningún mecanismo MCP estándar despierta a ChatGPT.** El polling acotado funciona en todos los clientes. MCP Events es la única vía de push hacia ChatGPT, y no es estándar ni está probada de extremo a extremo (ver 11.4). + +### 11.4 Referencia local: "MCP Events" de EducUp (no estándar) + +Fuente: `~/Projects/educup/educup-automations` (commit `77b23b0`, 2026-10-03), `docs/mcp-events.md`, `docs/mcp-events-implementation.md`, `src/services/educup_mcp/events.py`, `src/shared/mcp_events/` y las Lambdas `mcp_event_publisher`, `mcp_event_delivery` y `mcp_event_reconciler`. + +- **Contrato**: el de OpenAI MCP Events. + - `events.py` registra los métodos propios `events/list`, `events/subscribe` y `events/unsubscribe` en el SDK de Python. + - Inyecta `capabilities.events` en la respuesta de `server/discover` solo con `2026-07-28`. + - Un único evento, `approval.outcome`, con un payload mínimo (`approval_id`, `operation_id`, `status`, `attempt`, `receipt_version`). El evento solo es una pista: la verdad se lee con la tool `operation_status`. +- **Arquitectura**: + - El publicador deriva eventos deterministas del stream de Operations. + - Tres tablas DynamoDB (suscripciones, eventos y recibos de entrega), con TTL de 24 h. + - SQS solo transporta referencias. + - El worker de entrega reclama con un lease de 45 s, revalida el usuario, su rol y la familia OAuth, y firma con Standard Webhooks. + - Un reconciliador por minuto rellena huecos y reencola entregas. +- **Seguridad y reintentos**: + - Callbacks solo por HTTPS:443, con resolución DNS a IPs públicas fijadas, sin redirecciones y con un challenge firmado. + - Secretos cifrados con KMS. + - Plazo de 10 s por intento y reintentos exponenciales de 5 s a 15 min, hasta 12 intentos. + - `410` detiene la suscripción y `413` deja el recibo como muerto. +- **Reanudación**: un cursor opaco reproduce todo el historial retenido (24 h) y los recibos evitan reenviar lo ya acusado. Un cursor más antiguo responde `truncated: true`. +- **Estado real**: activado el 2026-09-30. ChatGPT muestra `approval.outcome` entre sus "Tools and events" y llegó a llamar a `events/subscribe`. **Todavía no se ha observado** un callback real con continuación en el chat, y el seguimiento por defecto "has not yet been demonstrated". Por eso se mantiene el flujo de consulta manual. +- **Lecciones para Alera**: + 1. Es una inversión de infraestructura considerable (tablas, colas, reconciliador, KMS, defensas SSRF). + 2. El payload debe ser mínimo y llevar a una tool de lectura. + 3. La reanudación por cursor con recibos es imprescindible. + 4. Hay que conservar el polling como camino principal hasta demostrar la continuación en el cliente. + +### 11.5 Propuesta, separando servidor y cliente + +**Servidor (Alera), por prioridad:** + +- **P1: mejoras que sirven a cualquier cliente sin protocolo nuevo**: + 1. **Identidad y aislamiento por cliente**: + - Pasar al CLI hijo, con una variable propia y no las de `CONTEXT_VARIABLES`, los claims de la llamada que ya verifica `relay_mcp.rs` (`clientId`, `clientName`, `grantId`). + - Usar un inbox por grant (`ext:mcp-`) o filtrar por origen. + - Guardar `origin: { surface: "mcp", clientName }` para que el agente sepa quién pregunta. + - Resuelve C1. + 2. **`wait_for_inbox`**: espera a nivel de inbox con `cursor`, sobre el RPC `inbox.wait` que ya existe. Una sola espera cubre todas las preguntas del cliente (C2). + 3. **`cancel_question` y `mark_thread_read`**, sobre `inbox cancel` e `inbox read` (C3). + 4. **Idempotencia**: un `clientRequestId` opcional en `ask_agent`, y también en `launch_agent` y `start_agent_workspace` (C4 y R2). + 5. **`structuredContent`** con `outcome`, `cursor`, `questionId`, `threadId` y `nextAction` en las herramientas de espera (C6). +- **P2: servicio de las preguntas**: + 6. Plazo de respuesta para preguntas entregadas (`answerBy`), con un estado `stale` y un recordatorio automático al agente (C5). + 7. Exponer el estado "el agente está trabajando" (agent presence) en `show_inbox_thread`. +- **P2: MCP estándar con streaming, útil para clientes locales o IDE y no para ChatGPT**: + 8. En `mcp serve` (stdio), anunciar `resources` con `subscribe` para `alera://inbox/` y `alera://question/`, y emitir `notifications/resources/updated` a partir del evento `inboxChanged` del host. Requiere que el servidor mantenga una conexión de eventos con el runtime; hoy lanza un CLI por llamada. + 9. `notifications/progress` en las herramientas largas cuando el cliente envía `progressToken`. + 10. Migrar el edge a `2026-07-28`. Encaja con su diseño sin sesiones, y `server/discover` es requisito previo para MCP Events. Lo siguiente sería evaluar la extensión Tasks como sustituto estándar de `wait_*`. +- **P3 (o P2 si ChatGPT confirma la continuación)**: implementar **OpenAI MCP Events** en la nube y el edge, no en el runtime. + - **Eventos**: `inbox.reply` (`runtimeId`, `threadId`, `questionId`, `cursor`), `orchestration.task.state`, `automation.run.finished` y `workspace.start.finished`. + - **Fuente**: el runtime ya genera `PushEvent::inbox_reply` para las respuestas a inboxes externos y lo envía a la nube para el push móvil (`push_delivery.rs:110-124`). La nube haría el fan-out a las suscripciones webhook en Postgres, con recibos, reintentos, Standard Webhooks, defensas SSRF y una ventana de reanudación de 24 h. + - **Payload**: solo identificadores. El cliente continúa con `show_inbox_thread` o `wait_for_reply` usando el cursor. + - **Requisitos**: protocolo `2026-07-28` y `server/discover` en el edge, un plugin de ChatGPT y Work chats. + - **Riesgo**: la continuación en el cliente no está demostrada (11.4). +- **Alternativa no MCP**: webhooks salientes genéricos configurados por el usuario (Slack, n8n, un endpoint propio) para respuestas del inbox y fin de tareas o automations. Usaría la misma infraestructura de fan-out y no depende de ningún cliente MCP. + +**Cliente (lo que debe soportar el cliente MCP):** + +| Mecanismo | Qué necesita el cliente | Estado conocido | +|---|---|---| +| Polling acotado (`wait_*` + cursor) | Solo tools | Funciona en todos los clientes, incluido ChatGPT (esta conversación lo demuestra) | +| `resources/subscribe` y notificaciones | Conexión o stream persistente y lógica para reaccionar | No soportado o sin confirmar en ChatGPT; sin verificar en el resto | +| `subscriptions/listen` (`2026-07-28`) | Cliente con la versión nueva | Sin verificar | +| Elicitation y MRTR | Formularios en el cliente | ChatGPT no consume la elicitation estándar (tiene una extensión propia, `openai/elicitation/create`, según fuentes de terceros) | +| OpenAI MCP Events | ChatGPT Work chats, plugin y tarea activada por eventos | Disponible según OpenAI; continuación real sin demostrar (EducUp) | + +**Fiabilidad ante desconexiones (estado actual):** + +| Escenario | Qué pasa | +|---|---| +| El cliente se desconecta durante `wait_*` | La llamada se cancela (`mcp.cancel` o fin del stdin); no se pierde nada porque el estado vive en el runtime; se reanuda con el mismo `cursor` | +| Runtime offline | `runtime_offline`; las preguntas pendientes siguen en la base local | +| Reinicio del runtime durante una espera | Se pierden las esperas en memoria; el cliente vuelve a llamar con el cursor y recupera lo nuevo | +| Reintento de `ask_agent`, `launch_agent` o `start_agent_workspace` | Duplica la operación (C4, R2) | +| Webhook caído (si se implementa MCP Events) | Hace falta reanudar por cursor con retención y recibos, como EducUp | + +**Recomendación priorizada:** + +1. P1, unos 2-4 días de trabajo estimados: C1 a C4 y C6 (identidad por cliente, `wait_for_inbox`, `cancel_question`, idempotencia, `structuredContent`). Mejora la coordinación en todos los clientes ya. +2. P2: plazo de respuesta y recordatorios (C5); `resources/subscribe` y `progress` en stdio para clientes locales; migrar el edge a `2026-07-28`. +3. P3, condicionado: OpenAI MCP Events reutilizando el flujo push existente, solo cuando EducUp (u otra prueba) demuestre que ChatGPT continúa el chat al recibir un callback. Hasta entonces el polling acotado sigue siendo el camino principal. + +Fuentes externas consultadas el 2026-10-10: + +- [OpenAI: MCP Events](https://developers.openai.com/plugins/build/mcp-events) +- [MCP Blog: The 2026-07-28 Specification](https://blog.modelcontextprotocol.io/posts/2026-07-28/) +- [Zuplo: Connect ChatGPT](https://zuplo.com/docs/mcp-gateway/connect-clients/chatgpt.md) +- [OpenAI Community: standard MCP resources in ChatGPT Apps](https://community.openai.com/t/does-chatgpt-apps-support-reading-standard-mcp-resources/1384027) diff --git a/docs/mcp-parity-implementation-plan.md b/docs/mcp-parity-implementation-plan.md new file mode 100644 index 000000000..9ee841b3b --- /dev/null +++ b/docs/mcp-parity-implementation-plan.md @@ -0,0 +1,997 @@ +# Plan de implementación: paridad máxima del MCP de Alera + +Fecha: 2026-10-10. Base de código: `c8b3d3984` (v0.104.1). Documento de partida: [mcp-capability-gap-audit.md](mcp-capability-gap-audit.md). Es **solo un plan**: no se implementó código. + +Estructura: spec → diseño → tareas por fases y PRs → pruebas → supuestos y decisiones residuales. + +**Revisión 2 (2026-10-10).** Incorpora las cuatro decisiones finales F1-F4, tomadas tras la revisión crítica, y las correcciones C1-C8 de esa revisión. El resumen de cambios está en la sección 0.1. + +## 0. Decisiones del usuario (vinculantes) + +| # | Decisión | Cómo la aplica este plan | +|---|---|---| +| U1 | Añadir un nivel **Admin** separado de Off, Read y Full. Los grants actuales no reciben admin automáticamente | Nivel `admin` en el runtime (MCP Control) y scope OAuth `mcp:admin`, con doble barrera como hoy (sección 3) | +| U2 | Sin preview, confirmToken ni aprobaciones externas para lo destructivo; flujo de la UI y confirmaciones normales del cliente dentro de los scopes | Las herramientas destructivas llevan `destructiveHint: true` y ejecutan el flujo de la UI en una sola llamada | +| U3 | Borrar workspaces exactamente como la UI, con la opción de conservar o eliminar la rama y sin preguntas extra; los cambios locales siguen la semántica de la UI | `remove_workspace` replica el lanzador desktop (sección 6.3) | +| U4 | Excluir el plano de control de seguridad, el consentimiento OAuth, el emparejamiento de dispositivos y el escalado de privilegios | Lista de exclusiones en 2.3, impuesta por un test | +| U5 | CRUD de automations y agent profiles con FULL; las automations pueden nacer activas; las decisiones humanas de sí/no solo desde la UI de Alera | Gates, run policy y decisiones de workflows quedan excluidos, también con test | +| U6 | No exponer Explorer, Search, la gestión general de Source Control ni la lectura de archivos o diffs. Sí el grupo PR: Restack, Ship Changes, Watch and Fix, Merge y las acciones de PR necesarias | Fase 7 | +| U7 | Buzón **compartido** entre clientes MCP, con atribución del cliente de origen y destino, filtros e identificación; historial compartido | Fase 5 (sección 6.4) | +| U8 | Todos los mecanismos de notificación viables: polling robusto como fallback, MCP Events con go/no-go tras una prueba real, webhooks genéricos y suscripciones locales. Los eventos llevan IDs y el detalle se recupera con tools | Fase 8 (sección 6.5) | +| U9 | Servicio backend asíncrono y reutilizable de New Workspace from Prompt, con inferencia de identidad, sección y proyecto, fallback a "Others" y ambigüedad explícita; máxima equivalencia con la UI y worktree cuando corresponda | Fase 1 (sección 6.1) | +| U10 | Política de `mcp serve` local sin acordar | Default elegido en la sección 11 (no bloquea) | +| F1 | Full puede borrar workspaces y proyectos, hacer merge de PR y de stacks, usar fixAndMerge y purgar automations. Admin queda para la administración avanzada del runtime y las operaciones internas sensibles (opción B). Sin confirmaciones fuera del flujo de la UI | Sección 3.2; sustituye la asignación anterior de Admin | +| F2 | Agent Profiles: Full solo lista, consulta y **usa** perfiles existentes. Crear, editar, borrar, reordenar o cambiar cualquier configuración (incluidas reduced protections y el perfil por defecto) requiere Admin | Sustituye la parte de perfiles de U5 | +| F3 | Pull requests en GitHub, GitLab y Azure DevOps dentro del alcance completo, reutilizando la implementación del desktop cuando proceda | Fase 7 rehecha (sección 6.4) | +| F4 | Prueba real de extremo a extremo de OpenAI MCP Events con la integración de Alera ya conectada en ChatGPT. Se verifican los requisitos de protocolo, plugin y permisos sin suponer que la conexión actual soporta eventos. Se mantiene el polling como fallback | Fase 8: las tareas 8.0 y 8f son obligatorias | + +### 0.1 Cambios de la revisión 2 + +- **Admin reducido (F1):** pasan a Full `remove_workspace`, `remove_project`, `merge_pull_request`, `merge_pull_request_stack`, el seguimiento `fixAndMerge` y `purge_automations`. Ship y watch vuelven a ser una sola herramienta cada uno. +- **Perfiles (F2):** `create_agent_profile`, `update_agent_profile`, `remove_agent_profile`, `reorder_agent_profiles` y `set_default_agent_profile` pasan a Admin. `launch_agent` sigue en Full. Desaparece la regla especial de `confirmReducedProtections`, porque toda mutación de perfiles ya es Admin. +- **PR multi-forja (F3):** la antigua fase opcional 7e entra en el alcance. El runtime tendrá una abstracción `ForgeProvider` con GitHub, GitLab y Azure DevOps, Watch and Fix y Ship para las tres forjas, y fixtures compartidas con las pruebas del desktop. +- **MCP Events (F4):** nueva tarea 8.0 de verificación de requisitos con la conexión existente, que se adelanta al inicio del proyecto. La prueba E2E 8f es obligatoria. +- **Correcciones C1-C8:** + - los eventos solo se reenvían a la nube si hay suscripciones activas; + - el diario de eventos se escribe antes del gate de push; + - sin AI Assist, se devuelve el mismo error que la UI (se elimina la identidad determinista); + - el prompt se guarda en claro hasta completar la operación y después solo su hash; + - los recibos de idempotencia viven en el host; + - error de step-up para herramientas Admin; + - sin doble tab Setup durante la migración; + - las previews de borrado son opcionales. +- **Estimación:** pasa de unos 76 a unos 90 días-persona (sección 8). + +## 1. Spec + +### 1.1 Objetivo + +Que cualquier capacidad de la GUI (desktop y mobile) o del CLI de Alera que cambie o lea estado del runtime esté disponible por MCP, en local (stdio) y en remoto (edge). Las únicas excepciones son las de U4, U5 y U6 y el estado puramente de presentación. Toda herramienta debe: + +- tener la misma semántica que la UI +- ser idempotente cuando muta +- ser atribuible al cliente que la usa +- cumplir el límite de tiempo de los clientes hospedados + +### 1.2 Criterios de éxito globales + +1. La matriz de la sección 2 queda cubierta al 100%: cada fila "Incluida" tiene herramienta, comando CLI, test de catálogo y al menos un test de comportamiento. +2. Ninguna herramienta excede 58 s. Las operaciones largas devuelven un `operationId` y se siguen con esperas acotadas. +3. Toda mutación acepta `clientRequestId`; reintentar con la misma clave no duplica efectos. +4. Admin no se concede a grants existentes; un test de contrato en la nube lo cubre. +5. Un test falla si alguna herramienta invoca un grupo o verbo excluido. +6. New Workspace from Prompt produce por MCP el mismo resultado que la UI (nombre, rama, sección o "Others", setup, agente), y la UI desktop y mobile pasan a usar el mismo servicio. +7. Un cliente MCP se entera de las respuestas del inbox y de los cambios de estado sin sondear cada pregunta: `wait_for_events` o `wait_for_inbox` en todos los clientes, suscripción en stdio, webhooks, y MCP Events en ChatGPT si supera el go/no-go. + +### 1.3 No objetivos + +- Explorer, búsqueda o reemplazo, quick open, editor, lectura de archivos o diffs, Reading Diff y Source Control general (stage, commit, push, ramas, stash, historial) (U6). +- Plano de control de seguridad (U4) y decisiones humanas (U5). +- Estado de presentación: layout, splits, paneles, preferencias de vista, tema, atajos, zoom. +- Cambiar los protocolos estrictos terminal-host o mobile. Todo es aditivo, con capabilities. + +## 2. Inventario de cobertura (GUI y CLI → MCP) + +Leyenda: + +- **Nivel**: R = Read, F = Full (ejecución), A = Admin. +- **CLI**: `existe` = comando actual; `nuevo` = comando a crear; `ampliar` = flags nuevos. +- **Fase**: ver la sección 8. +- Las herramientas existentes conservan su nombre y su contrato, con ampliaciones solo aditivas. + +### 2.1 Herramientas incluidas + +#### Runtime, host y diagnóstico + +| Capacidad (origen) | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Estado del runtime (D) | `runtime status` (existe) | `runtime_status` | R | — | +| Versiones y contratos (CLI) | `version` (existe) | `get_version` | R | 0 | +| Ajustes del runtime no sensibles: perfil por defecto, carpeta de workspaces, confirmaciones de borrado, AI Assist sin credenciales, retención de automations (D, M) | `runtime settings show/set` (nuevo, con allowlist de claves) | `get_runtime_settings` / `update_runtime_settings` | R / A | 4 | +| Integraciones de agentes (hooks) (D, CLI) | `runtime agents status/enable/disable` (existe) | `get_agent_integrations` / `set_agent_integrations` | R / A | 4 | +| Snapshot de recursos (D) | `runtime resources` (nuevo → `resources.snapshot`) | `get_resource_snapshot` | R | 4 | +| Cuotas de agentes (D, M) | `agent-quota show/refresh-claude/consume-codex-reset` (nuevo) | `get_agent_quotas` / `refresh_claude_quota` / `consume_codex_reset_credit` | R / F / A | 4 | +| Voz: hablar al humano y estado (D, M, CLI) | `voice speak/status` (existe) | `voice_speak` / `voice_status` | F / R | 4 | +| SSH targets: lista y estado (D, CLI) | `ssh-target list/status` (existe) | `list_ssh_targets` / `ssh_target_status` | R | 3 | +| Leer un issue (D, CLI) | `issue show` (existe) | `fetch_issue` | R | 2 | + +#### Proyectos + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Listar | `project list` (existe) | `list_projects` | R | — | +| Registrar carpeta local (D, M) | `project add` (existe) | `register_project` | F | 3 | +| Clonar desde URL, con progreso y cancelación (D, M) | `project clone start/list/show/cancel` (nuevo → `project.clone.*`) | `clone_project` / `get_project_clone` / `list_project_clones` / `cancel_project_clone` | F / R / R / F | 3 | +| Registrar proyecto o checkout remoto (D, CLI) | `project add-remote`, `register-checkout` (existe) | `register_remote_project` / `register_project_checkout` | F | 3 | +| Renombrar (D, M) | `project rename` (nuevo → `project.rename`) | `rename_project` | F | 3 | +| Vista previa de borrado (M) | `project remove-preview` (nuevo → `project.remove.preview` + `project.removalDependencies`) | `preview_project_removal` | R | 3 | +| Borrar proyecto como la UI: pausa automations dependientes y nunca borra archivos (D, M) | `project remove --pause-automations-and-cancel-runs` (existe) | `remove_project` | F | 3 | +| Hosts del proyecto (D, CLI) | `project hosts list/add/remove` (existe) | `list_project_hosts` / `add_project_host` / `remove_project_host` | R / F / F | 3 | +| Catálogo de ramas (D, M) | `project branches` (nuevo → `project.branches.list`) | `list_project_branches` | R | 3 | +| Configuración del proyecto: New Workspace, copias, setup, proveedor de PR (D, M) | `project config show/set/remove` (nuevo → `projectConfig.*`) | `get_project_config` / `update_project_config` / `reset_project_config` | R / F / F | 3 | + +#### Workspaces + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Listar, con filtros nuevos: sección, tag, archivado, padre, host | `workspace list` (ampliar) | `list_workspaces` (ampliar) | R | 2 | +| Ver detalle: sección, tags, issue, watch, PR enlazado, padre e hijos | `workspace show` (nuevo) | `show_workspace` | R | 2 | +| Crear manual: carpeta del proyecto o worktree, rama nueva o existente, padre, sección por id o nombre, issue, host, ruta | `workspace add` (existe) | `create_workspace` (ampliar: `parentWorkspaceId`, `sectionId`, `reuseExistingBranch`, `path`, `workspaceRoot`, `clientRequestId`) | F | 2 | +| New Workspace from Prompt (servicio, ver 6.1) | `workspace prompt-start run/show/list/wait/cancel/retry-launch` (nuevo) | `start_workspace_from_prompt` / `get_workspace_start` / `wait_for_workspace_start` / `list_workspace_starts` / `cancel_workspace_start` / `retry_workspace_start_launch` | F / R / R / R / F / F | 1 | +| Crear y lanzar con identidad explícita (compatibilidad) | `workspace start` (corregir defaults) | `start_agent_workspace` (se mantiene; documentado como "manual") | F | 1 | +| Renombrar | `workspace rename` (existe) | `rename_workspace` | F | 2 | +| Fijar o desfijar, también el árbol (D, M) | `workspace pin/unpin` (ampliar `--tree`) | `set_workspace_pinned` | F | 2 | +| Archivar o desarchivar | `workspace archive/unarchive` (existe) | `archive_workspace` / `unarchive_workspace` | F | 2 | +| Dormir | `workspace sleep` (existe) | `sleep_workspace` | F | — | +| Despertar: reabrir las sesiones de las tabs dormidas | `workspace wake` (nuevo → `workspace.wake` nuevo, sobre `workspace.sleptTabs` y `createOrAttach`) | `wake_workspace` | F | 2 | +| Enfocar en la app desktop | `workspace focus` (existe) | `focus_workspace` | F | 2 | +| Vista previa de borrado: almacenamiento, dependencias, cascada | `workspace remove-preview` (nuevo → `workspace.storageImpact` + `removalDependencies` + `workspaceCascade.preview`) | `preview_workspace_removal` | R | 2 | +| Borrar workspace activo o archivado con el flujo de la UI (6.3) | `workspace remove` (ampliar `--editor-buffers save|discard`) | `remove_workspace` | F | 2 | +| Hand Off / Hand On | `workspace hand-off/hand-on` (existe) | `hand_off_workspace` / `hand_on_workspace` | F | 2 | +| Recovery y setup: inspeccionar, ejecutar setup, recuperar o cancelar el setup de una reubicación | `workspace recovery`, `workspace setup` (existe); `workspace recovery resume/cancel` (nuevo) | `get_workspace_recovery` / `run_workspace_setup` / `recover_workspace_setup` / `cancel_workspace_setup` | R / F / F / F | 2 | +| Registrar o desregistrar metadatos (reparación) | `workspace register/unregister` (existe) | `register_workspace_record` / `unregister_workspace_record` | A | 2 | +| Secciones: listar, crear, asignar (también el árbol), quitar, borrar | `workspace section …` (existe; ampliar `--tree`) | `list_sections` / `create_section` / `set_workspace_section` / `clear_workspace_section` / `remove_section` | R / F / F / F / F | 2 | +| Tags globales y asignación | `tag list/upsert/remove`, `workspace tag/untag` (existe) | `list_tags` / `upsert_tag` / `remove_tag` / `tag_workspace` / `untag_workspace` | R / F / F / F / F | 2 | +| Relación padre/hijo y vista previa de cascada | `workspace link/unlink/cascade-preview` (existe) | `link_workspaces` / `unlink_workspaces` / `preview_workspace_cascade` | F / F / R | 2 | +| Issue enlazado | `workspace issue show/link/unlink` (existe) | `show_workspace_issue` / `link_workspace_issue` / `unlink_workspace_issue` | R / F / F | 2 | + +#### Tabs, terminales y perfiles de agente + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Listar tabs | `tab list` (existe) | `list_tabs` | R | — | +| Crear tab de terminal o comando | `tab create` (existe) | `create_tab` | F | 4 | +| Cerrar tab y terminar su sesión (D, M) | `tab remove --terminate` (ampliar) | `close_tab` | F | 4 | +| Renombrar tab y generar título con IA (D, M) | `tab rename`, `tab generate-title` (nuevo → `tab.rename`, `aiText.agentTitle.generate`) | `rename_tab` / `generate_tab_title` | F | 4 | +| Re-vincular agente a tab | `tab link-agent` (existe) | `link_agent_to_tab` | F | 4 | +| Terminales: listar, ver, leer, esperar, escribir | existe | existentes | R / F | — | +| Reiniciar terminal (D, M) | `terminal restart` (nuevo → `terminal.restart`) | `restart_terminal` | F | 4 | +| Terminar una sesión (Resource Manager) | `terminal terminate` (nuevo → `terminate`) | `terminate_terminal` | F | 4 | +| Purgar sesiones detenidas | `terminal prune` (existe) | `prune_terminals` | A | 4 | +| Terminal Pulse (D) | `terminal pulse show/set` (nuevo → `terminal.pulse.*`) | `get_terminal_pulse` / `configure_terminal_pulse` | R / F | 4 | +| Perfiles: listar, ver, impacto de borrado | `agent-profile list/show/removal-impact` (existe) | `list_agent_profiles` / `show_agent_profile` / `preview_agent_profile_removal` | R | 4 | +| Perfiles: crear, actualizar (incluidas reduced protections), borrar y reordenar (F2) | `agent-profile create/update/remove/reorder` (existe) | `create_agent_profile` / `update_agent_profile` / `remove_agent_profile` / `reorder_agent_profiles` | A | 4 | +| Perfil por defecto (cambio de configuración, F2) | `runtime settings set defaultAgentProfileId` | `set_default_agent_profile` | A | 4 | +| Lanzar perfil, incluida la reanudación de sesión | `agent-profile launch` (ampliar `--resume-session-id`) | `launch_agent` (ampliar `resumeSessionId`, `clientRequestId`) | F | 4 | + +#### Inbox (U7) + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Destinos, preguntar, hilos, ver, esperar respuesta | existe (ampliar con origen y filtros) | `list_inbox_targets` / `ask_agent` / `list_inbox_threads` / `show_inbox_thread` / `wait_for_reply` (ampliar) | R / F / R / R / R | 5 | +| Esperar cualquier novedad del inbox | `inbox wait --inbox` (existe) | `wait_for_inbox` | R | 5 | +| Cancelar pregunta y marcar como leído | `inbox cancel/read` (existe) | `cancel_question` / `mark_thread_read` | F | 5 | +| Resumen de inboxes | `inbox list` (existe) | `list_inboxes` | R | 5 | +| Conversaciones entre agentes | `inbox conversations/conversation` (existe) | `list_agent_conversations` / `show_agent_conversation` | R | 5 | +| Purgar inbox | `inbox purge` (existe) | `purge_inbox` | A | 5 | + +#### Orquestación y workflows (sin decisiones humanas) + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Tareas: listar, ver, esperar, delegar, cancelar | existe | existentes | R / F | — | +| Crear tarea, dispatch, ver o interrumpir dispatch, agent-spawn | `orchestration task-create/dispatch/dispatch-show/dispatch-interrupt/agent-spawn` (existe) | `create_task` / `dispatch_task` / `show_dispatch` / `interrupt_dispatch` / `spawn_agent` | F / F / R / F / F | 5 | +| Mensajes | existe | `send_message` / `list_messages` (ampliar `payload`, `taskId`) | F / R | 5 | +| Coordinador: iniciar, detener, ver run | `orchestration run/run-stop/run-show` (existe) | `start_coordinator` / `stop_coordinator` / `show_run` | F / F / R | 5 | +| Board, snapshot de run e inspección de tarea (D) | `orchestration board/run-snapshot/task-inspect` (nuevo → `orchestration.boardSnapshot/runSnapshot/taskInspection`) | `get_orchestration_board` / `get_run_snapshot` / `inspect_task` | R | 5 | +| Gates: listar y crear (pedir decisión al humano) | `orchestration gate-list/gate-create` (existe) | `list_gates` / `create_gate` | R / F | 5 | +| Run policy: proponer y ver | `orchestration run-policy-propose/show` (existe) | `propose_run_policy` / `show_run_policy` | F / R | 5 | +| Recuperar tarea, transferir coordinador, reset, purgar terminales | existe | `recover_task` / `transfer_coordinator` / `reset_orchestration` | A | 5 | +| Recetas: listar, ver, validar, guardar personal, exportar a proyecto | `orchestration recipes …` (existe; `export` nuevo → `workflows.previewRecipeExport/applyRecipeExport`) | `list_recipes` / `show_recipe` / `validate_recipe` / `save_personal_recipe` / `export_recipe` | R / R / R / F / F | 5 | +| Propuestas: crear, listar, estado, enviar, cancelar, reintentar cancelación, iniciar coordinador (D) | `orchestration proposals …` (nuevo → `workflows.*Proposal*`, `startCoordinator`) | `create_workflow_proposal` / `list_workflow_proposals` / `get_workflow_proposal` / `submit_workflow_proposal` / `cancel_workflow_proposal` / `start_workflow_coordinator` | F / R / R / F / F / F | 5 | +| Planes: preparar y ver | `orchestration plans prepare/show/proposal/submit-proposal` (existe) | `prepare_workflow_plan` / `show_workflow_plan` | F / R | 5 | +| Ejecución: iniciar o pausar, nuevo intento, reintentar integración, corrección (D) | `orchestration workspaces …` (existe) + `orchestration execution control/correct` (nuevo → `workflows.controlExecution/createCorrection`) | `control_workflow_execution` / `prepare_workflow_attempt` / `launch_workflow_task` / `integrate_workflow_result` / `list_workflow_integrations` / `create_workflow_correction` | F / F / F / F / R / F | 5 | +| Limpieza de recursos de workflow (D) | `orchestration cleanup preview/apply/retry/abandon/status` (nuevo → `workflows.*Cleanup*`) | `preview_workflow_cleanup` / `apply_workflow_cleanup` / `retry_workflow_cleanup` / `abandon_workflow_cleanup` / `get_workflow_cleanup` | R / A / A / A / R | 5 | + +#### Automations (U5) + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Listar, con todos los filtros | `automation list` (existe) | `list_automations` (ampliar) | R | 6 | +| Ver automation, ver run y su contexto | `automation show/run-show` (existe) | `show_automation` / `show_automation_run` | R | 6 | +| Crear (activa o borrador), editar, clonar | `automation create/edit` (existe; `--request-key`, `--expected-revision`) | `create_automation` / `update_automation` / `clone_automation` | F | 6 | +| Readiness y vista previa de cron | `automation readiness/preview-schedule` (existe) | `check_automation_readiness` / `preview_automation_schedule` | R | 6 | +| Pausar (y cancelar runs), reanudar, papelera, restaurar | `automation pause/resume/trash/restore` (existe) | `pause_automation` / `resume_automation` / `trash_automation` / `restore_automation` | F | 6 | +| Purgar | `automation purge` (existe) | `purge_automations` | F | 6 | +| Ejecutar ahora con todas las opciones (sin precheck, en cola o en paralelo, continuar desde un run) | `automation run-now` (existe) | `run_automation` (ampliar) | F | 6 | +| Runs: listar, cancelar, reanudar en espera, extender, tomar el control | `automation runs/cancel/wait/extend` (existe); `automation take-over` (nuevo → `automation.takeOver`) | `list_automation_runs` / `cancel_automation_run` / `resume_automation_run` / `extend_automation_run` / `take_over_automation_run` | R / F / F / F / F | 6 | +| Plantillas, tags, exportar, importar | `automation templates/tags/export/import` (existe) | `list_automation_templates` / `upsert_automation_template` / `list_automation_tags` / `upsert_automation_tags` / `export_automations` / `import_automations` | R / F / R / F / R / F | 6 | + +#### Pull requests (U6, F3) + +Cubre GitHub, GitLab y Azure DevOps en todas las filas, a través de `ForgeProvider` en el runtime (sección 6.4). La única excepción son los stacks, que como en el desktop solo existen en GitHub. + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Snapshot: estado, checks, conversación, métodos de merge | `pr show` (nuevo → `mobile.pullRequest.snapshot`, multi-forja) | `get_pull_request` | R | 7 | +| Resúmenes por workspace | `pr summaries` (nuevo → `mobile.pullRequest.summaries`) | `list_pull_request_summaries` | R | 7 | +| Generar título y cuerpo con IA | `pr generate-details` (nuevo → `aiText.pullRequestDetails.generate`) | `generate_pull_request_details` | F | 7 | +| Crear PR (draft opcional) | `pr create` (nuevo → `mobile.pullRequest.create`) | `create_pull_request` | F | 7 | +| Enlazar o desenlazar | `pr link/unlink` (nuevo) | `link_pull_request` / `unlink_pull_request` | F | 7 | +| Comentar, responder, editar comentario | `pr comment/comment-edit` (nuevo) | `comment_pull_request` / `edit_pull_request_comment` | F | 7 | +| Ready o draft | `pr draft` (nuevo → `draftStatus`) | `set_pull_request_draft` | F | 7 | +| Cerrar | `pr close` (nuevo) | `close_pull_request` | F | 7 | +| Merge con los métodos de cada forja (GitHub: merge, squash o rebase; GitLab: merge o squash; Azure DevOps: noFastForward, squash, rebase o rebaseMerge) | `pr merge` (nuevo) | `merge_pull_request` | F | 7 | +| Ship Changes (scope all o staged, draft, base) más watch de seguimiento opcional | `pr ship [--follow-up-watch …]` (nuevo → `mobile.pullRequest.ship` + `pullRequestWatch.start`) | `ship_changes` (incluido el seguimiento `fixAndMerge`) | F | 7 | +| Restack (dispatch a un agente) | `pr restack` (nuevo → `pullRequest.agentDispatch` nuevo) | `restack_pull_request` | F | 7 | +| Fix Failed Checks (dispatch a un agente) | `pr fix-checks` (nuevo, mismo RPC) | `fix_pull_request_checks` | F | 7 | +| Watch and Fix: ver, iniciar, detener | `workspace pr-watch show/start/stop` (existe) | `show_pull_request_watch` / `start_pull_request_watch` (modos `fix` y `fixAndMerge`) / `stop_pull_request_watch` | R / F / F | 7 | +| Stacks (solo GitHub, igual que el desktop): ver, crear desde workspaces, enlazar, merge del stack | `pr stack show/create/link/merge` (nuevo → RPC `pullRequestStack.*` nuevo, port de Dart) | `get_pull_request_stack` / `create_pull_request_stack` / `link_pull_request_stack` / `merge_pull_request_stack` | R / F / F / F | 7 | +| Archivar o borrar el workspace tras el merge | Se reutilizan `archive_workspace` / `remove_workspace` | — | — | — | + +#### Eventos y notificaciones (U8) + +| Capacidad | CLI | Herramienta MCP | Nivel | Fase | +|---|---|---|---|---| +| Diario de eventos: listar y esperar con cursor | `events list/wait` (nuevo → `runtimeEvents.*` nuevo) | `list_events` / `wait_for_events` | R | 8 | +| Webhooks genéricos: listar, crear, borrar, probar | `webhook list/add/remove/test` (nuevo → API de la nube) | `list_webhooks` / `create_webhook` / `delete_webhook` / `test_webhook` | A / A / A / A (listar es A: la URL suele llevar el token del receptor) | 8 | +| Suscripción local stdio y MCP Events | Métodos de protocolo, no tools (sección 6.5) | — | — | 8 | + +### 2.2 Cambios de nivel en herramientas existentes + +No se mueve ninguna herramienta existente a Admin, para no romper los grants actuales (U1). `cancel_task` sigue en Full aunque sea una cancelación administrativa, y queda anotada como `destructiveHint`. + +### 2.3 Excluido (impuesto por `catalog_exclusion_tests`) + +| Grupo | Ejemplos (CLI o RPC) | Motivo | +|---|---|---| +| Cuenta, OAuth y MCP Control | `account *`, `mcp enable/disable/apps/revoke/status`, `mcp.settings.*`, `mcp.grants.*`, consentimiento | U4 | +| Emparejamiento mobile y dispositivos | `mobile *`, `mobile.pairing.*`, `mobile.device.*`, `mobile.settings.update` | U4 | +| Credenciales e instalación remota | `ssh-target add/remove/bootstrap*/link`, `hostLink.*` | U4 (escalado: instala sidecars y guarda credenciales) | +| Ciclo de vida del runtime | `runtime start/stop/clear/rename`, `host.restart/shutdown/process.run`, `runtimeMetadata.set` | U4 (además, cortaría la propia sesión) | +| Credenciales de proveedores y de IA | `aiAssist.chatgpt.*`, `voice.credentials.*`, `aiDictation.credentials.*` | U4 y reglas de `AGENTS.md` | +| Instalación de software en el host | `cliRegistration.install`, `agentSkill.install`, updater | U4 (escalado) | +| Configuration Sync en la nube | `configuration.*` | U4 | +| Decisiones humanas | `orchestration gate-resolve`, `run-policy-approve/reject`, decisiones, revisiones y challenges de `workflows` | U5 | +| Explorer, búsqueda, Source Control general, archivos y diffs | `git.*`, `mobile.git.*`, `workspace.files.*`, `mobile.workspaceFile.*`, `mobile.workspaceSearch.*`, `quickOpen` | U6 | +| Ciclo de vida interno del worker o del run | `orchestration check/reply/context/heartbeat/escalate/complete/worker-done/worker-help/current`, `automation context/heartbeat/complete` | Requieren identidad de terminal o run; no son acciones de un cliente externo | +| Presentación | `layout.*`, `workbenchViewPrefs.*`, tema, atajos, voz en vivo (`voice.start/stop/turn`), dictado | No son capacidades del runtime | + +## 3. Modelo de permisos + +### 3.1 Niveles y scopes + +| Barrera | Valores | Regla | +|---|---|---| +| Runtime (MCP Control, `settings.mcp.access`) | `off` < `read` < `full` < `admin` | `allows(tool)`: Read requiere ≥ read, Execute ≥ full, Admin = admin | +| Token OAuth (grant) | `mcp:read`, `mcp:execute`, `mcp:admin` | Las herramientas Admin requieren `mcp:admin`; las Execute, `mcp:execute` | +| Call grant de la nube | `access ∈ {read, execute, admin}` | El runtime verifica que `claims.access` cubra la herramienta | + +- **Concesión**: `mcp:admin` nunca entra en el default de `normalize_scope` (`cloud/src/mcp_oauth/authorize.rs:52-64`). Solo se concede si el cliente lo pide **y** el usuario marca una casilla nueva en el consentimiento, que va desmarcada por defecto. Los grants existentes conservan su `scopes` (U1). +- **Runtime**: el nivel `admin` solo se elige de forma explícita en MCP Control (desktop) o con `alera mcp enable --access admin`. Pasar de `full` a `admin` no ocurre nunca sin intervención. + +### 3.2 Pertenencia a Admin (F1 opción B y F2) + +Admin se reserva para la administración avanzada del runtime, la configuración de agentes y las operaciones internas sensibles: + +- **Administración del runtime:** `update_runtime_settings`, `set_agent_integrations`, `consume_codex_reset_credit` y la gestión de webhooks (`create_webhook`, `delete_webhook`, `test_webhook`). +- **Configuración de agentes (F2):** `create_agent_profile`, `update_agent_profile` (incluidas reduced protections), `remove_agent_profile`, `reorder_agent_profiles` y `set_default_agent_profile`. +- **Operaciones internas sensibles:** `reset_orchestration`, `recover_task`, `transfer_coordinator`, `prune_terminals`, `apply_workflow_cleanup`, `retry_workflow_cleanup`, `abandon_workflow_cleanup`, `register_workspace_record`, `unregister_workspace_record` y `purge_inbox` (borra el historial compartido de todos los clientes; ver R10). + +Todo lo demás que muta es Full, sin confirmaciones fuera del flujo de la UI (F1, U2). Eso incluye: + +- borrar workspaces y proyectos; +- merge de PR y de stacks, y Watch o Ship en modo `fixAndMerge`; +- `purge_automations`; +- el CRUD de automations con activación inmediata (U5). + +Con Full, los perfiles solo se listan, se consultan y se usan: `launch_agent`, `start_workspace_from_prompt`, `delegate_task` y automations que referencian perfiles existentes. + +### 3.3 Cambios por capa (puntos exactos) + +**Nube:** + +- `cloud/src/mcp_models.rs:9-61`: `SCOPE_ADMIN`, `McpAccess::Admin`, `ToolAccess::Admin`. +- `cloud/src/mcp_oauth/authorize.rs:52-64,185-198`: admin no entra en el default. +- `consent.rs:106-145,282-290`: casilla admin desmarcada y etiqueta del runtime. +- `metadata.rs:48,58`, `clients.rs:244` (scopes soportados) y `api_models.rs:86`. +- `mcp_gateway.rs:192-219`: exigir `mcp:admin` y nivel del runtime `admin`. +- Migración `0024_mcp_admin.sql`: CHECK de `runtimes.mcp_access` y de `mcp_calls.access`; subir `migrations.rs:13-14`. + +**Edge:** + +- `protocol.ts:4` (`MCP_SCOPES`). +- `tools.ts:4,31` (`ToolAccess` y validador; catálogo v2). +- `tool_call.ts:190-196` (scope admin). +- `relay_authorization.ts:18,261` y `relay_calls.ts:83` (aceptar `admin`). + +**Runtime:** + +- `mcp_settings.rs:15-49`, `mcp_tools/mod.rs:31-43,120-135`. +- `relay_mcp.rs:224-235`, `relay_runtime_auth.rs:286`, `server/mcp_requests.rs:70-72`. +- CLI: `cli/mcp.rs:34-52` (`--access off|read|full|admin`, con `--read-only` como alias) y `mcp_commands.rs:38-41,283-290`. + +**Desktop:** + +- `mcp_access/domain/mcp_access_settings.dart:7-19` (`admin`). +- `presentation/mcp_access_control_group.dart:43-60,161-171` (cuarto segmento con aviso). +- `domain/mcp_grant.dart:36` (`canAdmin`) y `mcp_connected_apps_group.dart:149` (chip). + +**Documentación:** `docs/remote-mcp.md`. + +## 4. Arquitectura + +### 4.1 Principios + +1. **Una herramienta MCP equivale a un comando CLI `--json`.** Se mantiene el invariante de `mcp_tools/mod.rs`, así que el CLI gana la misma paridad. Los comandos "nuevos" son clientes finos de RPC del host que ya existen o que se crean. +2. **La lógica vive en el runtime host o en `alera-core`.** Las herramientas no reimplementan reglas. Lo que hoy solo existe en Dart (pipeline From Prompt, prompts de Restack y Fix Checks, stacks) se porta a Rust, y la GUI pasa a consumirlo, detectándolo por capability. +3. **Operación larga, respuesta asíncrona.** Todo lo que pueda superar unos 40 s devuelve un `operationId` persistido (From Prompt, clone, ship), y se sigue con `get_*` o `wait_*` (≤ 50 s). +4. **Todo es aditivo.** Hay capabilities nuevas y no cambian las versiones estrictas de los protocolos terminal-host y mobile (`AGENTS.md`). + +### 4.2 Evolución de `mcp_tools` + +- `ToolAccess::{Read, Execute, Admin}`. +- `ToolSpec` añade: + - `idempotent: bool` (`annotations.idempotentHint`) + - `structured: bool` (el executor convierte un stdout JSON objeto en `structuredContent` y conserva el `text`) + - `client_request_flag: Option<&'static str>` (con qué flag del CLI se pasa `clientRequestId`) + - `output_schema: Option Value>` (MCP `outputSchema`, para versiones ≥ 2025-06-18) +- El catálogo se divide por dominio en `mcp_tools/catalog/{runtime,projects,workspaces,prompt_workspace,tabs,agents,inbox,orchestration,workflows,automations,pull_requests,events}.rs`, para cumplir el ratchet de máximo de líneas (`dart run tool/quality/check_max_lines.dart`). +- `CATALOG_VERSION = 2`. El edge acepta 1 y 2 (`edge/src/mcp/tools.ts` `loadCatalog`). +- Executor (`executor.rs`): + 1. Exporta `ALERA_MCP_ORIGIN` (JSON `{transport, clientId, clientName, grantId?, callId}`). Remoto: claims del call grant (`relay_mcp.rs:189-242` ya tiene `client_name`; se añaden `client_id` y `grant_id` a los claims). Local: `clientInfo` de `initialize`, con `transport: "local"`. No entra en `CONTEXT_VARIABLES`: es un contexto nuevo y explícito. + 2. Mapea los errores del CLI al modelo de la sección 4.6. + 3. Las herramientas `structured` devuelven `structuredContent`. +- Servidor stdio (`stdio_server.rs`): añade `resources`, `subscribe`, `progress` y la negociación `2026-07-28` (sección 6.5). + +### 4.3 RPC nuevas o ampliadas del runtime host + +| RPC | Propósito | Capability | +|---|---|---| +| `workspace.promptStart.{start,get,wait,list,cancel,retryLaunch}` | Servicio From Prompt (6.1) | `promptWorkspaceServiceV1` | +| `aiText.workspaceIdentity.generate` (ampliar con `projects` y `infer: ["project"]`) | Inferencia de proyecto | `aiAssistWorkspaceIdentityV2` | +| `workspace.wake` | Reabrir sesiones dormidas | `workspaceWakeV1` | +| `workspace.bufferGuard.*` (ampliar con `resolution: save|discard`) y evento `checkoutBuffersSaveRequested` | Borrado con buffers sucios "Save" | `checkoutBufferSaveV1` | +| `workspace.storageImpact` (reenviar a host remoto) | Corrige el bloqueo del borrado remoto (6.3) | — (fix) | +| `pullRequest.agentDispatch {workspaceId, kind: restack|fixFailedChecks, target}` | Prompts únicos en Rust | `pullRequestAgentDispatchV1` | +| `pullRequestStack.{get,create,link,merge}` | Port de `github_stack_actions.dart` (solo GitHub, como el desktop) | `pullRequestStacksV1` | +| `mobile.pullRequest.*` sobre `ForgeProvider` (GitHub, GitLab y Azure DevOps) | Paridad multi-forja (F3) | `pullRequestForgesV1` (lista de proveedores soportados) | +| `pullRequestWatch.*` (ejecución en el runtime para las tres forjas) | Watch and Fix multi-forja | `pullRequestWatchExecutionV2` | +| `runtimeEvents.{list,wait}` | Diario de eventos (6.5) | `runtimeEventsV1` | +| `inbox.ask` (aceptar `externalOrigin` solo de clientes `Local`) e `inbox.threads` (filtros `originClientId` y `own`) | Atribución (6.4) | `inboxOriginV1` | +| `mutationReceipts` (interno) | Idempotencia genérica (4.5) | — | + +Toda RPC nueva se añade a la lista de capabilities del control file (`control_file.rs:79-154`) y a `status.get` (`host_status.rs:70`). La allowlist mobile (`mobile_gateway_surface.rs`) y la política del hub (`hub_reverse_policy.rs`) solo se tocan donde la GUI mobile lo necesite: From Prompt y PR dispatch. + +### 4.4 Esquemas de llamada (herramientas clave) + +Convenciones comunes: + +- todos los objetos llevan `additionalProperties: false` +- los textos largos viajan por stdin +- `clientRequestId`: string de 8 a 128 caracteres, opcional en toda mutación + +`start_workspace_from_prompt` (F): + +```json +{ + "prompt": "string (1..65536, requerido)", + "projectId": "string?", + "profile": "string? (id prof_… o nombre; por defecto el del runtime)", + "mode": "auto | worktree | projectCheckout (default auto)", + "sourceBranch": "string?", + "hostId": "string?", + "parentWorkspaceId": "string?", + "issueUrl": "string?", + "section": "auto | none | {\"id\"} | {\"name\"} (default auto)", + "clientRequestId": "string?" +} +``` + +Resultado `structuredContent`: + +```json +{ + "operationId": "pws_…", + "status": "running|needsInput|completed|failed|cancelled", + "phase": "resolvingProject|generatingIdentity|checkingBranch|creatingWorkspace|assigningSection|preparingSetup|launchingAgent|startingSetup|done", + "candidates": [{ "projectId": "…", "name": "…", "reason": "…" }], + "workspace": { "id": "…", "name": "…", "branch": "…", "sectionId": "…|null", "projectId": "…" }, + "agent": { "tabId": "…", "terminalHandle": "…", "profileId": "…" }, + "setup": { "tabId": "…|null", "status": "none|running|deferred" }, + "error": { "code": "…", "message": "…", "retryable": true }, + "cursor": 0 +} +``` + +`wait_for_workspace_start` (R): `{operationId, timeoutSeconds ≤ 50}` y devuelve lo mismo. + +`remove_workspace` (F): + +```json +{ + "workspaceId": "string", + "branch": "delete | keep (default: delete si la rama es borrable, como el botón Remove; si no, keep)", + "editorBuffers": "save | discard (default save)", + "clientRequestId": "string?" +} +``` + +Resultado: `{removed, workspaceId, branch: {name, deleted, retainedReason?}, pausedAutomations[], unlinkedChildren[], closedSessions}`. + +`ask_agent` (F, ampliado): añade `clientRequestId`. Resultado: `{questionId, threadId, recipient, origin: {clientId, clientName, transport}}`. + +`wait_for_inbox` (R): + +```json +{ + "after": "int?", + "scope": "own | all (default own)", + "threadId": "string?", + "timeoutSeconds": "int ≤ 50" +} +``` + +Resultado: `{outcome: message|timeout, messages[], cursor, truncated}`, y cada mensaje lleva `origin`, `isOwn` y `threadId`. + +`list_inbox_threads` (R, ampliado): `{originClientId?, scope: own|all (default all), status?, workspaceId?, limit?, before?}`. Cada hilo lleva `origin` e `isOwn`. + +`ship_changes` (F): + +```json +{ + "workspaceId": "string", + "baseBranch": "string", + "draft": "boolean?", + "scope": "all | staged", + "followUpWatch": { "mode": "fix | fixAndMerge", "profile": "string?", "handle": "string?", "checks": true, "comments": true, "conflicts": true }, + "clientRequestId": "string?" +} +``` + +Tras F1, `fixAndMerge` es Full, así que basta una sola herramienta. Funciona con GitHub, GitLab y Azure DevOps; el `baseBranch` y los métodos de merge se validan contra el proveedor. + +`wait_for_events` (R): + +```json +{ + "after": "int?", + "kinds": ["inbox.reply", "…"], + "workspaceId": "string?", + "timeoutSeconds": "int ≤ 50" +} +``` + +Resultado: `{events[], cursor, truncated}`. + +### 4.5 Idempotencia + +| Caso | Mecanismo | +|---|---| +| Lanzamientos (`launch_agent`, From Prompt, `start_agent_workspace`) | `agentProfile.launchIdempotent` con `clientMutationId` = `clientRequestId`, o derivado del `operationId` en From Prompt | +| From Prompt | `promptWorkspaceOperations.requestId UNIQUE`: misma clave, misma operación | +| Automations | `--request-key` (ya existe) | +| Resto de mutaciones | Tabla nueva `mcpMutationReceipts(scope, key, tool, argumentsHash, status, resultJson, createdAt)` con TTL de 24 h, **en el runtime host** (C5). RPC `mutationReceipts.claim/complete`: el claim es atómico, una clave en curso devuelve `conflict` reintentable y una clave completada devuelve el resultado guardado. Misma clave con argumentos distintos: `idempotency_conflict` | +| Esperas | Ya son idempotentes por cursor | + +### 4.6 Modelo de errores + +El resultado de la herramienta lleva `isError: true` y `structuredContent.error = {code, message, retryable, details?}`. + +Códigos estándar: + +- `invalid_argument`, `not_found`, `conflict`, `idempotency_conflict` +- `capability_missing` (el runtime necesita actualizarse), `tool_unavailable` +- `access_denied`, `runtime_read_only`, `runtime_offline` +- `timeout_pending` (incluye `operationId`), `ai_assist_unavailable` +- `provider_unsupported` (función que la forja no tiene en Alera, como los stacks fuera de GitHub) +- `provider_unavailable` (falta `gh`, `glab` o `az`, o no hay autenticación en el host del checkout) +- `blocked` (bloqueos de la UI, como "Cleanup Unavailable", con `details.blockers`) +- `needs_input` (con candidatos) +- `scope_required` (C6): una herramienta Admin llamada sin `mcp:admin` devuelve el error con instrucciones de reconectar la app y marcar Admin en el consentimiento. Para clientes que hagan step-up, el edge responde además `403 insufficient_scope` con `WWW-Authenticate: … scope="mcp:read mcp:execute mcp:admin"` + +Los mensajes de la UI se reutilizan literalmente, y el CLI emite siempre JSON de error con `--json`. + +## 5. Seguridad + +1. **Doble barrera por nivel** (3.1). Un test en cada capa: nube (contratos), edge (bun) y runtime (`relay_mcp_tests`). +2. **Exclusiones y decisiones humanas**: `catalog_exclusion_tests` recorre el catálogo y falla si alguna `Invocation` usa los grupos `account`, `mcp`, `mobile` o `ssh-target add/remove/bootstrap/link`, `runtime start/stop/clear/rename`, `gate-resolve`, `run-policy-approve/reject`, verbos de decisión de `workflows`, `git`, `files` o `search`. +3. **Atribución de origen confiable**: el host acepta `externalOrigin` solo de clientes `ClientKind::Local` (el CLI hijo). Mobile no puede falsificarlo. Se valida la forma y el tamaño (≤ 16 KiB, que ya es `INBOX_EXTERNAL_META_MAX_BYTES`). +4. **Política de payloads de eventos**: solo IDs, estado y nombres de proyecto o workspace, igual que la regla de push de `AGENTS.md`. Nunca prompts, salida, código ni texto de orquestación. Un test valida cada tipo de evento contra una lista blanca de claves. +5. **Webhooks**: + - HTTPS:443, DNS restringido a IPs públicas y fijado, sin redirecciones. + - Challenge firmado al registrar, Standard Webhooks (`webhook-id`, `webhook-timestamp`, `webhook-signature`). + - Secreto `whsec_` cifrado en reposo con una clave de la nube (`WEBHOOK_SECRET_KEY`, rotable). + - Respuestas limitadas a 16 KiB y 10 s por intento. + - Se reaprovecha el diseño de EducUp como referencia, no como dependencia. +6. **Configuración de agentes (F2)**: toda mutación de perfiles (crear, editar, incluidas reduced protections, borrar, reordenar, perfil por defecto) es Admin. Con Full solo se listan, se consultan y se lanzan perfiles existentes, y las automations Full solo referencian perfiles existentes. Limitación conocida: `write_terminal` y `create_tab --command` (Full) siguen permitiendo ejecutar comandos arbitrarios, incluido un agente con permisos reducidos; esto no cambia el riesgo existente. +7. **`write_terminal`** sigue en Full (riesgo aceptado, ya existente) y queda documentado en la UI de MCP Control. +8. **Auditoría**: `mcp_calls.access` admite `admin`. Se conserva la política de solo metadatos. +9. **Límites**: `MCP_LIMITER` actual. El runtime mantiene 4 llamadas concurrentes; las esperas no cuentan para el límite de mutaciones (`MAX_CONCURRENT_CALLS` se separa en lecturas y escrituras). + +## 6. Diseños detallados + +### 6.1 Servicio New Workspace from Prompt (U9) + +**Persistencia** (`alera-core`, patrón de `projectCloneJobs` y `workflowLaunches`): tabla `promptWorkspaceOperations` con estas columnas: + +- `id`, `requestId UNIQUE`, `status`, `phase`, `message`, `error` +- `projectId?`, `profileId`, `mode`, `sourceBranch?`, `hostId?`, `parentWorkspaceId?`, `issueUrl?`, `sectionPolicy` +- `prompt` en claro mientras la operación está activa (C4: `runtime.sqlite` no está cifrado, igual que el resto del store); al llegar a un estado final se sustituye por `promptHash` +- `candidatesJson?`, `workspaceId?`, `agentTabId?`, `setupTabId?`, `clientMutationId`, `attempts`, `origin`, `createdAt`, `updatedAt` + +Se añaden reconciliación al arranque (`reconcile_interrupted_prompt_starts`), un mapa de cancelación en memoria, el evento `promptWorkspaceOperationsChanged {id}` y un guard en `schedule_shutdown_if_idle`. + +**Algoritmo** (host). Reproduce `PromptWorkspacePipeline`, más las mejoras acordadas: + +1. **Proyecto**: + - Si viene `projectId`, se usa. + - Si solo hay uno, ese. + - Si no, una llamada de identidad ampliada que incluye proyectos (nombre y ruta, como mucho 50, recortados como hoy las secciones) y pide `project`. Se valida sin distinguir mayúsculas. + - Si responde "unknown", algo inválido o hay ambigüedad: `needsInput` con candidatos ordenados por actividad (`workspaceActivity`). El cliente repite con `projectId` y una `clientRequestId` nueva. + - La identidad (nombre, rama y sección) se genera en esa misma llamada cuando el proyecto queda resuelto. Si no, en una segunda llamada. +2. **Perfil**: el explícito, si no `runtimeSettings.defaultAgentProfileId`, si no el primero en orden (igual que `_defaultAgentProfile`). +3. **Modo**: + - `projectCheckout` y `worktree` se respetan. + - `auto`: worktree si el proyecto es repo Git (aislamiento para agentes desatendidos); projectCheckout si no lo es (como la UI cuando no hay Git). + - La UI, al migrar, envía siempre un modo explícito (su default actual es `projectCheckout`), así que la equivalencia con la UI se mantiene. +4. **Rama origen**: la explícita, si no `preferred_source_branch` (configuración del proyecto), si no la rama por defecto del repo, si no la rama actual de la carpeta del proyecto. Así lo hace hoy `managed_workspace.rs:139-141` para el primer paso. +5. **Identidad**: + - `generate_workspace_identity` con `autoAssignSection = (section == auto)`. + - Fallback "Others": sin `sectionId` (`ai_assist_workspace_identity.rs:151-166`). +6. **Colisión**: + - Ramas de workspaces activos del mismo host, más `branchExists` local o el catálogo del host remoto. + - Un reintento de identidad con el texto actual de la UI y, después, un sufijo determinista `-2…-9`. Es una mejora explícita, y la UI la hereda al migrar. +7. **Crear**: `createManaged` o `createShared` con `deferSetup: true` e `issueUrl`; enlazar el padre (si falla, aviso, como `_createWorkspace`). +8. **Sección**: `workspaceSection.setForWorkspace` en modo best-effort, guardando el aviso. +9. **Lanzar**: `agentProfile.launchIdempotent` con `clientMutationId` derivado de `id`. +10. **Setup**: el host crea la tab "Setup" con el `deferredSetupCommand` y la arranca (`terminal.create` con spawn), igual que mobile (`deferred_workspace_setup_launcher.dart`). Desktop y mobile la muestran al sincronizar tabs. La GUI migrada no abre su propia tab Setup cuando la operación trae `setupTabId` (C7). +11. **Fallo después de crear**: `failed` con `workspaceId` y `retryable`. `retryLaunch` relanza con el mismo `clientMutationId` y nunca recrea el workspace (equivalente al snapshot `created` de la UI). +12. **Eventos**: `workspace.start.state` en cada cambio de estado. + +**Sin AI Assist** (C3): igual que la UI, la operación falla en la fase `generatingIdentity` con `ai_assist_unavailable` y el mensaje del host ("AI Assist is disabled."). No hay identidad determinista. Para crear con nombre y rama explícitos se usa `start_agent_workspace` o `create_workspace`. + +**CLI**: `alera workspace prompt-start run|show|list|wait|cancel|retry-launch`. `run` admite `--wait `. Además, `workspace start`: + +- deja de exigir `--source-branch` y delega en el host +- envía `autoAssignSection` cuando no hay `--section` +- usa `deferSetup` +- reintenta en caso de colisión + +Esto corrige D2 a D6 de la auditoría. + +**Migración de la GUI**: si el runtime anuncia `promptWorkspaceServiceV1`, desktop (`background_setup_jobs.dart:100-260`) y mobile (`mobile/.../prompt_workspace_pipeline.dart`) delegan en el servicio y observan su progreso por el evento. Si no, siguen con el pipeline actual. El formulario no cambia. + +### 6.2 Atribución en el buzón compartido (U7) + +- **Origen**: `external_meta.origin = {surface: "mcp", transport: "remote"|"local", clientId, clientName, grantId?}`, extraído de `ALERA_MCP_ORIGIN`. El inbox sigue siendo `ext:mcp` (compartido). +- **Banner para el agente** (`message_formatter.rs:75-90`): "External question from ext:mcp via ChatGPT (MCP)". La respuesta conserva el hilo, y el origen del hilo identifica al destinatario de la respuesta. +- **Filtros**: `scope=own` compara `origin.clientId` con el del llamador. `isOwn` se calcula en el CLI con `ALERA_MCP_ORIGIN`. +- **Defaults**: + - `list_inbox_threads` usa `all` (historial compartido visible) con `isOwn` y `origin`. + - `wait_for_inbox` usa `own`, para no reaccionar a respuestas ajenas. + - `wait_for_reply` y `show_inbox_thread` funcionan con cualquier hilo. +- **Hilos antiguos** (sin origen): `origin: null` e `isOwn: false`. +- **Mobile y desktop** muestran el origen en la UI del inbox (aditivo, `external_origin_suffix`). + +### 6.3 Borrado de workspace con la semántica de la UI (U3) + +`remove_workspace` ejecuta en el CLI (`workspace remove` ampliado) la secuencia del lanzador desktop (`workspace_removal_launcher.dart:14-196`): + +1. **Rama**: `canDeleteBranch` = no es main, no es una rama reutilizada y no está vacía. Con `branch` sin indicar: `delete` si es borrable (el default del botón Remove), si no `keep`. El borrado es seguro: el servidor conserva las ramas no fusionadas o protegidas, igual que la UI, y el resultado informa `retainedReason`. +2. **Dependencias**: `workspace.removalDependencies`. Si existen, se pausan con `cancel-active` y se esperan hasta 30 s. Equivale a aceptar "Pause And Continue", y la confirmación es la del cliente MCP (U2). +3. **Almacenamiento**: `workspace.storageImpact {closeSessions: true}`. Si hay bloqueos (salvo los de automations que se van a pausar), devuelve un error `blocked` con `blockers`, igual que "Cleanup Unavailable". Hay que corregir el reenvío a hosts remotos, porque hoy bloquea el borrado remoto desde desktop. +4. **Buffers sucios de editor**: `workspace.bufferGuard`: + - `editorBuffers = discard`: se descartan. + - `save` (default): el host emite `checkoutBuffersSaveRequested`, los clientes guardan y responden; si un cliente no lo soporta o falla el guardado, el error es `blocked` (como "Save failure aborts" en la UI). +5. **Cambios git sin commitear**: semántica de la UI, es decir, se eliminan con el worktree (`remove_worktree(force=true)`). La descripción de la herramienta lo advierte con el texto de la UI: "Unsaved changes will be lost". +6. **Borrar**: `removeManaged` o `removeShared` con `closeSessions: true` y `deleteBranch`. Los hijos quedan desenlazados (`workspace_retirement.rs`). + +El mismo flujo vale para workspaces archivados. `remove_project` sigue el lanzador desktop: dependencias, pausa y `project.remove`. Nunca borra archivos, y si hay workspaces en SSH devuelve el error del servidor. + +### 6.4 Grupo PR multi-forja (U6, F3) + +**Estado actual (verificado):** + +- El runtime implementa las acciones de PR solo para GitHub, vía `gh`: `mobile.pullRequest.*` en `mobile_pull_request_actions.rs`, `mobile_pull_request_requests.rs`, `mobile_pull_request_ship.rs` y `mobile_pull_request_summaries.rs`. +- Para otros proveedores devuelve `unsupported` (`mobile_pull_request_requests.rs:75-95`, `actions.rs:455-470`). +- GitLab y Azure DevOps existen solo en Dart (`lib/src/features/pull_requests/infra/gitlab_*`, `azure_devops_*`), con `glab` y `az repos pr`. +- La ejecución de Watch and Fix en el runtime (`pullRequestWatchExecutionV1`) solo cubre GitHub; el desktop vigila las otras forjas en Dart. +- Los stacks son exclusivos de GitHub también en el desktop. + +**Diseño:** + +1. **`ForgeProvider` en el runtime** (Rust; módulo nuevo `pull_request_forges/` en `alera-cli`, o en `alera-core` si mobile o desktop lo necesitan vía FRB). Trait con operaciones: + - `detect`, `auth_status`, `snapshot` (estado, checks o pipelines, comentarios e hilos, métodos de merge) + - `summaries`, `create`, `link_validate`, `comment`, `comment_update` + - `merge(method)`, `set_draft`, `close`, `merge_methods` + - `head_sha` (para `--match-head-commit` o su equivalente) + - Las implementaciones son `GitHubProvider` (lo actual, movido detrás del trait), `GitLabProvider` (`glab`) y `AzureDevOpsProvider` (`az repos pr`, `az pipelines`). + - La detección del proveedor reutiliza la configuración del proyecto (`projectConfig` › proveedor de PR) y el remote, igual que el desktop. +2. **Reutilización del desktop "cuando proceda":** + - La lógica de comandos, flags, parseo y mapeo de estados se porta 1:1 desde `gitlab_forge_provider.dart`, `gitlab_review_comments.dart`, `azure_devops_forge_provider.dart` y `azure_devops_review_comments.dart`, y los equivalentes de acciones. + - Las respuestas reales de `glab` y `az` que usan las pruebas Dart se exportan como **fixtures compartidas** (`test/fixtures/forges/…`) y alimentan las pruebas Rust, para detectar deriva entre las dos implementaciones. + - El desktop conserva su implementación Dart (sin migración obligatoria). Su adopción del runtime queda como mejora posterior por capability. +3. **Métodos de merge por forja:** + - GitHub: `merge`, `squash`, `rebase`. + - GitLab: `merge`, `squash` (con `--squash`; rebase solo si el proyecto lo permite). + - Azure DevOps: `noFastForward`, `squash`, `rebase`, `rebaseMerge` (`az repos pr update --merge-strategy`, auto-complete o complete). + - `merge_methods` devuelve los permitidos para la configuración del repo; un método no admitido da `invalid_argument`. +4. **Draft:** GitHub `gh pr ready [--undo]`; GitLab `glab mr update --draft/--ready`; Azure `az repos pr update --draft true/false`. +5. **Checks:** GitHub `gh pr checks`; GitLab pipelines del MR (`glab ci`, API); Azure policies y builds (`az repos pr policy list`, `az pipelines runs`). Todo se normaliza al modelo `checks[]` del snapshot actual. +6. **Ship** (`mobile.pullRequest.ship`): las partes git (fetch, rama `ship/`, stage, commit con IA y push) no cambian. Solo el paso de crear el PR y enlazarlo pasa por `ForgeProvider`. Requiere AI Assist, como hoy. +7. **Watch and Fix** (`pull_request_watch_runtime.rs`, `pull_request_watch_evaluation.rs`): + - La evaluación (checks fallidos, conflictos, hilos sin resolver) y el merge de `fixAndMerge` pasan por `ForgeProvider`. + - Se anuncia `pullRequestWatchExecutionV2` con la lista de proveedores. Así el desktop deja de vigilar en Dart las forjas que el runtime ya cubre y no hay doble dispatch, respetando la regla de `AGENTS.md` (Pull Request Watch). +8. **Restack y Fix Failed Checks:** + - RPC `pullRequest.agentDispatch`, independiente del proveedor. + - Los prompts se trasladan a una fuente única en Rust (hoy están duplicados en `lib/` y `mobile/`). + - Destino: `handle`, `tabId` o `profileId`. + - Desktop y mobile lo adoptan por capability. +9. **Stacks:** port de `github_stack_actions.dart` y `workspace_pull_request_stack_actions.dart` (`gh stack`, requiere la extensión `gh-stack`, que se detecta). Solo GitHub, igual que el desktop; con otras forjas, `provider_unsupported` con ese motivo. +10. **Remotos:** el hub ya reenvía `mobile.pullRequest.*` al host del checkout (`remote_pull_request_routing.rs`). `glab` o `az` deben estar instalados y autenticados **en el host que posee el checkout**; si faltan, el error es `provider_unavailable` y se indica qué CLI instalar y autenticar. + +### 6.5 Eventos y notificaciones (U8) + +**Fuente única: el diario de eventos del runtime.** + +- Tabla `runtimeEvents(seq INTEGER PK AUTOINCREMENT, id TEXT UNIQUE, kind, workspaceId?, projectId?, dataJson, occurredAt, forwardedAt?)`, con retención de 7 días. +- Se escribe en los mismos puntos que hoy encolan push (`push_delivery.rs`; llamadas en `orchestration_requests.rs`, `coordinator_*`, `pty_events.rs`, `session_termination.rs`, `automation_*`), **antes** del gate de ajustes de push móvil (C2), y en las nuevas transiciones (From Prompt, estado de tareas, watch de PR, ciclo de vida de workspaces). +- El push móvil actual no cambia. + +**Catálogo de eventos v1** (solo IDs y estado): + +| `kind` | `data` | +|---|---| +| `inbox.reply` | `inbox, threadId, questionId, messageId, originClientId?` | +| `inbox.question.status` | `questionId, threadId, status` | +| `agent.status` | `workspaceId, tabId, sessionId, state (waiting|blocked|done)` | +| `terminal.exit` | `workspaceId, tabId, sessionId, exitCode` | +| `orchestration.task.state` | `taskId, runId?, state` | +| `orchestration.gate.created` | `gateId, taskId, runId?` | +| `orchestration.escalation` | `taskId, runId?` | +| `automation.run.state` | `automationId, runId, status` | +| `workspace.start.state` | `operationId, status, phase, workspaceId?` | +| `workspace.lifecycle` | `workspaceId, action (created|archived|unarchived|slept|woken|removed)` | +| `pullRequest.watch` | `workspaceId, number, action (dispatched|merged|stopped)` | + +Sobre (envelope) común: `{eventId, seq, kind, runtimeId, occurredAt, data}`. + +**Mecanismos:** + +1. **Polling robusto (todos los clientes)**: + - `list_events` y `wait_for_events` (cursor = `seq`; `truncated` si el cursor es anterior a la retención). + - `wait_for_inbox` y `wait_for_reply` con atribución. + - Las instrucciones del servidor MCP explican el bucle con el cursor. +2. **Suscripciones locales (stdio)**: + - `capabilities.resources = {subscribe: true}`. + - Recursos `alera://events`, `alera://inbox/ext:mcp`, `alera://inbox/thread/` y `alera://workspace-start/`. + - `resources/read` devuelve el estado JSON y `notifications/resources/updated` se emite al llegar eventos. + - `notifications/progress` en las herramientas largas si llega `progressToken`. + - Requiere que `mcp serve` mantenga una conexión de eventos con el host: un modo "listen" en `RuntimeHostRpcClient`, que hoy descarta los eventos en `runtime_host_client.rs:289-291`. + - Con `2026-07-28`, se implementa `subscriptions/listen` sobre la misma fuente. +3. **Reenvío a la nube**: + - El runtime envía `runtimeEvents` con `forwardedAt IS NULL` a `POST /v1/runtime/domain-events` (endpoint nuevo, token del runtime, scope nuevo `events:send`), en lotes, con reintentos y marcando `forwardedAt`. + - Solo cuando la nube indica que hay suscripciones activas (webhook o MCP Events) para ese runtime (C1). La respuesta del endpoint y un `GET /v1/runtime/event-subscriptions` ligero devuelven `activeSubscriptions`; con 0, el runtime deja de reenviar y conserva el diario local. Activar MCP Control no abre por sí solo un flujo de datos nuevo hacia la nube. + - Es idempotente por `eventId`. +4. **Webhooks genéricos (nube)**: + - Tablas `event_subscriptions(id, account_id, runtime_ids[], kinds[], target_kind webhook|mcp_events, callback_url, secret_enc, status, refresh_before, owner_grant_id?, created_at)`, `domain_events(account_id, runtime_id, event_id UNIQUE, kind, data, occurred_at)` (retención de 24 h) y `event_deliveries(subscription_id, event_id, status, attempts, next_attempt_at, lease_until, last_error)`. + - Un worker en la nube (bucle como `maintenance.rs`, claim con `FOR UPDATE SKIP LOCKED` y lease de 45 s) firma y entrega. + - Reintentos exponenciales de 5 s a 15 min (12 intentos). `410` detiene la suscripción y `413` deja la entrega como muerta. + - Gestión desde Settings › Integrations › Webhooks (desktop), CLI `alera webhook …` y MCP (Admin). +5. **OpenAI MCP Events (detrás de una flag, con prueba real obligatoria, F4)**: + - **Requisitos publicados por OpenAI** ([MCP Events](https://developers.openai.com/plugins/build/mcp-events), consultado el 2026-10-10): + - Work chats en ChatGPT web, o en el desktop con Cloud seleccionado. + - "Workspace controls for plugins and event-triggered tasks apply". + - El servidor debe configurarse "in your plugin". + - Protocolo `2026-07-28` con `events` en las capabilities de `server/discover`. + - `events/list`, `events/subscribe` y `events/unsubscribe` en el mismo endpoint autenticado que las tools. + - Entrega solo por webhook firmado con Standard Webhooks; la verificación del callback falla con `-32015`. + - Hay que hacer "Rescan" del servidor cuando cambian tools o eventos. + - La página **no** aclara si un conector MCP ya conectado cuenta como plugin, ni qué plan de ChatGPT hace falta. Por eso no se supone nada: lo verifica 8.0. + - **8.0 Verificación de requisitos** (antes de implementar 8e; idealmente al inicio del proyecto). Con la integración de Alera ya conectada en ChatGPT: + 1. Confirmar el tipo de cuenta o workspace y que existen Work chats y los controles de plugins y tareas activadas por eventos. + 2. Comprobar si la conexión actual figura como plugin (página de plugin, sección "Tools and events") o si hay que crear o convertir un plugin. + 3. Hacer Rescan y registrar qué métodos y qué versión de protocolo envía ChatGPT al edge (métricas y logs de metadatos de `/v1/mcp`, sin cuerpos). + 4. Comprobar qué scopes y token usa al llamar `events/*`. + 5. Comprobar si ChatGPT permite crear una tarea activada por eventos sobre esa conexión. + - El resultado es un acta con cada requisito cumplido o faltante y los pasos de configuración exactos. Si falta algo que no depende de Alera (plan, permisos de workspace), se informa como bloqueo externo y se sigue con 8a-8d. + - **Implementación (8e)**: + - El edge negocia `2026-07-28` sin romper `initialize` en las versiones anteriores. + - `server/discover` con `capabilities.events`; `events/list` con los tipos del catálogo v1 y su `payloadSchema`; `events/subscribe` y `events/unsubscribe`, ligados al grant y revalidados en cada entrega. + - Las suscripciones se guardan en `event_subscriptions` con `target_kind = mcp_events` y se detienen al revocar el grant. + - Mismo worker, cursor de reanudación de 24 h, `refreshBefore` y `truncated`. + - Flag `MCP_EVENTS_ENABLED` (edge y nube), apagada por defecto. + - **8f Prueba real de extremo a extremo (obligatoria)**, con la integración conectada: + 1. Rescan. + 2. En un Work chat, pedir a ChatGPT que se suscriba a `inbox.reply` filtrado por un `threadId` creado con `ask_agent`. + 3. Verificar el challenge firmado y la suscripción guardada. + 4. El agente responde con `orchestration reply`. + 5. Callback firmado con 2xx. + 6. ChatGPT ejecuta la tarea y continúa en el chat llamando a `show_inbox_thread` o `wait_for_reply`. + 7. `events/unsubscribe` detiene la entrega. + 8. Repetir tras reiniciar el runtime y con un grant revocado. + - **Criterio go:** continuación observable en el chat en menos de 2 minutos en 3 de 3 intentos. **No-go:** la flag sigue apagada, el acta documenta la causa y el polling con cursor queda como camino principal (ya validado en 8a). + +## 7. Compatibilidad y migraciones + +| Ámbito | Cambio | Compatibilidad | +|---|---|---| +| Protocolo MCP | Añadir `2026-07-28` (edge y stdio): `server/discover` y métodos de eventos; se conservan `2025-11-25`, `2025-06-18` y `2025-03-26` | Los clientes antiguos siguen igual | +| Catálogo | v2; el edge acepta v1 y v2 | Un runtime antiguo responde `tool_unavailable` con "Update Alera" (ya existe) | +| Orden de despliegue | 1) runtime (release); 2) nube (migraciones y endpoints); 3) edge (catálogo v2, admin, eventos) | El edge puede listar herramientas que un runtime antiguo no tiene: error claro | +| Runtime store | `ensure_column` y esquemas `IF NOT EXISTS`: `promptWorkspaceOperations`, `runtimeEvents`, `mcpMutationReceipts` | Hacia adelante sin versión; las tablas nuevas se ignoran en un downgrade | +| Nube (Postgres) | `0024_mcp_admin.sql` (CHECK), `0025_domain_events.sql` (`domain_events`, `event_subscriptions`, `event_deliveries`, scope `events:send`) | Aditivo; `REQUIRED_SCHEMA_MIGRATION_VERSIONS` se actualiza | +| CLI | Flags nuevos aditivos. `workspace start` gana defaults y deja de exigir `--source-branch` (relaja una validación) | Sin rupturas | +| GUI | Migración por capability (From Prompt, PR dispatch, stacks, buffer save, watch multi-forja) | Fallback a la ruta actual con hosts antiguos | +| Forjas (F3) | El runtime usa `gh`, `glab` y `az` (con la extensión `azure-devops`) en el host que posee el checkout | Sin la CLI o sin autenticación: `provider_unavailable` con la acción concreta; el desktop sigue funcionando con su implementación Dart | +| Protocolos terminal-host y mobile | Sin cambios de versión estricta | — | + +## 8. Fases, PRs y estimaciones (orientativas) + +PRs apilados hacia `main`, un commit por PR y títulos en minúsculas con Conventional Commits. Las estimaciones son días-persona de un ingeniero con contexto del repo. + +| Fase | PR | Contenido | Est. | +|---|---|---|---| +| **0. Fundaciones** | 0a `refactor: split mcp tool catalog by domain` | Catálogo por dominio, `ToolSpec` v2 (idempotent, structured, outputSchema), executor con `structuredContent`, modelo de errores y `ALERA_MCP_ORIGIN` | 2 | +| | 0b `feat: add admin mcp access level to the runtime` | `McpAccess::Admin`, `ToolAccess::Admin`, relay y auth, CLI `--access`, `mcp serve --access` (11.R1), desktop MCP Control y tests | 2 | +| | 0c `feat: add mcp:admin scope to cloud oauth` | Migración 0024, consentimiento, gateway y contratos | 2 | +| | 0d `feat: accept admin tools and catalog v2 at the edge` | Edge: scopes, validador, relay y tests | 1 | +| | 0e `feat: add mcp mutation receipts and exclusion tests` | `mcpMutationReceipts`, `catalog_exclusion_tests` y test de que cada `Invocation` la acepta el parser clap del CLI | 1.5 | +| **1. From Prompt** | 1a `feat: add persisted prompt workspace operations` | Tabla, RPC `workspace.promptStart.*`, fases, cancelación y reconciliación | 3 | +| | 1b `feat: infer project in workspace identity generation` | Identidad v2 con proyectos, `needsInput` y error `ai_assist_unavailable` como la UI | 1.5 | +| | 1c `feat: expose prompt workspace start in cli and mcp` | CLI `prompt-start`, 6 herramientas, fix de `workspace start` y corrección de `start_agent_workspace` | 2 | +| | 1d `feat: use the runtime prompt workspace service on desktop` | Migración desktop por capability | 2 | +| | 1e `feat: use the runtime prompt workspace service on mobile` | Migración mobile | 1.5 | +| **2. Workspaces** | 2a `feat: add workspace show, wake and list filters` | `workspace show`, `workspace.wake`, filtros, pin, archive, focus, rename y herramientas | 2 | +| | 2b `feat: expose sections, tags and relations over mcp` | Herramientas y `--tree` | 1.5 | +| | 2c `feat: remove workspaces with ui semantics` | Buffer save (`checkoutBuffersSaveRequested` en desktop y mobile), `--editor-buffers`, preview, reenvío de `storageImpact` remoto, `remove_workspace` y `remove_project` | 3.5 | +| | 2d `feat: expose hand-off, recovery and setup over mcp` | Hand Off, recovery, setup, register y unregister | 1.5 | +| **3. Proyectos** | 3a `feat: add project rename, clone and branches commands` | CLI y herramientas de clone, rename, branches y hosts | 2 | +| | 3b `feat: expose project config over cli and mcp` | `project config` | 1.5 | +| **4. Tabs, terminales y perfiles** | 4a `feat: add tab rename, title and terminal lifecycle commands` | Rename, título, restart, terminate, pulse y prune | 2 | +| | 4b `feat: expose agent profiles over mcp with admin-only changes` | Lectura y lanzamiento de perfiles en Full (con reanudación de sesión); CRUD, reorden y perfil por defecto en Admin (F2) | 1.5 | +| | 4c `feat: expose runtime settings, quotas and resources over mcp` | Ajustes con allowlist, integraciones, cuotas, recursos y voz | 2 | +| **5. Inbox y orquestación** | 5a `feat: attribute shared inbox questions to mcp clients` | Origen, filtros, `wait_for_inbox`, cancel, read, conversations, purge, banner y UI del inbox | 2.5 | +| | 5b `feat: expose orchestration administration over mcp` | Tareas, dispatch, coordinador, board, gates (lista y creación), run policy (propuesta y vista), recover, transfer y reset | 2.5 | +| | 5c `feat: expose workflow proposals, plans and cleanup over mcp` | CLI nuevo para `workflows.*` (sin decisiones) y herramientas | 3 | +| **6. Automations** | 6a `feat: expose automation lifecycle over mcp` | CRUD, filtros, run-now completo, runs, take-over, plantillas y export/import | 3 | +| **7. Pull requests (multi-forja, F3)** | 7a `refactor: add a forge provider abstraction to runtime pull requests` | Trait `ForgeProvider`, GitHub movido detrás, fixtures compartidas exportadas desde las pruebas Dart y tests de regresión de GitHub | 3 | +| | 7b `feat: support gitlab merge requests in the runtime` | `GitLabProvider` (`glab`), portado de `gitlab_*` Dart: snapshot, pipelines, comentarios, create, merge, draft, close, summaries | 4 | +| | 7c `feat: support azure devops pull requests in the runtime` | `AzureDevOpsProvider` (`az repos pr`, policies y builds), portado de `azure_devops_*` Dart | 4 | +| | 7d `feat: add pr cli and mcp tools over runtime forge providers` | `alera pr` (show, summaries, create, details, link, comment, draft, close, merge, ship) y herramientas, con las tres forjas | 3 | +| | 7e `feat: dispatch restack and failed checks from the runtime` | Prompts únicos en Rust, RPC, CLI, herramientas y adopción desktop y mobile | 2 | +| | 7f `feat: run watch and fix and ship follow-up for every forge` | Evaluación y merge del watch vía `ForgeProvider`, `pullRequestWatchExecutionV2`, desktop sin doble vigilancia y ship con seguimiento | 3 | +| | 7g `feat: port pull request stacks to the runtime` | `pullRequestStack.*` (solo GitHub), CLI, herramientas y adopción desktop | 3 | +| **8. Eventos** | 8.0 (spike, sin código de producto) verificación de requisitos de MCP Events con la integración conectada | Acta con plan o workspace, controles, si la conexión es plugin, versión y métodos observados, scopes y pasos de configuración | 1 | +| | 8a `feat: add runtime event journal and polling tools` | `runtimeEvents`, escritura en todas las transiciones, `events list/wait`, herramientas y test de payloads | 3 | +| | 8b `feat: add resource subscriptions to mcp serve` | Modo listen del cliente host, resources, subscribe, progress y `subscriptions/listen` | 3 | +| | 8c `feat: forward runtime domain events to the cloud` | Reenviador persistente y endpoint con `events:send` (migración 0025, parte 1) | 2.5 | +| | 8d `feat: deliver signed webhooks for runtime events` | Tablas, worker, firma, SSRF, CLI, UI desktop y herramientas admin | 4 | +| | 8e `feat: support mcp events at the edge behind a flag` | `2026-07-28` en el edge, `server/discover` y `events/*`, ligado al grant | 3 | +| | 8f (prueba obligatoria) E2E real de MCP Events con la integración de Alera conectada en ChatGPT | Guion 6.5.5, criterio go/no-go y acta con evidencia; el polling se valida como fallback | 1.5 | +| **9. Cierre** | 9a `docs: document mcp parity and update agent skills` | `docs/remote-mcp.md`, `docs/mcp-capability-gap-audit.md`, skills `alera-cli` y `alera-orchestration`, `AGENTS.md` (eventos, admin) | 1.5 | +| | 9b Aceptación E2E completa | Matriz 9.4 en Linux, macOS y Windows | 2 | + +**Total orientativo**: unos **90 días-persona**. Por fases: 0 = 8.5, 1 = 10, 2 = 8.5, 3 = 3.5, 4 = 5.5, 5 = 8, 6 = 3, 7 = 22, 8 = 18, 9 = 3.5. Con dos o tres personas en pistas paralelas, del orden de 7 a 10 semanas de calendario. + +**Dependencias**: + +- 0 antes que todo; 0a antes que 0b-0e. +- 1a → 1b → 1c → 1d y 1e. +- 2c necesita el buffer save en GUI. +- 5a necesita 0a (origen). +- 7a → (7b ∥ 7c) → 7d → 7f; 7e y 7g solo dependen de 7a. +- 8.0 se adelanta al inicio del proyecto: sus hallazgos pueden cambiar el alcance de 8e. +- 8a → 8b; 8a → 8c → 8d → 8e → 8f. La 8f necesita además 5a (atribución) para la prueba con `ask_agent`. + +**Pistas paralelas posibles** tras la fase 0: (1), (2+3), (4+6), (5), (7a-7d), (7e-7g) y (8a-8b). La 8.0 puede hacerse en paralelo a la fase 0. + +## 9. Pruebas + +### 9.1 Unitarias y de contrato + +- **Runtime** (`cargo test -p alera-cli`): + - Catálogo: schemas válidos, la exclusión, cada `Invocation` aceptada por `clap` (`Cli::try_parse_from`), anotaciones y niveles. + - Executor: `structuredContent`, errores y origen. + - `relay_mcp_tests` con admin. + - `mcp_settings` con cuatro niveles. + - Algoritmo From Prompt con AI Assist falso (comando personalizado de AI Assist con respuestas fijas): proyecto único, inferido, ambiguo (`needsInput`), colisión con reintento y sufijo, "Others", fallo de lanzamiento y `retryLaunch`, cancelación y reinicio del host (reconciliación), idempotencia por `requestId`. + - Payloads de eventos frente a la lista blanca. + - `mcpMutationReceipts`. +- **alera-core**: esquemas nuevos, retención de `runtimeEvents` y migración idempotente. +- **Nube** (`cargo test`, con `TEST_DATABASE_URL` y `--include-ignored`): scope admin no concedido por defecto, el consentimiento lo marca explícitamente, el gateway lo exige, el CHECK de la migración, ingesta idempotente de domain events, worker de webhooks (reintentos, 410, 413, lease), SSRF y firma. +- **Edge** (`bun run check`, `bun test`): admin, catálogo v2, `server/discover`, `events/*` con la flag apagada y encendida, y ligadura al grant. +- **Desktop y mobile** (`flutter test`): MCP Control de cuatro niveles, migración From Prompt por capability (con y sin servicio), buffer save, origen en el inbox y dispatch de PR. + +Notas de entorno: + +- En `cargo test`, desactivar las `ALERA_*` heredadas con un bucle `unset`; en zsh, `env $LISTA` no separa palabras. +- Ejecutar `dart run tool/quality/check_max_lines.dart` (incluye `--packages` de mobile) antes de cada push. +- Inicializar `third_party/xterm` en worktrees para mobile. + +### 9.2 Integración del runtime + +Se amplía el harness `rust/alera-cli/tests/terminal_host_headless_runtime.rs` con casos MCP: + +- `mcp serve` sobre un runtime aislado (`ALERA_RUNTIME_DIR` temporal) y un repo Git de fixture +- llamadas `tools/call` reales para cada dominio +- `resources/subscribe` recibiendo `notifications/resources/updated` al responder un agente simulado + +### 9.3 E2E remoto + +Se amplía `edge/tool/relay_integration.mjs` (Miniflare) con la ruta MCP (hoy usa `mcp: None`). En local van nube, edge (`wrangler dev --local`) y runtime con `alera account login --device`; se prueban OAuth (consentimiento con y sin admin), llamadas Read, Full y Admin, rechazo por scope o nivel, eventos reenviados y webhook a un receptor local (HTTPS con certificado de prueba). + +### 9.4 Aceptación manual en máquina real + +Script `tool/ci/mcp_parity_acceptance.py` (patrón de `tool/ci/*_acceptance.py`): + +- usa el SDK MCP de Python como cliente stdio +- recorre la matriz 2.1 por dominio sobre un runtime aislado sin tocar la configuración del usuario +- guarda la evidencia (JSON de cada llamada) + +Comprobaciones manuales: + +1. From Prompt por MCP frente a la UI con el mismo prompt: misma sección o "Others", setup visible en desktop y mobile, agente activo. +2. Borrado con buffers sucios en desktop (Save y Discard) y con cambios git (se pierden, como la UI), con rama borrable y no borrable. +3. PR en repos de prueba de **GitHub, GitLab y Azure DevOps**: create, comment, draft o ready, ship con seguimiento, restack, fix checks, watch (`fix` y `fixAndMerge`) y merge con cada método de la forja. Stacks solo en GitHub. Todo con un grant **Full**. +4. Inbox compartido con dos clientes (ChatGPT y Claude o stdio): atribución, `own` y `all`, y que nadie reacciona a respuestas ajenas. +5. ChatGPT, con la integración ya conectada: acta 8.0, polling con cursor y prueba real de MCP Events (8f) con su criterio go/no-go. +7. Permisos: con un grant Full, borrar workspace o proyecto, merge y `purge_automations` funcionan; crear, editar o borrar perfiles y `set_default_agent_profile` devuelven `scope_required` o `access_denied`; con Admin funcionan. +6. Windows y macOS: From Prompt, borrado y eventos stdio (con los skills `build-on-windows` y `build-on-macos`). + +### 9.5 Criterios de aceptación por fase + +| Fase | Criterio verificable | +|---|---| +| 0 | Los grants existentes no ven herramientas Admin; un grant nuevo con admin y el runtime en admin ejecuta una herramienta Admin de prueba; con runtime en `full` devuelve `runtime_read_only` o `access_denied`; el test de exclusiones pasa; todas las herramientas devuelven `structuredContent` válido | +| 1 | El mismo prompt da nombre, rama y sección equivalentes por UI y por MCP; ambigüedad → `needsInput` con candidatos; un reintento con la misma `clientRequestId` no duplica; tras reiniciar el host, la operación queda `failed` y `retryLaunch` funciona; ninguna llamada pasa de 50 s | +| 2 | Con un grant **Full**, `remove_workspace` replica los resultados de la UI en los 6 casos (rama borrable o no, buffers save o discard, dependencias, bloqueo de almacenamiento, archivado, remoto); secciones y tags cuadran con la UI en vivo | +| 4 | Con Full, `list_agent_profiles`, `show_agent_profile` y `launch_agent` funcionan, y toda mutación de perfiles (incluidas reduced protections y el perfil por defecto) devuelve `scope_required` o `access_denied`; con Admin funciona | +| 3, 6 | Cada fila de la matriz tiene una prueba E2E verde en el script de aceptación; las automations se crean activas con Full | +| 5 | Dos clientes ven el historial compartido con `isOwn` correcto; el agente ve el nombre del cliente en el banner; ninguna herramienta de decisión humana está en el catálogo | +| 7 | En repos de prueba de GitHub, GitLab y Azure DevOps se completa desde MCP, con un grant Full, el flujo create → comment → draft/ready → ship con seguimiento → watch → fix → merge. Las fixtures compartidas pasan en Dart y Rust. Los stacks funcionan en GitHub y devuelven `provider_unsupported` en las otras forjas. El desktop no vigila dos veces | +| 8 | `wait_for_events` recibe `inbox.reply` en menos de 2 s tras la respuesta; stdio recibe `resources/updated`; el webhook llega firmado y verificable con reintentos probados; sin suscripciones no se reenvía nada a la nube (C1); acta 8.0 completa; 8f ejecutada con la integración conectada y resultado go (3 de 3 continuaciones en menos de 2 min) o no-go documentado con el polling validado | +| 9 | La documentación refleja solo el comportamiento implementado; la matriz se cumple al 100% en Linux, macOS y Windows | + +## 10. Riesgos y mitigaciones + +| Riesgo | Impacto | Mitigación | +|---|---|---| +| Unas 150 herramientas superan los límites prácticos de algunos clientes (descubrimiento, contexto del modelo) | Herramientas ignoradas o mal elegidas | Descripciones concisas; agrupar en el catálogo por dominio. Opcional: "toolsets" activables en MCP Control (aditivo, sin bloquear) | +| La inferencia de proyecto con IA falla o es inconsistente | Workspaces en el proyecto equivocado | Validación estricta contra la lista; `needsInput` ante la duda; nunca elegir en silencio | +| Buffer save entre clientes (desktop o mobile desconectados) | Borrado bloqueado | Error `blocked` claro con alternativa `editorBuffers: discard`; el default `save` nunca pierde datos en silencio | +| Pérdida de cambios git sin commitear al borrar (semántica de la UI, disponible con Full por F1) | Pérdida de datos | Advertencia literal de la UI en la descripción de la herramienta y en el resultado (`uncommittedChangesLost`) | +| Multi-forja (F3): `glab` y `az` en el host del checkout, diferencias semánticas entre forjas (métodos de merge, drafts, checks) y dos implementaciones (Dart y Rust) | Errores en hosts sin CLI y deriva | `provider_unavailable` con acción concreta; mapeo explícito por forja; fixtures compartidas Dart y Rust; repos de prueba en las tres forjas | +| Credenciales de prueba para GitLab y Azure DevOps | Bloquea la aceptación de 7b y 7c | Preparar proyectos de prueba y tokens antes de la fase 7 | +| MCP Events: la conexión actual puede no contar como plugin, el plan o workspace de ChatGPT puede no tener Work chats o tareas activadas por eventos, y la continuación no está demostrada (EducUp) | Prueba 8f imposible o no-go | 8.0 lo verifica antes de invertir en 8e; los bloqueos externos se informan; el polling y los webhooks siguen siendo el camino garantizado | +| Desfase entre el catálogo del edge y los runtimes | Errores `tool_unavailable` | Mensaje con la versión mínima; orden de despliegue (sección 7) | +| Fiabilidad de reenvío de eventos (hoy el push vive solo en memoria) | Eventos perdidos | Diario persistente con `forwardedAt` y reintentos; ingesta idempotente | +| Fuga de contenido por PR o comentarios a la nube y al proveedor | Privacidad | Aceptado por U6; solo en Full; los eventos sin contenido | +| Bug actual: `storageImpact` no se reenvía a hosts remotos | Borrado remoto bloqueado en desktop | Se corrige en 2c, con test | +| Bug actual: mobile llama a `workspace.runSetup` sin estar en la allowlist | Recovery falla en el teléfono | Corregir en 2d (añadir a `mobile_request_allowed`) | +| Carga de mantenimiento (CLI, catálogo, edge, GUI) | Deriva | Test catálogo↔edge (existe), test catálogo↔clap y test de exclusiones | + +## 11. Supuestos y decisiones residuales (no bloqueantes) + +| # | Default adoptado | Alternativa | ¿Bloquea? | +|---|---|---|---| +| R1 | **`mcp serve` local**: conserva el comportamiento actual (Full aunque MCP Control esté en Off, porque es confianza local de un usuario con shell). Se añade `--access read|full|admin`; Admin solo con `--access admin` explícito; `--read-only` queda como alias. MCP Control indica "aplica a clientes remotos" | Respetar el nivel del runtime en local (rompería configuraciones locales existentes cuando está en Off) | No | +| R2 | Resuelta por F1 y F2 (sección 3.2) | — | — | +| R3 | `cancel_task` se queda en Full por compatibilidad | Moverla a Admin (rompe grants) | No | +| R4 | `wait_for_inbox` usa `own` por defecto; `list_inbox_threads` usa `all` | Ambos en `all` | No | +| R5 | `mode: auto` = worktree en repos Git; la UI envía su modo explícito | `auto` = carpeta del proyecto, como la UI | No | +| R6 | Webhooks gestionables por MCP solo con Admin, y desde la UI y el CLI | Solo desde la UI | No | +| R7 | Resuelta por F3: multi-forja obligatoria (fase 7) | — | — | +| R8 | El prompt de From Prompt se conserva hasta completar y luego solo su hash | Conservarlo para auditoría | No | +| R9 | Resuelta (C3): sin AI Assist, mismo error que la UI | — | — | +| R10 | `purge_inbox` queda en Admin porque borra el historial compartido de todos los clientes (no lo nombra F1) | Moverlo a Full | No | +| R11 | Stacks solo en GitHub, igual que el desktop (GitLab y Azure DevOps no tienen equivalente en Alera) | Diseñar stacks para otras forjas (fuera de la paridad) | No | +| R12 | El desktop conserva su implementación Dart de PR; la adopción del runtime solo es obligatoria para watch (evitar doble dispatch) y agent dispatch | Migrar todo el desktop al runtime | No | + +No hay preguntas bloqueantes. + +## 12. Fuentes + +- Auditoría: [mcp-capability-gap-audit.md](mcp-capability-gap-audit.md), secciones 4, 6 y 11. +- Código citado con rutas y líneas a partir de `c8b3d3984`. +- Referencia de eventos: `~/Projects/educup/educup-automations/docs/mcp-events.md`. +- Externas: [OpenAI MCP Events](https://developers.openai.com/plugins/build/mcp-events) y [MCP 2026-07-28](https://blog.modelcontextprotocol.io/posts/2026-07-28/). + +## 13. Estado de implementación (rama `feat/mcp-parity`) + +Implementado y verificado: + +- **Catálogo:** 222 herramientas del runtime (87 Read, 112 Full, 23 Admin), más `list_runtimes`, `list_skills` y `read_skill`, que responde el edge. Los tests de catálogo verifican tres cosas: que cada invocación la acepta el parser real del CLI, que ninguna herramienta llega a un comando excluido ni a una decisión humana, y que los niveles siguen F1 y F2. +- **Fases 0-6, 7 y 8:** implementadas tal como describe este plan. +- **Validación:** + - Rust `alera-cli`: 2036 tests en verde; `alera-core` con la feature `runtime` también en verde. + - Cloud: 86 tests unitarios y 13 contratos con Postgres. + - Edge: 106 tests. + - Flutter: 4505 tests en desktop y 937 en mobile. + - `flutter analyze`, clippy, fmt, el ratchet de líneas y la consistencia de codegen sin incidencias. +- **Aceptación de extremo a extremo** con `tool/ci/mcp_parity_acceptance.py`: 10 de 10 escenarios. Se ejecuta con un cliente MCP real (`alera mcp serve`) sobre un runtime aislado y cubre: + - New Workspace from Prompt: inferencia, sección y "Others", idempotencia, candidatos, modo carpeta del proyecto y colisiones; + - el diario de eventos; + - las suscripciones a recursos. + +Diferencias con el plan: + +- **Idempotencia:** no se creó la tabla genérica `mcpMutationReceipts`. Usan las claves nativas existentes (`--client-mutation-id`, `--request-key`, `requestId`) donde las hay. +- **Ship:** sigue siendo síncrono y puede superar los 58 s del cliente. El ship continúa en el runtime y se consulta luego con `get_pull_request`. +- **Merge en Azure DevOps:** solo `mergeCommit` y `squash`, como en el desktop. +- **Stacks:** el desktop sigue usando su implementación Dart de stacks. + +Pendiente (requiere despliegue, cuentas reales o pruebas manuales): + +- **MCP Events (8.0 y 8f):** la verificación de requisitos y la prueba real en ChatGPT necesitan desplegar edge y nube, con estos ajustes: + - `ALERA_WEBHOOK_SECRET_KEY`; + - `MCP_EVENTS_ENABLED` / `ALERA_MCP_EVENTS_ENABLED`; + - `EVENT_DELIVERY_PUMP=true`, o `cpu_idle = false` en Cloud Run. +- **PR en forjas reales:** pruebas de extremo a extremo con cuentas y repos reales de GitLab y Azure DevOps (`glab`, `az`). +- **Pruebas manuales en máquina real:** + - borrado con buffers sucios (save y discard); + - wake; + - Setup lanzado por el host; + - inbox compartido entre dos clientes; + - Windows y macOS. + +### 13.1 Revisión de seguridad + +Corregido en el runtime: + +- **Buffers sucios:** solo un cliente local puede pedir `save` o `discard` de editores, y solo al borrar un workspace. Un teléfono o un satélite no pueden resolver los editores de otro cliente. +- **Satélites:** el hub rechaza `workspace.promptStart.*` (start, retryLaunch, cancel) y `workspace.wake` de un host remoto. Además, quita `externalOrigin`, `origin` y `resolution` de todo payload reenviado. +- **Origen de New Workspace from Prompt:** la conexión decide la superficie (desktop, mobile, cli). Solo el CLI local que ejecuta una herramienta MCP puede nombrar al cliente MCP. El origen se normaliza con las mismas cotas que el buzón. +- **`requestId` de New Workspace from Prompt:** limitado a 128 caracteres y separado por cliente MCP. Dos clientes que elijan la misma clave nunca comparten operación. +- **`ALERA_MCP_ORIGIN`:** no pasa a un runtime arrancado por una llamada ni a sus terminales. +- **`list_webhooks`:** pasa a Admin, porque la URL de callback suele llevar el token del receptor. +- **Borrado bloqueado:** si un borrado se bloquea por editores, el mensaje ya no dice que no cambió nada cuando las automations dependientes ya se pausaron y sus ejecuciones se cancelaron. +- **Test de exclusiones:** ahora recorre todas las variantes de argumentos de cada herramienta y toda la ruta de subcomandos. + +Corregido en las forjas de PR: + +- **Inyección en Windows:** `glab` y `az` ya no pasan texto libre por la línea de comandos. + - GitLab crea, comenta y edita con `glab api --input -` y el cuerpo JSON por stdin. + - Azure crea con `az devops invoke --in-file`. + - En Windows el runner local lanza la CLI directamente, resolviéndola con `PATH` y `PATHEXT`, sin pasar por `cmd.exe`. Nunca busca la CLI en el checkout. +- **Expansión `@file` de `az`:** se rechaza cualquier argumento que empiece por `@`. Una rama así se pasa como `refs/heads/@…`. +- **Carrera al mezclar en Azure:** el merge envía `lastMergeSourceCommit` con la cabeza esperada para que Azure rechace una cabeza vieja. Con un checkout local el cuerpo va en un archivo. En un host SSH va por stdin (`--in-file /dev/stdin`); en un host Windows, que no tiene esa ruta, el merge se rechaza en vez de hacerse sin la guarda. +- **Setup en reintentos:** el id de la pestaña Setup se guarda antes de crearla, así que el setup corre como mucho una vez. Si no se puede confirmar, queda el comando con un aviso para que el usuario decida. +- **Archivo temporal de Azure:** se crea nuevo y solo para el dueño (0600), y se borra al terminar la llamada. + +Corregido en los eventos de la nube: + +- **MCP Events respetan MCP Control:** con el runtime en Off no hay fan-out ni replay, la entrega en cola se detiene con `runtime_mcp_disabled` y una suscripción nueva recibe `409`. Los webhooks no dependen de MCP Control. +- **Bomba de entregas:** `/v1/internal/*` exige siempre el token de origen. +- **Un evento rechazado ya no bloquea el reenvío:** la nube guarda el resto del lote y lista los rechazados en `rejected`. +- **Eventos al crear una suscripción:** el forwarder refresca el conteo al crear un webhook. Mientras el conteo es cero, solo descarta eventos anteriores al último refresco. + +Decisión abierta (M6), sin cambios de nivel hasta que el usuario decida: + +- **El problema:** Full ya permite ejecutar comandos arbitrarios en la máquina del runtime por varias vías: + - `create_tab` con `command`; + - la entrada de `pulse`; + - `write_terminal`; + - la configuración del proyecto (setup) seguida de `run_workspace_setup`; + - el `precheck.command` de una automation. +- **La consecuencia:** un cliente Full puede llegar por esas vías a cualquier comando del CLI, incluidos los excluidos (`orchestration gate-resolve`, `mcp`, `account`). La exclusión del catálogo impide que una herramienta los invoque directamente, pero no es una barrera frente a un cliente Full decidido. +- **Opciones:** + - aceptarlo y documentarlo en la UI de MCP Control como hoy (`write_terminal`); + - subir a Admin las herramientas que aceptan comandos libres (`create_tab.command`, la configuración de setup y `precheck.command`), sin tocar `write_terminal` ni `pulse`, que son el uso principal de Full; + - restringir los comandos libres a una lista blanca por proyecto. + +### 13.2 Skills + +Decisiones del usuario (2026-10-10): +- S1: solo skills de Alera. +- S2: la instalación queda fijada a la versión del runtime. +- S3: no se exponen las skills personales. +- S4: entra en este mismo PR. + +Además, el usuario pidió dos cosas: que las skills del MCP sean hermanas de las del CLI, escritas para clientes MCP, y que vivan solo en el MCP de la nube, no en los runtimes. + +- **Skills hermanas en el edge:** + - Son cuatro, en `edge/skills/`: `alera-mcp`, `alera-mcp-orchestration`, `alera-mcp-automations` y `alera-mcp-agent-profiles`. + - `bun tool/skill_catalog.ts` genera `edge/src/mcp/skill_catalog.json`. + - `list_skills` y `read_skill` las responde el edge sin contactar al runtime. + - Un test falla en cualquiera de estos casos: el JSON está desactualizado, una skill nombra una herramienta que no existe, una herramienta del catálogo no aparece en ninguna skill, o un enlace no resuelve. +- **Leer primero:** las instrucciones del servidor y la descripción de cada herramienta del edge (excepto las dos de skills) piden leer la skill correspondiente antes de la tarea. `alera mcp serve` no sirve estas skills. +- **Skills de los agentes en terminales:** + - `check_agent_skills` (Read) y `install_agent_skills` (Admin) usan `alera skill status|install`. + - La instalación usa `skills add` con el repositorio en el commit con el que se compiló el runtime. Una release se compila desde el commit de su tag. + - El estado compara `metadata.version` de cada SKILL.md instalada con la constante del runtime. Un test de digest obliga a subir la versión cuando cambia el contenido de una skill. +- **Fallos corregidos de paso:** + - `agentSkill.install`, que usa el móvil, no pasaba `--agent codex --yes`, y sin terminal el instalador no instalaba nada. + - Tampoco aceptaba `agentProfiles`. +- **Pendiente:** el comando que muestra el escritorio en Settings todavía instala desde la rama por defecto, sin fijar el commit. diff --git a/docs/pull-request-watch.md b/docs/pull-request-watch.md index a6a307af0..eeb66db1d 100644 --- a/docs/pull-request-watch.md +++ b/docs/pull-request-watch.md @@ -1,11 +1,13 @@ # Pull Request Watch -For GitHub, a runtime advertising `pullRequestWatchExecutionV1` owns Watch and Fix and Watch, Fix and Merge. Mobile and desktop activate and stop the same persisted workspace watch with `pullRequestWatch.start` and `pullRequestWatch.stop`; `pullRequestWatch.list` and `pullRequestWatchChanged` supply both the PR panel and workspace-row indicators. The capability is advertised in the local control file, `status.get`, and `mobile.hello`, without changing either strict protocol version. +A runtime advertising `pullRequestWatchExecutionV2` owns Watch and Fix and Watch, Fix and Merge for GitHub, GitLab, and Azure DevOps; one advertising only `pullRequestWatchExecutionV1` owns them for GitHub. Mobile and desktop activate and stop the same persisted workspace watch with `pullRequestWatch.start` and `pullRequestWatch.stop`; `pullRequestWatch.list` and `pullRequestWatchChanged` supply both the PR panel and workspace-row indicators. The capability is advertised in the local control file, `status.get`, and `mobile.hello`, without changing either strict protocol version. The runtime polls in background jobs every 30 seconds and immediately after activation, with at most four concurrent snapshot reads. An active watch keeps an otherwise idle runtime alive. Closing a PR panel or disconnecting a client does not stop execution. Watches and dispatch watermarks survive runtime restart. Updates and stops invalidate pending evaluations; a late result cannot recreate a stopped watch or overwrite a replacement. Failed checks, unresolved current review threads from another author, and merge conflicts are evaluated according to the selected scope. The same concerns on the same commit are not sent again. A busy agent is retried on a later poll; an unavailable bound terminal can be replaced using its saved profile. Profile launch and prompt delivery do not select a workspace or tab in either client. -Automatic merge requires an open, non-draft, mergeable PR with successful checks, complete watched comment data, no watched unresolved threads, and a repository-allowed merge method. The GitHub merge command is bound to the evaluated head commit with `--match-head-commit`. A queued merge keeps the watch active until a snapshot confirms the final PR state, without submitting the same head again. Failed reads preserve the watch for retry; a closed, merged, removed, or replaced PR ends it. Stop cancels pending jobs, but cannot undo a merge already accepted by GitHub. +Automatic merge requires an open, non-draft, mergeable PR with successful checks, complete watched comment data, no watched unresolved threads, and a repository-allowed merge method. Evaluation and the merge go through the runtime's `ForgeProvider` (`rust/alera-cli/src/terminal_host/server/pull_request_forges/`), so every forge's snapshot carries the same review, `checks[]`, and comment shape. The merge is bound to the evaluated head commit: `gh pr merge --match-head-commit` on GitHub, `glab mr merge --sha` on GitLab, and on Azure DevOps a fresh read of the head just before the `az` completion call, which has no head guard of its own. GitLab merges with the project's merge setting (`providerDefault`); Azure DevOps uses a merge commit or squash. A queued merge keeps the watch active until a snapshot confirms the final PR state, without submitting the same head again. Failed reads preserve the watch for retry; a closed, merged, removed, or replaced PR ends it. Stop cancels pending jobs, but cannot undo a merge already accepted by GitHub. -Clients connected to older runtimes keep the legacy execution path. GitLab and Azure DevOps watches retain desktop execution; the runtime executor described here is GitHub-specific. Upgrade both clients and the runtime to use shared GitHub execution, since an older desktop does not understand the execution capability. +Clients connected to older runtimes keep the legacy execution path: with only `pullRequestWatchExecutionV1`, GitLab and Azure DevOps watches keep desktop execution. Clients decide per forge (`RuntimePullRequestWatchRepository.ownsExecutionFor` on desktop, `supportsPullRequestWatchExecution` on mobile) and MUST NOT evaluate, dispatch, or merge a forge the runtime owns. The forge CLI (`gh`, `glab`, or `az` with the azure-devops extension) must be installed and signed in on the host that owns the checkout; otherwise snapshots report the missing CLI and the watch waits. Upgrade both clients and the runtime to use shared execution, since an older desktop does not understand the execution capabilities. + +Restack and Fix Failed Checks prompts live in the runtime (`pullRequest.agentDispatch`, capability `pullRequestAgentDispatchV1`). Desktop and mobile ask it for the prompt and keep their own agent picker; the CLI (`alera pr restack` and `alera pr fix-checks`) and MCP also let the runtime deliver it to a terminal or a new profile tab. diff --git a/docs/remote-mcp.md b/docs/remote-mcp.md index 28348b580..c4f4e92a7 100644 --- a/docs/remote-mcp.md +++ b/docs/remote-mcp.md @@ -6,15 +6,16 @@ The same tool catalog is also served locally by `alera mcp serve` over stdio, wi ## Decisions -- MCP Control is a per-runtime opt-in with three levels: `off` (default), `read`, and `full`. Remote Access for the phone stays a separate setting. Either one keeps the cloud link open. +- MCP Control is a per-runtime opt-in with four ordered levels: `off` (default), `read`, `full`, and `admin`. Each level allows everything the previous one does: read tools need `read`, execute tools need `full`, and administrative tools need `admin`. A runtime never moves to `admin` on its own; the user has to choose it. Remote Access for the phone stays a separate setting. Either one keeps the cloud link open. - The privacy boundary differs from the mobile relay. An MCP client sends tool arguments over TLS to the edge, so the edge and the cloud gateway handle tool arguments and results in plaintext while forwarding them. Neither stores them. The audit log keeps metadata only: tool name, runtime, client, time, outcome, and duration. Terminal traffic between a phone and a runtime remains end-to-end encrypted and unchanged. - The Rust cloud service is the only authorization server. It adds standard OAuth endpoints, Dynamic Client Registration, Client ID Metadata Documents, a consent page, and a device authorization flow for headless runtimes. Google and GitHub remain the identity providers. - The MCP endpoint lives in the edge Worker, because Cloud Run requests time out after 30 seconds and the runtime socket already lives in the relay Durable Object. The edge is stateless: it builds no MCP session and keeps nothing between requests. - The runtime is the source of the tool catalog. Each tool maps to one typed `alera` CLI invocation with `--json`, so the MCP tools keep the CLI's exact semantics. The edge serves a generated copy of the catalog and adds the `runtime` argument. - Every routed call carries a short-lived call grant signed by the cloud for one runtime, account, tool, and call id. The runtime verifies it against the published JWKS and refuses replays before running anything, so a frame without such a grant cannot drive a runtime. The edge stays inside the trust boundary: it serves the JWKS and forwards the arguments, which the grant does not cover. - A call names its runtime with `runtime` (name or id). When the grant reaches exactly one connected runtime, `runtime` may be omitted. Nothing is remembered between calls, so concurrent conversations cannot move each other. -- The consent page grants a list of runtimes, or every runtime including future ones, plus the `mcp:read` and `mcp:execute` scopes. +- The consent page grants a list of runtimes, or every runtime including future ones, plus scopes. `mcp:read` is always granted and `mcp:execute` is checked by default. `mcp:admin` is offered only when the client requests it, behind an "Allow administrative tools" box that starts unchecked, and it is granted only together with `mcp:execute`. A tool needs both barriers: the scope of its class on the grant and a high enough MCP Control level on the runtime. Existing grants keep the scopes they were given. - Runtime names are chosen by the user, unique per account (case-insensitive) when set explicitly, and sent again on every sign-in so the cloud never reverts to the host name. Renaming needs a signed-in account so the cloud can reserve the name. Host-name defaults, or a name carried into another account, can still repeat; name-based calls then fail with `runtime_ambiguous` and list the ids. +- `full` is not a sandbox. Execute tools can type into terminals (`write_terminal`, `pulse`), open a tab that runs a command, change a project's setup and run it, or give an automation a precheck command, so a `full` client can run any command the runtime's user can, including the CLI commands no tool exposes. The excluded commands are kept out of the catalog, not out of reach. Grant `full` only to clients trusted with a shell. - Long operations are bounded. Waiting tools accept at most 50 seconds because hosted MCP clients abandon HTTP calls after about a minute; the agent polls again. ## Architecture @@ -61,7 +62,7 @@ Rules: - A `private_key_jwt` client sends an RFC 7523 assertion (`client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`) on both the code and the refresh grant. It must be signed with `RS256`, `PS256`, or `ES256` by a key from the client's `jwks_uri` (named by `kid`, or the only key published), carry `iss` and `sub` equal to the `client_id`, an `aud` that includes the issuer or the token endpoint URL, an `exp` at most five minutes ahead, and a `jti`. Each `jti` is accepted once per client (`mcp_client_assertions`, purged after expiry). The client is authenticated, and must be the client the code was issued to, before the code is consumed or treated as a replay, so a caller without the key cannot spend or revoke a stolen code. The `client_id` form field may be omitted; the assertion subject then names the client. - Client JWKS are fetched with the same guards as metadata documents and cached per instance for ten minutes. Each JWKS is fetched by one request at a time and at most once a minute, failed attempts included, and requests that miss during a fetch wait for its result, so an unknown `kid` or an unreachable host cannot amplify unauthenticated token requests into fetches. The cache holds up to 1,024 URIs; when every entry is still inside its rate window, a new URI is refused until one frees up rather than evicting another URI's guard. - Invalid `client_id` or `redirect_uri` renders an error page instead of redirecting. -- `resource` is optional; when present it must equal the MCP resource. `scope` defaults to `mcp:read mcp:execute`. +- `resource` is optional; when present it must equal the MCP resource. `scope` defaults to `mcp:read mcp:execute` and never includes `mcp:admin` by default. A request that names `mcp:admin` also requests `mcp:execute`, and consent still decides whether to grant it. The metadata `scopes_supported` and the registration response `scope` list `mcp:read`, `mcp:execute`, and `mcp:admin`. - The sign-in, consent, and device pages send `Content-Security-Policy` with `frame-ancestors 'none'` and a `form-action` that admits the client's redirect origin, because the consent form's response redirects there. The edge removes cookies, so the flow carries a single-use consent token in the form instead. - Access tokens are Ed25519 JWTs (`typ: at+jwt`) valid for 15 minutes with `aud` equal to the MCP resource, `client_kind: mcp`, `client_id` equal to the OAuth client id, `gid` equal to the grant id, `sid` equal to the refresh family, and `scope` from the grant. They are rejected by every other cloud route because the audience differs. - Refresh tokens reuse the rotating refresh families (`client_kind = 'mcp'`, `client_id = grant id`). Revoking a grant revokes its families, and `POST /oauth/revoke` with any token of a grant revokes the whole grant, because each grant has one session. @@ -89,7 +90,7 @@ These use the existing runtime and mobile access tokens. | `DELETE /v1/mcp/grants/{id}` | runtime or mobile | Revokes the grant and its refresh families; `204` | | `PUT /v1/runtime/name` | the runtime itself | `{ name }` (1-64 characters, unique per account ignoring case) returns `{ id, name }`; `409 runtime_name_taken` | | `PUT /v1/runtime/capabilities` | the runtime itself | `{ mcpAccess, mobileAccess }` returns `204`. Sent when MCP Control changes, because a runtime that turns both features off stops requesting relay grants | -| `POST /v1/relay/grants` | runtime | Adds optional `mcpAccess` (`off`, `read`, `full`; absent means `off`) and `mobileAccess` (absent means `true`). The cloud stores both on the runtime row and copies them into the runtime's relay grant claims | +| `POST /v1/relay/grants` | runtime | Adds optional `mcpAccess` (`off`, `read`, `full`, `admin`; absent means `off`) and `mobileAccess` (absent means `true`). The cloud stores both on the runtime row and copies them into the runtime's relay grant claims | | `GET /v1/mobile/runtimes` | mobile | Excludes runtimes whose last grant reported `mobileAccess: false` | ## Gateway APIs @@ -102,8 +103,8 @@ The edge returns `404` for `/v1/mcp/calls` and everything under it on the public - `POST /v1/mcp/calls` with `{ runtime?, tool, access }` resolves the runtime and records the call: - `runtime` matches an id exactly or a name ignoring case. Unknown, ungranted, and transferred runtimes all fail with `404 runtime_not_found`. A name shared by several runtimes fails with `409 runtime_ambiguous`. - Without `runtime`, exactly one connected runtime is chosen; none fails with `404 no_runtime_available`, several with `409 runtime_required`. The error message lists the candidate names. - - `access: execute` needs the `mcp:execute` scope (`403 insufficient_scope`). A runtime reporting `off` fails with `409 runtime_mcp_disabled`; `read` refuses execute tools with `403 runtime_read_only`. - - The response is `{ callId, runtimeId, runtimeName, grant, expiresIn }`. `grant` is an Ed25519 JWT with `typ: mcp-call+jwt`, `aud: alera-runtime-mcp`, a 120-second lifetime, `jti` equal to the call id, and claims `accountId`, `runtimeId`, `grantId`, `clientId`, `clientName`, `tool`, and `access`. + - `access` is `read`, `execute`, or `admin`. `execute` needs the `mcp:execute` scope and `admin` needs the `mcp:admin` scope (`403 insufficient_scope`; for `admin` the message asks the user to reconnect and allow administrative tools). A runtime reporting `off` fails with `409 runtime_mcp_disabled`; `read` refuses execute tools with `403 runtime_read_only`; any level below `admin` refuses administrative tools with `403 runtime_not_admin`. + - The response is `{ callId, runtimeId, runtimeName, grant, expiresIn }`. `grant` is an Ed25519 JWT with `typ: mcp-call+jwt`, `aud: alera-runtime-mcp`, a 120-second lifetime, `jti` equal to the call id, and claims `accountId`, `runtimeId`, `grantId`, `clientId`, `clientName`, `tool`, and `access` (`read`, `execute`, or `admin`). - `POST /v1/mcp/calls/{id}/outcome` with `{ outcome, durationMs }` completes the audit row once and returns `204`; a second outcome returns `409 call_already_completed`. Outcomes are `ok`, `tool_error`, `runtime_offline`, `timeout`, and `failed`. - Missing or invalid bearers return `401` (`missing_bearer`, `invalid_token`); a revoked grant or session returns `401 session_revoked` or `invalid_session`, which the edge turns into a `401` with `WWW-Authenticate`. @@ -114,10 +115,13 @@ The audit row (`mcp_calls`) is written before the runtime is contacted. A missin `/v1/mcp` implements stateless Streamable HTTP: - `POST` accepts one JSON-RPC message and answers with `application/json`. Notifications return `202`. `GET` and `DELETE` return `405` because the server keeps no sessions or streams. `OPTIONS` answers CORS preflight. -- Supported protocol versions: `2025-11-25`, `2025-06-18`, and `2025-03-26`. An unknown requested version gets the newest supported one. -- Methods: `initialize`, `ping`, `tools/list`, and `tools/call`. Anything else returns `-32601`. -- A missing or invalid bearer returns `401` with `WWW-Authenticate: Bearer resource_metadata="", scope="mcp:read mcp:execute"`. A read-only token calling an execute tool returns a tool error naming the missing scope. -- `tools/list` serves `edge/src/mcp/tool_catalog.json` plus the gateway tool `list_runtimes`. Each runtime tool gains an optional `runtime` string argument. +- Supported protocol versions: `2026-07-28`, `2025-11-25`, `2025-06-18`, and `2025-03-26`. Handshake clients keep using `initialize`, which negotiates among the three older versions (an unknown requested version gets `2025-11-25`) and answers exactly as before. +- A `2026-07-28` request carries its version in `params._meta["io.modelcontextprotocol/protocolVersion"]` and the `MCP-Protocol-Version` header. A header that disagrees with `_meta`, or an `Mcp-Method` or `Mcp-Name` header (base64 sentinel form accepted) that disagrees with the body, gets `400` with `-32020` (HeaderMismatch). A version the edge does not serve gets `400` with `-32022` and `data: { supported, requested }`. Modern results add `resultType: "complete"`, and an unknown method answers `404` with `-32601`. +- Methods: `initialize`, `server/discover`, `ping`, `tools/list`, and `tools/call`, plus `events/list`, `events/subscribe`, and `events/unsubscribe` while MCP Events is on. Anything else returns `-32601`. +- `server/discover` returns `{ resultType, supportedVersions, capabilities, serverInfo, _meta["io.modelcontextprotocol/serverInfo"], instructions }`; `capabilities` is `{ tools: { listChanged: false } }` and gains `events: {}` only while `MCP_EVENTS_ENABLED=true`. +- A missing or invalid bearer returns `401` with `WWW-Authenticate: Bearer resource_metadata="", scope="mcp:read mcp:execute mcp:admin"`. A token without `mcp:execute` calling an execute tool, or without `mcp:admin` calling an administrative tool, gets a tool error naming the missing scope without contacting the cloud. +- `tools/list` serves `edge/src/mcp/tool_catalog.json` plus the gateway tool `list_runtimes` and the skill tools `list_skills` and `read_skill`. Each runtime tool gains an optional `runtime` string argument. +- Every tool other than the two skill tools ends its description with a reminder to read the matching Alera skill first, and the server instructions say the same. - Calls are limited per token by the `MCP_LIMITER` binding. - `tools/call` asks the cloud for a call grant, then calls the runtime's Durable Object at `/mcp/call` with `{ callId, grant, tool, arguments, timeoutMs }`. The object answers `{ ok: true, result }` or `{ ok: false, code, message }`. The edge records the outcome with `waitUntil`. @@ -128,12 +132,12 @@ MCP traffic shares the runtime's existing relay WebSocket. A frame uses the norm - Durable Object to runtime: `{ "type": "mcp.call", "id", "grant", "tool", "arguments", "timeoutMs" }` and `{ "type": "mcp.cancel", "id" }`. - Runtime to Durable Object: `{ "type": "mcp.result", "id", "result": { "content", "structuredContent"?, "isError" } }` or `{ "type": "mcp.result", "id", "error": { "code", "message" } }`. - A single frame is at most 1 MiB; the runtime truncates command output to stay under it, and answers a call frame it cannot read with an `invalid_call` error instead of leaving the caller waiting. -- The object only routes to a runtime socket whose relay grant carries `mcpAccess` `read` or `full`, rejects pending calls when that socket closes, and never forwards `~mcp` frames to phones. +- The object only routes to a runtime socket whose relay grant carries `mcpAccess` `read`, `full`, or `admin`, rejects pending calls when that socket closes, and never forwards `~mcp` frames to phones. - The relay grant's `mobileAccess: false` makes the object refuse phones with `relay_runtime_unavailable`, and the runtime ignores phone handshakes while Remote Access is off. ## Runtime -- Settings live in `runtimeMetadata`: `settings.mcp.access` (`off`, `read`, `full`) and `settings.runtime.name`. +- Settings live in `runtimeMetadata`: `settings.mcp.access` (`off`, `read`, `full`, `admin`) and `settings.runtime.name`. - The relay link starts when an account is signed in and Remote Access or MCP Control is on, and restarts when either changes. - The runtime verifies each call grant (issuer, audience, signature, expiry, runtime id, account id), checks the tool exists and that its access level is allowed by the local setting, and runs at most four calls at once. - Each tool runs the runtime's own `alera` binary with `--json` and a bounded timeout. The process inherits the runtime's environment, so it talks to the same runtime. @@ -160,13 +164,127 @@ Host requests (local clients only; never forwarded by a satellite or accepted fr The catalog is defined in `rust/alera-cli/src/mcp_tools/`. `edge/src/mcp/tool_catalog.json` is generated from it and a Rust test fails when they differ. Regenerate it with `ALERA_UPDATE_MCP_CATALOG=1 cargo test -p alera-cli mcp_tool_catalog_matches_edge_copy`. -Each entry has `name`, `title`, `description`, `access` (`read` or `execute`), `timeoutSeconds`, `inputSchema`, and `annotations`. +Each entry has `name`, `title`, `description`, `access` (`read`, `execute`, or `admin`), `timeoutSeconds`, `inputSchema`, and `annotations`. The edge accepts catalog versions 1 and 2; version 2 is the one that may contain `admin` tools. Tools that read terminal output drop the `dataBase64` copy of the text before returning it. +## Skills + +MCP clients get Alera skills written for them: sisters of the CLI skills in `skills/`, but naming MCP tools instead of `alera` commands. They live only in the edge, so runtimes neither ship nor serve them: + +- **Sources:** `edge/skills//SKILL.md` and `references/*.md`. `bun tool/skill_catalog.ts` builds `edge/src/mcp/skill_catalog.json` from them, after checking that each `name` matches its folder, `description` and `metadata.version` are present, and file names and sizes are allowed. +- **Tools:** `list_skills` returns each skill's name, description, version, and files. `read_skill { name, file? }` returns one file with its SHA-256 digest. Both are read-only and answered by the edge without a runtime or a call grant. +- **Tests:** `edge/test/mcp_skills.test.ts` fails when any of the following holds: + - the generated JSON is stale; + - a skill names a tool that does not exist; + - a catalog tool is not explained by any skill; + - a link does not resolve. + +A local `alera mcp serve` does not serve these skills. + +The skills coding agents use in Alera terminals (`skills/`) are a separate concern, handled by the runtime tools `check_agent_skills` and `install_agent_skills` (`alera skill status|install`): +- Installs run `skills add https://github.com/leynier/alera/tree/ --skill ... --agent codex --global --yes`, so they match the runtime's release. +- Status compares each installed SKILL.md's `metadata.version` with the runtime's constant. `skill_version_matches_binary.rs` keeps the versions and a content digest in step. + +## Events And Webhooks + +Runtime domain events reach two kinds of subscribers through the cloud: generic webhooks that a signed-in runtime creates for its account, and OpenAI MCP Events subscriptions that an MCP client creates through the edge. The runtime forwards events only while the cloud reports active subscriptions for it, so turning MCP Control on does not by itself open a new data flow. + +### Payload Policy + +Events carry ids and states only, as mobile push does: never prompts, message or reply text, commands, terminal input or output, code, or repository contents. The cloud enforces it twice: + +- Ingest refuses `data` that is not a flat object of at most 20 scalar fields (strings at most 512 characters), whose keys are not short identifiers (`[A-Za-z0-9_.]`, at most 64 characters), or whose keys contain `prompt`, `body`, `text`, `output`, `command`, `subject`, `content`, `scrollback`, or `terminalbytes` (case-insensitive): `400 sensitive_event_data`, `invalid_event_data`, or `event_data_too_large`. +- It then keeps only the keys the catalog lists for the event kind, plus `workspaceName` and `projectName`, and drops everything else before storage. + +| `kind` | `data` keys | MCP Events filters besides `runtime` and `workspaceId` | +| --- | --- | --- | +| `inbox.reply` | `inbox`, `threadId`, `questionId`, `messageId`, `originClientId` | `threadId`, `questionId` | +| `inbox.question.status` | `questionId`, `threadId`, `status` | `threadId`, `questionId` | +| `agent.status` | `workspaceId`, `tabId`, `sessionId`, `state` | `tabId` | +| `terminal.exit` | `workspaceId`, `tabId`, `sessionId`, `exitCode` | `tabId` | +| `orchestration.task.state` | `taskId`, `runId`, `state` | `taskId`, `runId` | +| `orchestration.gate.created` | `gateId`, `taskId`, `runId` | `taskId`, `runId` | +| `orchestration.escalation` | `taskId`, `runId` | `taskId`, `runId` | +| `automation.run.state` | `automationId`, `runId`, `status` | `automationId`, `runId` | +| `workspace.start.state` | `operationId`, `status`, `phase`, `workspaceId` | `operationId` | +| `workspace.lifecycle` | `workspaceId`, `action` | none | +| `pullRequest.watch` | `workspaceId`, `number`, `action` | none | + +The catalog lives in `cloud/src/events/catalog.rs`. `edge/src/mcp/event_catalog.json` is generated from it and a cloud test fails when they differ; regenerate it with `ALERA_UPDATE_EVENT_CATALOG=1 cargo test --lib event_catalog_matches_edge_copy` in `cloud/`. + +### Runtime API + +These use the runtime's own access token. Runtime tokens now carry the scope `events:send`; tokens issued before it keep working with `push:send`. + +| Endpoint | Body | Result | +| --- | --- | --- | +| `POST /v1/runtime/domain-events` | `{ runtimeId, events: [{ eventId, seq, kind, workspaceId?, projectId?, data, occurredAt }] }`, 1 to 100 events, at most 512 KiB | `{ accepted, duplicate, activeSubscriptions, rejected? }`. `accepted` counts newly stored events; `duplicate` counts events already stored for `(runtimeId, eventId)` or repeated in the batch; `rejected` (omitted when empty) lists `[{ eventId, code }]` for events refused by the rules below while the rest of the batch is stored | +| `GET /v1/runtime/event-subscriptions` | none | `{ activeSubscriptions }` | +| `GET /v1/webhooks` | none | `{ webhooks: [{ id, url, kinds, runtimeIds, allRuntimes, status, createdAt, lastDeliveryAt?, lastError? }] }` for the account | +| `POST /v1/webhooks` | `{ url, kinds, runtimeIds?, allRuntimes? }` | `{ webhook, secret }`. `runtimeIds` defaults to the calling runtime; `secret` (`whsec_` + base64 of 32 random bytes) is returned only here | +| `DELETE /v1/webhooks/{id}` | none | `204`; `404 webhook_not_found` | +| `POST /v1/webhooks/{id}/test` | none | `{ deliveryId }` after queueing a signed `alera.test` event (`data: { runtimeId, seq: 0 }`); `409 webhook_inactive` for a stopped webhook | + +Rules: + +- `runtimeId` must be the calling runtime, still owned by the account (`403 runtime_event_not_owned`). `eventId` is a UUID and events are idempotent by `(runtimeId, eventId)`. `seq` is a non-negative integer, `kind` one of the catalog (`400 unknown_event_kind`), `workspaceId` and `projectId` at most 128 characters, and `occurredAt` RFC 3339 no more than five minutes ahead. An event that breaks one of these rules or the payload policy is listed in `rejected` with its code (`invalid_event_field`, `unknown_event_kind`, `invalid_event_time`, `sensitive_event_data`, `event_data_too_large`, `invalid_event_data`) and the others are still stored, so one bad event, including one from a runtime with a skewed clock, never blocks a runtime's journal. Only a malformed batch (`400 invalid_event_batch`) or an ownership failure refuses the whole request. A `workspaceId` that only appears in `data` also fills the envelope, so workspace filters match it. +- `activeSubscriptions` counts active webhooks that name the runtime (or all runtimes) and, while MCP Events is on, active MCP Events subscriptions whose grant is live and reaches the runtime, while MCP Control on the runtime is not `off`. The runtime should forward only while it is above zero and poll `GET /v1/runtime/event-subscriptions` otherwise. Non-GET requests through the edge share the runtime token's burst limit (10 per minute), so forward in batches. +- The runtime forwarder polls the count every minute, and right away after `webhook.create` succeeds. While the count is zero it marks as handled, without sending, only the events that occurred before the last refresh that reported zero; newer events wait for the next refresh. A subscription created between two refreshes therefore receives the events since the earlier refresh, which can include up to a minute of events from before it was created. A batch the cloud refuses as a whole with a client error other than `401`, `403`, `408`, or `429` (an older cloud refuses every batch with one bad event) is logged and skipped; other failures retry the same batch with backoff. Events the cloud lists in `rejected` are logged. +- Webhook kinds are one or more catalog kinds. A webhook names 1 to 20 runtimes of the account or sets `allRuntimes`; an account keeps at most 20 webhooks (`409 webhook_limit_reached`). +- Without `ALERA_WEBHOOK_SECRET_KEY` the cloud refuses webhook and MCP Events creation with `503 webhooks_not_configured` and delivers nothing; ingest and counts keep working. +- Webhooks are not challenged on creation, because generic receivers such as automation tools cannot echo a challenge; the cloud generates their secret, and the test endpoint confirms the receiver. Their URL is checked like an MCP Events callback before it is stored. + +### Callback Rules + +Webhook and MCP Events callbacks use the same egress: + +- HTTPS on port 443, without credentials or a fragment, at most 2048 bytes. `ALERA_WEBHOOK_ALLOW_ANY_PORT=true` admits other HTTPS ports for development. Plain HTTP and private addresses exist only in a test-only policy that the environment cannot enable. +- The host is resolved and refused when any answer is private, loopback, link-local, multicast, unspecified, shared, documentation, or otherwise non-public (`non_public_destination`), and the connection is pinned to the checked address so a second DNS answer cannot redirect it. Proxy environment variables (`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`) are ignored, because a proxy would resolve the host itself. Redirects are never followed. +- Each attempt has a ten-second wall-clock limit, response bodies are capped at 16 KiB, and request bodies at 256 KiB. The worker logs neither URLs nor bodies. + +### Delivery + +- Each event becomes one `POST` per matching subscription: same account, active, not past `refreshBefore`, kind listed, runtime listed or all runtimes, every filter equal to the event's `workspaceId`, `projectId`, or `data` value, and for MCP Events MCP Control on the event's runtime not `off` plus a grant that is not revoked and still reaches the runtime. Webhooks belong to the account owner and do not depend on MCP Control. +- Body: `{ eventId, name, timestamp, data, cursor }`. `name` is the kind, `timestamp` is `occurredAt`, and `data` is the stored data plus `runtimeId`, `seq`, and the envelope `workspaceId` and `projectId`. +- Headers: `content-type: application/json`, `webhook-id` (the `eventId`), `webhook-timestamp` (Unix seconds, new on every attempt), `webhook-signature` (Standard Webhooks `v1,` HMAC-SHA256 of `{id}.{timestamp}.{body}`; while a refreshed MCP Events secret rotates, the old key signs too for five minutes, space-separated), and `X-MCP-Subscription-Id` for MCP Events or `X-Alera-Webhook-Id` for webhooks. +- Outcomes: `2xx` delivers. `410` stops the subscription and its queued deliveries. `413`, `3xx`, and other `4xx` except `408`, `425`, and `429` mark the delivery dead. `5xx`, `408`, `425`, `429`, timeouts, and connection or DNS failures retry after 5 s, doubling to at most 15 minutes, up to 12 attempts inside the 24-hour retention; then the delivery is dead. The subscription keeps the last error code (`http_503`, `timeout`, ...) but never a response body. +- The worker claims due deliveries with `FOR UPDATE SKIP LOCKED` and a 45-second lease, so several Cloud Run instances share the work and an interrupted attempt is retried after the lease. It rechecks the subscription, the grant, and for MCP Events the runtime's MCP Control before each send; a delivery whose runtime has MCP Control `off` is stopped (`runtime_mcp_disabled`) while the subscription stays active. +- The worker runs as a background loop in each instance (`ALERA_EVENT_WORKER_ENABLED`, default on), woken by ingest. Cloud Run with `cpu_idle = true` throttles it between requests; production either keeps CPU allocated, or sets the edge variable `EVENT_DELIVERY_PUMP=true` so the edge's one-minute cron calls `POST /v1/internal/event-deliveries/pump`, which runs one drain of at most 20 seconds. The pump requires the edge origin token (`x-alera-origin-auth`, current or previous) itself, even with `ALERA_ALLOW_DIRECT_ORIGIN=true`, and answers `401 invalid_origin` without it or when no token is configured. The edge never forwards `/v1/internal/*` or `/v1/mcp/event-subscriptions*` from the internet. + +### OpenAI MCP Events + +MCP Events follows the [OpenAI contract](https://developers.openai.com/plugins/build/mcp-events) behind two switches that default to off: `MCP_EVENTS_ENABLED` on the edge (advertises the capability and serves the methods) and `ALERA_MCP_EVENTS_ENABLED` on the cloud (accepts subscriptions, counts them, and delivers). Turning the cloud switch off pauses MCP Events deliveries without touching webhooks: queued deliveries stay pending (not attempted, not stopped) and their subscriptions stay active, so they resume when the switch comes back on, while they are still inside the 24-hour retention. Events that arrive while the switch is off are not queued for MCP Events. + +- `events/list` returns the catalog above, each entry with `name`, `description`, `delivery: ["webhook"]`, `inputSchema` (optional string filters, `runtime` included), and `payloadSchema`. It has one page; a `cursor` is refused. +- `events/subscribe` with `{ name, arguments, delivery: { mode: "webhook", url, secret }, cursor, ttlMs? }` needs `mcp:read`. The secret is `whsec_` plus base64 of 24 to 64 bytes. `runtime` resolves like a tool call among the runtimes the grant reaches (`runtime_not_found`, `runtime_ambiguous`), and a runtime with MCP Control `off` is refused with `409 runtime_mcp_disabled`; without it the subscription follows every runtime the grant reaches, and receives events only from those whose MCP Control is not `off`. The cloud checks the callback URL, then posts a signed `{ "type": "verification", "challenge" }` with `X-MCP-Subscription-Id` and requires a `2xx` JSON echo of the challenge, compared in constant time. A failure is JSON-RPC `-32015` (`CallbackEndpointError`) with `data.reason` (`challenge_failed`, `timeout`, `invalid_url`, `dns_failed`, `non_public_destination`, `connection_failed`, `response_too_large`). A refresh within five minutes with the same secret skips the challenge; a new secret is always challenged. +- The subscription id is `sub_` plus a SHA-256 of the account, grant, callback URL, event name, and canonical arguments, so a refresh updates the same row. The row stores `target_kind = 'mcp_events'`, `owner_grant_id`, the filters, and the encrypted secret. The result is `{ id, refreshBefore, cursor, truncated }`. `ttlMs` is honored between one minute and 24 hours; absent or `null` grants 24 hours. Deliveries stop after `refreshBefore` until the client refreshes. +- Cursors are opaque retention anchors: the receive time of the oldest event that may still be undelivered. Subscribing with a cursor queues every retained matching event since it; deliveries already recorded for the same subscription are not sent again, except those the worker stopped because the subscription passed `refreshBefore` (`subscription_expired`), which a refresh queues again with a fresh retry budget. Deliveries stopped for a revoked grant, MCP Control `off`, or `410 Gone` stay stopped. A cursor older than the 24-hour retention returns `truncated: true` and replays what is retained. +- `events/unsubscribe` with `{ name, arguments, delivery: { mode, url } }` deletes the subscription for that identity and always answers `{}`. +- Revoking the grant (from Alera, `POST /oauth/revoke`, or a detected code or token replay) stops delivery: fan-out skips the subscription, and the worker marks queued deliveries stopped and the subscription `revoked` before sending. +- The edge maps cloud refusals to JSON-RPC: invalid input, unknown runtimes, and conflicts to `-32602`, a revoked token to HTTP `401` with `WWW-Authenticate`, and unavailability to `-32603`. + +### Storage + +Migration `0025_domain_events.sql` adds `domain_events` (unique by `(runtime_id, event_id)`, deleted 24 hours after `received_at`), `event_subscriptions` (`target_kind` `webhook` or `mcp_events`, `kinds[]`, `runtime_ids[]`, `all_runtimes`, `status` `active`, `stopped`, `revoked`, or `expired`, `refresh_before`, `owner_grant_id`, `filter`, encrypted secrets), and `event_deliveries` (unique by subscription and event, with `status`, `attempts`, `next_attempt_at`, `lease_until`, `last_status`, `last_error`, and `delivered_at`). Inactive MCP Events subscriptions are deleted after seven days; webhooks stay until the user deletes them. Secrets are encrypted with AES-256-GCM bound to the subscription id; `ALERA_WEBHOOK_PREVIOUS_SECRET_KEY` keeps decrypting older rows during a key rotation. + +### Production Settings + +| Setting | Where | Default | Purpose | +| --- | --- | --- | --- | +| `ALERA_WEBHOOK_SECRET_KEY` | cloud (secret) | unset | Base64 of 32 random bytes; required for webhooks and MCP Events | +| `ALERA_WEBHOOK_PREVIOUS_SECRET_KEY` | cloud (secret) | unset | Only during a key rotation | +| `ALERA_MCP_EVENTS_ENABLED` | cloud | `false` | Accept and deliver MCP Events subscriptions | +| `ALERA_EVENT_WORKER_ENABLED` | cloud | `true` | Background delivery loop | +| `ALERA_WEBHOOK_ALLOW_ANY_PORT` | cloud | `false` | Development only | +| `MCP_EVENTS_ENABLED` | edge var | `false` | Advertise and serve MCP Events | +| `EVENT_DELIVERY_PUMP` | edge var | `false` | Let the one-minute cron drive deliveries when Cloud Run throttles idle CPU | + +Rollout order: deploy the cloud (migration 0025) with the secret key, then the edge; turn on `ALERA_MCP_EVENTS_ENABLED` and `MCP_EVENTS_ENABLED` together only after the 8.0 requirements check, and keep polling with cursors (`wait_for_reply`, `list_events`) as the primary path until a real ChatGPT continuation is observed. + ## Testing -- `cloud`: `cargo test --workspace`; with `TEST_DATABASE_URL` pointing at an isolated PostgreSQL, `-- --include-ignored` also runs the OAuth, device, and gateway contracts. -- `edge`: `bun run check` and `bun test` cover the MCP endpoint, scope checks, OAuth proxying, and the Durable Object call routing. +- `cloud`: `cargo test --workspace`; with `TEST_DATABASE_URL` pointing at an isolated PostgreSQL, `-- --include-ignored` also runs the OAuth, device, gateway, webhook, and MCP Events contracts (the latter deliver to a loopback receiver through the test-only callback policy). +- `edge`: `bun run check` and `bun test` cover the MCP endpoint, `2026-07-28` negotiation, MCP Events with the switch off and on, scope checks, OAuth proxying, and the Durable Object call routing. - `rust`: `cargo test -p alera-cli -- mcp_tools mcp_settings relay_mcp` covers the catalog, argument checks, the edge catalog copy, and call grant verification on the link. - Local acceptance: run PostgreSQL, the cloud with GitHub endpoints pointed at a stub provider, the edge with `wrangler dev --local` and the `--var` overrides for `ORIGIN_BASE_URL`, `EDGE_ORIGIN_TOKEN`, `RELAY_ISSUER`, `RELAY_JWKS_URL`, and `MCP_RESOURCE`, and an isolated runtime with `ALERA_CLOUD_URL` at the edge. Sign the runtime in with `alera account login --device`, enable MCP Control, then drive registration, authorization, consent, token, refresh, and tool calls from any MCP client. Production still needs the web OAuth clients described above before the hosted flow can sign in. diff --git a/edge/readme.md b/edge/readme.md index a6e8971e8..4e0ba2bca 100644 --- a/edge/readme.md +++ b/edge/readme.md @@ -6,7 +6,7 @@ The relay control-plane endpoints, `POST /v1/relay/identity` and `POST /v1/relay ## Remote MCP -`POST /v1/mcp` is answered by the Worker itself (see [Remote MCP](../docs/remote-mcp.md)). It verifies the OAuth access token against the cloud JWKS, serves `src/mcp/tool_catalog.json` plus `list_runtimes`, asks Cloud Run for a call grant at `/v1/mcp/calls`, and calls the runtime's Durable Object at `/mcp/call`, which forwards a `~mcp` frame over the existing runtime socket and waits for the runtime's `mcp.result`. Pending calls live only in Object memory. `/v1/mcp/calls*` is not reachable through the public route. The endpoint is exposed only when `MCP_ENABLED=true`, uses `MCP_RESOURCE` as the token audience, and is limited per token by the `MCP_LIMITER` binding instead of the mutation burst limit. OAuth metadata, token, registration, revocation, and `/v1/mcp` answer CORS preflight at the edge. +`POST /v1/mcp` is answered by the Worker itself (see [Remote MCP](../docs/remote-mcp.md)). It verifies the OAuth access token against the cloud JWKS, serves `src/mcp/tool_catalog.json` plus `list_runtimes`, asks Cloud Run for a call grant at `/v1/mcp/calls`, and calls the runtime's Durable Object at `/mcp/call`, which forwards a `~mcp` frame over the existing runtime socket and waits for the runtime's `mcp.result`. Pending calls live only in Object memory. `/v1/mcp/calls*` is not reachable through the public route. The endpoint is exposed only when `MCP_ENABLED=true`, uses `MCP_RESOURCE` as the token audience, and is limited per token by the `MCP_LIMITER` binding instead of the mutation burst limit. OAuth metadata, token, registration, revocation, and `/v1/mcp` answer CORS preflight at the edge. `/v1/mcp` also serves MCP `2026-07-28` (`server/discover`) next to the handshake versions, and OpenAI MCP Events (`events/list` from `src/mcp/event_catalog.json`, `events/subscribe`, `events/unsubscribe`) only when `MCP_EVENTS_ENABLED=true`; subscriptions are forwarded to `/v1/mcp/event-subscriptions*`, which, like `/v1/internal/*`, is not reachable through the public route. A one-minute cron calls the origin's delivery pump only when `EVENT_DELIVERY_PUMP=true`. `tool_catalog.json` is generated from the Rust catalog; do not edit it by hand. diff --git a/edge/skills/alera-mcp-agent-profiles/SKILL.md b/edge/skills/alera-mcp-agent-profiles/SKILL.md new file mode 100644 index 000000000..742945554 --- /dev/null +++ b/edge/skills/alera-mcp-agent-profiles/SKILL.md @@ -0,0 +1,32 @@ +--- +name: alera-mcp-agent-profiles +description: Inspect, create, change, reorder, and remove Alera agent profiles through this MCP server. Use when the user asks to maintain the launch catalog of coding agents. +metadata: + version: 1 +--- + +# Alera Agent Profiles Through MCP + +An agent profile is a launch recipe for a coding agent: an adapter (`agentType`) plus either a command line or a managed configuration. Workspaces, delegation, and automations launch agents by profile. + +## Reading + +`list_agent_profiles` lists them. `show_agent_profile` shows one with its launch configuration and revision, by id or unique name. Reading is always allowed; launching is in the `alera-mcp` skill. + +## Changing + +Every change needs administrative access and must be covered by the user's request. Honor a prior explicit request for the same change without asking again; a proposal-only request stops at the proposal. + +- `create_agent_profile` creates one. A command profile takes `command`. A managed profile takes `managedConfig` with the keys its adapter supports; read [managed configuration](references/managed.md). +- `update_agent_profile` changes only the given fields. Pass `expectedRevision` from `show_agent_profile` to refuse a stale edit. Changing the adapter of a managed profile needs a new `managedConfig`. +- `reorder_agent_profiles` sets the whole order: list every current profile id exactly once. +- `set_default_agent_profile` chooses the profile New Workspace from Prompt uses when none is named. +- `remove_agent_profile` removes one. Run `preview_agent_profile_removal` first, report what refers to it (such as automations), and remove it only when that is covered by the request. + +Re-read the changed profile or the order afterwards to confirm it was saved. + +## Reduced Protections + +Never infer permission to reduce an agent's protections from a general profile request. Settings that skip permission prompts, bypass sandboxes, or approve actions automatically need the user's explicit intent and `confirmReducedProtections`. + +Examples are Codex bypass or never-ask approval, Claude bypass permissions, Copilot allow-all, Cursor force or trusted workspace, and OpenCode auto approval. diff --git a/edge/skills/alera-mcp-agent-profiles/references/managed.md b/edge/skills/alera-mcp-agent-profiles/references/managed.md new file mode 100644 index 000000000..71859ff3f --- /dev/null +++ b/edge/skills/alera-mcp-agent-profiles/references/managed.md @@ -0,0 +1,34 @@ +# Managed Configuration + +A managed profile stores its settings in `managedConfig`, and Alera builds the command line from them. These are the keys of Alera's current adapters. Unknown keys fail closed, and `show_agent_profile` returns an existing configuration in the same shape. + +| Adapter | Keys | +|---|---| +| `codex` | `model`, `effort`, `planModeEffort`, `sandbox`, `approvalPolicy`, `webSearch`, `bypassApprovalsAndSandbox` | +| `claude` | `model`, `effort`, `agent`, `permissionMode`, `allowSkipPermissions`, `ccsProfile` | +| `copilot` | `model`, `effort`, `agent`, `mode`, `context`, `allowAll`, `maxAiCredits`, `maxAutopilotContinues`, `noAskUser` | +| `cursor` | `model`, `mode`, `permissionMode`, `sandbox`, `trustWorkspace` | +| `agy` | `model`, `effort`, `agent`, `mode`, `skipPermissions`, `sandbox` | +| `opencode` | `model`, `agent`, `autoApprove` | +| `opencode2` | `model`, `agent`, `autoApprove` | +| `pi` | `model`, `thinking`, `projectTrust` | +| `amp` | `mode`, `fast` | +| `grok` | `model`, `effort`, `agent`, `permissionMode`, `sandbox`, `disableWebSearch` | +| `fx` | `resumeLast`, `noAdditionalDirs`, `record` | + +Use model names the user actually has; never invent a model slug. `quotaGroup` marks profiles that draw from the same usage limit, and `description` says what the profile is for. Coordinators read both to choose profiles and fallbacks, so keep them accurate. + +Example of a managed Codex profile: + +```json +{ + "name": "Codex High", + "agentType": "codex", + "launchMode": "managed", + "managedConfig": {"model": "gpt-5.6-sol", "effort": "high", "webSearch": true}, + "description": "Hard implementation and deep debugging.", + "quotaGroup": "codex" +} +``` + +The model in the example is illustrative. diff --git a/edge/skills/alera-mcp-automations/SKILL.md b/edge/skills/alera-mcp-automations/SKILL.md new file mode 100644 index 000000000..ab7c3bae6 --- /dev/null +++ b/edge/skills/alera-mcp-automations/SKILL.md @@ -0,0 +1,44 @@ +--- +name: alera-mcp-automations +description: Create, edit, inspect, pause, and run scheduled Alera agent automations through this MCP server, and follow their runs. Use for recurring or one-time agent work. +metadata: + version: 1 +--- + +# Alera Automations Through MCP + +An automation is a definition: a prompt, a schedule, a target, and an agent profile. Each time it fires, it creates a run. Orchestration tasks are separate; see the `alera-mcp-orchestration` skill. + +## Authoring + +Choose the execution target explicitly; never infer its type. A valid prompt, schedule, target, and launchable profile are enough. There is no approval or repository declaration step. Read [definitions](references/definitions.md) for the definition JSON and the five targets. + +1. `check_automation_readiness` validates a full or partial definition without saving it and lists the fields to fix. Run it before creating. +2. `preview_automation_schedule` lists the next occurrences of a cron expression or a one-time date in a time zone. +3. `create_automation` saves it, active unless `draft` is true. Pass `clientRequestId` so a retry does not create a duplicate. +4. `update_automation` changes the given fields. Pass `expectedRevision` to refuse a stale edit. Edits apply to future runs. +5. `clone_automation` copies an automation's settings and target. + +## Reading + +`list_automations` filters like the Automations view. `show_automation` shows the definition, readiness, next occurrences, recent runs, and audit history. `list_automation_runs` and `show_automation_run` read runs and their attempts. + +## Running And State + +- `run_automation` runs it once now, like Run Now, without changing its schedule. `continueFromRunId` continues an earlier run's conversation in its preserved workspace. +- `pause_automation` stops scheduling. When runs are active, `activeRuns` chooses whether they continue or are cancelled. `resume_automation` activates a paused or draft automation. +- `trash_automation` moves it to the trash, and `restore_automation` brings it back. `purge_automations` permanently deletes everything in the trash for at least 30 days; use it only on request. + +## Runs + +- `cancel_automation_run` cancels a queued, running, or waiting run. +- A run can wait for the user. `resume_automation_run` lets it continue, and `extend_automation_run` extends its deadline. +- `take_over_automation_run` hands the run's terminal to a person; the automation stops driving the agent. Use it only when the user intends to operate that agent. + +The runtime must be running for schedules to fire. Occurrences missed while it was offline are skipped by default. An interrupted run recovers on its own in its preserved workspace, a bounded number of times. Do not launch a replacement into the same target while that is happening. + +## Catalog + +- `list_automation_templates` and `upsert_automation_template` manage prompt templates. +- `list_automation_tags` lists tags, and `upsert_automation_tags` creates or renames a tag and sets an automation's tags. +- `export_automations` exports automations, templates, and tags as a portable catalog. `import_automations` imports one. diff --git a/edge/skills/alera-mcp-automations/references/definitions.md b/edge/skills/alera-mcp-automations/references/definitions.md new file mode 100644 index 000000000..a67cbff88 --- /dev/null +++ b/edge/skills/alera-mcp-automations/references/definitions.md @@ -0,0 +1,31 @@ +# Definitions And Targets + +`create_automation` takes a `definition` object. Leave out ids, revisions, actors, and timestamps: + +```json +{ + "name": "Daily Review", + "promptTemplate": "Review {{project.name}} and summarize findings", + "schedule": {"recurring": {"cron": "0 9 * * 1-5", "timezone": "America/Mexico_City"}}, + "target": {"freshTab": {"workspaceId": "workspace-id", "agentProfileId": "profile-id"}}, + "originWorkspaceId": "workspace-id" +} +``` + +A one-time schedule is `{"oneTime": {"at": "2026-11-02T09:00:00Z", "timezone": "UTC"}}`; `at` is an RFC 3339 date and time. A recurring schedule may add `startAt`, `endAt`, and `maxScheduledRuns`. + +The target is one of five: + +| Target | Fields | Behavior | +| --- | --- | --- | +| `freshTab` | `workspaceId`, `agentProfileId` | A new agent tab in an existing workspace | +| `existingTab` | `workspaceId`, `tabId`, optional `conversationId` | Sends to that tab's live conversation | +| `managedWorkspace` | `sourceWorkspaceId`, `sourceBranch`, `agentProfileId` | A new worktree per run, child of the source workspace | +| `projectWorktree` | `projectId`, `sourceBranch`, `agentProfileId` | A new worktree per run from the project, with no parent | +| `projectCheckout` | `projectId`, `hostId`, `agentProfileId` | A workspace in the project's local or SSH folder | + +The three targets that create a workspace also take an optional `nameTemplate`. + +Find ids with `list_workspaces`, `list_projects`, `list_agent_profiles`, `list_tabs`, and `list_project_branches`. `originWorkspaceId` decides where the definition appears in the app, independent of the target. + +Optional fields include `precheck`, `overlapPolicy`, `misfirePolicy`, `cleanupPolicy`, `retryMaxAttempts`, `retryBackoffSeconds`, and schedule bounds. Leave them out for the defaults. When a field is rejected, `check_automation_readiness` names it. diff --git a/edge/skills/alera-mcp-orchestration/SKILL.md b/edge/skills/alera-mcp-orchestration/SKILL.md new file mode 100644 index 000000000..927c1b219 --- /dev/null +++ b/edge/skills/alera-mcp-orchestration/SKILL.md @@ -0,0 +1,46 @@ +--- +name: alera-mcp-orchestration +description: Delegate tasks to Alera agents and follow coordinator runs, gates, and workflow recipes through this MCP server. Use when work should be split among agents or tracked as orchestration tasks. +metadata: + version: 1 +--- + +# Alera Orchestration Through MCP + +Orchestration tracks work as tasks owned by a coordinator, which is a running agent terminal. Workers accept tasks, report progress, and complete them. The runtime owns the lifecycle. As an MCP client you are not a coordinator terminal, so the tools that stop, cancel, or interrupt work run as audited administrative actions. Pass a clear `reason` to them. + +## Pick The Simplest Tool + +- One agent working on one request: use `start_workspace_from_prompt`, `start_agent_workspace`, or `launch_agent` from the `alera-mcp` skill. No task is needed. +- A coordinator agent is already running and should own the result: use `delegate_task`. It creates the task and starts a profile that accepts it, in the coordinator's workspace or, with `newWorkspace`, in a new child worktree. Find the coordinator's handle with `list_terminals`. Follow the task with `wait_for_task` or `wait_for_events`. +- A planned, multi-stage run reviewed by a person: read [workflows](references/workflows.md). + +A failed delegation can still leave a created workspace and task. Keep the returned ids. Inspect them with `show_task` and `show_dispatch`, and retry the existing task only after confirming no worker is still running it. Do not repeat `delegate_task` blindly, and do not remove the workspace on your own. + +## Tasks And Dispatches + +- `list_tasks` and `show_task` read tasks. `wait_for_task` waits until a task reaches the given states. +- `create_task` creates a task without starting an agent. A task of a coordinator run names the run and, with an execution policy, its stage. +- `spawn_agent` starts an agent for a ready task and dispatches it once the agent is ready. `dispatch_task` dispatches a ready task to an existing terminal; `dryRun` only builds the preamble. +- `show_dispatch` shows the dispatch state and preamble. `interrupt_dispatch` interrupts a worker's current turn without closing its terminal. +- `cancel_task` cancels a task and its not-yet-started descendants. + +## Coordinator Runs + +- `start_coordinator` starts the background loop for a running coordinator agent. It records the objective and dispatches the run's ready tasks. `stop_coordinator` stops it, and `cancelActive` also cancels active tasks. +- `list_runs`, `show_run`, and `orchestration_status` read runs. `get_orchestration_board` pages the run board grouped as attention, active, or history. `get_run_snapshot` reads a run with a page of its tasks, and `inspect_task` reads one task with its dispatches and history. +- `propose_run_policy` proposes a stage plan. The run holds scheduling until a person approves it in the Alera app. `show_run_policy` shows it. Never treat a pending policy as approved. + +## Gates And Messages + +- `create_gate` asks a person to decide before a task continues. `list_gates` lists gates. Gates are resolved by a person in the Alera app; never decide one for the user. +- `send_message` sends an orchestration message from one agent terminal to another terminal or a group such as `@all`. To ask an agent a question from outside, use `ask_agent` instead. `list_messages` lists recent messages. + +## Recovery + +These need administrative access, a reason, and the user's request: +- `recover_task` moves a stalled task to ready, failed, or cancelled. +- `transfer_coordinator` hands a task or a whole run to another coordinator terminal. +- `reset_orchestration` clears orchestration state. + +A stalled task keeps its slot and is never redispatched silently. Inspect it with `inspect_task` and `read_terminal` before recovering it. diff --git a/edge/skills/alera-mcp-orchestration/references/workflows.md b/edge/skills/alera-mcp-orchestration/references/workflows.md new file mode 100644 index 000000000..add1b8724 --- /dev/null +++ b/edge/skills/alera-mcp-orchestration/references/workflows.md @@ -0,0 +1,37 @@ +# Workflow Recipes And Runs + +A workflow recipe is a YAML plan of roles and stages. A workflow run goes through these steps: +1. A proposal. +2. A coordinator drafts the plan. +3. A person approves the plan in the Alera app. +4. Tasks run in isolated attempts. +5. Results integrate into an integration workspace. + +Approval always belongs to a person. + +## Recipes + +- `list_recipes` lists built-in, personal, and a workspace's project recipes. `show_recipe` shows one with its digest, roles, and stages. +- `validate_recipe` checks YAML without saving or running it. +- `save_personal_recipe` creates or updates a personal recipe; updating needs its current revision. +- `preview_recipe_export` previews writing a recipe into a project's `.alera/workflows`. `export_recipe` writes it with the digest from the preview, and fails if anything changed since. + +## Proposals And Plans + +- `create_workflow_proposal` proposes a run from a recipe at the source workspace's current commit. `start_workflow_coordinator` starts its coordinator, which drafts the plan without approving it. +- `list_workflow_proposals` and `get_workflow_proposal` read proposals. `submit_workflow_proposal` submits the concrete task list as the coordinator would. `cancel_workflow_proposal` cancels a proposal and its coordinator. +- `prepare_workflow_plan` prepares a plan for review from a document with a stable `requestId`. `show_workflow_plan` shows a plan revision. +- `create_workflow_correction` opens a correction of an approved revision; a person approves the corrected plan. + +## Execution + +- `get_workflow_execution` shows whether an approved run is scheduling, paused, or cancelled, with the sequence number `control_workflow_execution` needs to start, pause, or cancel it. +- `prepare_workflow_attempt` prepares the integration workspace or a task's isolated attempt. `launch_workflow_task` launches an approved task in its attempt. `integrate_workflow_result` squashes a completed task's result into the integration workspace. +- `list_workflow_integrations` lists integration outcomes and conflicts. `list_workflow_workspaces` lists the workspaces a run keeps. + +## Cleanup + +Cleanup is preview first, then apply with the preview's id and digest. +- `list_workflow_cleanups` lists retained resources. +- `preview_workflow_cleanup` previews removing up to 25 workspaces, and `get_workflow_cleanup` shows an operation. +- `apply_workflow_cleanup` removes them. `retry_workflow_cleanup` retries an unfinished cleanup, and `abandon_workflow_cleanup` keeps what was not removed. These three need administrative access. diff --git a/edge/skills/alera-mcp/SKILL.md b/edge/skills/alera-mcp/SKILL.md new file mode 100644 index 000000000..27157cb1b --- /dev/null +++ b/edge/skills/alera-mcp/SKILL.md @@ -0,0 +1,43 @@ +--- +name: alera-mcp +description: Work with Alera through this MCP server. Covers runtimes, projects, workspaces, agents and terminals, inbox questions, pull requests, events, and runtime settings. Read it before the first Alera task of a conversation. +metadata: + version: 1 +--- + +# Alera Through MCP + +Alera runs coding agents in workspaces on the user's machines. A workspace is a task with its own Git worktree and branch, or a task on the project folder itself. Each machine is a runtime. This server reaches the runtimes the user granted to this connection. + +## How Calls Work + +- Call `list_runtimes` first. Every other Alera tool takes an optional `runtime` (name or id). Pass it whenever more than one runtime is online. Nothing is remembered between calls, so name the runtime on each call. +- Use exact ids from the list and show tools. Never guess an id, a branch, or a profile name. +- Access has three levels: read, full, and admin. A tool outside the connection's grant fails with `insufficient_scope`. Tell the user to reconnect Alera and allow that level; do not look for another tool that does the same thing. +- Waits return after at most 50 seconds. Call the wait again while the work is still running, and pass the returned cursor so nothing is read twice. +- Tools that change state and accept `clientRequestId` deduplicate retries. After a timeout or an unclear answer, retry with the same `clientRequestId` instead of a new one, so the action does not happen twice. +- A failed tool returns `structuredContent.error` with `code` and `retryable`. Codes include `not_found`, `invalid_argument`, `conflict`, `blocked` (the app refused, as it would in its own UI), `capability_missing` (the runtime needs an Alera update), `provider_unavailable` (`gh`, `glab`, or `az` is missing or signed out on that machine), `timeout_pending` (still running; read its state again), and `runtime_unavailable`. Retry only when `retryable` is true. +- Errors from this server itself start with `runtime_offline:`, `timeout:`, or `insufficient_scope:`. A `runtime_offline` runtime is not running or has MCP Control off; only the user can fix that. + +## Authorization + +Do only what the user asked for. Before removing anything, run the matching preview (`preview_workspace_removal`, `preview_project_removal`, `preview_agent_profile_removal`) and report what it would affect. Merging, closing, and removing are the user's decisions; honor an explicit request for the same scope without asking again, but never infer one. + +## Choose The Workflow + +- Projects, workspaces, New Workspace from Prompt, sections, tags, hosts, issues, and relocation: read [workspaces](references/workspaces.md). +- Launching agents, tabs, terminals, and asking an agent a question: read [agents](references/agents.md). +- Pull requests on GitHub, GitLab, and Azure DevOps, Ship, and Watch and Fix: read [pull requests](references/pull-requests.md). +- Following what happens without polling each item, and webhooks: read [events](references/events.md). +- Runtime status, settings, quotas, voice, status hooks, and the agents' Alera skills: read [runtime](references/runtime.md). +- Delegating tasks to agents, coordinator runs, and workflow recipes: use the `alera-mcp-orchestration` skill. +- Scheduled or recurring agent work: use the `alera-mcp-automations` skill. +- Creating or changing agent profiles: use the `alera-mcp-agent-profiles` skill. + +Read only the references the task needs. Read them with `read_skill`, passing the reference path as `file`. + +## Starting Work + +For a task described in words, prefer `start_workspace_from_prompt`. It finds the project, names the workspace and branch, and launches the default agent, like the app's New Workspace from Prompt. Use `start_agent_workspace` when you already know the project, profile, and branch. Use `launch_agent` to add an agent to an existing workspace, and `delegate_task` when a running coordinator agent should own the result. + +Then follow the agent with `wait_for_events`, `read_terminal`, or `ask_agent` with `wait_for_reply`. An agent only sees what is typed into its terminal or asked through the inbox; it cannot see this conversation. diff --git a/edge/skills/alera-mcp/references/agents.md b/edge/skills/alera-mcp/references/agents.md new file mode 100644 index 000000000..984721be0 --- /dev/null +++ b/edge/skills/alera-mcp/references/agents.md @@ -0,0 +1,43 @@ +# Agents, Tabs, And Terminals + +## Launching Agents + +`list_agent_profiles` lists the agent profiles the user declared (Claude, Codex, and others) with their ids and names. Pick a profile from that list; never invent one. Profile administration belongs to the `alera-mcp-agent-profiles` skill. + +`launch_agent` opens a new tab in an existing workspace. Send a `prompt` to start a conversation, `resumeSessionId` to continue an earlier one, or neither to start the agent idle. Pass `clientRequestId` so a retry does not open a second tab. + +## Tabs + +`list_tabs` lists a workspace's tabs, including terminal and agent tabs. + +- `create_tab` opens a terminal tab, optionally running a command. +- `rename_tab` renames one, and `generate_tab_title` names an agent tab from its conversation with AI Assist. +- `close_tab` closes a tab and ends its session. +- `link_agent_to_tab` binds a running agent conversation to a tab again when the tab stopped showing the agent's status. + +## Terminals + +`list_terminals` lists live sessions with their handles. Most terminal tools take a `handle` from it. `show_terminal` shows one session with its agent and lifecycle state. + +- `read_terminal` reads retained output. Pass the returned cursor next time to read only what is new. +- `wait_for_terminal` waits for `process-started`, `agent-detected`, `agent-ready`, or `dispatch-accepted`. +- `write_terminal` types text. Use `submit` to send a prompt to an interactive agent; use `enter` to press Enter after a shell command. Type into an agent only when the user asked for it, and read the terminal first so you do not interrupt work in progress. +- `restart_terminal` replaces the process and keeps the tab and scrollback. `terminate_terminal` ends the session and closes the tab. +- `prune_terminals` lists stopped terminal tabs, and with `apply` removes them. Run it without `apply` first. +- Pulse types a configured input into a terminal after workspace files change. `get_terminal_pulse` shows it, and `configure_terminal_pulse` changes, arms, or disarms it. + +## Asking An Agent + +The inbox lets you ask a running agent a question and read its answer later. The agent sees which MCP client asked; it cannot read this conversation, so put everything it needs in the question. + +1. `list_inbox_targets` lists the agents you can reach. +2. `ask_agent` asks by terminal `to`, or by `workspaceId` when the workspace runs one agent (add `agent` to choose among several). It returns the question id and thread id. Pass `threadId` to follow up in the same thread. +3. `wait_for_reply` waits for news about one question. `wait_for_inbox` waits for any reply to this client's questions, so one call covers every open question. Pass the returned cursor as `after`. + +A question reaches the agent when its current turn ends. `expiresIn` drops it if it is never delivered. `cancel_question` cancels it only before the agent sees it. + +- `list_inbox_threads` lists threads asked by every MCP client sharing this inbox; `isOwn` marks this client's. `show_inbox_thread` shows one thread whole. +- `mark_thread_read` marks a thread's replies as read for everyone sharing the inbox. +- `list_inboxes` lists the external inboxes with their counts. +- `list_agent_conversations` and `show_agent_conversation` show conversations between agents, read-only. +- `purge_inbox` deletes every question of the shared MCP inbox, including other clients' threads. Use it only when the user asks for exactly that. diff --git a/edge/skills/alera-mcp/references/events.md b/edge/skills/alera-mcp/references/events.md new file mode 100644 index 000000000..ccb88a3c9 --- /dev/null +++ b/edge/skills/alera-mcp/references/events.md @@ -0,0 +1,24 @@ +# Events And Webhooks + +## Following Work + +The runtime keeps a journal of events, including: +- inbox replies and question states, agent states, and terminal exits; +- orchestration task changes, decision gates, and escalations; +- automation runs, workspace starts and lifecycle, and Watch and Fix actions. + +Events carry ids and states only; read details with the matching tool. + +- `wait_for_events` waits for events after a cursor and returns them with the next cursor. One wait covers every kind you filter for. Prefer it to polling each question, task, or run separately. Filter with `kinds` and `workspaceId`. +- `list_events` reads the journal without waiting. `truncated` means older events were pruned. + +Keep the latest cursor and pass it as `after` on the next call. Omitting `after` reads from the oldest retained event. + +Clients that support MCP Events can subscribe through the protocol instead; the events and their filters are the same. + +## Webhooks + +A webhook sends this runtime's events to an HTTPS endpoint as signed POST requests (Standard Webhooks) through the Alera cloud. It needs a signed-in Alera account and administrative access. + +- `create_webhook` adds one and returns the signing secret once. Give the secret to the user right away; it cannot be read again. +- `list_webhooks` lists them with their last delivery. `test_webhook` sends a signed test delivery. `delete_webhook` removes one. diff --git a/edge/skills/alera-mcp/references/pull-requests.md b/edge/skills/alera-mcp/references/pull-requests.md new file mode 100644 index 000000000..5ae05567c --- /dev/null +++ b/edge/skills/alera-mcp/references/pull-requests.md @@ -0,0 +1,39 @@ +# Pull Requests + +Alera works with pull requests on GitHub, merge requests on GitLab, and pull requests on Azure DevOps. It uses `gh`, `glab`, or `az` on the machine that holds the workspace. A `provider_unavailable` error means that CLI is missing or signed out there; tell the user rather than retrying. Every tool takes the `workspaceId` whose branch the pull request belongs to. + +## Reading + +- `get_pull_request` returns the workspace's linked pull request, or the open one for its branch. It includes state, draft, mergeability, head SHA, checks, the conversation and review threads with their ids, the merge methods allowed, and the base branches. Read it before acting on a pull request. +- `list_pull_request_summaries` gives one compact row per active workspace with a pull request, with failing check names. + +## Opening + +- `ship_changes` does what the app's Ship Changes button does. It stages, commits with an AI Assist message, moves work off a shared base branch, pushes, and opens a pull request with AI Assist details. With `followUpWatch` it then starts Watch and Fix. It needs AI Assist. If the call times out, the ship keeps running; read `get_pull_request` for the result instead of shipping again. +- `generate_pull_request_details` writes a title and description with AI Assist without touching the forge. After about 45 seconds it may return status `running` with an `operationId`. Call again with that `operationId` as `clientRequestId`, the same workspace, and the same base branch to wait or read the result. +- `create_pull_request` opens one from the workspace's current branch. Push the branch first. +- `link_pull_request` links an existing pull request by number or URL, and `unlink_pull_request` removes the link without changing the pull request. + +## Conversation And State + +- `comment_pull_request` comments, or replies with `replyToCommentId`. On GitLab and Azure DevOps also pass the `threadId` from `get_pull_request`. +- `edit_pull_request_comment` replaces the text of one of your comments, using the id, source, and threadId from `get_pull_request`. +- `set_pull_request_draft` marks it draft or ready. `close_pull_request` closes it without merging (abandon on Azure DevOps). + +## Merging + +`merge_pull_request` merges. Choose `method` from the `mergeMethods` in `get_pull_request`. Pass `expectedHeadSha` from the state you checked, so the merge fails if someone pushed since. Merge only when the user asked for it. + +## Agents On Pull Requests + +- `fix_pull_request_checks` asks an agent to fix failed checks, like the Fix Failed Checks button. `restack_pull_request` asks one to rewrite the changes into reviewable commits without pushing. Both send the prompt to a running agent (`handle`) or open a tab from a profile; `preview` returns only the prompt. +- Watch and Fix sends failing checks, merge conflicts, and unresolved review threads to an agent as they appear. `start_pull_request_watch` starts it; mode `fixAndMerge` also merges once checks pass and nothing is open. `show_pull_request_watch` shows the active watch, and `stop_pull_request_watch` stops it. + +## GitHub Stacks + +Stacks exist only on GitHub and need the `gh-stack` extension. + +- `get_pull_request_stack` shows the stack that holds the workspace's pull request, bottom layer first. +- `create_pull_request_stack` builds a stack from local workspaces, bottom to top. It pushes each branch and opens the pull requests that are missing. +- `link_pull_request_stack` stacks existing pull requests. +- `merge_pull_request_stack` merges every layer at or below the workspace's pull request at once. diff --git a/edge/skills/alera-mcp/references/runtime.md b/edge/skills/alera-mcp/references/runtime.md new file mode 100644 index 000000000..471bc7661 --- /dev/null +++ b/edge/skills/alera-mcp/references/runtime.md @@ -0,0 +1,32 @@ +# Runtime + +## Status + +- `runtime_status` shows whether the runtime host is running, its database, and its active sessions. +- `get_version` shows the Alera versions and the protocol and skill contract versions. Check it when a tool answers `capability_missing`; the runtime may need an Alera update. +- `get_resource_snapshot` shows CPU and memory use of the host, the runtime, and each terminal. + +## Settings + +`get_runtime_settings` shows the settings open to MCP clients. They include the default agent profile, the worktree folder, removal confirmations, AI Assist, and automation retention. `update_runtime_settings` changes them. It needs administrative access, and fields left out keep their values. Change settings only when the user asks. + +Status hooks let agents report their state to Alera. `get_agent_integrations` shows them, and `set_agent_integrations` turns them on or off. + +## Agent Quotas + +`get_agent_quotas` shows usage limits for the enabled providers; `refresh` fetches new numbers. +- `refresh_claude_quota` reads one Claude account's usage again. +- `consume_codex_reset_credit` spends an offered Codex reset credit. It needs administrative access and the user's explicit request. + +## Voice + +`voice_status` shows the voice home agent and its speech pipeline. `voice_speak` says a short message to the user through it. + +## Skills For Coding Agents + +Coding agents that run in Alera terminals use their own Alera skills, installed on the runtime's machine. These are separate from the skills this server serves. +- `check_agent_skills` shows whether those skills are installed and whether they match the runtime's version. It also shows an install in progress (`install.running`) and the last finished one (`install.last`). +- `install_agent_skills` installs or updates them at the runtime's own version. It needs administrative access. +- The runtime runs the install as a job, and only one at a time. The call waits up to 45 seconds. State `running` means the install is still going: read `check_agent_skills` later rather than calling again. + +Suggest an install when a skill is missing or outdated and agents misuse the `alera` CLI. diff --git a/edge/skills/alera-mcp/references/workspaces.md b/edge/skills/alera-mcp/references/workspaces.md new file mode 100644 index 000000000..aff379122 --- /dev/null +++ b/edge/skills/alera-mcp/references/workspaces.md @@ -0,0 +1,53 @@ +# Projects And Workspaces + +## Projects + +A project is a repository or folder registered in a runtime. `list_projects` shows each project with the hosts it is on. + +- Add a folder that already exists with `register_project`. The folder is not changed. +- Clone a repository with `clone_project`. It returns a clone job at once; follow it with `get_project_clone`. `list_project_clones` lists jobs, and `cancel_project_clone` stops one and deletes its partial folder. +- A project can live on SSH hosts too. `list_ssh_targets` lists the hosts the runtime knows, and `ssh_target_status` checks whether they are reachable. `register_remote_project` adds a project that exists only on a host. For an existing project, `add_project_host` adds a host, either from a folder there or by cloning, and `register_project_checkout` registers a specific folder. `list_project_hosts` shows the folder on each host, and `remove_project_host` forgets one without deleting files. +- `list_project_branches` lists the branches that can be a new worktree's `sourceBranch`, and the project's preferred one. +- `get_project_config` shows the effective project settings and whether they come from the app or from the repository's `alera.toml`. `update_project_config` saves app settings that override the file, part by part, and `reset_project_config` goes back to the file or the defaults. +- `rename_project` changes only the display name. `remove_project` removes the project from Alera as the app does: dependent automations are paused, and no file is deleted. Run `preview_project_removal` first and report what it shows. + +## Workspaces + +`list_workspaces` lists the workspaces of one project or all of them, with filters. `show_workspace` shows one with its section, tags, linked issue, linked pull request, Watch and Fix state, parent, children, and whether it is asleep. + +There are four ways to create one: + +1. `start_workspace_from_prompt` does what the app's New Workspace from Prompt form does. AI Assist picks the project when `projectId` is left out, names the workspace and branch, picks a section, and launches the agent. When the project is unclear it ends with status `needsInput` and candidates; call again with `projectId`. It answers within about 40 seconds; while the status is `running`, follow it with `wait_for_workspace_start` or `get_workspace_start`. `list_workspace_starts` lists recent operations. `cancel_workspace_start` stops one, and a workspace it already created is kept. If the workspace exists but the agent did not start, `retry_workspace_start_launch` launches it again without creating another workspace. +2. `start_agent_workspace` creates a workspace and launches a named profile with a prompt. A new worktree from `projectId` needs `sourceBranch`. +3. `create_workspace` creates a workspace without starting an agent, as the manual New Workspace form does. +4. `delegate_task` with a new workspace, described in the `alera-mcp-orchestration` skill. + +A workspace on its own worktree has its own branch. A workspace on the project folder shares the folder and its branch with every other task there. + +## Workspace Lifecycle + +- `rename_workspace` changes only the display name; the branch and folder stay. +- `set_workspace_pinned` pins or unpins it in the sidebar. `focus_workspace` selects it in the running desktop app; it fails when no desktop app is connected. +- `sleep_workspace` stops its terminals and keeps tabs, branch, and files. `wake_workspace` starts them again and resumes the agents' conversations. +- `archive_workspace` also hides it from the sidebar; `unarchive_workspace` brings it back. +- `remove_workspace` follows the app's Remove flow. Its sessions close, automations that depend on it are paused, editors open on it are saved, and its worktree is deleted. Uncommitted changes in the worktree are lost. The branch is deleted only when Alera created it, unless `branch` is `keep`. Always run `preview_workspace_removal` first and report the storage, blockers, automations, links, and branch outcome. A `blocked` answer removed nothing. + +## Moving Between Folder And Worktree + +- `hand_off_workspace` moves a task from the project folder to its own worktree on a new or existing branch, choosing whether uncommitted changes move with it. +- `hand_on_workspace` brings a task from its worktree back to the project folder. +- A relocation runs the project's setup. `get_workspace_recovery` shows its phases and setup attempts. `run_workspace_setup` runs the setup in a workspace. `cancel_workspace_setup` stops a running attempt, and `recover_workspace_setup` closes an interrupted one without running its commands again. + +## Organizing + +- Sections group workspaces in the sidebar. A workspace without one is in Others. Use `list_sections`, `create_section`, `set_workspace_section`, `clear_workspace_section`, and `remove_section`; removing a section keeps its workspaces. +- Tags: `list_tags`, `upsert_tag` to create, rename, or recolor, `remove_tag`, `tag_workspace`, and `untag_workspace`. +- Parent and child links nest workspaces: `link_workspaces` and `unlink_workspaces`. `preview_workspace_cascade` lists the workspaces an action would reach through descendants and shared tags. + +## Linked Issues + +`link_workspace_issue` links one issue URL to a workspace, and `unlink_workspace_issue` removes it. `show_workspace_issue` reads it fresh from GitHub, GitLab, or Azure DevOps. `fetch_issue` reads any issue by URL. These use `gh`, `glab`, or `az` on the runtime's machine. + +## Metadata Repair + +`register_workspace_record` and `unregister_workspace_record` only write or delete Alera's record of a workspace. They never create or delete a worktree. Use them only when the user asks to repair metadata, never as a shortcut to create or remove a workspace. diff --git a/edge/src/index.ts b/edge/src/index.ts index 4f47a659b..cd6564937 100644 --- a/edge/src/index.ts +++ b/edge/src/index.ts @@ -20,6 +20,10 @@ export interface EdgeEnvironment { EDGE_BURST_LIMITER: RateLimitBinding; EDGE_ORIGIN_TOKEN: string; MCP_ENABLED?: string; + /** OpenAI MCP Events (`events/*` and `capabilities.events`). Off unless "true". */ + MCP_EVENTS_ENABLED?: string; + /** When "true", the cron trigger asks the origin to deliver due webhooks. */ + EVENT_DELIVERY_PUMP?: string; MCP_LIMITER?: RateLimitBinding; OAUTH_LIMITER?: RateLimitBinding; BROWSER_LIMITER?: RateLimitBinding; @@ -55,8 +59,11 @@ const PUBLIC_EXACT_PATHS = new Set([ '/device', ]); const PUBLIC_PREFIXES = ['/v1/']; -// Gateway calls mint runtime call grants; only the edge may reach them, through the origin directly. -const EDGE_ONLY_PREFIX = '/v1/mcp/calls'; +// Gateway calls mint runtime call grants, MCP Events subscriptions must pass the edge's +// protocol checks, and the delivery pump is internal: only the edge reaches them, through +// the origin directly. +const EDGE_ONLY_PREFIXES = ['/v1/mcp/calls', '/v1/mcp/event-subscriptions', '/v1/internal']; +const EVENT_PUMP_PATH = '/v1/internal/event-deliveries/pump'; const CORS_EXACT_PATHS = new Set(['/oauth/token', '/oauth/register', '/oauth/revoke', MCP_PATH]); // Machine-to-machine sign-in calls carry no bearer, so the burst limiter would key // them by address alone: a device poll every five seconds, or a hosted client @@ -81,7 +88,7 @@ export function jsonError(status: number, code: string, message: string): Respon } function isPublicPath(pathname: string): boolean { - if (pathname === EDGE_ONLY_PREFIX || pathname.startsWith(`${EDGE_ONLY_PREFIX}/`)) return false; + if (EDGE_ONLY_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))) return false; return PUBLIC_EXACT_PATHS.has(pathname) || PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } @@ -338,8 +345,27 @@ export async function handleRequest( export { RuntimeRelayDurableObject } from './runtime_relay'; +/** Cron: asks the origin to fan out and deliver due webhooks while Cloud Run idles. */ +export async function pumpEventDeliveries( + env: EdgeEnvironment, + fetchOrigin: OriginFetch = (originRequest) => fetch(originRequest), +): Promise { + if (env.EVENT_DELIVERY_PUMP !== 'true' || validateEnvironment(env)) return false; + const url = new URL(EVENT_PUMP_PATH, env.ORIGIN_BASE_URL); + const request = new Request(url, { method: 'POST', signal: AbortSignal.timeout(25000) }); + try { + const response = await fetchOrigin(originRequest(request, env, url)); + return response.ok; + } catch { + return false; + } +} + export default { fetch(request: Request, env: EdgeEnvironment, ctx: ExecutionContext): Promise { return handleRequest(request, env, undefined, undefined, ctx); }, + scheduled(_controller: ScheduledController, env: EdgeEnvironment, ctx: ExecutionContext): void { + ctx.waitUntil(pumpEventDeliveries(env)); + }, }; diff --git a/edge/src/mcp/endpoint.ts b/edge/src/mcp/endpoint.ts index 0bd49210d..6b2ffe893 100644 --- a/edge/src/mcp/endpoint.ts +++ b/edge/src/mcp/endpoint.ts @@ -15,13 +15,14 @@ import { MAX_MCP_BODY_BYTES, MCP_INSTRUCTIONS, MCP_PATH, - MCP_PROTOCOL_VERSIONS, MCP_SCOPES, MCP_SERVER_VERSION, mcpHeaders, negotiateProtocolVersion, type JsonRpcId, } from './protocol'; +import { eventsEnabled, listEvents, subscribeEvent, unsubscribeEvent, type EventOutcome } from './events'; +import { discoverResult, requestEra, type RequestEra } from './modern'; import { callTool, type GatewayContext } from './tool_call'; import { listedTools } from './tools'; @@ -106,14 +107,25 @@ function validId(value: unknown): value is JsonRpcId { return typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)); } +function eventResponse(id: JsonRpcId, outcome: EventOutcome, era: RequestEra, publicUrl: URL): Response { + if (outcome.kind === 'unauthorized') return unauthorized(publicUrl, true); + if (outcome.kind === 'error') return jsonRpcError(id, outcome.code, outcome.message, 200, {}, outcome.data); + return jsonRpcResult(id, era.modern ? { resultType: 'complete', ...outcome.result } : outcome.result); +} + async function dispatch( message: Record, id: JsonRpcId, access: McpAccess, gateway: GatewayContext, + era: RequestEra, ): Promise { const params = message.params === undefined ? {} : message.params; if (!isJsonObject(params)) return jsonRpcError(id, JSON_RPC_INVALID_PARAMS, 'Params must be an object.'); + // Modern (2026-07-28) results carry resultType; legacy results keep their exact shape. + const respond = (result: object): Response => + jsonRpcResult(id, era.modern ? { resultType: 'complete', ...result } : result); + const events = eventsEnabled(gateway.env); switch (message.method) { case 'initialize': return jsonRpcResult(id, { @@ -122,19 +134,35 @@ async function dispatch( serverInfo: { name: 'alera', title: 'Alera', version: MCP_SERVER_VERSION }, instructions: MCP_INSTRUCTIONS, }); + case 'server/discover': + return jsonRpcResult(id, discoverResult(gateway.env)); case 'ping': - return jsonRpcResult(id, {}); + return respond({}); case 'tools/list': - return jsonRpcResult(id, { tools: listedTools() }); + return respond({ tools: listedTools() }); case 'tools/call': { const outcome = await callTool(gateway, access, params); if (outcome.kind === 'unauthorized') return unauthorized(gateway.publicUrl, true); if (outcome.kind === 'invalid') return jsonRpcError(id, JSON_RPC_INVALID_PARAMS, outcome.message); - return jsonRpcResult(id, outcome.result); + return respond(outcome.result); } - default: - return jsonRpcError(id, JSON_RPC_METHOD_NOT_FOUND, `Method not found: ${String(message.method)}`); - } + case 'events/list': + if (events) return eventResponse(id, listEvents(params), era, gateway.publicUrl); + break; + case 'events/subscribe': + if (events) return eventResponse(id, await subscribeEvent(gateway, params), era, gateway.publicUrl); + break; + case 'events/unsubscribe': + if (events) return eventResponse(id, await unsubscribeEvent(gateway, params), era, gateway.publicUrl); + break; + } + // Modern clients tell an unknown method from a missing endpoint by the 404 status. + return jsonRpcError( + id, + JSON_RPC_METHOD_NOT_FOUND, + `Method not found: ${String(message.method)}`, + era.modern ? 404 : 200, + ); } export async function handleMcpRequest(request: Request, context: McpRequestContext): Promise { @@ -196,14 +224,8 @@ export async function handleMcpRequest(request: Request, context: McpRequestCont if (!validId(message.id)) { return jsonRpcError(null, JSON_RPC_INVALID_REQUEST, 'The request id must be a string or number.', 400); } - const version = request.headers.get('mcp-protocol-version'); - if ( - message.method !== 'initialize' && - version !== null && - !(MCP_PROTOCOL_VERSIONS as readonly string[]).includes(version) - ) { - return jsonRpcError(message.id, JSON_RPC_INVALID_REQUEST, `Unsupported MCP protocol version: ${version}`, 400); - } + const era = requestEra(message, message.id, request.headers); + if (era instanceof Response) return era; const gateway: GatewayContext = { env, fetchOrigin: context.fetchOrigin, @@ -212,5 +234,5 @@ export async function handleMcpRequest(request: Request, context: McpRequestCont signal: request.signal, waitUntil: context.waitUntil, }; - return dispatch(message, message.id, access, gateway); + return dispatch(message, message.id, access, gateway, era); } diff --git a/edge/src/mcp/event_catalog.json b/edge/src/mcp/event_catalog.json new file mode 100644 index 000000000..209d64935 --- /dev/null +++ b/edge/src/mcp/event_catalog.json @@ -0,0 +1,771 @@ +{ + "events": [ + { + "delivery": [ + "webhook" + ], + "description": "An agent replied to a question in an Alera inbox. Read the thread with show_inbox_thread or wait_for_reply.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "questionId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "threadId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "inbox.reply", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "inbox": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "originClientId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "questionId": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "A question in an Alera inbox changed status (delivered, answered, expired, cancelled).", + "inputSchema": { + "additionalProperties": false, + "properties": { + "questionId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "threadId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "inbox.question.status", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "questionId": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "An agent in a workspace tab is waiting, blocked, or done.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "tabId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "agent.status", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "sessionId": { + "type": "string" + }, + "state": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "A terminal session exited.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "tabId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "terminal.exit", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "exitCode": { + "type": "integer" + }, + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "sessionId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "An orchestration task changed state.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "orchestration.task.state", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "An orchestration decision gate was created and waits for a person in Alera.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "orchestration.gate.created", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "gateId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "taskId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "An orchestration task escalated.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "orchestration.escalation", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "taskId": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "An automation run changed status.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "automation.run.state", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "A New Workspace from Prompt operation changed phase or status.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "operationId": { + "description": "Only events whose data carries this value.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "workspace.start.state", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "operationId": { + "type": "string" + }, + "phase": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "A workspace was created, archived, unarchived, slept, woken, or removed.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "workspace.lifecycle", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + }, + { + "delivery": [ + "webhook" + ], + "description": "Watch and Fix dispatched a fix, merged, or stopped for a pull request.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runtime": { + "description": "Runtime name or id. Omit to follow every runtime this connection reaches.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Only events for this workspace.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "pullRequest.watch", + "payloadSchema": { + "additionalProperties": false, + "properties": { + "action": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "projectId": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "runtimeId": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "workspaceId": { + "type": "string" + }, + "workspaceName": { + "type": "string" + } + }, + "required": [ + "runtimeId", + "seq" + ], + "type": "object" + } + } + ], + "version": 1 +} diff --git a/edge/src/mcp/events.ts b/edge/src/mcp/events.ts new file mode 100644 index 000000000..ec439f993 --- /dev/null +++ b/edge/src/mcp/events.ts @@ -0,0 +1,161 @@ +import type { EdgeEnvironment } from '../index'; +import catalog from './event_catalog.json'; +import { + isJsonObject, + JSON_RPC_CALLBACK_ENDPOINT_ERROR, + JSON_RPC_FORBIDDEN, + JSON_RPC_INTERNAL_ERROR, + JSON_RPC_INVALID_PARAMS, + JSON_RPC_METHOD_NOT_FOUND, +} from './protocol'; +import { gateway, type GatewayContext, type GatewayResponse } from './tool_call'; + +/** + * OpenAI MCP Events (`events/list`, `events/subscribe`, `events/unsubscribe`). The edge + * checks the request shape; the cloud verifies the callback, stores the subscription + * bound to the grant, and delivers signed webhooks. + */ + +export interface EventDefinition { + name: string; + description: string; + delivery: string[]; + inputSchema: { properties: Record }; + payloadSchema: Record; +} + +export const EVENT_DEFINITIONS: EventDefinition[] = (catalog as { events: EventDefinition[] }).events; +const EVENT_NAMES = new Set(EVENT_DEFINITIONS.map((event) => event.name)); +const SUBSCRIPTIONS_PATH = '/v1/mcp/event-subscriptions'; +const CALLBACK_REASONS = new Set([ + 'challenge_failed', + 'timeout', + 'invalid_url', + 'dns_failed', + 'non_public_destination', + 'connection_failed', + 'response_too_large', +]); + +export type EventOutcome = + | { kind: 'result'; result: Record } + | { kind: 'error'; code: number; message: string; data?: Record } + | { kind: 'unauthorized' }; + +export function eventsEnabled(env: EdgeEnvironment): boolean { + return env.MCP_EVENTS_ENABLED === 'true'; +} + +function invalid(message: string): EventOutcome { + return { kind: 'error', code: JSON_RPC_INVALID_PARAMS, message }; +} + +export function listEvents(params: Record): EventOutcome { + if (params.cursor !== undefined && params.cursor !== null) { + return invalid('The event catalog has one page; omit cursor.'); + } + return { kind: 'result', result: { events: EVENT_DEFINITIONS } }; +} + +function shortString(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max; +} + +/** Shared checks for subscribe and unsubscribe. Returns an error outcome or null. */ +function checkIdentity(params: Record, needsSecret: boolean): EventOutcome | null { + if (typeof params.name !== 'string' || !EVENT_NAMES.has(params.name)) { + return invalid('Unknown event name. Call events/list for the catalog.'); + } + if (params.arguments !== undefined && !isJsonObject(params.arguments)) { + return invalid('arguments must be an object.'); + } + const definition = EVENT_DEFINITIONS.find((event) => event.name === params.name); + const allowed = new Set(Object.keys(definition?.inputSchema.properties ?? {})); + for (const [key, value] of Object.entries((params.arguments as Record) ?? {})) { + if (!allowed.has(key) || !shortString(value, 128)) return invalid(`Invalid argument ${key}.`); + } + const delivery = params.delivery; + if (!isJsonObject(delivery) || delivery.mode !== 'webhook' || !shortString(delivery.url, 2048)) { + return invalid('delivery must be { mode: "webhook", url }.'); + } + if (needsSecret && !shortString(delivery.secret, 256)) { + return invalid('delivery.secret must be a whsec_ signing secret.'); + } + return null; +} + +function failure(response: GatewayResponse): EventOutcome { + if (response.status === 401) return { kind: 'unauthorized' }; + const error = isJsonObject(response.body?.error) ? response.body.error : null; + const code = typeof error?.code === 'string' ? error.code : ''; + const message = typeof error?.message === 'string' ? error.message : 'The Alera service refused the request.'; + if (code === 'callback_endpoint_error') { + const reason = CALLBACK_REASONS.has(message) ? message : 'verification_failed'; + return { + kind: 'error', + code: JSON_RPC_CALLBACK_ENDPOINT_ERROR, + message: 'CallbackEndpointError', + data: { reason }, + }; + } + if (code === 'mcp_events_disabled') { + return { kind: 'error', code: JSON_RPC_METHOD_NOT_FOUND, message: 'MCP Events are not enabled.' }; + } + if (response.status === 400 || response.status === 404 || response.status === 409) { + return invalid(code ? `${code}: ${message}` : message); + } + if (response.status === 403) return { kind: 'error', code: JSON_RPC_FORBIDDEN, message }; + return { kind: 'error', code: JSON_RPC_INTERNAL_ERROR, message: 'Event subscriptions are temporarily unavailable.' }; +} + +async function forward(context: GatewayContext, path: string, body: unknown): Promise { + try { + return await gateway(context, 'POST', path, body); + } catch { + return null; + } +} + +export async function subscribeEvent( + context: GatewayContext, + params: Record, +): Promise { + const problem = checkIdentity(params, true); + if (problem) return problem; + if (params.cursor !== undefined && params.cursor !== null && !shortString(params.cursor, 1024)) { + return invalid('cursor must be a string or null.'); + } + const ttl = params.ttlMs; + if (ttl !== undefined && ttl !== null && !(Number.isSafeInteger(ttl) && (ttl as number) > 0)) { + return invalid('ttlMs must be a positive integer or null.'); + } + const delivery = params.delivery as Record; + const response = await forward(context, SUBSCRIPTIONS_PATH, { + name: params.name, + arguments: params.arguments ?? {}, + delivery: { mode: 'webhook', url: delivery.url, secret: delivery.secret }, + cursor: params.cursor ?? null, + ttlMs: ttl ?? null, + }); + if (!response) return { kind: 'error', code: JSON_RPC_INTERNAL_ERROR, message: 'The Alera service is unavailable.' }; + if (response.status !== 200 || !response.body || typeof response.body.id !== 'string') return failure(response); + const { id, refreshBefore, cursor, truncated } = response.body; + return { kind: 'result', result: { id, refreshBefore, cursor, truncated: truncated === true } }; +} + +export async function unsubscribeEvent( + context: GatewayContext, + params: Record, +): Promise { + const problem = checkIdentity(params, false); + if (problem) return problem; + const delivery = params.delivery as Record; + const response = await forward(context, `${SUBSCRIPTIONS_PATH}/unsubscribe`, { + name: params.name, + arguments: params.arguments ?? {}, + delivery: { mode: 'webhook', url: delivery.url }, + }); + if (!response) return { kind: 'error', code: JSON_RPC_INTERNAL_ERROR, message: 'The Alera service is unavailable.' }; + if (response.status !== 204 && response.status !== 200) return failure(response); + return { kind: 'result', result: {} }; +} diff --git a/edge/src/mcp/modern.ts b/edge/src/mcp/modern.ts new file mode 100644 index 000000000..12dadecf0 --- /dev/null +++ b/edge/src/mcp/modern.ts @@ -0,0 +1,87 @@ +import type { EdgeEnvironment } from '../index'; +import { eventsEnabled } from './events'; +import { + isJsonObject, + jsonRpcError, + JSON_RPC_HEADER_MISMATCH, + JSON_RPC_UNSUPPORTED_VERSION, + MCP_INSTRUCTIONS, + MCP_MODERN_PROTOCOL_VERSION, + MCP_SERVER_VERSION, + MCP_SUPPORTED_VERSIONS, + type JsonRpcId, +} from './protocol'; + +/** + * MCP 2026-07-28 support next to the handshake-based versions. A modern request carries + * its version in `params._meta` and the `MCP-Protocol-Version` header; legacy clients keep + * sending `initialize` and are served exactly as before. + */ + +const META_VERSION = 'io.modelcontextprotocol/protocolVersion'; +const SERVER_INFO = { name: 'alera', title: 'Alera', version: MCP_SERVER_VERSION }; + +export interface RequestEra { + version: string | null; + modern: boolean; +} + +function decodeHeader(value: string): string | null { + const match = /^=\?base64\?(.*)\?=$/.exec(value); + if (!match) return value; + try { + const bytes = Uint8Array.from(atob(match[1]), (char) => char.charCodeAt(0)); + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return null; + } +} + +function mismatch(id: JsonRpcId, detail: string): Response { + return jsonRpcError(id, JSON_RPC_HEADER_MISMATCH, `Header mismatch: ${detail}`, 400); +} + +/** Reads the request's protocol version, or returns the 400 response that rejects it. */ +export function requestEra(message: Record, id: JsonRpcId, headers: Headers): RequestEra | Response { + const header = headers.get('mcp-protocol-version'); + const params = isJsonObject(message.params) ? message.params : {}; + const meta = isJsonObject(params._meta) ? params._meta : {}; + const metaVersion = typeof meta[META_VERSION] === 'string' ? (meta[META_VERSION] as string) : null; + if (header !== null && metaVersion !== null && header !== metaVersion) { + return mismatch(id, 'MCP-Protocol-Version does not match _meta.'); + } + const version = metaVersion ?? header; + // The legacy handshake negotiates its version in the body. + if (message.method === 'initialize') return { version, modern: false }; + if (version !== null && !MCP_SUPPORTED_VERSIONS.includes(version)) { + return jsonRpcError(id, JSON_RPC_UNSUPPORTED_VERSION, 'Unsupported protocol version', 400, {}, { + supported: [...MCP_SUPPORTED_VERSIONS], + requested: version, + }); + } + const modern = version === MCP_MODERN_PROTOCOL_VERSION; + if (modern) { + const method = headers.get('mcp-method'); + if (method !== null && method !== message.method) return mismatch(id, 'Mcp-Method does not match the body.'); + const name = headers.get('mcp-name'); + const target = typeof params.name === 'string' ? params.name : typeof params.uri === 'string' ? params.uri : null; + if (name !== null && target !== null && decodeHeader(name) !== target) { + return mismatch(id, 'Mcp-Name does not match the body.'); + } + } + return { version, modern }; +} + +/** The `server/discover` result. `events` is advertised only while MCP Events is on. */ +export function discoverResult(env: EdgeEnvironment): Record { + const capabilities: Record = { tools: { listChanged: false } }; + if (eventsEnabled(env)) capabilities.events = {}; + return { + resultType: 'complete', + supportedVersions: [...MCP_SUPPORTED_VERSIONS], + capabilities, + serverInfo: SERVER_INFO, + _meta: { 'io.modelcontextprotocol/serverInfo': SERVER_INFO }, + instructions: MCP_INSTRUCTIONS, + }; +} diff --git a/edge/src/mcp/protocol.ts b/edge/src/mcp/protocol.ts index 30cdcc957..271638d86 100644 --- a/edge/src/mcp/protocol.ts +++ b/edge/src/mcp/protocol.ts @@ -1,26 +1,42 @@ export const MCP_PATH = '/v1/mcp'; export const MCP_SERVER_VERSION = '0.1.0'; +/** Versions that open with an `initialize` handshake, newest first. */ export const MCP_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'] as const; -export const MCP_SCOPES = 'mcp:read mcp:execute'; +/** The first protocol revision without a handshake: every request carries its version. */ +export const MCP_MODERN_PROTOCOL_VERSION = '2026-07-28'; +/** Every version this endpoint serves, newest first, as `server/discover` lists them. */ +export const MCP_SUPPORTED_VERSIONS: readonly string[] = [MCP_MODERN_PROTOCOL_VERSION, ...MCP_PROTOCOL_VERSIONS]; +export const MCP_SCOPES = 'mcp:read mcp:execute mcp:admin'; export const MAX_MCP_BODY_BYTES = 1024 * 1024; export const MCP_INSTRUCTIONS = 'Alera controls coding workspaces, terminals, and agents on the runtimes this connection was granted. ' + 'Call list_runtimes first to see which runtimes are online. Every other tool accepts an optional ' + '`runtime` argument (runtime name or id); pass it whenever more than one runtime is online. ' + - 'Nothing is remembered between calls, so name the runtime on each call.'; + 'Nothing is remembered between calls, so name the runtime on each call. ' + + 'Alera skills explain how to use these tools for each kind of task: before the first Alera task of a conversation, ' + + 'call list_skills and read the matching skill with read_skill, then follow it.'; export const JSON_RPC_PARSE_ERROR = -32700; export const JSON_RPC_INVALID_REQUEST = -32600; export const JSON_RPC_METHOD_NOT_FOUND = -32601; export const JSON_RPC_INVALID_PARAMS = -32602; +export const JSON_RPC_INTERNAL_ERROR = -32603; export const JSON_RPC_SERVER_ERROR = -32000; +export const JSON_RPC_FORBIDDEN = -32001; +/** OpenAI MCP Events: the callback failed verification. */ +export const JSON_RPC_CALLBACK_ENDPOINT_ERROR = -32015; +/** MCP 2026-07-28: HTTP headers disagree with the body. */ +export const JSON_RPC_HEADER_MISMATCH = -32020; +/** MCP 2026-07-28: the requested protocol version is not served. */ +export const JSON_RPC_UNSUPPORTED_VERSION = -32022; export type JsonRpcId = string | number; export const CORS_HEADERS: Record = { 'access-control-allow-origin': '*', - 'access-control-allow-headers': 'authorization, content-type, mcp-protocol-version, mcp-session-id', + 'access-control-allow-headers': + 'authorization, content-type, mcp-protocol-version, mcp-session-id, mcp-method, mcp-name', 'access-control-expose-headers': 'www-authenticate, mcp-session-id', 'access-control-max-age': '86400', }; @@ -58,8 +74,10 @@ export function jsonRpcError( message: string, status = 200, extra: Record = {}, + data?: Record, ): Response { - return jsonRpcResponse({ jsonrpc: '2.0', id, error: { code, message } }, status, extra); + const error = data === undefined ? { code, message } : { code, message, data }; + return jsonRpcResponse({ jsonrpc: '2.0', id, error }, status, extra); } export function emptyMcpResponse(status: number, extra: Record = {}): Response { diff --git a/edge/src/mcp/relay_calls.ts b/edge/src/mcp/relay_calls.ts index 103d116ca..00b5dbf52 100644 --- a/edge/src/mcp/relay_calls.ts +++ b/edge/src/mcp/relay_calls.ts @@ -80,7 +80,7 @@ export class McpRelayCalls { attachment.role === 'runtime' && !attachment.suppressDisconnect && attachment.exp > now && - (attachment.mcpAccess === 'read' || attachment.mcpAccess === 'full') + (attachment.mcpAccess === 'read' || attachment.mcpAccess === 'full' || attachment.mcpAccess === 'admin') ) { return socket; } diff --git a/edge/src/mcp/skill_catalog.json b/edge/src/mcp/skill_catalog.json new file mode 100644 index 000000000..c5fe68fe9 --- /dev/null +++ b/edge/src/mcp/skill_catalog.json @@ -0,0 +1,97 @@ +{ + "version": 1, + "skills": [ + { + "name": "alera-mcp", + "description": "Work with Alera through this MCP server. Covers runtimes, projects, workspaces, agents and terminals, inbox questions, pull requests, events, and runtime settings. Read it before the first Alera task of a conversation.", + "version": 1, + "digest": "4e74bd77e1c3ac3cabc20f0c3d5db46e776a917944143fe54220890aa18a87e0", + "files": [ + { + "path": "SKILL.md", + "digest": "dc201ba535746a621f1adc76999fc025bc5635d78d2539b3c990e5671db62571", + "text": "---\nname: alera-mcp\ndescription: Work with Alera through this MCP server. Covers runtimes, projects, workspaces, agents and terminals, inbox questions, pull requests, events, and runtime settings. Read it before the first Alera task of a conversation.\nmetadata:\n version: 1\n---\n\n# Alera Through MCP\n\nAlera runs coding agents in workspaces on the user's machines. A workspace is a task with its own Git worktree and branch, or a task on the project folder itself. Each machine is a runtime. This server reaches the runtimes the user granted to this connection.\n\n## How Calls Work\n\n- Call `list_runtimes` first. Every other Alera tool takes an optional `runtime` (name or id). Pass it whenever more than one runtime is online. Nothing is remembered between calls, so name the runtime on each call.\n- Use exact ids from the list and show tools. Never guess an id, a branch, or a profile name.\n- Access has three levels: read, full, and admin. A tool outside the connection's grant fails with `insufficient_scope`. Tell the user to reconnect Alera and allow that level; do not look for another tool that does the same thing.\n- Waits return after at most 50 seconds. Call the wait again while the work is still running, and pass the returned cursor so nothing is read twice.\n- Tools that change state and accept `clientRequestId` deduplicate retries. After a timeout or an unclear answer, retry with the same `clientRequestId` instead of a new one, so the action does not happen twice.\n- A failed tool returns `structuredContent.error` with `code` and `retryable`. Codes include `not_found`, `invalid_argument`, `conflict`, `blocked` (the app refused, as it would in its own UI), `capability_missing` (the runtime needs an Alera update), `provider_unavailable` (`gh`, `glab`, or `az` is missing or signed out on that machine), `timeout_pending` (still running; read its state again), and `runtime_unavailable`. Retry only when `retryable` is true.\n- Errors from this server itself start with `runtime_offline:`, `timeout:`, or `insufficient_scope:`. A `runtime_offline` runtime is not running or has MCP Control off; only the user can fix that.\n\n## Authorization\n\nDo only what the user asked for. Before removing anything, run the matching preview (`preview_workspace_removal`, `preview_project_removal`, `preview_agent_profile_removal`) and report what it would affect. Merging, closing, and removing are the user's decisions; honor an explicit request for the same scope without asking again, but never infer one.\n\n## Choose The Workflow\n\n- Projects, workspaces, New Workspace from Prompt, sections, tags, hosts, issues, and relocation: read [workspaces](references/workspaces.md).\n- Launching agents, tabs, terminals, and asking an agent a question: read [agents](references/agents.md).\n- Pull requests on GitHub, GitLab, and Azure DevOps, Ship, and Watch and Fix: read [pull requests](references/pull-requests.md).\n- Following what happens without polling each item, and webhooks: read [events](references/events.md).\n- Runtime status, settings, quotas, voice, status hooks, and the agents' Alera skills: read [runtime](references/runtime.md).\n- Delegating tasks to agents, coordinator runs, and workflow recipes: use the `alera-mcp-orchestration` skill.\n- Scheduled or recurring agent work: use the `alera-mcp-automations` skill.\n- Creating or changing agent profiles: use the `alera-mcp-agent-profiles` skill.\n\nRead only the references the task needs. Read them with `read_skill`, passing the reference path as `file`.\n\n## Starting Work\n\nFor a task described in words, prefer `start_workspace_from_prompt`. It finds the project, names the workspace and branch, and launches the default agent, like the app's New Workspace from Prompt. Use `start_agent_workspace` when you already know the project, profile, and branch. Use `launch_agent` to add an agent to an existing workspace, and `delegate_task` when a running coordinator agent should own the result.\n\nThen follow the agent with `wait_for_events`, `read_terminal`, or `ask_agent` with `wait_for_reply`. An agent only sees what is typed into its terminal or asked through the inbox; it cannot see this conversation.\n" + }, + { + "path": "references/agents.md", + "digest": "2869fdfbe77a7a09bbc358194114a79d6180dcb9bbfb5f1747065ddbe2373811", + "text": "# Agents, Tabs, And Terminals\n\n## Launching Agents\n\n`list_agent_profiles` lists the agent profiles the user declared (Claude, Codex, and others) with their ids and names. Pick a profile from that list; never invent one. Profile administration belongs to the `alera-mcp-agent-profiles` skill.\n\n`launch_agent` opens a new tab in an existing workspace. Send a `prompt` to start a conversation, `resumeSessionId` to continue an earlier one, or neither to start the agent idle. Pass `clientRequestId` so a retry does not open a second tab.\n\n## Tabs\n\n`list_tabs` lists a workspace's tabs, including terminal and agent tabs.\n\n- `create_tab` opens a terminal tab, optionally running a command.\n- `rename_tab` renames one, and `generate_tab_title` names an agent tab from its conversation with AI Assist.\n- `close_tab` closes a tab and ends its session.\n- `link_agent_to_tab` binds a running agent conversation to a tab again when the tab stopped showing the agent's status.\n\n## Terminals\n\n`list_terminals` lists live sessions with their handles. Most terminal tools take a `handle` from it. `show_terminal` shows one session with its agent and lifecycle state.\n\n- `read_terminal` reads retained output. Pass the returned cursor next time to read only what is new.\n- `wait_for_terminal` waits for `process-started`, `agent-detected`, `agent-ready`, or `dispatch-accepted`.\n- `write_terminal` types text. Use `submit` to send a prompt to an interactive agent; use `enter` to press Enter after a shell command. Type into an agent only when the user asked for it, and read the terminal first so you do not interrupt work in progress.\n- `restart_terminal` replaces the process and keeps the tab and scrollback. `terminate_terminal` ends the session and closes the tab.\n- `prune_terminals` lists stopped terminal tabs, and with `apply` removes them. Run it without `apply` first.\n- Pulse types a configured input into a terminal after workspace files change. `get_terminal_pulse` shows it, and `configure_terminal_pulse` changes, arms, or disarms it.\n\n## Asking An Agent\n\nThe inbox lets you ask a running agent a question and read its answer later. The agent sees which MCP client asked; it cannot read this conversation, so put everything it needs in the question.\n\n1. `list_inbox_targets` lists the agents you can reach.\n2. `ask_agent` asks by terminal `to`, or by `workspaceId` when the workspace runs one agent (add `agent` to choose among several). It returns the question id and thread id. Pass `threadId` to follow up in the same thread.\n3. `wait_for_reply` waits for news about one question. `wait_for_inbox` waits for any reply to this client's questions, so one call covers every open question. Pass the returned cursor as `after`.\n\nA question reaches the agent when its current turn ends. `expiresIn` drops it if it is never delivered. `cancel_question` cancels it only before the agent sees it.\n\n- `list_inbox_threads` lists threads asked by every MCP client sharing this inbox; `isOwn` marks this client's. `show_inbox_thread` shows one thread whole.\n- `mark_thread_read` marks a thread's replies as read for everyone sharing the inbox.\n- `list_inboxes` lists the external inboxes with their counts.\n- `list_agent_conversations` and `show_agent_conversation` show conversations between agents, read-only.\n- `purge_inbox` deletes every question of the shared MCP inbox, including other clients' threads. Use it only when the user asks for exactly that.\n" + }, + { + "path": "references/events.md", + "digest": "287c727f182dc4ec6915eceb563004105067a15f408fbd79a26cf8bb12702d8a", + "text": "# Events And Webhooks\n\n## Following Work\n\nThe runtime keeps a journal of events, including:\n- inbox replies and question states, agent states, and terminal exits;\n- orchestration task changes, decision gates, and escalations;\n- automation runs, workspace starts and lifecycle, and Watch and Fix actions.\n\nEvents carry ids and states only; read details with the matching tool.\n\n- `wait_for_events` waits for events after a cursor and returns them with the next cursor. One wait covers every kind you filter for. Prefer it to polling each question, task, or run separately. Filter with `kinds` and `workspaceId`.\n- `list_events` reads the journal without waiting. `truncated` means older events were pruned.\n\nKeep the latest cursor and pass it as `after` on the next call. Omitting `after` reads from the oldest retained event.\n\nClients that support MCP Events can subscribe through the protocol instead; the events and their filters are the same.\n\n## Webhooks\n\nA webhook sends this runtime's events to an HTTPS endpoint as signed POST requests (Standard Webhooks) through the Alera cloud. It needs a signed-in Alera account and administrative access.\n\n- `create_webhook` adds one and returns the signing secret once. Give the secret to the user right away; it cannot be read again.\n- `list_webhooks` lists them with their last delivery. `test_webhook` sends a signed test delivery. `delete_webhook` removes one.\n" + }, + { + "path": "references/pull-requests.md", + "digest": "0aef70bf30724055ab6a782ebc302b9e1f41489e7e44d9557027ffbb938b6a3a", + "text": "# Pull Requests\n\nAlera works with pull requests on GitHub, merge requests on GitLab, and pull requests on Azure DevOps. It uses `gh`, `glab`, or `az` on the machine that holds the workspace. A `provider_unavailable` error means that CLI is missing or signed out there; tell the user rather than retrying. Every tool takes the `workspaceId` whose branch the pull request belongs to.\n\n## Reading\n\n- `get_pull_request` returns the workspace's linked pull request, or the open one for its branch. It includes state, draft, mergeability, head SHA, checks, the conversation and review threads with their ids, the merge methods allowed, and the base branches. Read it before acting on a pull request.\n- `list_pull_request_summaries` gives one compact row per active workspace with a pull request, with failing check names.\n\n## Opening\n\n- `ship_changes` does what the app's Ship Changes button does. It stages, commits with an AI Assist message, moves work off a shared base branch, pushes, and opens a pull request with AI Assist details. With `followUpWatch` it then starts Watch and Fix. It needs AI Assist. If the call times out, the ship keeps running; read `get_pull_request` for the result instead of shipping again.\n- `generate_pull_request_details` writes a title and description with AI Assist without touching the forge. After about 45 seconds it may return status `running` with an `operationId`. Call again with that `operationId` as `clientRequestId`, the same workspace, and the same base branch to wait or read the result.\n- `create_pull_request` opens one from the workspace's current branch. Push the branch first.\n- `link_pull_request` links an existing pull request by number or URL, and `unlink_pull_request` removes the link without changing the pull request.\n\n## Conversation And State\n\n- `comment_pull_request` comments, or replies with `replyToCommentId`. On GitLab and Azure DevOps also pass the `threadId` from `get_pull_request`.\n- `edit_pull_request_comment` replaces the text of one of your comments, using the id, source, and threadId from `get_pull_request`.\n- `set_pull_request_draft` marks it draft or ready. `close_pull_request` closes it without merging (abandon on Azure DevOps).\n\n## Merging\n\n`merge_pull_request` merges. Choose `method` from the `mergeMethods` in `get_pull_request`. Pass `expectedHeadSha` from the state you checked, so the merge fails if someone pushed since. Merge only when the user asked for it.\n\n## Agents On Pull Requests\n\n- `fix_pull_request_checks` asks an agent to fix failed checks, like the Fix Failed Checks button. `restack_pull_request` asks one to rewrite the changes into reviewable commits without pushing. Both send the prompt to a running agent (`handle`) or open a tab from a profile; `preview` returns only the prompt.\n- Watch and Fix sends failing checks, merge conflicts, and unresolved review threads to an agent as they appear. `start_pull_request_watch` starts it; mode `fixAndMerge` also merges once checks pass and nothing is open. `show_pull_request_watch` shows the active watch, and `stop_pull_request_watch` stops it.\n\n## GitHub Stacks\n\nStacks exist only on GitHub and need the `gh-stack` extension.\n\n- `get_pull_request_stack` shows the stack that holds the workspace's pull request, bottom layer first.\n- `create_pull_request_stack` builds a stack from local workspaces, bottom to top. It pushes each branch and opens the pull requests that are missing.\n- `link_pull_request_stack` stacks existing pull requests.\n- `merge_pull_request_stack` merges every layer at or below the workspace's pull request at once.\n" + }, + { + "path": "references/runtime.md", + "digest": "82bca839cc141a22385fd35e2b963fc1b674292d906929b5180bf8b06cc099ac", + "text": "# Runtime\n\n## Status\n\n- `runtime_status` shows whether the runtime host is running, its database, and its active sessions.\n- `get_version` shows the Alera versions and the protocol and skill contract versions. Check it when a tool answers `capability_missing`; the runtime may need an Alera update.\n- `get_resource_snapshot` shows CPU and memory use of the host, the runtime, and each terminal.\n\n## Settings\n\n`get_runtime_settings` shows the settings open to MCP clients. They include the default agent profile, the worktree folder, removal confirmations, AI Assist, and automation retention. `update_runtime_settings` changes them. It needs administrative access, and fields left out keep their values. Change settings only when the user asks.\n\nStatus hooks let agents report their state to Alera. `get_agent_integrations` shows them, and `set_agent_integrations` turns them on or off.\n\n## Agent Quotas\n\n`get_agent_quotas` shows usage limits for the enabled providers; `refresh` fetches new numbers.\n- `refresh_claude_quota` reads one Claude account's usage again.\n- `consume_codex_reset_credit` spends an offered Codex reset credit. It needs administrative access and the user's explicit request.\n\n## Voice\n\n`voice_status` shows the voice home agent and its speech pipeline. `voice_speak` says a short message to the user through it.\n\n## Skills For Coding Agents\n\nCoding agents that run in Alera terminals use their own Alera skills, installed on the runtime's machine. These are separate from the skills this server serves.\n- `check_agent_skills` shows whether those skills are installed and whether they match the runtime's version. It also shows an install in progress (`install.running`) and the last finished one (`install.last`).\n- `install_agent_skills` installs or updates them at the runtime's own version. It needs administrative access.\n- The runtime runs the install as a job, and only one at a time. The call waits up to 45 seconds. State `running` means the install is still going: read `check_agent_skills` later rather than calling again.\n\nSuggest an install when a skill is missing or outdated and agents misuse the `alera` CLI.\n" + }, + { + "path": "references/workspaces.md", + "digest": "b75d9e152931f80c19cc1cc843890a615bee92c8a50b65df7e1e525e3648e107", + "text": "# Projects And Workspaces\n\n## Projects\n\nA project is a repository or folder registered in a runtime. `list_projects` shows each project with the hosts it is on.\n\n- Add a folder that already exists with `register_project`. The folder is not changed.\n- Clone a repository with `clone_project`. It returns a clone job at once; follow it with `get_project_clone`. `list_project_clones` lists jobs, and `cancel_project_clone` stops one and deletes its partial folder.\n- A project can live on SSH hosts too. `list_ssh_targets` lists the hosts the runtime knows, and `ssh_target_status` checks whether they are reachable. `register_remote_project` adds a project that exists only on a host. For an existing project, `add_project_host` adds a host, either from a folder there or by cloning, and `register_project_checkout` registers a specific folder. `list_project_hosts` shows the folder on each host, and `remove_project_host` forgets one without deleting files.\n- `list_project_branches` lists the branches that can be a new worktree's `sourceBranch`, and the project's preferred one.\n- `get_project_config` shows the effective project settings and whether they come from the app or from the repository's `alera.toml`. `update_project_config` saves app settings that override the file, part by part, and `reset_project_config` goes back to the file or the defaults.\n- `rename_project` changes only the display name. `remove_project` removes the project from Alera as the app does: dependent automations are paused, and no file is deleted. Run `preview_project_removal` first and report what it shows.\n\n## Workspaces\n\n`list_workspaces` lists the workspaces of one project or all of them, with filters. `show_workspace` shows one with its section, tags, linked issue, linked pull request, Watch and Fix state, parent, children, and whether it is asleep.\n\nThere are four ways to create one:\n\n1. `start_workspace_from_prompt` does what the app's New Workspace from Prompt form does. AI Assist picks the project when `projectId` is left out, names the workspace and branch, picks a section, and launches the agent. When the project is unclear it ends with status `needsInput` and candidates; call again with `projectId`. It answers within about 40 seconds; while the status is `running`, follow it with `wait_for_workspace_start` or `get_workspace_start`. `list_workspace_starts` lists recent operations. `cancel_workspace_start` stops one, and a workspace it already created is kept. If the workspace exists but the agent did not start, `retry_workspace_start_launch` launches it again without creating another workspace.\n2. `start_agent_workspace` creates a workspace and launches a named profile with a prompt. A new worktree from `projectId` needs `sourceBranch`.\n3. `create_workspace` creates a workspace without starting an agent, as the manual New Workspace form does.\n4. `delegate_task` with a new workspace, described in the `alera-mcp-orchestration` skill.\n\nA workspace on its own worktree has its own branch. A workspace on the project folder shares the folder and its branch with every other task there.\n\n## Workspace Lifecycle\n\n- `rename_workspace` changes only the display name; the branch and folder stay.\n- `set_workspace_pinned` pins or unpins it in the sidebar. `focus_workspace` selects it in the running desktop app; it fails when no desktop app is connected.\n- `sleep_workspace` stops its terminals and keeps tabs, branch, and files. `wake_workspace` starts them again and resumes the agents' conversations.\n- `archive_workspace` also hides it from the sidebar; `unarchive_workspace` brings it back.\n- `remove_workspace` follows the app's Remove flow. Its sessions close, automations that depend on it are paused, editors open on it are saved, and its worktree is deleted. Uncommitted changes in the worktree are lost. The branch is deleted only when Alera created it, unless `branch` is `keep`. Always run `preview_workspace_removal` first and report the storage, blockers, automations, links, and branch outcome. A `blocked` answer removed nothing.\n\n## Moving Between Folder And Worktree\n\n- `hand_off_workspace` moves a task from the project folder to its own worktree on a new or existing branch, choosing whether uncommitted changes move with it.\n- `hand_on_workspace` brings a task from its worktree back to the project folder.\n- A relocation runs the project's setup. `get_workspace_recovery` shows its phases and setup attempts. `run_workspace_setup` runs the setup in a workspace. `cancel_workspace_setup` stops a running attempt, and `recover_workspace_setup` closes an interrupted one without running its commands again.\n\n## Organizing\n\n- Sections group workspaces in the sidebar. A workspace without one is in Others. Use `list_sections`, `create_section`, `set_workspace_section`, `clear_workspace_section`, and `remove_section`; removing a section keeps its workspaces.\n- Tags: `list_tags`, `upsert_tag` to create, rename, or recolor, `remove_tag`, `tag_workspace`, and `untag_workspace`.\n- Parent and child links nest workspaces: `link_workspaces` and `unlink_workspaces`. `preview_workspace_cascade` lists the workspaces an action would reach through descendants and shared tags.\n\n## Linked Issues\n\n`link_workspace_issue` links one issue URL to a workspace, and `unlink_workspace_issue` removes it. `show_workspace_issue` reads it fresh from GitHub, GitLab, or Azure DevOps. `fetch_issue` reads any issue by URL. These use `gh`, `glab`, or `az` on the runtime's machine.\n\n## Metadata Repair\n\n`register_workspace_record` and `unregister_workspace_record` only write or delete Alera's record of a workspace. They never create or delete a worktree. Use them only when the user asks to repair metadata, never as a shortcut to create or remove a workspace.\n" + } + ] + }, + { + "name": "alera-mcp-agent-profiles", + "description": "Inspect, create, change, reorder, and remove Alera agent profiles through this MCP server. Use when the user asks to maintain the launch catalog of coding agents.", + "version": 1, + "digest": "863df9612cb71d931c30f92655082149ed6090a797c8e515a2cce8a2b75b2a53", + "files": [ + { + "path": "SKILL.md", + "digest": "8e5e80287dcedb1a03d1f899ebffaf2811ff482bd687fd96cb040d64e6ba1202", + "text": "---\nname: alera-mcp-agent-profiles\ndescription: Inspect, create, change, reorder, and remove Alera agent profiles through this MCP server. Use when the user asks to maintain the launch catalog of coding agents.\nmetadata:\n version: 1\n---\n\n# Alera Agent Profiles Through MCP\n\nAn agent profile is a launch recipe for a coding agent: an adapter (`agentType`) plus either a command line or a managed configuration. Workspaces, delegation, and automations launch agents by profile.\n\n## Reading\n\n`list_agent_profiles` lists them. `show_agent_profile` shows one with its launch configuration and revision, by id or unique name. Reading is always allowed; launching is in the `alera-mcp` skill.\n\n## Changing\n\nEvery change needs administrative access and must be covered by the user's request. Honor a prior explicit request for the same change without asking again; a proposal-only request stops at the proposal.\n\n- `create_agent_profile` creates one. A command profile takes `command`. A managed profile takes `managedConfig` with the keys its adapter supports; read [managed configuration](references/managed.md).\n- `update_agent_profile` changes only the given fields. Pass `expectedRevision` from `show_agent_profile` to refuse a stale edit. Changing the adapter of a managed profile needs a new `managedConfig`.\n- `reorder_agent_profiles` sets the whole order: list every current profile id exactly once.\n- `set_default_agent_profile` chooses the profile New Workspace from Prompt uses when none is named.\n- `remove_agent_profile` removes one. Run `preview_agent_profile_removal` first, report what refers to it (such as automations), and remove it only when that is covered by the request.\n\nRe-read the changed profile or the order afterwards to confirm it was saved.\n\n## Reduced Protections\n\nNever infer permission to reduce an agent's protections from a general profile request. Settings that skip permission prompts, bypass sandboxes, or approve actions automatically need the user's explicit intent and `confirmReducedProtections`.\n\nExamples are Codex bypass or never-ask approval, Claude bypass permissions, Copilot allow-all, Cursor force or trusted workspace, and OpenCode auto approval.\n" + }, + { + "path": "references/managed.md", + "digest": "8310338d73e2981360d2970ec6f181d6ed2e13f713f962469ea4588516ad1654", + "text": "# Managed Configuration\n\nA managed profile stores its settings in `managedConfig`, and Alera builds the command line from them. These are the keys of Alera's current adapters. Unknown keys fail closed, and `show_agent_profile` returns an existing configuration in the same shape.\n\n| Adapter | Keys |\n|---|---|\n| `codex` | `model`, `effort`, `planModeEffort`, `sandbox`, `approvalPolicy`, `webSearch`, `bypassApprovalsAndSandbox` |\n| `claude` | `model`, `effort`, `agent`, `permissionMode`, `allowSkipPermissions`, `ccsProfile` |\n| `copilot` | `model`, `effort`, `agent`, `mode`, `context`, `allowAll`, `maxAiCredits`, `maxAutopilotContinues`, `noAskUser` |\n| `cursor` | `model`, `mode`, `permissionMode`, `sandbox`, `trustWorkspace` |\n| `agy` | `model`, `effort`, `agent`, `mode`, `skipPermissions`, `sandbox` |\n| `opencode` | `model`, `agent`, `autoApprove` |\n| `opencode2` | `model`, `agent`, `autoApprove` |\n| `pi` | `model`, `thinking`, `projectTrust` |\n| `amp` | `mode`, `fast` |\n| `grok` | `model`, `effort`, `agent`, `permissionMode`, `sandbox`, `disableWebSearch` |\n| `fx` | `resumeLast`, `noAdditionalDirs`, `record` |\n\nUse model names the user actually has; never invent a model slug. `quotaGroup` marks profiles that draw from the same usage limit, and `description` says what the profile is for. Coordinators read both to choose profiles and fallbacks, so keep them accurate.\n\nExample of a managed Codex profile:\n\n```json\n{\n \"name\": \"Codex High\",\n \"agentType\": \"codex\",\n \"launchMode\": \"managed\",\n \"managedConfig\": {\"model\": \"gpt-5.6-sol\", \"effort\": \"high\", \"webSearch\": true},\n \"description\": \"Hard implementation and deep debugging.\",\n \"quotaGroup\": \"codex\"\n}\n```\n\nThe model in the example is illustrative.\n" + } + ] + }, + { + "name": "alera-mcp-automations", + "description": "Create, edit, inspect, pause, and run scheduled Alera agent automations through this MCP server, and follow their runs. Use for recurring or one-time agent work.", + "version": 1, + "digest": "69a376c5cd20423aeab4a92fd07b7320a67be45a63e254f2bd518b9464fde248", + "files": [ + { + "path": "SKILL.md", + "digest": "9b47a386aad9ff3fe7dfa18503513d428e9aee0b77ba45cdf122496b2f4018da", + "text": "---\nname: alera-mcp-automations\ndescription: Create, edit, inspect, pause, and run scheduled Alera agent automations through this MCP server, and follow their runs. Use for recurring or one-time agent work.\nmetadata:\n version: 1\n---\n\n# Alera Automations Through MCP\n\nAn automation is a definition: a prompt, a schedule, a target, and an agent profile. Each time it fires, it creates a run. Orchestration tasks are separate; see the `alera-mcp-orchestration` skill.\n\n## Authoring\n\nChoose the execution target explicitly; never infer its type. A valid prompt, schedule, target, and launchable profile are enough. There is no approval or repository declaration step. Read [definitions](references/definitions.md) for the definition JSON and the five targets.\n\n1. `check_automation_readiness` validates a full or partial definition without saving it and lists the fields to fix. Run it before creating.\n2. `preview_automation_schedule` lists the next occurrences of a cron expression or a one-time date in a time zone.\n3. `create_automation` saves it, active unless `draft` is true. Pass `clientRequestId` so a retry does not create a duplicate.\n4. `update_automation` changes the given fields. Pass `expectedRevision` to refuse a stale edit. Edits apply to future runs.\n5. `clone_automation` copies an automation's settings and target.\n\n## Reading\n\n`list_automations` filters like the Automations view. `show_automation` shows the definition, readiness, next occurrences, recent runs, and audit history. `list_automation_runs` and `show_automation_run` read runs and their attempts.\n\n## Running And State\n\n- `run_automation` runs it once now, like Run Now, without changing its schedule. `continueFromRunId` continues an earlier run's conversation in its preserved workspace.\n- `pause_automation` stops scheduling. When runs are active, `activeRuns` chooses whether they continue or are cancelled. `resume_automation` activates a paused or draft automation.\n- `trash_automation` moves it to the trash, and `restore_automation` brings it back. `purge_automations` permanently deletes everything in the trash for at least 30 days; use it only on request.\n\n## Runs\n\n- `cancel_automation_run` cancels a queued, running, or waiting run.\n- A run can wait for the user. `resume_automation_run` lets it continue, and `extend_automation_run` extends its deadline.\n- `take_over_automation_run` hands the run's terminal to a person; the automation stops driving the agent. Use it only when the user intends to operate that agent.\n\nThe runtime must be running for schedules to fire. Occurrences missed while it was offline are skipped by default. An interrupted run recovers on its own in its preserved workspace, a bounded number of times. Do not launch a replacement into the same target while that is happening.\n\n## Catalog\n\n- `list_automation_templates` and `upsert_automation_template` manage prompt templates.\n- `list_automation_tags` lists tags, and `upsert_automation_tags` creates or renames a tag and sets an automation's tags.\n- `export_automations` exports automations, templates, and tags as a portable catalog. `import_automations` imports one.\n" + }, + { + "path": "references/definitions.md", + "digest": "3de3b409fc54629861bcda2deee073fb2b3cc2a676685306f12aa78631f271b5", + "text": "# Definitions And Targets\n\n`create_automation` takes a `definition` object. Leave out ids, revisions, actors, and timestamps:\n\n```json\n{\n \"name\": \"Daily Review\",\n \"promptTemplate\": \"Review {{project.name}} and summarize findings\",\n \"schedule\": {\"recurring\": {\"cron\": \"0 9 * * 1-5\", \"timezone\": \"America/Mexico_City\"}},\n \"target\": {\"freshTab\": {\"workspaceId\": \"workspace-id\", \"agentProfileId\": \"profile-id\"}},\n \"originWorkspaceId\": \"workspace-id\"\n}\n```\n\nA one-time schedule is `{\"oneTime\": {\"at\": \"2026-11-02T09:00:00Z\", \"timezone\": \"UTC\"}}`; `at` is an RFC 3339 date and time. A recurring schedule may add `startAt`, `endAt`, and `maxScheduledRuns`.\n\nThe target is one of five:\n\n| Target | Fields | Behavior |\n| --- | --- | --- |\n| `freshTab` | `workspaceId`, `agentProfileId` | A new agent tab in an existing workspace |\n| `existingTab` | `workspaceId`, `tabId`, optional `conversationId` | Sends to that tab's live conversation |\n| `managedWorkspace` | `sourceWorkspaceId`, `sourceBranch`, `agentProfileId` | A new worktree per run, child of the source workspace |\n| `projectWorktree` | `projectId`, `sourceBranch`, `agentProfileId` | A new worktree per run from the project, with no parent |\n| `projectCheckout` | `projectId`, `hostId`, `agentProfileId` | A workspace in the project's local or SSH folder |\n\nThe three targets that create a workspace also take an optional `nameTemplate`.\n\nFind ids with `list_workspaces`, `list_projects`, `list_agent_profiles`, `list_tabs`, and `list_project_branches`. `originWorkspaceId` decides where the definition appears in the app, independent of the target.\n\nOptional fields include `precheck`, `overlapPolicy`, `misfirePolicy`, `cleanupPolicy`, `retryMaxAttempts`, `retryBackoffSeconds`, and schedule bounds. Leave them out for the defaults. When a field is rejected, `check_automation_readiness` names it.\n" + } + ] + }, + { + "name": "alera-mcp-orchestration", + "description": "Delegate tasks to Alera agents and follow coordinator runs, gates, and workflow recipes through this MCP server. Use when work should be split among agents or tracked as orchestration tasks.", + "version": 1, + "digest": "6da151ebf2805c7ca744cd6b842a0f8841854a529fafb9da2d0fe4a4440d75b7", + "files": [ + { + "path": "SKILL.md", + "digest": "a5fbdea6838a627098147833f9f84a2605dacd540524194910da794e54dfcde0", + "text": "---\nname: alera-mcp-orchestration\ndescription: Delegate tasks to Alera agents and follow coordinator runs, gates, and workflow recipes through this MCP server. Use when work should be split among agents or tracked as orchestration tasks.\nmetadata:\n version: 1\n---\n\n# Alera Orchestration Through MCP\n\nOrchestration tracks work as tasks owned by a coordinator, which is a running agent terminal. Workers accept tasks, report progress, and complete them. The runtime owns the lifecycle. As an MCP client you are not a coordinator terminal, so the tools that stop, cancel, or interrupt work run as audited administrative actions. Pass a clear `reason` to them.\n\n## Pick The Simplest Tool\n\n- One agent working on one request: use `start_workspace_from_prompt`, `start_agent_workspace`, or `launch_agent` from the `alera-mcp` skill. No task is needed.\n- A coordinator agent is already running and should own the result: use `delegate_task`. It creates the task and starts a profile that accepts it, in the coordinator's workspace or, with `newWorkspace`, in a new child worktree. Find the coordinator's handle with `list_terminals`. Follow the task with `wait_for_task` or `wait_for_events`.\n- A planned, multi-stage run reviewed by a person: read [workflows](references/workflows.md).\n\nA failed delegation can still leave a created workspace and task. Keep the returned ids. Inspect them with `show_task` and `show_dispatch`, and retry the existing task only after confirming no worker is still running it. Do not repeat `delegate_task` blindly, and do not remove the workspace on your own.\n\n## Tasks And Dispatches\n\n- `list_tasks` and `show_task` read tasks. `wait_for_task` waits until a task reaches the given states.\n- `create_task` creates a task without starting an agent. A task of a coordinator run names the run and, with an execution policy, its stage.\n- `spawn_agent` starts an agent for a ready task and dispatches it once the agent is ready. `dispatch_task` dispatches a ready task to an existing terminal; `dryRun` only builds the preamble.\n- `show_dispatch` shows the dispatch state and preamble. `interrupt_dispatch` interrupts a worker's current turn without closing its terminal.\n- `cancel_task` cancels a task and its not-yet-started descendants.\n\n## Coordinator Runs\n\n- `start_coordinator` starts the background loop for a running coordinator agent. It records the objective and dispatches the run's ready tasks. `stop_coordinator` stops it, and `cancelActive` also cancels active tasks.\n- `list_runs`, `show_run`, and `orchestration_status` read runs. `get_orchestration_board` pages the run board grouped as attention, active, or history. `get_run_snapshot` reads a run with a page of its tasks, and `inspect_task` reads one task with its dispatches and history.\n- `propose_run_policy` proposes a stage plan. The run holds scheduling until a person approves it in the Alera app. `show_run_policy` shows it. Never treat a pending policy as approved.\n\n## Gates And Messages\n\n- `create_gate` asks a person to decide before a task continues. `list_gates` lists gates. Gates are resolved by a person in the Alera app; never decide one for the user.\n- `send_message` sends an orchestration message from one agent terminal to another terminal or a group such as `@all`. To ask an agent a question from outside, use `ask_agent` instead. `list_messages` lists recent messages.\n\n## Recovery\n\nThese need administrative access, a reason, and the user's request:\n- `recover_task` moves a stalled task to ready, failed, or cancelled.\n- `transfer_coordinator` hands a task or a whole run to another coordinator terminal.\n- `reset_orchestration` clears orchestration state.\n\nA stalled task keeps its slot and is never redispatched silently. Inspect it with `inspect_task` and `read_terminal` before recovering it.\n" + }, + { + "path": "references/workflows.md", + "digest": "5e6ab61446626141c7305ee16ff1859748cc30a9d88ab83e98a61b051d2a3bb1", + "text": "# Workflow Recipes And Runs\n\nA workflow recipe is a YAML plan of roles and stages. A workflow run goes through these steps:\n1. A proposal.\n2. A coordinator drafts the plan.\n3. A person approves the plan in the Alera app.\n4. Tasks run in isolated attempts.\n5. Results integrate into an integration workspace.\n\nApproval always belongs to a person.\n\n## Recipes\n\n- `list_recipes` lists built-in, personal, and a workspace's project recipes. `show_recipe` shows one with its digest, roles, and stages.\n- `validate_recipe` checks YAML without saving or running it.\n- `save_personal_recipe` creates or updates a personal recipe; updating needs its current revision.\n- `preview_recipe_export` previews writing a recipe into a project's `.alera/workflows`. `export_recipe` writes it with the digest from the preview, and fails if anything changed since.\n\n## Proposals And Plans\n\n- `create_workflow_proposal` proposes a run from a recipe at the source workspace's current commit. `start_workflow_coordinator` starts its coordinator, which drafts the plan without approving it.\n- `list_workflow_proposals` and `get_workflow_proposal` read proposals. `submit_workflow_proposal` submits the concrete task list as the coordinator would. `cancel_workflow_proposal` cancels a proposal and its coordinator.\n- `prepare_workflow_plan` prepares a plan for review from a document with a stable `requestId`. `show_workflow_plan` shows a plan revision.\n- `create_workflow_correction` opens a correction of an approved revision; a person approves the corrected plan.\n\n## Execution\n\n- `get_workflow_execution` shows whether an approved run is scheduling, paused, or cancelled, with the sequence number `control_workflow_execution` needs to start, pause, or cancel it.\n- `prepare_workflow_attempt` prepares the integration workspace or a task's isolated attempt. `launch_workflow_task` launches an approved task in its attempt. `integrate_workflow_result` squashes a completed task's result into the integration workspace.\n- `list_workflow_integrations` lists integration outcomes and conflicts. `list_workflow_workspaces` lists the workspaces a run keeps.\n\n## Cleanup\n\nCleanup is preview first, then apply with the preview's id and digest.\n- `list_workflow_cleanups` lists retained resources.\n- `preview_workflow_cleanup` previews removing up to 25 workspaces, and `get_workflow_cleanup` shows an operation.\n- `apply_workflow_cleanup` removes them. `retry_workflow_cleanup` retries an unfinished cleanup, and `abandon_workflow_cleanup` keeps what was not removed. These three need administrative access.\n" + } + ] + } + ] +} diff --git a/edge/src/mcp/skills.ts b/edge/src/mcp/skills.ts new file mode 100644 index 000000000..22040689f --- /dev/null +++ b/edge/src/mcp/skills.ts @@ -0,0 +1,123 @@ +import catalog from './skill_catalog.json'; +import { isJsonObject, toolError, toolJson, type ToolResult } from './protocol'; + +/** + * Skills written for MCP clients, sisters of the CLI skills agents use in + * Alera terminals. They live only in this service, so they are answered here + * without a runtime and match the tool catalog this edge serves. + */ +export const LIST_SKILLS_TOOL = 'list_skills'; +export const READ_SKILL_TOOL = 'read_skill'; + +/** Appended to every other tool's description so a model reads the skills first. */ +export const SKILLS_REMINDER = + 'Before using Alera tools for a task, read the matching Alera skill with list_skills and read_skill, unless you already did in this conversation.'; + +interface SkillFile { + path: string; + digest: string; + text: string; +} + +interface Skill { + name: string; + description: string; + version: number; + digest: string; + files: SkillFile[]; +} + +export const SKILLS: readonly Skill[] = catalog.skills; + +const READ_ONLY = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +}; + +export const SKILL_TOOL_DEFINITIONS = [ + { + name: LIST_SKILLS_TOOL, + title: 'List Skills', + description: + 'List the Alera skills: guides that explain how to use the Alera tools for each kind of task, with their names, descriptions, versions, and files. Call it before the first Alera task of a conversation, then read the matching skill with read_skill. Needs no runtime.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { title: 'List Skills', ...READ_ONLY }, + }, + { + name: READ_SKILL_TOOL, + title: 'Read Skill', + description: + "Read an Alera skill's SKILL.md, or one of the reference files it links to with file (such as references/workspaces.md). Follow it before calling other Alera tools for that task. Needs no runtime.", + inputSchema: { + type: 'object', + properties: { + name: { + type: 'string', + enum: SKILLS.map((skill) => skill.name), + description: 'Skill name from list_skills.', + }, + file: { + type: 'string', + description: 'File of the skill to read, as list_skills lists it (default SKILL.md).', + }, + }, + required: ['name'], + additionalProperties: false, + }, + annotations: { title: 'Read Skill', ...READ_ONLY }, + }, +]; + +export function isSkillTool(name: string): boolean { + return name === LIST_SKILLS_TOOL || name === READ_SKILL_TOOL; +} + +export function withSkillsReminder(description: string): string { + return `${description} ${SKILLS_REMINDER}`; +} + +function listSkills(): ToolResult { + return toolJson({ + skills: SKILLS.map((skill) => ({ + name: skill.name, + description: skill.description, + version: skill.version, + files: skill.files.map((file) => file.path), + })), + }); +} + +function readSkill(args: Record): ToolResult { + const unknown = Object.keys(args).filter((key) => key !== 'name' && key !== 'file'); + if (unknown.length > 0) return toolError(`invalid_argument: Unknown argument ${unknown[0]}.`); + const skill = SKILLS.find((candidate) => candidate.name === args.name); + if (!skill) { + const names = SKILLS.map((candidate) => candidate.name).join(', '); + return toolError(`not_found: No skill named ${String(args.name)}. Skills: ${names}.`); + } + if (args.file !== undefined && typeof args.file !== 'string') { + return toolError('invalid_argument: file must be a string.'); + } + const path = (args.file ?? 'SKILL.md').replace(/^\.\//, ''); + const file = skill.files.find((candidate) => candidate.path === path); + if (!file) { + const files = skill.files.map((candidate) => candidate.path).join(', '); + return toolError(`not_found: ${skill.name} has no file ${path}. Files: ${files}.`); + } + return toolJson({ + name: skill.name, + version: skill.version, + file: file.path, + digest: `sha256:${file.digest}`, + text: file.text, + files: skill.files.map((candidate) => candidate.path), + }); +} + +/** Answers a skill tool call here, without a runtime. */ +export function callSkillTool(name: string, args: unknown): ToolResult { + const values = isJsonObject(args) ? args : {}; + return name === LIST_SKILLS_TOOL ? listSkills() : readSkill(values); +} diff --git a/edge/src/mcp/tool_call.ts b/edge/src/mcp/tool_call.ts index c1f5aac87..503769ced 100644 --- a/edge/src/mcp/tool_call.ts +++ b/edge/src/mcp/tool_call.ts @@ -1,6 +1,7 @@ import { originRequest, type EdgeEnvironment, type OriginFetch } from '../index'; import type { McpAccess } from './access_token'; import { isJsonObject, toolError, toolJson, validToolResult, type ToolResult } from './protocol'; +import { callSkillTool, isSkillTool } from './skills'; import { LIST_RUNTIMES_TOOL, RUNTIME_TOOLS, type CatalogTool } from './tools'; export interface GatewayContext { @@ -19,7 +20,7 @@ export type ToolCallOutcome = type CallOutcomeName = 'ok' | 'tool_error' | 'runtime_offline' | 'timeout' | 'failed'; -interface GatewayResponse { +export interface GatewayResponse { status: number; body: Record | null; } @@ -33,9 +34,9 @@ interface CallGrant { const RELAY_CALL_URL = 'https://relay.internal/mcp/call'; -async function gateway( +export async function gateway( context: GatewayContext, - method: 'GET' | 'POST', + method: 'GET' | 'POST' | 'DELETE', path: string, body?: unknown, ): Promise { @@ -185,6 +186,8 @@ export async function callTool( return { kind: 'invalid', message: 'Tool arguments must be an object.' }; } if (params.name === LIST_RUNTIMES_TOOL) return listRuntimes(context); + // Skills live in this service, so they answer without a runtime. + if (isSkillTool(params.name)) return { kind: 'result', result: callSkillTool(params.name, params.arguments) }; const tool = RUNTIME_TOOLS.get(params.name); if (!tool) return { kind: 'invalid', message: `Unknown tool: ${params.name}` }; if (tool.access === 'execute' && !access.scopes.has('mcp:execute')) { @@ -195,6 +198,14 @@ export async function callTool( ), }; } + if (tool.access === 'admin' && !access.scopes.has('mcp:admin')) { + return { + kind: 'result', + result: toolError( + `insufficient_scope: ${tool.name} needs the mcp:admin scope, but this connection was not granted administrative tools. Reconnect Alera and allow administrative tools.`, + ), + }; + } const { runtime, ...args } = (params.arguments ?? {}) as Record; if (runtime !== undefined && (typeof runtime !== 'string' || !runtime.trim())) { return { kind: 'result', result: toolError('The runtime argument must be a runtime name or id.') }; diff --git a/edge/src/mcp/tool_catalog.json b/edge/src/mcp/tool_catalog.json index 3431ae70f..f004fc599 100644 --- a/edge/src/mcp/tool_catalog.json +++ b/edge/src/mcp/tool_catalog.json @@ -4,6 +4,7 @@ "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, "title": "Runtime Status" @@ -22,670 +23,1097 @@ "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Projects" + "title": "Get Version" }, - "description": "List the projects registered in this Alera runtime with their ids, names, and the hosts each one is on.", + "description": "Show the Alera CLI and runtime host versions and the protocol and skill contract versions.", "inputSchema": { "additionalProperties": false, "properties": {}, "type": "object" }, - "name": "list_projects", + "name": "get_version", "timeoutSeconds": 30, - "title": "List Projects" + "title": "Get Version" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Workspaces" + "title": "Get Runtime Settings" + }, + "description": "Show the runtime settings open to MCP clients: default agent profile, worktree folder, removal confirmations, AI Assist, and automation retention.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "get_runtime_settings", + "timeoutSeconds": 30, + "title": "Get Runtime Settings" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update Runtime Settings" }, - "description": "List workspaces (tasks with their branch and worktree) of one project, or of every project when projectId is omitted. Filter by host with hostId.", + "description": "Change runtime settings. Fields left out keep their values; list a setting in clear to remove it.", "inputSchema": { "additionalProperties": false, "properties": { - "hostId": { - "description": "SSH target id, or `local`.", + "aiAssistAgent": { + "description": "Agent AI Assist runs.", + "enum": [ + "chatgpt", + "codex", + "claude", + "copilot", + "cursor", + "agy", + "opencode", + "opencode2", + "opencode-go", + "pi", + "amp", + "grok", + "fx" + ], + "type": "string" + }, + "aiAssistAutoGenerateAgentTitles": { + "description": "Name agent tabs from their conversations.", + "type": "boolean" + }, + "aiAssistEnabled": { + "description": "Turn AI Assist on or off.", + "type": "boolean" + }, + "aiAssistTimeoutSeconds": { + "description": "AI Assist time limit in seconds.", + "maximum": 600, + "minimum": 10, + "type": "integer" + }, + "automationAuditRetentionDays": { + "description": "Days to keep, from 1 to 3650.", + "maximum": 3650, + "minimum": 1, + "type": "integer" + }, + "automationRunRetentionDays": { + "description": "Days to keep, from 1 to 3650.", + "maximum": 3650, + "minimum": 1, + "type": "integer" + }, + "automationStartAtLogin": { + "description": "Start the automation host when the user signs in.", + "type": "boolean" + }, + "automationTrashRetentionDays": { + "description": "Days to keep, from 1 to 3650.", + "maximum": 3650, + "minimum": 1, + "type": "integer" + }, + "clear": { + "description": "Settings to clear.", + "items": { + "enum": [ + "workspaceDirectory", + "defaultAgentProfileId" + ], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "confirmProjectRemoval": { + "description": "Ask before removing a project in the app.", + "type": "boolean" + }, + "confirmWorkspaceRemoval": { + "description": "Ask before removing a workspace in the app.", + "type": "boolean" + }, + "defaultAgentProfileId": { + "description": "Profile id used when a prompt names none.", "minLength": 1, "type": "string" }, - "projectId": { - "description": "Project id from list_projects.", + "workspaceDirectory": { + "description": "Folder where new worktrees are created.", "minLength": 1, "type": "string" } }, "type": "object" }, - "name": "list_workspaces", + "name": "update_runtime_settings", "timeoutSeconds": 30, - "title": "List Workspaces" + "title": "Update Runtime Settings" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Tabs" + "title": "Get Agent Integrations" }, - "description": "List the tabs of a workspace, including terminal and agent tabs.", + "description": "Show which agents report their status to Alera through hooks.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "get_agent_integrations", + "timeoutSeconds": 30, + "title": "Get Agent Integrations" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Set Agent Integrations" + }, + "description": "Turn status hooks on or off for the listed agents.", "inputSchema": { "additionalProperties": false, "properties": { - "workspaceId": { - "description": "Workspace id.", - "minLength": 1, - "type": "string" + "agents": { + "description": "Agents to change.", + "items": { + "enum": [ + "codex", + "claude", + "copilot", + "cursor", + "agy", + "opencode", + "opencode2", + "pi", + "amp", + "grok", + "fx" + ], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "enabled": { + "description": "Turn the hooks on (true) or off (false).", + "type": "boolean" } }, "required": [ - "workspaceId" + "enabled", + "agents" ], "type": "object" }, - "name": "list_tabs", + "name": "set_agent_integrations", "timeoutSeconds": 30, - "title": "List Tabs" + "title": "Set Agent Integrations" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Agent Profiles" + "title": "Get Resource Snapshot" }, - "description": "List the agent profiles (Claude, Codex, and others) that can be launched or delegated to, with their ids and names.", + "description": "Show CPU and memory use of the host, the runtime, and each terminal session.", "inputSchema": { "additionalProperties": false, "properties": {}, "type": "object" }, - "name": "list_agent_profiles", + "name": "get_resource_snapshot", "timeoutSeconds": 30, - "title": "List Agent Profiles" + "title": "Get Resource Snapshot" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Terminals" + "title": "Get Agent Quotas" }, - "description": "List live terminal sessions with their handles, optionally for one workspace.", + "description": "Show usage limits for the enabled agent providers. Set refresh to fetch new numbers instead of the cached ones.", "inputSchema": { "additionalProperties": false, "properties": { - "workspaceId": { - "description": "Workspace id.", + "refresh": { + "description": "Fetch fresh numbers.", + "type": "boolean" + } + }, + "type": "object" + }, + "name": "get_agent_quotas", + "timeoutSeconds": 58, + "title": "Get Agent Quotas" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Refresh Claude Quota" + }, + "description": "Read one Claude account's usage again through the Claude terminal UI.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "accountId": { + "description": "Claude account from get_agent_quotas. Defaults to default.", "minLength": 1, "type": "string" } }, "type": "object" }, - "name": "list_terminals", - "timeoutSeconds": 30, - "title": "List Terminals" + "name": "refresh_claude_quota", + "timeoutSeconds": 58, + "title": "Refresh Claude Quota" }, { - "access": "read", + "access": "admin", "annotations": { - "destructiveHint": false, + "destructiveHint": true, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true, - "title": "Show Terminal" + "readOnlyHint": false, + "title": "Consume Codex Reset Credit" }, - "description": "Show one terminal session by handle, including its agent and lifecycle state.", + "description": "Spend the Codex rate-limit reset credit offered in get_agent_quotas. A changed offer is refused.", "inputSchema": { "additionalProperties": false, "properties": { - "handle": { - "description": "Terminal handle from list_terminals.", + "offerRevision": { + "description": "Offer revision from the Codex quota snapshot.", "minLength": 1, "type": "string" } }, "required": [ - "handle" + "offerRevision" ], "type": "object" }, - "name": "show_terminal", - "timeoutSeconds": 30, - "title": "Show Terminal" + "name": "consume_codex_reset_credit", + "timeoutSeconds": 58, + "title": "Consume Codex Reset Credit" }, { - "access": "read", + "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true, - "title": "Read Terminal" + "readOnlyHint": false, + "title": "Speak To The User" }, - "description": "Read retained terminal output. Pass the returned cursor on the next call to read only new output.", + "description": "Say a short message to the user through the Alera voice home agent.", "inputSchema": { "additionalProperties": false, "properties": { - "cursor": { - "description": "Cursor returned by a previous read.", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" - }, - "handle": { - "description": "Terminal handle from list_terminals.", + "text": { + "description": "What to say.", + "maxLength": 2000, "minLength": 1, "type": "string" - }, - "maxBytes": { - "description": "Maximum bytes to return (default 32768).", - "maximum": 262144, - "minimum": 1, - "type": "integer" } }, "required": [ - "handle" + "text" ], "type": "object" }, - "name": "read_terminal", + "name": "voice_speak", "timeoutSeconds": 30, - "title": "Read Terminal" + "title": "Speak To The User" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Tasks" + "title": "Voice Status" }, - "description": "List orchestration tasks, optionally filtered by status, coordinator run, or workspace.", + "description": "Show the voice home workspace, its agent session, and the speech pipeline.", "inputSchema": { "additionalProperties": false, - "properties": { - "runId": { - "description": "Coordinator run id.", - "minLength": 1, - "type": "string" - }, - "status": { - "description": "Task status.", - "enum": [ - "pending", - "ready", - "dispatched", - "completed", - "failed", - "blocked", - "stalled", - "cancelled" - ], - "type": "string" - }, - "workspaceId": { - "description": "Workspace id.", - "minLength": 1, - "type": "string" - } - }, + "properties": {}, "type": "object" }, - "name": "list_tasks", + "name": "voice_status", "timeoutSeconds": 30, - "title": "List Tasks" + "title": "Voice Status" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "Show Task" + "title": "List Projects" }, - "description": "Show one orchestration task with its active dispatch.", + "description": "List the projects registered in this Alera runtime with their ids, names, and the hosts each one is on.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_projects", + "timeoutSeconds": 30, + "title": "List Projects" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Register Project" + }, + "description": "Add an existing folder on this machine as an Alera project. The folder is not changed.", "inputSchema": { "additionalProperties": false, "properties": { - "taskId": { - "description": "Task id.", + "kind": { + "description": "gitRepository (default) or folder for a plain folder without Git.", + "enum": [ + "gitRepository", + "folder" + ], + "type": "string" + }, + "name": { + "description": "Display name. Defaults to the folder name.", + "minLength": 1, + "type": "string" + }, + "path": { + "description": "Absolute path of the project folder.", "minLength": 1, "type": "string" } }, "required": [ - "taskId" + "path" ], "type": "object" }, - "name": "show_task", + "name": "register_project", "timeoutSeconds": 30, - "title": "Show Task" + "title": "Register Project" }, { - "access": "read", + "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true, - "title": "List Coordinator Runs" + "readOnlyHint": false, + "title": "Clone Project" }, - "description": "List durable orchestration coordinator runs, optionally for one workspace.", + "description": "Clone a Git repository into a new folder on this machine and register it as a project. Returns a clone job at once; follow it with get_project_clone.", "inputSchema": { "additionalProperties": false, "properties": { - "workspaceId": { - "description": "Workspace id.", + "directoryName": { + "description": "New folder name. Defaults to the repository name.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Project display name.", + "minLength": 1, + "type": "string" + }, + "parentPath": { + "description": "Existing folder the clone goes into.", + "minLength": 1, + "type": "string" + }, + "url": { + "description": "Repository URL.", + "maxLength": 2048, "minLength": 1, "type": "string" } }, + "required": [ + "url", + "parentPath" + ], "type": "object" }, - "name": "list_runs", + "name": "clone_project", "timeoutSeconds": 30, - "title": "List Coordinator Runs" + "title": "Clone Project" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "Orchestration Status" + "title": "Get Project Clone" }, - "description": "Aggregate the run, task, worker, and escalation state of one coordinator run.", + "description": "Show a clone job: its status, progress, message, and the project it registered when done.", "inputSchema": { "additionalProperties": false, "properties": { - "runId": { - "description": "Coordinator run id from list_runs.", + "cloneId": { + "description": "Clone job id from clone_project.", "minLength": 1, "type": "string" } }, "required": [ - "runId" + "cloneId" ], "type": "object" }, - "name": "orchestration_status", + "name": "get_project_clone", "timeoutSeconds": 30, - "title": "Orchestration Status" + "title": "Get Project Clone" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Orchestration Messages" + "title": "List Project Clones" }, - "description": "List recent orchestration messages across all recipients, or the inbox or outbox of one terminal.", + "description": "List the clone jobs of this runtime, running and finished.", "inputSchema": { "additionalProperties": false, - "properties": { - "direction": { - "description": "Direction relative to terminal.", - "enum": [ - "inbox", - "outbox" - ], - "type": "string" - }, - "limit": { - "description": "Maximum messages (default 50).", - "maximum": 200, - "minimum": 1, - "type": "integer" - }, - "terminal": { - "description": "Terminal handle.", - "minLength": 1, - "type": "string" - } - }, + "properties": {}, "type": "object" }, - "name": "list_messages", + "name": "list_project_clones", "timeoutSeconds": 30, - "title": "List Orchestration Messages" + "title": "List Project Clones" }, { - "access": "read", + "access": "execute", "annotations": { - "destructiveHint": false, + "destructiveHint": true, + "idempotentHint": true, "openWorldHint": false, - "readOnlyHint": true, - "title": "List Askable Agents" + "readOnlyHint": false, + "title": "Cancel Project Clone" }, - "description": "List running agents that ask_agent can reach, optionally for one workspace.", + "description": "Stop a running clone and delete its partial folder.", "inputSchema": { "additionalProperties": false, "properties": { - "workspaceId": { - "description": "Workspace id.", + "cloneId": { + "description": "Clone job id from clone_project.", "minLength": 1, "type": "string" } }, + "required": [ + "cloneId" + ], "type": "object" }, - "name": "list_inbox_targets", + "name": "cancel_project_clone", "timeoutSeconds": 30, - "title": "List Askable Agents" + "title": "Cancel Project Clone" }, { - "access": "read", + "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true, - "title": "List Question Threads" + "readOnlyHint": false, + "title": "Register Remote Project" }, - "description": "List question threads started through ask_agent, newest first.", + "description": "Add a project that lives only on an SSH host, from an existing folder there (path) or by cloning a repository into the host's projects folder (cloneUrl).", "inputSchema": { "additionalProperties": false, "properties": { - "before": { - "description": "Continue from the nextBefore value of a previous listing.", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "cloneUrl": { + "description": "Repository URL to clone on the host.", + "maxLength": 2048, + "minLength": 1, + "type": "string" }, - "limit": { - "description": "Maximum threads (default 50).", - "maximum": 200, - "minimum": 1, - "type": "integer" + "hostId": { + "description": "SSH target id from list_ssh_targets.", + "minLength": 1, + "type": "string" }, - "status": { - "description": "Thread status.", + "kind": { + "description": "gitRepository (default) or folder for a plain folder without Git.", "enum": [ - "pending", - "received", - "delivered", - "expired", - "answered", - "cancelled" + "gitRepository", + "folder" ], "type": "string" }, - "workspaceId": { - "description": "Workspace id.", + "name": { + "description": "Display name.", + "minLength": 1, + "type": "string" + }, + "path": { + "description": "Existing folder on the host.", "minLength": 1, "type": "string" } }, + "required": [ + "hostId" + ], "type": "object" }, - "name": "list_inbox_threads", - "timeoutSeconds": 30, - "title": "List Question Threads" + "name": "register_remote_project", + "timeoutSeconds": 58, + "title": "Register Remote Project" }, { - "access": "read", + "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true, - "title": "Show Question Thread" + "readOnlyHint": false, + "title": "Register Project Checkout" }, - "description": "Show a question thread with every question and reply.", + "description": "Register an existing folder on an SSH host as a project's checkout there, or clone into that new folder first with cloneUrl.", "inputSchema": { "additionalProperties": false, "properties": { - "questionId": { - "description": "Any question id in the thread.", + "cloneUrl": { + "description": "Repository URL to clone into path first.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "hostId": { + "description": "SSH target id from list_ssh_targets.", + "minLength": 1, + "type": "string" + }, + "path": { + "description": "Folder on the host.", + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project id from list_projects.", "minLength": 1, "type": "string" } }, "required": [ - "questionId" + "projectId", + "hostId", + "path" ], "type": "object" }, - "name": "show_inbox_thread", - "timeoutSeconds": 30, - "title": "Show Question Thread" + "name": "register_project_checkout", + "timeoutSeconds": 58, + "title": "Register Project Checkout" }, { - "access": "read", + "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, - "readOnlyHint": true, - "title": "List Automations" + "readOnlyHint": false, + "title": "Rename Project" }, - "description": "List runtime automations, optionally filtered by state, project, or search text.", + "description": "Change a project's display name. Its folder is not touched.", "inputSchema": { "additionalProperties": false, "properties": { - "projectId": { - "description": "Project id.", - "minLength": 1, - "type": "string" - }, - "search": { - "description": "Text to search for.", + "name": { + "description": "New name.", + "maxLength": 200, "minLength": 1, "type": "string" }, - "state": { - "description": "Automation state.", + "projectId": { + "description": "Project id from list_projects.", "minLength": 1, "type": "string" } }, + "required": [ + "projectId", + "name" + ], "type": "object" }, - "name": "list_automations", + "name": "rename_project", "timeoutSeconds": 30, - "title": "List Automations" + "title": "Rename Project" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "List Automation Runs" + "title": "Preview Project Removal" }, - "description": "List automation runs, newest first, optionally for one automation.", + "description": "Show what remove_project would affect: workspaces, tabs, live sessions, the automations it would pause, and whether settings override the repository's. Changes nothing.", "inputSchema": { "additionalProperties": false, "properties": { - "automationId": { - "description": "Automation id.", + "projectId": { + "description": "Project id from list_projects.", "minLength": 1, "type": "string" - }, - "limit": { - "description": "Maximum runs (default 20).", - "maximum": 100, - "minimum": 1, - "type": "integer" } }, + "required": [ + "projectId" + ], "type": "object" }, - "name": "list_automation_runs", + "name": "preview_project_removal", "timeoutSeconds": 30, - "title": "List Automation Runs" + "title": "Preview Project Removal" }, { - "access": "read", + "access": "execute", "annotations": { - "destructiveHint": false, + "destructiveHint": true, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true, - "title": "Wait For Task" + "readOnlyHint": false, + "title": "Remove Project" }, - "description": "Wait up to timeoutSeconds for an orchestration task to reach one of the given states, then return it. Returns the current state when the wait ends first.", + "description": "Remove a project from Alera as the app does: dependent automations are paused and their runs cancelled, then the project and its workspace records go. No file is deleted: the project folder and its worktrees stay on disk.", "inputSchema": { "additionalProperties": false, "properties": { - "states": { - "description": "States to wait for (default completed, failed, stalled, cancelled).", - "items": { - "enum": [ - "pending", - "ready", - "dispatched", - "completed", - "failed", - "blocked", - "stalled", - "cancelled" - ], - "type": "string" - }, - "minItems": 1, - "type": "array", - "uniqueItems": true - }, - "taskId": { - "description": "Task id.", + "projectId": { + "description": "Project id from list_projects.", "minLength": 1, "type": "string" - }, - "timeoutSeconds": { - "description": "Seconds to wait before returning the current state. Call again to keep waiting.", - "maximum": 50, - "minimum": 1, - "type": "integer" } }, "required": [ - "taskId" + "projectId" ], "type": "object" }, - "name": "wait_for_task", + "name": "remove_project", "timeoutSeconds": 58, - "title": "Wait For Task" + "title": "Remove Project" }, { "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": true, - "title": "Wait For Terminal" + "title": "List Project Hosts" }, - "description": "Wait up to timeoutSeconds for a terminal to reach a lifecycle state, such as an agent becoming ready.", + "description": "List the hosts a project is on, with its folder on each.", "inputSchema": { "additionalProperties": false, "properties": { - "handle": { - "description": "Terminal handle.", + "projectId": { + "description": "Project id from list_projects.", "minLength": 1, "type": "string" - }, - "state": { - "description": "Lifecycle state.", - "enum": [ - "process-started", - "agent-detected", - "agent-ready", - "dispatch-accepted" - ], - "type": "string" - }, - "timeoutSeconds": { - "description": "Seconds to wait before returning the current state. Call again to keep waiting.", - "maximum": 50, - "minimum": 1, - "type": "integer" } }, "required": [ - "handle", - "state" + "projectId" ], "type": "object" }, - "name": "wait_for_terminal", - "timeoutSeconds": 58, - "title": "Wait For Terminal" + "name": "list_project_hosts", + "timeoutSeconds": 30, + "title": "List Project Hosts" }, { - "access": "read", + "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true, - "title": "Wait For Reply" + "readOnlyHint": false, + "title": "Add Project Host" }, - "description": "Wait up to timeoutSeconds for news about a question asked with ask_agent. Pass the returned cursor as after to skip what was already seen.", + "description": "Add a project to an SSH host: register an existing folder there (path), or clone the project's Git remote (or cloneUrl) into the host's projects folder.", "inputSchema": { "additionalProperties": false, "properties": { - "after": { - "description": "Cursor from a previous result.", - "maximum": 9007199254740991, - "minimum": 0, - "type": "integer" + "cloneUrl": { + "description": "Repository URL to clone instead of the project's remote.", + "maxLength": 2048, + "minLength": 1, + "type": "string" }, - "questionId": { - "description": "Question id returned by ask_agent.", + "hostId": { + "description": "SSH target id from list_ssh_targets.", "minLength": 1, "type": "string" }, - "timeoutSeconds": { - "description": "Seconds to wait before returning the current state. Call again to keep waiting.", - "maximum": 50, - "minimum": 1, - "type": "integer" + "path": { + "description": "Existing folder on the host.", + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" } }, "required": [ - "questionId" + "projectId", + "hostId" ], "type": "object" }, - "name": "wait_for_reply", + "name": "add_project_host", "timeoutSeconds": 58, - "title": "Wait For Reply" + "title": "Add Project Host" }, { "access": "execute", "annotations": { - "destructiveHint": false, + "destructiveHint": true, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": false, - "title": "Create Workspace" + "title": "Remove Project Host" }, - "description": "Create a workspace (task) in a project, optionally on its own Git worktree and branch. A worktree also needs sourceBranch, such as main. No agent is started.", + "description": "Forget a project's folder on a host. No file is deleted.", "inputSchema": { "additionalProperties": false, "properties": { - "branch": { - "description": "Branch to create or use.", - "minLength": 1, - "type": "string" - }, "hostId": { - "description": "SSH target that owns the worktree. Omit for this machine.", - "minLength": 1, - "type": "string" - }, - "issueUrl": { - "description": "Issue URL to link to the workspace.", + "description": "SSH target id from list_ssh_targets.", "minLength": 1, "type": "string" }, - "name": { - "description": "Workspace display name.", + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "projectId", + "hostId" + ], + "type": "object" + }, + "name": "remove_project_host", + "timeoutSeconds": 30, + "title": "Remove Project Host" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Project Branches" + }, + "description": "List the branches of a project's folder on a host, which are the choices for a new worktree's sourceBranch, and the project's preferred source branch when one is set.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "hostId": { + "description": "SSH target id, or `local` (default).", + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "projectId" + ], + "type": "object" + }, + "name": "list_project_branches", + "timeoutSeconds": 58, + "title": "List Project Branches" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Project Settings" + }, + "description": "Show a project's effective settings (New Workspace prompt and source branch, worktree copy rules and setup commands, pull request provider) and whether they come from the app or from the repository's alera.toml.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "projectId" + ], + "type": "object" + }, + "name": "get_project_config", + "timeoutSeconds": 30, + "title": "Get Project Settings" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update Project Settings" + }, + "description": "Save project settings as the app's settings dialog does, overriding the repository's alera.toml. config is a JSON object with any of worktree {copy: [{from, to, overwrite}], setup: [commands]}, newWorkspace {promptAppend, sourceBranch}, and gitHostingProvider (auto to detect it). Each part given replaces that part; the others stay.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "config": { + "description": "Settings as a JSON object.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "projectId", + "config" + ], + "type": "object" + }, + "name": "update_project_config", + "timeoutSeconds": 30, + "title": "Update Project Settings" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Reset Project Settings" + }, + "description": "Remove the settings saved in the app so the repository's alera.toml, or the defaults, apply again.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "projectId" + ], + "type": "object" + }, + "name": "reset_project_config", + "timeoutSeconds": 30, + "title": "Reset Project Settings" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List SSH Targets" + }, + "description": "List the SSH hosts this runtime knows, with their platform, installed runtime, and bootstrap state. Credentials are never included.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_ssh_targets", + "timeoutSeconds": 30, + "title": "List SSH Targets" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "SSH Target Status" + }, + "description": "Check whether SSH hosts are reachable and which runtime they have installed: one host by targetId, or all of them.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "targetId": { + "description": "SSH target id from list_ssh_targets.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "ssh_target_status", + "timeoutSeconds": 58, + "title": "SSH Target Status" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Workspaces" + }, + "description": "List workspaces (tasks with their branch and worktree) of one project, or of every project when projectId is omitted. Filter by host, section, tag, archive state, or parent.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "archived": { + "description": "all (default), only archived, or only visible workspaces.", + "enum": [ + "all", + "archived", + "visible" + ], + "type": "string" + }, + "hostId": { + "description": "SSH target id, or `local`.", + "minLength": 1, + "type": "string" + }, + "parentWorkspaceId": { + "description": "Only the children of this workspace.", + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" + }, + "sectionId": { + "description": "Section id from list_sections, or `none` for workspaces in Others.", + "minLength": 1, + "type": "string" + }, + "tagId": { + "description": "Tag id from list_tags.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_workspaces", + "timeoutSeconds": 30, + "title": "List Workspaces" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Workspace" + }, + "description": "Create a workspace (task) in a project, on the project folder or on its own Git worktree and branch, as the app's manual New Workspace form does. A worktree on a new branch also needs sourceBranch, such as main. No agent is started.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "branch": { + "description": "Branch to create or use.", + "minLength": 1, + "type": "string" + }, + "hostId": { + "description": "SSH target that owns the worktree. Omit for this machine.", + "minLength": 1, + "type": "string" + }, + "issueUrl": { + "description": "Issue URL to link to the workspace.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Workspace display name.", + "minLength": 1, + "type": "string" + }, + "parentWorkspaceId": { + "description": "Workspace to nest the new one under.", + "minLength": 1, + "type": "string" + }, + "path": { + "description": "Exact folder for the worktree.", "minLength": 1, "type": "string" }, @@ -694,16 +1122,30 @@ "minLength": 1, "type": "string" }, + "reuseExistingBranch": { + "description": "Check out an existing branch in the worktree instead of creating it.", + "type": "boolean" + }, "section": { "description": "Sidebar section name.", "minLength": 1, "type": "string" }, + "sectionId": { + "description": "Section id from list_sections. Use instead of section.", + "minLength": 1, + "type": "string" + }, "sourceBranch": { "description": "Branch the new branch starts from.", "minLength": 1, "type": "string" }, + "workspaceRoot": { + "description": "Folder under which Alera names the worktree. Defaults to the runtime's workspace folder.", + "minLength": 1, + "type": "string" + }, "worktree": { "description": "Create an exclusive Git worktree instead of sharing the project folder.", "type": "boolean" @@ -722,6 +1164,7 @@ "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, "readOnlyHint": false, "title": "Start Agent In New Workspace" @@ -799,26 +1242,16 @@ { "access": "execute", "annotations": { - "destructiveHint": false, + "destructiveHint": true, + "idempotentHint": false, "openWorldHint": false, "readOnlyHint": false, - "title": "Launch Agent" + "title": "Sleep Workspace" }, - "description": "Launch an agent profile in a new tab of an existing workspace with a prompt.", + "description": "Stop a workspace's terminal sessions while keeping its tabs, branch, and files. Opening it again wakes it.", "inputSchema": { "additionalProperties": false, "properties": { - "profile": { - "description": "Agent profile id or unique name from list_agent_profiles.", - "minLength": 1, - "type": "string" - }, - "prompt": { - "description": "Prompt delivered to the agent.", - "maxLength": 65536, - "minLength": 1, - "type": "string" - }, "workspaceId": { "description": "Workspace id.", "minLength": 1, @@ -826,363 +1259,7266 @@ } }, "required": [ - "workspaceId", - "profile", - "prompt" + "workspaceId" ], "type": "object" }, - "name": "launch_agent", + "name": "sleep_workspace", "timeoutSeconds": 58, - "title": "Launch Agent" + "title": "Sleep Workspace" }, { "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, "readOnlyHint": false, - "title": "Delegate Task" + "title": "Start Workspace From Prompt" }, - "description": "Create an orchestration task and start an agent profile that accepts it, in an existing workspace or a new child worktree. The coordinator is a running agent terminal (from list_terminals) that receives the worker's messages. Returns the task; follow it with wait_for_task. Without a coordinator terminal, use start_agent_workspace or ask_agent instead.", + "description": "Create a workspace from a task prompt and launch an agent in it, exactly like the Alera app's New Workspace from Prompt form. Without projectId, AI Assist recognizes the project from the prompt; when it is unclear the operation ends with status needsInput and a list of candidates, so call again with projectId. AI Assist also names the workspace and its branch and picks the best sidebar section, or none (Others) when nothing fits. Mode auto uses a new worktree for Git projects. Returns the operation after up to 40 seconds; if it is still running, follow it with wait_for_workspace_start.", "inputSchema": { "additionalProperties": false, "properties": { - "branch": { - "description": "Branch for the new workspace.", + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "hostId": { + "description": "SSH target that owns the worktree. Omit for this machine.", "minLength": 1, "type": "string" }, - "coordinator": { - "description": "Terminal handle of the coordinating agent, from list_terminals.", + "issueUrl": { + "description": "Issue URL to link to the workspace.", "minLength": 1, "type": "string" }, - "name": { - "description": "Name for the new workspace.", - "minLength": 1, + "mode": { + "description": "Where the workspace lives.", + "enum": [ + "auto", + "worktree", + "projectCheckout" + ], "type": "string" }, - "newWorkspace": { - "description": "Create a child worktree and delegate into it.", - "type": "boolean" + "parentWorkspaceId": { + "description": "Workspace to set as the parent of the new one.", + "minLength": 1, + "type": "string" }, "profile": { - "description": "Agent profile id or unique name from list_agent_profiles.", + "description": "Agent profile id or unique name. Defaults to the runtime's default profile.", "minLength": 1, "type": "string" }, "projectId": { - "description": "Project for the new workspace.", + "description": "Project id from list_projects. Omit to recognize it from the prompt.", "minLength": 1, "type": "string" }, - "sourceBranch": { - "description": "Branch the new workspace starts from.", + "prompt": { + "description": "Task for the agent; it also names the workspace.", + "maxLength": 65536, "minLength": 1, "type": "string" }, - "spec": { - "description": "Task brief the worker receives.", - "maxLength": 65536, + "section": { + "description": "auto (default) lets AI Assist pick a section or none; none skips sections; any other value is a section name.", "minLength": 1, "type": "string" }, - "timeoutSeconds": { - "description": "Seconds to wait for the agent to accept.", - "maximum": 50, - "minimum": 1, - "type": "integer" - }, - "title": { - "description": "Short title for listings.", + "sectionId": { + "description": "Section to join, by id.", "minLength": 1, "type": "string" }, - "workspaceId": { - "description": "Workspace that owns the task, or the source workspace with newWorkspace.", + "sourceBranch": { + "description": "Branch a new worktree starts from. Defaults to the project's preferred branch.", "minLength": 1, "type": "string" } }, "required": [ - "profile", - "spec", - "coordinator" + "prompt" ], "type": "object" }, - "name": "delegate_task", - "timeoutSeconds": 58, - "title": "Delegate Task" + "name": "start_workspace_from_prompt", + "timeoutSeconds": 50, + "title": "Start Workspace From Prompt" }, { - "access": "execute", + "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, - "readOnlyHint": false, - "title": "Write To Terminal" + "readOnlyHint": true, + "title": "Get Workspace Start" }, - "description": "Type text into a terminal. Set submit to send it to an interactive agent as a prompt, or enter to press Enter after it.", + "description": "Show a New Workspace from Prompt operation: status (running, needsInput, completed, failed, cancelled), phase, workspace, agent tab, setup tab, section, candidates, and error.", "inputSchema": { "additionalProperties": false, "properties": { - "enter": { - "description": "Press Enter after the text.", - "type": "boolean" - }, - "handle": { - "description": "Terminal handle from list_terminals.", - "minLength": 1, - "type": "string" - }, - "submit": { - "description": "Submit to an interactive agent with bracketed paste and Enter.", - "type": "boolean" - }, - "text": { - "description": "Text to type.", - "maxLength": 65536, + "operationId": { + "description": "Operation id from start_workspace_from_prompt or list_workspace_starts.", "minLength": 1, "type": "string" } }, "required": [ - "handle", - "text" + "operationId" ], "type": "object" }, - "name": "write_terminal", + "name": "get_workspace_start", "timeoutSeconds": 30, - "title": "Write To Terminal" + "title": "Get Workspace Start" }, { - "access": "execute", + "access": "read", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, - "readOnlyHint": false, - "title": "Send Orchestration Message" + "readOnlyHint": true, + "title": "Wait For Workspace Start" }, - "description": "Send an orchestration message from one agent terminal to a terminal handle or a group such as @all, @idle, or @workspace:. To ask an agent a question from outside, use ask_agent.", + "description": "Wait up to timeoutSeconds for a New Workspace from Prompt operation to stop running, then return it. Call again while the status is still running.", "inputSchema": { "additionalProperties": false, "properties": { - "body": { - "description": "Message body.", - "maxLength": 65536, - "minLength": 1, - "type": "string" - }, - "from": { - "description": "Sender terminal handle, from list_terminals.", - "minLength": 1, - "type": "string" - }, - "priority": { - "description": "Priority.", - "enum": [ - "normal", - "high", - "urgent" - ], - "type": "string" - }, - "subject": { - "description": "Message subject.", + "operationId": { + "description": "Operation id from start_workspace_from_prompt or list_workspace_starts.", "minLength": 1, "type": "string" }, - "threadId": { - "description": "Thread to attach the message to.", - "minLength": 1, - "type": "string" - }, - "to": { - "description": "Terminal handle or @group.", - "minLength": 1, - "type": "string" - }, - "type": { - "description": "Message type.", - "enum": [ - "status", - "dispatch", - "merge_ready", - "escalation", - "handoff", - "decision_gate" - ], - "type": "string" + "timeoutSeconds": { + "description": "Seconds to wait before returning the current state. Call again to keep waiting.", + "maximum": 50, + "minimum": 1, + "type": "integer" } }, "required": [ - "from", - "to", - "subject" + "operationId" ], "type": "object" }, - "name": "send_message", + "name": "wait_for_workspace_start", + "timeoutSeconds": 58, + "title": "Wait For Workspace Start" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Workspace Starts" + }, + "description": "List recent New Workspace from Prompt operations, newest first.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "limit": { + "description": "Maximum operations (default 20).", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "name": "list_workspace_starts", "timeoutSeconds": 30, - "title": "Send Orchestration Message" + "title": "List Workspace Starts" }, { "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": false, "readOnlyHint": false, - "title": "Ask Agent" + "title": "Cancel Workspace Start" }, - "description": "Ask a running agent a question by terminal handle, or the single agent of a workspace. Returns the question id; follow it with wait_for_reply.", + "description": "Cancel a running New Workspace from Prompt operation. A workspace it already created is kept.", "inputSchema": { "additionalProperties": false, "properties": { - "agent": { - "description": "With workspaceId, only agents of this type (claude, codex, ...).", - "minLength": 1, - "type": "string" - }, - "body": { - "description": "The question.", - "maxLength": 65536, - "minLength": 1, - "type": "string" - }, - "expiresIn": { - "description": "Drop the question if undelivered in time, such as 30m or 2h.", - "minLength": 1, - "type": "string" - }, - "priority": { - "description": "Priority.", - "enum": [ - "normal", - "high", - "urgent" - ], - "type": "string" - }, - "subject": { - "description": "Short subject.", - "minLength": 1, - "type": "string" - }, - "threadId": { - "description": "Continue an earlier question's thread.", - "minLength": 1, - "type": "string" - }, - "to": { - "description": "Terminal handle of the agent.", - "minLength": 1, - "type": "string" - }, - "workspaceId": { - "description": "Ask the single running agent of this workspace.", + "operationId": { + "description": "Operation id from start_workspace_from_prompt or list_workspace_starts.", "minLength": 1, "type": "string" } }, "required": [ - "body" + "operationId" ], "type": "object" }, - "name": "ask_agent", + "name": "cancel_workspace_start", "timeoutSeconds": 30, - "title": "Ask Agent" + "title": "Cancel Workspace Start" }, { "access": "execute", "annotations": { - "destructiveHint": true, + "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": false, - "title": "Cancel Task" + "title": "Retry Workspace Start Launch" }, - "description": "Cancel an orchestration task and its not-yet-started descendants. Runs as an audited administrative cancellation, because an MCP client is not the task's coordinator terminal.", + "description": "Launch the agent again for an operation whose workspace was created but whose agent did not start. It never creates another workspace and reuses the first launch's retry key.", "inputSchema": { "additionalProperties": false, "properties": { - "reason": { - "description": "Why it is cancelled.", + "operationId": { + "description": "Operation id from start_workspace_from_prompt or list_workspace_starts.", "minLength": 1, "type": "string" - }, - "taskId": { - "description": "Task id.", + } + }, + "required": [ + "operationId" + ], + "type": "object" + }, + "name": "retry_workspace_start_launch", + "timeoutSeconds": 30, + "title": "Retry Workspace Start Launch" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Workspace" + }, + "description": "Show one workspace with its project, section, tags, linked issue, linked pull request, Watch and Fix state, parent, children, and whether it is asleep.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", "minLength": 1, "type": "string" } }, "required": [ - "taskId", - "reason" + "workspaceId" ], "type": "object" }, - "name": "cancel_task", + "name": "show_workspace", "timeoutSeconds": 30, - "title": "Cancel Task" + "title": "Show Workspace" }, { "access": "execute", "annotations": { - "destructiveHint": true, + "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": false, - "title": "Sleep Workspace" + "title": "Rename Workspace" }, - "description": "Stop a workspace's terminal sessions while keeping its tabs, branch, and files. Opening it again wakes it.", + "description": "Change a workspace's display name. Its branch and folder are not touched.", "inputSchema": { "additionalProperties": false, "properties": { + "name": { + "description": "New display name.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, "workspaceId": { - "description": "Workspace id.", + "description": "Workspace id from list_workspaces.", "minLength": 1, "type": "string" } }, "required": [ - "workspaceId" + "workspaceId", + "name" ], "type": "object" }, - "name": "sleep_workspace", - "timeoutSeconds": 58, - "title": "Sleep Workspace" + "name": "rename_workspace", + "timeoutSeconds": 30, + "title": "Rename Workspace" }, { "access": "execute", "annotations": { "destructiveHint": false, + "idempotentHint": true, "openWorldHint": false, "readOnlyHint": false, - "title": "Run Automation" + "title": "Pin Or Unpin Workspace" }, - "description": "Start one run of an automation immediately.", + "description": "Pin a workspace to the top of the sidebar, or unpin it. With tree, the same applies to every workspace below it.", "inputSchema": { "additionalProperties": false, "properties": { - "automationId": { - "description": "Automation id from list_automations.", + "pinned": { + "description": "True to pin, false to unpin.", + "type": "boolean" + }, + "tree": { + "description": "Also apply to every descendant workspace.", + "type": "boolean" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", "minLength": 1, "type": "string" } }, "required": [ - "automationId" + "workspaceId", + "pinned" ], "type": "object" }, - "name": "run_automation", + "name": "set_workspace_pinned", "timeoutSeconds": 30, - "title": "Run Automation" + "title": "Pin Or Unpin Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Archive Workspace" + }, + "description": "Archive a workspace: its terminal sessions stop and it leaves the sidebar, while its tabs, branch, and files are kept for unarchive_workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "archive_workspace", + "timeoutSeconds": 58, + "title": "Archive Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Unarchive Workspace" + }, + "description": "Return an archived workspace to the sidebar.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "unarchive_workspace", + "timeoutSeconds": 30, + "title": "Unarchive Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Wake Workspace" + }, + "description": "Wake a workspace put to sleep with sleep_workspace: its stopped terminals start again and agent tabs resume their sessions, as opening it in the Alera app does.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "wake_workspace", + "timeoutSeconds": 58, + "title": "Wake Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Focus Workspace" + }, + "description": "Select and show a workspace in the running Alera desktop app.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "focus_workspace", + "timeoutSeconds": 30, + "title": "Focus Workspace" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Preview Workspace Removal" + }, + "description": "Show what remove_workspace would do without changing anything: measured storage and what blocks cleanup, the automations it would pause, the linked workspaces it would unlink, live sessions, and what happens to the branch.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "preview_workspace_removal", + "timeoutSeconds": 58, + "title": "Preview Workspace Removal" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Remove Workspace" + }, + "description": "Remove a workspace, active or archived, with the Remove flow of the Alera app: its sessions close, dependent automations are paused and their runs cancelled, editors open on it in the app are saved (or discarded), and its worktree is deleted. Uncommitted changes in the worktree are lost, as when removing from the Alera app. A project folder workspace keeps its files. The branch is deleted when the workspace created it, unless branch is keep; Git still keeps a branch with unmerged work. Fails with blocked, removing nothing, when cleanup is unavailable or an editor cannot be saved.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "branch": { + "description": "delete removes the branch, keep preserves it. Default: delete when the workspace created the branch, as the Remove button does.", + "enum": [ + "delete", + "keep" + ], + "type": "string" + }, + "editorBuffers": { + "description": "What to do with unsaved editors on this workspace in the Alera app: save them (default) or discard them.", + "enum": [ + "save", + "discard" + ], + "type": "string" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "remove_workspace", + "timeoutSeconds": 58, + "title": "Remove Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Hand Off Workspace" + }, + "description": "Move a task from its project folder to its own Git worktree on a new or existing branch, as Hand Off in the app does. Choose whether the uncommitted changes move with it or stay in the project folder.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "branch": { + "description": "Branch for the new worktree.", + "minLength": 1, + "type": "string" + }, + "changes": { + "description": "move takes the uncommitted changes to the worktree; leave keeps them in the project folder.", + "enum": [ + "move", + "leave" + ], + "type": "string" + }, + "name": { + "description": "Display name of the moved task.", + "minLength": 1, + "type": "string" + }, + "path": { + "description": "Exact folder for the new worktree.", + "minLength": 1, + "type": "string" + }, + "replacementBranch": { + "description": "Branch the project folder switches to when the worktree takes its current branch.", + "minLength": 1, + "type": "string" + }, + "reuseExistingBranch": { + "description": "Use an existing branch instead of creating one. Needs replacementBranch and changes move.", + "type": "boolean" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + }, + "workspaceRoot": { + "description": "Folder under which Alera names the new worktree. Defaults to the runtime's workspace folder.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "branch", + "changes" + ], + "type": "object" + }, + "name": "hand_off_workspace", + "timeoutSeconds": 58, + "title": "Hand Off Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Hand On Workspace" + }, + "description": "Bring a task from its own worktree back to its project folder on the same host, as Hand On in the app does. Every task on the project folder then shares that branch and its files.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "hand_on_workspace", + "timeoutSeconds": 58, + "title": "Hand On Workspace" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Workspace Recovery" + }, + "description": "Show a workspace's relocation phases and setup attempts, with the ids recover_workspace_setup and cancel_workspace_setup need. Runs nothing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "get_workspace_recovery", + "timeoutSeconds": 30, + "title": "Get Workspace Recovery" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Run Workspace Setup" + }, + "description": "Run the project's worktree setup (copies and setup commands) in a workspace, or with relocationId the saved setup of that relocation once.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "copiesOnly": { + "description": "Only copy files; skip the setup commands.", + "type": "boolean" + }, + "relocationId": { + "description": "Relocation whose saved setup runs, from get_workspace_recovery.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "run_workspace_setup", + "timeoutSeconds": 58, + "title": "Run Workspace Setup" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Recover Workspace Setup" + }, + "description": "Close an interrupted relocation setup attempt once its processes are verified gone, without running its commands again.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "attemptId": { + "description": "Setup attempt id from get_workspace_recovery.", + "minLength": 1, + "type": "string" + }, + "relocationId": { + "description": "Relocation id from get_workspace_recovery.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "relocationId", + "attemptId" + ], + "type": "object" + }, + "name": "recover_workspace_setup", + "timeoutSeconds": 30, + "title": "Recover Workspace Setup" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Cancel Workspace Setup" + }, + "description": "Ask a running relocation setup attempt to stop.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "attemptId": { + "description": "Setup attempt id from get_workspace_recovery.", + "minLength": 1, + "type": "string" + }, + "relocationId": { + "description": "Relocation id from get_workspace_recovery.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "relocationId", + "attemptId" + ], + "type": "object" + }, + "name": "cancel_workspace_setup", + "timeoutSeconds": 30, + "title": "Cancel Workspace Setup" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Register Workspace Record" + }, + "description": "Repair tool: write a workspace record for an existing folder without creating or touching any Git worktree.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "branch": { + "description": "Branch checked out in the folder.", + "minLength": 1, + "type": "string" + }, + "hostId": { + "description": "SSH target id the record names. Metadata only.", + "minLength": 1, + "type": "string" + }, + "kind": { + "description": "main for the project folder, linked for a worktree (default).", + "enum": [ + "linked", + "main" + ], + "type": "string" + }, + "name": { + "description": "Display name.", + "minLength": 1, + "type": "string" + }, + "path": { + "description": "Existing workspace folder.", + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project id from list_projects.", + "minLength": 1, + "type": "string" + }, + "reuseExistingBranch": { + "description": "The branch existed before the workspace.", + "type": "boolean" + }, + "sourceBranch": { + "description": "Branch it started from.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id to use or replace.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "projectId", + "name", + "path" + ], + "type": "object" + }, + "name": "register_workspace_record", + "timeoutSeconds": 30, + "title": "Register Workspace Record" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Unregister Workspace Record" + }, + "description": "Repair tool: delete a workspace record and its tabs without touching any Git worktree or file.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "unregister_workspace_record", + "timeoutSeconds": 30, + "title": "Unregister Workspace Record" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Sections" + }, + "description": "List the sidebar sections workspaces are grouped in. A workspace without a section is in Others.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_sections", + "timeoutSeconds": 30, + "title": "List Sections" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Section" + }, + "description": "Create a sidebar section and put a first workspace in it.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "name": { + "description": "Section name.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "tree": { + "description": "Also apply to every descendant workspace, like the sidebar's Tree actions.", + "type": "boolean" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "workspaceId" + ], + "type": "object" + }, + "name": "create_section", + "timeoutSeconds": 30, + "title": "Create Section" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Set Workspace Section" + }, + "description": "Move a workspace to a section, given by sectionId or by its unique name.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "section": { + "description": "Section name, matched without case.", + "minLength": 1, + "type": "string" + }, + "sectionId": { + "description": "Section id from list_sections.", + "minLength": 1, + "type": "string" + }, + "tree": { + "description": "Also apply to every descendant workspace, like the sidebar's Tree actions.", + "type": "boolean" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "set_workspace_section", + "timeoutSeconds": 30, + "title": "Set Workspace Section" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Clear Workspace Section" + }, + "description": "Move a workspace to Others, out of any section.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "tree": { + "description": "Also apply to every descendant workspace, like the sidebar's Tree actions.", + "type": "boolean" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "clear_workspace_section", + "timeoutSeconds": 30, + "title": "Clear Workspace Section" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Remove Section" + }, + "description": "Delete a sidebar section. Its workspaces are kept and move to Others.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "sectionId": { + "description": "Section id from list_sections.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "sectionId" + ], + "type": "object" + }, + "name": "remove_section", + "timeoutSeconds": 30, + "title": "Remove Section" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Tags" + }, + "description": "List the workspace tags defined in this runtime.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_tags", + "timeoutSeconds": 30, + "title": "List Tags" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Or Update Tag" + }, + "description": "Create a workspace tag, or rename or recolor one when tagId is given.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "color": { + "description": "Tag color, such as #3B82F6.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Tag name.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "tagId": { + "description": "Existing tag id to update.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "name": "upsert_tag", + "timeoutSeconds": 30, + "title": "Create Or Update Tag" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Remove Tag" + }, + "description": "Delete a workspace tag and take it off every workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "tagId": { + "description": "Tag id from list_tags.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tagId" + ], + "type": "object" + }, + "name": "remove_tag", + "timeoutSeconds": 30, + "title": "Remove Tag" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Tag Workspace" + }, + "description": "Add a tag to a workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "tagId": { + "description": "Tag id from list_tags.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "tagId" + ], + "type": "object" + }, + "name": "tag_workspace", + "timeoutSeconds": 30, + "title": "Tag Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Untag Workspace" + }, + "description": "Take a tag off a workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "tagId": { + "description": "Tag id from list_tags.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "tagId" + ], + "type": "object" + }, + "name": "untag_workspace", + "timeoutSeconds": 30, + "title": "Untag Workspace" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Link Workspaces" + }, + "description": "Make one workspace the child of another, so it nests under it in the sidebar.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "childWorkspaceId": { + "description": "Child workspace id.", + "minLength": 1, + "type": "string" + }, + "parentWorkspaceId": { + "description": "Parent workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "parentWorkspaceId", + "childWorkspaceId" + ], + "type": "object" + }, + "name": "link_workspaces", + "timeoutSeconds": 30, + "title": "Link Workspaces" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Unlink Workspaces" + }, + "description": "Remove a parent and child link between two workspaces.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "childWorkspaceId": { + "description": "Child workspace id.", + "minLength": 1, + "type": "string" + }, + "parentWorkspaceId": { + "description": "Parent workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "parentWorkspaceId", + "childWorkspaceId" + ], + "type": "object" + }, + "name": "unlink_workspaces", + "timeoutSeconds": 30, + "title": "Unlink Workspaces" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Preview Workspace Cascade" + }, + "description": "List the workspaces an action would reach from some workspaces, optionally adding their descendants and every workspace sharing the given tags.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "descendants": { + "description": "Include the descendants of the starting workspaces.", + "type": "boolean" + }, + "tagIds": { + "description": "Tag ids whose workspaces are included.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "tags": { + "description": "Include every workspace with the given tags.", + "type": "boolean" + }, + "workspaceIds": { + "description": "Starting workspace ids.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "type": "object" + }, + "name": "preview_workspace_cascade", + "timeoutSeconds": 30, + "title": "Preview Workspace Cascade" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Workspace Issue" + }, + "description": "Show the issue linked to a workspace, fetched fresh from GitHub, GitLab, or Azure DevOps, or only its saved title and state with cached.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cached": { + "description": "Skip the forge and show the saved title and state.", + "type": "boolean" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "show_workspace_issue", + "timeoutSeconds": 58, + "title": "Show Workspace Issue" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Link Workspace Issue" + }, + "description": "Link an issue URL to a workspace, replacing its linked issue. Trackers Alera does not know are kept as a plain link.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "url": { + "description": "Issue URL.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "url" + ], + "type": "object" + }, + "name": "link_workspace_issue", + "timeoutSeconds": 58, + "title": "Link Workspace Issue" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Unlink Workspace Issue" + }, + "description": "Remove the issue linked to a workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id from list_workspaces.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "unlink_workspace_issue", + "timeoutSeconds": 30, + "title": "Unlink Workspace Issue" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Fetch Issue" + }, + "description": "Read an issue or work item from GitHub, GitLab, or Azure DevOps by URL, with its title, state, labels, assignees, and body, through the forge CLI on this machine.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "url": { + "description": "Issue or work item URL.", + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "name": "fetch_issue", + "timeoutSeconds": 58, + "title": "Fetch Issue" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Tabs" + }, + "description": "List the tabs of a workspace, including terminal and agent tabs.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "list_tabs", + "timeoutSeconds": 30, + "title": "List Tabs" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Terminals" + }, + "description": "List live terminal sessions with their handles, optionally for one workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_terminals", + "timeoutSeconds": 30, + "title": "List Terminals" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Terminal" + }, + "description": "Show one terminal session by handle, including its agent and lifecycle state.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "handle": { + "description": "Terminal handle from list_terminals.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "name": "show_terminal", + "timeoutSeconds": 30, + "title": "Show Terminal" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Read Terminal" + }, + "description": "Read retained terminal output. Pass the returned cursor on the next call to read only new output.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "description": "Cursor returned by a previous read.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "handle": { + "description": "Terminal handle from list_terminals.", + "minLength": 1, + "type": "string" + }, + "maxBytes": { + "description": "Maximum bytes to return (default 32768).", + "maximum": 262144, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "name": "read_terminal", + "timeoutSeconds": 30, + "title": "Read Terminal" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Wait For Terminal" + }, + "description": "Wait up to timeoutSeconds for a terminal to reach a lifecycle state, such as an agent becoming ready.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "handle": { + "description": "Terminal handle.", + "minLength": 1, + "type": "string" + }, + "state": { + "description": "Lifecycle state.", + "enum": [ + "process-started", + "agent-detected", + "agent-ready", + "dispatch-accepted" + ], + "type": "string" + }, + "timeoutSeconds": { + "description": "Seconds to wait before returning the current state. Call again to keep waiting.", + "maximum": 50, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "handle", + "state" + ], + "type": "object" + }, + "name": "wait_for_terminal", + "timeoutSeconds": 58, + "title": "Wait For Terminal" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Write To Terminal" + }, + "description": "Type text into a terminal. Set submit to send it to an interactive agent as a prompt, or enter to press Enter after it.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "enter": { + "description": "Press Enter after the text.", + "type": "boolean" + }, + "handle": { + "description": "Terminal handle from list_terminals.", + "minLength": 1, + "type": "string" + }, + "submit": { + "description": "Submit to an interactive agent with bracketed paste and Enter.", + "type": "boolean" + }, + "text": { + "description": "Text to type.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "handle", + "text" + ], + "type": "object" + }, + "name": "write_terminal", + "timeoutSeconds": 30, + "title": "Write To Terminal" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Tab" + }, + "description": "Open a terminal tab in a workspace and start it, optionally running a command such as an agent CLI.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "command": { + "description": "Command typed after the shell starts.", + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "title": { + "description": "Tab title.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "title" + ], + "type": "object" + }, + "name": "create_tab", + "timeoutSeconds": 30, + "title": "Create Tab" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Close Tab" + }, + "description": "Close a tab and end its terminal session, as closing it in the app does.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "tabId": { + "description": "Tab id from list_tabs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tabId" + ], + "type": "object" + }, + "name": "close_tab", + "timeoutSeconds": 30, + "title": "Close Tab" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Rename Tab" + }, + "description": "Rename a tab.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "tabId": { + "description": "Tab id from list_tabs.", + "minLength": 1, + "type": "string" + }, + "title": { + "description": "New tab title.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tabId", + "title" + ], + "type": "object" + }, + "name": "rename_tab", + "timeoutSeconds": 30, + "title": "Rename Tab" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Generate Tab Title" + }, + "description": "Name an agent tab from its conversation with AI Assist, as the app does.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "tabId": { + "description": "Tab id from list_tabs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tabId" + ], + "type": "object" + }, + "name": "generate_tab_title", + "timeoutSeconds": 58, + "title": "Generate Tab Title" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Link Agent To Tab" + }, + "description": "Bind a running agent conversation to a tab again so the agent's status updates that tab.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "description": "Agent type, such as claude or codex.", + "minLength": 1, + "type": "string" + }, + "sessionId": { + "description": "The agent's conversation id.", + "minLength": 1, + "type": "string" + }, + "state": { + "description": "Status shown until the agent reports again.", + "enum": [ + "working", + "waiting", + "blocked", + "done" + ], + "type": "string" + }, + "tabId": { + "description": "Tab id from list_tabs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "tabId", + "agent", + "sessionId" + ], + "type": "object" + }, + "name": "link_agent_to_tab", + "timeoutSeconds": 30, + "title": "Link Agent To Tab" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Restart Terminal" + }, + "description": "Replace a terminal's process, keeping its tab and scrollback, and start its command or agent again.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "handle": { + "description": "Terminal handle from list_terminals.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "name": "restart_terminal", + "timeoutSeconds": 30, + "title": "Restart Terminal" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Terminate Terminal" + }, + "description": "End a terminal session and close its tab, as the Resource Manager does.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "handle": { + "description": "Terminal handle from list_terminals.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "name": "terminate_terminal", + "timeoutSeconds": 30, + "title": "Terminate Terminal" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Prune Terminals" + }, + "description": "List stopped terminal tabs, or remove them with apply. Limit it to one workspace with workspaceId.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "apply": { + "description": "Remove the stopped terminals instead of listing them.", + "type": "boolean" + }, + "workspaceId": { + "description": "Workspace id. Omit for every workspace.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "prune_terminals", + "timeoutSeconds": 30, + "title": "Prune Terminals" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Terminal Pulse" + }, + "description": "Show a terminal's Pulse: the input typed into it after workspace files change, its delay, and whether it is armed.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "handle": { + "description": "Terminal handle from list_terminals.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "name": "get_terminal_pulse", + "timeoutSeconds": 30, + "title": "Get Terminal Pulse" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Configure Terminal Pulse" + }, + "description": "Change a terminal's Pulse and arm or disarm it. Fields left out keep their saved values.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "delayMs": { + "description": "Quiet time after the last change before typing, in milliseconds.", + "maximum": 3600000, + "minimum": 100, + "type": "integer" + }, + "enter": { + "description": "Press Enter after the input.", + "type": "boolean" + }, + "handle": { + "description": "Terminal handle from list_terminals.", + "minLength": 1, + "type": "string" + }, + "input": { + "description": "Text typed into the terminal after files change.", + "maxLength": 4096, + "minLength": 1, + "type": "string" + }, + "watch": { + "description": "Arm or disarm the Pulse. Omit to keep its state.", + "enum": [ + "arm", + "disarm" + ], + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "name": "configure_terminal_pulse", + "timeoutSeconds": 30, + "title": "Configure Terminal Pulse" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Agent Profiles" + }, + "description": "List the agent profiles (Claude, Codex, and others) that can be launched or delegated to, with their ids and names.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_agent_profiles", + "timeoutSeconds": 30, + "title": "List Agent Profiles" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Launch Agent" + }, + "description": "Launch an agent profile in a new tab of an existing workspace. Send a prompt to start a conversation, resumeSessionId to continue an earlier one, or neither to start the agent idle.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "profile": { + "description": "Agent profile id or unique name from list_agent_profiles.", + "minLength": 1, + "type": "string" + }, + "prompt": { + "description": "Prompt delivered to the agent.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "resumeSessionId": { + "description": "Agent conversation id to resume instead of sending a prompt.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "profile" + ], + "type": "object" + }, + "name": "launch_agent", + "timeoutSeconds": 58, + "title": "Launch Agent" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Agent Profile" + }, + "description": "Show one agent profile with its launch configuration and revision.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "profile": { + "description": "Agent profile id or unique name from list_agent_profiles.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "name": "show_agent_profile", + "timeoutSeconds": 30, + "title": "Show Agent Profile" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Preview Agent Profile Removal" + }, + "description": "Show what refers to an agent profile, such as automations, before removing it.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "profile": { + "description": "Agent profile id or unique name from list_agent_profiles.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "name": "preview_agent_profile_removal", + "timeoutSeconds": 30, + "title": "Preview Agent Profile Removal" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Agent Profile" + }, + "description": "Create an agent profile. A managed profile takes managedConfig; a command profile takes command.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agentType": { + "description": "Agent adapter, such as claude, codex, or opencode.", + "minLength": 1, + "type": "string" + }, + "command": { + "description": "Interactive command for the command launch mode.", + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "confirmReducedProtections": { + "description": "Confirm settings that reduce the agent's protections, such as skipping permission prompts.", + "type": "boolean" + }, + "customPrompt": { + "description": "Instructions added to every prompt this profile receives.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Short description.", + "maxLength": 2000, + "minLength": 1, + "type": "string" + }, + "launchMode": { + "description": "Run a command line or a managed configuration.", + "enum": [ + "command", + "managed" + ], + "type": "string" + }, + "managedConfig": { + "description": "Managed configuration for the managed launch mode, as show_agent_profile returns it.", + "type": "object" + }, + "name": { + "description": "Display name.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "quotaGroup": { + "description": "Quota group the profile counts against.", + "minLength": 1, + "type": "string" + }, + "showInNewTabMenu": { + "description": "Show the profile in the app's new tab menu.", + "type": "boolean" + } + }, + "required": [ + "name", + "agentType", + "launchMode" + ], + "type": "object" + }, + "name": "create_agent_profile", + "timeoutSeconds": 30, + "title": "Create Agent Profile" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update Agent Profile" + }, + "description": "Change an agent profile. Fields left out keep their values; the clear fields remove an optional text.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agentType": { + "description": "Agent adapter, such as claude, codex, or opencode.", + "minLength": 1, + "type": "string" + }, + "clearCustomPrompt": { + "description": "Remove the custom prompt.", + "type": "boolean" + }, + "clearDescription": { + "description": "Remove the description.", + "type": "boolean" + }, + "clearQuotaGroup": { + "description": "Remove the quota group.", + "type": "boolean" + }, + "command": { + "description": "Interactive command for the command launch mode.", + "maxLength": 8192, + "minLength": 1, + "type": "string" + }, + "confirmReducedProtections": { + "description": "Confirm settings that reduce the agent's protections, such as skipping permission prompts.", + "type": "boolean" + }, + "customPrompt": { + "description": "Instructions added to every prompt this profile receives.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "description": { + "description": "Short description.", + "maxLength": 2000, + "minLength": 1, + "type": "string" + }, + "expectedRevision": { + "description": "Profile revision from show_agent_profile. The change is refused if the profile changed since.", + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "launchMode": { + "description": "Run a command line or a managed configuration.", + "enum": [ + "command", + "managed" + ], + "type": "string" + }, + "managedConfig": { + "description": "Managed configuration for the managed launch mode, as show_agent_profile returns it.", + "type": "object" + }, + "name": { + "description": "Display name.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "profile": { + "description": "Agent profile id or unique name from list_agent_profiles.", + "minLength": 1, + "type": "string" + }, + "quotaGroup": { + "description": "Quota group the profile counts against.", + "minLength": 1, + "type": "string" + }, + "showInNewTabMenu": { + "description": "Show the profile in the app's new tab menu.", + "type": "boolean" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "name": "update_agent_profile", + "timeoutSeconds": 30, + "title": "Update Agent Profile" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Remove Agent Profile" + }, + "description": "Remove an agent profile after the runtime checks what refers to it.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "expectedRevision": { + "description": "Profile revision from show_agent_profile. The change is refused if the profile changed since.", + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "profile": { + "description": "Agent profile id or unique name from list_agent_profiles.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profile" + ], + "type": "object" + }, + "name": "remove_agent_profile", + "timeoutSeconds": 30, + "title": "Remove Agent Profile" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Reorder Agent Profiles" + }, + "description": "Set the order of every agent profile. List each profile id once, in the new order.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "ids": { + "description": "Every profile id from list_agent_profiles, in the new order.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "ids" + ], + "type": "object" + }, + "name": "reorder_agent_profiles", + "timeoutSeconds": 30, + "title": "Reorder Agent Profiles" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Set Default Agent Profile" + }, + "description": "Choose the agent profile new workspaces from a prompt use when none is named.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "profileId": { + "description": "Profile id from list_agent_profiles.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profileId" + ], + "type": "object" + }, + "name": "set_default_agent_profile", + "timeoutSeconds": 30, + "title": "Set Default Agent Profile" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Askable Agents" + }, + "description": "List running agents that ask_agent can reach, optionally for one workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_inbox_targets", + "timeoutSeconds": 30, + "title": "List Askable Agents" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Question Threads" + }, + "description": "List question threads asked through ask_agent by every MCP client sharing this inbox, newest first. Each thread carries origin, the MCP client that asked, and isOwn, whether this client asked it.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "before": { + "description": "Continue from the nextBefore value of a previous listing.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "limit": { + "description": "Maximum threads (default 50).", + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + "originClientId": { + "description": "Only threads asked by this MCP client id, from a thread's origin.clientId.", + "minLength": 1, + "type": "string" + }, + "scope": { + "description": "own: only threads this MCP client asked. all (default): every client's threads.", + "enum": [ + "all", + "own" + ], + "type": "string" + }, + "status": { + "description": "Thread status.", + "enum": [ + "pending", + "received", + "delivered", + "expired", + "answered", + "cancelled" + ], + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_inbox_threads", + "timeoutSeconds": 30, + "title": "List Question Threads" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Question Thread" + }, + "description": "Show a question thread with every question and reply.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "questionId": { + "description": "Any question id in the thread.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "questionId" + ], + "type": "object" + }, + "name": "show_inbox_thread", + "timeoutSeconds": 30, + "title": "Show Question Thread" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Wait For Reply" + }, + "description": "Wait up to timeoutSeconds for news about a question asked with ask_agent. Pass the returned cursor as after to skip what was already seen.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "after": { + "description": "Cursor from a previous result.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "questionId": { + "description": "Question id returned by ask_agent.", + "minLength": 1, + "type": "string" + }, + "timeoutSeconds": { + "description": "Seconds to wait before returning the current state. Call again to keep waiting.", + "maximum": 50, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "questionId" + ], + "type": "object" + }, + "name": "wait_for_reply", + "timeoutSeconds": 58, + "title": "Wait For Reply" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Ask Agent" + }, + "description": "Ask a running agent a question by terminal handle, or the single agent of a workspace. The agent sees which MCP client asked. Returns the question id, its thread id, and the recorded origin; follow it with wait_for_reply or wait_for_inbox.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "description": "With workspaceId, only agents of this type (claude, codex, ...).", + "minLength": 1, + "type": "string" + }, + "body": { + "description": "The question.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "expiresIn": { + "description": "Drop the question if undelivered in time, such as 30m or 2h.", + "minLength": 1, + "type": "string" + }, + "priority": { + "description": "Priority.", + "enum": [ + "normal", + "high", + "urgent" + ], + "type": "string" + }, + "subject": { + "description": "Short subject.", + "minLength": 1, + "type": "string" + }, + "threadId": { + "description": "Continue an earlier question's thread.", + "minLength": 1, + "type": "string" + }, + "to": { + "description": "Terminal handle of the agent.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Ask the single running agent of this workspace.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "name": "ask_agent", + "timeoutSeconds": 30, + "title": "Ask Agent" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Wait For Inbox" + }, + "description": "Wait up to timeoutSeconds for any new reply or message in the shared MCP inbox, so one call covers every open question. By default only replies to questions this MCP client asked count. Pass the returned cursor as after to skip what was already seen. Each message carries threadId, origin, and isOwn.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "after": { + "description": "Cursor from a previous result (default 0).", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "scope": { + "description": "own (default): only threads this MCP client asked. all: every client's threads. Not used with threadId.", + "enum": [ + "own", + "all" + ], + "type": "string" + }, + "threadId": { + "description": "Only news in this thread, by any question id in it.", + "minLength": 1, + "type": "string" + }, + "timeoutSeconds": { + "description": "Seconds to wait before returning the current state. Call again to keep waiting.", + "maximum": 50, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "name": "wait_for_inbox", + "timeoutSeconds": 58, + "title": "Wait For Inbox" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Cancel Question" + }, + "description": "Cancel a question asked with ask_agent before it reaches the agent. A question the agent already received cannot be cancelled.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "questionId": { + "description": "Question id returned by ask_agent.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "questionId" + ], + "type": "object" + }, + "name": "cancel_question", + "timeoutSeconds": 30, + "title": "Cancel Question" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Mark Thread Read" + }, + "description": "Mark every reply in a question thread as read for all clients sharing the inbox.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "questionId": { + "description": "Any question id in the thread.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "questionId" + ], + "type": "object" + }, + "name": "mark_thread_read", + "timeoutSeconds": 30, + "title": "Mark Thread Read" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Inboxes" + }, + "description": "List the external inboxes, such as the shared MCP inbox and the one the Alera apps use, with their thread, pending, awaiting reply, and unread counts.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_inboxes", + "timeoutSeconds": 30, + "title": "List Inboxes" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Agent Conversations" + }, + "description": "List conversations between agents, newest first, optionally for one workspace or one participating terminal.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "before": { + "description": "Continue from the nextBefore value of a previous listing.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "limit": { + "description": "Maximum conversations (default 50).", + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + "participant": { + "description": "Terminal handle that took part.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_agent_conversations", + "timeoutSeconds": 30, + "title": "List Agent Conversations" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Agent Conversation" + }, + "description": "Show every message of one conversation between agents.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "threadId": { + "description": "Conversation thread id from list_agent_conversations.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "name": "show_agent_conversation", + "timeoutSeconds": 30, + "title": "Show Agent Conversation" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Purge MCP Inbox" + }, + "description": "Delete every question and reply of the shared MCP inbox, including the threads other MCP clients asked.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "purge_inbox", + "timeoutSeconds": 30, + "title": "Purge MCP Inbox" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Tasks" + }, + "description": "List orchestration tasks, optionally filtered by status, coordinator run, or workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Coordinator run id.", + "minLength": 1, + "type": "string" + }, + "status": { + "description": "Task status.", + "enum": [ + "pending", + "ready", + "dispatched", + "completed", + "failed", + "blocked", + "stalled", + "cancelled" + ], + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_tasks", + "timeoutSeconds": 30, + "title": "List Tasks" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Task" + }, + "description": "Show one orchestration task with its active dispatch.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "name": "show_task", + "timeoutSeconds": 30, + "title": "Show Task" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Coordinator Runs" + }, + "description": "List durable orchestration coordinator runs, optionally for one workspace.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_runs", + "timeoutSeconds": 30, + "title": "List Coordinator Runs" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Orchestration Status" + }, + "description": "Aggregate the run, task, worker, and escalation state of one coordinator run.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Coordinator run id from list_runs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "orchestration_status", + "timeoutSeconds": 30, + "title": "Orchestration Status" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Orchestration Messages" + }, + "description": "List recent orchestration messages across all recipients, or the inbox or outbox of one terminal.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "direction": { + "description": "Direction relative to terminal.", + "enum": [ + "inbox", + "outbox" + ], + "type": "string" + }, + "limit": { + "description": "Maximum messages (default 50).", + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + "terminal": { + "description": "Terminal handle.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_messages", + "timeoutSeconds": 30, + "title": "List Orchestration Messages" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Wait For Task" + }, + "description": "Wait up to timeoutSeconds for an orchestration task to reach one of the given states, then return it. Returns the current state when the wait ends first.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "states": { + "description": "States to wait for (default completed, failed, stalled, cancelled).", + "items": { + "enum": [ + "pending", + "ready", + "dispatched", + "completed", + "failed", + "blocked", + "stalled", + "cancelled" + ], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + }, + "timeoutSeconds": { + "description": "Seconds to wait before returning the current state. Call again to keep waiting.", + "maximum": 50, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "name": "wait_for_task", + "timeoutSeconds": 58, + "title": "Wait For Task" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Delegate Task" + }, + "description": "Create an orchestration task and start an agent profile that accepts it, in an existing workspace or a new child worktree. The coordinator is a running agent terminal (from list_terminals) that receives the worker's messages. Returns the task; follow it with wait_for_task. Without a coordinator terminal, use start_agent_workspace or ask_agent instead.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "branch": { + "description": "Branch for the new workspace.", + "minLength": 1, + "type": "string" + }, + "coordinator": { + "description": "Terminal handle of the coordinating agent, from list_terminals.", + "minLength": 1, + "type": "string" + }, + "name": { + "description": "Name for the new workspace.", + "minLength": 1, + "type": "string" + }, + "newWorkspace": { + "description": "Create a child worktree and delegate into it.", + "type": "boolean" + }, + "profile": { + "description": "Agent profile id or unique name from list_agent_profiles.", + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project for the new workspace.", + "minLength": 1, + "type": "string" + }, + "sourceBranch": { + "description": "Branch the new workspace starts from.", + "minLength": 1, + "type": "string" + }, + "spec": { + "description": "Task brief the worker receives.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "timeoutSeconds": { + "description": "Seconds to wait for the agent to accept.", + "maximum": 50, + "minimum": 1, + "type": "integer" + }, + "title": { + "description": "Short title for listings.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace that owns the task, or the source workspace with newWorkspace.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profile", + "spec", + "coordinator" + ], + "type": "object" + }, + "name": "delegate_task", + "timeoutSeconds": 58, + "title": "Delegate Task" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Send Orchestration Message" + }, + "description": "Send an orchestration message from one agent terminal to a terminal handle or a group such as @all, @idle, or @workspace:. To ask an agent a question from outside, use ask_agent.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Message body.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "from": { + "description": "Sender terminal handle, from list_terminals.", + "minLength": 1, + "type": "string" + }, + "payload": { + "description": "Raw JSON payload text, instead of taskId.", + "maxLength": 16384, + "minLength": 1, + "type": "string" + }, + "priority": { + "description": "Priority.", + "enum": [ + "normal", + "high", + "urgent" + ], + "type": "string" + }, + "subject": { + "description": "Message subject.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task the message is about, recorded in its payload.", + "minLength": 1, + "type": "string" + }, + "threadId": { + "description": "Thread to attach the message to.", + "minLength": 1, + "type": "string" + }, + "to": { + "description": "Terminal handle or @group.", + "minLength": 1, + "type": "string" + }, + "type": { + "description": "Message type.", + "enum": [ + "status", + "dispatch", + "merge_ready", + "escalation", + "handoff", + "decision_gate" + ], + "type": "string" + } + }, + "required": [ + "from", + "to", + "subject" + ], + "type": "object" + }, + "name": "send_message", + "timeoutSeconds": 30, + "title": "Send Orchestration Message" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Cancel Task" + }, + "description": "Cancel an orchestration task and its not-yet-started descendants. Runs as an audited administrative cancellation, because an MCP client is not the task's coordinator terminal.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "reason": { + "description": "Why it is cancelled.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "taskId", + "reason" + ], + "type": "object" + }, + "name": "cancel_task", + "timeoutSeconds": 30, + "title": "Cancel Task" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Task" + }, + "description": "Create an orchestration task without starting an agent. A manual task names its coordinator terminal; a task of a coordinator run names the run and, with an execution policy, its stage. Follow it with dispatch_task or spawn_agent.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "coordinator": { + "description": "Terminal handle of the coordinating agent, from list_terminals.", + "minLength": 1, + "type": "string" + }, + "dependsOn": { + "description": "Task ids this task waits for.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "parentTaskId": { + "description": "Parent task id.", + "minLength": 1, + "type": "string" + }, + "resultSchema": { + "description": "JSON Schema text that structured completion results must match.", + "maxLength": 16384, + "minLength": 1, + "type": "string" + }, + "runId": { + "description": "Coordinator run for a coordinated task.", + "minLength": 1, + "type": "string" + }, + "spec": { + "description": "Task brief the worker receives.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "stage": { + "description": "Execution policy stage id, for a task of a run with a policy.", + "minLength": 1, + "type": "string" + }, + "title": { + "description": "Short title for listings.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace that owns the task.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "spec", + "workspaceId" + ], + "type": "object" + }, + "name": "create_task", + "timeoutSeconds": 30, + "title": "Create Task" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Dispatch Task" + }, + "description": "Dispatch a ready task to an existing terminal. With inject, the task preamble is pasted into the running agent; dryRun only builds the preamble.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "coordinator": { + "description": "Terminal handle of the coordinating agent, from list_terminals.", + "minLength": 1, + "type": "string" + }, + "dryRun": { + "description": "Build the preamble without changing anything.", + "type": "boolean" + }, + "inject": { + "description": "Paste the preamble into the worker's running agent.", + "type": "boolean" + }, + "returnPreamble": { + "description": "Include the full preamble text in the result.", + "type": "boolean" + }, + "taskId": { + "description": "Ready task id.", + "minLength": 1, + "type": "string" + }, + "terminalPolicy": { + "description": "What happens to the worker terminal after success (default keep-open).", + "enum": [ + "keep-open", + "close-on-success", + "return-to-shell" + ], + "type": "string" + }, + "to": { + "description": "Terminal handle of the worker, from list_terminals.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "taskId", + "to", + "coordinator" + ], + "type": "object" + }, + "name": "dispatch_task", + "timeoutSeconds": 30, + "title": "Dispatch Task" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Dispatch" + }, + "description": "Show the dispatch state and preamble of a task.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "taskId" + ], + "type": "object" + }, + "name": "show_dispatch", + "timeoutSeconds": 30, + "title": "Show Dispatch" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Interrupt Dispatch" + }, + "description": "Interrupt the current turn of a dispatched worker without closing its terminal. Runs as an audited administrative interrupt.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "dispatchId": { + "description": "Dispatch id from show_task or show_dispatch.", + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Why it is interrupted.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "dispatchId", + "reason" + ], + "type": "object" + }, + "name": "interrupt_dispatch", + "timeoutSeconds": 30, + "title": "Interrupt Dispatch" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Spawn Agent For Task" + }, + "description": "Start an agent for an existing ready task, in a new or given terminal of a workspace, and dispatch the task once the agent is ready. Returns when the agent accepts or the wait ends.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "description": "Agent type (claude, codex, ...) when no profile is given.", + "minLength": 1, + "type": "string" + }, + "coordinator": { + "description": "Terminal handle of the coordinating agent, from list_terminals.", + "minLength": 1, + "type": "string" + }, + "profile": { + "description": "Agent profile name from list_agent_profiles.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Ready task id.", + "minLength": 1, + "type": "string" + }, + "terminal": { + "description": "Existing terminal handle to reuse.", + "minLength": 1, + "type": "string" + }, + "timeoutSeconds": { + "description": "Seconds to wait for the agent to accept.", + "maximum": 50, + "minimum": 1, + "type": "integer" + }, + "title": { + "description": "Title of the worker tab.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace for the worker terminal.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "taskId", + "workspaceId", + "coordinator" + ], + "type": "object" + }, + "name": "spawn_agent", + "timeoutSeconds": 58, + "title": "Spawn Agent For Task" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Start Coordinator Run" + }, + "description": "Start the background coordinator loop for a running coordinator agent: it records the run objective and dispatches the run's ready tasks to worker terminals.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent": { + "description": "Agent type for worker terminals the loop creates (default codex).", + "minLength": 1, + "type": "string" + }, + "coordinator": { + "description": "Terminal handle of the coordinating agent, from list_terminals.", + "minLength": 1, + "type": "string" + }, + "maxConcurrent": { + "description": "Maximum concurrent dispatches (default 4).", + "maximum": 64, + "minimum": 1, + "type": "integer" + }, + "spec": { + "description": "Run objective.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace that scopes worker terminals.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "spec", + "coordinator", + "workspaceId" + ], + "type": "object" + }, + "name": "start_coordinator", + "timeoutSeconds": 30, + "title": "Start Coordinator Run" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Stop Coordinator Run" + }, + "description": "Stop a coordinator run's loop, optionally cancelling its active tasks. Runs as an audited administrative stop.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cancelActive": { + "description": "Also cancel the run's active tasks.", + "type": "boolean" + }, + "reason": { + "description": "Why it is stopped.", + "minLength": 1, + "type": "string" + }, + "runId": { + "description": "Coordinator run id from list_runs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "reason" + ], + "type": "object" + }, + "name": "stop_coordinator", + "timeoutSeconds": 30, + "title": "Stop Coordinator Run" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Coordinator Run" + }, + "description": "Show one coordinator run.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Coordinator run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "show_run", + "timeoutSeconds": 30, + "title": "Show Coordinator Run" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Propose Run Policy" + }, + "description": "Propose an execution policy (a stage plan) for a coordinator run. The run holds scheduling until a person approves or rejects the policy in the Alera app.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "policy": { + "description": "Execution policy as JSON text.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + }, + "runId": { + "description": "Coordinator run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "policy" + ], + "type": "object" + }, + "name": "propose_run_policy", + "timeoutSeconds": 30, + "title": "Propose Run Policy" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Run Policy" + }, + "description": "Show a coordinator run's execution policy and whether it is approved.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Coordinator run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "show_run_policy", + "timeoutSeconds": 30, + "title": "Show Run Policy" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Decision Gates" + }, + "description": "List decision gates, optionally for one task or status.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "status": { + "description": "Gate status.", + "enum": [ + "pending", + "resolved", + "timeout" + ], + "type": "string" + }, + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_gates", + "timeoutSeconds": 30, + "title": "List Decision Gates" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Decision Gate" + }, + "description": "Ask a person to decide before a task continues. The task stays blocked until someone resolves the gate in the Alera app.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "options": { + "description": "Answers to offer.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "question": { + "description": "The question for the person.", + "maxLength": 4096, + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task the gate blocks.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "taskId", + "question" + ], + "type": "object" + }, + "name": "create_gate", + "timeoutSeconds": 30, + "title": "Create Decision Gate" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Orchestration Board" + }, + "description": "Read one page of the run board, with coordinator runs grouped as attention, active, or history. Pass nextCursor back as cursor for the next page.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "bucket": { + "description": "Board column.", + "enum": [ + "attention", + "active", + "history" + ], + "type": "string" + }, + "cursor": { + "description": "The nextCursor JSON object of a previous page, as text.", + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "limit": { + "description": "Maximum runs.", + "maximum": 100, + "minimum": 1, + "type": "integer" + }, + "projectId": { + "description": "Project id.", + "minLength": 1, + "type": "string" + }, + "search": { + "description": "Text to search for.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "get_orchestration_board", + "timeoutSeconds": 30, + "title": "Get Orchestration Board" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Run Snapshot" + }, + "description": "Read a coordinator run with a page of its tasks. Continue with afterTaskId and the returned revision.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "afterTaskId": { + "description": "Continue after this task id.", + "minLength": 1, + "type": "string" + }, + "limit": { + "description": "Maximum tasks.", + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + "revision": { + "description": "Board revision of the previous page.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "runId": { + "description": "Coordinator run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "get_run_snapshot", + "timeoutSeconds": 30, + "title": "Get Run Snapshot" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Inspect Task" + }, + "description": "Read one task of a coordinator run with its dispatches and history.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cursor": { + "description": "The history cursor JSON object of a previous page, as text.", + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "limit": { + "description": "Maximum history entries.", + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Coordinator run id.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "taskId" + ], + "type": "object" + }, + "name": "inspect_task", + "timeoutSeconds": 30, + "title": "Inspect Task" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Recover Task" + }, + "description": "Move a stalled task to ready, failed, or cancelled through an audited administrative recovery.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "reason": { + "description": "Why it is recovered.", + "minLength": 1, + "type": "string" + }, + "status": { + "description": "New status.", + "enum": [ + "ready", + "failed", + "cancelled" + ], + "type": "string" + }, + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "taskId", + "status", + "reason" + ], + "type": "object" + }, + "name": "recover_task", + "timeoutSeconds": 30, + "title": "Recover Task" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Transfer Coordinator" + }, + "description": "Hand a task or a whole coordinator run to another coordinator terminal through an audited administrative transfer.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "reason": { + "description": "Why it is transferred.", + "minLength": 1, + "type": "string" + }, + "runId": { + "description": "Coordinator run to transfer.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task to transfer.", + "minLength": 1, + "type": "string" + }, + "to": { + "description": "Terminal handle of the new coordinator.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "to", + "reason" + ], + "type": "object" + }, + "name": "transfer_coordinator", + "timeoutSeconds": 30, + "title": "Transfer Coordinator" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Reset Orchestration" + }, + "description": "Clear orchestration state: tasks with their dispatches, gates, and coordinator runs, messages, or both (default all).", + "inputSchema": { + "additionalProperties": false, + "properties": { + "scope": { + "description": "What to clear (default all).", + "enum": [ + "all", + "tasks", + "messages" + ], + "type": "string" + } + }, + "type": "object" + }, + "name": "reset_orchestration", + "timeoutSeconds": 30, + "title": "Reset Orchestration" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Workflow Recipes" + }, + "description": "List built-in and personal workflow recipes, plus a workspace's project recipes when workspaceId is given.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace whose project recipes to include.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_recipes", + "timeoutSeconds": 30, + "title": "List Workflow Recipes" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Workflow Recipe" + }, + "description": "Show one workflow recipe with its digest, roles, and stages. Name it as list_recipes reports its source.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "description": "Recipe id, or for a project recipe its path, as list_recipes reports them.", + "minLength": 1, + "type": "string" + }, + "origin": { + "description": "Where the recipe lives, as list_recipes reports it.", + "enum": [ + "builtIn", + "personal", + "project" + ], + "type": "string" + }, + "workspaceId": { + "description": "Workspace of a project recipe.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "origin", + "id" + ], + "type": "object" + }, + "name": "show_recipe", + "timeoutSeconds": 30, + "title": "Show Workflow Recipe" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Validate Workflow Recipe" + }, + "description": "Check a portable workflow recipe (YAML) without saving or running it. Returns its digest and stage order.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "document": { + "description": "Recipe YAML.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "document" + ], + "type": "object" + }, + "name": "validate_recipe", + "timeoutSeconds": 30, + "title": "Validate Workflow Recipe" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Save Personal Recipe" + }, + "description": "Create or update a personal workflow recipe from YAML. Updating one needs its current catalog revision.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "document": { + "description": "Recipe YAML.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + }, + "expectedRevision": { + "description": "Catalog revision of the recipe being replaced.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "document" + ], + "type": "object" + }, + "name": "save_personal_recipe", + "timeoutSeconds": 30, + "title": "Save Personal Recipe" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Preview Recipe Export" + }, + "description": "Preview writing a recipe into a workspace's project catalog (.alera/workflows). Returns the file before and after and the digest export_recipe needs.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "document": { + "description": "Recipe YAML.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + }, + "filename": { + "description": "File name such as release.yaml, inside .alera/workflows.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Active local Git workspace whose project receives the recipe.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "filename", + "document" + ], + "type": "object" + }, + "name": "preview_recipe_export", + "timeoutSeconds": 30, + "title": "Preview Recipe Export" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Export Recipe" + }, + "description": "Write a recipe into a workspace's project catalog after preview_recipe_export. Fails if the file or document changed since the preview.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "document": { + "description": "Recipe YAML.", + "maxLength": 262144, + "minLength": 1, + "type": "string" + }, + "expectedDigest": { + "description": "Digest from preview_recipe_export.", + "minLength": 1, + "type": "string" + }, + "filename": { + "description": "File name such as release.yaml, inside .alera/workflows.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Active local Git workspace whose project receives the recipe.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "filename", + "document", + "expectedDigest" + ], + "type": "object" + }, + "name": "export_recipe", + "timeoutSeconds": 30, + "title": "Export Recipe" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Workflow Proposals" + }, + "description": "List workflow proposals, newest first, with their coordinator and cancellation state.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "beforeCreatedAt": { + "description": "createdAt of the last entry of the previous page.", + "minLength": 1, + "type": "string" + }, + "beforeId": { + "description": "id of the last entry of the previous page.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_workflow_proposals", + "timeoutSeconds": 30, + "title": "List Workflow Proposals" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Workflow Proposal" + }, + "description": "Show a workflow proposal's lifecycle state, or with frozenSelection its frozen recipe, profiles, and source.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "frozenSelection": { + "description": "Return the frozen selection instead of the lifecycle state.", + "type": "boolean" + }, + "proposalId": { + "description": "Proposal id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "proposalId" + ], + "type": "object" + }, + "name": "get_workflow_proposal", + "timeoutSeconds": 30, + "title": "Get Workflow Proposal" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Workflow Proposal" + }, + "description": "Propose a workflow run from a recipe at the source workspace's current commit. Start its coordinator with start_workflow_coordinator; a person approves the plan in the Alera app.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "coordinatorProfileId": { + "description": "Agent profile id for the coordinator, from list_agent_profiles.", + "minLength": 1, + "type": "string" + }, + "expectedRevision": { + "description": "Current revision of runId.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "maxConcurrent": { + "description": "Workers that may run at once (default 4).", + "maximum": 16, + "minimum": 1, + "type": "integer" + }, + "objective": { + "description": "What the workflow should achieve.", + "maxLength": 16384, + "minLength": 1, + "type": "string" + }, + "recipeDigest": { + "description": "Recipe digest from show_recipe.", + "minLength": 1, + "type": "string" + }, + "recipeId": { + "description": "Recipe id, or for a project recipe its path, as list_recipes reports them.", + "minLength": 1, + "type": "string" + }, + "recipeOrigin": { + "description": "Where the recipe lives, as list_recipes reports it.", + "enum": [ + "builtIn", + "personal", + "project" + ], + "type": "string" + }, + "recipeWorkspaceId": { + "description": "Workspace of a project recipe.", + "minLength": 1, + "type": "string" + }, + "roleProfiles": { + "description": "Agent profile per recipe role, each as role=profileId.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "runId": { + "description": "Revise this existing run instead of starting a new one.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Active local Git workspace the workflow starts from.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "objective", + "recipeOrigin", + "recipeId", + "recipeDigest", + "coordinatorProfileId" + ], + "type": "object" + }, + "name": "create_workflow_proposal", + "timeoutSeconds": 30, + "title": "Create Workflow Proposal" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Submit Workflow Proposal Tasks" + }, + "description": "Submit the concrete task list for a proposal, as its coordinator would. Does not approve the plan or start workers.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "proposalId": { + "description": "Proposal id.", + "minLength": 1, + "type": "string" + }, + "tasks": { + "description": "JSON array of plan tasks (id, title, spec, stageId, roleId, dependsOn, inputs, correctsTaskId).", + "maxLength": 1048576, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "proposalId", + "tasks" + ], + "type": "object" + }, + "name": "submit_workflow_proposal", + "timeoutSeconds": 30, + "title": "Submit Workflow Proposal Tasks" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Cancel Workflow Proposal" + }, + "description": "Cancel a workflow proposal and its coordinator. Pass expectedSequence to retry a cancellation that did not finish.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "expectedSequence": { + "description": "Cancellation sequence from get_workflow_proposal, to retry it.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "proposalId": { + "description": "Proposal id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "proposalId" + ], + "type": "object" + }, + "name": "cancel_workflow_proposal", + "timeoutSeconds": 30, + "title": "Cancel Workflow Proposal" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Start Workflow Coordinator" + }, + "description": "Start the coordinator agent of a workflow proposal. The coordinator drafts the plan; it does not approve it.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "proposalId": { + "description": "Proposal id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "proposalId" + ], + "type": "object" + }, + "name": "start_workflow_coordinator", + "timeoutSeconds": 58, + "title": "Start Workflow Coordinator" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Prepare Workflow Plan" + }, + "description": "Prepare a durable workflow plan for review from a PrepareWorkflowPlan JSON document with a stable requestId. Starts no workers; a person approves it in the Alera app.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "document": { + "description": "PrepareWorkflowPlan JSON document.", + "maxLength": 1048576, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "document" + ], + "type": "object" + }, + "name": "prepare_workflow_plan", + "timeoutSeconds": 30, + "title": "Prepare Workflow Plan" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Workflow Plan" + }, + "description": "Show the current or a past revision of a workflow run's plan, with its digest, tasks, and frozen profiles.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "revision": { + "description": "Plan revision (default current).", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "show_workflow_plan", + "timeoutSeconds": 30, + "title": "Show Workflow Plan" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Workflow Execution" + }, + "description": "Show whether a workflow run is scheduling, paused, or cancelled, with the command sequence control_workflow_execution needs.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "revision": { + "description": "Plan revision (default current).", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "get_workflow_execution", + "timeoutSeconds": 30, + "title": "Get Workflow Execution" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Control Workflow Execution" + }, + "description": "Start, pause, or cancel the scheduling of an approved workflow run revision. Fails if expectedSequence is stale.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "action": { + "description": "What to do.", + "enum": [ + "start", + "pause", + "cancel" + ], + "type": "string" + }, + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "expectedSequence": { + "description": "Sequence from get_workflow_execution.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "revision": { + "description": "Approved plan revision.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "revision", + "expectedSequence", + "action" + ], + "type": "object" + }, + "name": "control_workflow_execution", + "timeoutSeconds": 30, + "title": "Control Workflow Execution" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Workflow Correction" + }, + "description": "Open a correction proposal for a workflow run revision, with the reason the coordinator receives. A person still approves the corrected plan.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "planDigest": { + "description": "Plan digest from show_workflow_plan.", + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Why the run needs a correction.", + "maxLength": 4096, + "minLength": 1, + "type": "string" + }, + "revision": { + "description": "Approved plan revision.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "revision", + "planDigest", + "reason" + ], + "type": "object" + }, + "name": "create_workflow_correction", + "timeoutSeconds": 30, + "title": "Create Workflow Correction" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Prepare Workflow Attempt" + }, + "description": "Prepare the integration workspace of an approved run, or a task's isolated attempt. retryOf names the latest failed attempt; a retry always gets a new worktree. Dispatches no worker.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "retryOf": { + "description": "Workspace id of the task's latest failed attempt.", + "minLength": 1, + "type": "string" + }, + "revision": { + "description": "Approved plan revision.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task to prepare an attempt for. Omit to prepare the integration workspace.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "revision" + ], + "type": "object" + }, + "name": "prepare_workflow_attempt", + "timeoutSeconds": 58, + "title": "Prepare Workflow Attempt" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Launch Workflow Task" + }, + "description": "Launch one approved task in its ready isolated attempt, at most once per clientRequestId.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "revision": { + "description": "Approved plan revision.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "The task attempt's workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "revision", + "taskId", + "workspaceId" + ], + "type": "object" + }, + "name": "launch_workflow_task", + "timeoutSeconds": 58, + "title": "Launch Workflow Task" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Integrate Workflow Result" + }, + "description": "Squash a completed task's result into its run's local integration workspace. Use a new clientRequestId to retry a failed integration.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "revision": { + "description": "Approved plan revision.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Task id.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "The task attempt's workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId", + "revision", + "taskId", + "workspaceId" + ], + "type": "object" + }, + "name": "integrate_workflow_result", + "timeoutSeconds": 58, + "title": "Integrate Workflow Result" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Workflow Integrations" + }, + "description": "List a run's local integration outcomes, or show one integration receipt with any conflict paths.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "afterRow": { + "description": "Continue after this row of a previous page.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "integrationId": { + "description": "Show this integration instead of listing.", + "minLength": 1, + "type": "string" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_workflow_integrations", + "timeoutSeconds": 30, + "title": "List Workflow Integrations" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Workflow Workspaces" + }, + "description": "List the workspaces a workflow run retains (integration and attempts) with their setup outcome.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "beforeRow": { + "description": "Continue before this row of a previous page.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "limit": { + "description": "Maximum entries.", + "maximum": 200, + "minimum": 1, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "list_workflow_workspaces", + "timeoutSeconds": 30, + "title": "List Workflow Workspaces" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Workflow Cleanups" + }, + "description": "List a workflow run's retained resources with their cleanup state, or its cleanup operations.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "beforeRow": { + "description": "Continue before this row of a previous page.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + }, + "view": { + "description": "resources (default) or operations.", + "enum": [ + "resources", + "operations" + ], + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "list_workflow_cleanups", + "timeoutSeconds": 30, + "title": "List Workflow Cleanups" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Preview Workflow Cleanup" + }, + "description": "Preview removing up to 25 retained workspaces of a workflow run, optionally with their branches. Changes nothing; returns the id and digest the cleanup tools need.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "removeBranchFor": { + "description": "Selected workspaces whose branch is deleted too.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "runId": { + "description": "Workflow run id.", + "minLength": 1, + "type": "string" + }, + "workspaceIds": { + "description": "Retained workspaces to remove.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "runId", + "workspaceIds" + ], + "type": "object" + }, + "name": "preview_workflow_cleanup", + "timeoutSeconds": 30, + "title": "Preview Workflow Cleanup" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Workflow Cleanup" + }, + "description": "Show a workflow cleanup operation, its reviewed preview, and the outcome of each resource.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cleanupId": { + "description": "Cleanup (preview) id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "cleanupId" + ], + "type": "object" + }, + "name": "get_workflow_cleanup", + "timeoutSeconds": 30, + "title": "Get Workflow Cleanup" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Apply Workflow Cleanup" + }, + "description": "Remove the workspaces (and branches) of a previewed workflow cleanup. Fails if they changed since the preview.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cleanupId": { + "description": "Cleanup id from preview_workflow_cleanup.", + "minLength": 1, + "type": "string" + }, + "digest": { + "description": "Digest from preview_workflow_cleanup.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "cleanupId", + "digest" + ], + "type": "object" + }, + "name": "apply_workflow_cleanup", + "timeoutSeconds": 58, + "title": "Apply Workflow Cleanup" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Retry Workflow Cleanup" + }, + "description": "Retry a workflow cleanup that did not finish, for the same reviewed preview.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cleanupId": { + "description": "Cleanup id from preview_workflow_cleanup.", + "minLength": 1, + "type": "string" + }, + "digest": { + "description": "Digest from preview_workflow_cleanup.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "cleanupId", + "digest" + ], + "type": "object" + }, + "name": "retry_workflow_cleanup", + "timeoutSeconds": 58, + "title": "Retry Workflow Cleanup" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Abandon Workflow Cleanup" + }, + "description": "Abandon an unfinished workflow cleanup and keep the resources it did not remove.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "cleanupId": { + "description": "Cleanup id from preview_workflow_cleanup.", + "minLength": 1, + "type": "string" + }, + "digest": { + "description": "Digest from preview_workflow_cleanup.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "cleanupId", + "digest" + ], + "type": "object" + }, + "name": "abandon_workflow_cleanup", + "timeoutSeconds": 58, + "title": "Abandon Workflow Cleanup" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Automations" + }, + "description": "List runtime automations with the same filters as the Automations view: state, bucket, project, agent profile, tag, workspace, section, host, or search text.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "bucket": { + "description": "View bucket. needsAttention covers blocked or not ready automations and runs waiting for you.", + "enum": [ + "all", + "needsAttention", + "completed", + "draft", + "active", + "paused", + "blocked", + "trashed" + ], + "type": "string" + }, + "hostId": { + "description": "Host the automation runs on.", + "minLength": 1, + "type": "string" + }, + "includeTrashed": { + "description": "Include automations in the trash.", + "type": "boolean" + }, + "profileId": { + "description": "Agent profile id the automation runs.", + "minLength": 1, + "type": "string" + }, + "projectId": { + "description": "Project id.", + "minLength": 1, + "type": "string" + }, + "search": { + "description": "Text to search for in the name, slug, or description.", + "minLength": 1, + "type": "string" + }, + "sectionId": { + "description": "Workspace section id.", + "minLength": 1, + "type": "string" + }, + "state": { + "description": "Automation state: draft, active, paused, blocked, archived, or trashed.", + "minLength": 1, + "type": "string" + }, + "tagId": { + "description": "Automation tag id from list_automation_tags.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace the automation belongs to.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_automations", + "timeoutSeconds": 30, + "title": "List Automations" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Automation Runs" + }, + "description": "List automation runs, newest first, optionally for one automation.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "description": "Automation id.", + "minLength": 1, + "type": "string" + }, + "limit": { + "description": "Maximum runs (default 20).", + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "name": "list_automation_runs", + "timeoutSeconds": 30, + "title": "List Automation Runs" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Run Automation" + }, + "description": "Start one run of an automation immediately, as Run Now does. By default it follows the automation's own precheck and overlap settings.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + }, + "continueFromRunId": { + "description": "Earlier run whose conversation this run continues.", + "minLength": 1, + "type": "string" + }, + "expectedRevision": { + "description": "Refuse to run if the automation changed since this revision.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "overlap": { + "description": "What to do when another run is active: skip, queue behind it, run the latest once, or run in parallel.", + "enum": [ + "skip", + "queue", + "runLatestOnce", + "forceParallel" + ], + "type": "string" + }, + "precheck": { + "description": "Run or skip the configured precheck for this run.", + "enum": [ + "run", + "skip" + ], + "type": "string" + } + }, + "required": [ + "automationId" + ], + "type": "object" + }, + "name": "run_automation", + "timeoutSeconds": 30, + "title": "Run Automation" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Automation" + }, + "description": "Show one automation with its definition, readiness, next occurrences, recent runs, and audit history.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "automationId" + ], + "type": "object" + }, + "name": "show_automation", + "timeoutSeconds": 30, + "title": "Show Automation" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Automation Run" + }, + "description": "Show one automation run with its attempts and the automation definition it ran.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Automation run id from list_automation_runs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "show_automation_run", + "timeoutSeconds": 30, + "title": "Show Automation Run" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Automation" + }, + "description": "Create an automation from a JSON definition, as the automation editor saves it. It starts active unless draft is true or the definition sets state to draft. Check the definition first with check_automation_readiness.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "definition": { + "description": "Automation definition: name, promptTemplate, schedule ({recurring: {cron, timezone}} or {oneTime: {at, timezone}}), target, and optional policies, tagIds, and projectId.", + "type": "object" + }, + "draft": { + "description": "Save as a draft instead of activating it.", + "type": "boolean" + } + }, + "required": [ + "definition" + ], + "type": "object" + }, + "name": "create_automation", + "timeoutSeconds": 30, + "title": "Create Automation" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Update Automation" + }, + "description": "Change fields of an automation, as saving the automation editor does. Fields not given keep their value.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + }, + "changes": { + "description": "Definition fields to replace, such as name, promptTemplate, schedule, target, or tagIds.", + "type": "object" + }, + "expectedRevision": { + "description": "Refuse the change if the automation changed since this revision.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "automationId", + "changes" + ], + "type": "object" + }, + "name": "update_automation", + "timeoutSeconds": 30, + "title": "Update Automation" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Clone Automation" + }, + "description": "Create a new automation with the settings and target of an existing one, as the Clone action does. The copy is named after the original followed by Copy unless a name is given.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + }, + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "draft": { + "description": "Save the copy as a draft instead of activating it.", + "type": "boolean" + }, + "name": { + "description": "Name of the copy.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "automationId" + ], + "type": "object" + }, + "name": "clone_automation", + "timeoutSeconds": 30, + "title": "Clone Automation" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Check Automation Readiness" + }, + "description": "Validate a full or partial automation definition without saving it, and list the fields to fix.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "definition": { + "description": "Automation definition, as create_automation takes it.", + "type": "object" + } + }, + "required": [ + "definition" + ], + "type": "object" + }, + "name": "check_automation_readiness", + "timeoutSeconds": 30, + "title": "Check Automation Readiness" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Preview Automation Schedule" + }, + "description": "Preview the next occurrences of a recurring cron schedule or a one-time date.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "kind": { + "description": "recurring takes a cron expression; oneTime takes a date and time.", + "enum": [ + "recurring", + "oneTime" + ], + "type": "string" + }, + "timezone": { + "description": "IANA time zone such as Europe/Madrid (default UTC).", + "minLength": 1, + "type": "string" + }, + "value": { + "description": "Cron expression such as 0 9 * * 1-5, or an ISO 8601 date and time.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "name": "preview_automation_schedule", + "timeoutSeconds": 30, + "title": "Preview Automation Schedule" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Pause Automation" + }, + "description": "Pause an automation so it stops scheduling runs. With active runs, choose activeRuns: continue-active keeps them, cancel-active cancels them.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "activeRuns": { + "description": "With active runs, keep them running or cancel them. Required when pausing an automation that has active runs.", + "enum": [ + "continue-active", + "cancel-active" + ], + "type": "string" + }, + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Why, recorded in the audit history.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "automationId" + ], + "type": "object" + }, + "name": "pause_automation", + "timeoutSeconds": 30, + "title": "Pause Automation" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Resume Automation" + }, + "description": "Activate a paused or draft automation. Fails with the fields to fix when it is not ready.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "activeRuns": { + "description": "With active runs, keep them running or cancel them. Required when pausing an automation that has active runs.", + "enum": [ + "continue-active", + "cancel-active" + ], + "type": "string" + }, + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Why, recorded in the audit history.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "automationId" + ], + "type": "object" + }, + "name": "resume_automation", + "timeoutSeconds": 30, + "title": "Resume Automation" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Trash Automation" + }, + "description": "Move an automation to the trash. It can be restored with restore_automation until it is purged.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "activeRuns": { + "description": "With active runs, keep them running or cancel them. Required when pausing an automation that has active runs.", + "enum": [ + "continue-active", + "cancel-active" + ], + "type": "string" + }, + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Why, recorded in the audit history.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "automationId" + ], + "type": "object" + }, + "name": "trash_automation", + "timeoutSeconds": 30, + "title": "Trash Automation" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Restore Automation" + }, + "description": "Restore a trashed automation, paused, or completed if it was completed before.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "activeRuns": { + "description": "With active runs, keep them running or cancel them. Required when pausing an automation that has active runs.", + "enum": [ + "continue-active", + "cancel-active" + ], + "type": "string" + }, + "automationId": { + "description": "Automation id from list_automations.", + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Why, recorded in the audit history.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "automationId" + ], + "type": "object" + }, + "name": "restore_automation", + "timeoutSeconds": 30, + "title": "Restore Automation" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Purge Automations" + }, + "description": "Permanently delete every automation that has been in the trash for at least 30 days, as Purge in the trash view does.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "purge_automations", + "timeoutSeconds": 30, + "title": "Purge Automations" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Cancel Automation Run" + }, + "description": "Cancel a queued, running, or waiting automation run, as Cancel in the run view does.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Automation run id from list_automation_runs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "cancel_automation_run", + "timeoutSeconds": 30, + "title": "Cancel Automation Run" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Resume Automation Run" + }, + "description": "Resume a run that is waiting for you, so its agent continues.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Automation run id from list_automation_runs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "resume_automation_run", + "timeoutSeconds": 30, + "title": "Resume Automation Run" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Extend Automation Run" + }, + "description": "Extend the deadline of a run that is waiting for you, by seconds (default one hour) or until a date and time.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Automation run id from list_automation_runs.", + "minLength": 1, + "type": "string" + }, + "seconds": { + "description": "Seconds to add (default 3600).", + "maximum": 2592000, + "minimum": 1, + "type": "integer" + }, + "until": { + "description": "New deadline as an ISO 8601 date and time, instead of seconds.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "extend_automation_run", + "timeoutSeconds": 30, + "title": "Extend Automation Run" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Take Over Automation Run" + }, + "description": "Take over the terminal of an automation run, as Take Over in the app does. The automation stops driving the agent and the terminal stays open for a person.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runId": { + "description": "Automation run id from list_automation_runs.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "runId" + ], + "type": "object" + }, + "name": "take_over_automation_run", + "timeoutSeconds": 30, + "title": "Take Over Automation Run" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Automation Templates" + }, + "description": "List the saved prompt templates automations can start from.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_automation_templates", + "timeoutSeconds": 30, + "title": "List Automation Templates" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Save Automation Template" + }, + "description": "Create or replace a prompt template by id, as Save as Template does.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "template": { + "description": "Template with id, name, promptTemplate, and optional description and defaults. updatedAt is optional.", + "type": "object" + } + }, + "required": [ + "template" + ], + "type": "object" + }, + "name": "upsert_automation_template", + "timeoutSeconds": 30, + "title": "Save Automation Template" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Automation Tags" + }, + "description": "List the tags that group automations.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_automation_tags", + "timeoutSeconds": 30, + "title": "List Automation Tags" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Save Automation Tags" + }, + "description": "Create a tag, or rename one by tagId, and set the tags of an automation. Pass tagName, automationId with tagIds, or both. To remove every tag, use update_automation with tagIds set to an empty list.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "automationId": { + "description": "Automation whose tags tagIds replaces.", + "minLength": 1, + "type": "string" + }, + "tagId": { + "description": "Existing tag to rename.", + "minLength": 1, + "type": "string" + }, + "tagIds": { + "description": "Every tag id the automation should have.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "tagName": { + "description": "Name of the tag to create, or the new name of tagId.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "upsert_automation_tags", + "timeoutSeconds": 30, + "title": "Save Automation Tags" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Export Automations" + }, + "description": "Export this runtime's automations, templates, and tags as a portable catalog that import_automations accepts.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "export_automations", + "timeoutSeconds": 30, + "title": "Export Automations" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Import Automations" + }, + "description": "Import a catalog from export_automations. Imported automations keep their ids unless remapped.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "bundle": { + "description": "Catalog object as export_automations returns it.", + "type": "object" + }, + "remap": { + "description": "Map of source id to target id, for projects, profiles, or workspaces that differ on this runtime.", + "type": "object" + } + }, + "required": [ + "bundle" + ], + "type": "object" + }, + "name": "import_automations", + "timeoutSeconds": 30, + "title": "Import Automations" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Pull Request" + }, + "description": "Show the pull request of a workspace on GitHub, GitLab, or Azure DevOps: state, draft, mergeability, head SHA, checks (pipelines or policies on GitLab and Azure DevOps), the conversation and review threads with their ids, the merge methods the forge allows, and the base branches. It is the linked pull request, or the open one for the current branch.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "get_pull_request", + "timeoutSeconds": 58, + "title": "Get Pull Request" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Pull Request Summaries" + }, + "description": "List one compact row per active workspace that has a pull request: number, title, state, and the rolled-up check status with failing check names.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Only this workspace.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_pull_request_summaries", + "timeoutSeconds": 58, + "title": "List Pull Request Summaries" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Generate Pull Request Details" + }, + "description": "Write a pull request title and description for the workspace branch against a base branch with AI Assist, the same generator the apps use. It changes nothing on the forge; AI Assist must be enabled. Generation can take minutes: after about 45 seconds the result is status running with an operationId, and the runtime keeps generating. Call again with that operationId as clientRequestId (or the clientRequestId you passed) and the same workspace and base branch to wait again or read the finished result, which is kept for 15 minutes. A completed result has status completed, title, and body; a failure is reported once, and the next call with the same key generates again.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "baseBranch": { + "description": "Base branch the pull request targets, such as main.", + "minLength": 1, + "type": "string" + }, + "clientRequestId": { + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + "maxLength": 128, + "minLength": 8, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "baseBranch" + ], + "type": "object" + }, + "name": "generate_pull_request_details", + "timeoutSeconds": 55, + "title": "Generate Pull Request Details" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Pull Request" + }, + "description": "Open a pull request (a merge request on GitLab) from the workspace's current branch into a base branch, optionally as a draft, and link it to the workspace. Push the branch first. Returns the refreshed pull request.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "baseBranch": { + "description": "Base branch the pull request targets, such as main.", + "minLength": 1, + "type": "string" + }, + "body": { + "description": "Pull request description in Markdown.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "draft": { + "description": "Open it as a draft.", + "type": "boolean" + }, + "title": { + "description": "Pull request title.", + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "baseBranch", + "title" + ], + "type": "object" + }, + "name": "create_pull_request", + "timeoutSeconds": 58, + "title": "Create Pull Request" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Link Pull Request" + }, + "description": "Link an existing pull request of the workspace repository by number or URL, so the workspace shows it instead of branch detection.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "reference": { + "description": "Pull request number, #number, or URL of this repository.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "reference" + ], + "type": "object" + }, + "name": "link_pull_request", + "timeoutSeconds": 58, + "title": "Link Pull Request" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Unlink Pull Request" + }, + "description": "Unlink the workspace's pull request, so branch detection stops showing that one until it is linked again. The pull request itself is not changed.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "unlink_pull_request", + "timeoutSeconds": 58, + "title": "Unlink Pull Request" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Comment On Pull Request" + }, + "description": "Post a comment on the pull request, or reply to a comment with replyToCommentId. On GitLab and Azure DevOps a reply joins that comment's discussion or thread; pass its threadId from get_pull_request when known.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Comment in Markdown.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "replyToCommentId": { + "description": "Comment id to reply to.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "threadId": { + "description": "Thread or discussion id of that comment.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "body" + ], + "type": "object" + }, + "name": "comment_pull_request", + "timeoutSeconds": 58, + "title": "Comment On Pull Request" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Edit Pull Request Comment" + }, + "description": "Replace the text of one of your comments. Use the id, source, and threadId the comment has in get_pull_request.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "New comment text in Markdown.", + "maxLength": 65536, + "minLength": 1, + "type": "string" + }, + "commentId": { + "description": "Comment id.", + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "source": { + "description": "Where the comment lives.", + "enum": [ + "conversation", + "reviewSummary", + "reviewThread" + ], + "type": "string" + }, + "threadId": { + "description": "Thread or discussion id (GitLab and Azure DevOps).", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "commentId", + "source", + "body" + ], + "type": "object" + }, + "name": "edit_pull_request_comment", + "timeoutSeconds": 58, + "title": "Edit Pull Request Comment" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Set Pull Request Draft" + }, + "description": "Mark the pull request as a draft (draft true) or ready for review (draft false).", + "inputSchema": { + "additionalProperties": false, + "properties": { + "draft": { + "description": "true for draft, false for ready for review.", + "type": "boolean" + }, + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "draft" + ], + "type": "object" + }, + "name": "set_pull_request_draft", + "timeoutSeconds": 58, + "title": "Set Pull Request Draft" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Close Pull Request" + }, + "description": "Close the pull request without merging it (abandon on Azure DevOps).", + "inputSchema": { + "additionalProperties": false, + "properties": { + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "close_pull_request", + "timeoutSeconds": 58, + "title": "Close Pull Request" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Merge Pull Request" + }, + "description": "Merge the pull request. Methods per forge: GitHub mergeCommit, squash, or rebase as the repository allows; GitLab providerDefault (the project's merge setting) or squash; Azure DevOps mergeCommit (no fast-forward) or squash. Without method the forge's preferred allowed method is used. Pass expectedHeadSha to merge only if nobody pushed since you checked.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "expectedHeadSha": { + "description": "Merge only while the head commit is this SHA.", + "minLength": 1, + "type": "string" + }, + "method": { + "description": "Merge method from mergeMethods in get_pull_request.", + "enum": [ + "mergeCommit", + "squash", + "rebase", + "providerDefault" + ], + "type": "string" + }, + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "merge_pull_request", + "timeoutSeconds": 58, + "title": "Merge Pull Request" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Ship Changes" + }, + "description": "Ship the workspace like the Ship Changes button: stage (all changes or only staged ones), commit with an AI Assist message, move work off a shared base branch to a ship/ branch, push, and open a pull request with AI Assist details on GitHub, GitLab, or Azure DevOps. With followUpWatch it then starts Watch and Fix (mode fix) or Watch, Fix and Merge (mode fixAndMerge) on the new pull request with the given agent. Needs AI Assist. If the call times out the ship keeps running; read get_pull_request for the result.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "baseBranch": { + "description": "Base branch for the pull request, such as main.", + "minLength": 1, + "type": "string" + }, + "draft": { + "description": "Open the pull request as a draft.", + "type": "boolean" + }, + "followUpWatch": { + "additionalProperties": false, + "description": "Watch the new pull request afterwards.", + "properties": { + "checks": { + "description": "Watch failing checks (default true).", + "type": "boolean" + }, + "comments": { + "description": "Watch unresolved review threads (default true).", + "type": "boolean" + }, + "conflicts": { + "description": "Watch merge conflicts (default true).", + "type": "boolean" + }, + "handle": { + "description": "Running agent terminal handle to send fixes to.", + "minLength": 1, + "type": "string" + }, + "mode": { + "description": "fix sends problems to the agent; fixAndMerge also merges once clear.", + "enum": [ + "fix", + "fixAndMerge" + ], + "type": "string" + }, + "profile": { + "description": "Agent profile id or name for a new tab when no terminal is running.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "scope": { + "description": "all (default) stages every change; staged ships only staged changes.", + "enum": [ + "all", + "staged" + ], + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "baseBranch" + ], + "type": "object" + }, + "name": "ship_changes", + "timeoutSeconds": 58, + "title": "Ship Changes" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Restack Pull Request" + }, + "description": "Ask an agent to rewrite the workspace's committed and uncommitted changes into logical, easy-to-review commits without pushing, like the Restack button. Send it to a running agent (handle) or open a tab from a profile; with preview true only the prompt is returned.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "handle": { + "description": "Running agent terminal handle to send the prompt to.", + "minLength": 1, + "type": "string" + }, + "preview": { + "description": "Only return the prompt; send nothing.", + "type": "boolean" + }, + "profile": { + "description": "Agent profile id or name to open a new tab with when no terminal is given or running.", + "minLength": 1, + "type": "string" + }, + "tabId": { + "description": "Terminal tab to send the prompt to.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "restack_pull_request", + "timeoutSeconds": 58, + "title": "Restack Pull Request" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Fix Pull Request Checks" + }, + "description": "Ask an agent to fix the failed checks of the workspace's pull request, like the Fix Failed Checks button. Send it to a running agent (handle) or open a tab from a profile; with preview true only the prompt is returned.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "handle": { + "description": "Running agent terminal handle to send the prompt to.", + "minLength": 1, + "type": "string" + }, + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "preview": { + "description": "Only return the prompt; send nothing.", + "type": "boolean" + }, + "profile": { + "description": "Agent profile id or name to open a new tab with when no terminal is given or running.", + "minLength": 1, + "type": "string" + }, + "tabId": { + "description": "Terminal tab to send the prompt to.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "fix_pull_request_checks", + "timeoutSeconds": 58, + "title": "Fix Pull Request Checks" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Show Pull Request Watch" + }, + "description": "Show the active Watch and Fix session of a workspace: the pull request, mode, watched problems, and agent. Returns watch null when nothing is watched.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "show_pull_request_watch", + "timeoutSeconds": 30, + "title": "Show Pull Request Watch" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Start Pull Request Watch" + }, + "description": "Start Watch and Fix on the workspace's pull request (GitHub, GitLab, or Azure DevOps). The runtime sends failing checks, merge conflicts, and unresolved review threads to the agent; mode fixAndMerge also merges once checks pass and nothing is open. Replaces the workspace's current watch.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "checks": { + "description": "Watch failing checks (default true).", + "type": "boolean" + }, + "comments": { + "description": "Watch unresolved review threads (default true).", + "type": "boolean" + }, + "conflicts": { + "description": "Watch merge conflicts (default true).", + "type": "boolean" + }, + "handle": { + "description": "Running agent terminal handle.", + "minLength": 1, + "type": "string" + }, + "mode": { + "description": "fix (default) or fixAndMerge.", + "enum": [ + "fix", + "fixAndMerge" + ], + "type": "string" + }, + "profile": { + "description": "Agent profile id or name used when the terminal is gone.", + "minLength": 1, + "type": "string" + }, + "reviewNumber": { + "description": "Pull request number. Defaults to the linked pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "start_pull_request_watch", + "timeoutSeconds": 30, + "title": "Start Pull Request Watch" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Stop Pull Request Watch" + }, + "description": "Stop Watch and Fix for a workspace. A merge the forge already accepted is not undone.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "stop_pull_request_watch", + "timeoutSeconds": 30, + "title": "Stop Pull Request Watch" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Get Pull Request Stack" + }, + "description": "Show the GitHub stack that holds the workspace's pull request, bottom layer first, and whether the gh-stack extension is installed. Stacks exist only on GitHub.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "get_pull_request_stack", + "timeoutSeconds": 58, + "title": "Get Pull Request Stack" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Pull Request Stack" + }, + "description": "Build a GitHub stack from local workspaces, bottom to top: push each branch, open the pull requests they lack (each targeting the layer below), link them to their workspaces, and stack them with gh stack. The current workspace must be one of the layers of a new stack; with an existing stack the layers are appended. Needs the gh-stack extension.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "baseBranch": { + "description": "Base branch of a new stack, such as main.", + "minLength": 1, + "type": "string" + }, + "draft": { + "description": "Open new pull requests as drafts.", + "type": "boolean" + }, + "titles": { + "description": "Title for each layer that needs a new pull request, in layer order.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + }, + "workspaceIds": { + "description": "Workspace of each layer, bottom to top.", + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "workspaceId", + "workspaceIds" + ], + "type": "object" + }, + "name": "create_pull_request_stack", + "timeoutSeconds": 58, + "title": "Create Pull Request Stack" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Link Pull Request Stack" + }, + "description": "Stack existing GitHub pull requests, bottom to top, or append them to the stack of the workspace's pull request. A new stack needs at least two and must include the workspace's pull request; every branch must descend from the one below. Needs the gh-stack extension.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "numbers": { + "description": "Pull request numbers, bottom to top, separated by commas, such as 12,13.", + "minLength": 1, + "type": "string" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId", + "numbers" + ], + "type": "object" + }, + "name": "link_pull_request_stack", + "timeoutSeconds": 58, + "title": "Link Pull Request Stack" + }, + { + "access": "execute", + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Merge Pull Request Stack" + }, + "description": "Merge the GitHub stack atomically through the workspace's pull request: every layer at or below it merges. Every affected layer must be open and ready. Without method the first allowed one is used.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "method": { + "description": "Merge method.", + "enum": [ + "mergeCommit", + "squash", + "rebase" + ], + "type": "string" + }, + "number": { + "description": "Pull request number. Defaults to the workspace's linked or detected pull request.", + "maximum": 2147483648, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Workspace id.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspaceId" + ], + "type": "object" + }, + "name": "merge_pull_request_stack", + "timeoutSeconds": 58, + "title": "Merge Pull Request Stack" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "List Events" + }, + "description": "List runtime events after a cursor, oldest first: inbox replies, question states, agent states, terminal exits, task and run changes, decision gates, automation runs, workspace starts and lifecycle, and pull request watch actions. Events carry ids and states; read details with the matching tool. Pass the returned cursor as after next time. truncated means older events were pruned.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "after": { + "description": "Cursor from a previous result; omit to read from the oldest retained event.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "kinds": { + "description": "Only these event kinds.", + "items": { + "enum": [ + "inbox.reply", + "inbox.question.status", + "agent.status", + "terminal.exit", + "orchestration.task.state", + "orchestration.gate.created", + "orchestration.escalation", + "automation.run.state", + "workspace.start.state", + "workspace.lifecycle", + "pullRequest.watch" + ], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "limit": { + "description": "Maximum events (default 100).", + "maximum": 500, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Only events of this workspace.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "list_events", + "timeoutSeconds": 30, + "title": "List Events" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Wait For Events" + }, + "description": "Wait up to timeoutSeconds for runtime events after a cursor, then return them with the next cursor. Use it instead of polling each question or task: one wait covers every kind you filter for. Call again with the returned cursor to keep following.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "after": { + "description": "Cursor from a previous result; omit to read from the oldest retained event.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "kinds": { + "description": "Only these event kinds.", + "items": { + "enum": [ + "inbox.reply", + "inbox.question.status", + "agent.status", + "terminal.exit", + "orchestration.task.state", + "orchestration.gate.created", + "orchestration.escalation", + "automation.run.state", + "workspace.start.state", + "workspace.lifecycle", + "pullRequest.watch" + ], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "limit": { + "description": "Maximum events (default 100).", + "maximum": 500, + "minimum": 1, + "type": "integer" + }, + "timeoutSeconds": { + "description": "Seconds to wait before returning the current state. Call again to keep waiting.", + "maximum": 50, + "minimum": 1, + "type": "integer" + }, + "workspaceId": { + "description": "Only events of this workspace.", + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "name": "wait_for_events", + "timeoutSeconds": 58, + "title": "Wait For Events" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "List Webhooks" + }, + "description": "List the signed webhooks that receive runtime events through the Alera cloud for this account, with their URLs, kinds, status, and last delivery.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "list_webhooks", + "timeoutSeconds": 30, + "title": "List Webhooks" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Create Webhook" + }, + "description": "Add an HTTPS webhook that receives this runtime's events as signed POST requests (Standard Webhooks). Events carry ids and states only. Returns the signing secret once. Needs a signed-in Alera account.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "kinds": { + "description": "Event kinds to send (default every kind).", + "items": { + "enum": [ + "inbox.reply", + "inbox.question.status", + "agent.status", + "terminal.exit", + "orchestration.task.state", + "orchestration.gate.created", + "orchestration.escalation", + "automation.run.state", + "workspace.start.state", + "workspace.lifecycle", + "pullRequest.watch" + ], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "url": { + "description": "Public HTTPS endpoint. Private and loopback addresses are refused.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "name": "create_webhook", + "timeoutSeconds": 30, + "title": "Create Webhook" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": true, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Delete Webhook" + }, + "description": "Delete a webhook so it receives no more events.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "webhookId": { + "description": "Webhook id from list_webhooks.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "webhookId" + ], + "type": "object" + }, + "name": "delete_webhook", + "timeoutSeconds": 30, + "title": "Delete Webhook" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Test Webhook" + }, + "description": "Send a signed test delivery to a webhook.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "webhookId": { + "description": "Webhook id from list_webhooks.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "webhookId" + ], + "type": "object" + }, + "name": "test_webhook", + "timeoutSeconds": 30, + "title": "Test Webhook" + }, + { + "access": "read", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + "title": "Check Agent Skills" + }, + "description": "Show whether the Alera skills that coding agents use in Alera terminals (alera-cli, alera-orchestration, alera-automations, alera-agent-profiles) are installed on this runtime's machine and whether each matches this runtime's version, plus the running install (install.running) and the last finished one (install.last).", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "check_agent_skills", + "timeoutSeconds": 30, + "title": "Check Agent Skills" + }, + { + "access": "admin", + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": false, + "title": "Install Agent Skills" + }, + "description": "Install or update the Alera skills that coding agents use, at this runtime's own version, through the skills installer (npx or bunx) on its machine. Installs every skill unless skills is given. The runtime runs the install as a job and this call waits up to 45 seconds: state completed or failed carries the outcome and the new status; state running means it is still installing, so read check_agent_skills later instead of calling again. Only one install runs at a time.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "runner": { + "description": "Package runner: auto (default) tries npx, then bunx.", + "enum": [ + "auto", + "npx", + "bunx" + ], + "type": "string" + }, + "skills": { + "description": "Skills to install (default all).", + "items": { + "enum": [ + "cli", + "orchestration", + "automations", + "agent-profiles" + ], + "type": "string" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + } + }, + "type": "object" + }, + "name": "install_agent_skills", + "timeoutSeconds": 58, + "title": "Install Agent Skills" } ], - "version": 1 + "version": 2 } diff --git a/edge/src/mcp/tools.ts b/edge/src/mcp/tools.ts index 48061bd38..a5603cbb0 100644 --- a/edge/src/mcp/tools.ts +++ b/edge/src/mcp/tools.ts @@ -1,7 +1,12 @@ import catalog from './tool_catalog.json'; import { isJsonObject } from './protocol'; +import { isSkillTool, SKILL_TOOL_DEFINITIONS, withSkillsReminder } from './skills'; -export type ToolAccess = 'read' | 'execute'; +export type ToolAccess = 'read' | 'execute' | 'admin'; + +/** Catalog versions this edge understands. Version 2 adds the admin access class. */ +const CATALOG_VERSIONS: readonly unknown[] = [1, 2]; +const TOOL_ACCESS: readonly unknown[] = ['read', 'execute', 'admin']; export interface CatalogTool { name: string; @@ -28,7 +33,7 @@ function validCatalogTool(value: unknown): value is CatalogTool { value.name.length > 0 && typeof value.title === 'string' && typeof value.description === 'string' && - (value.access === 'read' || value.access === 'execute') && + TOOL_ACCESS.includes(value.access) && Number.isInteger(value.timeoutSeconds) && (value.timeoutSeconds as number) > 0 && isJsonObject(value.inputSchema) && @@ -37,12 +42,12 @@ function validCatalogTool(value: unknown): value is CatalogTool { } export function loadCatalog(source: unknown): Map { - if (!isJsonObject(source) || source.version !== 1 || !Array.isArray(source.tools)) { + if (!isJsonObject(source) || !CATALOG_VERSIONS.includes(source.version) || !Array.isArray(source.tools)) { throw new Error('Invalid MCP tool catalog'); } const tools = new Map(); for (const tool of source.tools) { - if (!validCatalogTool(tool) || tool.name === LIST_RUNTIMES_TOOL || tools.has(tool.name)) { + if (!validCatalogTool(tool) || tool.name === LIST_RUNTIMES_TOOL || isSkillTool(tool.name) || tools.has(tool.name)) { throw new Error('Invalid MCP tool catalog entry'); } tools.set(tool.name, tool); @@ -70,8 +75,9 @@ function withRuntimeArgument(schema: Record): Record = RUNTIME_TOOLS): Array> { return [ LIST_RUNTIMES_DEFINITION, + ...SKILL_TOOL_DEFINITIONS, ...Array.from(tools.values(), (tool) => ({ name: tool.name, title: tool.title, - description: tool.description, + description: withSkillsReminder(tool.description), inputSchema: withRuntimeArgument(tool.inputSchema), annotations: tool.annotations, })), diff --git a/edge/src/relay_authorization.ts b/edge/src/relay_authorization.ts index 68632f9ea..724c59a91 100644 --- a/edge/src/relay_authorization.ts +++ b/edge/src/relay_authorization.ts @@ -15,7 +15,7 @@ export interface RelayClaims { keyVersion: number; clientPublicKey: string; runtimePublicKey: string; - mcpAccess?: 'off' | 'read' | 'full'; + mcpAccess?: 'off' | 'read' | 'full' | 'admin'; mobileAccess?: boolean; } @@ -258,7 +258,7 @@ export async function verifyRelayGrant( claims.keyVersion <= 0 || publicKeyBytes.byteLength !== 32 || runtimeKeyBytes.byteLength !== 32 || - (claims.mcpAccess !== undefined && !['off', 'read', 'full'].includes(claims.mcpAccess)) || + (claims.mcpAccess !== undefined && !['off', 'read', 'full', 'admin'].includes(claims.mcpAccess)) || (claims.mobileAccess !== undefined && typeof claims.mobileAccess !== 'boolean') ) { return null; diff --git a/edge/test/mcp_admin.test.ts b/edge/test/mcp_admin.test.ts new file mode 100644 index 000000000..f5cef0c3a --- /dev/null +++ b/edge/test/mcp_admin.test.ts @@ -0,0 +1,62 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { MCP_SCOPES } from '../src/mcp/protocol'; +import { loadCatalog, RUNTIME_TOOLS, type CatalogTool } from '../src/mcp/tools'; +import { accessToken, mcpHarness } from './mcp_fixture'; + +const ADMIN_TOOL: CatalogTool = { + name: 'test_admin_tool', + title: 'Test Admin Tool', + description: 'An administrative tool used only by these tests.', + access: 'admin', + timeoutSeconds: 30, + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: false, destructiveHint: true }, +}; + +describe('MCP admin access', () => { + beforeAll(() => { + RUNTIME_TOOLS.set(ADMIN_TOOL.name, ADMIN_TOOL); + }); + + afterAll(() => { + RUNTIME_TOOLS.delete(ADMIN_TOOL.name); + }); + + test('the challenge advertises the admin scope', () => { + expect(MCP_SCOPES).toBe('mcp:read mcp:execute mcp:admin'); + }); + + test('catalog version 2 with admin tools loads and unknown classes are rejected', () => { + const tools = loadCatalog({ version: 2, tools: [ADMIN_TOOL] }); + expect(tools.get(ADMIN_TOOL.name)?.access).toBe('admin'); + expect(loadCatalog({ version: 1, tools: [] }).size).toBe(0); + expect(() => loadCatalog({ version: 2, tools: [{ ...ADMIN_TOOL, access: 'owner' }] })).toThrow(); + }); + + test('an admin tool without mcp:admin is a tool error that names the scope', async () => { + const harness = await mcpHarness(); + for (const scope of ['mcp:read', 'mcp:read mcp:execute']) { + const token = await accessToken(harness.privateKey, { scope }); + const { result } = await harness.rpc('tools/call', { name: ADMIN_TOOL.name }, token); + expect(result.isError).toBeTrue(); + expect(result.content[0].text).toBe( + 'insufficient_scope: test_admin_tool needs the mcp:admin scope, but this connection was not granted administrative tools. Reconnect Alera and allow administrative tools.', + ); + } + expect(harness.originRequests).toBeEmpty(); + expect(harness.relayCalls).toBeEmpty(); + }); + + test('an admin tool with mcp:admin asks the gateway for an admin call and reaches the runtime', async () => { + const harness = await mcpHarness(); + const token = await accessToken(harness.privateKey, { scope: 'mcp:read mcp:execute mcp:admin' }); + const { result } = await harness.rpc( + 'tools/call', + { name: ADMIN_TOOL.name, arguments: { runtime: 'laptop' } }, + token, + ); + expect(result.isError).toBeFalse(); + expect(harness.originBodies[0]).toEqual({ runtime: 'laptop', tool: ADMIN_TOOL.name, access: 'admin' }); + expect(harness.relayCalls.map(({ body }) => body.tool)).toEqual([ADMIN_TOOL.name]); + }); +}); diff --git a/edge/test/mcp_endpoint.test.ts b/edge/test/mcp_endpoint.test.ts index 400fe138d..aa9fd3b24 100644 --- a/edge/test/mcp_endpoint.test.ts +++ b/edge/test/mcp_endpoint.test.ts @@ -12,7 +12,7 @@ describe('MCP authorization', () => { const response = await harness.send({ jsonrpc: '2.0', id: 1, method: 'ping' }, { token: null }); expect(response.status).toBe(401); expect(response.headers.get('www-authenticate')).toBe( - `Bearer resource_metadata="${METADATA}", scope="mcp:read mcp:execute"`, + `Bearer resource_metadata="${METADATA}", scope="mcp:read mcp:execute mcp:admin"`, ); expect(response.headers.get('access-control-allow-origin')).toBe('*'); expect(response.headers.get('access-control-expose-headers')).toContain('www-authenticate'); @@ -133,7 +133,7 @@ describe('MCP transport', () => { expect(preflight.status).toBe(204); expect(preflight.headers.get('access-control-allow-origin')).toBe('*'); expect(preflight.headers.get('access-control-allow-headers')).toBe( - 'authorization, content-type, mcp-protocol-version, mcp-session-id', + 'authorization, content-type, mcp-protocol-version, mcp-session-id, mcp-method, mcp-name', ); expect(preflight.headers.get('access-control-expose-headers')).toBe('www-authenticate, mcp-session-id'); const disabled = await handleRequest(new Request(MCP_URL, { method: 'POST' }), { @@ -147,7 +147,7 @@ describe('MCP transport', () => { describe('MCP tools', () => { test('the bundled catalog loads and invalid catalogs are rejected', () => { expect(RUNTIME_TOOLS.size).toBeGreaterThan(0); - expect(() => loadCatalog({ version: 2, tools: [] })).toThrow(); + expect(() => loadCatalog({ version: 3, tools: [] })).toThrow(); expect(() => loadCatalog({ version: 1, tools: [{ name: 'x' }] })).toThrow(); }); @@ -156,7 +156,8 @@ describe('MCP tools', () => { const { result } = await harness.rpc('tools/list'); const names = result.tools.map((tool: { name: string }) => tool.name); expect(names[0]).toBe('list_runtimes'); - for (const tool of result.tools.slice(1)) { + // list_skills and read_skill follow it and answer without a runtime. + for (const tool of result.tools.slice(3)) { expect(Object.keys(tool).sort()).toEqual(['annotations', 'description', 'inputSchema', 'name', 'title']); expect(tool.inputSchema.properties.runtime.type).toBe('string'); expect(tool.inputSchema.required ?? []).not.toContain('runtime'); diff --git a/edge/test/mcp_events.test.ts b/edge/test/mcp_events.test.ts new file mode 100644 index 000000000..9c4d32129 --- /dev/null +++ b/edge/test/mcp_events.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from 'bun:test'; +import { handleRequest, pumpEventDeliveries } from '../src/index'; +import { EVENT_DEFINITIONS } from '../src/mcp/events'; +import { defaultOrigin, json, mcpHarness, ORIGIN } from './mcp_fixture'; +import { environment } from './relay_fixture'; + +const MODERN = '2026-07-28'; +const META = { 'io.modelcontextprotocol/protocolVersion': MODERN }; +const SECRET = `whsec_${btoa(String.fromCharCode(...new Uint8Array(32).fill(7)))}`; +const ENABLED = { MCP_EVENTS_ENABLED: 'true' }; + +function modern(method: string, params: Record = {}, id: number | string = 9) { + return { jsonrpc: '2.0', id, method, params: { ...params, _meta: META } }; +} + +const MODERN_HEADERS = { 'mcp-protocol-version': MODERN }; + +function subscribeParams(overrides: Record = {}) { + return { + name: 'inbox.reply', + arguments: { threadId: 'thread-1', runtime: 'laptop' }, + delivery: { mode: 'webhook', url: 'https://receiver.example/cb', secret: SECRET }, + cursor: null, + ...overrides, + }; +} + +describe('MCP 2026-07-28 negotiation', () => { + test('server/discover lists every version and hides events while the switch is off', async () => { + const harness = await mcpHarness(); + const response = await harness.send(modern('server/discover'), { headers: MODERN_HEADERS }); + expect(response.status).toBe(200); + const { result } = (await response.json()) as Record; + expect(result.resultType).toBe('complete'); + expect(result.supportedVersions).toEqual([MODERN, '2025-11-25', '2025-06-18', '2025-03-26']); + expect(result.capabilities).toEqual({ tools: { listChanged: false } }); + expect(result.serverInfo).toMatchObject({ name: 'alera', title: 'Alera' }); + expect(result._meta['io.modelcontextprotocol/serverInfo'].name).toBe('alera'); + expect(result.instructions).toContain('list_runtimes'); + const enabled = await mcpHarness({ env: ENABLED }); + const discovered = await enabled.rpc('server/discover'); + expect(discovered.result.capabilities).toEqual({ tools: { listChanged: false }, events: {} }); + }); + + test('modern results carry resultType while legacy results keep their shape', async () => { + const harness = await mcpHarness(); + const ping = (await (await harness.send(modern('ping'), { headers: MODERN_HEADERS })).json()) as any; + expect(ping.result).toEqual({ resultType: 'complete' }); + const tools = (await (await harness.send(modern('tools/list'))).json()) as any; + expect(tools.result.resultType).toBe('complete'); + expect(tools.result.tools.length).toBeGreaterThan(1); + expect((await harness.rpc('ping')).result).toEqual({}); + expect((await harness.rpc('tools/list')).result.resultType).toBeUndefined(); + const legacyInitialize = await harness.rpc('initialize', { protocolVersion: MODERN }); + expect(legacyInitialize.result.protocolVersion).toBe('2025-11-25'); + }); + + test('header and body disagreements are rejected with HeaderMismatch', async () => { + const harness = await mcpHarness(); + const cases: Array<[unknown, Record]> = [ + [modern('ping'), { 'mcp-protocol-version': '2025-11-25' }], + [modern('ping'), { ...MODERN_HEADERS, 'mcp-method': 'tools/list' }], + [modern('tools/call', { name: 'list_runtimes' }), { ...MODERN_HEADERS, 'mcp-name': 'other_tool' }], + ]; + for (const [body, headers] of cases) { + const response = await harness.send(body, { headers }); + expect(response.status).toBe(400); + expect(((await response.json()) as any).error.code).toBe(-32020); + } + const encoded = `=?base64?${btoa('list_runtimes')}?=`; + const call = await harness.send(modern('tools/call', { name: 'list_runtimes' }), { + headers: { ...MODERN_HEADERS, 'mcp-method': 'tools/call', 'mcp-name': encoded }, + }); + const called = (await call.json()) as any; + expect(called.result.resultType).toBe('complete'); + expect(called.result.structuredContent.runtimes[0].name).toBe('laptop'); + }); + + test('an unknown version gets UnsupportedProtocolVersionError and unknown modern methods 404', async () => { + const harness = await mcpHarness(); + const response = await harness.send( + { jsonrpc: '2.0', id: 1, method: 'ping', params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '1900-01-01' } } }, + ); + expect(response.status).toBe(400); + const { error } = (await response.json()) as any; + expect(error.code).toBe(-32022); + expect(error.data).toEqual({ supported: [MODERN, '2025-11-25', '2025-06-18', '2025-03-26'], requested: '1900-01-01' }); + const unknown = await harness.send(modern('resources/list'), { headers: MODERN_HEADERS }); + expect(unknown.status).toBe(404); + expect(((await unknown.json()) as any).error.code).toBe(-32601); + expect((await harness.send({ jsonrpc: '2.0', id: 1, method: 'resources/list' })).status).toBe(200); + }); +}); + +describe('MCP Events', () => { + test('events methods do not exist while the switch is off', async () => { + const harness = await mcpHarness(); + for (const method of ['events/list', 'events/subscribe', 'events/unsubscribe']) { + const legacy = await harness.rpc(method, subscribeParams()); + expect(legacy.error.code).toBe(-32601); + const response = await harness.send(modern(method, subscribeParams()), { headers: MODERN_HEADERS }); + expect(response.status).toBe(404); + } + expect(harness.originRequests).toBeEmpty(); + }); + + test('events/list serves the catalog with webhook delivery and schemas', async () => { + const harness = await mcpHarness({ env: ENABLED }); + const { result } = await harness.rpc('events/list'); + const names = result.events.map((event: { name: string }) => event.name); + expect(names).toEqual([ + 'inbox.reply', + 'inbox.question.status', + 'agent.status', + 'terminal.exit', + 'orchestration.task.state', + 'orchestration.gate.created', + 'orchestration.escalation', + 'automation.run.state', + 'workspace.start.state', + 'workspace.lifecycle', + 'pullRequest.watch', + ]); + for (const event of result.events) { + expect(event.delivery).toEqual(['webhook']); + expect(event.inputSchema.properties.runtime.type).toBe('string'); + expect(event.payloadSchema.required).toEqual(['runtimeId', 'seq']); + for (const key of Object.keys(event.payloadSchema.properties)) { + expect(key).not.toMatch(/prompt|body|text|output|command|subject/i); + } + } + expect(EVENT_DEFINITIONS.find((event) => event.name === 'inbox.reply')?.inputSchema.properties).toHaveProperty( + 'threadId', + ); + expect((await harness.rpc('events/list', { cursor: 'next' })).error.code).toBe(-32602); + expect(harness.originRequests).toBeEmpty(); + }); + + test('events/subscribe forwards a checked request to the cloud and returns its subscription', async () => { + const harness = await mcpHarness({ + env: ENABLED, + origin: (request, body) => + new URL(request.url).pathname === '/v1/mcp/event-subscriptions' + ? json({ id: 'sub_1', refreshBefore: '2026-10-11T12:00:00Z', cursor: 'cursor-1', truncated: false }) + : defaultOrigin(request, body), + }); + const { result } = await harness.rpc('events/subscribe', subscribeParams({ ttlMs: 3_600_000 })); + expect(result).toEqual({ id: 'sub_1', refreshBefore: '2026-10-11T12:00:00Z', cursor: 'cursor-1', truncated: false }); + const request = harness.originRequests[0]; + expect(request.url).toBe(`${ORIGIN}/v1/mcp/event-subscriptions`); + expect(request.headers.get('authorization')).toBe(`Bearer ${harness.token}`); + expect(request.headers.get('x-alera-origin-auth')).toBe('edge-secret'); + expect(harness.originBodies[0]).toEqual({ + name: 'inbox.reply', + arguments: { threadId: 'thread-1', runtime: 'laptop' }, + delivery: { mode: 'webhook', url: 'https://receiver.example/cb', secret: SECRET }, + cursor: null, + ttlMs: 3_600_000, + }); + const modernReply = (await ( + await harness.send(modern('events/subscribe', subscribeParams()), { headers: MODERN_HEADERS }) + ).json()) as any; + expect(modernReply.result).toMatchObject({ resultType: 'complete', id: 'sub_1' }); + }); + + test('invalid subscriptions are refused before reaching the cloud', async () => { + const harness = await mcpHarness({ env: ENABLED }); + const invalid = [ + subscribeParams({ name: 'unknown.event' }), + subscribeParams({ arguments: { prompt: 'x' } }), + subscribeParams({ arguments: { threadId: 7 } }), + subscribeParams({ delivery: { mode: 'sse', url: 'https://receiver.example/cb', secret: SECRET } }), + subscribeParams({ delivery: { mode: 'webhook', url: 'https://receiver.example/cb' } }), + subscribeParams({ ttlMs: 1.5 }), + subscribeParams({ cursor: 12 }), + ]; + for (const params of invalid) { + expect((await harness.rpc('events/subscribe', params)).error.code).toBe(-32602); + } + expect(harness.originRequests).toBeEmpty(); + }); + + test('cloud refusals map to MCP errors', async () => { + const replies: Array<[Response, number, unknown]> = [ + [json({ error: { code: 'callback_endpoint_error', message: 'challenge_failed' } }, 422), -32015, { reason: 'challenge_failed' }], + [json({ error: { code: 'callback_endpoint_error', message: 'odd' } }, 422), -32015, { reason: 'verification_failed' }], + [json({ error: { code: 'runtime_not_found', message: 'No granted runtime.' } }, 404), -32602, undefined], + [json({ error: { code: 'mcp_events_disabled', message: 'off' } }, 404), -32601, undefined], + [json({ error: { code: 'webhooks_not_configured', message: 'no key' } }, 503), -32603, undefined], + ]; + for (const [reply, code, data] of replies) { + const harness = await mcpHarness({ env: ENABLED, origin: () => reply }); + const { error } = await harness.rpc('events/subscribe', subscribeParams()); + expect(error.code).toBe(code); + expect(error.data).toEqual(data); + } + const revoked = await mcpHarness({ env: ENABLED, origin: () => json({ error: { code: 'session_revoked' } }, 401) }); + const response = await revoked.send({ jsonrpc: '2.0', id: 1, method: 'events/subscribe', params: subscribeParams() }); + expect(response.status).toBe(401); + expect(response.headers.get('www-authenticate')).toContain('error="invalid_token"'); + }); + + test('events/unsubscribe forwards the identity without the secret', async () => { + const harness = await mcpHarness({ env: ENABLED, origin: () => new Response(null, { status: 204 }) }); + const params = { + name: 'inbox.reply', + arguments: { threadId: 'thread-1' }, + delivery: { mode: 'webhook', url: 'https://receiver.example/cb' }, + }; + expect((await harness.rpc('events/unsubscribe', params)).result).toEqual({}); + expect(new URL(harness.originRequests[0].url).pathname).toBe('/v1/mcp/event-subscriptions/unsubscribe'); + expect(harness.originBodies[0]).toEqual(params); + }); +}); + +describe('event routes at the edge', () => { + test('subscription and pump routes are never forwarded from the internet', async () => { + for (const path of [ + '/v1/mcp/event-subscriptions', + '/v1/mcp/event-subscriptions/unsubscribe', + '/v1/internal/event-deliveries/pump', + ]) { + let forwarded = false; + const response = await handleRequest( + new Request(`https://api.alera.build${path}`, { method: 'POST', body: '{}' }), + environment(), + async () => { + forwarded = true; + return new Response(null, { status: 204 }); + }, + ); + expect(response.status).toBe(404); + expect(forwarded).toBeFalse(); + } + }); + + test('the cron pump calls the origin only when enabled', async () => { + const requests: Request[] = []; + const fetchOrigin = async (request: Request) => { + requests.push(request); + return json({ fannedOut: 0, claimed: 0, delivered: 0 }); + }; + expect(await pumpEventDeliveries(environment(), fetchOrigin)).toBeFalse(); + expect(requests).toBeEmpty(); + expect(await pumpEventDeliveries({ ...environment(), EVENT_DELIVERY_PUMP: 'true' }, fetchOrigin)).toBeTrue(); + expect(requests[0].method).toBe('POST'); + expect(requests[0].url).toBe(`${ORIGIN}/v1/internal/event-deliveries/pump`); + expect(requests[0].headers.get('x-alera-origin-auth')).toBe('edge-secret'); + }); +}); diff --git a/edge/test/mcp_relay.test.ts b/edge/test/mcp_relay.test.ts index 1a418eea9..006354f1a 100644 --- a/edge/test/mcp_relay.test.ts +++ b/edge/test/mcp_relay.test.ts @@ -4,7 +4,7 @@ import { McpRelayCalls, mcpFrame } from '../src/mcp/relay_calls'; import { verifyRelayGrant } from '../src/relay_authorization'; import { base64Url, environment, relayAttachment, relayJwks, signedRelayGrant, TestSocket } from './relay_fixture'; -function mcpRuntime(mcpAccess?: 'off' | 'read' | 'full', expiresIn = 120) { +function mcpRuntime(mcpAccess?: 'off' | 'read' | 'full' | 'admin', expiresIn = 120) { return new TestSocket({ ...relayAttachment('runtime', 'runtime-1', expiresIn), mcpAccess }); } @@ -98,6 +98,20 @@ describe('MCP relay calls', () => { expect(runtime.closed).toBeNull(); }); + test('routes calls to a runtime socket with admin MCP Control', async () => { + const runtime = mcpRuntime('admin'); + const object = relay([runtime]); + const pending = object.fetch(callRequest({ tool: 'update_runtime_settings' })); + await Bun.sleep(0); + expect(sent(runtime).map(({ payload }) => payload.tool)).toEqual(['update_runtime_settings']); + const result = { content: [{ type: 'text', text: 'saved' }] }; + object.webSocketMessage( + runtime as unknown as WebSocket, + mcpFrame({ type: 'mcp.result', id: 'call-1', result }).buffer as ArrayBuffer, + ); + expect(await settled(pending)).toEqual({ ok: true, result }); + }); + for (const access of [undefined, 'off'] as const) { test(`refuses runtime sockets with mcpAccess ${access ?? 'absent'}`, async () => { const runtime = mcpRuntime(access); @@ -214,7 +228,9 @@ describe('MCP relay admission', () => { const claims = await verifyRelayGrant(valid.grant, env, relayJwks(valid.publicJwk)); expect(claims?.mcpAccess).toBe('read'); expect(claims?.mobileAccess).toBe(false); - for (const overrides of [{ mcpAccess: 'admin' }, { mobileAccess: 'no' }]) { + const admin = await signedRelayGrant(120, { mcpAccess: 'admin' } as never); + expect((await verifyRelayGrant(admin.grant, { ...env }, relayJwks(admin.publicJwk)))?.mcpAccess).toBe('admin'); + for (const overrides of [{ mcpAccess: 'owner' }, { mobileAccess: 'no' }]) { const invalid = await signedRelayGrant(120, overrides as never); expect(await verifyRelayGrant(invalid.grant, env, relayJwks(invalid.publicJwk))).toBeNull(); } diff --git a/edge/test/mcp_skills.test.ts b/edge/test/mcp_skills.test.ts new file mode 100644 index 000000000..007312ff0 --- /dev/null +++ b/edge/test/mcp_skills.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { RUNTIME_TOOLS } from '../src/mcp/tools'; +import { LIST_SKILLS_TOOL, READ_SKILL_TOOL, SKILLS, SKILLS_REMINDER } from '../src/mcp/skills'; +import { buildSkillCatalog, serializeSkillCatalog, SKILL_CATALOG_PATH } from '../tool/skill_catalog'; +import { mcpHarness } from './mcp_fixture'; + +const EDGE_TOOLS = ['list_runtimes', LIST_SKILLS_TOOL, READ_SKILL_TOOL]; +// Snake case words in the skills that are error codes, not tools. +const ERROR_CODES = new Set([ + 'not_found', + 'invalid_argument', + 'capability_missing', + 'provider_unavailable', + 'timeout_pending', + 'runtime_unavailable', + 'runtime_offline', + 'insufficient_scope', +]); + +const allTexts = SKILLS.flatMap((skill) => skill.files.map((file) => ({ skill, file }))); + +describe('MCP skill catalog', () => { + test('the generated catalog matches the skill sources', () => { + // Regenerate with `bun tool/skill_catalog.ts`. + expect(readFileSync(SKILL_CATALOG_PATH, 'utf8')).toBe(serializeSkillCatalog(buildSkillCatalog())); + }); + + test('every Alera tool is explained by some skill', () => { + const mentioned = new Set(allTexts.flatMap(({ file }) => [...file.text.matchAll(/`([a-z0-9_]+)`/g)].map((m) => m[1]))); + const missing = [...RUNTIME_TOOLS.keys(), 'list_runtimes'].filter((name) => !mentioned.has(name)); + expect(missing).toEqual([]); + }); + + test('skills name only tools that exist', () => { + const known = new Set([...RUNTIME_TOOLS.keys(), ...EDGE_TOOLS]); + const unknown = allTexts.flatMap(({ skill, file }) => + [...file.text.matchAll(/`([a-z][a-z0-9]*(?:_[a-z0-9]+)+)`/g)] + .map((match) => match[1]) + .filter((word) => !known.has(word) && !ERROR_CODES.has(word)) + .map((word) => `${skill.name}/${file.path}: ${word}`), + ); + expect(unknown).toEqual([]); + }); + + test('links and skill names resolve', () => { + const names = new Set(SKILLS.map((skill) => skill.name)); + const broken = allTexts.flatMap(({ skill, file }) => { + const paths = new Set(skill.files.map((candidate) => candidate.path)); + const folder = file.path.includes('/') ? file.path.slice(0, file.path.lastIndexOf('/') + 1) : ''; + const links = [...file.text.matchAll(/\]\(([^)]+)\)/g)] + .map((match) => match[1]) + .filter((target) => !paths.has(`${folder}${target}`)) + .map((target) => `${skill.name}/${file.path}: link ${target}`); + const skills = [...file.text.matchAll(/`(alera-mcp[a-z-]*)`/g)] + .map((match) => match[1]) + .filter((name) => !names.has(name)) + .map((name) => `${skill.name}/${file.path}: skill ${name}`); + return [...links, ...skills]; + }); + expect(broken).toEqual([]); + }); + + test('a skill without frontmatter or with a mismatched name is rejected', () => { + const { mkdtempSync, mkdirSync, writeFileSync } = require('node:fs'); + const { join } = require('node:path'); + const root = mkdtempSync(join(require('node:os').tmpdir(), 'skills-')); + mkdirSync(join(root, 'good-name')); + writeFileSync(join(root, 'good-name', 'SKILL.md'), '---\nname: other\ndescription: x\nmetadata:\n version: 1\n---\n'); + expect(() => buildSkillCatalog(root)).toThrow('name must match its folder'); + writeFileSync(join(root, 'good-name', 'SKILL.md'), '# No frontmatter\n'); + expect(() => buildSkillCatalog(root)).toThrow('frontmatter'); + writeFileSync(join(root, 'good-name', 'SKILL.md'), '---\nname: good-name\ndescription: x\n---\n'); + expect(() => buildSkillCatalog(root)).toThrow('metadata.version'); + }); +}); + +describe('MCP skill tools', () => { + test('initialize tells the client to read the skills first', async () => { + const harness = await mcpHarness(); + const { result } = await harness.rpc('initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'test', version: '1' }, + }); + expect(result.instructions).toContain('list_skills'); + expect(result.instructions).toContain('read_skill'); + }); + + test('every other tool reminds the model to read the skills', async () => { + const harness = await mcpHarness(); + const { result } = await harness.rpc('tools/list'); + const names = result.tools.map((tool: { name: string }) => tool.name); + expect(names.slice(0, 3)).toEqual(EDGE_TOOLS); + for (const tool of result.tools) { + if (tool.name === LIST_SKILLS_TOOL || tool.name === READ_SKILL_TOOL) { + expect(tool.description).not.toContain(SKILLS_REMINDER); + expect(tool.inputSchema.properties.runtime).toBeUndefined(); + expect(tool.annotations.readOnlyHint).toBe(true); + } else { + expect(tool.description.endsWith(` ${SKILLS_REMINDER}`)).toBe(true); + } + } + }); + + test('list_skills answers without reaching a runtime', async () => { + const harness = await mcpHarness(); + const { result } = await harness.rpc('tools/call', { name: LIST_SKILLS_TOOL, arguments: {} }); + expect(result.isError).toBeUndefined(); + const listed = result.structuredContent.skills; + expect(listed.map((skill: { name: string }) => skill.name)).toEqual(SKILLS.map((skill) => skill.name)); + expect(listed[0].files[0]).toBe('SKILL.md'); + expect(harness.originRequests).toHaveLength(0); + expect(harness.relayCalls).toHaveLength(0); + }); + + test('read_skill returns SKILL.md by default and a reference by file', async () => { + const harness = await mcpHarness(); + const entry = await harness.rpc('tools/call', { name: READ_SKILL_TOOL, arguments: { name: 'alera-mcp' } }); + expect(entry.result.structuredContent.file).toBe('SKILL.md'); + expect(entry.result.structuredContent.text).toContain('name: alera-mcp'); + expect(entry.result.structuredContent.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + const reference = await harness.rpc('tools/call', { + name: READ_SKILL_TOOL, + arguments: { name: 'alera-mcp', file: 'references/workspaces.md' }, + }); + expect(reference.result.structuredContent.text).toContain('# Projects And Workspaces'); + expect(harness.originRequests).toHaveLength(0); + }); + + test('read_skill refuses unknown skills, files, and arguments', async () => { + const harness = await mcpHarness(); + const call = async (args: Record) => + (await harness.rpc('tools/call', { name: READ_SKILL_TOOL, arguments: args })).result; + const missingSkill = await call({ name: 'nope' }); + expect(missingSkill.isError).toBe(true); + expect(missingSkill.content[0].text).toStartWith('not_found:'); + const escape = await call({ name: 'alera-mcp', file: '../alera-mcp-automations/SKILL.md' }); + expect(escape.content[0].text).toStartWith('not_found:'); + const extra = await call({ name: 'alera-mcp', runtime: 'laptop' }); + expect(extra.content[0].text).toStartWith('invalid_argument:'); + }); +}); diff --git a/edge/test/relay_fixture.ts b/edge/test/relay_fixture.ts index 4e573b73d..7635d3e85 100644 --- a/edge/test/relay_fixture.ts +++ b/edge/test/relay_fixture.ts @@ -100,7 +100,7 @@ export class TestSocket { controlProtocol?: boolean; connectionId?: string; awaitingRuntime?: boolean; - mcpAccess?: 'off' | 'read' | 'full'; + mcpAccess?: 'off' | 'read' | 'full' | 'admin'; mobileAccess?: boolean; }, ) {} diff --git a/edge/tool/skill_catalog.ts b/edge/tool/skill_catalog.ts new file mode 100644 index 000000000..c06673ed3 --- /dev/null +++ b/edge/tool/skill_catalog.ts @@ -0,0 +1,103 @@ +// Builds src/mcp/skill_catalog.json from the skills in edge/skills. +// Run `bun tool/skill_catalog.ts` after editing a skill; a test fails while +// the generated copy is stale. + +import { createHash } from 'node:crypto'; +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +export const SKILLS_DIRECTORY = join(import.meta.dir, '..', 'skills'); +export const SKILL_CATALOG_PATH = join(import.meta.dir, '..', 'src', 'mcp', 'skill_catalog.json'); + +const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const FILE = /^(SKILL\.md|references\/[a-z0-9-]+\.md)$/; +const MAX_FILE_BYTES = 64 * 1024; + +export interface SkillFile { + path: string; + digest: string; + text: string; +} + +export interface Skill { + name: string; + description: string; + version: number; + digest: string; + files: SkillFile[]; +} + +export interface SkillCatalog { + version: 1; + skills: Skill[]; +} + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function filesUnder(directory: string): string[] { + return readdirSync(directory) + .sort() + .flatMap((entry) => { + const path = join(directory, entry); + return statSync(path).isDirectory() ? filesUnder(path) : [path]; + }); +} + +/** Reads `name`, `description`, and `metadata.version` from the frontmatter. */ +function frontmatter(text: string, where: string): { name: string; description: string; version: number } { + const match = /^---\n([\s\S]*?)\n---\n/.exec(text); + if (!match) throw new Error(`${where}: SKILL.md must start with frontmatter`); + const fields = new Map(); + let section = ''; + for (const line of match[1].split('\n')) { + const field = /^(\s*)([A-Za-z-]+):\s*(.*)$/.exec(line); + if (!field) throw new Error(`${where}: unsupported frontmatter line: ${line}`); + const [, indent, key, value] = field; + if (!indent) section = value ? '' : key; + fields.set(indent ? `${section}.${key}` : key, value.trim()); + } + const name = fields.get('name') ?? ''; + const description = fields.get('description') ?? ''; + const version = Number(fields.get('metadata.version')); + if (!NAME.test(name) || name.length > 64) throw new Error(`${where}: invalid name ${name}`); + if (!description || description.length > 1024) { + throw new Error(`${where}: description must have 1 to 1024 characters`); + } + if (!Number.isInteger(version) || version < 1) throw new Error(`${where}: metadata.version must be a positive integer`); + return { name, description, version }; +} + +export function buildSkillCatalog(directory: string = SKILLS_DIRECTORY): SkillCatalog { + const skills = readdirSync(directory) + .sort() + .filter((entry) => statSync(join(directory, entry)).isDirectory()) + .map((folder): Skill => { + const root = join(directory, folder); + const files = filesUnder(root).map((path): SkillFile => { + const relativePath = relative(root, path).split(sep).join('/'); + if (!FILE.test(relativePath)) throw new Error(`${folder}: unexpected file ${relativePath}`); + const text = readFileSync(path, 'utf8').replace(/\r\n/g, '\n'); + if (Buffer.byteLength(text) > MAX_FILE_BYTES) throw new Error(`${folder}: ${relativePath} is too large`); + return { path: relativePath, digest: sha256(text), text }; + }); + // SKILL.md first, references after it in name order. + files.sort((a, b) => (a.path === 'SKILL.md' ? -1 : b.path === 'SKILL.md' ? 1 : a.path.localeCompare(b.path))); + const entry = files[0]; + if (entry?.path !== 'SKILL.md') throw new Error(`${folder}: SKILL.md is missing`); + const { name, description, version } = frontmatter(entry.text, folder); + if (name !== folder) throw new Error(`${folder}: name must match its folder`); + const digest = sha256(files.map((file) => `${file.path}\0${file.text}`).join('\0')); + return { name, description, version, digest, files }; + }); + return { version: 1, skills }; +} + +export function serializeSkillCatalog(catalog: SkillCatalog): string { + return `${JSON.stringify(catalog, null, 2)}\n`; +} + +if (import.meta.main) { + writeFileSync(SKILL_CATALOG_PATH, serializeSkillCatalog(buildSkillCatalog())); +} diff --git a/edge/wrangler.jsonc b/edge/wrangler.jsonc index f47fbea33..665260062 100644 --- a/edge/wrangler.jsonc +++ b/edge/wrangler.jsonc @@ -10,8 +10,13 @@ "zone_name": "alera.build" } ], + "triggers": { + "crons": ["* * * * *"] + }, "vars": { + "EVENT_DELIVERY_PUMP": "false", "MCP_ENABLED": "true", + "MCP_EVENTS_ENABLED": "false", "MCP_RESOURCE": "https://api.alera.build/v1/mcp", "RELAY_ENABLED": "true", "RELAY_ISSUER": "https://api.alera.build", diff --git a/lib/src/features/inbox/domain/inbox_models.dart b/lib/src/features/inbox/domain/inbox_models.dart index 8acfaada1..b0d3fa069 100644 --- a/lib/src/features/inbox/domain/inbox_models.dart +++ b/lib/src/features/inbox/domain/inbox_models.dart @@ -79,16 +79,21 @@ DateTime? parseInboxTimestamp(Object? value) { class const InboxOrigin({ required final String surface, final String? deviceName, + final String? clientName, }) { factory InboxOrigin.fromJson(Map json) => InboxOrigin( surface: _optionalString(json['surface']) ?? 'cli', deviceName: _optionalString(json['deviceName']), + clientName: + _optionalString(json['clientName']) ?? + _optionalString(json['clientId']), ); String get label => switch (surface) { 'desktop' => 'Alera desktop', 'mobile' => deviceName == null ? 'Alera mobile' : 'Alera mobile, $deviceName', + 'mcp' => clientName == null ? 'MCP client' : '$clientName (MCP)', _ => 'CLI', }; } diff --git a/lib/src/features/mcp_access/domain/mcp_access_settings.dart b/lib/src/features/mcp_access/domain/mcp_access_settings.dart index bab21d6af..2c82256e3 100644 --- a/lib/src/features/mcp_access/domain/mcp_access_settings.dart +++ b/lib/src/features/mcp_access/domain/mcp_access_settings.dart @@ -2,12 +2,14 @@ /// `docs/remote-mcp.md`). MCP clients connect here, not to the runtime. const String aleraRemoteMcpEndpoint = 'https://api.alera.build/v1/mcp'; -/// Per-runtime MCP Control level. `off` is the default and what an unknown -/// wire value falls back to, so a malformed payload never widens access. +/// Per-runtime MCP Control level, ordered `off` < `read` < `full` < `admin`. +/// `off` is the default and what an unknown wire value falls back to, so a +/// malformed payload never widens access. enum McpAccessLevel(final String wireName, final String label) { off('off', 'Off'), read('read', 'Read Only'), - full('full', 'Full Control'); + full('full', 'Full Control'), + admin('admin', 'Admin'); static McpAccessLevel fromWire(Object? value) { for (final level in values) { diff --git a/lib/src/features/mcp_access/domain/mcp_grant.dart b/lib/src/features/mcp_access/domain/mcp_grant.dart index bca982fc1..649cf50b4 100644 --- a/lib/src/features/mcp_access/domain/mcp_grant.dart +++ b/lib/src/features/mcp_access/domain/mcp_grant.dart @@ -1,3 +1,6 @@ +/// OAuth scope that unlocks administrative MCP tools. +const String mcpAdminScope = 'mcp:admin'; + /// An MCP client the user authorized through the consent page, as returned by /// `mcp.grants.list`. final class const McpGrant({ @@ -34,6 +37,9 @@ final class const McpGrant({ /// Whether the grant may run execute tools, not only read ones. bool get canExecute => scopes.contains('mcp:execute'); + + /// Whether the user allowed this app's administrative tools at consent. + bool get canAdmin => scopes.contains(mcpAdminScope); } String? _optionalString(Object? value) { diff --git a/lib/src/features/mcp_access/presentation/mcp_access_control_group.dart b/lib/src/features/mcp_access/presentation/mcp_access_control_group.dart index 87ef86bf4..427ff8005 100644 --- a/lib/src/features/mcp_access/presentation/mcp_access_control_group.dart +++ b/lib/src/features/mcp_access/presentation/mcp_access_control_group.dart @@ -9,7 +9,7 @@ import 'package:alera/src/design_system/layout/alera_settings_group.dart'; import 'package:alera/src/features/mcp_access/domain/mcp_access_settings.dart'; import 'package:flutter/material.dart'; -const double _kAccessControlWidth = 320; +const double _kAccessControlWidth = 400; const double _kEndpointControlWidth = 340; /// Access level, cloud link status and endpoint. Presentational: the pane owns @@ -165,7 +165,12 @@ String _accessDescription(McpAccessLevel access) { 'MCP clients can run read-only tools. Tools that change anything are ' 'refused.', .full => - 'MCP clients can run every Alera tool, including ones that start ' - 'agents and change workspaces.', + 'MCP clients can also run tools that start agents, change or delete ' + 'workspaces, merge pull requests, and run automations. ' + 'Administrative tools are refused.', + .admin => + 'Connected apps can also change agent profiles, runtime settings, ' + 'webhooks, and run internal maintenance. Each app must also be ' + 'allowed administrative tools when you connect it.', }; } diff --git a/lib/src/features/mcp_access/presentation/mcp_connected_apps_group.dart b/lib/src/features/mcp_access/presentation/mcp_connected_apps_group.dart index 84038ba7f..2f9513fbc 100644 --- a/lib/src/features/mcp_access/presentation/mcp_connected_apps_group.dart +++ b/lib/src/features/mcp_access/presentation/mcp_connected_apps_group.dart @@ -146,7 +146,14 @@ class const McpGrantListRow({ fontWeight: .w600, ), ), - for (final scope in grant.scopes) AleraChip(label: scope), + for (final scope in grant.scopes) + if (scope != mcpAdminScope) AleraChip(label: scope), + if (grant.canAdmin) + const AleraChip( + label: 'Admin', + leading: AleraIcons.secure, + tooltip: mcpAdminScope, + ), ], ), const SizedBox(height: AleraTokens.space4), diff --git a/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.dart b/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.dart index a5624c57a..6b38e5f36 100644 --- a/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.dart +++ b/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'package:alera/src/shared/git_hosting/domain/git_hosting_provider.dart'; - import 'package:alera/src/design_system/feedback/alera_toast.dart'; import 'package:alera/src/features/agent_task_dispatch/application/agent_task_dispatch_providers.dart'; import 'package:alera/src/features/agent_task_dispatch/domain/agent_task_dispatch.dart'; @@ -92,14 +90,13 @@ class PullRequestAgentWatchController extends _$PullRequestAgentWatchController PullRequestAgentWatchDispatchMark? lastDispatch, }) async { final repository = ref.read(pullRequestAgentWatchRepositoryProvider); - if (await repository.supportsExecution() && - ref - .read(workspacePullRequestControllerProvider(scope)) - .asData - ?.value - .review - ?.provider == - GitHostingProvider.github) { + final panel = ref + .read(workspacePullRequestControllerProvider(scope)) + .asData + ?.value; + if (await repository.ownsExecutionFor( + panel?.review?.provider ?? panel?.identity?.provider, + )) { await repository.upsert( PullRequestAgentWatchRecord.fromSession( PullRequestAgentWatchSession( @@ -214,10 +211,9 @@ class PullRequestAgentWatchController extends _$PullRequestAgentWatchController ?.value) ?.identity ?.provider; - if (provider == GitHostingProvider.github && - await ref - .read(pullRequestAgentWatchRepositoryProvider) - .supportsExecution()) { + if (await ref + .read(pullRequestAgentWatchRepositoryProvider) + .ownsExecutionFor(provider)) { return; } final evaluation = evaluatePullRequestAgentWatch( diff --git a/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.g.dart b/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.g.dart index 2ed946ab2..0710336ae 100644 --- a/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.g.dart +++ b/lib/src/features/pull_requests/application/pull_request_agent_watch_providers.g.dart @@ -99,7 +99,7 @@ final class PullRequestAgentWatchControllerProvider } String _$pullRequestAgentWatchControllerHash() => - r'8d93f6e8ab77736cf7651c54097b733b5ab2d585'; + r'50a69c41a120514a46880e25578f8acb26282c9a'; abstract class _$PullRequestAgentWatchController extends $Notifier> { diff --git a/lib/src/features/pull_requests/infra/runtime_pull_request_watch_repository.dart b/lib/src/features/pull_requests/infra/runtime_pull_request_watch_repository.dart index a49bd8aac..783f37b1f 100644 --- a/lib/src/features/pull_requests/infra/runtime_pull_request_watch_repository.dart +++ b/lib/src/features/pull_requests/infra/runtime_pull_request_watch_repository.dart @@ -1,8 +1,17 @@ import 'package:alera/src/features/pull_requests/domain/pull_request_agent_watch.dart'; +import 'package:alera/src/shared/git_hosting/domain/git_hosting_provider.dart'; import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; import 'package:alera/src/shared/infra/runtime/runtime_change_coalescer.dart'; import 'package:alera/src/shared/infra/runtime/runtime_snapshot_stream.dart'; +/// Runtime capability for Watch and Fix execution on every forge the runtime +/// supports (GitHub, GitLab, and Azure DevOps). +const pullRequestWatchExecutionV2Capability = 'pullRequestWatchExecutionV2'; + +/// Runtime capability for `pullRequest.agentDispatch`, the single source of +/// the Restack and Fix Failed Checks prompts. +const pullRequestAgentDispatchCapability = 'pullRequestAgentDispatchV1'; + class RuntimePullRequestWatchRepository { RuntimePullRequestWatchRepository( this._client, { @@ -20,6 +29,60 @@ class RuntimePullRequestWatchRepository { ); } + /// Whether the runtime runs Watch and Fix for [provider], so this client + /// MUST NOT also evaluate, dispatch, or merge it. `pullRequestWatchExecutionV2` + /// covers GitHub, GitLab, and Azure DevOps; `pullRequestWatchExecutionV1` + /// covers GitHub only. + Future ownsExecutionFor(GitHostingProvider? provider) async { + final client = _client; + if (provider == null || client is! RuntimeHostCapabilityClient) { + return false; + } + final capabilities = client as RuntimeHostCapabilityClient; + if (await capabilities.supportsRuntimeCapability( + pullRequestWatchExecutionV2Capability, + )) { + return true; + } + return provider == GitHostingProvider.github && + await capabilities.supportsRuntimeCapability( + 'pullRequestWatchExecutionV1', + ); + } + + /// The Restack (`restack`) or Fix Failed Checks (`fixFailedChecks`) prompt + /// from the runtime, which owns their text when it advertises + /// `pullRequestAgentDispatchV1`. Null on an older runtime or a failed read, + /// so the caller falls back to its bundled prompt. + Future agentDispatchPrompt({ + required String workspaceId, + required String kind, + int? reviewNumber, + }) async { + final client = _client; + if (client is! RuntimeHostCapabilityClient || + !await (client as RuntimeHostCapabilityClient) + .supportsRuntimeCapability(pullRequestAgentDispatchCapability)) { + return null; + } + try { + final payload = _asMap( + await _client.runtimeRequest( + 'pullRequest.agentDispatch', + { + 'workspaceId': workspaceId, + 'kind': kind, + 'number': ?reviewNumber, + }, + ), + ); + final prompt = payload['prompt']; + return prompt is String && prompt.trim().isNotEmpty ? prompt : null; + } on Object { + return null; + } + } + Future isSupported() async { final client = _client; return client is RuntimeHostCapabilityClient && diff --git a/lib/src/features/pull_requests/presentation/pull_request_agent_dispatch.dart b/lib/src/features/pull_requests/presentation/pull_request_agent_dispatch.dart index 8fb31966e..e7474ce42 100644 --- a/lib/src/features/pull_requests/presentation/pull_request_agent_dispatch.dart +++ b/lib/src/features/pull_requests/presentation/pull_request_agent_dispatch.dart @@ -11,7 +11,6 @@ import 'package:alera/src/features/pull_requests/domain/pull_request_agent_watch import 'package:alera/src/features/pull_requests/domain/pull_request_agent_watch_scope.dart'; import 'package:alera/src/features/pull_requests/domain/pull_request_ship_follow_up.dart'; import 'package:alera/src/features/pull_requests/domain/workspace_pull_request_scope.dart'; -import 'package:alera/src/shared/git_hosting/domain/git_hosting_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -24,12 +23,22 @@ Future dispatchPullRequestFailedChecks({ required String workspaceId, required HostedReview review, }) async { + final prompt = + await ref + .read(pullRequestAgentWatchRepositoryProvider) + .agentDispatchPrompt( + workspaceId: workspaceId, + kind: 'fixFailedChecks', + reviewNumber: review.number, + ) ?? + pullRequestFailedChecksPrompt(review.number); + if (!context.mounted) return; await showAgentTaskDispatchFlow( context, ref, request: AgentTaskDispatchRequest( workspaceId: workspaceId, - prompt: pullRequestFailedChecksPrompt(review.number), + prompt: prompt, message: _agentDispatchMessage, ), ); @@ -40,12 +49,18 @@ Future dispatchPullRequestRestack({ required WidgetRef ref, required String workspaceId, }) async { + final prompt = + await ref + .read(pullRequestAgentWatchRepositoryProvider) + .agentDispatchPrompt(workspaceId: workspaceId, kind: 'restack') ?? + pullRequestRestackPrompt; + if (!context.mounted) return; await showAgentTaskDispatchFlow( context, ref, request: AgentTaskDispatchRequest( workspaceId: workspaceId, - prompt: pullRequestRestackPrompt, + prompt: prompt, title: 'Restack Changes', message: _agentDispatchMessage, ), @@ -92,11 +107,9 @@ Future startPullRequestAgentWatch({ } var binding = choice.binding; PullRequestAgentWatchDispatchMark? dispatched; - final runtimeOwned = - review.provider == GitHostingProvider.github && - await ref - .read(pullRequestAgentWatchRepositoryProvider) - .supportsExecution(); + final runtimeOwned = await ref + .read(pullRequestAgentWatchRepositoryProvider) + .ownsExecutionFor(review.provider); if (!context.mounted) return; if (!runtimeOwned && pullRequestAgentWatchInjectsOnStart(concerns)) { final result = await completeAgentTaskDispatch( diff --git a/lib/src/features/settings/presentation/mcp_access_settings_section.dart b/lib/src/features/settings/presentation/mcp_access_settings_section.dart index 7efdbc969..1e8c5d8c2 100644 --- a/lib/src/features/settings/presentation/mcp_access_settings_section.dart +++ b/lib/src/features/settings/presentation/mcp_access_settings_section.dart @@ -1,12 +1,15 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; import 'package:alera/src/design_system/icons/alera_icons.dart'; import 'package:alera/src/features/mcp_access/presentation/mcp_access_settings_pane.dart'; import 'package:alera/src/features/settings/presentation/settings_sections.dart'; +import 'package:alera/src/features/webhooks/presentation/webhooks_settings.dart'; import 'package:flutter/material.dart'; const List mcpAccessGroups = [ SettingsGroupSpec(id: 'control', title: 'MCP Control'), SettingsGroupSpec(id: 'runtime', title: 'Runtime Name'), SettingsGroupSpec(id: 'apps', title: 'Connected Apps'), + SettingsGroupSpec(id: 'webhooks', title: 'Webhooks'), ]; const List mcpAccessSearchEntries = [ @@ -39,6 +42,19 @@ const List mcpAccessSearchEntries = [ keywords: ['oauth', 'grant', 'revoke', 'apps', 'clients'], groupId: 'apps', ), + SettingsSearchEntry( + title: 'Webhooks', + description: 'Send signed runtime events to your own HTTPS endpoint.', + keywords: [ + 'webhook', + 'events', + 'callback', + 'signing secret', + 'notifications', + 'integrations', + ], + groupId: 'webhooks', + ), ]; /// The MCP Access settings section. [paneKeys] resolves the dialog's stable @@ -50,12 +66,24 @@ SettingsSectionData mcpAccessSettingsSection({ return SettingsSectionData( id: 'mcpAccess', title: 'MCP Access', - description: 'Remote MCP control, runtime name and connected apps.', + description: + 'Remote MCP control, runtime name, connected apps and webhooks.', icon: AleraIcons.mcp, entries: mcpAccessSearchEntries, groups: mcpAccessGroups, - builder: (_) => McpAccessSettingsPane( - groupKeys: paneKeys('mcpAccess', mcpAccessGroups), - ), + builder: (_) { + final groupKeys = paneKeys('mcpAccess', mcpAccessGroups); + return Column( + crossAxisAlignment: .stretch, + children: [ + McpAccessSettingsPane(groupKeys: groupKeys), + const SizedBox(height: AleraTokens.space16), + KeyedSubtree( + key: groupKeys['webhooks'], + child: const WebhooksSettings(), + ), + ], + ); + }, ); } diff --git a/lib/src/features/webhooks/application/webhook_providers.dart b/lib/src/features/webhooks/application/webhook_providers.dart new file mode 100644 index 000000000..cca34cf67 --- /dev/null +++ b/lib/src/features/webhooks/application/webhook_providers.dart @@ -0,0 +1,26 @@ +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:alera/src/features/webhooks/domain/webhook_repository.dart'; +import 'package:alera/src/features/webhooks/infra/runtime_webhook_repository.dart'; +import 'package:alera/src/shared/infra/runtime/runtime_host_providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'webhook_providers.g.dart'; + +// The list has a Refresh action, so an automatic retry would only hide a +// failure behind a spinner. +Duration? _noWebhookRetry(int retryCount, Object error) => null; + +@Riverpod(keepAlive: true) +WebhookRepository webhookRepository(Ref ref) => + RuntimeWebhookRepository(ref.watch(runtimeHostClientProvider)); + +/// Whether the connected runtime serves `webhook.*`, read each time the +/// settings page opens. +@Riverpod(retry: _noWebhookRetry) +Future webhooksSupported(Ref ref) => + ref.watch(webhookRepositoryProvider).supportsWebhooks(); + +/// Webhooks of the signed-in account, read each time the settings page opens. +@Riverpod(retry: _noWebhookRetry) +Future> runtimeWebhooks(Ref ref) => + ref.watch(webhookRepositoryProvider).listWebhooks(); diff --git a/lib/src/features/webhooks/application/webhook_providers.g.dart b/lib/src/features/webhooks/application/webhook_providers.g.dart new file mode 100644 index 000000000..5f4180249 --- /dev/null +++ b/lib/src/features/webhooks/application/webhook_providers.g.dart @@ -0,0 +1,144 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'webhook_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(webhookRepository) +final webhookRepositoryProvider = WebhookRepositoryProvider._(); + +final class WebhookRepositoryProvider + extends + $FunctionalProvider< + WebhookRepository, + WebhookRepository, + WebhookRepository + > + with $Provider { + WebhookRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'webhookRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$webhookRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + WebhookRepository create(Ref ref) { + return webhookRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(WebhookRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$webhookRepositoryHash() => r'a61ff08f30707c583baaa1fa7f7063f8b99e34ec'; + +/// Whether the connected runtime serves `webhook.*`, read each time the +/// settings page opens. + +@ProviderFor(webhooksSupported) +final webhooksSupportedProvider = WebhooksSupportedProvider._(); + +/// Whether the connected runtime serves `webhook.*`, read each time the +/// settings page opens. + +final class WebhooksSupportedProvider + extends $FunctionalProvider, bool, FutureOr> + with $FutureModifier, $FutureProvider { + /// Whether the connected runtime serves `webhook.*`, read each time the + /// settings page opens. + WebhooksSupportedProvider._() + : super( + from: null, + argument: null, + retry: _noWebhookRetry, + name: r'webhooksSupportedProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$webhooksSupportedHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return webhooksSupported(ref); + } +} + +String _$webhooksSupportedHash() => r'4ddf795d47ba82325cd61928ec7ad03f461e5000'; + +/// Webhooks of the signed-in account, read each time the settings page opens. + +@ProviderFor(runtimeWebhooks) +final runtimeWebhooksProvider = RuntimeWebhooksProvider._(); + +/// Webhooks of the signed-in account, read each time the settings page opens. + +final class RuntimeWebhooksProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Webhooks of the signed-in account, read each time the settings page opens. + RuntimeWebhooksProvider._() + : super( + from: null, + argument: null, + retry: _noWebhookRetry, + name: r'runtimeWebhooksProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$runtimeWebhooksHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return runtimeWebhooks(ref); + } +} + +String _$runtimeWebhooksHash() => r'11b5a3185027eddbf79a2fc0da6dc5b5ccfff701'; diff --git a/lib/src/features/webhooks/domain/runtime_webhook.dart b/lib/src/features/webhooks/domain/runtime_webhook.dart new file mode 100644 index 000000000..19249f99f --- /dev/null +++ b/lib/src/features/webhooks/domain/runtime_webhook.dart @@ -0,0 +1,134 @@ +/// Runtime capability that advertises the `webhook.*` requests. +const String runtimeEventsCapability = 'runtimeEventsV1'; + +/// Event kinds a webhook can subscribe to, in the runtime journal's order. +enum RuntimeEventKind(final String wireName, final String label) { + inboxReply('inbox.reply', 'Inbox Reply'), + inboxQuestionStatus('inbox.question.status', 'Inbox Question Status'), + agentStatus('agent.status', 'Agent Status'), + terminalExit('terminal.exit', 'Terminal Exit'), + orchestrationTaskState('orchestration.task.state', 'Task State'), + orchestrationGateCreated('orchestration.gate.created', 'Gate Created'), + orchestrationEscalation('orchestration.escalation', 'Escalation'), + automationRunState('automation.run.state', 'Automation Run State'), + workspaceStartState('workspace.start.state', 'Workspace Start State'), + workspaceLifecycle('workspace.lifecycle', 'Workspace Lifecycle'), + pullRequestWatch('pullRequest.watch', 'Pull Request Watch'); + + static RuntimeEventKind? fromWire(Object? value) { + for (final kind in values) { + if (kind.wireName == value) { + return kind; + } + } + return null; + } +} + +/// A signed webhook that receives this account's runtime events, as returned +/// by `webhook.list` and `webhook.create`. +final class const RuntimeWebhook({ + required final String id, + required final String url, + required final List kinds, + final List runtimeIds = const [], + final bool allRuntimes = true, + final String status = '', + final DateTime? createdAt, + final DateTime? lastDeliveryAt, + final String? lastError, +}) { + factory fromJson(Map json) { + final id = _optionalString(json['id']); + final url = _optionalString(json['url']); + if (id == null || url == null) { + throw const FormatException('Webhook payload needs an id and a url.'); + } + return RuntimeWebhook( + id: id, + url: url, + kinds: _strings(json['kinds']), + runtimeIds: _strings(json['runtimeIds']), + allRuntimes: json['allRuntimes'] != false, + status: _optionalString(json['status']) ?? '', + createdAt: _optionalDateTime(json['createdAt']), + lastDeliveryAt: _optionalDateTime(json['lastDeliveryAt']), + lastError: _optionalString(json['lastError']), + ); + } + + /// Whether the webhook receives every event kind the runtime knows. + bool get receivesAllKinds { + return RuntimeEventKind.values.every( + (kind) => kinds.contains(kind.wireName), + ); + } +} + +/// Result of `webhook.create`: the stored webhook and its signing secret, +/// which the cloud returns only this once. +final class const RuntimeWebhookCreation({ + required final RuntimeWebhook webhook, + required final String secret, +}) { + factory fromJson(Map json) { + final webhook = json['webhook']; + final secret = _optionalString(json['secret']); + if (webhook is! Map || secret == null) { + throw const FormatException( + 'Webhook creation payload needs a webhook and a secret.', + ); + } + return RuntimeWebhookCreation( + webhook: RuntimeWebhook.fromJson(Map.from(webhook)), + secret: secret, + ); + } +} + +/// Sentence-case reason [value] is not an acceptable webhook URL, or `null` +/// when it is one. +String? webhookUrlError(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return 'Enter the URL that receives the events.'; + } + final uri = Uri.tryParse(trimmed); + if (uri == null || !uri.isAbsolute || uri.host.isEmpty) { + return 'Enter a full URL, such as https://example.com/hooks/alera.'; + } + if (uri.scheme != 'https') { + return 'Webhook URLs must use https.'; + } + return null; +} + +String? _optionalString(Object? value) { + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + return null; +} + +List _strings(Object? value) { + if (value is! List) { + return const []; + } + return List.unmodifiable([ + for (final item in value) + if (item is String && item.trim().isNotEmpty) item.trim(), + ]); +} + +// ISO-8601 strings are the contract; epoch numbers are read defensively so a +// payload change degrades to a readable date instead of a parse failure. +DateTime? _optionalDateTime(Object? value) { + if (value is String && value.trim().isNotEmpty) { + return DateTime.tryParse(value)?.toUtc(); + } + if (value is num) { + final millis = value < 100000000000 ? value * 1000 : value; + return DateTime.fromMillisecondsSinceEpoch(millis.toInt(), isUtc: true); + } + return null; +} diff --git a/lib/src/features/webhooks/domain/webhook_repository.dart b/lib/src/features/webhooks/domain/webhook_repository.dart new file mode 100644 index 000000000..9513e3bf4 --- /dev/null +++ b/lib/src/features/webhooks/domain/webhook_repository.dart @@ -0,0 +1,30 @@ +import 'package:alera/src/features/mcp_access/domain/mcp_access_error_message.dart'; +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; + +/// Runtime event webhooks of the signed-in Alera account, managed through the +/// local runtime's `webhook.*` requests. +abstract interface class WebhookRepository { + /// Whether the connected runtime advertises [runtimeEventsCapability]. + Future supportsWebhooks(); + + Future> listWebhooks(); + + /// Omitting [kinds] subscribes the webhook to every event kind. + Future createWebhook({ + required String url, + List? kinds, + }); + + Future deleteWebhook(String id); + + /// Queues a test delivery and returns its delivery id. + Future testWebhook(String id); +} + +/// Sentence-case text for a webhook request failure. +String webhookErrorMessage(Object error) { + if (isUnsupportedMcpRequest(error)) { + return 'Update the Alera runtime to use webhooks.'; + } + return mcpAccessErrorMessage(error); +} diff --git a/lib/src/features/webhooks/infra/runtime_webhook_repository.dart b/lib/src/features/webhooks/infra/runtime_webhook_repository.dart new file mode 100644 index 000000000..5dbe0f2ba --- /dev/null +++ b/lib/src/features/webhooks/infra/runtime_webhook_repository.dart @@ -0,0 +1,60 @@ +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:alera/src/features/webhooks/domain/webhook_repository.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; + +final class RuntimeWebhookRepository(final RuntimeHostClient _client) + implements WebhookRepository { + @override + Future supportsWebhooks() async { + final status = _map(await _client.runtimeRequest('status.get'), 'status'); + final capabilities = status['runtimeCapabilities']; + return capabilities is List && + capabilities.contains(runtimeEventsCapability); + } + + @override + Future> listWebhooks() async { + final payload = _map(await _client.runtimeRequest('webhook.list'), 'list'); + final webhooks = payload['webhooks']; + return List.unmodifiable([ + if (webhooks is List) + for (final webhook in webhooks) + if (webhook is Map) + RuntimeWebhook.fromJson(Map.from(webhook)), + ]); + } + + @override + Future createWebhook({ + required String url, + List? kinds, + }) async { + final payload = await _client.runtimeRequest( + 'webhook.create', + {'url': url.trim(), 'kinds': ?kinds}, + ); + return RuntimeWebhookCreation.fromJson(_map(payload, 'creation')); + } + + @override + Future deleteWebhook(String id) async { + await _client.runtimeRequest('webhook.delete', {'id': id}); + } + + @override + Future testWebhook(String id) async { + final payload = _map( + await _client.runtimeRequest('webhook.test', {'id': id}), + 'test', + ); + final deliveryId = payload['deliveryId']; + return deliveryId is String ? deliveryId : ''; + } +} + +Map _map(Object? value, String label) { + if (value is Map) { + return Map.from(value); + } + throw FormatException('Runtime webhook $label payload must be an object.'); +} diff --git a/lib/src/features/webhooks/presentation/add_webhook_dialog.dart b/lib/src/features/webhooks/presentation/add_webhook_dialog.dart new file mode 100644 index 000000000..093877b40 --- /dev/null +++ b/lib/src/features/webhooks/presentation/add_webhook_dialog.dart @@ -0,0 +1,197 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/feedback/alera_inline_notice.dart'; +import 'package:alera/src/design_system/forms/alera_checkbox.dart'; +import 'package:alera/src/design_system/forms/alera_text_field.dart'; +import 'package:alera/src/design_system/layout/alera_dialog.dart'; +import 'package:alera/src/design_system/layout/alera_dialog_header.dart'; +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:flutter/material.dart'; + +/// Creates the webhook; `kinds` is `null` when every event kind is selected. +typedef WebhookCreator = Future Function( + String url, + List? kinds, +); + +/// Message shown when [WebhookCreator] fails, so the dialog stays open with +/// the user's input instead of losing it. +typedef WebhookErrorFormatter = String Function(Object error); + +/// Asks for a webhook URL and its event kinds, then runs [onCreate]. Pops the +/// [RuntimeWebhookCreation] on success so the caller can show the secret once. +class const AddWebhookDialog({ + super.key, + required final WebhookCreator onCreate, + required final WebhookErrorFormatter errorMessage, +}) extends StatefulWidget { + @override + State createState() => _AddWebhookDialogState(); +} + +class _AddWebhookDialogState extends State { + final TextEditingController _url = TextEditingController(); + final Set _kinds = RuntimeEventKind.values.toSet(); + String? _urlError; + String? _error; + bool _creating = false; + + @override + void dispose() { + _url.dispose(); + super.dispose(); + } + + bool get _allKinds => _kinds.length == RuntimeEventKind.values.length; + + Future _submit() async { + final urlError = webhookUrlError(_url.text); + if (urlError != null || _kinds.isEmpty) { + setState(() { + _urlError = urlError; + _error = _kinds.isEmpty ? 'Select at least one event.' : null; + }); + return; + } + setState(() { + _creating = true; + _urlError = null; + _error = null; + }); + try { + final created = await widget.onCreate( + _url.text.trim(), + _allKinds + ? null + : [ + for (final kind in RuntimeEventKind.values) + if (_kinds.contains(kind)) kind.wireName, + ], + ); + if (mounted) { + Navigator.of(context).pop(created); + } + } catch (error) { + if (mounted) { + setState(() { + _creating = false; + _error = widget.errorMessage(error); + }); + } + } + } + + void _toggleAll(bool selected) { + setState(() { + _kinds.clear(); + if (selected) { + _kinds.addAll(RuntimeEventKind.values); + } + }); + } + + void _toggle(RuntimeEventKind kind, bool selected) { + setState(() => selected ? _kinds.add(kind) : _kinds.remove(kind)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // The cloud returns the signing secret once, in the creation answer, so + // nothing may close the dialog while that answer is on its way. + return PopScope( + canPop: !_creating, + child: AleraDialog( + maxWidth: 520, + child: Padding( + padding: const EdgeInsets.all(AleraTokens.space20), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + AleraDialogHeader( + title: 'Add Webhook', + onClose: () => Navigator.of(context).maybePop(), + ), + const SizedBox(height: AleraTokens.space12), + AleraTextField( + controller: _url, + autofocus: true, + enabled: !_creating, + labelText: 'Webhook URL', + hintText: 'https://example.com/hooks/alera', + errorText: _urlError, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + onChanged: (_) { + if (_urlError != null) { + setState(() => _urlError = null); + } + }, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: AleraTokens.space16), + Text( + 'Events', + style: theme.textTheme.bodyMedium?.copyWith( + color: AleraTokens.foreground, + fontWeight: .w500, + ), + ), + const SizedBox(height: AleraTokens.space4), + AleraCheckbox( + label: 'All Events', + value: _allKinds, + enabled: !_creating, + onChanged: _toggleAll, + ), + Flexible( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(left: AleraTokens.space16), + child: Wrap( + spacing: AleraTokens.space8, + children: [ + for (final kind in RuntimeEventKind.values) + Tooltip( + message: kind.wireName, + child: AleraCheckbox( + label: kind.label, + value: _kinds.contains(kind), + enabled: !_creating, + onChanged: (selected) => _toggle(kind, selected), + ), + ), + ], + ), + ), + ), + ), + if (_error case final String message) ...[ + const SizedBox(height: AleraTokens.space12), + AleraInlineNotice(tone: .error, message: message), + ], + const SizedBox(height: AleraTokens.space20), + Row( + mainAxisAlignment: .end, + children: [ + TextButton( + onPressed: _creating + ? null + : () => Navigator.of(context).maybePop(), + child: const Text('Cancel'), + ), + const SizedBox(width: AleraTokens.space8), + FilledButton( + onPressed: _creating ? null : _submit, + child: Text(_creating ? 'Adding…' : 'Add Webhook'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/webhooks/presentation/webhook_list_row.dart b/lib/src/features/webhooks/presentation/webhook_list_row.dart new file mode 100644 index 000000000..5e54b2004 --- /dev/null +++ b/lib/src/features/webhooks/presentation/webhook_list_row.dart @@ -0,0 +1,164 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/badges/alera_badge.dart'; +import 'package:alera/src/design_system/buttons/alera_icon_button.dart'; +import 'package:alera/src/design_system/chips/alera_chip.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/features/settings/presentation/panes/mobile_device_list_row.dart'; +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:flutter/material.dart'; + +/// One webhook: its URL and status, the events it receives, its last delivery, +/// and Test and Delete actions. +class const WebhookListRow({ + super.key, + required final RuntimeWebhook webhook, + required final VoidCallback onTest, + required final VoidCallback onDelete, + final bool busy = false, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final mutedSmall = theme.textTheme.bodySmall?.copyWith( + color: AleraTokens.foregroundMuted, + ); + final status = webhookStatusBadge(webhook.status); + final lastError = webhook.lastError; + return Padding( + padding: const EdgeInsets.all(AleraTokens.space12), + child: Row( + children: [ + const Icon( + AleraIcons.link, + size: AleraTokens.iconMd, + color: AleraTokens.foregroundMuted, + ), + const SizedBox(width: AleraTokens.space8), + Expanded( + child: Column( + crossAxisAlignment: .start, + children: [ + Wrap( + spacing: AleraTokens.space6, + runSpacing: AleraTokens.space4, + crossAxisAlignment: .center, + children: [ + Text( + webhook.url, + maxLines: 1, + overflow: .ellipsis, + style: AleraTokens.monoStyle.copyWith( + color: AleraTokens.foreground, + ), + ), + ?status, + AleraChip( + label: webhookKindsSummary(webhook), + tooltip: _kindsTooltip(webhook), + ), + ], + ), + const SizedBox(height: AleraTokens.space4), + Text( + webhookDeliveryDetail(webhook), + maxLines: 1, + overflow: .ellipsis, + style: mutedSmall, + ), + if (lastError != null) ...[ + const SizedBox(height: AleraTokens.space2), + Text( + 'Last error: $lastError', + maxLines: 2, + overflow: .ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: AleraTokens.error, + ), + ), + ], + ], + ), + ), + AleraIconButton( + tooltip: 'Send Test Event', + icon: AleraIcons.send, + onPressed: busy ? null : onTest, + ), + AleraIconButton( + tooltip: 'Delete Webhook', + icon: AleraIcons.delete, + iconColor: AleraTokens.error, + onPressed: busy ? null : onDelete, + ), + ], + ), + ); + } +} + +/// Short description of the events a webhook receives. +String webhookKindsSummary(RuntimeWebhook webhook) { + if (webhook.receivesAllKinds) { + return 'All Events'; + } + final labels = _kindLabels(webhook); + return switch (labels.length) { + 0 => 'No Events', + 1 || 2 => labels.join(', '), + final count => '$count Events', + }; +} + +/// When the webhook last delivered, and when it was added. +String webhookDeliveryDetail(RuntimeWebhook webhook) { + final lastDelivery = webhook.lastDeliveryAt; + final createdAt = webhook.createdAt; + return [ + lastDelivery == null + ? 'No deliveries yet' + : 'Last delivery ${formatMobileTimestamp(lastDelivery)}', + if (createdAt != null) 'Added ${formatMobileTimestamp(createdAt)}', + if (!webhook.allRuntimes) + switch (webhook.runtimeIds.length) { + 1 => '1 runtime', + final count => '$count runtimes', + }, + ].join(' · '); +} + +/// Badge for the cloud's delivery status, or `null` when it reports none. +AleraBadge? webhookStatusBadge(String status) { + final normalized = status.trim().toLowerCase(); + if (normalized.isEmpty) { + return null; + } + final tone = switch (normalized) { + 'active' || 'enabled' || 'healthy' || 'ok' => AleraBadgeTone.success, + 'failing' || 'failed' || 'error' || 'blocked' => AleraBadgeTone.error, + 'pending' || 'retrying' => AleraBadgeTone.attention, + _ => AleraBadgeTone.neutral, + }; + return AleraBadge(label: _titleCase(normalized), tone: tone); +} + +List _kindLabels(RuntimeWebhook webhook) { + return [ + for (final kind in webhook.kinds) + RuntimeEventKind.fromWire(kind)?.label ?? kind, + ]; +} + +String _kindsTooltip(RuntimeWebhook webhook) { + if (webhook.kinds.isEmpty) { + return ''; + } + return webhook.kinds.join('\n'); +} + +String _titleCase(String value) { + return value + .split(RegExp(r'[\s_-]+')) + .where((word) => word.isNotEmpty) + .map((word) => '${word[0].toUpperCase()}${word.substring(1)}') + .join(' '); +} diff --git a/lib/src/features/webhooks/presentation/webhook_secret_dialog.dart b/lib/src/features/webhooks/presentation/webhook_secret_dialog.dart new file mode 100644 index 000000000..3b828835e --- /dev/null +++ b/lib/src/features/webhooks/presentation/webhook_secret_dialog.dart @@ -0,0 +1,116 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/buttons/alera_icon_button.dart'; +import 'package:alera/src/design_system/feedback/alera_inline_notice.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/design_system/layout/alera_dialog.dart'; +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Shows a new webhook's signing secret, which the cloud returns only once. +class const WebhookSecretDialog({ + super.key, + required final RuntimeWebhookCreation creation, +}) extends StatefulWidget { + @override + State createState() => _WebhookSecretDialogState(); +} + +class _WebhookSecretDialogState extends State { + bool _copied = false; + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.creation.secret)); + if (mounted) { + setState(() => _copied = true); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final muted = theme.textTheme.bodySmall?.copyWith( + color: AleraTokens.foregroundMuted, + ); + return AleraDialog( + maxWidth: 520, + child: Padding( + padding: const EdgeInsets.all(AleraTokens.space20), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + Text('Webhook Added', style: theme.textTheme.titleMedium), + const SizedBox(height: AleraTokens.space8), + Text( + widget.creation.webhook.url, + maxLines: 1, + overflow: .ellipsis, + style: AleraTokens.monoStyle.copyWith( + color: AleraTokens.foregroundMuted, + ), + ), + const SizedBox(height: AleraTokens.space16), + Text( + 'Signing Secret', + style: theme.textTheme.bodyMedium?.copyWith( + color: AleraTokens.foreground, + fontWeight: .w500, + ), + ), + const SizedBox(height: AleraTokens.space4), + Text( + 'Use it to verify the signature header on each delivery.', + style: muted, + ), + const SizedBox(height: AleraTokens.space8), + Container( + padding: const EdgeInsets.only( + left: AleraTokens.space12, + right: AleraTokens.space4, + ), + decoration: BoxDecoration( + color: AleraTokens.surfaceVariant, + borderRadius: BorderRadius.circular(AleraTokens.radiusMd), + border: Border.all(color: AleraTokens.borderSubtle), + ), + child: Row( + children: [ + Expanded( + child: SelectableText( + widget.creation.secret, + maxLines: 1, + style: AleraTokens.monoStyle.copyWith( + color: AleraTokens.foreground, + ), + ), + ), + AleraIconButton( + tooltip: _copied ? 'Copied' : 'Copy Secret', + icon: _copied ? AleraIcons.check : AleraIcons.copy, + onPressed: _copy, + ), + ], + ), + ), + const SizedBox(height: AleraTokens.space12), + const AleraInlineNotice( + tone: .warning, + message: + "Copy this secret now. You won't see it again after you " + 'close this dialog.', + ), + const SizedBox(height: AleraTokens.space20), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/webhooks/presentation/webhooks_settings.dart b/lib/src/features/webhooks/presentation/webhooks_settings.dart new file mode 100644 index 000000000..abdf83e4c --- /dev/null +++ b/lib/src/features/webhooks/presentation/webhooks_settings.dart @@ -0,0 +1,156 @@ +import 'package:alera/src/design_system/layout/alera_confirm_dialog.dart'; +import 'package:alera/src/features/mcp_access/application/mcp_access_providers.dart'; +import 'package:alera/src/features/webhooks/application/webhook_providers.dart'; +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:alera/src/features/webhooks/domain/webhook_repository.dart'; +import 'package:alera/src/features/webhooks/presentation/add_webhook_dialog.dart'; +import 'package:alera/src/features/webhooks/presentation/webhook_secret_dialog.dart'; +import 'package:alera/src/features/webhooks/presentation/webhooks_settings_group.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Wires the webhook providers into [WebhooksSettingsGroup]. Account sign-in +/// comes from the MCP settings, which already follow `aleraAccountChanged`. +class const WebhooksSettings({super.key}) extends ConsumerStatefulWidget { + @override + ConsumerState createState() => _WebhooksSettingsState(); +} + +class _WebhooksSettingsState extends ConsumerState { + final Set _busyIds = {}; + String? _error; + String? _notice; + + @override + Widget build(BuildContext context) { + return WebhooksSettingsGroup( + view: _view(), + busyIds: _busyIds, + error: _error, + notice: _notice, + onRefresh: _refresh, + onAdd: _add, + onTest: _test, + onDelete: _delete, + ); + } + + WebhooksView _view() { + final supported = ref.watch(webhooksSupportedProvider); + final settings = ref.watch(mcpAccessSettingsControllerProvider); + if (supported.isLoading || settings.isLoading) { + return const WebhooksLoading(); + } + if (supported case AsyncError(:final error)) { + return WebhooksFailed(webhookErrorMessage(error)); + } + if (supported.value != true) { + return const WebhooksUnavailable( + 'Update the Alera runtime to use webhooks. This runtime does not ' + 'publish runtime events.', + ); + } + if (settings.value?.accountConnected != true) { + return const WebhooksSignedOut(); + } + // `when` keeps the previous list on screen while a refresh is in flight. + return ref + .watch(runtimeWebhooksProvider) + .when( + data: WebhooksLoaded.new, + error: (error, _) => WebhooksFailed(webhookErrorMessage(error)), + loading: WebhooksLoading.new, + ); + } + + void _refresh() { + setState(() { + _error = null; + _notice = null; + }); + ref.invalidate(runtimeWebhooksProvider); + } + + Future _add() async { + final repository = ref.read(webhookRepositoryProvider); + final created = await showDialog( + context: context, + builder: (_) => AddWebhookDialog( + onCreate: (url, kinds) => + repository.createWebhook(url: url, kinds: kinds), + errorMessage: webhookErrorMessage, + ), + ); + if (created == null || !mounted) { + return; + } + setState(() { + _error = null; + _notice = null; + }); + ref.invalidate(runtimeWebhooksProvider); + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => WebhookSecretDialog(creation: created), + ); + } + + Future _test(RuntimeWebhook webhook) { + return _run(webhook, () async { + await ref.read(webhookRepositoryProvider).testWebhook(webhook.id); + if (mounted) { + setState( + () => _notice = + 'Test event queued for ${webhook.url}. Refresh to see its ' + 'delivery.', + ); + } + }); + } + + Future _delete(RuntimeWebhook webhook) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AleraConfirmDialog( + title: 'Delete Webhook', + message: + '${webhook.url} stops receiving runtime events. Its signing ' + 'secret cannot be recovered.', + confirmLabel: 'Delete', + destructive: true, + ), + ); + if (confirmed != true || !mounted) { + return; + } + await _run(webhook, () async { + await ref.read(webhookRepositoryProvider).deleteWebhook(webhook.id); + if (mounted) { + ref.invalidate(runtimeWebhooksProvider); + } + }); + } + + Future _run( + RuntimeWebhook webhook, + Future Function() action, + ) async { + setState(() { + _busyIds.add(webhook.id); + _error = null; + _notice = null; + }); + try { + await action(); + } catch (error) { + if (mounted) { + setState(() => _error = webhookErrorMessage(error)); + } + } finally { + if (mounted) { + setState(() => _busyIds.remove(webhook.id)); + } + } + } +} diff --git a/lib/src/features/webhooks/presentation/webhooks_settings_group.dart b/lib/src/features/webhooks/presentation/webhooks_settings_group.dart new file mode 100644 index 000000000..76087e64f --- /dev/null +++ b/lib/src/features/webhooks/presentation/webhooks_settings_group.dart @@ -0,0 +1,140 @@ +import 'package:alera/src/app/theme/alera_tokens.dart'; +import 'package:alera/src/design_system/buttons/alera_icon_button.dart'; +import 'package:alera/src/design_system/feedback/alera_empty_state.dart'; +import 'package:alera/src/design_system/feedback/alera_inline_notice.dart'; +import 'package:alera/src/design_system/forms/alera_setting_row.dart'; +import 'package:alera/src/design_system/icons/alera_icons.dart'; +import 'package:alera/src/design_system/layout/alera_settings_group.dart'; +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:alera/src/features/webhooks/presentation/webhook_list_row.dart'; +import 'package:flutter/material.dart'; + +const double _kActionsControlWidth = 260; + +/// What the Webhooks group currently holds. +sealed class WebhooksView { + const WebhooksView(); +} + +/// The runtime does not serve `webhook.*`, or its capabilities are unknown. +final class const WebhooksUnavailable(final String message) + extends WebhooksView; + +final class const WebhooksSignedOut() extends WebhooksView; + +final class const WebhooksLoading() extends WebhooksView; + +final class const WebhooksFailed(final String message) extends WebhooksView; + +final class const WebhooksLoaded(final List webhooks) + extends WebhooksView; + +/// Signed webhooks that receive this account's runtime events. Presentational: +/// the settings wrapper loads the list and runs every action. +class const WebhooksSettingsGroup({ + super.key, + required final WebhooksView view, + required final VoidCallback onRefresh, + required final VoidCallback onAdd, + required final ValueChanged onTest, + required final ValueChanged onDelete, + final Set busyIds = const {}, + final String? error, + final String? notice, +}) extends StatelessWidget { + @override + Widget build(BuildContext context) { + final view = this.view; + final ready = view is WebhooksLoaded || view is WebhooksFailed; + return AleraSettingsGroup( + title: 'Webhooks', + description: + 'Signed HTTPS callbacks for inbox replies, agent status, ' + 'orchestration, automations, workspaces and pull request watches. ' + 'Payloads carry ids and states only.', + children: [ + AleraSettingRow( + title: 'Event Webhooks', + description: + 'Stored in your Alera account and delivered by the ' + 'Alera cloud.', + controlWidth: _kActionsControlWidth, + child: Row( + mainAxisAlignment: .end, + children: [ + AleraIconButton( + tooltip: 'Refresh Webhooks', + icon: AleraIcons.refresh, + onPressed: ready ? onRefresh : null, + ), + const SizedBox(width: AleraTokens.space8), + FilledButton.icon( + onPressed: view is WebhooksLoaded ? onAdd : null, + icon: const Icon(AleraIcons.add, size: AleraTokens.iconMd), + label: const Text('Add Webhook'), + ), + ], + ), + ), + if (notice case final String message) + Padding( + padding: const EdgeInsets.all(AleraTokens.space12), + child: AleraInlineNotice(message: message), + ), + if (error case final String message) + Padding( + padding: const EdgeInsets.all(AleraTokens.space12), + child: AleraInlineNotice(tone: .error, message: message), + ), + ...switch (view) { + WebhooksUnavailable(:final message) => [ + Padding( + padding: const EdgeInsets.all(AleraTokens.space12), + child: AleraInlineNotice(tone: .warning, message: message), + ), + ], + WebhooksSignedOut() => const [ + Padding( + padding: EdgeInsets.all(AleraTokens.space12), + child: AleraInlineNotice( + tone: .warning, + message: + 'Sign in to an Alera account in Settings > Account to ' + 'manage webhooks.', + ), + ), + ], + WebhooksLoading() => const [ + AleraEmptyState(loading: true, message: 'Loading webhooks…'), + ], + WebhooksFailed(:final message) => [ + AleraEmptyState( + icon: AleraIcons.error, + title: 'Webhooks unavailable', + message: message, + ), + ], + WebhooksLoaded(:final webhooks) when webhooks.isEmpty => + const [ + AleraEmptyState( + icon: AleraIcons.link, + title: 'No webhooks', + message: + 'Add a webhook to send runtime events to your own ' + 'service.', + ), + ], + WebhooksLoaded(:final webhooks) => [ + for (final webhook in webhooks) + WebhookListRow( + webhook: webhook, + busy: busyIds.contains(webhook.id), + onTest: () => onTest(webhook), + onDelete: () => onDelete(webhook), + ), + ], + }, + ], + ); + } +} diff --git a/lib/src/features/workbench/application/background_setup_jobs.dart b/lib/src/features/workbench/application/background_setup_jobs.dart index 92c9fbc6e..4a879d0ab 100644 --- a/lib/src/features/workbench/application/background_setup_jobs.dart +++ b/lib/src/features/workbench/application/background_setup_jobs.dart @@ -6,11 +6,13 @@ import 'package:alera/src/features/projects/domain/project.dart'; import 'package:alera/src/features/projects/domain/project_clone_job.dart'; import 'package:alera/src/features/workbench/application/prompt_workspace_pipeline.dart'; import 'package:alera/src/features/workbench/application/prompt_workspace_branch_checks.dart'; +import 'package:alera/src/features/workbench/application/prompt_workspace_service_run.dart'; import 'package:alera/src/features/workbench/application/workbench_controller.dart'; import 'package:alera/src/features/workbench/domain/background_setup_job.dart'; import 'package:alera/src/features/workbench/domain/remote_workspace.dart'; import 'package:alera/src/features/workbench/domain/workspace_creation_result.dart'; import 'package:alera/src/features/workbench/infra/prompt_workspace_runtime_client.dart'; +import 'package:alera/src/features/workbench/infra/prompt_workspace_service_client.dart'; import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; import 'package:alera/src/shared/infra/git/git_providers.dart'; import 'package:alera/src/shared/infra/runtime/runtime_host_providers.dart'; @@ -20,10 +22,14 @@ import 'package:uuid/uuid.dart'; part 'background_setup_jobs.g.dart'; part 'background_setup_jobs_clone.dart'; +part 'background_setup_jobs_prompt_service.dart'; @Riverpod(keepAlive: true) class BackgroundSetupJobs extends _$BackgroundSetupJobs - with _BackgroundSetupJobsInternals, _BackgroundSetupJobsClone { + with + _BackgroundSetupJobsInternals, + _BackgroundSetupJobsClone, + _BackgroundSetupJobsPromptService { @override BackgroundSetupJobsState build() { _disposed = false; @@ -116,6 +122,7 @@ class BackgroundSetupJobs extends _$BackgroundSetupJobs originalLaunchWasIdempotent: existingSnapshot.originalLaunchWasIdempotent, setupStarted: existingSnapshot.setupStarted, + serviceOperationId: existingSnapshot.serviceOperationId, ) : request; _upsert( @@ -133,6 +140,9 @@ class BackgroundSetupJobs extends _$BackgroundSetupJobs ), ); return _run(id, () async { + if (await _runPromptWorkspaceOnService(id, requestToRun)) { + return; + } final controller = ref.read(workbenchControllerProvider.notifier); final runtime = PromptWorkspaceRuntimeClient( ref.read(runtimeHostClientProvider), diff --git a/lib/src/features/workbench/application/background_setup_jobs.g.dart b/lib/src/features/workbench/application/background_setup_jobs.g.dart index 80673f28c..0dc72d1c5 100644 --- a/lib/src/features/workbench/application/background_setup_jobs.g.dart +++ b/lib/src/features/workbench/application/background_setup_jobs.g.dart @@ -42,7 +42,7 @@ final class BackgroundSetupJobsProvider } String _$backgroundSetupJobsHash() => - r'49510c21ff1c81c7282ed71aa8f76e3f59d91518'; + r'1e74589b03b3b2cc3ebcc17d689d59419903acd9'; abstract class _$BackgroundSetupJobs extends $Notifier { diff --git a/lib/src/features/workbench/application/background_setup_jobs_prompt_service.dart b/lib/src/features/workbench/application/background_setup_jobs_prompt_service.dart new file mode 100644 index 000000000..ff84068e8 --- /dev/null +++ b/lib/src/features/workbench/application/background_setup_jobs_prompt_service.dart @@ -0,0 +1,80 @@ +part of 'background_setup_jobs.dart'; + +/// New Workspace from Prompt on a runtime that runs it as an operation +/// (`promptWorkspaceServiceV1`): the host generates the identity, creates the +/// workspace, launches the agent and starts the Setup tab, and the job only +/// follows it and shows the result. +mixin _BackgroundSetupJobsPromptService + on _$BackgroundSetupJobs, _BackgroundSetupJobsInternals { + /// Submissions of each job that ended without completing; the next one + /// needs a fresh runtime operation. + final Map _promptServiceAttempts = {}; + + /// Returns false when the runtime has no service for [request], so the + /// caller runs the client-side pipeline instead. + Future _runPromptWorkspaceOnService( + String jobId, + PromptWorkspaceCreateRequest request, + ) async { + final controller = ref.read(workbenchControllerProvider.notifier); + final attempt = _promptServiceAttempts[jobId] ?? 0; + final PromptWorkspaceServiceOutcome? outcome; + try { + outcome = await runPromptWorkspaceJobOnService( + service: PromptWorkspaceServiceClient( + ref.read(runtimeHostClientProvider), + beforeAccess: ref.read(runtimeStateMigrationProvider).ensureMigrated, + ), + request: request, + requestId: promptWorkspaceServiceRequestId( + jobId: jobId, + attempt: attempt, + request: request, + ), + onPhase: (phase) => _setPhase(jobId, phase), + showWorkspace: (creation, selectTabId) async { + controller.reconcileRuntimeCreatedWorkspace(creation.workspace); + await controller.completePromptWorkspaceCreation( + creation: creation, + agentTabId: selectTabId, + openDeferredSetup: false, + ); + }, + onWorkspaceKept: (snapshot) => _upsert( + BackgroundSetupJob( + id: jobId, + kind: .promptWorkspace, + status: .running, + title: 'Starting agent', + phase: 'Starting agent', + snapshot: snapshot, + ), + ), + ); + } on PromptWorkspaceServiceFailure { + _promptServiceAttempts[jobId] = attempt + 1; + rethrow; + } + if (outcome == null) { + return false; + } + _promptServiceAttempts.remove(jobId); + _publishPromptServiceWorkspace(outcome.creation, outcome.operation); + return true; + } + + void _publishPromptServiceWorkspace( + WorkspaceCreationResult creation, + PromptWorkspaceOperation operation, + ) { + if (operation.warnings.isEmpty) { + _publishWorkspaceCreated(creation); + return; + } + AleraToast.publish( + message: 'Workspace created with warnings: ${operation.warnings.first}', + tone: .warning, + duration: AleraToast.longDuration, + ); + } +} diff --git a/lib/src/features/workbench/application/editor_buffer_guards.dart b/lib/src/features/workbench/application/editor_buffer_guards.dart index 3c1494b44..4f8896af2 100644 --- a/lib/src/features/workbench/application/editor_buffer_guards.dart +++ b/lib/src/features/workbench/application/editor_buffer_guards.dart @@ -1,7 +1,9 @@ part of 'workspace_file_service.dart'; -class EditorBufferGuardRuntimeHandler(final EditorSessionRegistry registry) - implements RuntimeBufferGuardHandler { +class EditorBufferGuardRuntimeHandler( + final EditorSessionRegistry registry, { + final WorkspaceFileService Function()? files, +}) implements RuntimeBufferGuardResolver { @override List> lock({ required String guardId, @@ -21,6 +23,36 @@ class EditorBufferGuardRuntimeHandler(final EditorSessionRegistry registry) ) .toList(); + @override + Future>> resolveAndLock({ + required String guardId, + required Set tabIds, + required Set workspacePaths, + required bool discard, + }) async { + final scope = EditorBufferGuardScope( + tabIds: tabIds, + workspacePaths: workspacePaths, + ); + final failures = await registry.resolveDirtyBuffers( + scope, + discard: discard, + files: files?.call() ?? const WorkspaceFileService(), + ); + final failedTabIds = {for (final failure in failures) failure.tabId}; + final blockers = registry + .acquireBufferGuard(guardId, scope) + .where((blocker) => !failedTabIds.contains(blocker.tabId)); + return [ + for (final blocker in [...failures, ...blockers]) + { + 'tabId': blocker.tabId, + 'path': blocker.path, + 'reason': blocker.reason, + }, + ]; + } + @override void release(String guardId, {bool retired = false}) => registry.releaseBufferGuard(guardId, retired: retired); @@ -80,6 +112,48 @@ extension EditorBufferGuards on EditorSessionRegistry { return blockers; } + /// Saves, or discards when [discard] is true, every dirty editor in + /// [scope] before a guard freezes it, as the Remove dialog's Save and + /// Discard buttons do. Returns the editors that could not be settled. + Future> resolveDirtyBuffers( + EditorBufferGuardScope scope, { + required bool discard, + required WorkspaceFileService files, + }) async { + final failures = []; + for (final tabId in {..._documents.keys, ..._sessions.keys}.toList()) { + if (!_scopeContains(scope, tabId) || !isDirty(tabId)) continue; + final path = _documents[tabId]?.relativePath ?? tabId; + try { + if (discard) { + await this.discard(tabId); + } else { + await saveDocument(tabId, files); + } + if (isDirty(tabId)) { + failures.add( + EditorBufferGuardBlocker( + tabId: tabId, + path: path, + reason: 'The editor still has unsaved changes.', + ), + ); + } + } catch (error) { + failures.add( + EditorBufferGuardBlocker( + tabId: tabId, + path: path, + reason: discard + ? 'Could not discard the changes: $error' + : 'Could not save the changes: $error', + ), + ); + } + } + return failures; + } + bool isBufferGuarded(String tabId) => _retiredBufferTabs.contains(tabId) || _bufferGuards.values.any((scope) => _scopeContains(scope, tabId)); diff --git a/lib/src/features/workbench/application/prompt_workspace_service_run.dart b/lib/src/features/workbench/application/prompt_workspace_service_run.dart new file mode 100644 index 000000000..334004792 --- /dev/null +++ b/lib/src/features/workbench/application/prompt_workspace_service_run.dart @@ -0,0 +1,179 @@ +import 'package:alera/src/features/workbench/domain/background_setup_job.dart'; +import 'package:alera/src/features/workbench/domain/remote_workspace.dart'; +import 'package:alera/src/features/workbench/domain/workspace_creation_result.dart'; +import 'package:alera/src/features/workbench/infra/prompt_workspace_service_client.dart'; +import 'package:alera/src/features/workbench/infra/runtime_managed_workspace_client.dart'; + +/// A runtime operation that ended without completing. [creation] is set when +/// the workspace exists, so the job can still show it and retry the launch. +class PromptWorkspaceServiceFailure implements Exception { + PromptWorkspaceServiceFailure(this.operation, {this.creation}); + + final PromptWorkspaceOperation operation; + final WorkspaceCreationResult? creation; + + @override + String toString() => operation.failureMessage; +} + +class const PromptWorkspaceServiceOutcome({ + required final PromptWorkspaceOperation operation, + required final WorkspaceCreationResult creation, +}) { + String? get agentTabId => operation.agentTabId; +} + +/// Whether [request] can run on the runtime service: a fresh request always +/// can, a launch retry only when the service created that workspace. +bool canRunPromptWorkspaceOnService(PromptWorkspaceCreateRequest request) => + request.created == null || request.serviceOperationId != null; + +/// The idempotency key of one submission of a background job. The same job, +/// attempt, and form values map to the same runtime operation, so a repeated +/// submission follows the running operation instead of creating a second +/// workspace; a later attempt or edited form starts a new one. +String promptWorkspaceServiceRequestId({ + required String jobId, + required int attempt, + required PromptWorkspaceCreateRequest request, +}) { + final fingerprint = _fnv1a( + [ + request.project.id, + request.prompt.trim(), + request.profileId, + request.useProjectCheckout, + request.sourceBranch, + request.hostId, + request.parentWorkspaceId, + request.issueUrl, + request.autoAssignSection, + ].join('\u0000'), + ); + return 'desktop-prompt-workspace:$jobId:$attempt:$fingerprint'; +} + +Map promptWorkspaceStartPayload( + PromptWorkspaceCreateRequest request, { + required String requestId, +}) { + final sourceBranch = request.sourceBranch.trim(); + return { + 'prompt': request.prompt.trim(), + 'projectId': request.project.id, + 'profile': request.profileId, + 'mode': request.useProjectCheckout ? 'projectCheckout' : 'worktree', + if (!request.useProjectCheckout && sourceBranch.isNotEmpty) + 'sourceBranch': sourceBranch, + 'hostId': ?normalizedRemoteHostId(request.hostId), + 'parentWorkspaceId': ?_nonEmpty(request.parentWorkspaceId), + 'issueUrl': ?_nonEmpty(request.issueUrl), + 'section': request.autoAssignSection ? 'auto' : 'none', + 'requestId': requestId, + }; +} + +/// Starts [request] on the runtime, or relaunches the agent of the operation +/// that created its workspace, and follows it to the end. +Future runPromptWorkspaceService({ + required PromptWorkspaceServiceClient service, + required PromptWorkspaceCreateRequest request, + required String requestId, + void Function(String phase)? onPhase, +}) async { + final retryOperationId = request.created == null + ? null + : request.serviceOperationId; + final started = retryOperationId == null + ? await service.start( + promptWorkspaceStartPayload(request, requestId: requestId), + ) + : await service.retryLaunch(retryOperationId); + final operation = await service.follow( + started, + onUpdate: (operation) { + if (operation.isRunning) { + onPhase?.call(promptWorkspacePhaseLabel(operation.phase)); + } + }, + ); + final workspace = operation.workspace; + final creation = workspace == null + ? null + : workspaceCreationResultFromRuntime({ + 'workspace': workspace, + 'setupReport': const {}, + }); + if (operation.status == PromptWorkspaceOperationStatus.completed && + creation != null) { + return PromptWorkspaceServiceOutcome( + operation: operation, + creation: creation, + ); + } + throw PromptWorkspaceServiceFailure(operation, creation: creation); +} + +/// One background job on the runtime service. Returns null, having called +/// nothing, when the runtime cannot run [request]; the caller then runs the +/// client-side pipeline. +/// +/// [showWorkspace] brings the created workspace into the workbench with the +/// tab to select; the host already created and started its Setup tab. On a +/// failure after creation, [onWorkspaceKept] receives the job snapshot whose +/// retry relaunches the agent on the host before the failure is rethrown. +Future runPromptWorkspaceJobOnService({ + required PromptWorkspaceServiceClient service, + required PromptWorkspaceCreateRequest request, + required String requestId, + required Future Function( + WorkspaceCreationResult creation, + String? selectTabId, + ) + showWorkspace, + void Function(String phase)? onPhase, + void Function(PromptWorkspaceCreateRequest snapshot)? onWorkspaceKept, +}) async { + if (!canRunPromptWorkspaceOnService(request) || + !await service.isSupported()) { + return null; + } + try { + final outcome = await runPromptWorkspaceService( + service: service, + request: request, + requestId: requestId, + onPhase: onPhase, + ); + onPhase?.call('Starting agent'); + await showWorkspace(outcome.creation, outcome.agentTabId); + return outcome; + } on PromptWorkspaceServiceFailure catch (failure) { + final creation = failure.creation; + if (creation != null) { + onWorkspaceKept?.call( + request.withCreated(creation, serviceOperationId: failure.operation.id), + ); + try { + await showWorkspace(creation, failure.operation.setupTabId); + } catch (_) { + // The workspace list refresh still brings it in; the job reports the + // launch failure either way. + } + } + rethrow; + } +} + +String? _nonEmpty(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} + +String _fnv1a(String value) { + var hash = 0x811c9dc5; + for (final unit in value.codeUnits) { + hash = ((hash ^ unit) * 0x01000193) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); +} diff --git a/lib/src/features/workbench/application/workbench_controller_workspace_creation.dart b/lib/src/features/workbench/application/workbench_controller_workspace_creation.dart index 76e8b98a1..03c133623 100644 --- a/lib/src/features/workbench/application/workbench_controller_workspace_creation.dart +++ b/lib/src/features/workbench/application/workbench_controller_workspace_creation.dart @@ -130,6 +130,18 @@ mixin _WorkbenchControllerWorkspaceCreation } } + /// Adds a workspace the runtime created on its own, as the From Prompt + /// service does, without waiting for the next workspace list refresh. + void reconcileRuntimeCreatedWorkspace(Workspace workspace) { + final project = state.projects + .where((candidate) => candidate.id == workspace.projectId) + .firstOrNull; + if (project == null) { + throw StateError('Workspace project not found: ${workspace.projectId}'); + } + _reconcileCreatedWorkspace(project, workspace); + } + /// Finishes a From Prompt workspace after the host has persisted the agent /// tab: seeds the panel, appends Setup, and records the agent as that /// workspace's active tab without changing the visible workspace. diff --git a/lib/src/features/workbench/domain/background_setup_job.dart b/lib/src/features/workbench/domain/background_setup_job.dart index d863003c3..e7a79598f 100644 --- a/lib/src/features/workbench/domain/background_setup_job.dart +++ b/lib/src/features/workbench/domain/background_setup_job.dart @@ -41,12 +41,17 @@ class const PromptWorkspaceCreateRequest({ final String? clientMutationId, final bool? originalLaunchWasIdempotent, final bool setupStarted = false, + + /// The runtime's `workspace.promptStart` operation that created [created], + /// so a retry relaunches its agent there instead of on the client. + final String? serviceOperationId, }) extends BackgroundSetupRetrySnapshot { PromptWorkspaceCreateRequest withCreated( WorkspaceCreationResult created, { String? clientMutationId, bool? originalLaunchWasIdempotent, bool? setupStarted, + String? serviceOperationId, }) { return PromptWorkspaceCreateRequest( useProjectCheckout: useProjectCheckout, @@ -63,6 +68,7 @@ class const PromptWorkspaceCreateRequest({ originalLaunchWasIdempotent: originalLaunchWasIdempotent ?? this.originalLaunchWasIdempotent, setupStarted: setupStarted ?? this.setupStarted, + serviceOperationId: serviceOperationId ?? this.serviceOperationId, ); } diff --git a/lib/src/features/workbench/infra/prompt_workspace_service_client.dart b/lib/src/features/workbench/infra/prompt_workspace_service_client.dart new file mode 100644 index 000000000..2b34f86a8 --- /dev/null +++ b/lib/src/features/workbench/infra/prompt_workspace_service_client.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; + +enum PromptWorkspaceOperationStatus { + running, + needsInput, + completed, + failed, + cancelled; + + static PromptWorkspaceOperationStatus parse(Object? value) { + for (final status in values) { + if (status.name == value) { + return status; + } + } + return failed; + } +} + +/// One `workspace.promptStart` operation as the runtime reports it. +class const PromptWorkspaceOperation({ + required final String id, + required final PromptWorkspaceOperationStatus status, + required final String phase, + final Map? workspace, + final String? agentTabId, + final String? setupTabId, + final List warnings = const [], + final String? errorMessage, + final bool retryable = false, +}) { + factory fromJson(Map json) { + final error = _optionalMap(json['error']); + final workspace = _optionalMap(json['workspace']); + return PromptWorkspaceOperation( + id: _requiredString(json, 'id'), + status: .parse(json['status']), + phase: _optionalString(json['phase']) ?? '', + workspace: workspace == null || _optionalString(workspace['id']) == null + ? null + : workspace, + agentTabId: _optionalString(_optionalMap(json['agent'])?['tabId']), + setupTabId: _optionalString(_optionalMap(json['setup'])?['tabId']), + warnings: [ + for (final warning in _list(json['warnings'])) + if (warning is String && warning.trim().isNotEmpty) warning, + ], + errorMessage: _optionalString(error?['message']), + retryable: error?['retryable'] == true, + ); + } + + bool get isRunning => status == PromptWorkspaceOperationStatus.running; + + bool get hasWorkspace => workspace != null; + + /// The failure as the job card shows it. + String get failureMessage => switch (status) { + .cancelled => 'Workspace creation was cancelled.', + .needsInput => + errorMessage ?? 'The runtime needs more input to create this workspace.', + _ => errorMessage ?? 'Workspace creation failed.', + }; +} + +/// The job card phase for a runtime phase, using the same text as the +/// client-side pipeline so both paths read alike. +String promptWorkspacePhaseLabel(String phase) => switch (phase) { + 'resolvingProject' || 'generatingIdentity' => 'Generating workspace identity', + 'checkingBranch' => 'Checking generated branch', + 'creatingWorkspace' || 'assigningSection' => 'Creating workspace', + _ => 'Starting agent', +}; + +/// `workspace.promptStart.*` on the runtime that owns New Workspace from +/// Prompt when it advertises +/// [aleraRuntimeHostPromptWorkspaceServiceCapability]. +class PromptWorkspaceServiceClient( + final RuntimeHostClient _client, { + final Future Function()? beforeAccess, + final Duration pollInterval = const Duration(seconds: 1), + final Duration eventSafetyInterval = const Duration(seconds: 10), + final int maxConsecutiveReadFailures = 3, +}) { + Future isSupported() async { + await beforeAccess?.call(); + final client = _client; + if (client is! RuntimeHostCapabilityClient) { + return false; + } + try { + return await (client as RuntimeHostCapabilityClient) + .supportsRuntimeCapability( + aleraRuntimeHostPromptWorkspaceServiceCapability, + ); + } on Object { + return false; + } + } + + Future start(Map payload) => + _call('workspace.promptStart.start', payload); + + Future get(String id) => + _call('workspace.promptStart.get', {'id': id}); + + Future retryLaunch(String id) => + _call('workspace.promptStart.retryLaunch', {'id': id}); + + /// Follows [operation] until it leaves `running`. Each + /// `promptWorkspaceOperationsChanged` for it triggers a read; until one + /// arrives the operation is polled every [pollInterval], since an older or + /// disconnected event stream would otherwise leave the job waiting forever. + Future follow( + PromptWorkspaceOperation operation, { + void Function(PromptWorkspaceOperation operation)? onUpdate, + }) async { + var current = operation; + onUpdate?.call(current); + if (!current.isRunning) { + return current; + } + var wake = Completer(); + var eventsSeen = false; + final subscription = _client.runtimeEvents.listen((event) { + if (event.name != aleraPromptWorkspaceOperationsChangedEvent || + event.payload['id'] != current.id) { + return; + } + eventsSeen = true; + if (!wake.isCompleted) { + wake.complete(); + } + }); + var failures = 0; + try { + while (current.isRunning) { + await Future.any(>[ + wake.future, + Future.pause(eventsSeen ? eventSafetyInterval : pollInterval), + ]); + wake = Completer(); + try { + current = await get(current.id); + failures = 0; + } on Object { + failures += 1; + if (failures >= maxConsecutiveReadFailures) { + rethrow; + } + continue; + } + onUpdate?.call(current); + } + return current; + } finally { + await subscription.cancel(); + } + } + + Future _call( + String type, + Map payload, + ) async { + await beforeAccess?.call(); + final response = await _client.runtimeRequest(type, payload); + if (response is! Map) { + throw const FormatException('Runtime response must be a JSON object.'); + } + return PromptWorkspaceOperation.fromJson( + Map.from(response), + ); + } +} + +Map? _optionalMap(Object? value) => + value is Map ? Map.from(value) : null; + +List _list(Object? value) => + value is List ? List.of(value) : const []; + +String? _optionalString(Object? value) => + value is String && value.trim().isNotEmpty ? value : null; + +String _requiredString(Map json, String key) => + _optionalString(json[key]) ?? + (throw FormatException('Runtime response is missing "$key".')); diff --git a/lib/src/features/workbench/infra/terminal_host/runtime_buffer_guard_handler.dart b/lib/src/features/workbench/infra/terminal_host/runtime_buffer_guard_handler.dart index 17cdf4a7d..38757d1e1 100644 --- a/lib/src/features/workbench/infra/terminal_host/runtime_buffer_guard_handler.dart +++ b/lib/src/features/workbench/infra/terminal_host/runtime_buffer_guard_handler.dart @@ -7,3 +7,20 @@ abstract interface class RuntimeBufferGuardHandler { void release(String guardId, {bool retired = false}); } + +/// A buffer guard handler that can also settle dirty editors before it locks +/// them, so a workspace operation started outside this app (the CLI or an MCP +/// client) can save or discard them the way the app's own dialogs do. The +/// client announces `checkoutBufferSaveV1` in `hello` only for these. +abstract interface class RuntimeBufferGuardResolver + implements RuntimeBufferGuardHandler { + /// Saves the dirty editors in scope, or discards them when [discard] is + /// true, then locks the scope like [lock]. Returns the blockers: editors + /// that could not be saved or are still busy. + Future>> resolveAndLock({ + required String guardId, + required Set tabIds, + required Set workspacePaths, + required bool discard, + }); +} diff --git a/lib/src/features/workbench/infra/terminal_host/terminal_host_client.dart b/lib/src/features/workbench/infra/terminal_host/terminal_host_client.dart index f234c5c30..0b9728e3e 100644 --- a/lib/src/features/workbench/infra/terminal_host/terminal_host_client.dart +++ b/lib/src/features/workbench/infra/terminal_host/terminal_host_client.dart @@ -92,6 +92,10 @@ final class SocketTerminalHostClient._( final Map _pending = {}; final Map> _heldBufferGuards = {}; + /// Guards whose editors are being saved or discarded before they lock. A + /// release that arrives meanwhile removes the id so the late lock is undone. + final Set _resolvingBufferGuards = {}; + Future<_TerminalHostConnection>? _terminalConnectionFuture; @override _TerminalHostConnection? _terminalConnection; @@ -491,10 +495,15 @@ final class SocketTerminalHostClient._( final decoded = line is String ? jsonDecode(line) : line; final message = asTerminalHostMap(decoded, 'Terminal host message'); if (message['event'] case final String event) { - if (event == 'checkoutBuffersLock') { + if (event == 'checkoutBuffersLock' || + event == 'checkoutBuffersSaveRequested') { + final payload = asTerminalHostMap(message['payload'], 'buffer guard'); _lockCheckoutBuffers( connection, - asTerminalHostMap(message['payload'], 'buffer guard'), + payload, + resolution: event == 'checkoutBuffersSaveRequested' + ? 'save' + : payload['resolution'] as String?, ); return; } diff --git a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_buffer_guards.dart b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_buffer_guards.dart index 944808b90..28ba25982 100644 --- a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_buffer_guards.dart +++ b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_buffer_guards.dart @@ -1,31 +1,80 @@ part of 'terminal_host_client.dart'; extension _TerminalHostBufferGuards on SocketTerminalHostClient { + /// Locks the guard's editors and acknowledges with what still blocks it. + /// A guard asking to `save` or `discard` settles the dirty editors first, + /// when this client's handler can. void _lockCheckoutBuffers( _TerminalHostConnection connection, - Map payload, - ) { + Map payload, { + String? resolution, + }) { final id = payload['guardId']; if (id is! String || id.isEmpty) { throw const FormatException('A buffer guard ID is required.'); } - List> blockers; + unawaited( + _checkoutBufferBlockers(connection, id, payload, resolution) + .then( + (blockers) => _requestOnConnection( + connection, + 'workspace.bufferGuard.ack', + {'guardId': id, 'blockers': blockers}, + ), + ) + .catchError((Object error) { + AppLogger.recordError( + error, + .current, + context: 'WorkspaceBufferGuard', + ); + return null; + }), + ); + } + + Future>> _checkoutBufferBlockers( + _TerminalHostConnection connection, + String id, + Map payload, + String? resolution, + ) async { try { final scope = asTerminalHostMap(payload['scope'], 'buffer guard scope'); final handler = _bufferGuardHandler; if (handler == null) { throw StateError('This client cannot verify editor buffers.'); } - blockers = handler.lock( + final tabIds = (scope['tabIds'] as List).cast().toSet(); + final workspacePaths = (scope['workspacePaths'] as List) + .cast() + .toSet(); + if (handler is RuntimeBufferGuardResolver && + (resolution == 'save' || resolution == 'discard')) { + _resolvingBufferGuards.add(id); + final blockers = await handler.resolveAndLock( + guardId: id, + tabIds: tabIds, + workspacePaths: workspacePaths, + discard: resolution == 'discard', + ); + if (!_resolvingBufferGuards.remove(id)) { + // The runtime released the guard while the editors were saving. + handler.release(id); + return blockers; + } + _heldBufferGuards.putIfAbsent(id, () => {}).add(connection); + return blockers; + } + final blockers = handler.lock( guardId: id, - tabIds: (scope['tabIds'] as List).cast().toSet(), - workspacePaths: (scope['workspacePaths'] as List) - .cast() - .toSet(), + tabIds: tabIds, + workspacePaths: workspacePaths, ); _heldBufferGuards.putIfAbsent(id, () => {}).add(connection); + return blockers; } catch (error) { - blockers = [ + return [ { 'tabId': '', 'path': '', @@ -33,18 +82,10 @@ extension _TerminalHostBufferGuards on SocketTerminalHostClient { }, ]; } - unawaited( - _requestOnConnection(connection, 'workspace.bufferGuard.ack', { - 'guardId': id, - 'blockers': blockers, - }).catchError((Object error) { - AppLogger.recordError(error, .current, context: 'WorkspaceBufferGuard'); - return null; - }), - ); } void _releaseCheckoutBuffers(String id, {bool retired = false}) { + _resolvingBufferGuards.remove(id); _heldBufferGuards.remove(id); _bufferGuardHandler?.release(id, retired: retired); } diff --git a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_connections.dart b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_connections.dart index d3ff5c748..cb220e648 100644 --- a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_connections.dart +++ b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_connections.dart @@ -219,6 +219,8 @@ extension _SocketTerminalHostClientConnections on SocketTerminalHostClient { 'clientKind': 'app', 'sharedCheckoutWorkspacesV1': true, 'checkoutBufferGuardsV1': _bufferGuardHandler != null, + 'checkoutBufferSaveV1': + _bufferGuardHandler is RuntimeBufferGuardResolver, aleraWorkspaceFocusHelloFlag: true, 'supportedTabKinds': const [], if (control.supportsBinaryFrames) 'binaryFrames': true, diff --git a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart index b9003afb9..a12807e14 100644 --- a/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart +++ b/lib/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart @@ -361,6 +361,7 @@ const Set runtimeHostEventNames = { aleraWorkspaceFocusRequestedEvent, 'projectConfigsChanged', 'projectCloneJobsChanged', + aleraPromptWorkspaceOperationsChangedEvent, 'linkedReviewsChanged', 'linkedIssuesChanged', 'pullRequestWatchChanged', diff --git a/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart b/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart index a1ca99de5..3ed4a77d0 100644 --- a/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart +++ b/lib/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart @@ -24,6 +24,14 @@ const String aleraRuntimeHostPullRequestWatchCapability = 'pullRequestWatchV1'; const String aleraRuntimeHostAutomationTerminalObserveCapability = 'automationTerminalObserveV1'; +/// Feature-detect `workspace.promptStart.*`: the host runs New Workspace from +/// Prompt as a persisted operation and announces each change with +/// [aleraPromptWorkspaceOperationsChangedEvent]. Additive. +const String aleraRuntimeHostPromptWorkspaceServiceCapability = + 'promptWorkspaceServiceV1'; +const String aleraPromptWorkspaceOperationsChangedEvent = + 'promptWorkspaceOperationsChanged'; + /// Feature-detect `workspace.archive` / `workspace.unarchive`. Additive: do /// not bump [aleraTerminalHostProtocolVersion]. const String aleraRuntimeHostWorkspaceArchiveCapability = 'workspaceArchiveV1'; diff --git a/lib/src/shared/infra/runtime/runtime_host_providers.dart b/lib/src/shared/infra/runtime/runtime_host_providers.dart index d7c386ee7..96ccf2f39 100644 --- a/lib/src/shared/infra/runtime/runtime_host_providers.dart +++ b/lib/src/shared/infra/runtime/runtime_host_providers.dart @@ -14,6 +14,8 @@ SocketTerminalHostClient runtimeHostClient(Ref ref) { final client = SocketTerminalHostClient( bufferGuardHandler: EditorBufferGuardRuntimeHandler( ref.watch(editorSessionRegistryProvider), + // Read on demand: the file service itself talks to this client. + files: () => ref.read(workspaceFileServiceProvider), ), ); final changes = watchRuntimeEditorFileChanges( diff --git a/lib/src/shared/infra/runtime/runtime_host_providers.g.dart b/lib/src/shared/infra/runtime/runtime_host_providers.g.dart index 5657db760..27ab5b30a 100644 --- a/lib/src/shared/infra/runtime/runtime_host_providers.g.dart +++ b/lib/src/shared/infra/runtime/runtime_host_providers.g.dart @@ -54,7 +54,7 @@ final class RuntimeHostClientProvider } } -String _$runtimeHostClientHash() => r'47750f0f5dff565eaded135aab0be6eddae4b545'; +String _$runtimeHostClientHash() => r'e22c4dac594eaa9bfdd25e59d9f942c9a42235f9'; /// One coalescer for every runtime watcher, keyed by namespaced strings /// (`tabs:`, `workspaces:`, `projects`, ...), so there is a single diff --git a/mobile/lib/src/features/inbox/domain/inbox_models.dart b/mobile/lib/src/features/inbox/domain/inbox_models.dart index 8bb4d3ba8..5f833e6d7 100644 --- a/mobile/lib/src/features/inbox/domain/inbox_models.dart +++ b/mobile/lib/src/features/inbox/domain/inbox_models.dart @@ -93,16 +93,20 @@ class const InboxSummaryEntry({ class const InboxOrigin({ required final String surface, final String? deviceName, + final String? clientName, }) { factory fromJson(Map json) => InboxOrigin( surface: json.optionalString('surface') ?? 'cli', deviceName: json.optionalString('deviceName'), + clientName: + json.optionalString('clientName') ?? json.optionalString('clientId'), ); String get label => switch (surface) { 'mobile' => deviceName == null ? 'Alera mobile' : 'Alera mobile on $deviceName', 'desktop' => 'Alera desktop', + 'mcp' => clientName == null ? 'MCP client' : '$clientName (MCP)', _ => 'Command line', }; } diff --git a/mobile/lib/src/features/pull_requests/domain/mobile_pull_request_watch.dart b/mobile/lib/src/features/pull_requests/domain/mobile_pull_request_watch.dart index 7b54289b9..b166bc404 100644 --- a/mobile/lib/src/features/pull_requests/domain/mobile_pull_request_watch.dart +++ b/mobile/lib/src/features/pull_requests/domain/mobile_pull_request_watch.dart @@ -55,3 +55,15 @@ abstract interface class MobilePullRequestWatchExecutionClient Future startPullRequestWatch(Map watch); Future stopPullRequestWatch(String workspaceId); } + +/// The runtime's Restack and Fix Failed Checks prompts +/// (`pullRequest.agentDispatch`), the single source the apps share. +abstract interface class MobilePullRequestAgentDispatchClient { + /// Null when the runtime does not own the prompts or the read failed, so + /// the caller uses its bundled prompt. + Future pullRequestAgentPrompt({ + required String workspaceId, + required String kind, + int? reviewNumber, + }); +} diff --git a/mobile/lib/src/features/pull_requests/infra/mobile_runtime_pull_request_watch_requests.dart b/mobile/lib/src/features/pull_requests/infra/mobile_runtime_pull_request_watch_requests.dart index 4ef71ce68..a8afc9369 100644 --- a/mobile/lib/src/features/pull_requests/infra/mobile_runtime_pull_request_watch_requests.dart +++ b/mobile/lib/src/features/pull_requests/infra/mobile_runtime_pull_request_watch_requests.dart @@ -3,7 +3,9 @@ import 'package:alera_mobile/src/core/mobile_protocol.dart'; import 'package:alera_mobile/src/features/pull_requests/domain/mobile_pull_request_watch.dart'; mixin MobileRuntimePullRequestWatchRequests - implements MobilePullRequestWatchExecutionClient { + implements + MobilePullRequestWatchExecutionClient, + MobilePullRequestAgentDispatchClient { Set get runtimeCapabilities; Future> requestMap( String type, [ @@ -15,9 +17,37 @@ mixin MobileRuntimePullRequestWatchRequests bool get supportsPullRequestWatch => runtimeCapabilities.contains(pullRequestWatchCapability); + /// V1 is GitHub execution; V2 adds GitLab and Azure DevOps. Either way the + /// runtime owns the watch, so the phone never evaluates it as well. @override bool get supportsPullRequestWatchExecution => - runtimeCapabilities.contains('pullRequestWatchExecutionV1'); + runtimeCapabilities.contains('pullRequestWatchExecutionV1') || + runtimeCapabilities.contains('pullRequestWatchExecutionV2'); + + @override + Future pullRequestAgentPrompt({ + required String workspaceId, + required String kind, + int? reviewNumber, + }) async { + if (!runtimeCapabilities.contains('pullRequestAgentDispatchV1')) { + return null; + } + try { + final payload = await requestMap( + 'pullRequest.agentDispatch', + { + 'workspaceId': workspaceId, + 'kind': kind, + 'number': ?reviewNumber, + }, + ); + final prompt = payload['prompt']; + return prompt is String && prompt.trim().isNotEmpty ? prompt : null; + } on Object { + return null; + } + } @override Future startPullRequestWatch(Map watch) async { diff --git a/mobile/lib/src/features/runtime/domain/mobile_pull_request_actions.dart b/mobile/lib/src/features/runtime/domain/mobile_pull_request_actions.dart index 7400d8baf..654f84e69 100644 --- a/mobile/lib/src/features/runtime/domain/mobile_pull_request_actions.dart +++ b/mobile/lib/src/features/runtime/domain/mobile_pull_request_actions.dart @@ -3,8 +3,11 @@ import 'dart:async'; import 'package:alera_mobile/src/features/runtime/domain/mobile_workspace_panels.dart'; /// Merge methods a phone can offer, with the runtime's wire names and the -/// desktop's labels (`review_merge_method.dart`). +/// desktop's labels (`review_merge_method.dart`). [providerDefault] is how a +/// GitLab project merges with its own settings (merge commit, fast-forward or +/// semi-linear) rather than a method Alera chooses. enum MobilePullRequestMergeMethod(final String wireName, final String label) { + providerDefault('providerDefault', 'Merge Using Project Settings'), mergeCommit('mergeCommit', 'Create Merge Commit'), squash('squash', 'Squash and Merge'), rebase('rebase', 'Rebase and Merge'); @@ -19,12 +22,17 @@ enum MobilePullRequestMergeMethod(final String wireName, final String label) { } } -/// First method the runtime listed, matching desktop `preferredReviewMergeMethod` -/// once provider-default has already been dropped. +/// The method an unselected or automatic merge uses, matching desktop +/// `preferredReviewMergeMethod`: provider-default when the runtime lists it, +/// otherwise the first method the phone recognizes. MobilePullRequestMergeMethod? preferredMobilePullRequestMergeMethod( Iterable wireNames, ) { - for (final name in wireNames) { + final names = List.of(wireNames); + if (names.contains(MobilePullRequestMergeMethod.providerDefault.wireName)) { + return MobilePullRequestMergeMethod.providerDefault; + } + for (final name in names) { final method = MobilePullRequestMergeMethod.fromWireName(name); if (method != null) { return method; @@ -73,6 +81,7 @@ abstract interface class MobilePullRequestActionsClient { required int number, required String body, int? replyToCommentId, + String? replyToThreadId, }); Future editPullRequestComment({ diff --git a/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart b/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart index fae17a363..29ce17c5a 100644 --- a/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart +++ b/mobile/lib/src/features/runtime/domain/runtime_client_surfaces.dart @@ -67,6 +67,24 @@ const String mobilePromptFileUploadCapability = 'mobilePromptFileUploadV1'; const String mobilePromptAttachmentReadCapability = 'mobilePromptAttachmentReadV1'; +/// The runtime runs New Workspace from Prompt as an operation +/// (`workspace.promptStart.*`) and announces each change with +/// [promptWorkspaceOperationsChangedEvent]. Additive. +const String promptWorkspaceServiceCapability = 'promptWorkspaceServiceV1'; +const String promptWorkspaceOperationsChangedEvent = + 'promptWorkspaceOperationsChanged'; + +/// What New Workspace from Prompt needs to follow a runtime operation. +abstract interface class MobilePromptWorkspaceServiceClient { + Stream get events; + Set get runtimeCapabilities; + Future> requestMap( + String type, [ + Map payload, + Duration? timeout, + ]); +} + class const MobileRuntimeEvent( final String name, final Map payload, diff --git a/mobile/lib/src/features/runtime/infra/mobile_runtime_client.dart b/mobile/lib/src/features/runtime/infra/mobile_runtime_client.dart index 6b560826c..cd1d924e6 100644 --- a/mobile/lib/src/features/runtime/infra/mobile_runtime_client.dart +++ b/mobile/lib/src/features/runtime/infra/mobile_runtime_client.dart @@ -100,7 +100,8 @@ class MobileRuntimeClient._( MobileLinkedIssueClient, MobilePullRequestWatchClient, MobilePullRequestActionsClient, - MobileWorkspacePullRequestSummariesClient { + MobileWorkspacePullRequestSummariesClient, + MobilePromptWorkspaceServiceClient { this { _subscription = _channel.stream.listen( _handleMessage, diff --git a/mobile/lib/src/features/runtime/infra/mobile_runtime_pull_request_requests.dart b/mobile/lib/src/features/runtime/infra/mobile_runtime_pull_request_requests.dart index c12be4095..e6abe4ce6 100644 --- a/mobile/lib/src/features/runtime/infra/mobile_runtime_pull_request_requests.dart +++ b/mobile/lib/src/features/runtime/infra/mobile_runtime_pull_request_requests.dart @@ -123,12 +123,16 @@ mixin MobileRuntimePullRequestRequests required int number, required String body, int? replyToCommentId, + String? replyToThreadId, }) { + // Azure DevOps comment ids repeat across threads, so a reply names its + // thread too. return _pullRequestWrite('comment', { 'workspaceId': workspaceId, 'number': number, 'body': body, 'replyToCommentId': ?replyToCommentId, + 'replyToThreadId': ?replyToThreadId, }); } @@ -143,6 +147,7 @@ mixin MobileRuntimePullRequestRequests 'workspaceId': workspaceId, 'number': number, 'commentId': comment.id, + 'threadId': ?comment.threadId, 'source': comment.source, 'body': body, }); diff --git a/mobile/lib/src/features/workbench/application/background_setup_jobs.dart b/mobile/lib/src/features/workbench/application/background_setup_jobs.dart index 61ac1df40..5cadf9daf 100644 --- a/mobile/lib/src/features/workbench/application/background_setup_jobs.dart +++ b/mobile/lib/src/features/workbench/application/background_setup_jobs.dart @@ -3,6 +3,7 @@ import 'package:alera_mobile/src/features/runtime/domain/workspace_creation_resu import 'package:alera_mobile/src/features/terminal/application/terminal_providers.dart'; import 'package:flutter/material.dart'; import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_pipeline.dart'; +import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_service.dart'; import 'package:alera_mobile/src/features/workbench/application/workbench_providers.dart'; import 'package:alera_mobile/src/features/workbench/application/workspace_list_controller.dart'; import 'package:alera_mobile/src/features/workbench/domain/background_setup_job.dart'; @@ -13,6 +14,10 @@ part 'background_setup_jobs.g.dart'; @Riverpod(keepAlive: true) class BackgroundSetupJobs extends _$BackgroundSetupJobs { final Set _inFlightIds = {}; + + /// Runtime operations of each prompt job that ended without completing; + /// the next submission needs a fresh one. + final Map _promptServiceAttempts = {}; var _retryOpening = false; @override @@ -111,6 +116,7 @@ class BackgroundSetupJobs extends _$BackgroundSetupJobs { originalLaunchWasIdempotent: existingSnapshot.originalLaunchWasIdempotent, setupStarted: existingSnapshot.setupStarted, + serviceOperationId: existingSnapshot.serviceOperationId, ) : request; final clientMutationId = @@ -151,6 +157,11 @@ class BackgroundSetupJobs extends _$BackgroundSetupJobs { ref.read(terminalClientProvider(request.hostId).future), request: requestToRun, clientMutationId: clientMutationId, + serviceRequestId: promptWorkspaceServiceRequestId( + jobId: id, + attempt: _promptServiceAttempts[id] ?? 0, + request: requestToRun, + ), onPhase: (phase) { final job = state.jobById(id); if (job == null) { @@ -172,8 +183,15 @@ class BackgroundSetupJobs extends _$BackgroundSetupJobs { }, ); result = outcome; + _promptServiceAttempts.remove(id); _publishWorkspaceCreatedIfDetached(outcome.creation); + } on PromptWorkspaceServiceFailure { + _promptServiceAttempts[id] = (_promptServiceAttempts[id] ?? 0) + 1; + rethrow; } on PromptWorkspaceLaunchException catch (failure) { + if (failure.serviceOperationId != null) { + _promptServiceAttempts[id] = (_promptServiceAttempts[id] ?? 0) + 1; + } final job = state.jobById(id); if (job != null) { state = state.withJob( @@ -188,6 +206,7 @@ class BackgroundSetupJobs extends _$BackgroundSetupJobs { originalLaunchWasIdempotent: failure.originalLaunchWasIdempotent, setupStarted: failure.setupStarted, + serviceOperationId: failure.serviceOperationId, ), phase: job.phase, error: job.error, diff --git a/mobile/lib/src/features/workbench/application/background_setup_jobs.g.dart b/mobile/lib/src/features/workbench/application/background_setup_jobs.g.dart index 8a44a446c..b9f1f3720 100644 --- a/mobile/lib/src/features/workbench/application/background_setup_jobs.g.dart +++ b/mobile/lib/src/features/workbench/application/background_setup_jobs.g.dart @@ -42,7 +42,7 @@ final class BackgroundSetupJobsProvider } String _$backgroundSetupJobsHash() => - r'566170b9c89864ebdf32837cc42d95b9727d1a16'; + r'2438f8f66cc1532648b48076d10d099091a3a307'; abstract class _$BackgroundSetupJobs extends $Notifier { diff --git a/mobile/lib/src/features/workbench/application/prompt_workspace_controller.dart b/mobile/lib/src/features/workbench/application/prompt_workspace_controller.dart index 8cecd4b28..52699b278 100644 --- a/mobile/lib/src/features/workbench/application/prompt_workspace_controller.dart +++ b/mobile/lib/src/features/workbench/application/prompt_workspace_controller.dart @@ -1,11 +1,15 @@ +import 'dart:async'; + import 'package:alera_mobile/src/features/projects/domain/preferred_source_branch.dart'; import 'package:alera_mobile/src/features/runtime/domain/agent_profile_summary.dart'; import 'package:alera_mobile/src/features/runtime/infra/mobile_runtime_project_client.dart'; import 'package:alera_mobile/src/features/runtime/domain/project_summary.dart'; +import 'package:alera_mobile/src/features/runtime/domain/runtime_client_surfaces.dart'; import 'package:alera_mobile/src/features/runtime/domain/workspace_creation_result.dart'; import 'package:alera_mobile/src/features/terminal/application/terminal_providers.dart'; import 'package:alera_mobile/src/features/workbench/application/deferred_workspace_setup_launcher.dart'; import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_pipeline.dart'; +import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_service.dart'; import 'package:alera_mobile/src/features/workbench/application/workbench_providers.dart'; import 'package:alera_mobile/src/features/workbench/domain/background_setup_job.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -63,6 +67,15 @@ class PromptWorkspaceController extends _$PromptWorkspaceController { String? _checkoutHostId; int _selectionGeneration = 0; String? _activeOperationId; + + /// The running `workspace.promptStart` operation on a runtime that owns + /// New Workspace from Prompt; cancel goes to it instead of the identity. + String? _activeServiceOperationId; + bool _cancelRequested = false; + + /// The request and runtime operation behind a workspace the runtime kept + /// after a failed or cancelled launch, so Retry Agent relaunches there. + PromptWorkspaceCreateRequest? _serviceRetryRequest; String? _agentLaunchMutationId; bool? _originalAgentLaunchWasIdempotent; String? _defaultAgentProfileId; @@ -213,26 +226,30 @@ class PromptWorkspaceController extends _$PromptWorkspaceController { ); _agentLaunchMutationId = null; _originalAgentLaunchWasIdempotent = null; + _serviceRetryRequest = null; + _cancelRequested = false; + late final PromptWorkspaceCreateRequest request; try { final client = await ref.read(workspaceClientProvider(hostId).future); _originalAgentLaunchWasIdempotent = client.supportsIdempotentAgentProfileLaunch; final clientMutationId = _agentLaunchMutationId ??= 'mobile-agent-launch-${DateTime.now().microsecondsSinceEpoch}'; + request = PromptWorkspaceCreateRequest( + hostId: hostId, + checkoutHostId: _checkoutHostId, + prompt: prompt, + projectId: resolvedProjectId, + sourceBranch: resolvedSourceBranch, + profileId: resolvedProfileId, + workspaceBranches: workspaceBranches, + parentWorkspaceId: parentWorkspaceId, + ); final outcome = await runPromptWorkspaceCreate( client: client, loadTerminalClient: () => ref.read(terminalClientProvider(hostId).future), - request: PromptWorkspaceCreateRequest( - hostId: hostId, - checkoutHostId: _checkoutHostId, - prompt: prompt, - projectId: resolvedProjectId, - sourceBranch: resolvedSourceBranch, - profileId: resolvedProfileId, - workspaceBranches: workspaceBranches, - parentWorkspaceId: parentWorkspaceId, - ), + request: request, clientMutationId: clientMutationId, onPhase: (phase) { state = state.copyWith(phase: phase); @@ -240,6 +257,13 @@ class PromptWorkspaceController extends _$PromptWorkspaceController { onOperationId: (operationId) { _activeOperationId = operationId; }, + onServiceOperationId: (operationId) { + _activeServiceOperationId = operationId; + if (operationId != null && _cancelRequested) { + _cancelRequested = false; + unawaited(_cancelServiceOperation(operationId)); + } + }, onWorkspaceCreated: (creation) { state = state.copyWith(creation: creation, phase: 'Starting agent'); }, @@ -252,12 +276,23 @@ class PromptWorkspaceController extends _$PromptWorkspaceController { ); return outcome.creation; } on Object catch (error) { + if (error case PromptWorkspaceLaunchException( + :final serviceOperationId?, + :final creation, + )) { + _serviceRetryRequest = request.withCreated( + creation, + serviceOperationId: serviceOperationId, + ); + } state = state.copyWith( loading: false, clearPhase: true, error: error.toString(), ); rethrow; + } finally { + _cancelRequested = false; } } @@ -274,6 +309,11 @@ class PromptWorkspaceController extends _$PromptWorkspaceController { ); try { final client = await ref.read(workspaceClientProvider(hostId).future); + final serviceRetry = _serviceRetryRequest; + if (serviceRetry != null) { + await _retryAgentOnService(client, serviceRetry); + return; + } if (_originalAgentLaunchWasIdempotent != true || !client.supportsIdempotentAgentProfileLaunch) { throw UnsupportedError( @@ -316,7 +356,38 @@ class PromptWorkspaceController extends _$PromptWorkspaceController { } } + /// Relaunches the agent through the runtime operation that kept the + /// workspace, which owns both the launch and its Setup tab. + Future _retryAgentOnService( + MobileWorkspaceClient client, + PromptWorkspaceCreateRequest request, + ) async { + try { + final outcome = await runPromptWorkspaceCreate( + client: client, + loadTerminalClient: () => + ref.read(terminalClientProvider(hostId).future), + request: request, + clientMutationId: _agentLaunchMutationId ?? request.serviceOperationId!, + ); + _serviceRetryRequest = null; + state = state.copyWith( + creation: outcome.creation, + loading: false, + agentTabId: outcome.agentTabId, + clearPhase: true, + ); + } on PromptWorkspaceLaunchException catch (failure) { + _serviceRetryRequest = request.withCreated( + failure.creation, + serviceOperationId: failure.serviceOperationId, + ); + rethrow; + } + } + void resetForAnother() { + _serviceRetryRequest = null; _agentLaunchMutationId = null; _originalAgentLaunchWasIdempotent = null; state = state.copyWith( @@ -329,14 +400,33 @@ class PromptWorkspaceController extends _$PromptWorkspaceController { } Future cancelGeneration() async { + final serviceOperationId = _activeServiceOperationId; + if (serviceOperationId != null) { + await _cancelServiceOperation(serviceOperationId); + return; + } final operationId = _activeOperationId; if (operationId == null) { + // The runtime has not answered the start yet; cancel the operation as + // soon as it names one. + if (_creating) { + _cancelRequested = true; + } return; } final client = await ref.read(workspaceClientProvider(hostId).future); await client.cancelWorkspaceIdentity(operationId); } + Future _cancelServiceOperation(String operationId) async { + final client = await ref.read(workspaceClientProvider(hostId).future); + final service = promptWorkspaceServiceOf(client); + if (service == null) { + return; + } + await cancelPromptWorkspaceOperation(service, operationId); + } + Future _preferredSourceFor(String projectId) async { try { final client = await ref.read(workspaceClientProvider(hostId).future); diff --git a/mobile/lib/src/features/workbench/application/prompt_workspace_controller.g.dart b/mobile/lib/src/features/workbench/application/prompt_workspace_controller.g.dart index e07b4e798..47138ac10 100644 --- a/mobile/lib/src/features/workbench/application/prompt_workspace_controller.g.dart +++ b/mobile/lib/src/features/workbench/application/prompt_workspace_controller.g.dart @@ -60,7 +60,7 @@ final class PromptWorkspaceControllerProvider } String _$promptWorkspaceControllerHash() => - r'c4df4e1d058f8215dc748c38b38a90d670a573e4'; + r'e46bd5e6f5dd3b08fc378c2ac513167bc9edb531'; final class PromptWorkspaceControllerFamily extends $Family with diff --git a/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart b/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart index 1cf68cf73..60569cb0a 100644 --- a/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart +++ b/mobile/lib/src/features/workbench/application/prompt_workspace_pipeline.dart @@ -2,6 +2,7 @@ import 'package:alera_mobile/src/features/runtime/domain/agent_profile_summary.d import 'package:alera_mobile/src/features/runtime/domain/runtime_client_surfaces.dart'; import 'package:alera_mobile/src/features/runtime/domain/workspace_creation_result.dart'; import 'package:alera_mobile/src/features/workbench/application/deferred_workspace_setup_launcher.dart'; +import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_service.dart'; import 'package:alera_mobile/src/features/workbench/domain/background_setup_job.dart'; import 'package:logging/logging.dart'; @@ -12,6 +13,7 @@ class PromptWorkspaceLaunchException implements Exception { this.clientMutationId, this.setupStarted = false, this.originalLaunchWasIdempotent, + this.serviceOperationId, }); final WorkspaceCreationResult creation; @@ -20,6 +22,10 @@ class PromptWorkspaceLaunchException implements Exception { final bool setupStarted; final bool? originalLaunchWasIdempotent; + /// Set when the runtime service created [creation]; its retry relaunches + /// the agent through that operation. + final String? serviceOperationId; + @override String toString() => cause.toString(); } @@ -36,7 +42,9 @@ Future runPromptWorkspaceCreate({ required String clientMutationId, void Function(String phase)? onPhase, void Function(String? operationId)? onOperationId, + void Function(String? operationId)? onServiceOperationId, void Function(WorkspaceCreationResult creation)? onWorkspaceCreated, + String? serviceRequestId, }) async { if (request.useProjectCheckout && request.created == null) { if (!requireSharedCheckoutClient(client).supportsSharedCheckoutWorkspaces) { @@ -59,6 +67,19 @@ Future runPromptWorkspaceCreate({ 'Complete the prompt, project, branch, and agent profile.', ); } + final service = promptWorkspaceServiceOf(client); + if (service != null && + (request.created == null || request.serviceOperationId != null)) { + return _runOnService( + service, + request: request, + requestId: + serviceRequestId ?? 'mobile-prompt-workspace:$clientMutationId', + onPhase: onPhase, + onOperationId: onServiceOperationId, + onWorkspaceCreated: onWorkspaceCreated, + ); + } WorkspaceCreationResult? creation = request.created; Object? collisionError; for (var attempt = 0; creation == null && attempt < 2; attempt++) { @@ -212,6 +233,53 @@ Future runPromptWorkspaceCreate({ } } +/// The runtime runs every step, including the Setup tab, so the phone never +/// launches the deferred setup on this path. [onOperationId] receives the +/// running `workspace.promptStart` operation, cleared once it ends, so a +/// cancel reaches that operation rather than the client-side identity call. +Future _runOnService( + MobilePromptWorkspaceServiceClient service, { + required PromptWorkspaceCreateRequest request, + required String requestId, + void Function(String phase)? onPhase, + void Function(String? operationId)? onOperationId, + void Function(WorkspaceCreationResult creation)? onWorkspaceCreated, +}) async { + late final PromptWorkspaceOperation operation; + try { + operation = await runPromptWorkspaceOperation( + service, + request: request, + requestId: requestId, + onPhase: onPhase, + onStarted: onOperationId, + ); + } finally { + onOperationId?.call(null); + } + final creation = operation.creation; + if (creation == null) { + throw PromptWorkspaceServiceFailure(operation); + } + onWorkspaceCreated?.call(creation); + final agentTabId = operation.agentTabId; + if (operation.status == PromptWorkspaceOperationStatus.completed && + agentTabId != null) { + return PromptWorkspaceCreateOutcome( + creation: creation, + agentTabId: agentTabId, + ); + } + throw PromptWorkspaceLaunchException( + creation: creation, + cause: PromptWorkspaceServiceFailure(operation), + clientMutationId: request.clientMutationId, + setupStarted: true, + originalLaunchWasIdempotent: true, + serviceOperationId: operation.id, + ); +} + bool _looksLikeCollision(Object error) { final message = error.toString().toLowerCase(); return message.contains('already exists') || diff --git a/mobile/lib/src/features/workbench/application/prompt_workspace_service.dart b/mobile/lib/src/features/workbench/application/prompt_workspace_service.dart new file mode 100644 index 000000000..0e1c001ce --- /dev/null +++ b/mobile/lib/src/features/workbench/application/prompt_workspace_service.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:alera_mobile/src/core/json_payload_fields.dart'; +import 'package:alera_mobile/src/features/runtime/domain/runtime_client_surfaces.dart'; +import 'package:alera_mobile/src/features/runtime/domain/workspace_creation_result.dart'; +import 'package:alera_mobile/src/features/workbench/domain/background_setup_job.dart'; + +enum PromptWorkspaceOperationStatus { + running, + needsInput, + completed, + failed, + cancelled; + + static PromptWorkspaceOperationStatus parse(Object? value) { + for (final status in values) { + if (status.name == value) { + return status; + } + } + return failed; + } +} + +/// One `workspace.promptStart` operation as the runtime reports it. +class const PromptWorkspaceOperation({ + required final String id, + required final PromptWorkspaceOperationStatus status, + required final String phase, + final Map? workspace, + final String? agentTabId, + final String? setupTabId, + final String? setupCommand, + final List warnings = const [], + final String? errorMessage, +}) { + factory fromJson(Map json) { + final workspace = json['workspace'] is Map + ? json.mapValue('workspace') + : null; + final setup = json.mapValue('setup'); + return PromptWorkspaceOperation( + id: json.requiredString('id'), + status: .parse(json['status']), + phase: json.optionalString('phase') ?? '', + workspace: workspace?.optionalString('id') == null ? null : workspace, + agentTabId: json.mapValue('agent').optionalString('tabId'), + setupTabId: setup.optionalString('tabId'), + setupCommand: setup.optionalString('command'), + warnings: [ + for (final warning in json.stringList('warnings')) + if (warning.trim().isNotEmpty) warning, + ], + errorMessage: json.mapValue('error').optionalString('message'), + ); + } + + bool get isRunning => status == PromptWorkspaceOperationStatus.running; + + String get failureMessage => switch (status) { + .cancelled => 'Workspace creation was cancelled.', + .needsInput => + errorMessage ?? 'The runtime needs more input to create this workspace.', + _ => errorMessage ?? 'Workspace creation failed.', + }; + + /// The workspace as the job shows it. The host starts the Setup tab itself, + /// so the result carries no deferred command for the phone to run; when the + /// host could not start it, the warning is reported instead. + WorkspaceCreationResult? get creation { + final json = workspace; + if (json == null) { + return null; + } + final creation = WorkspaceCreationResult.fromJson({ + 'workspace': json, + }); + if (setupTabId == null && setupCommand != null) { + return creation.withSetupLaunchError( + warnings.isEmpty ? 'The worktree setup did not start.' : warnings.first, + ); + } + return creation; + } +} + +/// A runtime operation that ended without completing and without a workspace. +class PromptWorkspaceServiceFailure implements Exception { + PromptWorkspaceServiceFailure(this.operation); + + final PromptWorkspaceOperation operation; + + @override + String toString() => operation.failureMessage; +} + +/// The job phase for a runtime phase, using the same text as the client-side +/// pipeline so both paths read alike. +String promptWorkspacePhaseLabel(String phase) => switch (phase) { + 'resolvingProject' || 'generatingIdentity' => 'Generating workspace identity', + 'checkingBranch' => 'Checking generated branch', + 'creatingWorkspace' || 'assigningSection' => 'Creating workspace', + 'startingSetup' => 'Starting setup', + _ => 'Starting agent', +}; + +/// The service surface of [client] when its runtime advertises +/// [promptWorkspaceServiceCapability], else null. +MobilePromptWorkspaceServiceClient? promptWorkspaceServiceOf(Object client) { + if (client case final MobilePromptWorkspaceServiceClient service + when service.runtimeCapabilities.contains( + promptWorkspaceServiceCapability, + )) { + return service; + } + return null; +} + +/// The idempotency key of one submission of a background job. The same job, +/// attempt, and form values map to the same runtime operation, so a repeated +/// submission follows the running operation instead of creating a second +/// workspace; a later attempt or edited form starts a new one. +String promptWorkspaceServiceRequestId({ + required String jobId, + required int attempt, + required PromptWorkspaceCreateRequest request, +}) { + final fingerprint = _fnv1a( + [ + request.projectId, + request.prompt.trim(), + request.profileId, + request.useProjectCheckout, + request.sourceBranch, + request.checkoutHostId, + request.parentWorkspaceId, + request.issueUrl, + request.autoAssignSection, + ].join('\u0000'), + ); + return 'mobile-prompt-workspace:$jobId:$attempt:$fingerprint'; +} + +Map promptWorkspaceStartPayload( + PromptWorkspaceCreateRequest request, { + required String requestId, +}) { + final sourceBranch = request.sourceBranch.trim(); + final hostId = _nonEmpty(request.checkoutHostId); + return { + 'prompt': request.prompt.trim(), + 'projectId': request.projectId, + 'profile': request.profileId, + 'mode': request.useProjectCheckout ? 'projectCheckout' : 'worktree', + if (!request.useProjectCheckout && sourceBranch.isNotEmpty) + 'sourceBranch': sourceBranch, + if (hostId != null && hostId != 'local') 'hostId': hostId, + 'parentWorkspaceId': ?_nonEmpty(request.parentWorkspaceId), + 'issueUrl': ?_nonEmpty(request.issueUrl), + 'section': request.autoAssignSection ? 'auto' : 'none', + 'requestId': requestId, + }; +} + +/// Asks the runtime to stop operation [id]. The host keeps a workspace it +/// already created, and the operation can later retry its agent launch. +Future cancelPromptWorkspaceOperation( + MobilePromptWorkspaceServiceClient service, + String id, +) => service.requestMap('workspace.promptStart.cancel', { + 'id': id, +}); + +/// Starts [request] on the runtime, or relaunches the agent of the operation +/// that created its workspace, and follows it until it leaves `running`. +/// +/// [onStarted] receives the operation id as soon as the runtime accepts the +/// request, so a caller can cancel it while it runs. +/// +/// Each `promptWorkspaceOperationsChanged` for the operation triggers a read; +/// until one arrives the operation is polled every [pollInterval], since an +/// older or interrupted event stream would otherwise leave the job waiting. +Future runPromptWorkspaceOperation( + MobilePromptWorkspaceServiceClient service, { + required PromptWorkspaceCreateRequest request, + required String requestId, + void Function(String phase)? onPhase, + void Function(String operationId)? onStarted, + Duration pollInterval = const Duration(seconds: 1), + Duration eventSafetyInterval = const Duration(seconds: 10), + int maxConsecutiveReadFailures = 3, +}) async { + final retryOperationId = request.created == null + ? null + : request.serviceOperationId; + var current = PromptWorkspaceOperation.fromJson( + retryOperationId == null + ? await service.requestMap( + 'workspace.promptStart.start', + promptWorkspaceStartPayload(request, requestId: requestId), + ) + : await service.requestMap( + 'workspace.promptStart.retryLaunch', + {'id': retryOperationId}, + ), + ); + if (!current.isRunning) { + return current; + } + onStarted?.call(current.id); + onPhase?.call(promptWorkspacePhaseLabel(current.phase)); + var wake = Completer(); + var eventsSeen = false; + final subscription = service.events.listen((event) { + if (event.name != promptWorkspaceOperationsChangedEvent || + event.payload['id'] != current.id) { + return; + } + eventsSeen = true; + if (!wake.isCompleted) { + wake.complete(); + } + }); + var failures = 0; + try { + while (current.isRunning) { + await Future.any(>[ + wake.future, + Future.pause(eventsSeen ? eventSafetyInterval : pollInterval), + ]); + wake = Completer(); + try { + current = PromptWorkspaceOperation.fromJson( + await service.requestMap( + 'workspace.promptStart.get', + {'id': current.id}, + ), + ); + failures = 0; + } on Object { + failures += 1; + if (failures >= maxConsecutiveReadFailures) { + rethrow; + } + continue; + } + if (current.isRunning) { + onPhase?.call(promptWorkspacePhaseLabel(current.phase)); + } + } + return current; + } finally { + await subscription.cancel(); + } +} + +String? _nonEmpty(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} + +String _fnv1a(String value) { + var hash = 0x811c9dc5; + for (final unit in value.codeUnits) { + hash = ((hash ^ unit) * 0x01000193) & 0xffffffff; + } + return hash.toRadixString(16).padLeft(8, '0'); +} diff --git a/mobile/lib/src/features/workbench/domain/background_setup_job.dart b/mobile/lib/src/features/workbench/domain/background_setup_job.dart index 63229f5cd..097a4375b 100644 --- a/mobile/lib/src/features/workbench/domain/background_setup_job.dart +++ b/mobile/lib/src/features/workbench/domain/background_setup_job.dart @@ -42,12 +42,17 @@ class const PromptWorkspaceCreateRequest({ final String? clientMutationId, final bool? originalLaunchWasIdempotent, final bool setupStarted = false, + + /// The runtime's `workspace.promptStart` operation that created [created], + /// so a retry relaunches its agent there instead of on the phone. + final String? serviceOperationId, }) { PromptWorkspaceCreateRequest withCreated( WorkspaceCreationResult created, { String? clientMutationId, bool? originalLaunchWasIdempotent, bool? setupStarted, + String? serviceOperationId, }) { return PromptWorkspaceCreateRequest( hostId: hostId, @@ -67,6 +72,7 @@ class const PromptWorkspaceCreateRequest({ originalLaunchWasIdempotent: originalLaunchWasIdempotent ?? this.originalLaunchWasIdempotent, setupStarted: setupStarted ?? this.setupStarted, + serviceOperationId: serviceOperationId ?? this.serviceOperationId, ); } diff --git a/mobile/lib/src/features/workbench/presentation/pull_request_agent_dispatch.dart b/mobile/lib/src/features/workbench/presentation/pull_request_agent_dispatch.dart index 92ed90707..ff6924f90 100644 --- a/mobile/lib/src/features/workbench/presentation/pull_request_agent_dispatch.dart +++ b/mobile/lib/src/features/workbench/presentation/pull_request_agent_dispatch.dart @@ -28,14 +28,24 @@ Future dispatchPullRequestFailedChecks({ required String hostId, required String workspaceId, required MobilePullRequestReview review, -}) { - return _showAndComplete( +}) async { + final prompt = + await _runtimePrompt( + ref, + hostId: hostId, + workspaceId: workspaceId, + kind: 'fixFailedChecks', + reviewNumber: review.number, + ) ?? + pullRequestFailedChecksPrompt(review.number); + if (!context.mounted) return; + await _showAndComplete( context, ref, request: AgentTaskDispatchRequest( hostId: hostId, workspaceId: workspaceId, - prompt: pullRequestFailedChecksPrompt(review.number), + prompt: prompt, message: _agentDispatchMessage, ), ); @@ -46,20 +56,54 @@ Future dispatchPullRequestRestack({ required WidgetRef ref, required String hostId, required String workspaceId, -}) { - return _showAndComplete( +}) async { + final prompt = + await _runtimePrompt( + ref, + hostId: hostId, + workspaceId: workspaceId, + kind: 'restack', + ) ?? + pullRequestRestackPrompt; + if (!context.mounted) return; + await _showAndComplete( context, ref, request: AgentTaskDispatchRequest( hostId: hostId, workspaceId: workspaceId, - prompt: pullRequestRestackPrompt, + prompt: prompt, title: 'Restack Changes', message: _agentDispatchMessage, ), ); } +/// The runtime's prompt when it owns them (`pullRequestAgentDispatchV1`); +/// null keeps the bundled copy for an older runtime. +Future _runtimePrompt( + WidgetRef ref, { + required String hostId, + required String workspaceId, + required String kind, + int? reviewNumber, +}) async { + try { + final client = await ref.read(workspaceClientProvider(hostId).future); + if (client is! MobilePullRequestAgentDispatchClient) { + return null; + } + return await (client as MobilePullRequestAgentDispatchClient) + .pullRequestAgentPrompt( + workspaceId: workspaceId, + kind: kind, + reviewNumber: reviewNumber, + ); + } on Object { + return null; + } +} + Future startPullRequestAgentWatch({ required BuildContext context, required WidgetRef ref, diff --git a/mobile/lib/src/features/workbench/presentation/pull_request_panel_actions.dart b/mobile/lib/src/features/workbench/presentation/pull_request_panel_actions.dart index b224569c7..14aec6ae4 100644 --- a/mobile/lib/src/features/workbench/presentation/pull_request_panel_actions.dart +++ b/mobile/lib/src/features/workbench/presentation/pull_request_panel_actions.dart @@ -141,6 +141,7 @@ class const PullRequestPanelActions({ number: number, body: body, replyToCommentId: root.id, + replyToThreadId: root.threadId, ), ), ); diff --git a/mobile/test/inbox_domain_test.dart b/mobile/test/inbox_domain_test.dart index d7ac85a62..71c27d17a 100644 --- a/mobile/test/inbox_domain_test.dart +++ b/mobile/test/inbox_domain_test.dart @@ -28,6 +28,14 @@ void main() { expect(thread.unreadReplyCount, 2); expect(thread.recipientLabel, 'claude · Fix Login'); expect(thread.origin!.label, 'Alera mobile on Pixel'); + expect( + InboxOrigin.fromJson(const { + 'surface': 'mcp', + 'clientId': 'claude-code', + }).label, + 'claude-code (MCP)', + ); + expect(const InboxOrigin(surface: 'mcp').label, 'MCP client'); expect(thread.target.workspaceName, 'auth'); final detail = InboxThreadDetail.fromJson({ diff --git a/mobile/test/mobile_runtime_pull_request_requests_test.dart b/mobile/test/mobile_runtime_pull_request_requests_test.dart new file mode 100644 index 000000000..e5ca5bcca --- /dev/null +++ b/mobile/test/mobile_runtime_pull_request_requests_test.dart @@ -0,0 +1,80 @@ +import 'package:alera_mobile/src/core/mobile_protocol.dart'; +import 'package:alera_mobile/src/features/runtime/domain/mobile_pull_request_actions.dart'; +import 'package:alera_mobile/src/features/runtime/domain/mobile_workspace_panels.dart'; +import 'package:alera_mobile/src/features/runtime/infra/mobile_runtime_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Records what the pull request requests send instead of reaching a runtime. +final class _RecordingRequests with MobileRuntimePullRequestRequests { + final List<(String, Map)> requests = + <(String, Map)>[]; + + @override + Set get runtimeCapabilities => const { + mobilePullRequestActionsCapability, + }; + + @override + Future> requestMap( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async { + requests.add((type, payload)); + return { + 'branch': 'feat/gitlab', + 'provider': 'gitlab', + 'authStatus': 'authenticated', + 'mergeMethods': ['providerDefault', 'squash'], + }; + } +} + +void main() { + test( + 'a provider-default merge sends the wire name the runtime parses', + () async { + final requests = _RecordingRequests(); + + await requests.mergePullRequest( + workspaceId: 'workspace-1', + number: 12, + method: MobilePullRequestMergeMethod.providerDefault, + ); + + final (type, payload) = requests.requests.single; + expect(type, 'mobile.pullRequest.merge'); + expect(payload, { + 'workspaceId': 'workspace-1', + 'number': 12, + 'method': 'providerDefault', + }); + }, + ); + + test('replies and edits name their thread, as Azure DevOps needs', () async { + final requests = _RecordingRequests(); + + await requests.commentOnPullRequest( + workspaceId: 'workspace-1', + number: 12, + body: 'Done', + replyToCommentId: 1, + replyToThreadId: '7', + ); + await requests.editPullRequestComment( + workspaceId: 'workspace-1', + number: 12, + comment: const MobilePullRequestComment( + id: 1, + source: 'reviewThread', + threadId: '8', + ), + body: 'Edited', + ); + + expect(requests.requests[0].$2['replyToThreadId'], '7'); + expect(requests.requests[0].$2['replyToCommentId'], 1); + expect(requests.requests[1].$2['threadId'], '8'); + }); +} diff --git a/mobile/test/prompt_workspace_service_test.dart b/mobile/test/prompt_workspace_service_test.dart new file mode 100644 index 000000000..0ac9d4a4e --- /dev/null +++ b/mobile/test/prompt_workspace_service_test.dart @@ -0,0 +1,470 @@ +import 'dart:async'; + +import 'package:alera_mobile/src/features/runtime/infra/mobile_runtime_client.dart'; +import 'package:alera_mobile/src/features/terminal/application/terminal_providers.dart'; +import 'package:alera_mobile/src/features/workbench/application/background_setup_jobs.dart'; +import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_controller.dart'; +import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_pipeline.dart'; +import 'package:alera_mobile/src/features/workbench/application/prompt_workspace_service.dart'; +import 'package:alera_mobile/src/features/workbench/application/workbench_providers.dart'; +import 'package:alera_mobile/src/features/workbench/domain/background_setup_job.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'support/fake_terminal_client.dart'; + +/// A paired runtime that may run New Workspace from Prompt itself. +class _ServiceClient extends FakeTerminalClient + implements MobilePromptWorkspaceServiceClient { + _ServiceClient({bool service = true}) + : runtimeCapabilities = { + if (service) promptWorkspaceServiceCapability, + }; + + @override + final Set runtimeCapabilities; + final List<(String, Map)> requests = + <(String, Map)>[]; + final List> reads = >[]; + Map started = _operation('running', 'resolvingProject'); + + /// Holds the start answer back until completed, when set. + Completer? startGate; + final StreamController serviceEvents = + StreamController.broadcast(); + + @override + Stream get events => serviceEvents.stream; + + @override + Future> requestMap( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async { + requests.add((type, payload)); + if (type == 'workspace.promptStart.start') { + await startGate?.future; + } + return switch (type) { + 'workspace.promptStart.start' || + 'workspace.promptStart.retryLaunch' => started, + 'workspace.promptStart.get' => reads.removeAt(0), + 'workspace.promptStart.cancel' => { + 'id': payload['id'], + 'cancelling': true, + }, + _ => throw StateError('unexpected $type'), + }; + } + + void changed() => serviceEvents.add( + const MobileRuntimeEvent(promptWorkspaceOperationsChangedEvent, { + 'id': 'op-1', + }), + ); + + List get types => [ + for (final request in requests) request.$1, + ]; + + /// The payloads of every [type] request, in order. + List> payloadsOf(String type) => >[ + for (final request in requests) + if (request.$1 == type) request.$2, + ]; +} + +Map _operation( + String status, + String phase, { + bool workspace = false, + String? agentTabId, + String? errorMessage, +}) => { + 'id': 'op-1', + 'status': status, + 'phase': phase, + if (workspace) + 'workspace': const { + 'id': 'ws-1', + 'projectId': 'project', + 'name': 'Prompt Workspace', + 'path': '/repo/ws-1', + 'branch': 'feat/prompt-workspace', + }, + if (agentTabId != null) 'agent': {'tabId': agentTabId}, + if (workspace) 'setup': const {'tabId': 'setup-tab'}, + if (errorMessage != null) + 'error': { + 'code': 'failed', + 'message': errorMessage, + 'retryable': workspace, + }, +}; + +const _request = PromptWorkspaceCreateRequest( + hostId: 'host', + checkoutHostId: 'local', + projectId: 'project', + prompt: ' Build the feature ', + sourceBranch: 'main', + profileId: 'profile-1', + workspaceBranches: {}, + parentWorkspaceId: 'parent-1', + autoAssignSection: true, +); + +Future _create( + _ServiceClient client, { + PromptWorkspaceCreateRequest request = _request, + List? phases, +}) => runPromptWorkspaceCreate( + client: client, + loadTerminalClient: () async => client, + request: request, + clientMutationId: 'mutation-1', + serviceRequestId: 'req-1', + onPhase: phases?.add, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('runs the client pipeline when the runtime has no service', () async { + final client = _ServiceClient(service: false) + ..projectBranches = const ['main']; + addTearDown(client.dispose); + + final outcome = await _create(client); + + expect(client.requests, isEmpty); + expect( + client.calls, + contains('createWorkspace project feat/generated-workspace'), + ); + expect(outcome.agentTabId, 'agent-tab'); + }); + + test('delegates to the runtime and follows its change events', () async { + final client = _ServiceClient() + ..deferredSetupCommand = '/bin/sh "/runtime/setup.sh"' + ..reads.addAll(>[ + _operation('running', 'creatingWorkspace', workspace: true), + _operation( + 'completed', + 'done', + workspace: true, + agentTabId: 'agent-tab', + ), + ]); + addTearDown(client.dispose); + final phases = []; + + final future = _create(client, phases: phases); + await pumpEventQueue(); + client.changed(); + await pumpEventQueue(); + client.changed(); + final outcome = await future; + + expect(client.requests.first.$2, { + 'prompt': 'Build the feature', + 'projectId': 'project', + 'profile': 'profile-1', + 'mode': 'worktree', + 'sourceBranch': 'main', + 'parentWorkspaceId': 'parent-1', + 'section': 'auto', + 'requestId': 'req-1', + }); + expect(client.types, [ + 'workspace.promptStart.start', + 'workspace.promptStart.get', + 'workspace.promptStart.get', + ]); + expect(phases, [ + 'Generating workspace identity', + 'Creating workspace', + ]); + expect(outcome.agentTabId, 'agent-tab'); + expect(outcome.creation.workspace.id, 'ws-1'); + expect(outcome.creation.hasDeferredSetup, isFalse); + // The host created the Setup tab; the phone starts neither a workspace, + // an agent, nor a second Setup terminal. + expect(client.calls, isEmpty); + }); + + test('polls the operation when no change event arrives', () async { + final client = _ServiceClient() + ..started = _operation('running', 'generatingIdentity') + ..reads.addAll(>[ + _operation('running', 'launchingAgent', workspace: true), + _operation('completed', 'done', workspace: true, agentTabId: 'a'), + ]); + addTearDown(client.dispose); + + final operation = await runPromptWorkspaceOperation( + client, + request: _request, + requestId: 'req-1', + pollInterval: Duration.zero, + ); + + expect(operation.status, PromptWorkspaceOperationStatus.completed); + expect(operation.agentTabId, 'a'); + }); + + test( + 'a launch failure keeps the workspace and retries on the host', + () async { + final client = _ServiceClient() + ..started = _operation( + 'failed', + 'launchingAgent', + workspace: true, + errorMessage: 'The agent did not start.', + ); + addTearDown(client.dispose); + + final failure = await _create(client) + .then( + (_) => null, + onError: (Object error) => error as PromptWorkspaceLaunchException, + ); + expect(failure!.toString(), 'The agent did not start.'); + expect(failure.serviceOperationId, 'op-1'); + expect(failure.setupStarted, isTrue); + expect(failure.creation.workspace.id, 'ws-1'); + + client.started = _operation( + 'completed', + 'done', + workspace: true, + agentTabId: 'agent-tab', + ); + final retried = await _create( + client, + request: _request.withCreated( + failure.creation, + serviceOperationId: failure.serviceOperationId, + ), + ); + expect(client.requests.last.$1, 'workspace.promptStart.retryLaunch'); + expect(client.requests.last.$2, {'id': 'op-1'}); + expect(retried.agentTabId, 'agent-tab'); + expect(client.calls, isEmpty); + }, + ); + + test('a prompt the runtime cannot place fails with its message', () async { + final client = _ServiceClient() + ..started = _operation( + 'needsInput', + 'resolvingProject', + errorMessage: 'The prompt does not clearly name a project.', + ); + addTearDown(client.dispose); + + await expectLater( + _create(client), + throwsA( + isA().having( + (failure) => failure.toString(), + 'message', + 'The prompt does not clearly name a project.', + ), + ), + ); + }); + + test('a background job retries the launch through its operation', () async { + final client = _ServiceClient() + ..started = _operation( + 'failed', + 'launchingAgent', + workspace: true, + errorMessage: 'The agent did not start.', + ); + addTearDown(client.dispose); + final container = ProviderContainer( + overrides: [ + workspaceClientProvider('host').overrideWith((ref) async => client), + terminalClientProvider('host').overrideWith((ref) async => client), + ], + ); + addTearDown(container.dispose); + final jobs = container.read(backgroundSetupJobsProvider.notifier); + + await expectLater( + jobs.enqueuePromptWorkspace(_request, jobId: 'job-1'), + throwsA(isA()), + ); + final requestId = client.requests.single.$2['requestId'] as String?; + expect(requestId, startsWith('mobile-prompt-workspace:job-1:0:')); + final job = container.read(backgroundSetupJobsProvider).jobById('job-1'); + expect(job?.isFailed, isTrue); + final snapshot = job!.snapshot as PromptWorkspaceCreateRequest; + expect(snapshot.serviceOperationId, 'op-1'); + expect(snapshot.created?.workspace.id, 'ws-1'); + + client.started = _operation( + 'completed', + 'done', + workspace: true, + agentTabId: 'agent-tab', + ); + final outcome = await jobs.enqueuePromptWorkspace(_request, jobId: 'job-1'); + + expect(client.types.last, 'workspace.promptStart.retryLaunch'); + expect(outcome.agentTabId, 'agent-tab'); + expect( + container.read(backgroundSetupJobsProvider).jobById('job-1'), + isNull, + ); + }); + + test('reports the running operation so a caller can cancel it', () async { + final client = _ServiceClient() + ..started = _operation('running', 'generatingIdentity') + ..reads.add(_operation('cancelled', 'generatingIdentity')); + addTearDown(client.dispose); + final ids = []; + + final future = runPromptWorkspaceCreate( + client: client, + loadTerminalClient: () async => client, + request: _request, + clientMutationId: 'mutation-1', + serviceRequestId: 'req-1', + onServiceOperationId: ids.add, + ); + await pumpEventQueue(); + expect(ids, ['op-1']); + await cancelPromptWorkspaceOperation(client, ids.single!); + client.changed(); + + await expectLater( + future, + throwsA( + isA().having( + (failure) => failure.toString(), + 'message', + 'Workspace creation was cancelled.', + ), + ), + ); + expect(ids, ['op-1', null]); + expect(client.types[1], 'workspace.promptStart.cancel'); + expect(client.payloadsOf('workspace.promptStart.cancel'), [ + {'id': 'op-1'}, + ]); + }); + + group('prompt workspace controller on the runtime service', () { + Future controllerFor( + _ServiceClient client, + ) async { + final container = ProviderContainer( + overrides: [ + workspaceClientProvider('host').overrideWith((ref) async => client), + terminalClientProvider('host').overrideWith((ref) async => client), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen( + promptWorkspaceControllerProvider('host'), + (_, _) {}, + ); + addTearDown(subscription.close); + final controller = container.read( + promptWorkspaceControllerProvider('host').notifier, + ); + await controller.selectProject('project'); + return controller; + } + + test('cancel stops the operation and Retry Agent relaunches it', () async { + final client = _ServiceClient() + ..projectBranches = const ['main'] + ..started = _operation('running', 'generatingIdentity') + ..reads.add(_operation('cancelled', 'launchingAgent', workspace: true)); + addTearDown(client.dispose); + final controller = await controllerFor(client); + + final created = controller.create( + prompt: 'Build the feature', + workspaceBranches: const {}, + ); + final failure = expectLater( + created, + throwsA(isA()), + ); + await pumpEventQueue(); + expect(controller.state.phase, 'Generating workspace identity'); + await controller.cancelGeneration(); + client.changed(); + await failure; + + expect(client.payloadsOf('workspace.promptStart.cancel'), [ + {'id': 'op-1'}, + ]); + expect( + client.calls.where((call) => call.startsWith('cancelWorkspace')), + isEmpty, + ); + expect(controller.state.creation?.workspace.id, 'ws-1'); + + // The host kept the workspace; Retry Agent relaunches the operation + // there rather than launching a second agent from the phone. + client.started = _operation( + 'completed', + 'done', + workspace: true, + agentTabId: 'agent-tab', + ); + await controller.retryAgent('Build the feature'); + + expect(client.requests.last.$1, 'workspace.promptStart.retryLaunch'); + expect(client.requests.last.$2, {'id': 'op-1'}); + expect(controller.state.agentTabId, 'agent-tab'); + expect(controller.state.error, isNull); + expect( + client.calls.where((call) => call.startsWith('launchAgentProfile')), + isEmpty, + ); + }); + + test('cancel before the runtime answers reaches the operation', () async { + final gate = Completer(); + final client = _ServiceClient() + ..projectBranches = const ['main'] + ..startGate = gate + ..started = _operation('running', 'generatingIdentity') + ..reads.add(_operation('cancelled', 'generatingIdentity')); + addTearDown(client.dispose); + final controller = await controllerFor(client); + + final failure = expectLater( + controller.create( + prompt: 'Build the feature', + workspaceBranches: const {}, + ), + throwsA(isA()), + ); + await pumpEventQueue(); + await controller.cancelGeneration(); + expect(client.types, ['workspace.promptStart.start']); + + gate.complete(); + await pumpEventQueue(); + expect(client.types[1], 'workspace.promptStart.cancel'); + expect(client.payloadsOf('workspace.promptStart.cancel'), [ + {'id': 'op-1'}, + ]); + client.changed(); + await failure; + expect(controller.state.error, 'Workspace creation was cancelled.'); + }); + }); +} diff --git a/mobile/test/pull_request_actions_test.dart b/mobile/test/pull_request_actions_test.dart index b7e7ecabb..c19f61dc2 100644 --- a/mobile/test/pull_request_actions_test.dart +++ b/mobile/test/pull_request_actions_test.dart @@ -144,6 +144,41 @@ void main() { ); }); + test('a GitLab merge request offers its project settings merge', () { + final snapshot = _snapshot( + mergeMethods: ['providerDefault', 'squash'], + ); + expect(labels(snapshot), [ + 'Merge Using Project Settings', + 'Squash and Merge', + 'Convert To Draft', + 'Close Pull Request', + 'Unlink Pull Request', + ]); + expect( + availablePullRequestReviewActions(snapshot).first, + const MobilePullRequestReviewAction( + kind: .merge, + method: MobilePullRequestMergeMethod.providerDefault, + ), + ); + expect( + MobilePullRequestMergeMethod.providerDefault.wireName, + 'providerDefault', + ); + }); + + test('prefers provider-default wherever the runtime lists it', () { + expect( + preferredMobilePullRequestMergeMethod(const [ + 'squash', + 'providerDefault', + ]), + MobilePullRequestMergeMethod.providerDefault, + ); + expect(preferredMobilePullRequestMergeMethod(const []), isNull); + }); + test('unknown merge methods are ignored', () { expect( labels(_snapshot(mergeMethods: ['octopus', 'mergeCommit'])), @@ -165,6 +200,14 @@ void main() { pullRequestActionConfirmation(squash, 7).title, 'Squash and Merge PR #7?', ); + const projectSettings = MobilePullRequestReviewAction( + kind: .merge, + method: MobilePullRequestMergeMethod.providerDefault, + ); + expect( + pullRequestActionConfirmation(projectSettings, 7).title, + 'Merge Using Project Settings PR #7?', + ); }); test('runtime errors keep their wording', () { diff --git a/mobile/test/pull_request_actions_widget_cases.dart b/mobile/test/pull_request_actions_widget_cases.dart index 03edbe883..4671a9232 100644 --- a/mobile/test/pull_request_actions_widget_cases.dart +++ b/mobile/test/pull_request_actions_widget_cases.dart @@ -21,6 +21,26 @@ void _registerPullRequestActionsWidgetTests() { expect(find.text('Merged'), findsOneWidget); }); + testWidgets('a GitLab merge request merges with its project settings', ( + tester, + ) async { + final client = _client( + _snapshot(mergeMethods: ['providerDefault', 'squash']), + )..nextSnapshot = _snapshot(state: 'MERGED'); + addTearDown(client.dispose); + await _openPullRequest(tester, client); + + await tester.tap(find.text('Merge Using Project Settings')); + await tester.pumpAndSettle(); + expect(find.text('Merge Using Project Settings PR #700?'), findsOneWidget); + await tester.tap( + find.widgetWithText(FilledButton, 'Merge Using Project Settings').last, + ); + await tester.pumpAndSettle(); + + expect(client.calls, contains('mergePullRequest 700 providerDefault')); + }); + testWidgets('other actions live in the overflow sheet', (tester) async { final client = _client(_snapshot()); addTearDown(client.dispose); @@ -121,7 +141,10 @@ void _registerPullRequestActionsWidgetTests() { await tester.enterText(find.byType(TextField).last, 'Done'); await tester.tap(find.widgetWithText(FilledButton, 'Reply')); await tester.pumpAndSettle(); - expect(client.calls, contains('commentOnPullRequest 700 Done reply:21')); + expect( + client.calls, + contains('commentOnPullRequest 700 Done reply:21 thread:T1'), + ); await tester.scrollUntilVisible(find.byTooltip('Edit Comment'), -200); await tester.tap(find.byTooltip('Edit Comment')); diff --git a/mobile/test/pull_request_agent_prompt_requests_test.dart b/mobile/test/pull_request_agent_prompt_requests_test.dart new file mode 100644 index 000000000..75a04cadc --- /dev/null +++ b/mobile/test/pull_request_agent_prompt_requests_test.dart @@ -0,0 +1,61 @@ +import 'package:alera_mobile/src/features/pull_requests/infra/mobile_runtime_pull_request_watch_requests.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('runtime watch execution covers V1 and V2 runtimes', () { + expect( + _Requests({'pullRequestWatchExecutionV1'}) + .supportsPullRequestWatchExecution, + isTrue, + ); + expect( + _Requests({'pullRequestWatchExecutionV2'}) + .supportsPullRequestWatchExecution, + isTrue, + ); + expect(_Requests({}).supportsPullRequestWatchExecution, isFalse); + }); + + test('agent prompts come from runtimes that own them', () async { + final runtime = _Requests({'pullRequestAgentDispatchV1'}); + final prompt = await runtime.pullRequestAgentPrompt( + workspaceId: 'w', + kind: 'fixFailedChecks', + reviewNumber: 9, + ); + expect(prompt, 'Pull request #9 checks failed. Please fix them.'); + expect(runtime.payloads.single, { + 'workspaceId': 'w', + 'kind': 'fixFailedChecks', + 'number': 9, + }); + final older = _Requests({}); + expect( + await older.pullRequestAgentPrompt(workspaceId: 'w', kind: 'restack'), + isNull, + ); + expect(older.payloads, isEmpty); + }); +} + +class _Requests with MobileRuntimePullRequestWatchRequests { + _Requests(this.runtimeCapabilities); + + @override + final Set runtimeCapabilities; + final payloads = >[]; + + @override + Future> requestMap( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async { + expect(type, 'pullRequest.agentDispatch'); + payloads.add(payload); + return { + 'prompt': + 'Pull request #${payload['number']} checks failed. Please fix them.', + }; + } +} diff --git a/mobile/test/support/fake_pull_request_actions_client.dart b/mobile/test/support/fake_pull_request_actions_client.dart index 7bcb54a2b..e403d165b 100644 --- a/mobile/test/support/fake_pull_request_actions_client.dart +++ b/mobile/test/support/fake_pull_request_actions_client.dart @@ -68,9 +68,11 @@ mixin FakePullRequestActionsClient implements MobilePullRequestActionsClient { required int number, required String body, int? replyToCommentId, + String? replyToThreadId, }) => _answer( 'commentOnPullRequest $number $body' - '${replyToCommentId == null ? '' : ' reply:$replyToCommentId'}', + '${replyToCommentId == null ? '' : ' reply:$replyToCommentId'}' + '${replyToThreadId == null ? '' : ' thread:$replyToThreadId'}', ); @override diff --git a/rust/alera-cli/src/agent_profile_launch.rs b/rust/alera-cli/src/agent_profile_launch.rs index f046b89d6..cbe2d7871 100644 --- a/rust/alera-cli/src/agent_profile_launch.rs +++ b/rust/alera-cli/src/agent_profile_launch.rs @@ -7,7 +7,10 @@ use crate::agent_profile_commands::{ }; use crate::cli::{AgentProfileLaunchArgs, RuntimeDirArgs}; use crate::runtime_host_client::RuntimeHostRpcClient; -use crate::terminal_host::agent_profile_capabilities::RUNTIME_HOST_AGENT_PROFILE_LAUNCH_IDEMPOTENCY_CAPABILITY; +use crate::terminal_host::agent_profile_capabilities::{ + RUNTIME_HOST_AGENT_PROFILE_LAUNCH_IDEMPOTENCY_CAPABILITY, + RUNTIME_HOST_AGENT_PROFILE_SESSION_RESUME_CAPABILITY, +}; use crate::terminal_host::protocol::RUNTIME_HOST_AGENT_PROFILE_PROMPT_LAUNCH_CAPABILITY; use crate::workspace_context::resolve_workspace_context; @@ -42,11 +45,16 @@ pub async fn run( ) -> Result<()> { let prompt = args.prompt.read()?; let context = resolve_workspace_context(runtime, args.workspace.as_deref()).await?; - let envelope = launch_selected( + let profile = resolve_selected_profile(client, &args.selector).await?; + let envelope = launch_profile_request( client, - &args.selector, - &context.workspace_id, - &prompt, + LaunchRequest { + workspace_id: &context.workspace_id, + profile_id: &profile.id, + profile_name: &profile.name, + prompt: &prompt, + resume_session_id: args.prompt.resume_session_id.as_deref(), + }, args.client_mutation_id, ) .await?; @@ -75,38 +83,62 @@ pub async fn resolve_selected_profile( Ok(profile) } -pub async fn launch_selected( +pub async fn launch_profile( client: &mut RuntimeHostRpcClient, - selector: &crate::cli::AgentProfileSelectorArgs, workspace_id: &str, + profile_id: &str, + profile_name: &str, prompt: &str, client_mutation_id: Option, ) -> Result { - let profile = resolve_selected_profile(client, selector).await?; - launch_profile( + launch_profile_request( client, - workspace_id, - &profile.id, - &profile.name, - prompt, + LaunchRequest { + workspace_id, + profile_id, + profile_name, + prompt, + resume_session_id: None, + }, client_mutation_id, ) .await } -pub async fn launch_profile( +/// One launch of a resolved profile. A resume carries no prompt. +pub struct LaunchRequest<'a> { + pub workspace_id: &'a str, + pub profile_id: &'a str, + pub profile_name: &'a str, + pub prompt: &'a str, + pub resume_session_id: Option<&'a str>, +} + +/// The launch payload. `resumeSessionId` is only sent when set, so a host +/// without session resume sees the payload it always did. +pub fn launch_payload(request: &LaunchRequest<'_>, mutation_id: &str) -> Value { + let mut payload = json!({ + "workspaceId": request.workspace_id, + "profileId": request.profile_id, + "prompt": request.prompt, + "clientMutationId": mutation_id, + }); + if let Some(id) = request.resume_session_id { + payload["resumeSessionId"] = json!(id); + } + payload +} + +pub async fn launch_profile_request( client: &mut RuntimeHostRpcClient, - workspace_id: &str, - profile_id: &str, - profile_name: &str, - prompt: &str, + request: LaunchRequest<'_>, client_mutation_id: Option, ) -> Result { - ensure_capabilities( - client, - &[RUNTIME_HOST_AGENT_PROFILE_PROMPT_LAUNCH_CAPABILITY], - ) - .await?; + let mut required = vec![RUNTIME_HOST_AGENT_PROFILE_PROMPT_LAUNCH_CAPABILITY]; + if request.resume_session_id.is_some() { + required.push(RUNTIME_HOST_AGENT_PROFILE_SESSION_RESUME_CAPABILITY); + } + ensure_capabilities(client, &required).await?; let idempotent = host_has_capability( client, RUNTIME_HOST_AGENT_PROFILE_LAUNCH_IDEMPOTENCY_CAPABILITY, @@ -122,15 +154,7 @@ pub async fn launch_profile( .filter(|value| !value.is_empty()) .unwrap_or_else(|| Uuid::new_v4().to_string()); let payload = client - .request_value( - request_type, - &json!({ - "workspaceId": workspace_id, - "profileId": profile_id, - "prompt": prompt, - "clientMutationId": mutation_id, - }), - ) + .request_value(request_type, &launch_payload(&request, &mutation_id)) .await?; let tab_id = payload .get("tab") @@ -144,9 +168,9 @@ pub async fn launch_profile( .unwrap_or("") .to_string(); Ok(AgentProfileLaunchEnvelope { - workspace_id: workspace_id.to_string(), - profile_id: profile_id.to_string(), - profile_name: profile_name.to_string(), + workspace_id: request.workspace_id.to_string(), + profile_id: request.profile_id.to_string(), + profile_name: request.profile_name.to_string(), agent_type, tab_id, payload, diff --git a/rust/alera-cli/src/agent_quota_commands.rs b/rust/alera-cli/src/agent_quota_commands.rs new file mode 100644 index 000000000..27095be99 --- /dev/null +++ b/rust/alera-cli/src/agent_quota_commands.rs @@ -0,0 +1,132 @@ +//! `alera agent-quota`: the usage limits the app shows for agent providers. + +use anyhow::{bail, Result}; +use serde_json::{json, Value}; + +use crate::agent_profile_commands::ensure_capabilities; +use crate::cli::{AgentQuotaAction, AgentQuotaCommand}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::{ + RUNTIME_HOST_AGENT_QUOTA_CLAUDE_TUI_CAPABILITY, RUNTIME_HOST_CODEX_RESET_CREDITS_CAPABILITY, + RUNTIME_HOST_MOBILE_AGENT_QUOTA_CAPABILITY, +}; + +/// The app's own limits for each request: provider APIs and the Claude +/// terminal UI can be slow. +const SNAPSHOT_DEADLINE_MS: u64 = 45_000; +const CLAUDE_TUI_DEADLINE_MS: u64 = 60_000; +const CODEX_RESET_DEADLINE_MS: u64 = 45_000; + +pub(crate) async fn run(command: AgentQuotaCommand) -> i32 { + let json_output = command.output.json; + match run_command(command).await { + Ok((value, message)) => { + crate::print_value(&value, json_output, message); + 0 + } + Err(error) => crate::print_error(error), + } +} + +async fn run_command(command: AgentQuotaCommand) -> Result<(Value, &'static str)> { + let mut client = + RuntimeHostRpcClient::connect_or_start(&crate::runtime_dir(&command.runtime)).await?; + let (capability, request_type, payload, deadline, message) = request(&command.action)?; + ensure_capabilities(&mut client, &[capability]).await?; + let value = client + .request_value_with_deadline(request_type, &payload, deadline) + .await?; + Ok((value, message)) +} + +/// The capability, request, payload, deadline, and summary for an action. +pub(crate) fn request( + action: &AgentQuotaAction, +) -> Result<(&'static str, &'static str, Value, u64, &'static str)> { + Ok(match action { + AgentQuotaAction::Show(args) => ( + RUNTIME_HOST_MOBILE_AGENT_QUOTA_CAPABILITY, + "agentQuota.snapshot", + json!({ "forceRefresh": args.refresh }), + SNAPSHOT_DEADLINE_MS, + "agent quotas", + ), + AgentQuotaAction::RefreshClaude(args) => { + let account_id = args.account_id.trim(); + if account_id.is_empty() { + bail!("--account-id cannot be empty"); + } + ( + RUNTIME_HOST_AGENT_QUOTA_CLAUDE_TUI_CAPABILITY, + "agentQuota.fetchClaudeTui", + json!({ "accountId": account_id }), + CLAUDE_TUI_DEADLINE_MS, + "Claude quota refreshed", + ) + } + AgentQuotaAction::ConsumeCodexReset(args) => { + let revision = args.offer_revision.trim(); + if revision.is_empty() { + bail!("--offer-revision cannot be empty"); + } + ( + RUNTIME_HOST_CODEX_RESET_CREDITS_CAPABILITY, + "agentQuota.consumeCodexResetCredit", + json!({ "offerRevision": revision }), + CODEX_RESET_DEADLINE_MS, + "Codex reset credit requested", + ) + } + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::request; + use crate::cli::{ + AgentQuotaAction, AgentQuotaClaudeArgs, AgentQuotaCodexResetArgs, AgentQuotaShowArgs, + }; + + #[test] + fn each_action_sends_the_app_payload() { + let (_, kind, payload, _, _) = request(&AgentQuotaAction::Show(AgentQuotaShowArgs { + refresh: true, + })) + .unwrap(); + assert_eq!(kind, "agentQuota.snapshot"); + assert_eq!(payload, json!({ "forceRefresh": true })); + let (_, kind, payload, _, _) = + request(&AgentQuotaAction::RefreshClaude(AgentQuotaClaudeArgs { + account_id: " work ".into(), + })) + .unwrap(); + assert_eq!(kind, "agentQuota.fetchClaudeTui"); + assert_eq!(payload, json!({ "accountId": "work" })); + let (_, kind, payload, _, _) = request(&AgentQuotaAction::ConsumeCodexReset( + AgentQuotaCodexResetArgs { + offer_revision: "rev-7".into(), + }, + )) + .unwrap(); + assert_eq!(kind, "agentQuota.consumeCodexResetCredit"); + assert_eq!(payload, json!({ "offerRevision": "rev-7" })); + } + + #[test] + fn blank_identifiers_are_refused() { + assert!(request(&AgentQuotaAction::ConsumeCodexReset( + AgentQuotaCodexResetArgs { + offer_revision: " ".into(), + } + )) + .is_err()); + assert!( + request(&AgentQuotaAction::RefreshClaude(AgentQuotaClaudeArgs { + account_id: String::new(), + })) + .is_err() + ); + } +} diff --git a/rust/alera-cli/src/agent_skills.rs b/rust/alera-cli/src/agent_skills.rs new file mode 100644 index 000000000..1698aadf0 --- /dev/null +++ b/rust/alera-cli/src/agent_skills.rs @@ -0,0 +1,251 @@ +//! The Alera skills coding agents use in Alera terminals (`skills/` in the +//! repository): where they are installed from and whether the copies on this +//! machine match this runtime. + +use std::path::Path; + +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::host_tools::SkillKind; +use crate::terminal_host::protocol::{ + AGENT_PROFILES_SKILL_VERSION, AUTOMATIONS_SKILL_VERSION, CLI_SKILL_VERSION, + ORCHESTRATION_SKILL_VERSION, +}; + +const SKILL_REPOSITORY: &str = "https://github.com/leynier/alera"; + +impl SkillKind { + /// The version of the skill this runtime documents and expects. + pub(crate) fn contract_version(self) -> i64 { + match self { + Self::Cli => CLI_SKILL_VERSION, + Self::Orchestration => ORCHESTRATION_SKILL_VERSION, + Self::Automations => AUTOMATIONS_SKILL_VERSION, + Self::AgentProfiles => AGENT_PROFILES_SKILL_VERSION, + } + } +} + +/// The commit this binary was built from. A release is built from its tag's +/// commit, so installing at it pins the skills to this runtime's release. +fn build_commit() -> Option<&'static str> { + pinned_commit(option_env!("ALERA_BUILD_COMMIT")) +} + +fn pinned_commit(commit: Option<&'static str>) -> Option<&'static str> { + commit.filter(|value| value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit())) +} + +/// What `skills add` installs from: the repository at this build's commit, +/// or its default branch for a build without one. +pub(crate) fn install_source() -> String { + source_for(build_commit()) +} + +fn source_for(commit: Option<&str>) -> String { + match commit { + Some(commit) => format!("{SKILL_REPOSITORY}/tree/{commit}"), + None => SKILL_REPOSITORY.to_owned(), + } +} + +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentSkillStatus { + pub(crate) skill: &'static str, + pub(crate) name: &'static str, + /// `current`, `outdated`, `newer`, or `missing`. + pub(crate) state: &'static str, + pub(crate) expected_version: i64, + pub(crate) installed_version: Option, + /// The commit the installed copy came from, when the installer recorded it. + pub(crate) installed_ref: Option, + pub(crate) updated_at: Option, + pub(crate) path: String, +} + +/// The state of every agent skill under `/.agents/skills`, where the +/// `skills` installer puts global skills for every agent. +pub(crate) fn status(home: &Path) -> Value { + status_with_commit(home, build_commit()) +} + +fn status_with_commit(home: &Path, commit: Option<&str>) -> Value { + let lock = std::fs::read_to_string(home.join(".agents").join(".skill-lock.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .unwrap_or(Value::Null); + let skills = SkillKind::ALL + .iter() + .map(|kind| skill_status(home, *kind, &lock)) + .collect::>(); + let ready = skills.iter().all(|skill| skill.state == "current"); + json!({ + "ready": ready, + "source": source_for(commit), + "pinnedRef": commit, + "skills": skills, + }) +} + +fn skill_status(home: &Path, kind: SkillKind, lock: &Value) -> AgentSkillStatus { + let folder = home + .join(".agents") + .join("skills") + .join(kind.package_name()); + let installed = std::fs::read_to_string(folder.join("SKILL.md")).ok(); + let installed_version = installed.as_deref().and_then(frontmatter_version); + let expected_version = kind.contract_version(); + let state = match (&installed, installed_version) { + (None, _) => "missing", + (Some(_), Some(version)) if version == expected_version => "current", + (Some(_), Some(version)) if version > expected_version => "newer", + // A copy without a version predates versioned skills. + (Some(_), _) => "outdated", + }; + let entry = &lock["skills"][kind.package_name()]; + AgentSkillStatus { + skill: kind.id(), + name: kind.package_name(), + state, + expected_version, + installed_version, + installed_ref: entry["ref"].as_str().map(str::to_owned), + updated_at: entry["updatedAt"].as_str().map(str::to_owned), + path: folder.to_string_lossy().into_owned(), + } +} + +/// `metadata.version` from the leading frontmatter block. +fn frontmatter_version(contents: &str) -> Option { + let mut lines = contents.lines(); + if lines.next()?.trim() != "---" { + return None; + } + lines + .take_while(|line| line.trim() != "---") + .find_map(|line| line.trim().strip_prefix("version:")) + .and_then(|value| value.trim().parse().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const COMMIT: &str = "c8b3d3984f850a7b0b9a0692e70c7501ba4f7d1c"; + + fn write_skill(home: &Path, name: &str, frontmatter: &str) { + let folder = home.join(".agents/skills").join(name); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write( + folder.join("SKILL.md"), + format!("---\n{frontmatter}---\n# Body\n"), + ) + .unwrap(); + } + + #[test] + fn a_release_commit_pins_the_install_and_a_dev_build_does_not() { + assert_eq!(pinned_commit(Some(COMMIT)), Some(COMMIT)); + assert_eq!(pinned_commit(Some("unknown")), None); + assert_eq!(pinned_commit(None), None); + assert_eq!( + source_for(Some(COMMIT)), + format!("https://github.com/leynier/alera/tree/{COMMIT}") + ); + assert_eq!(source_for(None), "https://github.com/leynier/alera"); + } + + #[test] + fn install_arguments_never_prompt_and_name_every_skill() { + let arguments = + crate::host_tools::skill_install_arguments(&[SkillKind::Cli, SkillKind::AgentProfiles]); + assert_eq!(&arguments[..2], ["skills", "add"]); + assert_eq!( + &arguments[3..], + [ + "--skill", + "alera-cli", + "--skill", + "alera-agent-profiles", + "--agent", + "codex", + "--global", + "--yes" + ] + ); + } + + #[test] + fn status_compares_each_installed_version_with_the_runtime() { + let home = tempfile::tempdir().unwrap(); + write_skill( + home.path(), + "alera-cli", + &format!("name: alera-cli\nmetadata:\n version: {CLI_SKILL_VERSION}\n"), + ); + write_skill( + home.path(), + "alera-orchestration", + "name: alera-orchestration\nmetadata:\n version: 1\n", + ); + write_skill( + home.path(), + "alera-automations", + "name: alera-automations\n", + ); + let lock = json!({ "skills": { "alera-cli": { "ref": COMMIT, "updatedAt": "2026-10-10T00:00:00Z" } } }); + std::fs::write( + home.path().join(".agents/.skill-lock.json"), + lock.to_string(), + ) + .unwrap(); + + let report = status_with_commit(home.path(), Some(COMMIT)); + + let states = report["skills"] + .as_array() + .unwrap() + .iter() + .map(|skill| { + ( + skill["name"].as_str().unwrap(), + skill["state"].as_str().unwrap(), + ) + }) + .collect::>(); + assert_eq!( + states, + [ + ("alera-cli", "current"), + ("alera-orchestration", "outdated"), + ("alera-automations", "outdated"), + ("alera-agent-profiles", "missing"), + ] + ); + assert_eq!(report["ready"], false); + assert_eq!(report["pinnedRef"], COMMIT); + assert_eq!(report["skills"][0]["installedRef"], COMMIT); + assert_eq!(report["skills"][1]["installedVersion"], 1); + assert_eq!(report["skills"][3]["installedVersion"], Value::Null); + } + + #[test] + fn a_copy_ahead_of_the_runtime_is_newer() { + let home = tempfile::tempdir().unwrap(); + for kind in SkillKind::ALL { + let version = kind.contract_version() + i64::from(kind == SkillKind::Cli); + write_skill( + home.path(), + kind.package_name(), + &format!("metadata:\n version: {version}\n"), + ); + } + let report = status_with_commit(home.path(), None); + assert_eq!(report["skills"][0]["state"], "newer"); + assert_eq!(report["skills"][1]["state"], "current"); + assert_eq!(report["ready"], false); + assert_eq!(report["source"], "https://github.com/leynier/alera"); + } +} diff --git a/rust/alera-cli/src/alera_account/cloud_client.rs b/rust/alera-cli/src/alera_account/cloud_client.rs index 98eccd707..64e8cd330 100644 --- a/rust/alera-cli/src/alera_account/cloud_client.rs +++ b/rust/alera-cli/src/alera_account/cloud_client.rs @@ -374,7 +374,7 @@ impl CloudAccountClient { .with_context(|| format!("invalid response from {path}")) } - async fn empty( + pub(super) async fn empty( &self, method: Method, path: &str, diff --git a/rust/alera-cli/src/alera_account/service.rs b/rust/alera-cli/src/alera_account/service.rs index 562ee5c4b..5400e5c9a 100644 --- a/rust/alera-cli/src/alera_account/service.rs +++ b/rust/alera-cli/src/alera_account/service.rs @@ -1,4 +1,5 @@ mod configuration; +mod events; mod mcp; pub(crate) use mcp::DevicePoll; diff --git a/rust/alera-cli/src/alera_account/service/events.rs b/rust/alera-cli/src/alera_account/service/events.rs new file mode 100644 index 000000000..7747f3195 --- /dev/null +++ b/rust/alera-cli/src/alera_account/service/events.rs @@ -0,0 +1,217 @@ +//! Account calls for runtime events: forwarding the journal to the cloud, and +//! the webhooks that receive it. Events carry identifiers and states only. + +use alera_core::runtime::RuntimeEvent; +use anyhow::Result; +use reqwest::Method; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::super::cloud_client::CloudRequestError; +use super::AleraAccountService; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ForwardResponse { + #[serde(default)] + active_subscriptions: usize, + #[serde(default)] + rejected: Vec, +} + +/// An event the cloud refused by its payload policy while it stored the rest. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct RejectedEvent { + #[serde(default)] + event_id: String, + #[serde(default)] + code: String, +} + +impl AleraAccountService { + /// Sends a batch of journal events and returns how many subscriptions + /// still want this runtime's events. Events the cloud rejected one by one + /// are logged. `Ok(None)` means the cloud refused the whole batch with a + /// client error that resending it cannot fix (an older cloud refuses every + /// batch with one bad event), so the caller should move past it. Transient, + /// authorization, and rate-limit failures stay errors, and the caller + /// retries the batch. + pub(crate) async fn forward_domain_events( + &self, + events: &[RuntimeEvent], + ) -> Result> { + let token = self.access_token().await?; + let events = events + .iter() + .map(|event| { + json!({ + "eventId": event.event_id, + "seq": event.seq, + "kind": event.kind, + "workspaceId": event.workspace_id, + "projectId": event.project_id, + "data": event.data, + "occurredAt": event.occurred_at, + }) + }) + .collect::>(); + let response = self + .cloud + .json::( + Method::POST, + "/v1/runtime/domain-events", + Some(&token), + Some(json!({ "runtimeId": self.runtime_id, "events": events })), + ) + .await; + match response { + Ok(response) => { + for event in &response.rejected { + tracing::warn!( + event_id = %event.event_id, + code = %event.code, + "the cloud rejected a runtime event" + ); + } + Ok(Some(response.active_subscriptions)) + } + Err(error) => match refusal(&error) { + Some(reason) => { + tracing::warn!( + events = events.len(), + "the cloud refused a runtime event batch: {reason}" + ); + Ok(None) + } + None => Err(error), + }, + } + } + + /// Webhook and MCP Events subscriptions that target this runtime. + pub(crate) async fn event_subscription_count(&self) -> Result { + let token = self.access_token().await?; + let response: ForwardResponse = self + .cloud + .json( + Method::GET, + "/v1/runtime/event-subscriptions", + Some(&token), + None, + ) + .await?; + Ok(response.active_subscriptions) + } + + pub(crate) async fn list_webhooks(&self) -> Result { + let token = self.access_token().await?; + self.cloud + .json(Method::GET, "/v1/webhooks", Some(&token), None) + .await + } + + /// Creates a webhook for this runtime unless `runtime_ids` names others. + /// The signing secret is returned once. + pub(crate) async fn create_webhook( + &self, + url: &str, + kinds: &[String], + runtime_ids: Option<&[String]>, + ) -> Result { + let token = self.access_token().await?; + let runtime_ids = runtime_ids + .map(<[String]>::to_vec) + .unwrap_or_else(|| vec![self.runtime_id.clone()]); + self.cloud + .json( + Method::POST, + "/v1/webhooks", + Some(&token), + Some(json!({ "url": url, "kinds": kinds, "runtimeIds": runtime_ids })), + ) + .await + } + + pub(crate) async fn delete_webhook(&self, id: &str) -> Result<()> { + let token = self.access_token().await?; + self.cloud + .empty( + Method::DELETE, + &format!("/v1/webhooks/{}", encode_segment(id)), + Some(&token), + None, + ) + .await + } + + pub(crate) async fn test_webhook(&self, id: &str) -> Result { + let token = self.access_token().await?; + self.cloud + .json( + Method::POST, + &format!("/v1/webhooks/{}/test", encode_segment(id)), + Some(&token), + None, + ) + .await + } +} + +/// A client error other than authorization, timeout, or rate limiting: the same +/// batch would be refused again. +fn refusal(error: &anyhow::Error) -> Option { + let request = error.downcast_ref::()?; + (request.is_permanent_failure() && !request.is_permanent_authorization_failure()).then(|| { + format!( + "{}: {}", + request.code().unwrap_or("rejected"), + request.message() + ) + }) +} + +/// Webhook ids are cloud-issued; this keeps a pasted value from changing +/// the path. +fn encode_segment(id: &str) -> String { + id.chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{encode_segment, refusal, ForwardResponse, RejectedEvent}; + + #[test] + fn webhook_ids_cannot_change_the_path() { + assert_eq!(encode_segment("wh_123-abc"), "wh_123-abc"); + assert_eq!(encode_segment("../admin?x=1"), "adminx1"); + } + + #[test] + fn runtime_event_forward_responses_carry_rejected_events() { + let older: ForwardResponse = + serde_json::from_value(serde_json::json!({ "activeSubscriptions": 2 })).unwrap(); + assert_eq!(older.active_subscriptions, 2); + assert!(older.rejected.is_empty()); + let newer: ForwardResponse = serde_json::from_value(serde_json::json!({ + "accepted": 1, + "activeSubscriptions": 1, + "rejected": [{ "eventId": "e1", "code": "invalid_event_time" }], + })) + .unwrap(); + assert_eq!( + newer.rejected, + [RejectedEvent { + event_id: "e1".to_owned(), + code: "invalid_event_time".to_owned(), + }] + ); + } + + #[test] + fn runtime_event_batches_are_refused_only_by_cloud_client_errors() { + assert!(refusal(&anyhow::anyhow!("connection reset")).is_none()); + } +} diff --git a/rust/alera-cli/src/automation_commands.rs b/rust/alera-cli/src/automation_commands.rs index f2d1ec1eb..46e60e226 100644 --- a/rust/alera-cli/src/automation_commands.rs +++ b/rust/alera-cli/src/automation_commands.rs @@ -45,6 +45,10 @@ pub(crate) async fn run(command: AutomationCommand) -> i32 { AutomationAction::RunShow(args) => { request(&runtime, "automation.runShow", json!({"id": args.id})).await } + AutomationAction::Clone(args) => automation_run_commands::clone(&runtime, args).await, + AutomationAction::TakeOver(args) => { + automation_run_commands::take_over(&runtime, args).await + } AutomationAction::Cancel(args) => lifecycle(&runtime, "automation.cancel", &args).await, AutomationAction::Context(args) => lifecycle(&runtime, "automation.context", &args).await, AutomationAction::Heartbeat(args) => { @@ -54,7 +58,7 @@ pub(crate) async fn run(command: AutomationCommand) -> i32 { AutomationAction::Extend(args) => extend(&runtime, args).await, AutomationAction::Complete(args) => complete(&runtime, args).await, AutomationAction::Templates(args) => catalog(&runtime, "template", args).await, - AutomationAction::Tags(args) => catalog(&runtime, "tag", args).await, + AutomationAction::Tags(args) => automation_run_commands::tags(&runtime, args).await, AutomationAction::Import(args) => import_catalog(&runtime, args).await, AutomationAction::Export(args) => export_catalog(&runtime, args, json_output).await, AutomationAction::Policy(_) => Err(anyhow::anyhow!( @@ -171,22 +175,36 @@ async fn lifecycle( request_type: &str, args: &AutomationRunIdArgs, ) -> Result { + let identity = automation_run_commands::identity( + runtime, + &args.run_id, + &args.target, + args.use_run_identity, + ) + .await?; request( runtime, request_type, - json!({"run": args.run_id, "targetIdentity": target_identity(&args.target)}), + json!({"run": args.run_id, "targetIdentity": identity}), ) .await } async fn wait(runtime: &RuntimeDirArgs, args: AutomationWaitArgs) -> Result { + let identity = automation_run_commands::identity( + runtime, + &args.run_id, + &args.target, + args.use_run_identity, + ) + .await?; request( runtime, "automation.wait", json!({ "run": args.run_id, "waiting": !args.resume, - "targetIdentity": target_identity(&args.target), + "targetIdentity": identity, }), ) .await @@ -196,6 +214,13 @@ async fn extend(runtime: &RuntimeDirArgs, args: AutomationExtendArgs) -> Result< if args.until.is_none() && args.seconds.is_none() { anyhow::bail!("waiting extension requires --until or --seconds"); } + let identity = automation_run_commands::identity( + runtime, + &args.run_id, + &args.target, + args.use_run_identity, + ) + .await?; request( runtime, "automation.extend", @@ -203,7 +228,7 @@ async fn extend(runtime: &RuntimeDirArgs, args: AutomationExtendArgs) -> Result< "run": args.run_id, "until": args.until, "seconds": args.seconds, - "targetIdentity": target_identity(&args.target), + "targetIdentity": identity, }), ) .await @@ -385,3 +410,5 @@ mod tests { #[path = "automation_authoring_commands.rs"] mod automation_authoring_commands; use automation_authoring_commands::author; +#[path = "automation_run_commands.rs"] +mod automation_run_commands; diff --git a/rust/alera-cli/src/automation_run_commands.rs b/rust/alera-cli/src/automation_run_commands.rs new file mode 100644 index 000000000..24a8aa100 --- /dev/null +++ b/rust/alera-cli/src/automation_run_commands.rs @@ -0,0 +1,220 @@ +//! Automation commands an operator outside the run uses: clone, take over, +//! tag upkeep, and run lifecycle calls bound to the identity the run recorded. + +use anyhow::{anyhow, bail, Result}; +use chrono::Utc; +use serde_json::{json, Map, Value}; + +use super::{AutomationTargetArgs, RuntimeDirArgs}; +use crate::cli::{AutomationCloneArgs, AutomationTagsArgs, AutomationTakeOverArgs}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::RUNTIME_HOST_AUTOMATION_TERMINAL_OBSERVE_CAPABILITY; + +/// Fields of a saved automation that belong to that one record, so a copy +/// gets its own. +const RECORD_FIELDS: &[&str] = &[ + "id", + "slug", + "state", + "revision", + "approvedRevision", + "createdBy", + "modifiedBy", + "createdAt", + "updatedAt", + "creationRequestKey", + "creationRequestFingerprint", + "scheduleCursorAt", + "stateBeforeTrash", + "circuitOpened", + "circuitOpenedAt", +]; + +/// The target identity for a run lifecycle call. With `use_run_identity` it +/// is the identity the run recorded, which is what the Alera app sends; +/// explicit selectors still win. +pub(super) async fn identity( + runtime: &RuntimeDirArgs, + run_id: &str, + target: &AutomationTargetArgs, + use_run_identity: bool, +) -> Result { + if !use_run_identity { + return Ok(super::target_identity(target)); + } + let shown = super::request(runtime, "automation.runShow", json!({"id": run_id})).await?; + recorded_identity(&shown["run"], target) +} + +fn recorded_identity(run: &Value, target: &AutomationTargetArgs) -> Result { + let mut identity = run["targetIdentity"] + .as_object() + .cloned() + .filter(|identity| identity.values().any(|value| !value.is_null())) + .ok_or_else(|| anyhow!("automation run has no recorded target identity yet"))?; + let explicit = [ + ("attemptId", &target.attempt_id), + ("workspaceId", &target.workspace_id), + ("tabId", &target.tab_id), + ("sessionId", &target.session_id), + ("profileId", &target.profile_id), + ("conversationId", &target.conversation_id), + ("terminalHandle", &target.terminal_handle), + ]; + if let Some(attempt) = run["attemptId"].as_str() { + identity.insert("attemptId".into(), json!(attempt)); + } + for (key, value) in explicit { + if let Some(value) = value.as_ref().filter(|value| !value.trim().is_empty()) { + identity.insert(key.into(), json!(value)); + } + } + Ok(Value::Object(identity)) +} + +/// Creates a new automation with the settings and target of an existing +/// one, named " Copy" unless a name is given. +pub(super) async fn clone(runtime: &RuntimeDirArgs, args: AutomationCloneArgs) -> Result { + let shown = super::request(runtime, "automation.show", json!({"id": args.id})).await?; + let definition = copy_definition(&shown["automation"], args.name, args.draft)?; + let mut payload = json!({"automation": definition, "requestKey": args.request_key}); + super::add_automation_context(&mut payload); + super::request(runtime, "automation.create", payload).await +} + +fn copy_definition(saved: &Value, name: Option, draft: bool) -> Result { + let Some(saved) = saved.as_object() else { + bail!("automation definition is missing"); + }; + let mut copy: Map = saved + .iter() + .filter(|(key, _)| !RECORD_FIELDS.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + let name = + name.unwrap_or_else(|| format!("{} Copy", saved["name"].as_str().unwrap_or("Automation"))); + copy.insert("name".into(), json!(name)); + if draft { + copy.insert("state".into(), json!("draft")); + } + Ok(Value::Object(copy)) +} + +pub(super) async fn take_over( + runtime: &RuntimeDirArgs, + args: AutomationTakeOverArgs, +) -> Result { + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &crate::runtime_dir(runtime), + RUNTIME_HOST_AUTOMATION_TERMINAL_OBSERVE_CAPABILITY, + ) + .await?; + client + .request_value("automation.takeOver", &json!({"runId": args.run_id})) + .await +} + +/// Lists tags, or upserts one and then sets an automation's tags. +pub(super) async fn tags(runtime: &RuntimeDirArgs, args: AutomationTagsArgs) -> Result { + let tag = match (args.file, args.name) { + (Some(file), _) => Some(super::read_json(&file)?), + (None, Some(name)) => Some(named_tag(runtime, args.id, name).await?), + (None, None) => None, + }; + if args.automation_id.is_some() && !args.clear && args.assign.is_empty() { + bail!("--automation-id needs --assign or --clear"); + } + let assignment = args.automation_id.map(|id| (id, args.assign)); + if tag.is_none() && assignment.is_none() { + let mut payload = json!({}); + super::add_automation_context(&mut payload); + return super::request(runtime, "automation.tags", payload).await; + } + let mut result = json!({}); + if let Some(tag) = tag { + let mut payload = json!({"tag": tag}); + super::add_automation_context(&mut payload); + result["tag"] = super::request(runtime, "automation.tags", payload).await?; + } + if let Some((automation_id, tag_ids)) = assignment { + let mut payload = json!({"automationId": automation_id, "tagIds": tag_ids}); + super::add_automation_context(&mut payload); + result["assignment"] = super::request(runtime, "automation.tags", payload).await?; + } + Ok(result) +} + +/// A tag keeps its creation time when renamed; a new tag gets a fresh id. +async fn named_tag(runtime: &RuntimeDirArgs, id: Option, name: String) -> Result { + if name.trim().is_empty() { + bail!("tag name cannot be empty"); + } + let Some(id) = id else { + return Ok(json!({ + "id": uuid::Uuid::new_v4().to_string(), + "name": name.trim(), + "createdAt": Utc::now(), + })); + }; + let listing = super::request(runtime, "automation.tags", json!({})).await?; + let created_at = listing["items"] + .as_array() + .into_iter() + .flatten() + .find(|tag| tag["id"] == json!(id)) + .map(|tag| tag["createdAt"].clone()) + .ok_or_else(|| anyhow!("automation tag not found: {id}"))?; + Ok(json!({"id": id, "name": name.trim(), "createdAt": created_at})) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{copy_definition, recorded_identity, AutomationTargetArgs}; + + #[test] + fn a_copy_drops_record_fields_and_takes_a_new_name() { + let saved = json!({ + "id": "a1", "slug": "nightly-a1", "name": "Nightly", "state": "archived", + "revision": 4, "promptTemplate": "Run", "schedule": {"recurring": {}}, + "creationRequestKey": "key", "tagIds": ["t1"], + }); + let copy = copy_definition(&saved, None, false).unwrap(); + assert_eq!(copy["name"], "Nightly Copy"); + assert_eq!(copy["promptTemplate"], "Run"); + assert_eq!(copy["tagIds"], json!(["t1"])); + for field in ["id", "slug", "state", "revision", "creationRequestKey"] { + assert!(copy.get(field).is_none(), "{field} was copied"); + } + let draft = copy_definition(&saved, Some("Other".into()), true).unwrap(); + assert_eq!(draft["name"], "Other"); + assert_eq!(draft["state"], "draft"); + assert!(copy_definition(&json!(null), None, false).is_err()); + } + + #[test] + fn the_recorded_identity_carries_the_attempt_and_explicit_overrides() { + let run = json!({ + "attemptId": "attempt-2", + "targetIdentity": {"workspaceId": "w", "tabId": "t", "terminalHandle": "s"}, + }); + let identity = recorded_identity( + &run, + &AutomationTargetArgs { + tab_id: Some("explicit".into()), + ..AutomationTargetArgs::default() + }, + ) + .unwrap(); + assert_eq!(identity["attemptId"], "attempt-2"); + assert_eq!(identity["workspaceId"], "w"); + assert_eq!(identity["tabId"], "explicit"); + assert!(recorded_identity(&json!({"targetIdentity": null}), &Default::default()).is_err()); + assert!(recorded_identity( + &json!({"targetIdentity": {"tabId": null}}), + &Default::default() + ) + .is_err()); + } +} diff --git a/rust/alera-cli/src/cli.rs b/rust/alera-cli/src/cli.rs index 241af649a..fa9a38c4e 100644 --- a/rust/alera-cli/src/cli.rs +++ b/rust/alera-cli/src/cli.rs @@ -5,23 +5,37 @@ use crate::terminal_host::protocol::{ }; mod agent_profile; mod automation; +mod events; mod issue; mod mcp; mod mobile; mod project; +mod pull_request; +mod runtime_manage; +mod skill; +mod terminal_lifecycle; mod text_source; mod voice; +mod webhook; mod workspace; +mod workspace_prompt_start; pub use agent_profile::*; pub use automation::*; +pub use events::*; pub use issue::*; pub use mcp::*; pub use mobile::*; pub use project::*; +pub use pull_request::*; +pub use runtime_manage::*; +pub use skill::*; +pub use terminal_lifecycle::*; pub use text_source::*; pub use voice::*; +pub use webhook::*; pub use workspace::*; +pub use workspace_prompt_start::*; use clap::{Args, Parser, Subcommand, ValueEnum}; /// Top-level CLI, mirroring the Dart `AleraCliCommandRunner`. @@ -64,6 +78,10 @@ pub enum Command { /// Read issues from GitHub, GitLab, or Azure DevOps through their CLIs. Issue(IssueCommand), + /// Pull requests on GitHub, GitLab, and Azure DevOps: show, create, comment, merge, ship, restack, fix checks, and stacks. + #[command(name = "pr")] + Pr(PullRequestCommand), + /// Manage global workspace tags. Tag(TagCommand), @@ -87,12 +105,22 @@ pub enum Command { #[command(name = "agent-profile")] AgentProfile(AgentProfileCommand), + /// Show agent usage limits, refresh Claude usage, and spend a Codex reset. + #[command(name = "agent-quota")] + AgentQuota(AgentQuotaCommand), + /// Inter-agent orchestration: messaging, task DAG, dispatch, gates, coordinator. Orchestration(OrchestrationCommand), /// Ask agents questions from outside a terminal and read their replies. Inbox(crate::cli_inbox::InboxCommand), + /// Read the runtime event journal: replies, agent states, tasks, runs, and workspace starts. + Events(EventsCommand), + + /// Signed webhooks that receive this runtime's events through the Alera cloud. + Webhook(WebhookCommand), + /// Global voice home agent: speak, status, and the runtime home folder. Voice(VoiceCommand), @@ -101,6 +129,9 @@ pub enum Command { /// Let MCP clients run Alera tools on this runtime, locally or through the Alera cloud. Mcp(McpCommand), + + /// Check and install the Alera skills coding agents use on this machine. + Skill(SkillCommand), } #[derive(Debug, Args)] @@ -127,6 +158,12 @@ pub enum TerminalAction { Read(TerminalReadArgs), /// Write text, a file, or stdin to a terminal. Write(TerminalWriteArgs), + /// Replace a terminal's process, keeping its tab and scrollback. + Restart(TerminalHandleArgs), + /// End a terminal session and close its tab, as the Resource Manager does. + Terminate(TerminalHandleArgs), + /// Show or change the input typed into a terminal after files change. + Pulse(TerminalPulseCommand), } #[derive(Debug, Args)] @@ -289,6 +326,10 @@ pub enum RuntimeAction { Agents(RuntimeAgentsCommand), /// Name this runtime so MCP clients and phones can tell it apart. Rename(RuntimeRenameArgs), + /// Show or change the runtime settings open to the CLI and MCP clients. + Settings(RuntimeSettingsCommand), + /// Show CPU and memory use of the host and its terminal sessions. + Resources, } #[derive(Debug, Args)] @@ -379,7 +420,11 @@ pub struct TabCommand { pub enum TabAction { List(WorkspaceIdArgs), Create(TabCreateArgs), - Remove(IdArgs), + Remove(TabRemoveArgs), + /// Rename a tab. + Rename(TabRenameArgs), + /// Name an agent tab from its conversation with AI Assist. + GenerateTitle(TabGenerateTitleArgs), /// Bind a running agent to a tab again so its hooks update that tab's /// status. Inside Claude Code or Codex every option is detected. LinkAgent(TabLinkAgentArgs), diff --git a/rust/alera-cli/src/cli/agent_profile.rs b/rust/alera-cli/src/cli/agent_profile.rs index 67e5bc34f..e4c5f751d 100644 --- a/rust/alera-cli/src/cli/agent_profile.rs +++ b/rust/alera-cli/src/cli/agent_profile.rs @@ -205,8 +205,50 @@ pub struct AgentProfileLaunchArgs { #[arg(long = "workspace", value_name = "workspace_id")] pub workspace: Option, #[command(flatten)] - pub prompt: PromptSourceArgs, + pub prompt: AgentProfileLaunchInputArgs, /// Stable mutation id used to retry an identical launch. #[arg(long = "client-mutation-id", value_name = "id")] pub client_mutation_id: Option, } + +/// A launch starts a conversation with a prompt, resumes an existing one, or +/// starts the agent without a prompt when neither is given. +#[derive(Debug, Args)] +#[command(group( + ArgGroup::new("launch-input") + .required(false) + .multiple(false) + .args(["prompt", "prompt_file", "prompt_stdin", "resume_session_id"]) +))] +pub struct AgentProfileLaunchInputArgs { + /// Prompt text delivered to the agent profile. + #[arg(long, value_name = "text")] + pub prompt: Option, + /// Read the prompt from a file. + #[arg(long = "prompt-file", value_name = "path")] + pub prompt_file: Option, + /// Read the prompt from standard input. + #[arg(long = "prompt-stdin")] + pub prompt_stdin: bool, + /// Resume this agent conversation instead of starting one with a prompt. + #[arg(long = "resume-session-id", value_name = "id")] + pub resume_session_id: Option, +} + +impl AgentProfileLaunchInputArgs { + /// The prompt to deliver, empty when the launch resumes a session or + /// names no prompt. + pub fn read(&self) -> anyhow::Result { + if self.resume_session_id.is_some() + || (self.prompt.is_none() && self.prompt_file.is_none() && !self.prompt_stdin) + { + return Ok(String::new()); + } + PromptSourceArgs { + prompt: self.prompt.clone(), + prompt_file: self.prompt_file.clone(), + prompt_stdin: self.prompt_stdin, + } + .read() + } +} diff --git a/rust/alera-cli/src/cli/automation.rs b/rust/alera-cli/src/cli/automation.rs index d4938b375..727a9a731 100644 --- a/rust/alera-cli/src/cli/automation.rs +++ b/rust/alera-cli/src/cli/automation.rs @@ -43,6 +43,10 @@ pub enum AutomationAction { Runs(AutomationRunsArgs), /// Show one run and its automation definition. RunShow(IdArgs), + /// Create a new automation from an existing one, as the Clone action does. + Clone(AutomationCloneArgs), + /// Take over the terminal of a run so the automation stops driving it. + TakeOver(AutomationTakeOverArgs), /// Cancel one non-final run. Cancel(AutomationRunIdArgs), /// Show the context and lifecycle contract for a run. @@ -57,8 +61,8 @@ pub enum AutomationAction { Complete(AutomationCompleteArgs), /// List prompt templates, or upsert from JSON. updatedAt is optional on upsert. Templates(AutomationCatalogFileArgs), - /// List or upsert tags and assignments. - Tags(AutomationCatalogFileArgs), + /// List tags, upsert one, or set the tags of an automation. + Tags(AutomationTagsArgs), /// Import a runtime-local automation catalog. Import(AutomationImportArgs), /// Export a runtime-local automation catalog. @@ -200,6 +204,9 @@ pub struct AutomationRunsArgs { pub struct AutomationRunIdArgs { #[arg(long = "run")] pub run_id: String, + /// Use the target identity the run recorded, as the Alera app does. + #[arg(long = "use-run-identity")] + pub use_run_identity: bool, #[command(flatten)] pub target: AutomationTargetArgs, } @@ -228,6 +235,9 @@ pub struct AutomationWaitArgs { pub run_id: String, #[arg(long)] pub resume: bool, + /// Use the target identity the run recorded, as the Alera app does. + #[arg(long = "use-run-identity")] + pub use_run_identity: bool, #[command(flatten)] pub target: AutomationTargetArgs, } @@ -240,6 +250,9 @@ pub struct AutomationExtendArgs { pub until: Option, #[arg(long, conflicts_with = "until")] pub seconds: Option, + /// Use the target identity the run recorded, as the Alera app does. + #[arg(long = "use-run-identity")] + pub use_run_identity: bool, #[command(flatten)] pub target: AutomationTargetArgs, } @@ -265,6 +278,47 @@ pub struct AutomationCatalogFileArgs { pub file: Option, } +#[derive(Debug, Args)] +pub struct AutomationTagsArgs { + /// Optional JSON file (or - for stdin) with a tag object to upsert. + #[arg(long = "file", alias = "object-file", conflicts_with = "name")] + pub file: Option, + /// Name of a tag to create, or to rename when --id is given. + #[arg(long)] + pub name: Option, + #[arg(long, requires = "name")] + pub id: Option, + /// Automation whose tags --assign or --clear replaces. + #[arg(long = "automation-id")] + pub automation_id: Option, + /// Tag id to assign, repeatable. Requires --automation-id. + #[arg(long = "assign", value_name = "tag_id", requires = "automation_id")] + pub assign: Vec, + /// With --automation-id, remove every tag from the automation. + #[arg(long = "clear", requires = "automation_id", conflicts_with = "assign")] + pub clear: bool, +} + +#[derive(Debug, Args)] +pub struct AutomationCloneArgs { + #[arg(long)] + pub id: String, + /// Name of the copy (default: the original name followed by Copy). + #[arg(long)] + pub name: Option, + /// Save the copy as a draft instead of activating it. + #[arg(long)] + pub draft: bool, + #[arg(long)] + pub request_key: Option, +} + +#[derive(Debug, Args)] +pub struct AutomationTakeOverArgs { + #[arg(long = "run")] + pub run_id: String, +} + #[derive(Debug, Args)] pub struct AutomationImportArgs { /// JSON catalog file, or - to read the catalog from stdin. diff --git a/rust/alera-cli/src/cli/events.rs b/rust/alera-cli/src/cli/events.rs new file mode 100644 index 000000000..e4f9030ec --- /dev/null +++ b/rust/alera-cli/src/cli/events.rs @@ -0,0 +1,52 @@ +use clap::{Args, Subcommand}; + +use super::{OutputArgs, RuntimeDirArgs}; + +/// The runtime event journal: inbox replies, agent states, task and run +/// changes, and workspace starts, as identifiers and states only. +#[derive(Debug, Args)] +pub struct EventsCommand { + #[command(flatten)] + pub runtime: RuntimeDirArgs, + #[command(flatten)] + pub output: OutputArgs, + #[command(subcommand)] + pub action: EventsAction, +} + +#[derive(Debug, Subcommand)] +pub enum EventsAction { + /// List events after a cursor, oldest first. + List(EventsListArgs), + /// Wait for events after a cursor, then list them. + Wait(EventsWaitArgs), +} + +#[derive(Debug, Args)] +pub struct EventsFilterArgs { + /// Cursor from a previous result; 0 reads from the oldest retained event. + #[arg(long, default_value_t = 0)] + pub after: i64, + /// Only these kinds, such as inbox.reply. Repeat or separate with commas. + #[arg(long = "kind", value_delimiter = ',')] + pub kinds: Vec, + #[arg(long)] + pub workspace_id: Option, + #[arg(long, default_value_t = 100, value_parser = clap::value_parser!(i64).range(1..=500))] + pub limit: i64, +} + +#[derive(Debug, Args)] +pub struct EventsListArgs { + #[command(flatten)] + pub filter: EventsFilterArgs, +} + +#[derive(Debug, Args)] +pub struct EventsWaitArgs { + #[command(flatten)] + pub filter: EventsFilterArgs, + /// Seconds to wait for a matching event before returning an empty page. + #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))] + pub timeout_seconds: u64, +} diff --git a/rust/alera-cli/src/cli/issue.rs b/rust/alera-cli/src/cli/issue.rs index afda4c7e3..a51e3a26c 100644 --- a/rust/alera-cli/src/cli/issue.rs +++ b/rust/alera-cli/src/cli/issue.rs @@ -1,9 +1,13 @@ use clap::{Args, Subcommand}; -use super::OutputArgs; +use super::{OutputArgs, RuntimeDirArgs}; #[derive(Debug, Args)] pub struct IssueCommand { + /// Accepted for symmetry with the other groups; issues are read through + /// the forge CLIs and never touch the runtime. + #[command(flatten)] + pub runtime: RuntimeDirArgs, #[command(flatten)] pub output: OutputArgs, #[command(subcommand)] diff --git a/rust/alera-cli/src/cli/mcp.rs b/rust/alera-cli/src/cli/mcp.rs index 339dc4a76..4f030a3a1 100644 --- a/rust/alera-cli/src/cli/mcp.rs +++ b/rust/alera-cli/src/cli/mcp.rs @@ -30,13 +30,42 @@ pub enum McpAction { Serve(McpServeArgs), } +/// How much of the MCP catalog a client may use. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum McpAccessArg { + /// Only tools that read runtime state. + Read, + /// Reading and running tools, including deletions, merges, and automations. + Full, + /// Everything in full, plus agent profile changes, runtime settings, and + /// internal maintenance. + Admin, +} + #[derive(Debug, Args)] pub struct McpEnableArgs { - /// Allow only tools that read runtime state. + /// Tools MCP clients may use. Defaults to full. + #[arg(long, value_enum, conflicts_with = "read_only")] + pub access: Option, + /// Allow only tools that read runtime state. Same as `--access read`. #[arg(long)] pub read_only: bool, } +impl McpEnableArgs { + pub fn level(&self) -> McpAccessArg { + effective_access(self.access, self.read_only) + } +} + +fn effective_access(access: Option, read_only: bool) -> McpAccessArg { + if read_only { + McpAccessArg::Read + } else { + access.unwrap_or(McpAccessArg::Full) + } +} + #[derive(Debug, Args)] pub struct McpRevokeArgs { /// Connected app id from `alera mcp apps`. @@ -46,11 +75,21 @@ pub struct McpRevokeArgs { #[derive(Debug, Args)] pub struct McpServeArgs { - /// Expose only tools that read runtime state. + /// Tools to expose to the local client. Defaults to full; administrative + /// tools need `--access admin`. MCP Control applies to remote clients only. + #[arg(long, value_enum, conflicts_with = "read_only")] + pub access: Option, + /// Expose only tools that read runtime state. Same as `--access read`. #[arg(long)] pub read_only: bool, } +impl McpServeArgs { + pub fn level(&self) -> McpAccessArg { + effective_access(self.access, self.read_only) + } +} + #[derive(Debug, Args)] pub struct AccountCommand { #[command(flatten)] diff --git a/rust/alera-cli/src/cli/project.rs b/rust/alera-cli/src/cli/project.rs index 9b35b9e1b..c61e7fcce 100644 --- a/rust/alera-cli/src/cli/project.rs +++ b/rust/alera-cli/src/cli/project.rs @@ -68,6 +68,16 @@ pub enum ProjectAction { AddRemote(ProjectAddRemoteArgs), /// Remove a project and runtime-owned child records. Remove(ProjectRemoveArgs), + /// Change a project's display name. Its folder is not touched. + Rename(ProjectRenameArgs), + /// Clone a repository into a new folder on this machine and register it. + Clone(ProjectCloneCommand), + /// Preview a project removal: workspaces, tabs, sessions, and dependent automations. + RemovePreview(ProjectIdArgs), + /// List the branches of a project's checkout on a host, for a worktree's source branch. + Branches(ProjectBranchesArgs), + /// Show, set, or reset the project's settings (New Workspace, copies, setup, pull request provider). + Config(ProjectConfigCommand), } #[derive(Debug, Args)] @@ -155,8 +165,9 @@ pub struct ProjectCheckoutInspectArgs { pub struct ProjectAddArgs { #[arg(long)] pub id: Option, + /// Display name. Defaults to the folder name. #[arg(long)] - pub name: String, + pub name: Option, #[arg(long = "repo-path")] pub repo_path: String, #[arg(long, value_enum, default_value_t = ProjectKindArg::GitRepository)] @@ -184,3 +195,100 @@ pub enum ProjectKindArg { GitRepository, Folder, } + +#[derive(Debug, Args)] +pub struct ProjectIdArgs { + #[arg(long)] + pub id: String, +} + +#[derive(Debug, Args)] +pub struct ProjectRenameArgs { + #[arg(long)] + pub id: String, + #[arg(long)] + pub name: String, +} + +#[derive(Debug, Args)] +pub struct ProjectCloneCommand { + #[command(subcommand)] + pub action: ProjectCloneAction, +} + +#[derive(Debug, Subcommand)] +pub enum ProjectCloneAction { + /// Start cloning in the background and print the clone job. + Start(ProjectCloneStartArgs), + /// List clone jobs, newest first. + List, + /// Show one clone job with its progress. + Show(ProjectIdArgs), + /// Cancel a running clone and delete its partial folder. + Cancel(ProjectIdArgs), +} + +#[derive(Debug, Args)] +pub struct ProjectCloneStartArgs { + /// Repository URL or path that `git clone` accepts. + #[arg(long)] + pub url: String, + /// Existing folder to clone into. + #[arg(long)] + pub parent_path: String, + /// New folder name. Defaults to the repository name. + #[arg(long)] + pub directory_name: Option, + /// Project display name. Defaults to the folder name. + #[arg(long)] + pub name: Option, +} + +#[derive(Debug, Args)] +pub struct ProjectBranchesArgs { + #[arg(long)] + pub project_id: String, + /// SSH target id, or `local` (the default). + #[arg(long)] + pub host_id: Option, +} + +#[derive(Debug, Args)] +pub struct ProjectConfigCommand { + #[command(subcommand)] + pub action: ProjectConfigAction, +} + +#[derive(Debug, Subcommand)] +pub enum ProjectConfigAction { + /// Show the effective settings and where they come from. + Show(ProjectConfigTargetArgs), + /// Save settings that override the repository's alera.toml. The JSON may + /// carry `worktree`, `newWorkspace`, and `gitHostingProvider`; each one + /// given replaces that part of the current settings. + Set(ProjectConfigSetArgs), + /// Remove the override so the repository's alera.toml applies again. + Remove(ProjectConfigTargetArgs), +} + +#[derive(Debug, Args)] +pub struct ProjectConfigTargetArgs { + #[arg(long)] + pub project_id: String, +} + +#[derive(Debug, Args)] +pub struct ProjectConfigSetArgs { + #[arg(long)] + pub project_id: String, + /// Settings as JSON. + #[arg( + long, + conflicts_with = "config_stdin", + required_unless_present = "config_stdin" + )] + pub config: Option, + /// Read the settings JSON from standard input. + #[arg(long)] + pub config_stdin: bool, +} diff --git a/rust/alera-cli/src/cli/pull_request.rs b/rust/alera-cli/src/cli/pull_request.rs new file mode 100644 index 000000000..1c80fc2ca --- /dev/null +++ b/rust/alera-cli/src/cli/pull_request.rs @@ -0,0 +1,348 @@ +use clap::{Args, Subcommand, ValueEnum}; + +use super::{OutputArgs, RuntimeDirArgs}; + +/// Pull requests on GitHub, GitLab, and Azure DevOps through the runtime, +/// which runs `gh`, `glab`, or `az` on the host that owns the checkout. +#[derive(Debug, Args)] +pub struct PullRequestCommand { + #[command(flatten)] + pub runtime: RuntimeDirArgs, + #[command(flatten)] + pub output: OutputArgs, + #[command(subcommand)] + pub action: PullRequestAction, +} + +#[derive(Debug, Subcommand)] +pub enum PullRequestAction { + /// Show the workspace's pull request: state, checks, conversation, and merge methods. + Show(PrTargetArgs), + /// List compact pull request summaries for every active workspace. + Summaries(PrTargetArgs), + /// Write a title and description for the branch with AI Assist. + #[command(name = "generate-details")] + GenerateDetails(PrGenerateDetailsArgs), + /// Open a pull request from the current branch and link it to the workspace. + Create(PrCreateArgs), + /// Link a pull request by number or URL to the workspace. + Link(PrLinkArgs), + /// Unlink the workspace's pull request so branch detection stops showing it. + Unlink(PrNumberArgs), + /// Comment on the pull request, or reply to a comment. + Comment(PrCommentArgs), + /// Edit one of your comments. + #[command(name = "comment-edit")] + CommentEdit(PrCommentEditArgs), + /// Mark the pull request as a draft, or ready for review with --ready. + Draft(PrDraftArgs), + /// Close the pull request without merging. + Close(PrNumberArgs), + /// Merge the pull request with a method the forge allows. + Merge(PrMergeArgs), + /// Commit, push, and open a pull request in one step, optionally followed by Watch and Fix. + Ship(PrShipArgs), + /// Ask an agent to rewrite the branch into logical, easy-to-review commits. + Restack(PrDispatchArgs), + /// Ask an agent to fix the pull request's failed checks. + #[command(name = "fix-checks")] + FixChecks(PrDispatchArgs), + /// GitHub pull request stacks: show, create from workspaces, link, merge. + Stack(PrStackCommand), +} + +#[derive(Debug, Args)] +pub struct PrTargetArgs { + /// Workspace to act on. Defaults to ALERA_WORKSPACE_ID. + #[arg(long = "workspace-id", value_name = "id")] + pub workspace_id: Option, +} + +#[derive(Debug, Args)] +pub struct PrNumberArgs { + #[command(flatten)] + pub target: PrTargetArgs, + /// Pull request number. Defaults to the workspace's linked or detected pull request. + #[arg(long, value_name = "n", value_parser = clap::value_parser!(i64).range(1..))] + pub number: Option, +} + +#[derive(Debug, Args)] +pub struct PrBaseArgs { + #[command(flatten)] + pub target: PrTargetArgs, + /// Base branch the pull request targets. + #[arg(long = "base", value_name = "branch")] + pub base: String, +} + +#[derive(Debug, Args)] +pub struct PrGenerateDetailsArgs { + #[command(flatten)] + pub base: PrBaseArgs, + /// Retry key. Running the command again with it attaches to the same + /// generation, or reads its result for 15 minutes, instead of starting over. + #[arg(long = "operation-id", value_name = "id")] + pub operation_id: Option, + /// Wait at most this long, then print status running and the operation id + /// to run again with. By default the command waits for the result. + #[arg( + long = "wait-seconds", + value_name = "n", + value_parser = clap::value_parser!(u64).range(0..=900) + )] + pub wait_seconds: Option, +} + +#[derive(Debug, Args)] +pub struct PrBodyArgs { + /// Text of the body. + #[arg(long, value_name = "text", conflicts_with = "body_stdin")] + pub body: Option, + /// Read the body from standard input. + #[arg(long = "body-stdin")] + pub body_stdin: bool, +} + +#[derive(Debug, Args)] +pub struct PrCreateArgs { + #[command(flatten)] + pub base: PrBaseArgs, + #[arg(long, value_name = "text")] + pub title: String, + #[command(flatten)] + pub body: PrBodyArgs, + /// Open it as a draft. + #[arg(long)] + pub draft: bool, +} + +#[derive(Debug, Args)] +pub struct PrLinkArgs { + #[command(flatten)] + pub target: PrTargetArgs, + /// Pull request number, #number, or URL of this repository. + #[arg(value_name = "reference")] + pub reference: String, +} + +#[derive(Debug, Args)] +pub struct PrCommentArgs { + #[command(flatten)] + pub number: PrNumberArgs, + #[command(flatten)] + pub body: PrBodyArgs, + /// Reply to this comment id instead of commenting at the top level. + #[arg(long = "reply-to", value_name = "id", value_parser = clap::value_parser!(i64).range(1..))] + pub reply_to: Option, + /// Thread or discussion id of the comment (GitLab and Azure DevOps). + #[arg(long = "thread-id", value_name = "id")] + pub thread_id: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PrCommentSource { + #[value(name = "conversation")] + Conversation, + #[value(name = "reviewSummary")] + ReviewSummary, + #[value(name = "reviewThread")] + ReviewThread, +} + +impl PrCommentSource { + pub fn wire(self) -> &'static str { + match self { + Self::Conversation => "conversation", + Self::ReviewSummary => "reviewSummary", + Self::ReviewThread => "reviewThread", + } + } +} + +#[derive(Debug, Args)] +pub struct PrCommentEditArgs { + #[command(flatten)] + pub number: PrNumberArgs, + #[arg(long = "comment-id", value_name = "id", value_parser = clap::value_parser!(i64).range(1..))] + pub comment_id: i64, + /// Where the comment lives, as the snapshot's comment `source` says. + #[arg(long, value_enum)] + pub source: PrCommentSource, + /// Thread or discussion id of the comment (GitLab and Azure DevOps). + #[arg(long = "thread-id", value_name = "id")] + pub thread_id: Option, + #[command(flatten)] + pub body: PrBodyArgs, +} + +#[derive(Debug, Args)] +pub struct PrDraftArgs { + #[command(flatten)] + pub number: PrNumberArgs, + /// Mark it ready for review instead. + #[arg(long)] + pub ready: bool, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PrMergeMethodArg { + #[value(name = "mergeCommit")] + MergeCommit, + #[value(name = "squash")] + Squash, + #[value(name = "rebase")] + Rebase, + #[value(name = "providerDefault")] + ProviderDefault, +} + +impl PrMergeMethodArg { + pub fn wire(self) -> &'static str { + match self { + Self::MergeCommit => "mergeCommit", + Self::Squash => "squash", + Self::Rebase => "rebase", + Self::ProviderDefault => "providerDefault", + } + } +} + +#[derive(Debug, Args)] +pub struct PrMergeArgs { + #[command(flatten)] + pub number: PrNumberArgs, + /// Merge method. Defaults to the forge's preferred allowed method. + #[arg(long, value_enum)] + pub method: Option, + /// Only merge while the head commit is still this SHA. + #[arg(long = "expected-head", value_name = "sha")] + pub expected_head: Option, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PrShipScope { + All, + Staged, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PrWatchMode { + Fix, + #[value(name = "fixAndMerge")] + FixAndMerge, +} + +#[derive(Debug, Args)] +pub struct PrAgentTargetArgs { + /// Running terminal handle to send the prompt to. + #[arg(long, value_name = "handle")] + pub handle: Option, + /// Terminal tab to send the prompt to. + #[arg(long = "tab-id", value_name = "id")] + pub tab_id: Option, + /// Agent profile id used to open a new tab when no terminal is given or running. + #[arg(long = "profile-id", value_name = "id")] + pub profile_id: Option, + /// Unique agent profile name. Alias for looking up --profile-id. + #[arg(long = "profile", value_name = "name", conflicts_with = "profile_id")] + pub profile: Option, +} + +#[derive(Debug, Args)] +pub struct PrShipArgs { + #[command(flatten)] + pub base: PrBaseArgs, + #[arg(long, value_enum, default_value = "all")] + pub scope: PrShipScope, + #[arg(long)] + pub draft: bool, + /// Start Watch and Fix (fix) or Watch, Fix and Merge (fixAndMerge) on the new pull request. + #[arg(long = "follow-up-watch", value_enum, value_name = "mode")] + pub follow_up_watch: Option, + #[command(flatten)] + pub agent: PrAgentTargetArgs, + /// Do not watch failing checks. + #[arg(long = "no-checks")] + pub no_checks: bool, + /// Do not watch unresolved review comments. + #[arg(long = "no-comments")] + pub no_comments: bool, + /// Do not watch merge conflicts. + #[arg(long = "no-conflicts")] + pub no_conflicts: bool, +} + +#[derive(Debug, Args)] +pub struct PrDispatchArgs { + #[command(flatten)] + pub number: PrNumberArgs, + #[command(flatten)] + pub agent: PrAgentTargetArgs, + /// Only print the prompt; do not send it. + #[arg(long)] + pub preview: bool, +} + +#[derive(Debug, Args)] +pub struct PrStackCommand { + #[command(subcommand)] + pub action: PrStackAction, +} + +#[derive(Debug, Subcommand)] +pub enum PrStackAction { + /// Show the stack that holds the workspace's pull request. + Show(PrNumberArgs), + /// Open pull requests for workspace branches (bottom to top) and stack them. + Create(PrStackCreateArgs), + /// Stack existing pull requests (bottom to top), or append them to the current stack. + Link(PrStackLinkArgs), + /// Merge the stack through the workspace's pull request. + Merge(PrStackMergeArgs), +} + +#[derive(Debug, Args)] +pub struct PrStackCreateArgs { + #[command(flatten)] + pub target: PrTargetArgs, + /// Base branch of a new stack. + #[arg(long = "base", value_name = "branch")] + pub base: Option, + /// Workspace of each layer, bottom to top. Repeat or separate with commas. + #[arg( + long = "layer", + value_name = "workspace-id", + value_delimiter = ',', + required = true + )] + pub layers: Vec, + /// Title for each layer that needs a new pull request, in layer order. + #[arg(long = "title", value_name = "text")] + pub titles: Vec, + /// Open new pull requests as drafts. + #[arg(long)] + pub draft: bool, +} + +#[derive(Debug, Args)] +pub struct PrStackLinkArgs { + #[command(flatten)] + pub target: PrTargetArgs, + /// Pull request numbers, bottom to top. Repeat or separate with commas. + #[arg( + long = "numbers", + value_name = "n", + value_delimiter = ',', + required = true + )] + pub numbers: Vec, +} + +#[derive(Debug, Args)] +pub struct PrStackMergeArgs { + #[command(flatten)] + pub number: PrNumberArgs, + #[arg(long, value_enum)] + pub method: Option, +} diff --git a/rust/alera-cli/src/cli/runtime_manage.rs b/rust/alera-cli/src/cli/runtime_manage.rs new file mode 100644 index 000000000..a897809ca --- /dev/null +++ b/rust/alera-cli/src/cli/runtime_manage.rs @@ -0,0 +1,73 @@ +use clap::{Args, Subcommand}; + +use super::{OutputArgs, RuntimeDirArgs}; + +#[derive(Debug, Args)] +pub struct RuntimeSettingsCommand { + #[command(subcommand)] + pub action: RuntimeSettingsAction, +} + +#[derive(Debug, Subcommand)] +pub enum RuntimeSettingsAction { + /// Show the runtime settings the CLI and MCP clients may read and change. + Show, + /// Change one or more of those settings. + Set(RuntimeSettingsSetArgs), +} + +#[derive(Debug, Args)] +pub struct RuntimeSettingsSetArgs { + /// KEY=VALUE. Keys: workspaceDirectory, confirmProjectRemoval, + /// confirmWorkspaceRemoval, defaultAgentProfileId, aiAssist.enabled, + /// aiAssist.autoGenerateAgentTitles, aiAssist.agent, + /// aiAssist.timeoutSeconds, automation.startAtLogin, + /// automation.runRetentionDays, automation.auditRetentionDays, + /// automation.trashRetentionDays. + #[arg(value_name = "key=value", required_unless_present = "unset")] + pub assignments: Vec, + /// Clear workspaceDirectory or defaultAgentProfileId. May be repeated. + #[arg(long = "unset", value_name = "key")] + pub unset: Vec, +} + +#[derive(Debug, Args)] +pub struct AgentQuotaCommand { + #[command(flatten)] + pub runtime: RuntimeDirArgs, + #[command(flatten)] + pub output: OutputArgs, + #[command(subcommand)] + pub action: AgentQuotaAction, +} + +#[derive(Debug, Subcommand)] +pub enum AgentQuotaAction { + /// Show usage limits for the enabled agent providers. + Show(AgentQuotaShowArgs), + /// Read one Claude account's usage again through the Claude terminal UI. + RefreshClaude(AgentQuotaClaudeArgs), + /// Spend the Codex rate-limit reset credit the latest snapshot offers. + ConsumeCodexReset(AgentQuotaCodexResetArgs), +} + +#[derive(Debug, Args)] +pub struct AgentQuotaShowArgs { + /// Fetch fresh numbers instead of the cached snapshot. + #[arg(long)] + pub refresh: bool, +} + +#[derive(Debug, Args)] +pub struct AgentQuotaClaudeArgs { + /// Claude account from the snapshot, or `default`. + #[arg(long = "account-id", value_name = "id", default_value = "default")] + pub account_id: String, +} + +#[derive(Debug, Args)] +pub struct AgentQuotaCodexResetArgs { + /// Offer revision from the Codex snapshot, so a changed offer is refused. + #[arg(long = "offer-revision", value_name = "revision")] + pub offer_revision: String, +} diff --git a/rust/alera-cli/src/cli/skill.rs b/rust/alera-cli/src/cli/skill.rs new file mode 100644 index 000000000..35f22965f --- /dev/null +++ b/rust/alera-cli/src/cli/skill.rs @@ -0,0 +1,50 @@ +use clap::{Args, Subcommand, ValueEnum}; + +use super::{OutputArgs, RuntimeDirArgs}; + +#[derive(Debug, Args)] +pub struct SkillCommand { + #[command(flatten)] + pub runtime: RuntimeDirArgs, + #[command(flatten)] + pub output: OutputArgs, + #[command(subcommand)] + pub action: SkillAction, +} + +#[derive(Debug, Subcommand)] +pub enum SkillAction { + /// Show whether each Alera agent skill is installed and matches this runtime. + Status, + /// Install or update Alera agent skills at this runtime's commit. + Install(SkillInstallArgs), +} + +#[derive(Debug, Args)] +pub struct SkillInstallArgs { + /// Skill to install. May be repeated; defaults to every Alera skill. + #[arg(long = "skill", value_enum)] + pub skills: Vec, + /// Package runner: auto tries npx, then bunx when npx is missing. + #[arg(long, value_enum, default_value_t = SkillRunnerName::Auto)] + pub runner: SkillRunnerName, + /// Seconds to wait for the install. The runtime keeps installing after + /// that, and `alera skill status` shows the result. + #[arg(long = "wait-seconds", default_value_t = 45, value_parser = clap::value_parser!(u64).range(1..=600))] + pub wait_seconds: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SkillName { + Cli, + Orchestration, + Automations, + AgentProfiles, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum SkillRunnerName { + Auto, + Npx, + Bunx, +} diff --git a/rust/alera-cli/src/cli/terminal_lifecycle.rs b/rust/alera-cli/src/cli/terminal_lifecycle.rs new file mode 100644 index 000000000..a62d68360 --- /dev/null +++ b/rust/alera-cli/src/cli/terminal_lifecycle.rs @@ -0,0 +1,72 @@ +use clap::{Args, Subcommand}; + +#[derive(Debug, Args)] +pub struct TabRemoveArgs { + #[arg(long)] + pub id: String, + /// Close the tab through the runtime host so its terminal session ends + /// too, as closing it in the app does. Fails when no host is running. + #[arg(long)] + pub terminate: bool, +} + +#[derive(Debug, Args)] +pub struct TabRenameArgs { + #[arg(long)] + pub id: String, + #[arg(long)] + pub title: String, +} + +#[derive(Debug, Args)] +pub struct TabGenerateTitleArgs { + /// Agent tab whose conversation names the title. + #[arg(long)] + pub id: String, +} + +#[derive(Debug, Args)] +pub struct TerminalHandleArgs { + #[arg(long)] + pub handle: String, +} + +#[derive(Debug, Args)] +pub struct TerminalPulseCommand { + #[command(subcommand)] + pub action: TerminalPulseAction, +} + +#[derive(Debug, Subcommand)] +pub enum TerminalPulseAction { + /// Show a terminal's Pulse input, delay, and whether it is armed. + Show(TerminalHandleArgs), + /// Change a terminal's Pulse, and arm or disarm it. + Set(TerminalPulseSetArgs), +} + +#[derive(Debug, Args)] +pub struct TerminalPulseSetArgs { + #[arg(long)] + pub handle: String, + /// Text typed into the terminal after workspace files change. Keeps the + /// saved input when omitted. + #[arg(long, value_name = "text")] + pub input: Option, + /// Whether Enter is pressed after the input. + #[arg(long, value_name = "true|false")] + pub enter: Option, + /// Quiet time after the last file change before the input is typed. + #[arg( + long = "delay-ms", + value_name = "ms", + value_parser = clap::value_parser!(u64).range(100..=3_600_000), + )] + pub delay_ms: Option, + /// Start watching the workspace files. + #[arg(long, conflicts_with = "disarm")] + pub arm: bool, + /// Stop watching the workspace files. + #[arg(long)] + pub disarm: bool, +} diff --git a/rust/alera-cli/src/cli/webhook.rs b/rust/alera-cli/src/cli/webhook.rs new file mode 100644 index 000000000..215bbcd6c --- /dev/null +++ b/rust/alera-cli/src/cli/webhook.rs @@ -0,0 +1,40 @@ +use clap::{Args, Subcommand}; + +use super::{IdArgs, OutputArgs, RuntimeDirArgs}; + +/// Signed webhooks that receive this runtime's event journal through the +/// Alera cloud. Needs a signed-in account. +#[derive(Debug, Args)] +pub struct WebhookCommand { + #[command(flatten)] + pub runtime: RuntimeDirArgs, + #[command(flatten)] + pub output: OutputArgs, + #[command(subcommand)] + pub action: WebhookAction, +} + +#[derive(Debug, Subcommand)] +pub enum WebhookAction { + /// List the account's webhooks. + List, + /// Add an HTTPS webhook. Prints its signing secret once. + Add(WebhookAddArgs), + /// Delete a webhook. + Remove(IdArgs), + /// Send a signed test delivery. + Test(IdArgs), +} + +#[derive(Debug, Args)] +pub struct WebhookAddArgs { + /// HTTPS endpoint that receives signed POST requests. + #[arg(long)] + pub url: String, + /// Event kinds to send, such as inbox.reply. Defaults to every kind. + #[arg(long = "kind", value_delimiter = ',')] + pub kinds: Vec, + /// Runtimes whose events it receives. Defaults to this runtime. + #[arg(long = "runtime-id", value_delimiter = ',')] + pub runtime_ids: Vec, +} diff --git a/rust/alera-cli/src/cli/workspace.rs b/rust/alera-cli/src/cli/workspace.rs index 8be61df97..935c23f8c 100644 --- a/rust/alera-cli/src/cli/workspace.rs +++ b/rust/alera-cli/src/cli/workspace.rs @@ -2,6 +2,10 @@ use clap::{Args, Subcommand, ValueEnum}; use super::{AgentProfileSelectorArgs, IdArgs, OutputArgs, PromptSourceArgs, RuntimeDirArgs}; +#[path = "workspace_manage.rs"] +mod manage; +pub use manage::*; + #[derive(Debug, Args)] pub struct WorkspaceCommand { #[command(flatten)] @@ -20,6 +24,8 @@ pub enum WorkspaceAction { Add(WorkspaceAddArgs), /// Create a task on the project folder and launch an agent profile. Use --worktree for isolation. Start(WorkspaceStartArgs), + /// New Workspace from Prompt as a runtime operation, like the app's form. + PromptStart(super::WorkspacePromptStartCommand), /// Remove task state and optionally its owned worktree. Shared project files are preserved. Remove(WorkspaceRemoveArgs), /// Apply the project's worktree setup to an existing workspace. @@ -35,9 +41,15 @@ pub enum WorkspaceAction { /// Select and show an existing workspace in the running Alera desktop app. Focus(WorkspaceFocusArgs), /// Pin a workspace in the desktop sidebar. - Pin(IdArgs), + Pin(WorkspacePinArgs), /// Unpin a workspace from the desktop sidebar. - Unpin(IdArgs), + Unpin(WorkspacePinArgs), + /// Show a workspace with its section, tags, issue, pull request, watch, parent, and children. + Show(IdArgs), + /// Wake a slept workspace: start its stopped terminals again, as opening it in the app does. + Wake(IdArgs), + /// Preview a removal: storage, dependent automations, and linked workspaces. Changes nothing. + RemovePreview(IdArgs), /// Sleep a workspace: stop its terminal sessions while it stays visible in /// the sidebar, preserving tabs, branch, and files. Opening it wakes it. Sleep(IdArgs), @@ -71,17 +83,6 @@ pub enum WorkspaceAction { Section(WorkspaceSectionCommand), } -#[derive(Debug, Args)] -pub struct WorkspaceListArgs { - #[arg(long = "project-id")] - pub project_id: Option, - #[arg(long)] - pub all: bool, - /// Only workspaces on this host: an SSH target id, or `local`. - #[arg(long = "host-id")] - pub host_id: Option, -} - #[derive(Debug, Args)] pub struct WorkspaceAddArgs { /// Create a new exclusive worktree instead of sharing the project folder. @@ -175,22 +176,6 @@ pub struct WorkspaceStartArgs { pub section_id: Option, } -#[derive(Debug, Args)] -pub struct WorkspaceRemoveArgs { - #[arg(long)] - pub id: String, - #[arg(long = "delete-branch", conflicts_with = "keep_branch")] - pub delete_branch: bool, - #[arg(long = "keep-branch", conflicts_with = "delete_branch")] - pub keep_branch: bool, - /// Stop only this workspace's processes before removal; otherwise active sessions block removal. - #[arg(long = "close-sessions")] - pub close_sessions: bool, - /// Pause dependent automations and cancel all their active runs before removing the workspace. - #[arg(long = "pause-automations-and-cancel-runs")] - pub pause_automations_and_cancel_runs: bool, -} - #[derive(Debug, Args)] pub struct WorkspaceSetupArgs { #[arg(long)] @@ -428,57 +413,3 @@ pub struct WorkspacePrWatchStartArgs { #[arg(long = "profile", value_name = "name", conflicts_with = "profile_id")] pub profile: Option, } - -#[derive(Debug, Args)] -pub struct WorkspaceSectionCommand { - #[command(subcommand)] - pub action: WorkspaceSectionAction, -} - -#[derive(Debug, Subcommand)] -pub enum WorkspaceSectionAction { - /// List workspace sections. - List, - /// Create a section and assign its first workspace. - Create(WorkspaceSectionCreateArgs), - /// Assign a workspace to an existing section. - Set(WorkspaceSectionSetArgs), - /// Move a workspace to Others (no section). - Clear(WorkspaceSectionWorkspaceArgs), - /// Delete a section. Workspaces are kept and moved to Others. - Remove(IdArgs), -} - -#[derive(Debug, Args)] -pub struct WorkspaceSectionCreateArgs { - #[arg(long)] - pub name: String, - #[arg(long = "workspace-id")] - pub workspace_id: String, -} - -#[derive(Debug, Args)] -pub struct WorkspaceSectionSetArgs { - #[arg(long = "workspace-id")] - pub workspace_id: String, - /// Unique section name, matched case-insensitively. - #[arg( - long = "section", - required_unless_present = "section_id", - conflicts_with = "section_id" - )] - pub section: Option, - /// Section id. - #[arg( - long = "section-id", - required_unless_present = "section", - conflicts_with = "section" - )] - pub section_id: Option, -} - -#[derive(Debug, Args)] -pub struct WorkspaceSectionWorkspaceArgs { - #[arg(long = "workspace-id")] - pub workspace_id: String, -} diff --git a/rust/alera-cli/src/cli/workspace_manage.rs b/rust/alera-cli/src/cli/workspace_manage.rs new file mode 100644 index 000000000..8e6a6134f --- /dev/null +++ b/rust/alera-cli/src/cli/workspace_manage.rs @@ -0,0 +1,138 @@ +//! Arguments for listing, pinning, sectioning, and removing workspaces. + +use clap::{Args, Subcommand, ValueEnum}; + +use super::IdArgs; + +#[derive(Debug, Args)] +pub struct WorkspaceListArgs { + #[arg(long = "project-id")] + pub project_id: Option, + #[arg(long)] + pub all: bool, + /// Only workspaces on this host: an SSH target id, or `local`. + #[arg(long = "host-id")] + pub host_id: Option, + /// Only workspaces in this section. `none` lists those in Others. + #[arg(long = "section-id")] + pub section_id: Option, + /// Only workspaces with this tag. + #[arg(long = "tag-id")] + pub tag_id: Option, + /// Only archived workspaces (`true`) or only visible ones (`false`). + #[arg(long, value_name = "bool")] + pub archived: Option, + /// Only the children of this workspace. + #[arg(long = "parent-workspace-id")] + pub parent_workspace_id: Option, +} + +#[derive(Debug, Args)] +pub struct WorkspacePinArgs { + #[arg(long)] + pub id: String, + /// Also apply to every descendant of the workspace (Pin Workspace Tree). + #[arg(long)] + pub tree: bool, +} + +#[derive(Debug, Args)] +pub struct WorkspaceRemoveArgs { + #[arg(long)] + pub id: String, + #[arg(long = "delete-branch", conflicts_with = "keep_branch")] + pub delete_branch: bool, + #[arg(long = "keep-branch", conflicts_with = "delete_branch")] + pub keep_branch: bool, + /// Stop only this workspace's processes before removal; otherwise active sessions block removal. + #[arg(long = "close-sessions")] + pub close_sessions: bool, + /// Pause dependent automations and cancel all their active runs before removing the workspace. + #[arg(long = "pause-automations-and-cancel-runs")] + pub pause_automations_and_cancel_runs: bool, + /// Run the Alera app's Remove flow: save or discard the editors open on + /// the workspace in connected apps, close its sessions, pause dependent + /// automations and cancel their runs, refuse when storage cleanup is + /// blocked, and delete the branch when it can be deleted unless + /// --keep-branch. Uncommitted changes in the worktree are lost. + #[arg(long = "editor-buffers", value_enum, value_name = "save|discard")] + pub editor_buffers: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum EditorBuffersArg { + Save, + Discard, +} + +impl EditorBuffersArg { + pub fn as_str(self) -> &'static str { + match self { + Self::Save => "save", + Self::Discard => "discard", + } + } +} + +#[derive(Debug, Args)] +pub struct WorkspaceSectionCommand { + #[command(subcommand)] + pub action: WorkspaceSectionAction, +} + +#[derive(Debug, Subcommand)] +pub enum WorkspaceSectionAction { + /// List workspace sections. + List, + /// Create a section and assign its first workspace. + Create(WorkspaceSectionCreateArgs), + /// Assign a workspace to an existing section. + Set(WorkspaceSectionSetArgs), + /// Move a workspace to Others (no section). + Clear(WorkspaceSectionWorkspaceArgs), + /// Delete a section. Workspaces are kept and moved to Others. + Remove(IdArgs), +} + +#[derive(Debug, Args)] +pub struct WorkspaceSectionCreateArgs { + #[arg(long)] + pub name: String, + #[arg(long = "workspace-id")] + pub workspace_id: String, + /// Also assign every descendant of the workspace. + #[arg(long)] + pub tree: bool, +} + +#[derive(Debug, Args)] +pub struct WorkspaceSectionSetArgs { + #[arg(long = "workspace-id")] + pub workspace_id: String, + /// Unique section name, matched case-insensitively. + #[arg( + long = "section", + required_unless_present = "section_id", + conflicts_with = "section_id" + )] + pub section: Option, + /// Section id. + #[arg( + long = "section-id", + required_unless_present = "section", + conflicts_with = "section" + )] + pub section_id: Option, + /// Also assign every descendant of the workspace (Set Section Tree). + #[arg(long)] + pub tree: bool, +} + +#[derive(Debug, Args)] +pub struct WorkspaceSectionWorkspaceArgs { + #[arg(long = "workspace-id")] + pub workspace_id: String, + /// Also move every descendant of the workspace to Others. + #[arg(long)] + pub tree: bool, +} diff --git a/rust/alera-cli/src/cli/workspace_prompt_start.rs b/rust/alera-cli/src/cli/workspace_prompt_start.rs new file mode 100644 index 000000000..bc77a9025 --- /dev/null +++ b/rust/alera-cli/src/cli/workspace_prompt_start.rs @@ -0,0 +1,92 @@ +use clap::{Args, Subcommand, ValueEnum}; + +use super::{IdArgs, PromptSourceArgs}; + +/// New Workspace from Prompt, run by the runtime as one operation. +#[derive(Debug, Args)] +pub struct WorkspacePromptStartCommand { + #[command(subcommand)] + pub action: WorkspacePromptStartAction, +} + +#[derive(Debug, Subcommand)] +pub enum WorkspacePromptStartAction { + /// Start an operation: choose the project, name the workspace and its + /// branch, pick its section, create it, start its setup, and launch the + /// agent, as the app's New Workspace from Prompt form does. + Run(Box), + /// Show one operation. + Show(IdArgs), + /// List recent operations, newest first. + List(WorkspacePromptStartListArgs), + /// Wait until an operation stops running, or until the timeout. + Wait(WorkspacePromptStartWaitArgs), + /// Cancel a running operation. A workspace it already created is kept. + Cancel(IdArgs), + /// Launch the agent again for an operation whose workspace exists but + /// whose agent did not start. It never creates another workspace. + RetryLaunch(IdArgs), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum PromptStartModeArg { + /// A worktree for Git projects, the project folder otherwise. + Auto, + Worktree, + ProjectCheckout, +} + +#[derive(Debug, Args)] +pub struct WorkspacePromptStartRunArgs { + #[command(flatten)] + pub prompt: PromptSourceArgs, + /// Project for the workspace. Omit to let AI Assist recognize it from the + /// prompt; an unclear prompt returns candidates instead of guessing. + #[arg(long)] + pub project_id: Option, + /// Agent profile id or unique name. Defaults to the runtime's default profile. + #[arg(long)] + pub profile: Option, + #[arg(long, value_enum, default_value = "auto")] + pub mode: PromptStartModeArg, + /// Branch the new worktree starts from. Defaults to the project's preferred + /// source branch, then the repository's default branch. + #[arg(long)] + pub source_branch: Option, + /// SSH target that owns the worktree. Omit for this machine. + #[arg(long)] + pub host_id: Option, + #[arg(long)] + pub parent_workspace_id: Option, + /// Issue URL to link to the new workspace. + #[arg(long = "issue", value_name = "url")] + pub issue_url: Option, + /// `auto` (default) lets AI Assist pick a section or none ("Others"), + /// `none` skips sections, any other value names a section. + #[arg(long, conflicts_with = "section_id")] + pub section: Option, + /// Section to join, by id. + #[arg(long)] + pub section_id: Option, + /// Retry key: a second start with the same key returns the first operation. + #[arg(long)] + pub request_id: Option, + /// Seconds to wait for the operation before printing it. 0 returns at once. + #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(u64).range(0..=600))] + pub wait: u64, +} + +#[derive(Debug, Args)] +pub struct WorkspacePromptStartListArgs { + #[arg(long, default_value_t = 20, value_parser = clap::value_parser!(i64).range(1..=200))] + pub limit: i64, +} + +#[derive(Debug, Args)] +pub struct WorkspacePromptStartWaitArgs { + #[arg(long)] + pub id: String, + /// Seconds to wait before printing the current state. + #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..=600))] + pub timeout_seconds: u64, +} diff --git a/rust/alera-cli/src/cli_inbox.rs b/rust/alera-cli/src/cli_inbox.rs index 3afd70382..620f5af26 100644 --- a/rust/alera-cli/src/cli_inbox.rs +++ b/rust/alera-cli/src/cli_inbox.rs @@ -1,4 +1,4 @@ -use clap::{Args, Subcommand}; +use clap::{Args, Subcommand, ValueEnum}; use crate::cli::{OutputArgs, RuntimeDirArgs}; @@ -109,6 +109,18 @@ pub struct InboxAskArgs { /// Drop the question if it has not reached the agent in time, such as 30m or 2h (default 5h, at most 7d). #[arg(long = "expires-in", value_name = "duration", value_parser = parse_duration_ms)] pub expires_in_ms: Option, + /// Retry key (8 to 128 characters): asking again with it returns the first question. + #[arg(long = "request-key", value_name = "key")] + pub request_key: Option, +} + +/// Whose threads an MCP client sees in the shared inbox. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum InboxScope { + /// Only threads the calling MCP client started. + Own, + /// Every thread of the inbox. + All, } #[derive(Debug, Args)] @@ -130,6 +142,12 @@ pub struct InboxThreadsArgs { /// Continue a listing from the `nextBefore` value it returned. #[arg(long = "before", value_name = "sequence")] pub before: Option, + /// Only threads started by this MCP client id. + #[arg(long = "origin-client-id", value_name = "id", conflicts_with = "scope")] + pub origin_client_id: Option, + /// own: only threads the calling MCP client started (default all). + #[arg(long = "scope", value_enum)] + pub scope: Option, } #[derive(Debug, Args)] @@ -152,6 +170,9 @@ pub struct InboxWaitArgs { /// How long to wait, such as 90s, 30m or 2h (default 10m). #[arg(long = "timeout", value_name = "duration", value_parser = parse_duration_ms)] pub timeout_ms: Option, + /// Inbox waits only. own: only replies in threads the calling MCP client started. + #[arg(long = "scope", value_enum, conflicts_with = "question")] + pub scope: Option, } #[derive(Debug, Args)] diff --git a/rust/alera-cli/src/cli_orchestration.rs b/rust/alera-cli/src/cli_orchestration.rs index 5d10ddecd..bd6aed3f6 100644 --- a/rust/alera-cli/src/cli_orchestration.rs +++ b/rust/alera-cli/src/cli_orchestration.rs @@ -27,6 +27,12 @@ pub enum OrchestrationAction { Plans(crate::cli_workflow_plans::WorkflowPlansArgs), /// Prepare and inspect isolated workflow workspaces and attempts. Workspaces(crate::cli_workflow_workspaces::WorkflowWorkspacesArgs), + /// Create, inspect, cancel, and start workflow proposals; never approves plans. + Proposals(crate::cli_workflow_plans::WorkflowProposalsArgs), + /// Inspect, start, pause, or correct the execution of a workflow run. + Execution(crate::cli_workflow_plans::WorkflowExecutionArgs), + /// Preview and run the cleanup of a workflow's retained workspaces. + Cleanup(crate::cli_workflow_plans::WorkflowCleanupArgs), /// Create or select a worker terminal and dispatch once the agent is ready. #[command(name = "agent-spawn")] AgentSpawn(OrchestrationAgentSpawnArgs), @@ -43,22 +49,16 @@ pub enum OrchestrationAction { /// Ask another agent a question and block until it answers. Ask(OrchestrationAskArgs), /// Create a task in the orchestration DAG. - #[command(name = "task-create")] TaskCreate(OrchestrationTaskCreateArgs), /// List tasks, optionally filtered by status. - #[command(name = "task-list")] TaskList(OrchestrationTaskListArgs), /// Show one task with its active dispatch. - #[command(name = "task-show")] TaskShow(OrchestrationTaskIdArgs), /// Wait until a task reaches one of the requested states. - #[command(name = "task-wait")] TaskWait(OrchestrationTaskWaitArgs), /// Cancel a task and its not-yet-started descendants. - #[command(name = "task-cancel")] TaskCancel(OrchestrationTaskCancelArgs), /// Resolve a stalled task through an audited administrative action. - #[command(name = "task-recover")] TaskRecover(OrchestrationTaskRecoverArgs), /// Transfer task or run coordinator ownership. #[command(name = "transfer-coordinator")] @@ -66,13 +66,10 @@ pub enum OrchestrationAction { /// Dispatch a ready task to a terminal. Dispatch(OrchestrationDispatchArgs), /// Show the dispatch state and preamble for a task. - #[command(name = "dispatch-show")] DispatchShow(OrchestrationDispatchShowArgs), /// Accept the active dispatch installed for this worker terminal. - #[command(name = "dispatch-accept")] DispatchAccept, /// Interrupt an active worker turn without terminating the terminal. - #[command(name = "dispatch-interrupt")] DispatchInterrupt(OrchestrationDispatchInterruptArgs), /// Inspect the active dispatch context for this terminal. Context, @@ -115,16 +112,19 @@ pub enum OrchestrationAction { /// Start the background coordinator loop. Run(OrchestrationRunArgs), /// List durable coordinator runs. - #[command(name = "run-list")] RunList(OrchestrationRunListArgs), /// Show a coordinator run. - #[command(name = "run-show")] RunShow(OrchestrationRunIdArgs), /// Aggregate run, task, worker, and escalation state. Status(OrchestrationRunIdArgs), /// Stop the active coordinator loop. - #[command(name = "run-stop")] RunStop(OrchestrationRunStopArgs), + /// Read one page of the run board, with runs grouped by attention, active, or history. + Board(OrchestrationBoardArgs), + /// Read a coordinator run with a page of its tasks. + RunSnapshot(OrchestrationRunSnapshotArgs), + /// Read one task of a run with its dispatch history. + TaskInspect(OrchestrationTaskInspectArgs), /// List live terminals with agent presence. #[command(name = "terminal-list")] TerminalList(OrchestrationTerminalListArgs), @@ -294,9 +294,8 @@ pub struct OrchestrationAskArgs { #[derive(Debug, Args)] pub struct OrchestrationTaskCreateArgs { - /// The task brief the dispatched worker receives. - #[arg(long = "spec", value_name = "text")] - pub spec: String, + #[command(flatten)] + pub spec: crate::cli::SpecSourceArgs, /// Short title for listings. #[arg(long = "task-title", value_name = "text")] diff --git a/rust/alera-cli/src/cli_orchestration_runs.rs b/rust/alera-cli/src/cli_orchestration_runs.rs index e3b20efbf..2e04fee03 100644 --- a/rust/alera-cli/src/cli_orchestration_runs.rs +++ b/rust/alera-cli/src/cli_orchestration_runs.rs @@ -37,9 +37,9 @@ pub struct OrchestrationRunPolicyRejectArgs { #[derive(Debug, Args)] pub struct OrchestrationRunArgs { - /// Run objective recorded on the coordinator run. - #[arg(long = "spec", value_name = "text")] - pub spec: String, + /// Run objective recorded on the coordinator run (--spec, --spec-file or --spec-stdin). + #[command(flatten)] + pub spec: SpecSourceArgs, /// Coordinator handle. Defaults to ALERA_TERMINAL_HANDLE. #[arg(long = "from", value_name = "handle")] @@ -142,3 +142,51 @@ pub struct OrchestrationDelegateArgs { )] pub timeout_ms: u64, } + +/// `orchestration board`: one page of the run board the desktop shows. +#[derive(Debug, Args)] +pub struct OrchestrationBoardArgs { + #[arg(long = "project-id", value_name = "project_id")] + pub project_id: Option, + #[arg(long = "workspace", value_name = "workspace_id")] + pub workspace: Option, + /// Text to search for in run objectives. + #[arg(long = "search", value_name = "text")] + pub search: Option, + #[arg(long = "bucket", value_parser = ["attention", "active", "history"])] + pub bucket: Option, + /// The `nextCursor` JSON object of a previous page. + #[arg(long = "cursor", value_name = "json")] + pub cursor: Option, + #[arg(long = "limit", value_name = "n")] + pub limit: Option, +} + +/// `orchestration run-snapshot`: a run with a page of its tasks. +#[derive(Debug, Args)] +pub struct OrchestrationRunSnapshotArgs { + #[arg(long = "run", value_name = "run_id")] + pub run: String, + /// Continue after this task id from a previous page. + #[arg(long = "after-task", value_name = "task_id")] + pub after_task: Option, + /// Board revision of the previous page, so pages stay consistent. + #[arg(long = "revision", value_name = "n")] + pub revision: Option, + #[arg(long = "limit", value_name = "n")] + pub limit: Option, +} + +/// `orchestration task-inspect`: one task of a run with its history. +#[derive(Debug, Args)] +pub struct OrchestrationTaskInspectArgs { + #[arg(long = "run", value_name = "run_id")] + pub run: String, + #[arg(long = "task", value_name = "task_id")] + pub task: String, + /// The history cursor JSON object of a previous page. + #[arg(long = "cursor", value_name = "json")] + pub cursor: Option, + #[arg(long = "limit", value_name = "n")] + pub limit: Option, +} diff --git a/rust/alera-cli/src/cli_workflow_plans.rs b/rust/alera-cli/src/cli_workflow_plans.rs index 304e9b4c3..1282b92e3 100644 --- a/rust/alera-cli/src/cli_workflow_plans.rs +++ b/rust/alera-cli/src/cli_workflow_plans.rs @@ -1,5 +1,8 @@ use clap::{Args, Subcommand}; +mod lifecycle; +pub use lifecycle::*; + #[derive(Debug, Args)] pub struct WorkflowPlansArgs { #[command(subcommand)] diff --git a/rust/alera-cli/src/cli_workflow_plans/lifecycle.rs b/rust/alera-cli/src/cli_workflow_plans/lifecycle.rs new file mode 100644 index 000000000..8752ea7ad --- /dev/null +++ b/rust/alera-cli/src/cli_workflow_plans/lifecycle.rs @@ -0,0 +1,205 @@ +//! `alera orchestration proposals|execution|cleanup`: workflow lifecycle verbs +//! that need no human decision. Approvals, reviews, and signed decisions stay +//! in the Alera desktop app. + +use clap::{Args, Subcommand, ValueEnum}; + +#[derive(Debug, Args)] +pub struct WorkflowProposalsArgs { + #[command(subcommand)] + pub action: WorkflowProposalsAction, +} + +#[derive(Debug, Subcommand)] +pub enum WorkflowProposalsAction { + /// List workflow proposals, newest first. + List { + /// Continue after the createdAt of the last entry of a previous page. + #[arg(long, requires = "before_id")] + before_created_at: Option, + /// Continue after the id of the last entry of a previous page. + #[arg(long, requires = "before_created_at")] + before_id: Option, + }, + /// Show the lifecycle state of a proposal, its coordinator, and its run. + Status { + #[arg(long)] + id: String, + }, + /// Create a proposal from a recipe and the source workspace's current commit. + Create(WorkflowProposalCreateArgs), + /// Cancel a proposal, or retry a cancellation that did not finish. + Cancel { + #[arg(long)] + id: String, + /// Retry an unfinished cancellation at this cancellation sequence. + #[arg(long)] + expected_sequence: Option, + }, + /// Start the coordinator agent of a proposal. Does not approve any plan. + StartCoordinator { + #[arg(long)] + id: String, + }, +} + +#[derive(Debug, Args)] +pub struct WorkflowProposalCreateArgs { + /// Local Git workspace the workflow starts from. + #[arg(long)] + pub workspace_id: String, + /// Recipe source JSON, such as {"origin":"builtIn","id":"quick-fix"}. + #[arg(long)] + pub recipe_source: String, + /// Recipe digest from `recipes show`; creation fails if the recipe changed. + #[arg(long)] + pub recipe_digest: String, + /// Agent profile id for the coordinator. + #[arg(long)] + pub coordinator_profile_id: String, + /// Agent profile for a recipe role, as role=profileId. Repeat for each role. + #[arg(long = "role-profile", value_name = "role=profile_id")] + pub role_profiles: Vec, + /// Workers that may run at once (1-16, default 4). + #[arg(long, default_value_t = 4, value_parser = clap::value_parser!(u32).range(1..=16))] + pub max_concurrent: u32, + /// Revise this existing run instead of starting a new one. + #[arg(long, requires = "expected_revision")] + pub run: Option, + #[arg(long, requires = "run")] + pub expected_revision: Option, + /// Stable idempotency key. Reuse it after a timeout or disconnect. + #[arg(long)] + pub request_id: String, + /// What the workflow should achieve. + #[arg( + long, + required_unless_present = "objective_stdin", + conflicts_with = "objective_stdin" + )] + pub objective: Option, + /// Read the objective from standard input. + #[arg(long)] + pub objective_stdin: bool, +} + +#[derive(Debug, Args)] +pub struct WorkflowExecutionArgs { + #[command(subcommand)] + pub action: WorkflowExecutionAction, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum WorkflowExecutionVerb { + Start, + Pause, + Cancel, +} + +#[derive(Debug, Subcommand)] +pub enum WorkflowExecutionAction { + /// Show the execution state and command sequence of a run revision. + Show { + #[arg(long)] + run: String, + #[arg(long)] + revision: Option, + }, + /// Start, pause, or cancel scheduling of an approved run revision. + Control { + #[arg(long)] + run: String, + #[arg(long)] + revision: i64, + /// Sequence from `execution show`; a stale value is rejected. + #[arg(long)] + expected_sequence: i64, + #[arg(long, value_enum)] + action: WorkflowExecutionVerb, + /// Stable idempotency key. Reuse it after a timeout or disconnect. + #[arg(long)] + request_id: String, + }, + /// Open a correction proposal for a run revision. Does not approve it. + Correct { + #[arg(long)] + run: String, + #[arg(long)] + revision: i64, + /// Plan digest from `plans show`. + #[arg(long)] + plan_digest: String, + /// Stable idempotency key. Reuse it after a timeout or disconnect. + #[arg(long)] + request_id: String, + /// Why the run needs a correction. + #[arg( + long, + required_unless_present = "reason_stdin", + conflicts_with = "reason_stdin" + )] + reason: Option, + /// Read the reason from standard input. + #[arg(long)] + reason_stdin: bool, + }, +} + +#[derive(Debug, Args)] +pub struct WorkflowCleanupArgs { + #[command(subcommand)] + pub action: WorkflowCleanupAction, +} + +#[derive(Debug, Subcommand)] +pub enum WorkflowCleanupAction { + /// List a run's retained workspaces with their cleanup state. + Resources { + #[arg(long)] + run: String, + #[arg(long)] + before_row: Option, + }, + /// List a run's cleanup operations. + List { + #[arg(long)] + run: String, + #[arg(long)] + before_row: Option, + }, + /// Preview removing retained workspaces. Changes nothing. + Preview { + #[arg(long)] + run: String, + /// Workspace to remove. Repeat for up to 25 workspaces. + #[arg(long = "workspace", value_name = "workspace_id", required = true)] + workspaces: Vec, + /// Also delete the branch of this selected workspace. Repeatable. + #[arg(long = "remove-branch", value_name = "workspace_id")] + remove_branches: Vec, + /// Preview id (a UUID). Defaults to a new one. + #[arg(long)] + id: Option, + }, + /// Show a cleanup operation and its outcome. + Status { + #[arg(long)] + id: String, + }, + /// Apply a previewed cleanup. + Apply(WorkflowCleanupConfirmation), + /// Retry a cleanup that did not finish. + Retry(WorkflowCleanupConfirmation), + /// Abandon a cleanup and keep the remaining resources. + Abandon(WorkflowCleanupConfirmation), +} + +#[derive(Debug, Args)] +pub struct WorkflowCleanupConfirmation { + /// Preview id. + #[arg(long)] + pub id: String, + /// Digest the preview returned. + #[arg(long)] + pub digest: String, +} diff --git a/rust/alera-cli/src/cli_workflow_recipes.rs b/rust/alera-cli/src/cli_workflow_recipes.rs index 3b218ff85..98d17f227 100644 --- a/rust/alera-cli/src/cli_workflow_recipes.rs +++ b/rust/alera-cli/src/cli_workflow_recipes.rs @@ -27,6 +27,22 @@ pub enum WorkflowRecipesAction { #[arg(long)] expected_revision: Option, }, + /// Preview, or with --apply write, a recipe file into a workspace's project catalog. + Export { + #[command(flatten)] + input: WorkflowRecipeDocumentArgs, + #[arg(long)] + workspace_id: String, + /// File name inside the project's recipe directory. + #[arg(long)] + filename: String, + /// Digest from the preview. Required with --apply. + #[arg(long)] + expected_digest: Option, + /// Write the previewed file. Without it the command only previews. + #[arg(long, requires = "expected_digest")] + apply: bool, + }, } #[derive(Debug, Args)] diff --git a/rust/alera-cli/src/cli_workspace_tests.rs b/rust/alera-cli/src/cli_workspace_tests.rs index 4c3d69c8e..374770ebc 100644 --- a/rust/alera-cli/src/cli_workspace_tests.rs +++ b/rust/alera-cli/src/cli_workspace_tests.rs @@ -60,21 +60,30 @@ fn shared_workspace_cli_defaults_and_explicit_worktree_options() { } #[test] fn workspace_pin_commands_parse_workspace_ids() { + use crate::cli::WorkspacePinArgs; + let pin = Cli::try_parse_from(["alera", "workspace", "pin", "--id", "workspace-1"]).unwrap(); - let unpin = - Cli::try_parse_from(["alera", "workspace", "unpin", "--id", "workspace-2"]).unwrap(); + let unpin = Cli::try_parse_from([ + "alera", + "workspace", + "unpin", + "--id", + "workspace-2", + "--tree", + ]) + .unwrap(); assert!(matches!( pin.command, Command::Workspace(WorkspaceCommand { - action: WorkspaceAction::Pin(IdArgs { id }), + action: WorkspaceAction::Pin(WorkspacePinArgs { id, tree: false }), .. }) if id == "workspace-1" )); assert!(matches!( unpin.command, Command::Workspace(WorkspaceCommand { - action: WorkspaceAction::Unpin(IdArgs { id }), + action: WorkspaceAction::Unpin(WorkspacePinArgs { id, tree: true }), .. }) if id == "workspace-2" )); diff --git a/rust/alera-cli/src/events_commands.rs b/rust/alera-cli/src/events_commands.rs new file mode 100644 index 000000000..099185ec2 --- /dev/null +++ b/rust/alera-cli/src/events_commands.rs @@ -0,0 +1,85 @@ +//! `alera events`: read the runtime event journal by cursor. + +use std::time::{Duration, Instant}; + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::cli::{EventsAction, EventsCommand, EventsFilterArgs}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY; +use crate::{print_error, print_value, runtime_dir}; + +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +pub async fn run(command: EventsCommand) -> i32 { + let json_output = command.output.json; + let result = async { + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &runtime_dir(&command.runtime), + RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY, + ) + .await?; + match command.action { + EventsAction::List(args) => list(&mut client, &args.filter).await, + EventsAction::Wait(args) => { + wait( + &mut client, + &args.filter, + Duration::from_secs(args.timeout_seconds), + ) + .await + } + } + } + .await; + match result { + Ok(page) => { + let count = page["events"].as_array().map_or(0, Vec::len); + let message = format!("{count} event(s), cursor {}", page["cursor"]); + print_value(&page, json_output, &message); + 0 + } + Err(error) => print_error(error), + } +} + +fn payload(filter: &EventsFilterArgs) -> Value { + json!({ + "after": filter.after, + "kinds": filter.kinds, + "workspaceId": filter.workspace_id, + "limit": filter.limit, + }) +} + +async fn list(client: &mut RuntimeHostRpcClient, filter: &EventsFilterArgs) -> Result { + client + .request_value("runtimeEvents.list", &payload(filter)) + .await +} + +/// Polls from the cursor until a matching event arrives or the time is up. +/// The cursor still advances past events the filter skips. +async fn wait( + client: &mut RuntimeHostRpcClient, + filter: &EventsFilterArgs, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + let mut request = payload(filter); + loop { + let page = client.request_value("runtimeEvents.list", &request).await?; + let found = page["events"] + .as_array() + .is_some_and(|events| !events.is_empty()); + let now = Instant::now(); + if found || page["truncated"] == true || now >= deadline { + let mut page = page; + page["timedOut"] = json!(!found && now >= deadline); + return Ok(page); + } + request["after"] = page["cursor"].clone(); + tokio::time::sleep(POLL_INTERVAL.min(deadline - now)).await; + } +} diff --git a/rust/alera-cli/src/host_tools.rs b/rust/alera-cli/src/host_tools.rs index 64d6eedf0..28cae30b0 100644 --- a/rust/alera-cli/src/host_tools.rs +++ b/rust/alera-cli/src/host_tools.rs @@ -8,7 +8,6 @@ use serde::Serialize; use crate::login_shell_environment::setup_command_environment; const WRAPPER_MARKER: &str = "alera-managed-cli-wrapper-v1"; -const SKILL_REPOSITORY: &str = "https://github.com/leynier/alera"; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -116,28 +115,47 @@ pub(crate) async fn install_cli_registration(runtime_dir: &Path) -> Result Option { match value { "cli" => Some(Self::Cli), "orchestration" => Some(Self::Orchestration), "automations" => Some(Self::Automations), + "agentProfiles" => Some(Self::AgentProfiles), _ => None, } } - fn package_name(self) -> &'static str { + pub(crate) fn id(self) -> &'static str { + match self { + Self::Cli => "cli", + Self::Orchestration => "orchestration", + Self::Automations => "automations", + Self::AgentProfiles => "agentProfiles", + } + } + + pub(crate) fn package_name(self) -> &'static str { match self { Self::Cli => "alera-cli", Self::Orchestration => "alera-orchestration", Self::Automations => "alera-automations", + Self::AgentProfiles => "alera-agent-profiles", } } } @@ -178,7 +196,8 @@ pub(crate) struct SkillInstallAttempt { pub runner_missing: bool, } -pub(crate) async fn install_skill(kind: SkillKind, runner: SkillRunner) -> SkillInstallResult { +/// Installs the skills in one `skills add` run, from this build's commit. +pub(crate) async fn install_skills(kinds: &[SkillKind], runner: SkillRunner) -> SkillInstallResult { let environment = setup_command_environment().await; let runners = match runner { SkillRunner::Auto => vec![SkillRunner::Npx, SkillRunner::Bunx], @@ -186,7 +205,7 @@ pub(crate) async fn install_skill(kind: SkillKind, runner: SkillRunner) -> Skill }; let mut attempts = Vec::new(); for candidate in runners { - let attempt = run_skill_install(kind, candidate, &environment).await; + let attempt = run_skill_install(kinds, candidate, &environment).await; let done = attempt.exit_code == 0 || !attempt.runner_missing; attempts.push(attempt); if done { @@ -196,19 +215,24 @@ pub(crate) async fn install_skill(kind: SkillKind, runner: SkillRunner) -> Skill let succeeded = attempts .last() .is_some_and(|attempt| attempt.exit_code == 0); + let names = kinds + .iter() + .map(|kind| kind.package_name()) + .collect::>() + .join(", "); let summary = if succeeded { - format!("{} Installed", kind.package_name()) + format!("{names} Installed") } else { attempts .last() .map(|attempt| { if attempt.output.is_empty() { - format!("{} Install Failed", kind.package_name()) + format!("{names} Install Failed") } else { - format!("{} Install Failed: {}", kind.package_name(), attempt.output) + format!("{names} Install Failed: {}", attempt.output) } }) - .unwrap_or_else(|| format!("{} Install Failed", kind.package_name())) + .unwrap_or_else(|| format!("{names} Install Failed")) }; SkillInstallResult { succeeded, @@ -217,8 +241,26 @@ pub(crate) async fn install_skill(kind: SkillKind, runner: SkillRunner) -> Skill } } +/// `skills add` arguments. `--agent codex` and `--yes` keep it from asking +/// which agents to install for: without a terminal it would install nothing. +/// Codex is named explicitly so a global install is not expanded to +/// project-only universal agents, as in the desktop command. +pub(crate) fn skill_install_arguments(kinds: &[SkillKind]) -> Vec { + let mut arguments = vec![ + "skills".to_owned(), + "add".to_owned(), + crate::agent_skills::install_source(), + ]; + for kind in kinds { + arguments.push("--skill".to_owned()); + arguments.push(kind.package_name().to_owned()); + } + arguments.extend(["--agent", "codex", "--global", "--yes"].map(str::to_owned)); + arguments +} + async fn run_skill_install( - kind: SkillKind, + kinds: &[SkillKind], runner: SkillRunner, environment: &[(String, String)], ) -> SkillInstallAttempt { @@ -229,14 +271,7 @@ async fn run_skill_install( (_, SkillRunner::Auto) => unreachable!(), }; let result = windowless_async_command(executable) - .args([ - "skills", - "add", - SKILL_REPOSITORY, - "--skill", - kind.package_name(), - "--global", - ]) + .args(skill_install_arguments(kinds)) .envs(environment.iter().cloned()) .output() .await; diff --git a/rust/alera-cli/src/inbox_commands.rs b/rust/alera-cli/src/inbox_commands.rs index 5e1af3898..3dcb37bb7 100644 --- a/rust/alera-cli/src/inbox_commands.rs +++ b/rust/alera-cli/src/inbox_commands.rs @@ -4,11 +4,15 @@ use serde_json::{json, Map, Value}; use crate::cli::RuntimeDirArgs; use crate::cli_inbox::{InboxAction, InboxCommand, InboxWaitArgs}; +use crate::mcp_tools::CallOrigin; use crate::orchestration_commands::{read_body, request_value_with_capability}; use crate::terminal_host::protocol::{ ORCHESTRATION_MAX_WAIT_TIMEOUT_MS, RUNTIME_HOST_INBOX_CAPABILITY, + RUNTIME_HOST_INBOX_ORIGIN_CAPABILITY, }; +mod origin; + const DEFAULT_INBOX: &str = "ext:user"; const DEFAULT_WAIT_MS: u64 = 10 * 60 * 1000; /// The host answers a parked wait at its deadline; the client allows a little more. @@ -18,6 +22,7 @@ const RECONNECT_BACKOFF: Duration = Duration::from_secs(2); pub(crate) async fn run(command: InboxCommand) -> i32 { let json_output = command.output.json; let runtime = command.runtime; + let caller = CallOrigin::from_env(); let result = match command.action { InboxAction::Targets(args) => { call( @@ -50,6 +55,12 @@ pub(crate) async fn run(command: InboxCommand) -> i32 { insert(&mut payload, "subject", args.subject); insert(&mut payload, "priority", args.priority); insert(&mut payload, "expiresInMs", args.expires_in_ms); + insert(&mut payload, "requestKey", args.request_key); + insert( + &mut payload, + "externalOrigin", + caller.as_ref().map(origin::external_origin), + ); payload.insert("body".into(), json!(body)); call(&runtime, "inbox.ask", Value::Object(payload)).await } @@ -63,6 +74,10 @@ pub(crate) async fn run(command: InboxCommand) -> i32 { insert(&mut payload, "status", args.status); insert(&mut payload, "limit", args.limit); insert(&mut payload, "before", args.before); + match origin::client_filter(args.origin_client_id, args.scope, caller.as_ref()) { + Ok(filter) => insert(&mut payload, "originClientId", filter), + Err(message) => return usage(&message), + } call(&runtime, "inbox.threads", Value::Object(payload)).await } InboxAction::Show(args) => { @@ -98,7 +113,10 @@ pub(crate) async fn run(command: InboxCommand) -> i32 { let inbox = inbox_address(args.address.inbox); call(&runtime, "inbox.purge", json!({ "inbox": inbox })).await } - InboxAction::Wait(args) => wait(&runtime, args).await, + InboxAction::Wait(args) => match origin::client_filter(None, args.scope, caller.as_ref()) { + Ok(filter) => wait(&runtime, args, filter).await, + Err(message) => return usage(&message), + }, InboxAction::Conversations(args) => { let mut payload = Map::new(); insert(&mut payload, "workspaceId", args.workspace); @@ -117,7 +135,8 @@ pub(crate) async fn run(command: InboxCommand) -> i32 { } }; match result { - Ok(value) => { + Ok(mut value) => { + origin::annotate(&mut value, caller.as_ref()); print(&value, json_output); exit_code(&value) } @@ -152,7 +171,7 @@ async fn call( ) -> anyhow::Result { request_value_with_capability( runtime, - RUNTIME_HOST_INBOX_CAPABILITY, + capability_for(&payload), request_type, payload, None, @@ -160,10 +179,27 @@ async fn call( .await } +/// Attribution, retry keys, and origin filters need a host that honors +/// them; anything else works with any inbox host. +fn capability_for(payload: &Value) -> &'static str { + let needs_origin = ["externalOrigin", "requestKey", "originClientId"] + .iter() + .any(|key| payload.get(*key).is_some()); + if needs_origin { + RUNTIME_HOST_INBOX_ORIGIN_CAPABILITY + } else { + RUNTIME_HOST_INBOX_CAPABILITY + } +} + /// Chains host waits, each at most the host's ceiling, until there is news or /// the overall deadline passes. A dropped connection is retried, so a host /// restart in the middle of a long wait does not end it. -async fn wait(runtime: &RuntimeDirArgs, args: InboxWaitArgs) -> anyhow::Result { +async fn wait( + runtime: &RuntimeDirArgs, + args: InboxWaitArgs, + origin_client_id: Option, +) -> anyhow::Result { let total = Duration::from_millis(args.timeout_ms.unwrap_or(DEFAULT_WAIT_MS)); let started = Instant::now(); let mut payload = Map::new(); @@ -172,6 +208,8 @@ async fn wait(runtime: &RuntimeDirArgs, args: InboxWaitArgs) -> anyhow::Result payload.insert("inbox".into(), json!(inbox_address(args.address.inbox))), }; payload.insert("after".into(), json!(args.after)); + insert(&mut payload, "originClientId", origin_client_id); + let capability = capability_for(&Value::Object(payload.clone())); loop { let remaining = total.saturating_sub(started.elapsed()); // Never 0: the host treats 0 as a poll and answers `pending`. @@ -179,7 +217,7 @@ async fn wait(runtime: &RuntimeDirArgs, args: InboxWaitArgs) -> anyhow::Result anyhow::Result {} + // An origin-filtered wait moves its cursor past other clients' news. + Ok(value) if value["outcome"] == "timeout" && still_time => { + if let Some(cursor) = value["cursor"].as_i64() { + payload.insert("after".into(), json!(cursor)); + } + } Ok(mut value) => { if value["outcome"] == "pending" { value["outcome"] = json!("timeout"); diff --git a/rust/alera-cli/src/inbox_commands/origin.rs b/rust/alera-cli/src/inbox_commands/origin.rs new file mode 100644 index 000000000..c7bc825fd --- /dev/null +++ b/rust/alera-cli/src/inbox_commands/origin.rs @@ -0,0 +1,155 @@ +//! The MCP client behind an `inbox` command, when an MCP tool runs it: who a +//! question is attributed to, which threads `--scope own` keeps, and whether +//! each returned thread or message is the caller's own. + +use serde_json::{json, Map, Value}; + +use crate::cli_inbox::InboxScope; +use crate::mcp_tools::CallOrigin; + +/// `externalOrigin` for `inbox.ask`: who asked, without the per-call id. +pub(super) fn external_origin(origin: &CallOrigin) -> Value { + let mut value = Map::new(); + value.insert("transport".into(), json!(origin.transport)); + for (key, field) in [ + ("clientId", &origin.client_id), + ("clientName", &origin.client_name), + ("grantId", &origin.grant_id), + ] { + if let Some(text) = field { + value.insert(key.into(), json!(text)); + } + } + Value::Object(value) +} + +/// The `originClientId` filter: an explicit id, or the caller's own for +/// `--scope own`. +pub(super) fn client_filter( + explicit: Option, + scope: Option, + caller: Option<&CallOrigin>, +) -> Result, String> { + if explicit.is_some() { + return Ok(explicit); + } + if scope != Some(InboxScope::Own) { + return Ok(None); + } + caller + .and_then(|origin| origin.client_id.clone()) + .map(Some) + .ok_or_else(|| { + "--scope own needs the identity of the MCP client running this command; use --scope all." + .to_string() + }) +} + +/// Whether `origin` (a thread's recorded origin) names the calling client. +fn is_own(origin: &Value, caller: Option<&CallOrigin>) -> bool { + let Some(client_id) = caller.and_then(|origin| origin.client_id.as_deref()) else { + return false; + }; + origin["surface"] == "mcp" && origin["clientId"].as_str() == Some(client_id) +} + +/// Adds `isOwn` to listed threads and to a shown thread, and `threadId`, +/// `origin`, and `isOwn` to the messages of an inbox wait. +pub(super) fn annotate(value: &mut Value, caller: Option<&CallOrigin>) { + let listing = value["kind"] == "inboxThreads"; + if let Some(items) = value.get_mut("items").and_then(Value::as_array_mut) { + for thread in items.iter_mut().filter(|_| listing) { + let own = is_own(&thread["origin"], caller); + thread["isOwn"] = json!(own); + } + } + if let Some(thread) = value.get_mut("thread").filter(|thread| thread.is_object()) { + let own = is_own(&thread["origin"], caller); + thread["isOwn"] = json!(own); + } + let Some(origins) = value.get("threadOrigins").cloned() else { + return; + }; + if let Some(messages) = value.get_mut("messages").and_then(Value::as_array_mut) { + for message in messages { + let thread_id = message["thread_id"] + .as_str() + .or_else(|| message["id"].as_str()) + .unwrap_or_default() + .to_string(); + let origin = origins.get(&thread_id).cloned().unwrap_or(Value::Null); + message["isOwn"] = json!(is_own(&origin, caller)); + message["origin"] = origin; + message["threadId"] = json!(thread_id); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn caller(client_id: &str) -> CallOrigin { + CallOrigin::remote(client_id, "ChatGPT", "g-1", "c-1") + } + + #[test] + fn the_external_origin_drops_the_call_id() { + let origin = external_origin(&caller("chatgpt")); + assert_eq!( + origin, + json!({"transport": "remote", "clientId": "chatgpt", "clientName": "ChatGPT", "grantId": "g-1"}) + ); + } + + #[test] + fn own_scope_uses_the_callers_client_id() { + let chatgpt = caller("chatgpt"); + assert_eq!( + client_filter(None, Some(InboxScope::Own), Some(&chatgpt)).unwrap(), + Some("chatgpt".to_string()) + ); + assert_eq!( + client_filter(None, Some(InboxScope::All), Some(&chatgpt)).unwrap(), + None + ); + assert_eq!( + client_filter(Some("other".into()), None, None).unwrap(), + Some("other".to_string()) + ); + assert!(client_filter(None, Some(InboxScope::Own), None).is_err()); + } + + #[test] + fn threads_and_messages_say_whether_they_are_the_callers() { + let chatgpt = caller("chatgpt"); + let mut listing = json!({"kind": "inboxThreads", "items": [ + {"threadId": "a", "origin": {"surface": "mcp", "clientId": "chatgpt"}}, + {"threadId": "b", "origin": {"surface": "mcp", "clientId": "claude-ai"}}, + {"threadId": "c", "origin": null}, + ]}); + annotate(&mut listing, Some(&chatgpt)); + let own: Vec = listing["items"] + .as_array() + .unwrap() + .iter() + .map(|item| item["isOwn"].as_bool().unwrap()) + .collect(); + assert_eq!(own, [true, false, false]); + let mut wait = json!({ + "outcome": "message", + "messages": [{"id": "r1", "thread_id": "a"}, {"id": "r2", "thread_id": "z"}], + "threadOrigins": {"a": {"surface": "mcp", "clientId": "chatgpt"}}, + }); + annotate(&mut wait, Some(&chatgpt)); + assert_eq!(wait["messages"][0]["isOwn"], true); + assert_eq!(wait["messages"][0]["threadId"], "a"); + assert_eq!(wait["messages"][1]["origin"], json!(null)); + assert_eq!(wait["messages"][1]["isOwn"], false); + let mut anonymous = listing.clone(); + annotate(&mut anonymous, None); + assert_eq!(anonymous["items"][0]["isOwn"], false); + } +} diff --git a/rust/alera-cli/src/main.rs b/rust/alera-cli/src/main.rs index 0c6779a29..f981f6c86 100644 --- a/rust/alera-cli/src/main.rs +++ b/rust/alera-cli/src/main.rs @@ -3,6 +3,8 @@ mod agent_profile_input; mod agent_profile_launch; mod agent_prompt_stdin_script; mod agent_quota; +mod agent_quota_commands; +mod agent_skills; mod agent_status; mod automation_autostart; mod automation_commands; @@ -24,6 +26,7 @@ mod cli_tests; mod cli_workflow_plans; mod cli_workflow_recipes; mod cli_workflow_workspaces; +mod events_commands; mod host_tools; mod hosted_review_retention; mod hub_federation; @@ -61,6 +64,7 @@ mod project_hosts; mod project_management; #[cfg(windows)] mod pty_job_bootstrap; +mod pull_request_commands; mod relocation_owned_worktree; mod relocation_setup_process; mod remote_managed_workspace; @@ -90,20 +94,25 @@ mod runtime_clear; mod runtime_commands; mod runtime_host_client; mod runtime_host_command; +mod runtime_settings_commands; mod setup_process_cancellation; mod shared_workspace; mod shared_workspace_removal; +mod skill_commands; mod ssh_bootstrap; mod ssh_remote; mod ssh_target_status; mod ssh_windows_command; mod tab_agent_link_command; +mod tab_commands; mod tab_record_factory; mod tailscale; mod terminal_alias_commands; mod terminal_host; +mod terminal_lifecycle_commands; mod terminal_stdio_mode; mod voice_commands; +mod webhook_commands; mod windows_path_form; mod workflow_plan_commands; mod workflow_recipe_commands; @@ -115,17 +124,24 @@ mod workspace_context; mod workspace_focus; mod workspace_handoff; mod workspace_issue_commands; +mod workspace_list; mod workspace_pinning; mod workspace_pr_watch_commands; +mod workspace_prompt_start; mod workspace_registration; mod workspace_relocation_recovery; mod workspace_relocation_setup; mod workspace_removal_dependencies; +mod workspace_remove; +mod workspace_remove_preview; mod workspace_rename; mod workspace_sections; mod workspace_setup_command; +mod workspace_show; mod workspace_sleep; mod workspace_start; +mod workspace_tree; +mod workspace_wake; mod worktree_copy; mod worktree_include; mod worktree_setup; @@ -151,7 +167,7 @@ use crate::cli::{ CascadePreviewArgs, Cli, Command, IdArgs, ProjectAction, ProjectCommand, ProjectKindArg, RuntimeDirArgs, SshAuthKindArg, SshTargetAction, SshTargetAddArgs, SshTargetBootstrapArgs, SshTargetBootstrapPlanArgs, SshTargetCommand, SshTargetLinkArgs, SshTargetStatusArgs, - TabAction, TabCommand, WorkspaceAction, WorkspaceCommand, + WorkspaceAction, WorkspaceCommand, }; use crate::cli::{MobileAction, MobileCommand, MobileDevicesAction, MobilePairingAction}; use crate::cli::{TerminalAction, TerminalCommand}; @@ -167,7 +183,6 @@ use crate::ssh_bootstrap::{ run_ssh_bootstrap, SshTargetBootstrapRequest, }; use crate::ssh_target_status::{collect_ssh_target_status, LiveSshTargetProbe}; -use crate::tab_record_factory::tab_from_args; /// Usage-error exit code, matching the Dart CLI (`_usageExitCode`). const USAGE_EXIT_CODE: i32 = 64; @@ -213,19 +228,24 @@ async fn run(cli: Cli) -> i32 { Command::Project(command) => run_project_command(command).await, Command::Workspace(command) => run_workspace_command(command).await, Command::Issue(command) => issue_commands::run(command).await, + Command::Pr(command) => pull_request_commands::run(command).await, Command::Tag(command) => run_tag_command(command).await, - Command::Tab(command) => run_tab_command(command).await, + Command::Tab(command) => tab_commands::run(command).await, Command::Terminal(command) => run_terminal_command(command).await, Command::SshTarget(command) => run_ssh_target_command(command).await, Command::Mobile(command) => run_mobile_command(command).await, Command::Automation(command) => automation_commands::run(command).await, Command::AgentProfile(command) => agent_profile_commands::run(command).await, + Command::AgentQuota(command) => agent_quota_commands::run(command).await, Command::Orchestration(command) => { orchestration_commands::run_orchestration_command(command).await } Command::Voice(command) => voice_commands::run(command).await, Command::Inbox(command) => inbox_commands::run(command).await, + Command::Events(command) => events_commands::run(command).await, + Command::Webhook(command) => webhook_commands::run(command).await, + Command::Skill(command) => skill_commands::run(command).await, Command::Account(command) => mcp_commands::run_account(command).await, Command::Mcp(command) => mcp_commands::run_mcp(command).await, } @@ -254,6 +274,12 @@ async fn run_terminal_command(command: TerminalCommand) -> i32 { | TerminalAction::Prune(_)) => { terminal_alias_commands::run(&mut client, action, command.output.json).await } + action @ (TerminalAction::Restart(_) + | TerminalAction::Terminate(_) + | TerminalAction::Pulse(_)) => { + terminal_lifecycle_commands::run_terminal(&mut client, action, command.output.json) + .await + } TerminalAction::Read(args) => match client .request_value( "terminal.read", @@ -353,72 +379,14 @@ async fn run_workspace_command(command: WorkspaceCommand) -> i32 { let json_output = command.output.json; match command.action { WorkspaceAction::List(args) => { - let store = match open_store(&runtime).await { - Ok(store) => store, - Err(error) => return print_error(error), - }; - if !args.all && args.project_id.is_none() { - eprintln!("Missing --project-id or --all."); - return USAGE_EXIT_CODE; - } - match crate::hub_federation::read_from_hub( - &runtime, - &store, - "workspace.list", - json!({ "projectId": args.project_id, "hostId": args.host_id }), - ) - .await - { - Ok(Some(answer)) => { - print_value( - &json!({ - "kind": "workspaces", - "items": answer["items"], - "filters": { "hostId": args.host_id }, - "source": "hub", - "originHostId": answer["originHostId"], - }), - json_output, - "workspaces listed", - ); - return 0; - } - Err(error) => return print_error(error), - Ok(None) => {} - } - let result = if args.all { - store.list_all_workspaces().await - } else if let Some(project_id) = args.project_id { - store.list_workspaces(&project_id).await - } else { - eprintln!("Missing --project-id or --all."); - return USAGE_EXIT_CODE; - }; - let host_id = args - .host_id - .as_deref() - .map(|host_id| crate::ssh_remote::normalized_host_id(Some(host_id))); - match result { - Ok(mut workspaces) => { - if let Some(host_id) = &host_id { - workspaces.retain(|workspace| &workspace.host_id == host_id); - } - print_value( - &json!({ - "kind": "workspaces", - "items": workspaces, - "filters": { "hostId": host_id }, - }), - json_output, - "workspaces listed", - ) - } - Err(error) => return print_error(error), - } + return workspace_list::run(runtime, args, json_output).await; } WorkspaceAction::Start(args) => { return workspace_start::run(runtime, args, json_output).await; } + WorkspaceAction::PromptStart(command) => { + return workspace_prompt_start::run(runtime, command, json_output).await; + } WorkspaceAction::HandOff(args) => { return workspace_handoff::run_hand_off(runtime, args, json_output).await; } @@ -478,61 +446,16 @@ async fn run_workspace_command(command: WorkspaceCommand) -> i32 { } } WorkspaceAction::Remove(args) => { - let delete_branch = if args.delete_branch { - Some(true) - } else if args.keep_branch { - Some(false) - } else { - None - }; - let payload = json!({ - "id": args.id, - "deleteBranch": delete_branch, - "closeSessions": args.close_sessions, - }); - let value: Value = match runtime_host_required(&runtime).await { - Ok(mut client) => { - let workspace: Value = match client - .request_value("workspace.find", &json!({"id": args.id})) - .await - { - Ok(workspace) => workspace, - Err(error) => return print_error(error), - }; - if let Err(error) = - workspace_removal_dependencies::prepare_cli_removal_dependencies( - &mut client, - &args.id, - args.pause_automations_and_cancel_runs, - ) - .await - { - return print_error(error); - } - let operation = if workspace.get("kind").and_then(Value::as_str) == Some("main") - { - "workspace.removeShared" - } else { - "workspace.removeManaged" - }; - let removed = if operation == "workspace.removeShared" { - workspace_buffer_guard_request::request_with_workspace_buffer_guard( - &mut client, - "removeShared", - &payload, - ) - .await - } else { - client.request_value(operation, &payload).await - }; - match removed { - Ok(value) => value, - Err(error) => return print_error(error), - } - } - Err(error) => return print_error(error), - }; - print_value(&value, json_output, "workspace removed"); + return workspace_remove::run(runtime, args, json_output).await; + } + WorkspaceAction::RemovePreview(args) => { + return workspace_remove_preview::run(runtime, args, json_output).await; + } + WorkspaceAction::Show(args) => { + return workspace_show::run(runtime, args, json_output).await; + } + WorkspaceAction::Wake(args) => { + return workspace_wake::run(runtime, args, json_output).await; } WorkspaceAction::Register(args) => { let workspace = match workspace_registration::from_args(args) { @@ -577,11 +500,11 @@ async fn run_workspace_command(command: WorkspaceCommand) -> i32 { WorkspaceAction::Focus(args) => { return workspace_focus::run(&runtime, args, json_output).await; } - WorkspaceAction::Pin(IdArgs { id }) => { - return workspace_pinning::run(runtime_dir(&runtime), json_output, id, true).await; + WorkspaceAction::Pin(args) => { + return workspace_pinning::run(runtime_dir(&runtime), json_output, args, true).await; } - WorkspaceAction::Unpin(IdArgs { id }) => { - return workspace_pinning::run(runtime_dir(&runtime), json_output, id, false).await; + WorkspaceAction::Unpin(args) => { + return workspace_pinning::run(runtime_dir(&runtime), json_output, args, false).await; } WorkspaceAction::Sleep(args) => { return workspace_sleep::run(&runtime_dir(&runtime), args, json_output).await; @@ -744,70 +667,6 @@ async fn run_tag_command(command: crate::cli::TagCommand) -> i32 { 0 } -async fn run_tab_command(command: TabCommand) -> i32 { - let runtime = command.runtime; - let json_output = command.output.json; - match command.action { - TabAction::LinkAgent(args) => { - return tab_agent_link_command::run(&runtime, args, json_output).await; - } - TabAction::List(args) => match open_store(&runtime).await { - Ok(store) => match store.list_workspace_tabs(&args.workspace_id).await { - Ok(tabs) => print_value( - &json!({ "kind": "tabs", "items": tabs, "filters": { "workspaceId": args.workspace_id } }), - json_output, - "tabs listed", - ), - Err(error) => return print_error(error), - }, - Err(error) => return print_error(error), - }, - TabAction::Create(args) => { - let tab = match tab_from_args(args) { - Ok(tab) => tab, - Err(error) => { - eprintln!("{error}"); - return USAGE_EXIT_CODE; - } - }; - let fallback_tab = tab.clone(); - match runtime_host_or_store(&runtime, "tab.upsert", &tab, |store| async move { - store.upsert_workspace_tab(fallback_tab).await - }) - .await - { - Ok(tab) => print_value(&tab, json_output, "tab saved"), - Err(error) => return print_error(error), - } - } - TabAction::Remove(IdArgs { id }) => { - let payload = json!({ "id": id }); - let removed_id = id.clone(); - match runtime_host_or_store_unit(&runtime, "tab.remove", &payload, |store| async move { - if let Some(tab) = store.find_workspace_tab(&id).await? { - if tab.kind == "terminal" { - if let Some(workspace) = store.find_workspace(&tab.workspace_id).await? { - if workspace.host_id != alera_core::runtime::LOCAL_HOST_ID { - anyhow::bail!("The Home runtime must be available to verify SSH terminal closure before removing this tab"); - } - } - } - } - let retentions = hosted_review_retention::for_tab(&store, &id).await; - store.remove_workspace_tab(&id).await?; - hosted_review_retention::release(retentions); - Ok(()) - }) - .await - { - Ok(()) => print_value(&json!({ "id": removed_id }), json_output, "tab removed"), - Err(error) => return print_error(error), - } - } - } - 0 -} - async fn run_ssh_target_command(command: SshTargetCommand) -> i32 { let runtime = command.runtime; let json_output = command.output.json; diff --git a/rust/alera-cli/src/main_project_commands.rs b/rust/alera-cli/src/main_project_commands.rs index c0e3e83ed..bd8e3c597 100644 --- a/rust/alera-cli/src/main_project_commands.rs +++ b/rust/alera-cli/src/main_project_commands.rs @@ -1,9 +1,19 @@ use super::*; +#[path = "project_manage_commands.rs"] +mod project_manage_commands; + pub(super) async fn run_project_command(command: ProjectCommand) -> i32 { let runtime = command.runtime; let json_output = command.output.json; match command.action { + action @ (ProjectAction::Rename(_) + | ProjectAction::Clone(_) + | ProjectAction::RemovePreview(_) + | ProjectAction::Branches(_) + | ProjectAction::Config(_)) => { + return project_manage_commands::run(&runtime, action, json_output).await; + } ProjectAction::List => match open_store(&runtime).await { Ok(store) => match crate::hub_federation::read_from_hub( &runtime, @@ -222,7 +232,7 @@ pub(super) async fn run_project_command(command: ProjectCommand) -> i32 { project_management::register_project_with_identity( &store, &args.repo_path, - Some(&args.name), + args.name.as_deref(), args.id.as_deref(), Some(kind), ) @@ -242,22 +252,31 @@ pub(super) async fn run_project_command(command: ProjectCommand) -> i32 { Ok(client) => client, Err(error) => return print_error(error), }; - if let Err(error) = - workspace_removal_dependencies::prepare_cli_project_removal_dependencies( + let paused = + match workspace_removal_dependencies::prepare_cli_project_removal_dependencies( &mut client, &id, true, ) .await - { - return print_error(error); - } + { + Ok(dependencies) => dependencies + .into_iter() + .filter(|dependency| dependency.requires_pause) + .map(|dependency| json!({"id": dependency.id, "name": dependency.name})) + .collect::>(), + Err(error) => return print_error(error), + }; return match client .request_value("project.remove", &json!({"id": id})) .await { Ok(_) => { - print_value(&json!({"id":id}), json_output, "project removed"); + print_value( + &json!({"id": id, "removed": true, "pausedAutomations": paused}), + json_output, + "project removed", + ); 0 } Err(error) => print_error(error), diff --git a/rust/alera-cli/src/mcp_commands.rs b/rust/alera-cli/src/mcp_commands.rs index 8d1110d85..3616c2a79 100644 --- a/rust/alera-cli/src/mcp_commands.rs +++ b/rust/alera-cli/src/mcp_commands.rs @@ -11,8 +11,10 @@ use anyhow::{anyhow, bail, Result}; use serde_json::{json, Value}; use crate::cli::{ - AccountAction, AccountCommand, AccountLoginArgs, AccountProviderArg, McpAction, McpCommand, + AccountAction, AccountCommand, AccountLoginArgs, AccountProviderArg, McpAccessArg, McpAction, + McpCommand, }; +use crate::mcp_settings::McpAccess; use crate::mcp_tools::{catalog, catalog_json, serve_stdio, ToolExecution}; use crate::runtime_host_client::RuntimeHostRpcClient; use crate::{print_error, print_value, runtime_dir}; @@ -35,7 +37,7 @@ pub(crate) async fn run_mcp(command: McpCommand) -> i32 { let result = match command.action { McpAction::Status => mcp_status(&runtime_dir).await, McpAction::Enable(args) => { - let access = if args.read_only { "read" } else { "full" }; + let access = access_level(args.level()).as_str(); update_settings(&runtime_dir, json!({ "access": access })).await } McpAction::Disable => update_settings(&runtime_dir, json!({ "access": "off" })).await, @@ -51,7 +53,7 @@ pub(crate) async fn run_mcp(command: McpCommand) -> i32 { McpAction::Tools => Ok(catalog_json()), McpAction::Serve(args) => { return match ToolExecution::current(runtime_dir) { - Ok(execution) => match serve_stdio(execution, args.read_only).await { + Ok(execution) => match serve_stdio(execution, access_level(args.level())).await { Ok(()) => 0, Err(error) => print_error(error), }, @@ -279,10 +281,19 @@ fn open_in_browser(url: &str) { .spawn(); } +fn access_level(access: McpAccessArg) -> McpAccess { + match access { + McpAccessArg::Read => McpAccess::Read, + McpAccessArg::Full => McpAccess::Full, + McpAccessArg::Admin => McpAccess::Admin, + } +} + fn settings_message(value: &Value) -> String { let access = value["access"].as_str().unwrap_or("off"); let name = value["effectiveRuntimeName"].as_str().unwrap_or_default(); let mut message = match access { + "admin" => format!("MCP Control is on for {name} (full control with administrative tools)"), "full" => format!("MCP Control is on for {name} (full control)"), "read" => format!("MCP Control is on for {name} (read only)"), _ => format!("MCP Control is off for {name}"), diff --git a/rust/alera-cli/src/mcp_settings.rs b/rust/alera-cli/src/mcp_settings.rs index 901cfb9c7..db68e2a8f 100644 --- a/rust/alera-cli/src/mcp_settings.rs +++ b/rust/alera-cli/src/mcp_settings.rs @@ -19,6 +19,7 @@ pub(crate) enum McpAccess { Off, Read, Full, + Admin, } impl McpAccess { @@ -27,6 +28,7 @@ impl McpAccess { Self::Off => "off", Self::Read => "read", Self::Full => "full", + Self::Admin => "admin", } } @@ -35,15 +37,18 @@ impl McpAccess { "off" => Some(Self::Off), "read" => Some(Self::Read), "full" => Some(Self::Full), + "admin" => Some(Self::Admin), _ => None, } } + /// Each level allows its own tool class and every class below it. pub(crate) fn allows(self, access: ToolAccess) -> bool { match self { Self::Off => false, Self::Read => access == ToolAccess::Read, - Self::Full => true, + Self::Full => access != ToolAccess::Admin, + Self::Admin => true, } } } @@ -106,6 +111,10 @@ mod tests { assert!(McpAccess::Read.allows(ToolAccess::Read)); assert!(!McpAccess::Read.allows(ToolAccess::Execute)); assert!(McpAccess::Full.allows(ToolAccess::Execute)); + assert!(!McpAccess::Full.allows(ToolAccess::Admin)); + assert!(McpAccess::Admin.allows(ToolAccess::Admin)); + assert!(McpAccess::Admin.allows(ToolAccess::Read)); + assert_eq!(McpAccess::parse("admin"), Some(McpAccess::Admin)); assert_eq!(McpAccess::parse("full"), Some(McpAccess::Full)); assert_eq!(McpAccess::parse("all"), None); } diff --git a/rust/alera-cli/src/mcp_tools/arguments.rs b/rust/alera-cli/src/mcp_tools/arguments.rs index d87763206..d73813ee8 100644 --- a/rust/alera-cli/src/mcp_tools/arguments.rs +++ b/rust/alera-cli/src/mcp_tools/arguments.rs @@ -61,10 +61,20 @@ impl ToolArguments { .unwrap_or(false) } + /// A boolean the caller may leave out, so `false` and absent differ. + pub(crate) fn optional_flag(&self, name: &str) -> Option { + self.values.get(name).and_then(Value::as_bool) + } + pub(crate) fn integer(&self, name: &str) -> Option { self.values.get(name).and_then(Value::as_u64) } + /// A JSON object argument, such as a definition sent to the CLI on stdin. + pub(crate) fn object(&self, name: &str) -> Option<&Map> { + self.values.get(name).and_then(Value::as_object) + } + pub(crate) fn list(&self, name: &str) -> Option> { self.values .get(name) @@ -105,6 +115,7 @@ fn check_property(name: &str, value: &Value, schema: &Value) -> Result<(), ToolI } } Some("boolean") if !value.is_boolean() => return invalid("must be a boolean"), + Some("object") if !value.is_object() => return invalid("must be an object"), Some("integer") => { let Some(number) = value.as_u64() else { return invalid("must be a non-negative integer"); diff --git a/rust/alera-cli/src/mcp_tools/catalog/agent_skills.rs b/rust/alera-cli/src/mcp_tools/catalog/agent_skills.rs new file mode 100644 index 000000000..5c60de334 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/agent_skills.rs @@ -0,0 +1,55 @@ +//! The Alera skills coding agents use in Alera terminals. The skills MCP +//! clients read are served by the Alera cloud, not by the runtime. + +use super::{admin, no_arguments, read, LAUNCH_TIMEOUT}; +use crate::mcp_tools::schema::{object, one_of, string_list}; +use crate::mcp_tools::{Invocation, ToolSpec}; + +const SKILLS: &[&str] = &["cli", "orchestration", "automations", "agent-profiles"]; +/// Below the call's deadline, so the CLI answers `running` before the call is +/// cut off; the runtime keeps installing. +const INSTALL_WAIT_SECONDS: u64 = 45; + +pub(super) fn tools() -> Vec { + vec![ + read( + "check_agent_skills", + "Check Agent Skills", + "Show whether the Alera skills that coding agents use in Alera terminals (alera-cli, alera-orchestration, alera-automations, alera-agent-profiles) are installed on this runtime's machine and whether each matches this runtime's version, plus the running install (install.running) and the last finished one (install.last).", + no_arguments, + |_| Ok(Invocation::new("skill", &["status"])), + ), + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + idempotent: true, + ..admin( + "install_agent_skills", + "Install Agent Skills", + "Install or update the Alera skills that coding agents use, at this runtime's own version, through the skills installer (npx or bunx) on its machine. Installs every skill unless skills is given. The runtime runs the install as a job and this call waits up to 45 seconds: state completed or failed carries the outcome and the new status; state running means it is still installing, so read check_agent_skills later instead of calling again. Only one install runs at a time.", + || { + object( + &[ + ("skills", string_list("Skills to install (default all).", SKILLS)), + ( + "runner", + one_of( + "Package runner: auto (default) tries npx, then bunx.", + &["auto", "npx", "bunx"], + ), + ), + ], + &[], + ) + }, + |arguments| { + let mut invocation = + Invocation::new("skill", &["install"]).option("--wait-seconds", INSTALL_WAIT_SECONDS.to_string()); + for skill in arguments.list("skills").unwrap_or_default() { + invocation = invocation.option("--skill", skill); + } + Ok(invocation.option_if("--runner", arguments.string("runner"))) + }, + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/agents.rs b/rust/alera-cli/src/mcp_tools/catalog/agents.rs new file mode 100644 index 000000000..308a46904 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/agents.rs @@ -0,0 +1,53 @@ +//! Agent profiles: reading them and launching them. + +use super::{ + execute, no_arguments, profile_schema, read, with_profile, LAUNCH_TIMEOUT, PROMPT_LIMIT, +}; +use crate::mcp_tools::schema::{object, string, text}; +use crate::mcp_tools::{Invocation, ToolInputError, ToolSpec}; + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_agent_profiles", + "List Agent Profiles", + "List the agent profiles (Claude, Codex, and others) that can be launched or delegated to, with their ids and names.", + no_arguments, + |_| Ok(Invocation::new("agent-profile", &["list"])), + ), + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + client_request_flag: Some("--client-mutation-id"), + ..execute( + "launch_agent", + "Launch Agent", + "Launch an agent profile in a new tab of an existing workspace. Send a prompt to start a conversation, resumeSessionId to continue an earlier one, or neither to start the agent idle.", + || { + object( + &[ + ("workspaceId", string("Workspace id.")), + ("profile", profile_schema()), + ("prompt", text("Prompt delivered to the agent.", PROMPT_LIMIT)), + ("resumeSessionId", string("Agent conversation id to resume instead of sending a prompt.")), + ], + &["workspaceId", "profile"], + ) + }, + |arguments| { + let invocation = with_profile( + Invocation::new("agent-profile", &["launch"]), + arguments.required("profile")?, + ) + .option("--workspace", arguments.required("workspaceId")?); + match (arguments.string("prompt"), arguments.string("resumeSessionId")) { + (Some(_), Some(_)) => Err(ToolInputError( + "A resumed session cannot receive a prompt. Send prompt or resumeSessionId, not both.".into(), + )), + (Some(prompt), None) => Ok(invocation.flag("--prompt-stdin").stdin(prompt)), + (None, resume) => Ok(invocation.option_if("--resume-session-id", resume)), + } + }, + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/agents_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/agents_manage.rs new file mode 100644 index 000000000..e193076f2 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/agents_manage.rs @@ -0,0 +1,222 @@ +//! Agent profile configuration, which is administrative. Reading profiles is +//! open to every client; changing them needs the `admin` class (F2). + +use serde_json::Value; + +use super::{admin, profile_schema, read, with_profile, PROMPT_LIMIT}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +const REVISION_LIMIT: u64 = i64::MAX as u64; + +fn selected(action: &[&str], arguments: &ToolArguments) -> Result { + Ok(with_profile( + Invocation::new("agent-profile", action), + arguments.required("profile")?, + )) +} + +fn expected_revision(invocation: Invocation, arguments: &ToolArguments) -> Invocation { + invocation.option_if( + "--expected-revision", + arguments + .integer("expectedRevision") + .map(|value| value.to_string()), + ) +} + +fn revision_schema() -> Value { + integer( + "Profile revision from show_agent_profile. The change is refused if the profile changed since.", + 0, + REVISION_LIMIT, + ) +} + +/// Properties shared by create and update. Update treats each as optional. +fn profile_properties() -> Vec<(&'static str, Value)> { + vec![ + ("name", text("Display name.", 200)), + ("agentType", string("Agent adapter, such as claude, codex, or opencode.")), + ("launchMode", one_of("Run a command line or a managed configuration.", &["command", "managed"])), + ("command", text("Interactive command for the command launch mode.", 8_192)), + ("managedConfig", serde_json::json!({ + "type": "object", + "description": "Managed configuration for the managed launch mode, as show_agent_profile returns it.", + })), + ("customPrompt", text("Instructions added to every prompt this profile receives.", PROMPT_LIMIT)), + ("description", text("Short description.", 2_000)), + ("quotaGroup", string("Quota group the profile counts against.")), + ("showInNewTabMenu", boolean("Show the profile in the app's new tab menu.")), + ("confirmReducedProtections", boolean("Confirm settings that reduce the agent's protections, such as skipping permission prompts.")), + ] +} + +/// The options both create and update pass the same way. +fn profile_options( + invocation: Invocation, + arguments: &ToolArguments, +) -> Result { + let invocation = invocation + .option_if("--name", arguments.string("name")) + .option_if("--agent-type", arguments.string("agentType")) + .option_if("--launch-mode", arguments.string("launchMode")) + .option_if("--command", arguments.string("command")) + .flag_if( + "--confirm-reduced-protections", + arguments.flag("confirmReducedProtections"), + ); + Ok(match arguments.object("managedConfig") { + Some(config) => { + let config = + serde_json::to_string(config).map_err(|error| ToolInputError(error.to_string()))?; + invocation.flag("--managed-config-stdin").stdin(config) + } + None => invocation, + }) +} + +/// A text option or its `--clear-…` flag, never both. +fn text_or_clear( + invocation: Invocation, + arguments: &ToolArguments, + (property, flag): (&str, &str), + (clear_property, clear_flag): (&str, &str), +) -> Result { + let value = arguments.string(property); + let clear = arguments.flag(clear_property); + if value.is_some() && clear { + return Err(ToolInputError(format!( + "Send {property} or {clear_property}, not both." + ))); + } + Ok(invocation.option_if(flag, value).flag_if(clear_flag, clear)) +} + +pub(super) fn tools() -> Vec { + vec![ + read( + "show_agent_profile", + "Show Agent Profile", + "Show one agent profile with its launch configuration and revision.", + || object(&[("profile", profile_schema())], &["profile"]), + |arguments| selected(&["show"], arguments), + ), + read( + "preview_agent_profile_removal", + "Preview Agent Profile Removal", + "Show what refers to an agent profile, such as automations, before removing it.", + || object(&[("profile", profile_schema())], &["profile"]), + |arguments| selected(&["removal-impact"], arguments), + ), + admin( + "create_agent_profile", + "Create Agent Profile", + "Create an agent profile. A managed profile takes managedConfig; a command profile takes command.", + || object(&profile_properties(), &["name", "agentType", "launchMode"]), + |arguments| { + let invocation = profile_options(Invocation::new("agent-profile", &["create"]), arguments)? + .option_if("--custom-prompt", arguments.string("customPrompt")) + .option_if("--description", arguments.string("description")) + .option_if("--quota-group", arguments.string("quotaGroup")) + .flag_if("--show-in-new-tab-menu", arguments.flag("showInNewTabMenu")); + Ok(invocation) + }, + ), + ToolSpec { + idempotent: true, + ..admin( + "update_agent_profile", + "Update Agent Profile", + "Change an agent profile. Fields left out keep their values; the clear fields remove an optional text.", + || { + let mut properties = vec![ + ("profile", profile_schema()), + ("expectedRevision", revision_schema()), + ]; + properties.extend(profile_properties()); + properties.extend([ + ("clearCustomPrompt", boolean("Remove the custom prompt.")), + ("clearDescription", boolean("Remove the description.")), + ("clearQuotaGroup", boolean("Remove the quota group.")), + ]); + object(&properties, &["profile"]) + }, + |arguments| { + let invocation = expected_revision(selected(&["update"], arguments)?, arguments); + let invocation = profile_options(invocation, arguments)?.option_if( + "--show-in-new-tab-menu", + arguments.optional_flag("showInNewTabMenu").map(|value| value.to_string()), + ); + let invocation = text_or_clear(invocation, arguments, ("customPrompt", "--custom-prompt"), ("clearCustomPrompt", "--clear-custom-prompt"))?; + let invocation = text_or_clear(invocation, arguments, ("description", "--description"), ("clearDescription", "--clear-description"))?; + text_or_clear(invocation, arguments, ("quotaGroup", "--quota-group"), ("clearQuotaGroup", "--clear-quota-group")) + }, + ) + }, + ToolSpec { + destructive: true, + ..admin( + "remove_agent_profile", + "Remove Agent Profile", + "Remove an agent profile after the runtime checks what refers to it.", + || { + object( + &[("profile", profile_schema()), ("expectedRevision", revision_schema())], + &["profile"], + ) + }, + |arguments| { + Ok(expected_revision(selected(&["remove"], arguments)?, arguments).flag("--confirm")) + }, + ) + }, + ToolSpec { + idempotent: true, + ..admin( + "reorder_agent_profiles", + "Reorder Agent Profiles", + "Set the order of every agent profile. List each profile id once, in the new order.", + || { + object( + &[( + "ids", + serde_json::json!({ + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true, + "description": "Every profile id from list_agent_profiles, in the new order.", + }), + )], + &["ids"], + ) + }, + |arguments| { + let ids = arguments.list("ids").unwrap_or_default(); + if ids.is_empty() { + return Err(ToolInputError("Missing required argument `ids`.".into())); + } + Ok(ids + .into_iter() + .fold(Invocation::new("agent-profile", &["reorder"]), |invocation, id| { + invocation.option("--id", id) + })) + }, + ) + }, + ToolSpec { + idempotent: true, + ..admin( + "set_default_agent_profile", + "Set Default Agent Profile", + "Choose the agent profile new workspaces from a prompt use when none is named.", + || object(&[("profileId", string("Profile id from list_agent_profiles."))], &["profileId"]), + |arguments| { + let assignment = format!("defaultAgentProfileId={}", arguments.required("profileId")?); + Ok(Invocation::new("runtime", &["settings", "set", &assignment])) + }, + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/automations.rs b/rust/alera-cli/src/mcp_tools/catalog/automations.rs new file mode 100644 index 000000000..0649ea319 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/automations.rs @@ -0,0 +1,108 @@ +//! Runtime automations and their runs. + +use super::{execute, read}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string}; +use crate::mcp_tools::{Invocation, ToolSpec}; + +const BUCKETS: &[&str] = &[ + "all", + "needsAttention", + "completed", + "draft", + "active", + "paused", + "blocked", + "trashed", +]; + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_automations", + "List Automations", + "List runtime automations with the same filters as the Automations view: state, bucket, project, agent profile, tag, workspace, section, host, or search text.", + || { + object( + &[ + ("state", string("Automation state: draft, active, paused, blocked, archived, or trashed.")), + ("bucket", one_of("View bucket. needsAttention covers blocked or not ready automations and runs waiting for you.", BUCKETS)), + ("projectId", string("Project id.")), + ("profileId", string("Agent profile id the automation runs.")), + ("tagId", string("Automation tag id from list_automation_tags.")), + ("workspaceId", string("Workspace the automation belongs to.")), + ("sectionId", string("Workspace section id.")), + ("hostId", string("Host the automation runs on.")), + ("search", string("Text to search for in the name, slug, or description.")), + ("includeTrashed", boolean("Include automations in the trash.")), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("automation", &["list"]) + .option_if("--state", arguments.string("state")) + .option_if("--bucket", arguments.string("bucket")) + .option_if("--project-id", arguments.string("projectId")) + .option_if("--profile-id", arguments.string("profileId")) + .option_if("--tag", arguments.string("tagId")) + .option_if("--workspace-id", arguments.string("workspaceId")) + .option_if("--section-id", arguments.string("sectionId")) + .option_if("--host-id", arguments.string("hostId")) + .option_if("--search", arguments.string("search")) + .flag_if("--include-trashed", arguments.flag("includeTrashed"))) + }, + ), + read( + "list_automation_runs", + "List Automation Runs", + "List automation runs, newest first, optionally for one automation.", + || { + object( + &[ + ("automationId", string("Automation id.")), + ("limit", integer("Maximum runs (default 20).", 1, 100)), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("automation", &["runs"]) + .option_if("--automation-id", arguments.string("automationId")) + .option( + "--limit", + arguments.integer("limit").unwrap_or(20).to_string(), + )) + }, + ), + execute( + "run_automation", + "Run Automation", + "Start one run of an automation immediately, as Run Now does. By default it follows the automation's own precheck and overlap settings.", + || { + object( + &[ + ("automationId", string("Automation id from list_automations.")), + ("precheck", one_of("Run or skip the configured precheck for this run.", &["run", "skip"])), + ("overlap", one_of("What to do when another run is active: skip, queue behind it, run the latest once, or run in parallel.", &["skip", "queue", "runLatestOnce", "forceParallel"])), + ("continueFromRunId", string("Earlier run whose conversation this run continues.")), + ("expectedRevision", integer("Refuse to run if the automation changed since this revision.", 0, u64::MAX >> 11)), + ], + &["automationId"], + ) + }, + |arguments| { + let precheck = arguments.string("precheck"); + Ok(Invocation::new("automation", &["run-now"]) + .option("--id", arguments.required("automationId")?) + .flag_if("--precheck", precheck.as_deref() == Some("run")) + .flag_if("--skip-precheck", precheck.as_deref() == Some("skip")) + .option_if("--overlap", arguments.string("overlap")) + .option_if("--continue-from-run", arguments.string("continueFromRunId")) + .option_if( + "--revision", + arguments.integer("expectedRevision").map(|value| value.to_string()), + )) + }, + ), + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/automations_catalog.rs b/rust/alera-cli/src/mcp_tools/catalog/automations_catalog.rs new file mode 100644 index 000000000..3ef46e781 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/automations_catalog.rs @@ -0,0 +1,139 @@ +//! Prompt templates, automation tags, and catalog export and import. + +use serde_json::json; + +use super::{json_object, with_object}; +use crate::mcp_tools::catalog::{execute, no_arguments, read}; +use crate::mcp_tools::schema::{object, string}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_automation_templates", + "List Automation Templates", + "List the saved prompt templates automations can start from.", + no_arguments, + |_| Ok(Invocation::new("automation", &["templates"])), + ), + ToolSpec { + idempotent: true, + ..execute( + "upsert_automation_template", + "Save Automation Template", + "Create or replace a prompt template by id, as Save as Template does.", + || { + object( + &[( + "template", + json_object("Template with id, name, promptTemplate, and optional description and defaults. updatedAt is optional."), + )], + &["template"], + ) + }, + |arguments| { + with_object( + Invocation::new("automation", &["templates"]), + arguments, + "template", + ) + }, + ) + }, + read( + "list_automation_tags", + "List Automation Tags", + "List the tags that group automations.", + no_arguments, + |_| Ok(Invocation::new("automation", &["tags"])), + ), + execute( + "upsert_automation_tags", + "Save Automation Tags", + "Create a tag, or rename one by tagId, and set the tags of an automation. Pass tagName, automationId with tagIds, or both. To remove every tag, use update_automation with tagIds set to an empty list.", + || { + object( + &[ + ("tagName", string("Name of the tag to create, or the new name of tagId.")), + ("tagId", string("Existing tag to rename.")), + ("automationId", string("Automation whose tags tagIds replaces.")), + ("tagIds", json!({ + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true, + "description": "Every tag id the automation should have.", + })), + ], + &[], + ) + }, + upsert_tags, + ), + read( + "export_automations", + "Export Automations", + "Export this runtime's automations, templates, and tags as a portable catalog that import_automations accepts.", + no_arguments, + |_| Ok(Invocation::new("automation", &["export"])), + ), + execute( + "import_automations", + "Import Automations", + "Import a catalog from export_automations. Imported automations keep their ids unless remapped.", + || { + object( + &[ + ("bundle", json_object("Catalog object as export_automations returns it.")), + ("remap", json_object("Map of source id to target id, for projects, profiles, or workspaces that differ on this runtime.")), + ], + &["bundle"], + ) + }, + import_automations, + ), + ] +} + +fn upsert_tags(arguments: &ToolArguments) -> Result { + let name = arguments.string("tagName"); + let automation = arguments.string("automationId"); + let tag_ids = arguments.list("tagIds").unwrap_or_default(); + if arguments.string("tagId").is_some() && name.is_none() { + return Err(ToolInputError("tagId needs tagName, the new name.".into())); + } + if automation.is_some() == tag_ids.is_empty() { + return Err(ToolInputError( + "Pass automationId together with tagIds.".into(), + )); + } + if name.is_none() && automation.is_none() { + return Err(ToolInputError( + "Pass tagName, or automationId with tagIds.".into(), + )); + } + let mut invocation = Invocation::new("automation", &["tags"]) + .option_if("--name", name) + .option_if("--id", arguments.string("tagId")) + .option_if("--automation-id", automation); + for tag_id in tag_ids { + invocation = invocation.option("--assign", tag_id); + } + Ok(invocation) +} + +fn import_automations(arguments: &ToolArguments) -> Result { + let mut invocation = Invocation::new("automation", &["import"]); + for (source, target) in arguments.object("remap").into_iter().flatten() { + let Some(target) = target.as_str().filter(|target| !target.is_empty()) else { + return Err(ToolInputError(format!( + "remap of `{source}` must be a non-empty id." + ))); + }; + if source.is_empty() || source.contains('=') { + return Err(ToolInputError("remap keys must be ids.".into())); + } + invocation = invocation.option("--remap", format!("{source}={target}")); + } + with_object(invocation, arguments, "bundle") +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/automations_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/automations_manage.rs new file mode 100644 index 000000000..0a7163b9e --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/automations_manage.rs @@ -0,0 +1,379 @@ +//! Automation authoring, state changes, run control, and the shared catalog +//! of templates and tags. Definitions travel to the CLI on stdin. + +use serde_json::{json, Value}; + +use super::{execute, read}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +const REQUEST_KEY: Option<&str> = Some("--request-key"); +const DEFAULT_EXTENSION_SECONDS: u64 = 3600; +const MAX_EXTENSION_SECONDS: u64 = 30 * 24 * 3600; + +pub(super) fn json_object(description: &str) -> Value { + json!({ "type": "object", "description": description }) +} + +fn automation_id() -> Value { + string("Automation id from list_automations.") +} + +fn run_id() -> Value { + string("Automation run id from list_automation_runs.") +} + +/// Sends a JSON object argument to the CLI on stdin. +pub(super) fn with_object( + invocation: Invocation, + arguments: &ToolArguments, + name: &str, +) -> Result { + let value = arguments + .object(name) + .ok_or_else(|| ToolInputError(format!("Missing required argument `{name}`.")))?; + Ok(invocation + .option("--file", "-") + .stdin(Value::Object(value.clone()).to_string())) +} + +#[path = "automations_catalog.rs"] +mod automations_catalog; + +pub(super) fn tools() -> Vec { + let mut tools = vec![ + read( + "show_automation", + "Show Automation", + "Show one automation with its definition, readiness, next occurrences, recent runs, and audit history.", + || object(&[("automationId", automation_id())], &["automationId"]), + |arguments| { + Ok(Invocation::new("automation", &["show"]) + .option("--id", arguments.required("automationId")?)) + }, + ), + read( + "show_automation_run", + "Show Automation Run", + "Show one automation run with its attempts and the automation definition it ran.", + || object(&[("runId", run_id())], &["runId"]), + |arguments| { + Ok(Invocation::new("automation", &["run-show"]) + .option("--id", arguments.required("runId")?)) + }, + ), + ToolSpec { + client_request_flag: REQUEST_KEY, + ..execute( + "create_automation", + "Create Automation", + "Create an automation from a JSON definition, as the automation editor saves it. It starts active unless draft is true or the definition sets state to draft. Check the definition first with check_automation_readiness.", + || { + object( + &[ + ("definition", json_object("Automation definition: name, promptTemplate, schedule ({recurring: {cron, timezone}} or {oneTime: {at, timezone}}), target, and optional policies, tagIds, and projectId.")), + ("draft", boolean("Save as a draft instead of activating it.")), + ], + &["definition"], + ) + }, + |arguments| { + let invocation = Invocation::new("automation", &["create"]) + .flag_if("--draft", arguments.flag("draft")); + with_object(invocation, arguments, "definition") + }, + ) + }, + execute( + "update_automation", + "Update Automation", + "Change fields of an automation, as saving the automation editor does. Fields not given keep their value.", + || { + object( + &[ + ("automationId", automation_id()), + ("changes", json_object("Definition fields to replace, such as name, promptTemplate, schedule, target, or tagIds.")), + ("expectedRevision", integer("Refuse the change if the automation changed since this revision.", 0, u64::MAX >> 11)), + ], + &["automationId", "changes"], + ) + }, + |arguments| { + let invocation = Invocation::new("automation", &["edit"]) + .option("--id", arguments.required("automationId")?) + .option_if( + "--expected-revision", + arguments.integer("expectedRevision").map(|value| value.to_string()), + ); + with_object(invocation, arguments, "changes") + }, + ), + ToolSpec { + client_request_flag: REQUEST_KEY, + ..execute( + "clone_automation", + "Clone Automation", + "Create a new automation with the settings and target of an existing one, as the Clone action does. The copy is named after the original followed by Copy unless a name is given.", + || { + object( + &[ + ("automationId", automation_id()), + ("name", string("Name of the copy.")), + ("draft", boolean("Save the copy as a draft instead of activating it.")), + ], + &["automationId"], + ) + }, + |arguments| { + Ok(Invocation::new("automation", &["clone"]) + .option("--id", arguments.required("automationId")?) + .option_if("--name", arguments.string("name")) + .flag_if("--draft", arguments.flag("draft"))) + }, + ) + }, + read( + "check_automation_readiness", + "Check Automation Readiness", + "Validate a full or partial automation definition without saving it, and list the fields to fix.", + || object(&[("definition", json_object("Automation definition, as create_automation takes it."))], &["definition"]), + |arguments| with_object(Invocation::new("automation", &["readiness"]), arguments, "definition"), + ), + read( + "preview_automation_schedule", + "Preview Automation Schedule", + "Preview the next occurrences of a recurring cron schedule or a one-time date.", + || { + object( + &[ + ("kind", one_of("recurring takes a cron expression; oneTime takes a date and time.", &["recurring", "oneTime"])), + ("value", string("Cron expression such as 0 9 * * 1-5, or an ISO 8601 date and time.")), + ("timezone", string("IANA time zone such as Europe/Madrid (default UTC).")), + ], + &["kind", "value"], + ) + }, + |arguments| { + let flag = match arguments.required("kind")?.as_str() { + "oneTime" => "--at", + _ => "--cron", + }; + Ok(Invocation::new("automation", &["preview-schedule"]) + .option(flag, arguments.required("value")?) + .option_if("--timezone", arguments.string("timezone"))) + }, + ), + ]; + tools.extend(state_changes()); + tools.extend(run_control()); + tools.extend(automations_catalog::tools()); + tools +} + +fn state_change( + name: &'static str, + title: &'static str, + description: &'static str, + build: super::Build, +) -> ToolSpec { + execute( + name, + title, + description, + || { + object( + &[ + ("automationId", automation_id()), + ("reason", string("Why, recorded in the audit history.")), + ("activeRuns", one_of("With active runs, keep them running or cancel them. Required when pausing an automation that has active runs.", &["continue-active", "cancel-active"])), + ], + &["automationId"], + ) + }, + build, + ) +} + +fn state_invocation( + action: &'static str, + arguments: &ToolArguments, +) -> Result { + Ok(Invocation::new("automation", &[action]) + .option("--id", arguments.required("automationId")?) + .option_if("--reason", arguments.string("reason")) + .option_if("--active-runs", arguments.string("activeRuns"))) +} + +fn state_changes() -> Vec { + vec![ + state_change("pause_automation", "Pause Automation", "Pause an automation so it stops scheduling runs. With active runs, choose activeRuns: continue-active keeps them, cancel-active cancels them.", |arguments| state_invocation("pause", arguments)), + state_change("resume_automation", "Resume Automation", "Activate a paused or draft automation. Fails with the fields to fix when it is not ready.", |arguments| state_invocation("resume", arguments)), + ToolSpec { + destructive: true, + ..state_change("trash_automation", "Trash Automation", "Move an automation to the trash. It can be restored with restore_automation until it is purged.", |arguments| state_invocation("trash", arguments)) + }, + state_change("restore_automation", "Restore Automation", "Restore a trashed automation, paused, or completed if it was completed before.", |arguments| state_invocation("restore", arguments)), + ToolSpec { + destructive: true, + ..execute( + "purge_automations", + "Purge Automations", + "Permanently delete every automation that has been in the trash for at least 30 days, as Purge in the trash view does.", + super::no_arguments, + |_| Ok(Invocation::new("automation", &["purge"])), + ) + }, + ] +} + +fn run_control() -> Vec { + vec![ + ToolSpec { + destructive: true, + idempotent: true, + ..execute( + "cancel_automation_run", + "Cancel Automation Run", + "Cancel a queued, running, or waiting automation run, as Cancel in the run view does.", + || object(&[("runId", run_id())], &["runId"]), + |arguments| { + Ok(Invocation::new("automation", &["cancel"]) + .option("--run", arguments.required("runId")?) + .flag("--use-run-identity")) + }, + ) + }, + execute( + "resume_automation_run", + "Resume Automation Run", + "Resume a run that is waiting for you, so its agent continues.", + || object(&[("runId", run_id())], &["runId"]), + |arguments| { + Ok(Invocation::new("automation", &["wait"]) + .option("--run", arguments.required("runId")?) + .flag("--resume") + .flag("--use-run-identity")) + }, + ), + execute( + "extend_automation_run", + "Extend Automation Run", + "Extend the deadline of a run that is waiting for you, by seconds (default one hour) or until a date and time.", + || { + object( + &[ + ("runId", run_id()), + ("seconds", integer("Seconds to add (default 3600).", 1, MAX_EXTENSION_SECONDS)), + ("until", string("New deadline as an ISO 8601 date and time, instead of seconds.")), + ], + &["runId"], + ) + }, + extend_run, + ), + execute( + "take_over_automation_run", + "Take Over Automation Run", + "Take over the terminal of an automation run, as Take Over in the app does. The automation stops driving the agent and the terminal stays open for a person.", + || object(&[("runId", run_id())], &["runId"]), + |arguments| { + Ok(Invocation::new("automation", &["take-over"]) + .option("--run", arguments.required("runId")?)) + }, + ), + ] +} + +fn extend_run(arguments: &ToolArguments) -> Result { + let invocation = Invocation::new("automation", &["extend"]) + .option("--run", arguments.required("runId")?) + .flag("--use-run-identity"); + match (arguments.integer("seconds"), arguments.string("until")) { + (Some(_), Some(_)) => Err(ToolInputError("Pass seconds or until, not both.".into())), + (None, Some(until)) => Ok(invocation.option("--until", until)), + (seconds, None) => Ok(invocation.option( + "--seconds", + seconds.unwrap_or(DEFAULT_EXTENSION_SECONDS).to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::mcp_tools::find_tool; + + fn args(tool: &str, arguments: serde_json::Value) -> Result, String> { + find_tool(tool) + .unwrap() + .invocation(&arguments) + .map(|invocation| invocation.args) + .map_err(|error| error.0) + } + + #[test] + fn definitions_travel_on_stdin() { + let invocation = find_tool("create_automation") + .unwrap() + .invocation( + &json!({"definition": {"name": "Nightly"}, "clientRequestId": "request-0001"}), + ) + .unwrap(); + assert_eq!(invocation.stdin.as_deref(), Some(r#"{"name":"Nightly"}"#)); + assert!(invocation.args.contains(&"--file=-".to_owned())); + assert!(invocation + .args + .contains(&"--request-key=request-0001".to_owned())); + assert!(args("create_automation", json!({"definition": "text"})).is_err()); + } + + #[test] + fn run_control_uses_the_recorded_run_identity() { + let extend = args("extend_automation_run", json!({"runId": "r"})).unwrap(); + assert!(extend.contains(&"--seconds=3600".to_owned())); + assert!(extend.contains(&"--use-run-identity".to_owned())); + assert!(args( + "extend_automation_run", + json!({"runId": "r", "seconds": 5, "until": "x"}) + ) + .is_err()); + let cancel = args("cancel_automation_run", json!({"runId": "r"})).unwrap(); + assert!(cancel.contains(&"--use-run-identity".to_owned())); + } + + #[test] + fn tags_and_imports_validate_their_shape() { + assert!(args("upsert_automation_tags", json!({})).is_err()); + assert!(args("upsert_automation_tags", json!({"automationId": "a"})).is_err()); + let assign = args( + "upsert_automation_tags", + json!({"automationId": "a", "tagIds": ["t1", "t2"]}), + ) + .unwrap(); + assert_eq!( + assign + .iter() + .filter(|arg| arg.starts_with("--assign=")) + .count(), + 2 + ); + let import = args( + "import_automations", + json!({"bundle": {}, "remap": {"p1": "p2"}}), + ) + .unwrap(); + assert!(import.contains(&"--remap=p1=p2".to_owned())); + assert!(args( + "import_automations", + json!({"bundle": {}, "remap": {"p1": 3}}) + ) + .is_err()); + let run = args( + "run_automation", + json!({"automationId": "a", "precheck": "skip"}), + ) + .unwrap(); + assert!(run.contains(&"--skip-precheck".to_owned())); + } +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/events.rs b/rust/alera-cli/src/mcp_tools/catalog/events.rs new file mode 100644 index 000000000..b9c977afc --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/events.rs @@ -0,0 +1,141 @@ +//! The runtime event journal: polling with a cursor works in every MCP +//! client, including those that cannot receive notifications. + +use super::{admin, no_arguments, read, wait_schema, wait_seconds, WAIT_TIMEOUT}; +use crate::mcp_tools::schema::{integer, object, string, string_list}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolSpec}; + +const KINDS: &[&str] = &[ + "inbox.reply", + "inbox.question.status", + "agent.status", + "terminal.exit", + "orchestration.task.state", + "orchestration.gate.created", + "orchestration.escalation", + "automation.run.state", + "workspace.start.state", + "workspace.lifecycle", + "pullRequest.watch", +]; + +fn filter_properties() -> Vec<(&'static str, serde_json::Value)> { + vec![ + ( + "after", + integer( + "Cursor from a previous result; omit to read from the oldest retained event.", + 0, + u64::MAX >> 11, + ), + ), + ("kinds", string_list("Only these event kinds.", KINDS)), + ("workspaceId", string("Only events of this workspace.")), + ("limit", integer("Maximum events (default 100).", 1, 500)), + ] +} + +fn filtered(invocation: Invocation, arguments: &ToolArguments) -> Invocation { + let invocation = invocation + .option( + "--after", + arguments.integer("after").unwrap_or(0).to_string(), + ) + .option_if("--workspace-id", arguments.string("workspaceId")) + .option_if( + "--limit", + arguments.integer("limit").map(|value| value.to_string()), + ); + match arguments.list("kinds") { + Some(kinds) => invocation.option("--kind", kinds.join(",")), + None => invocation, + } +} + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_events", + "List Events", + "List runtime events after a cursor, oldest first: inbox replies, question states, agent states, terminal exits, task and run changes, decision gates, automation runs, workspace starts and lifecycle, and pull request watch actions. Events carry ids and states; read details with the matching tool. Pass the returned cursor as after next time. truncated means older events were pruned.", + || object(&filter_properties(), &[]), + |arguments| Ok(filtered(Invocation::new("events", &["list"]), arguments)), + ), + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + ..read( + "wait_for_events", + "Wait For Events", + "Wait up to timeoutSeconds for runtime events after a cursor, then return them with the next cursor. Use it instead of polling each question or task: one wait covers every kind you filter for. Call again with the returned cursor to keep following.", + || { + let mut properties = filter_properties(); + properties.push(("timeoutSeconds", wait_schema())); + object(&properties, &[]) + }, + |arguments| { + Ok(filtered(Invocation::new("events", &["wait"]), arguments).option( + "--timeout-seconds", + wait_seconds(arguments, 30).to_string(), + )) + }, + ) + }, + // Admin, not read: a callback URL often carries its receiver's token. + ToolSpec { + idempotent: true, + ..admin( + "list_webhooks", + "List Webhooks", + "List the signed webhooks that receive runtime events through the Alera cloud for this account, with their URLs, kinds, status, and last delivery.", + no_arguments, + |_| Ok(Invocation::new("webhook", &["list"])), + ) + }, + admin( + "create_webhook", + "Create Webhook", + "Add an HTTPS webhook that receives this runtime's events as signed POST requests (Standard Webhooks). Events carry ids and states only. Returns the signing secret once. Needs a signed-in Alera account.", + || { + object( + &[ + ("url", string("Public HTTPS endpoint. Private and loopback addresses are refused.")), + ("kinds", string_list("Event kinds to send (default every kind).", KINDS)), + ], + &["url"], + ) + }, + |arguments| { + let invocation = Invocation::new("webhook", &["add"]) + .option("--url", arguments.required("url")?); + Ok(match arguments.list("kinds") { + Some(kinds) => invocation.option("--kind", kinds.join(",")), + None => invocation, + }) + }, + ), + ToolSpec { + destructive: true, + idempotent: true, + ..admin( + "delete_webhook", + "Delete Webhook", + "Delete a webhook so it receives no more events.", + || object(&[("webhookId", string("Webhook id from list_webhooks."))], &["webhookId"]), + |arguments| { + Ok(Invocation::new("webhook", &["remove"]) + .option("--id", arguments.required("webhookId")?)) + }, + ) + }, + admin( + "test_webhook", + "Test Webhook", + "Send a signed test delivery to a webhook.", + || object(&[("webhookId", string("Webhook id from list_webhooks."))], &["webhookId"]), + |arguments| { + Ok(Invocation::new("webhook", &["test"]) + .option("--id", arguments.required("webhookId")?)) + }, + ), + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/inbox.rs b/rust/alera-cli/src/mcp_tools/catalog/inbox.rs new file mode 100644 index 000000000..f80a09ccd --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/inbox.rs @@ -0,0 +1,132 @@ +//! Questions an MCP client asks running agents, and their replies. + +use super::{execute, read, wait_schema, wait_seconds, MCP_INBOX, PROMPT_LIMIT, WAIT_TIMEOUT}; +use crate::mcp_tools::schema::{integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolInputError, ToolSpec}; + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_inbox_targets", + "List Askable Agents", + "List running agents that ask_agent can reach, optionally for one workspace.", + || object(&[("workspaceId", string("Workspace id."))], &[]), + |arguments| { + Ok(Invocation::new("inbox", &["targets"]) + .option_if("--workspace", arguments.string("workspaceId"))) + }, + ), + read( + "list_inbox_threads", + "List Question Threads", + "List question threads asked through ask_agent by every MCP client sharing this inbox, newest first. Each thread carries origin, the MCP client that asked, and isOwn, whether this client asked it.", + || { + object( + &[ + ("scope", one_of("own: only threads this MCP client asked. all (default): every client's threads.", &["all", "own"])), + ("originClientId", string("Only threads asked by this MCP client id, from a thread's origin.clientId.")), + ("status", one_of("Thread status.", &["pending", "received", "delivered", "expired", "answered", "cancelled"])), + ("workspaceId", string("Workspace id.")), + ("limit", integer("Maximum threads (default 50).", 1, 200)), + ("before", integer("Continue from the nextBefore value of a previous listing.", 0, u64::MAX >> 11)), + ], + &[], + ) + }, + |arguments| { + if arguments.string("originClientId").is_some() && arguments.string("scope").is_some() { + return Err(ToolInputError("Pass scope or originClientId, not both.".into())); + } + Ok(Invocation::new("inbox", &["threads"]) + .option("--inbox", MCP_INBOX) + .option_if("--scope", arguments.string("scope")) + .option_if("--origin-client-id", arguments.string("originClientId")) + .option_if("--status", arguments.string("status")) + .option_if("--workspace", arguments.string("workspaceId")) + .option_if("--limit", arguments.integer("limit").map(|value| value.to_string())) + .option_if("--before", arguments.integer("before").map(|value| value.to_string()))) + }, + ), + read( + "show_inbox_thread", + "Show Question Thread", + "Show a question thread with every question and reply.", + || object(&[("questionId", string("Any question id in the thread."))], &["questionId"]), + |arguments| { + Ok(Invocation::new("inbox", &["show"]) + .option("--question", arguments.required("questionId")?)) + }, + ), + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + ..read( + "wait_for_reply", + "Wait For Reply", + "Wait up to timeoutSeconds for news about a question asked with ask_agent. Pass the returned cursor as after to skip what was already seen.", + || { + object( + &[ + ("questionId", string("Question id returned by ask_agent.")), + ("after", integer("Cursor from a previous result.", 0, u64::MAX >> 11)), + ("timeoutSeconds", wait_schema()), + ], + &["questionId"], + ) + }, + |arguments| { + Ok(Invocation::new("inbox", &["wait"]) + .option("--inbox", MCP_INBOX) + .option("--question", arguments.required("questionId")?) + .option_if("--after", arguments.integer("after").map(|value| value.to_string())) + .option("--timeout", format!("{}s", wait_seconds(arguments, 30)))) + }, + ) + }, + ToolSpec { + client_request_flag: Some("--request-key"), + ..execute( + "ask_agent", + "Ask Agent", + "Ask a running agent a question by terminal handle, or the single agent of a workspace. The agent sees which MCP client asked. Returns the question id, its thread id, and the recorded origin; follow it with wait_for_reply or wait_for_inbox.", + || { + object( + &[ + ("body", text("The question.", PROMPT_LIMIT)), + ("to", string("Terminal handle of the agent.")), + ("workspaceId", string("Ask the single running agent of this workspace.")), + ("agent", string("With workspaceId, only agents of this type (claude, codex, ...).")), + ("threadId", string("Continue an earlier question's thread.")), + ("subject", string("Short subject.")), + ("priority", one_of("Priority.", &["normal", "high", "urgent"])), + ("expiresIn", string("Drop the question if undelivered in time, such as 30m or 2h.")), + ], + &["body"], + ) + }, + |arguments| { + let named = [arguments.string("to"), arguments.string("workspaceId"), arguments.string("threadId")] + .iter() + .filter(|value| value.is_some()) + .count(); + if named > 1 { + return Err(ToolInputError("Pass only one of to, workspaceId, or threadId.".into())); + } + if arguments.string("agent").is_some() && arguments.string("workspaceId").is_none() { + return Err(ToolInputError("agent needs workspaceId.".into())); + } + Ok(Invocation::new("inbox", &["ask"]) + .option("--inbox", MCP_INBOX) + .option_if("--to", arguments.string("to")) + .option_if("--workspace", arguments.string("workspaceId")) + .option_if("--agent", arguments.string("agent")) + .option_if("--thread", arguments.string("threadId")) + .option_if("--subject", arguments.string("subject")) + .option_if("--priority", arguments.string("priority")) + .option_if("--expires-in", arguments.string("expiresIn")) + .flag("--body-stdin") + .stdin(arguments.required("body")?)) + }, + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/inbox_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/inbox_manage.rs new file mode 100644 index 000000000..22f3c0ce6 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/inbox_manage.rs @@ -0,0 +1,124 @@ +//! Inbox maintenance and agent conversations. + +use super::{ + admin, execute, no_arguments, read, wait_schema, wait_seconds, MCP_INBOX, WAIT_TIMEOUT, +}; +use crate::mcp_tools::schema::{integer, object, one_of, string}; +use crate::mcp_tools::{Invocation, ToolSpec}; + +const CURSOR_MAX: u64 = u64::MAX >> 11; + +pub(super) fn tools() -> Vec { + vec![ + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + ..read( + "wait_for_inbox", + "Wait For Inbox", + "Wait up to timeoutSeconds for any new reply or message in the shared MCP inbox, so one call covers every open question. By default only replies to questions this MCP client asked count. Pass the returned cursor as after to skip what was already seen. Each message carries threadId, origin, and isOwn.", + || { + object( + &[ + ("after", integer("Cursor from a previous result (default 0).", 0, CURSOR_MAX)), + ("scope", one_of("own (default): only threads this MCP client asked. all: every client's threads. Not used with threadId.", &["own", "all"])), + ("threadId", string("Only news in this thread, by any question id in it.")), + ("timeoutSeconds", wait_schema()), + ], + &[], + ) + }, + |arguments| { + let invocation = Invocation::new("inbox", &["wait"]).option("--inbox", MCP_INBOX); + let invocation = match arguments.string("threadId") { + Some(thread) => invocation.option("--question", thread), + None => invocation.option( + "--scope", + arguments.string("scope").unwrap_or_else(|| "own".into()), + ), + }; + Ok(invocation + .option_if("--after", arguments.integer("after").map(|value| value.to_string())) + .option("--timeout", format!("{}s", wait_seconds(arguments, 30)))) + }, + ) + }, + execute( + "cancel_question", + "Cancel Question", + "Cancel a question asked with ask_agent before it reaches the agent. A question the agent already received cannot be cancelled.", + || object(&[("questionId", string("Question id returned by ask_agent."))], &["questionId"]), + |arguments| { + Ok(Invocation::new("inbox", &["cancel"]) + .option("--question", arguments.required("questionId")?)) + }, + ), + ToolSpec { + idempotent: true, + ..execute( + "mark_thread_read", + "Mark Thread Read", + "Mark every reply in a question thread as read for all clients sharing the inbox.", + || object(&[("questionId", string("Any question id in the thread."))], &["questionId"]), + |arguments| { + Ok(Invocation::new("inbox", &["read"]) + .option("--question", arguments.required("questionId")?)) + }, + ) + }, + read( + "list_inboxes", + "List Inboxes", + "List the external inboxes, such as the shared MCP inbox and the one the Alera apps use, with their thread, pending, awaiting reply, and unread counts.", + no_arguments, + |_| Ok(Invocation::new("inbox", &["list"])), + ), + read( + "list_agent_conversations", + "List Agent Conversations", + "List conversations between agents, newest first, optionally for one workspace or one participating terminal.", + || { + object( + &[ + ("workspaceId", string("Workspace id.")), + ("participant", string("Terminal handle that took part.")), + ("limit", integer("Maximum conversations (default 50).", 1, 200)), + ("before", integer("Continue from the nextBefore value of a previous listing.", 0, CURSOR_MAX)), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("inbox", &["conversations"]) + .option_if("--workspace", arguments.string("workspaceId")) + .option_if("--participant", arguments.string("participant")) + .option_if("--limit", arguments.integer("limit").map(|value| value.to_string())) + .option_if("--before", arguments.integer("before").map(|value| value.to_string()))) + }, + ), + read( + "show_agent_conversation", + "Show Agent Conversation", + "Show every message of one conversation between agents.", + || object(&[("threadId", string("Conversation thread id from list_agent_conversations."))], &["threadId"]), + |arguments| { + Ok(Invocation::new("inbox", &["conversation"]) + .option("--thread", arguments.required("threadId")?)) + }, + ), + ToolSpec { + destructive: true, + idempotent: true, + ..admin( + "purge_inbox", + "Purge MCP Inbox", + "Delete every question and reply of the shared MCP inbox, including the threads other MCP clients asked.", + no_arguments, + |_| { + Ok(Invocation::new("inbox", &["purge"]) + .option("--inbox", MCP_INBOX) + .flag("--confirm")) + }, + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/mod.rs b/rust/alera-cli/src/mcp_tools/catalog/mod.rs new file mode 100644 index 000000000..9630a9db5 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/mod.rs @@ -0,0 +1,182 @@ +//! The tool catalog, one module per domain. The constructors here set each +//! access class and its default timeout; every domain module lists its tools +//! in the order clients see them. + +mod agent_skills; +mod agents; +mod agents_manage; +mod automations; +mod automations_manage; +mod events; +mod inbox; +mod inbox_manage; +mod orchestration; +mod orchestration_manage; +mod projects; +mod projects_manage; +mod prompt_workspace; +mod pull_requests; +mod runtime; +mod runtime_manage; +mod terminals; +mod terminals_manage; +mod workflows; +mod workspaces; +mod workspaces_manage; + +use serde_json::Value; + +use super::schema::{integer, object, string}; +use super::{Invocation, ToolAccess, ToolArguments, ToolInputError, ToolSpec, MAX_WAIT_SECONDS}; + +/// Inbox used for questions an MCP client asks, kept apart from the user's own. +pub(super) const MCP_INBOX: &str = "ext:mcp"; +pub(super) const LIST_TIMEOUT: u64 = 30; +pub(super) const MUTATION_TIMEOUT: u64 = 30; +/// A wait or launch runs up to [`MAX_WAIT_SECONDS`] plus process start-up. +pub(super) const WAIT_TIMEOUT: u64 = MAX_WAIT_SECONDS + 8; +pub(super) const LAUNCH_TIMEOUT: u64 = MAX_WAIT_SECONDS + 8; +pub(super) const PROMPT_LIMIT: u64 = 65_536; + +type Build = fn(&ToolArguments) -> Result; + +fn spec( + access: ToolAccess, + timeout_seconds: u64, + name: &'static str, + title: &'static str, + description: &'static str, + input_schema: fn() -> Value, + build: Build, +) -> ToolSpec { + ToolSpec { + name, + title, + description, + access, + timeout_seconds, + destructive: false, + idempotent: false, + client_request_flag: None, + omit_fields: &[], + input_schema, + build, + } +} + +/// A tool that only reads runtime state. +pub(super) fn read( + name: &'static str, + title: &'static str, + description: &'static str, + input_schema: fn() -> Value, + build: Build, +) -> ToolSpec { + spec( + ToolAccess::Read, + LIST_TIMEOUT, + name, + title, + description, + input_schema, + build, + ) +} + +/// A tool that changes workspaces, agents, terminals, or messages. +pub(super) fn execute( + name: &'static str, + title: &'static str, + description: &'static str, + input_schema: fn() -> Value, + build: Build, +) -> ToolSpec { + spec( + ToolAccess::Execute, + MUTATION_TIMEOUT, + name, + title, + description, + input_schema, + build, + ) +} + +/// A tool reserved for runtime administration, agent configuration, and +/// internal maintenance. It needs the `mcp:admin` scope and the `admin` level. +pub(super) fn admin( + name: &'static str, + title: &'static str, + description: &'static str, + input_schema: fn() -> Value, + build: Build, +) -> ToolSpec { + spec( + ToolAccess::Admin, + MUTATION_TIMEOUT, + name, + title, + description, + input_schema, + build, + ) +} + +pub(super) fn tools() -> Vec { + let groups: [fn() -> Vec; 21] = [ + runtime::tools, + runtime_manage::tools, + projects::tools, + projects_manage::tools, + workspaces::tools, + prompt_workspace::tools, + workspaces_manage::tools, + terminals::tools, + terminals_manage::tools, + agents::tools, + agents_manage::tools, + inbox::tools, + inbox_manage::tools, + orchestration::tools, + orchestration_manage::tools, + workflows::tools, + automations::tools, + automations_manage::tools, + pull_requests::tools, + events::tools, + agent_skills::tools, + ]; + groups.iter().flat_map(|group| group()).collect() +} + +pub(super) fn no_arguments() -> Value { + object(&[], &[]) +} + +pub(super) fn wait_seconds(arguments: &ToolArguments, default: u64) -> u64 { + arguments + .integer("timeoutSeconds") + .unwrap_or(default) + .clamp(1, MAX_WAIT_SECONDS) +} + +pub(super) fn wait_schema() -> Value { + integer( + "Seconds to wait before returning the current state. Call again to keep waiting.", + 1, + MAX_WAIT_SECONDS, + ) +} + +/// Profiles are addressed by stable id (`prof_...`) or by unique name. +pub(super) fn with_profile(invocation: Invocation, profile: String) -> Invocation { + if profile.starts_with("prof_") { + invocation.option("--profile-id", profile) + } else { + invocation.option("--profile-name", profile) + } +} + +pub(super) fn profile_schema() -> Value { + string("Agent profile id or unique name from list_agent_profiles.") +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/orchestration.rs b/rust/alera-cli/src/mcp_tools/catalog/orchestration.rs new file mode 100644 index 000000000..009c96a73 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/orchestration.rs @@ -0,0 +1,254 @@ +//! Orchestration tasks, coordinator runs, and messages between agents. + +use super::{ + execute, profile_schema, read, wait_schema, wait_seconds, with_profile, LAUNCH_TIMEOUT, + PROMPT_LIMIT, WAIT_TIMEOUT, +}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, string_list, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec, MAX_WAIT_SECONDS}; + +const TASK_STATES: &[&str] = &[ + "pending", + "ready", + "dispatched", + "completed", + "failed", + "blocked", + "stalled", + "cancelled", +]; + +pub(super) fn tools() -> Vec { + let mut tools = vec![ + read( + "list_tasks", + "List Tasks", + "List orchestration tasks, optionally filtered by status, coordinator run, or workspace.", + || { + object( + &[ + ("status", one_of("Task status.", TASK_STATES)), + ("runId", string("Coordinator run id.")), + ("workspaceId", string("Workspace id.")), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["task-list"]) + .option_if("--status", arguments.string("status")) + .option_if("--run", arguments.string("runId")) + .option_if("--workspace", arguments.string("workspaceId"))) + }, + ), + read( + "show_task", + "Show Task", + "Show one orchestration task with its active dispatch.", + || object(&[("taskId", string("Task id."))], &["taskId"]), + |arguments| { + Ok(Invocation::new("orchestration", &["task-show"]) + .option("--id", arguments.required("taskId")?)) + }, + ), + read( + "list_runs", + "List Coordinator Runs", + "List durable orchestration coordinator runs, optionally for one workspace.", + || object(&[("workspaceId", string("Workspace id."))], &[]), + |arguments| { + Ok(Invocation::new("orchestration", &["run-list"]) + .option_if("--workspace", arguments.string("workspaceId"))) + }, + ), + read( + "orchestration_status", + "Orchestration Status", + "Aggregate the run, task, worker, and escalation state of one coordinator run.", + || object(&[("runId", string("Coordinator run id from list_runs."))], &["runId"]), + |arguments| { + Ok(Invocation::new("orchestration", &["status"]) + .option("--id", arguments.required("runId")?)) + }, + ), + read( + "list_messages", + "List Orchestration Messages", + "List recent orchestration messages across all recipients, or the inbox or outbox of one terminal.", + || { + object( + &[ + ("terminal", string("Terminal handle.")), + ("direction", one_of("Direction relative to terminal.", &["inbox", "outbox"])), + ("limit", integer("Maximum messages (default 50).", 1, 200)), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["inbox"]) + .option_if("--terminal", arguments.string("terminal")) + .option_if("--direction", arguments.string("direction")) + .option_if("--limit", arguments.integer("limit").map(|value| value.to_string()))) + }, + ), + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + ..read( + "wait_for_task", + "Wait For Task", + "Wait up to timeoutSeconds for an orchestration task to reach one of the given states, then return it. Returns the current state when the wait ends first.", + || { + object( + &[ + ("taskId", string("Task id.")), + ("states", string_list("States to wait for (default completed, failed, stalled, cancelled).", TASK_STATES)), + ("timeoutSeconds", wait_schema()), + ], + &["taskId"], + ) + }, + |arguments| { + let states = arguments + .list("states") + .unwrap_or_else(|| ["completed", "failed", "stalled", "cancelled"].map(String::from).to_vec()); + Ok(Invocation::new("orchestration", &["task-wait"]) + .option("--task", arguments.required("taskId")?) + .option("--for", states.join(",")) + .option("--timeout-ms", (wait_seconds(arguments, 30) * 1000).to_string())) + }, + ) + }, + ]; + tools.extend(mutations()); + tools +} + +fn mutations() -> Vec { + vec![ + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "delegate_task", + "Delegate Task", + "Create an orchestration task and start an agent profile that accepts it, in an existing workspace or a new child worktree. The coordinator is a running agent terminal (from list_terminals) that receives the worker's messages. Returns the task; follow it with wait_for_task. Without a coordinator terminal, use start_agent_workspace or ask_agent instead.", + || { + object( + &[ + ("profile", profile_schema()), + ("spec", text("Task brief the worker receives.", PROMPT_LIMIT)), + ("coordinator", string("Terminal handle of the coordinating agent, from list_terminals.")), + ("title", string("Short title for listings.")), + ("workspaceId", string("Workspace that owns the task, or the source workspace with newWorkspace.")), + ("newWorkspace", boolean("Create a child worktree and delegate into it.")), + ("projectId", string("Project for the new workspace.")), + ("name", string("Name for the new workspace.")), + ("branch", string("Branch for the new workspace.")), + ("sourceBranch", string("Branch the new workspace starts from.")), + ("timeoutSeconds", integer("Seconds to wait for the agent to accept.", 1, MAX_WAIT_SECONDS)), + ], + &["profile", "spec", "coordinator"], + ) + }, + delegate_task, + ) + }, + execute( + "send_message", + "Send Orchestration Message", + "Send an orchestration message from one agent terminal to a terminal handle or a group such as @all, @idle, or @workspace:. To ask an agent a question from outside, use ask_agent.", + || { + object( + &[ + ("from", string("Sender terminal handle, from list_terminals.")), + ("to", string("Terminal handle or @group.")), + ("subject", string("Message subject.")), + ("body", text("Message body.", PROMPT_LIMIT)), + ("type", one_of("Message type.", &["status", "dispatch", "merge_ready", "escalation", "handoff", "decision_gate"])), + ("priority", one_of("Priority.", &["normal", "high", "urgent"])), + ("threadId", string("Thread to attach the message to.")), + ("taskId", string("Task the message is about, recorded in its payload.")), + ("payload", text("Raw JSON payload text, instead of taskId.", 16_384)), + ], + &["from", "to", "subject"], + ) + }, + |arguments| { + if arguments.string("taskId").is_some() && arguments.string("payload").is_some() { + return Err(ToolInputError("Pass taskId or payload, not both.".into())); + } + let invocation = Invocation::new("orchestration", &["send"]) + .option("--from", arguments.required("from")?) + .option("--to", arguments.required("to")?) + .option("--subject", arguments.required("subject")?) + .option_if("--type", arguments.string("type")) + .option_if("--priority", arguments.string("priority")) + .option_if("--thread-id", arguments.string("threadId")) + .option_if("--task-id", arguments.string("taskId")) + .option_if("--payload", arguments.string("payload")); + Ok(match arguments.string("body") { + Some(body) => invocation.flag("--body-stdin").stdin(body), + None => invocation, + }) + }, + ), + ToolSpec { + destructive: true, + ..execute( + "cancel_task", + "Cancel Task", + "Cancel an orchestration task and its not-yet-started descendants. Runs as an audited administrative cancellation, because an MCP client is not the task's coordinator terminal.", + || { + object( + &[("taskId", string("Task id.")), ("reason", string("Why it is cancelled."))], + &["taskId", "reason"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["task-cancel"]) + .option("--id", arguments.required("taskId")?) + .option("--reason", format!("[mcp] {}", arguments.required("reason")?)) + .flag("--force")) + }, + ) + }, + ] +} + +fn delegate_task(arguments: &ToolArguments) -> Result { + let new_workspace = arguments.flag("newWorkspace"); + let workspace = arguments.string("workspaceId"); + if workspace.is_none() && !(new_workspace && arguments.string("projectId").is_some()) { + return Err(ToolInputError( + "Pass workspaceId, or newWorkspace with projectId.".into(), + )); + } + let workspace_flag = if new_workspace { + "--from-workspace" + } else { + "--workspace" + }; + Ok(with_profile( + Invocation::new("orchestration", &["delegate"]), + arguments.required("profile")?, + ) + .flag("--spec-stdin") + .flag("--keep-on-failure") + .option("--from", arguments.required("coordinator")?) + .option_if("--task-title", arguments.string("title")) + .option_if(workspace_flag, workspace) + .flag_if("--new-workspace", new_workspace) + .option_if("--project-id", arguments.string("projectId")) + .option_if("--name", arguments.string("name")) + .option_if("--branch", arguments.string("branch")) + .option_if("--source-branch", arguments.string("sourceBranch")) + .flag_if( + "--no-parent", + new_workspace && arguments.string("workspaceId").is_none(), + ) + .option( + "--timeout-ms", + (wait_seconds(arguments, MAX_WAIT_SECONDS) * 1000).to_string(), + ) + .stdin(arguments.required("spec")?)) +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/orchestration_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/orchestration_manage.rs new file mode 100644 index 000000000..46fd6c884 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/orchestration_manage.rs @@ -0,0 +1,462 @@ +//! Orchestration tasks, dispatch, coordinators, the run board, gates, and run +//! policies. Approving a run policy or resolving a gate stays a decision for +//! a person in the Alera app, so neither is a tool. +//! +//! An MCP client is not a terminal, so commands that check coordinator +//! ownership run as audited administrative actions (`--force`) with the +//! reason marked `[mcp]`, as `cancel_task` does. + +use serde_json::{json, Value}; + +use super::{admin, execute, read, wait_seconds, LAUNCH_TIMEOUT, PROMPT_LIMIT}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec, MAX_WAIT_SECONDS}; + +const COORDINATOR: &str = "Terminal handle of the coordinating agent, from list_terminals."; +const JSON_LIMIT: u64 = 262_144; + +fn reason(arguments: &ToolArguments) -> Result { + Ok(format!("[mcp] {}", arguments.required("reason")?)) +} + +fn number(arguments: &ToolArguments, name: &str) -> Option { + arguments.integer(name).map(|value| value.to_string()) +} + +fn texts(description: &str) -> Value { + json!({ + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": description, + }) +} + +pub(super) fn tools() -> Vec { + let mut tools = tasks(); + tools.extend(coordinators()); + tools.extend(board()); + tools.extend(administration()); + tools +} + +fn tasks() -> Vec { + vec![ + execute( + "create_task", + "Create Task", + "Create an orchestration task without starting an agent. A manual task names its coordinator terminal; a task of a coordinator run names the run and, with an execution policy, its stage. Follow it with dispatch_task or spawn_agent.", + || { + object( + &[ + ("spec", text("Task brief the worker receives.", PROMPT_LIMIT)), + ("workspaceId", string("Workspace that owns the task.")), + ("coordinator", string(COORDINATOR)), + ("runId", string("Coordinator run for a coordinated task.")), + ("stage", string("Execution policy stage id, for a task of a run with a policy.")), + ("title", string("Short title for listings.")), + ("dependsOn", texts("Task ids this task waits for.")), + ("parentTaskId", string("Parent task id.")), + ("resultSchema", text("JSON Schema text that structured completion results must match.", 16_384)), + ], + &["spec", "workspaceId"], + ) + }, + |arguments| { + if arguments.string("coordinator").is_none() && arguments.string("runId").is_none() { + return Err(ToolInputError("Pass coordinator, runId, or both.".into())); + } + let deps = arguments.list("dependsOn").map(|deps| json!(deps).to_string()); + Ok(Invocation::new("orchestration", &["task-create"]) + .flag("--spec-stdin") + .option("--workspace", arguments.required("workspaceId")?) + .option_if("--coordinator", arguments.string("coordinator")) + .option_if("--run", arguments.string("runId")) + .option_if("--stage", arguments.string("stage")) + .option_if("--task-title", arguments.string("title")) + .option_if("--deps", deps) + .option_if("--parent", arguments.string("parentTaskId")) + .option_if("--result-schema", arguments.string("resultSchema")) + .stdin(arguments.required("spec")?)) + }, + ), + execute( + "dispatch_task", + "Dispatch Task", + "Dispatch a ready task to an existing terminal. With inject, the task preamble is pasted into the running agent; dryRun only builds the preamble.", + || { + object( + &[ + ("taskId", string("Ready task id.")), + ("to", string("Terminal handle of the worker, from list_terminals.")), + ("coordinator", string(COORDINATOR)), + ("inject", boolean("Paste the preamble into the worker's running agent.")), + ("dryRun", boolean("Build the preamble without changing anything.")), + ("returnPreamble", boolean("Include the full preamble text in the result.")), + ("terminalPolicy", one_of("What happens to the worker terminal after success (default keep-open).", &["keep-open", "close-on-success", "return-to-shell"])), + ], + &["taskId", "to", "coordinator"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["dispatch"]) + .option("--task", arguments.required("taskId")?) + .option("--to", arguments.required("to")?) + .option("--from", arguments.required("coordinator")?) + .flag_if("--inject", arguments.flag("inject")) + .flag_if("--dry-run", arguments.flag("dryRun")) + .flag_if("--return-preamble", arguments.flag("returnPreamble")) + .option_if("--terminal-policy", arguments.string("terminalPolicy"))) + }, + ), + read( + "show_dispatch", + "Show Dispatch", + "Show the dispatch state and preamble of a task.", + || object(&[("taskId", string("Task id."))], &["taskId"]), + |arguments| { + Ok(Invocation::new("orchestration", &["dispatch-show"]) + .option("--task", arguments.required("taskId")?)) + }, + ), + execute( + "interrupt_dispatch", + "Interrupt Dispatch", + "Interrupt the current turn of a dispatched worker without closing its terminal. Runs as an audited administrative interrupt.", + || { + object( + &[("dispatchId", string("Dispatch id from show_task or show_dispatch.")), ("reason", string("Why it is interrupted."))], + &["dispatchId", "reason"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["dispatch-interrupt"]) + .option("--id", arguments.required("dispatchId")?) + .option("--reason", reason(arguments)?) + .flag("--force")) + }, + ), + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "spawn_agent", + "Spawn Agent For Task", + "Start an agent for an existing ready task, in a new or given terminal of a workspace, and dispatch the task once the agent is ready. Returns when the agent accepts or the wait ends.", + || { + object( + &[ + ("taskId", string("Ready task id.")), + ("workspaceId", string("Workspace for the worker terminal.")), + ("coordinator", string(COORDINATOR)), + ("profile", string("Agent profile name from list_agent_profiles.")), + ("agent", string("Agent type (claude, codex, ...) when no profile is given.")), + ("terminal", string("Existing terminal handle to reuse.")), + ("title", string("Title of the worker tab.")), + ("timeoutSeconds", integer("Seconds to wait for the agent to accept.", 1, MAX_WAIT_SECONDS)), + ], + &["taskId", "workspaceId", "coordinator"], + ) + }, + |arguments| { + let (profile, agent) = (arguments.string("profile"), arguments.string("agent")); + if profile.is_some() == agent.is_some() { + return Err(ToolInputError("Pass profile or agent.".into())); + } + Ok(Invocation::new("orchestration", &["agent-spawn"]) + .option("--task", arguments.required("taskId")?) + .option("--workspace", arguments.required("workspaceId")?) + .option("--from", arguments.required("coordinator")?) + .option_if("--profile", profile) + .option_if("--agent", agent) + .option_if("--terminal", arguments.string("terminal")) + .option_if("--title", arguments.string("title")) + .flag("--keep-on-failure") + .option("--timeout-ms", (wait_seconds(arguments, MAX_WAIT_SECONDS) * 1000).to_string())) + }, + ) + }, + ] +} + +fn coordinators() -> Vec { + vec![ + execute( + "start_coordinator", + "Start Coordinator Run", + "Start the background coordinator loop for a running coordinator agent: it records the run objective and dispatches the run's ready tasks to worker terminals.", + || { + object( + &[ + ("spec", text("Run objective.", PROMPT_LIMIT)), + ("coordinator", string(COORDINATOR)), + ("workspaceId", string("Workspace that scopes worker terminals.")), + ("agent", string("Agent type for worker terminals the loop creates (default codex).")), + ("maxConcurrent", integer("Maximum concurrent dispatches (default 4).", 1, 64)), + ], + &["spec", "coordinator", "workspaceId"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["run"]) + .flag("--spec-stdin") + .option("--from", arguments.required("coordinator")?) + .option("--workspace", arguments.required("workspaceId")?) + .option_if("--agent", arguments.string("agent")) + .option_if("--max-concurrent", number(arguments, "maxConcurrent")) + .stdin(arguments.required("spec")?)) + }, + ), + ToolSpec { + destructive: true, + ..execute( + "stop_coordinator", + "Stop Coordinator Run", + "Stop a coordinator run's loop, optionally cancelling its active tasks. Runs as an audited administrative stop.", + || { + object( + &[ + ("runId", string("Coordinator run id from list_runs.")), + ("reason", string("Why it is stopped.")), + ("cancelActive", boolean("Also cancel the run's active tasks.")), + ], + &["runId", "reason"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["run-stop"]) + .option("--id", arguments.required("runId")?) + .option("--reason", reason(arguments)?) + .flag_if("--cancel-active", arguments.flag("cancelActive")) + .flag("--force")) + }, + ) + }, + read( + "show_run", + "Show Coordinator Run", + "Show one coordinator run.", + || object(&[("runId", string("Coordinator run id."))], &["runId"]), + |arguments| { + Ok(Invocation::new("orchestration", &["run-show"]) + .option("--id", arguments.required("runId")?)) + }, + ), + execute( + "propose_run_policy", + "Propose Run Policy", + "Propose an execution policy (a stage plan) for a coordinator run. The run holds scheduling until a person approves or rejects the policy in the Alera app.", + || { + object( + &[ + ("runId", string("Coordinator run id.")), + ("policy", text("Execution policy as JSON text.", JSON_LIMIT)), + ], + &["runId", "policy"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["run-policy-propose"]) + .option("--run", arguments.required("runId")?) + .option("--policy-file", "-") + .stdin(arguments.required("policy")?)) + }, + ), + read( + "show_run_policy", + "Show Run Policy", + "Show a coordinator run's execution policy and whether it is approved.", + || object(&[("runId", string("Coordinator run id."))], &["runId"]), + |arguments| { + Ok(Invocation::new("orchestration", &["run-policy-show"]) + .option("--run", arguments.required("runId")?)) + }, + ), + read( + "list_gates", + "List Decision Gates", + "List decision gates, optionally for one task or status.", + || { + object( + &[ + ("taskId", string("Task id.")), + ("status", one_of("Gate status.", &["pending", "resolved", "timeout"])), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["gate-list"]) + .option_if("--task", arguments.string("taskId")) + .option_if("--status", arguments.string("status"))) + }, + ), + execute( + "create_gate", + "Create Decision Gate", + "Ask a person to decide before a task continues. The task stays blocked until someone resolves the gate in the Alera app.", + || { + object( + &[ + ("taskId", string("Task the gate blocks.")), + ("question", text("The question for the person.", 4096)), + ("options", texts("Answers to offer.")), + ], + &["taskId", "question"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["gate-create"]) + .option("--task", arguments.required("taskId")?) + .option("--question", arguments.required("question")?) + .option_if("--options", arguments.list("options").map(|options| json!(options).to_string()))) + }, + ), + ] +} + +fn board() -> Vec { + vec![ + read( + "get_orchestration_board", + "Get Orchestration Board", + "Read one page of the run board, with coordinator runs grouped as attention, active, or history. Pass nextCursor back as cursor for the next page.", + || { + object( + &[ + ("projectId", string("Project id.")), + ("workspaceId", string("Workspace id.")), + ("search", string("Text to search for.")), + ("bucket", one_of("Board column.", &["attention", "active", "history"])), + ("cursor", text("The nextCursor JSON object of a previous page, as text.", 1024)), + ("limit", integer("Maximum runs.", 1, 100)), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["board"]) + .option_if("--project-id", arguments.string("projectId")) + .option_if("--workspace", arguments.string("workspaceId")) + .option_if("--search", arguments.string("search")) + .option_if("--bucket", arguments.string("bucket")) + .option_if("--cursor", arguments.string("cursor")) + .option_if("--limit", number(arguments, "limit"))) + }, + ), + read( + "get_run_snapshot", + "Get Run Snapshot", + "Read a coordinator run with a page of its tasks. Continue with afterTaskId and the returned revision.", + || { + object( + &[ + ("runId", string("Coordinator run id.")), + ("afterTaskId", string("Continue after this task id.")), + ("revision", integer("Board revision of the previous page.", 0, u64::MAX >> 11)), + ("limit", integer("Maximum tasks.", 1, 200)), + ], + &["runId"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["run-snapshot"]) + .option("--run", arguments.required("runId")?) + .option_if("--after-task", arguments.string("afterTaskId")) + .option_if("--revision", number(arguments, "revision")) + .option_if("--limit", number(arguments, "limit"))) + }, + ), + read( + "inspect_task", + "Inspect Task", + "Read one task of a coordinator run with its dispatches and history.", + || { + object( + &[ + ("runId", string("Coordinator run id.")), + ("taskId", string("Task id.")), + ("cursor", text("The history cursor JSON object of a previous page, as text.", 1024)), + ("limit", integer("Maximum history entries.", 1, 200)), + ], + &["runId", "taskId"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["task-inspect"]) + .option("--run", arguments.required("runId")?) + .option("--task", arguments.required("taskId")?) + .option_if("--cursor", arguments.string("cursor")) + .option_if("--limit", number(arguments, "limit"))) + }, + ), + ] +} + +fn administration() -> Vec { + vec![ + admin( + "recover_task", + "Recover Task", + "Move a stalled task to ready, failed, or cancelled through an audited administrative recovery.", + || { + object( + &[ + ("taskId", string("Task id.")), + ("status", one_of("New status.", &["ready", "failed", "cancelled"])), + ("reason", string("Why it is recovered.")), + ], + &["taskId", "status", "reason"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["task-recover"]) + .option("--id", arguments.required("taskId")?) + .option("--status", arguments.required("status")?) + .option("--reason", reason(arguments)?) + .flag("--force")) + }, + ), + admin( + "transfer_coordinator", + "Transfer Coordinator", + "Hand a task or a whole coordinator run to another coordinator terminal through an audited administrative transfer.", + || { + object( + &[ + ("taskId", string("Task to transfer.")), + ("runId", string("Coordinator run to transfer.")), + ("to", string("Terminal handle of the new coordinator.")), + ("reason", string("Why it is transferred.")), + ], + &["to", "reason"], + ) + }, + |arguments| { + let (task, run) = (arguments.string("taskId"), arguments.string("runId")); + if task.is_some() == run.is_some() { + return Err(ToolInputError("Pass taskId or runId.".into())); + } + Ok(Invocation::new("orchestration", &["transfer-coordinator"]) + .option_if("--task", task) + .option_if("--run", run) + .option("--to", arguments.required("to")?) + .option("--reason", reason(arguments)?) + .flag("--force")) + }, + ), + ToolSpec { + destructive: true, + idempotent: true, + ..admin( + "reset_orchestration", + "Reset Orchestration", + "Clear orchestration state: tasks with their dispatches, gates, and coordinator runs, messages, or both (default all).", + || object(&[("scope", one_of("What to clear (default all).", &["all", "tasks", "messages"]))], &[]), + |arguments| { + let scope = arguments.string("scope").unwrap_or_else(|| "all".into()); + Ok(Invocation::new("orchestration", &["reset"]).flag(&format!("--{scope}"))) + }, + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/projects.rs b/rust/alera-cli/src/mcp_tools/catalog/projects.rs new file mode 100644 index 000000000..b2f0bd985 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/projects.rs @@ -0,0 +1,14 @@ +//! Projects registered in the runtime. + +use super::{no_arguments, read}; +use crate::mcp_tools::{Invocation, ToolSpec}; + +pub(super) fn tools() -> Vec { + vec![read( + "list_projects", + "List Projects", + "List the projects registered in this Alera runtime with their ids, names, and the hosts each one is on.", + no_arguments, + |_| Ok(Invocation::new("project", &["list"])), + )] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/projects_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/projects_manage.rs new file mode 100644 index 000000000..1d09091f8 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/projects_manage.rs @@ -0,0 +1,341 @@ +//! Project registration, cloning, hosts, branches, and configuration, and the +//! SSH targets projects can live on. + +use serde_json::Value; + +use super::{execute, no_arguments, read, LAUNCH_TIMEOUT}; +use crate::mcp_tools::schema::{object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +const CONFIG_LIMIT: u64 = 65_536; + +fn project_id() -> Value { + string("Project id from list_projects.") +} + +fn project_schema() -> Value { + object(&[("projectId", project_id())], &["projectId"]) +} + +fn kind() -> Value { + one_of( + "gitRepository (default) or folder for a plain folder without Git.", + &["gitRepository", "folder"], + ) +} + +fn kind_flag(arguments: &ToolArguments) -> Option<&'static str> { + arguments.string("kind").map(|kind| match kind.as_str() { + "folder" => "folder", + _ => "git-repository", + }) +} + +fn host_schema() -> Value { + object( + &[ + ("projectId", project_id()), + ("hostId", string("SSH target id from list_ssh_targets.")), + ], + &["projectId", "hostId"], + ) +} + +fn clone_job_schema() -> Value { + object( + &[("cloneId", string("Clone job id from clone_project."))], + &["cloneId"], + ) +} + +fn project( + action: &[&str], + flag: &str, + arguments: &ToolArguments, +) -> Result { + Ok(Invocation::new("project", action).option(flag, arguments.required("projectId")?)) +} + +fn long(tool: ToolSpec) -> ToolSpec { + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..tool + } +} + +fn idempotent(tool: ToolSpec) -> ToolSpec { + ToolSpec { + idempotent: true, + ..tool + } +} + +pub(super) fn tools() -> Vec { + vec![ + execute( + "register_project", + "Register Project", + "Add an existing folder on this machine as an Alera project. The folder is not changed.", + || { + object( + &[ + ("path", string("Absolute path of the project folder.")), + ("name", string("Display name. Defaults to the folder name.")), + ("kind", kind()), + ], + &["path"], + ) + }, + |arguments| { + Ok(Invocation::new("project", &["add"]) + .option("--repo-path", arguments.required("path")?) + .option_if("--name", arguments.string("name")) + .option_if("--kind", kind_flag(arguments))) + }, + ), + execute( + "clone_project", + "Clone Project", + "Clone a Git repository into a new folder on this machine and register it as a project. Returns a clone job at once; follow it with get_project_clone.", + || { + object( + &[ + ("url", text("Repository URL.", 2_048)), + ("parentPath", string("Existing folder the clone goes into.")), + ("directoryName", string("New folder name. Defaults to the repository name.")), + ("name", string("Project display name.")), + ], + &["url", "parentPath"], + ) + }, + |arguments| { + Ok(Invocation::new("project", &["clone", "start"]) + .option("--url", arguments.required("url")?) + .option("--parent-path", arguments.required("parentPath")?) + .option_if("--directory-name", arguments.string("directoryName")) + .option_if("--name", arguments.string("name"))) + }, + ), + read( + "get_project_clone", + "Get Project Clone", + "Show a clone job: its status, progress, message, and the project it registered when done.", + clone_job_schema, + |arguments| { + Ok(Invocation::new("project", &["clone", "show"]) + .option("--id", arguments.required("cloneId")?)) + }, + ), + read( + "list_project_clones", + "List Project Clones", + "List the clone jobs of this runtime, running and finished.", + no_arguments, + |_| Ok(Invocation::new("project", &["clone", "list"])), + ), + ToolSpec { + destructive: true, + ..idempotent(execute( + "cancel_project_clone", + "Cancel Project Clone", + "Stop a running clone and delete its partial folder.", + clone_job_schema, + |arguments| { + Ok(Invocation::new("project", &["clone", "cancel"]) + .option("--id", arguments.required("cloneId")?)) + }, + )) + }, + long(execute( + "register_remote_project", + "Register Remote Project", + "Add a project that lives only on an SSH host, from an existing folder there (path) or by cloning a repository into the host's projects folder (cloneUrl).", + || { + object( + &[ + ("hostId", string("SSH target id from list_ssh_targets.")), + ("path", string("Existing folder on the host.")), + ("cloneUrl", text("Repository URL to clone on the host.", 2_048)), + ("name", string("Display name.")), + ("kind", kind()), + ], + &["hostId"], + ) + }, + |arguments| { + let (path, clone_url) = (arguments.string("path"), arguments.string("cloneUrl")); + if path.is_some() && clone_url.is_some() { + return Err(ToolInputError("Pass path or cloneUrl, not both.".into())); + } + Ok(Invocation::new("project", &["add-remote"]) + .option("--host-id", arguments.required("hostId")?) + .option_if("--path", path) + .option_if("--clone-url", clone_url) + .option_if("--name", arguments.string("name")) + .option_if("--kind", kind_flag(arguments))) + }, + )), + long(execute( + "register_project_checkout", + "Register Project Checkout", + "Register an existing folder on an SSH host as a project's checkout there, or clone into that new folder first with cloneUrl.", + || { + object( + &[ + ("projectId", project_id()), + ("hostId", string("SSH target id from list_ssh_targets.")), + ("path", string("Folder on the host.")), + ("cloneUrl", text("Repository URL to clone into path first.", 2_048)), + ], + &["projectId", "hostId", "path"], + ) + }, + |arguments| { + Ok(project(&["register-checkout"], "--project-id", arguments)? + .option("--host-id", arguments.required("hostId")?) + .option("--path", arguments.required("path")?) + .option_if("--clone-url", arguments.string("cloneUrl"))) + }, + )), + idempotent(execute( + "rename_project", + "Rename Project", + "Change a project's display name. Its folder is not touched.", + || object(&[("projectId", project_id()), ("name", text("New name.", 200))], &["projectId", "name"]), + |arguments| Ok(project(&["rename"], "--id", arguments)?.option("--name", arguments.required("name")?)), + )), + read( + "preview_project_removal", + "Preview Project Removal", + "Show what remove_project would affect: workspaces, tabs, live sessions, the automations it would pause, and whether settings override the repository's. Changes nothing.", + project_schema, + |arguments| project(&["remove-preview"], "--id", arguments), + ), + ToolSpec { + destructive: true, + ..long(execute( + "remove_project", + "Remove Project", + "Remove a project from Alera as the app does: dependent automations are paused and their runs cancelled, then the project and its workspace records go. No file is deleted: the project folder and its worktrees stay on disk.", + project_schema, + |arguments| { + Ok(project(&["remove"], "--id", arguments)? + .flag("--pause-automations-and-cancel-runs")) + }, + )) + }, + read( + "list_project_hosts", + "List Project Hosts", + "List the hosts a project is on, with its folder on each.", + project_schema, + |arguments| project(&["hosts", "list"], "--project-id", arguments), + ), + long(execute( + "add_project_host", + "Add Project Host", + "Add a project to an SSH host: register an existing folder there (path), or clone the project's Git remote (or cloneUrl) into the host's projects folder.", + || { + object( + &[ + ("projectId", project_id()), + ("hostId", string("SSH target id from list_ssh_targets.")), + ("path", string("Existing folder on the host.")), + ("cloneUrl", text("Repository URL to clone instead of the project's remote.", 2_048)), + ], + &["projectId", "hostId"], + ) + }, + |arguments| { + Ok(project(&["hosts", "add"], "--project-id", arguments)? + .option("--host-id", arguments.required("hostId")?) + .option_if("--path", arguments.string("path")) + .option_if("--clone-url", arguments.string("cloneUrl"))) + }, + )), + ToolSpec { + destructive: true, + ..idempotent(execute( + "remove_project_host", + "Remove Project Host", + "Forget a project's folder on a host. No file is deleted.", + host_schema, + |arguments| { + Ok(project(&["hosts", "remove"], "--project-id", arguments)? + .option("--host-id", arguments.required("hostId")?)) + }, + )) + }, + long(read( + "list_project_branches", + "List Project Branches", + "List the branches of a project's folder on a host, which are the choices for a new worktree's sourceBranch, and the project's preferred source branch when one is set.", + || { + object( + &[ + ("projectId", project_id()), + ("hostId", string("SSH target id, or `local` (default).")), + ], + &["projectId"], + ) + }, + |arguments| { + Ok(project(&["branches"], "--project-id", arguments)? + .option_if("--host-id", arguments.string("hostId"))) + }, + )), + read( + "get_project_config", + "Get Project Settings", + "Show a project's effective settings (New Workspace prompt and source branch, worktree copy rules and setup commands, pull request provider) and whether they come from the app or from the repository's alera.toml.", + project_schema, + |arguments| project(&["config", "show"], "--project-id", arguments), + ), + idempotent(execute( + "update_project_config", + "Update Project Settings", + "Save project settings as the app's settings dialog does, overriding the repository's alera.toml. config is a JSON object with any of worktree {copy: [{from, to, overwrite}], setup: [commands]}, newWorkspace {promptAppend, sourceBranch}, and gitHostingProvider (auto to detect it). Each part given replaces that part; the others stay.", + || { + object( + &[ + ("projectId", project_id()), + ("config", text("Settings as a JSON object.", CONFIG_LIMIT)), + ], + &["projectId", "config"], + ) + }, + |arguments| { + Ok(project(&["config", "set"], "--project-id", arguments)? + .flag("--config-stdin") + .stdin(arguments.required("config")?)) + }, + )), + ToolSpec { + destructive: true, + ..idempotent(execute( + "reset_project_config", + "Reset Project Settings", + "Remove the settings saved in the app so the repository's alera.toml, or the defaults, apply again.", + project_schema, + |arguments| project(&["config", "remove"], "--project-id", arguments), + )) + }, + read( + "list_ssh_targets", + "List SSH Targets", + "List the SSH hosts this runtime knows, with their platform, installed runtime, and bootstrap state. Credentials are never included.", + no_arguments, + |_| Ok(Invocation::new("ssh-target", &["list"])), + ), + long(read( + "ssh_target_status", + "SSH Target Status", + "Check whether SSH hosts are reachable and which runtime they have installed: one host by targetId, or all of them.", + || object(&[("targetId", string("SSH target id from list_ssh_targets."))], &[]), + |arguments| { + Ok(Invocation::new("ssh-target", &["status"]) + .option_if("--id", arguments.string("targetId"))) + }, + )), + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/prompt_workspace.rs b/rust/alera-cli/src/mcp_tools/catalog/prompt_workspace.rs new file mode 100644 index 000000000..8e0ed7586 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/prompt_workspace.rs @@ -0,0 +1,139 @@ +//! New Workspace from Prompt as an asynchronous runtime operation. + +use super::{execute, read, wait_schema, wait_seconds, PROMPT_LIMIT, WAIT_TIMEOUT}; +use crate::mcp_tools::schema::{integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec, MAX_WAIT_SECONDS}; + +/// The start waits this long for the operation before answering, which +/// leaves room for process start-up inside the client's deadline. +const START_WAIT_SECONDS: u64 = 40; +const _: () = assert!(START_WAIT_SECONDS < MAX_WAIT_SECONDS); + +fn operation_id() -> serde_json::Value { + string("Operation id from start_workspace_from_prompt or list_workspace_starts.") +} + +pub(super) fn tools() -> Vec { + vec![ + ToolSpec { + timeout_seconds: START_WAIT_SECONDS + 10, + client_request_flag: Some("--request-id"), + ..execute( + "start_workspace_from_prompt", + "Start Workspace From Prompt", + "Create a workspace from a task prompt and launch an agent in it, exactly like the Alera app's New Workspace from Prompt form. Without projectId, AI Assist recognizes the project from the prompt; when it is unclear the operation ends with status needsInput and a list of candidates, so call again with projectId. AI Assist also names the workspace and its branch and picks the best sidebar section, or none (Others) when nothing fits. Mode auto uses a new worktree for Git projects. Returns the operation after up to 40 seconds; if it is still running, follow it with wait_for_workspace_start.", + || { + object( + &[ + ("prompt", text("Task for the agent; it also names the workspace.", PROMPT_LIMIT)), + ("projectId", string("Project id from list_projects. Omit to recognize it from the prompt.")), + ("profile", string("Agent profile id or unique name. Defaults to the runtime's default profile.")), + ("mode", one_of("Where the workspace lives.", &["auto", "worktree", "projectCheckout"])), + ("sourceBranch", string("Branch a new worktree starts from. Defaults to the project's preferred branch.")), + ("hostId", string("SSH target that owns the worktree. Omit for this machine.")), + ("parentWorkspaceId", string("Workspace to set as the parent of the new one.")), + ("issueUrl", string("Issue URL to link to the workspace.")), + ("section", string("auto (default) lets AI Assist pick a section or none; none skips sections; any other value is a section name.")), + ("sectionId", string("Section to join, by id.")), + ], + &["prompt"], + ) + }, + start, + ) + }, + read( + "get_workspace_start", + "Get Workspace Start", + "Show a New Workspace from Prompt operation: status (running, needsInput, completed, failed, cancelled), phase, workspace, agent tab, setup tab, section, candidates, and error.", + || object(&[("operationId", operation_id())], &["operationId"]), + |arguments| { + Ok(Invocation::new("workspace", &["prompt-start", "show"]) + .option("--id", arguments.required("operationId")?)) + }, + ), + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + ..read( + "wait_for_workspace_start", + "Wait For Workspace Start", + "Wait up to timeoutSeconds for a New Workspace from Prompt operation to stop running, then return it. Call again while the status is still running.", + || { + object( + &[("operationId", operation_id()), ("timeoutSeconds", wait_schema())], + &["operationId"], + ) + }, + |arguments| { + Ok(Invocation::new("workspace", &["prompt-start", "wait"]) + .option("--id", arguments.required("operationId")?) + .option("--timeout-seconds", wait_seconds(arguments, 30).to_string())) + }, + ) + }, + read( + "list_workspace_starts", + "List Workspace Starts", + "List recent New Workspace from Prompt operations, newest first.", + || object(&[("limit", integer("Maximum operations (default 20).", 1, 200))], &[]), + |arguments| { + Ok(Invocation::new("workspace", &["prompt-start", "list"]).option( + "--limit", + arguments.integer("limit").unwrap_or(20).to_string(), + )) + }, + ), + execute( + "cancel_workspace_start", + "Cancel Workspace Start", + "Cancel a running New Workspace from Prompt operation. A workspace it already created is kept.", + || object(&[("operationId", operation_id())], &["operationId"]), + |arguments| { + Ok(Invocation::new("workspace", &["prompt-start", "cancel"]) + .option("--id", arguments.required("operationId")?)) + }, + ), + ToolSpec { + idempotent: true, + ..execute( + "retry_workspace_start_launch", + "Retry Workspace Start Launch", + "Launch the agent again for an operation whose workspace was created but whose agent did not start. It never creates another workspace and reuses the first launch's retry key.", + || object(&[("operationId", operation_id())], &["operationId"]), + |arguments| { + Ok(Invocation::new("workspace", &["prompt-start", "retry-launch"]) + .option("--id", arguments.required("operationId")?)) + }, + ) + }, + ] +} + +fn start(arguments: &ToolArguments) -> Result { + if arguments.string("section").is_some() && arguments.string("sectionId").is_some() { + return Err(ToolInputError( + "Pass section or sectionId, not both.".into(), + )); + } + let mode = match arguments.string("mode").as_deref() { + Some("projectCheckout") => "project-checkout", + Some("worktree") => "worktree", + _ => "auto", + }; + Ok(Invocation::new("workspace", &["prompt-start", "run"]) + .flag("--prompt-stdin") + .option("--mode", mode) + .option_if("--project-id", arguments.string("projectId")) + .option_if("--profile", arguments.string("profile")) + .option_if("--source-branch", arguments.string("sourceBranch")) + .option_if("--host-id", arguments.string("hostId")) + .option_if( + "--parent-workspace-id", + arguments.string("parentWorkspaceId"), + ) + .option_if("--issue", arguments.string("issueUrl")) + .option_if("--section", arguments.string("section")) + .option_if("--section-id", arguments.string("sectionId")) + .option("--wait", START_WAIT_SECONDS.to_string()) + .stdin(arguments.required("prompt")?)) +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/pull_requests.rs b/rust/alera-cli/src/mcp_tools/catalog/pull_requests.rs new file mode 100644 index 000000000..66ddbcd70 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/pull_requests.rs @@ -0,0 +1,271 @@ +//! Pull requests on GitHub, GitLab, and Azure DevOps, over `alera pr` and +//! `alera workspace pr-watch`. The runtime runs `gh`, `glab`, or `az` on the +//! host that owns the checkout; a missing or signed-out CLI answers +//! `provider_unavailable`, and stacks outside GitHub `provider_unsupported`. + +use serde_json::Value; + +use super::{execute, read, LAUNCH_TIMEOUT, PROMPT_LIMIT}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +#[path = "pull_requests_flow.rs"] +mod flow; + +/// Forge CLIs answer in seconds, but a write also reads the pull request back. +const FORGE_TIMEOUT: u64 = LAUNCH_TIMEOUT; +/// AI Assist may take minutes, so the CLI answers `running` after this long +/// and the client resumes with the same retry key; process start-up fits in +/// the rest of the client's deadline. +const DETAILS_WAIT_SECONDS: u64 = 45; +const _: () = assert!(DETAILS_WAIT_SECONDS < crate::mcp_tools::MAX_WAIT_SECONDS); +pub(super) const NUMBER_MAX: u64 = 1 << 31; +pub(super) const MERGE_METHODS: &[&str] = &["mergeCommit", "squash", "rebase", "providerDefault"]; + +pub(super) fn tools() -> Vec { + let mut tools = vec![ + slow(read( + "get_pull_request", + "Get Pull Request", + "Show the pull request of a workspace on GitHub, GitLab, or Azure DevOps: state, draft, mergeability, head SHA, checks (pipelines or policies on GitLab and Azure DevOps), the conversation and review threads with their ids, the merge methods the forge allows, and the base branches. It is the linked pull request, or the open one for the current branch.", + workspace_only, + |arguments| pr(arguments, &["show"]), + )), + slow(read( + "list_pull_request_summaries", + "List Pull Request Summaries", + "List one compact row per active workspace that has a pull request: number, title, state, and the rolled-up check status with failing check names.", + || object(&[("workspaceId", string("Only this workspace."))], &[]), + |arguments| { + Ok(Invocation::new("pr", &["summaries"]) + .option_if("--workspace-id", arguments.string("workspaceId"))) + }, + )), + ToolSpec { + timeout_seconds: DETAILS_WAIT_SECONDS + 10, + client_request_flag: Some("--operation-id"), + ..execute( + "generate_pull_request_details", + "Generate Pull Request Details", + "Write a pull request title and description for the workspace branch against a base branch with AI Assist, the same generator the apps use. It changes nothing on the forge; AI Assist must be enabled. Generation can take minutes: after about 45 seconds the result is status running with an operationId, and the runtime keeps generating. Call again with that operationId as clientRequestId (or the clientRequestId you passed) and the same workspace and base branch to wait again or read the finished result, which is kept for 15 minutes. A completed result has status completed, title, and body; a failure is reported once, and the next call with the same key generates again.", + || object(&[workspace(), base_branch()], &["workspaceId", "baseBranch"]), + |arguments| { + Ok(pr(arguments, &["generate-details"])? + .option("--base", arguments.required("baseBranch")?) + .option("--wait-seconds", DETAILS_WAIT_SECONDS.to_string())) + }, + ) + }, + slow(execute( + "create_pull_request", + "Create Pull Request", + "Open a pull request (a merge request on GitLab) from the workspace's current branch into a base branch, optionally as a draft, and link it to the workspace. Push the branch first. Returns the refreshed pull request.", + || { + object( + &[ + workspace(), + base_branch(), + ("title", text("Pull request title.", 512)), + ("body", text("Pull request description in Markdown.", PROMPT_LIMIT)), + ("draft", boolean("Open it as a draft.")), + ], + &["workspaceId", "baseBranch", "title"], + ) + }, + |arguments| { + let invocation = pr(arguments, &["create"])? + .option("--base", arguments.required("baseBranch")?) + .option("--title", arguments.required("title")?) + .flag_if("--draft", arguments.flag("draft")); + Ok(with_body(invocation, arguments.string("body"))) + }, + )), + slow(execute( + "link_pull_request", + "Link Pull Request", + "Link an existing pull request of the workspace repository by number or URL, so the workspace shows it instead of branch detection.", + || { + object( + &[workspace(), ("reference", string("Pull request number, #number, or URL of this repository."))], + &["workspaceId", "reference"], + ) + }, + |arguments| { + // After `--` a reference is never read as a flag. + Ok(pr(arguments, &["link"])? + .flag("--") + .flag(&arguments.required("reference")?)) + }, + )), + idempotent(slow(execute( + "unlink_pull_request", + "Unlink Pull Request", + "Unlink the workspace's pull request, so branch detection stops showing that one until it is linked again. The pull request itself is not changed.", + || object(&[workspace(), number()], &["workspaceId"]), + |arguments| numbered(arguments, &["unlink"]), + ))), + slow(execute( + "comment_pull_request", + "Comment On Pull Request", + "Post a comment on the pull request, or reply to a comment with replyToCommentId. On GitLab and Azure DevOps a reply joins that comment's discussion or thread; pass its threadId from get_pull_request when known.", + || { + object( + &[ + workspace(), + number(), + ("body", text("Comment in Markdown.", PROMPT_LIMIT)), + ("replyToCommentId", integer("Comment id to reply to.", 1, u64::MAX >> 11)), + ("threadId", string("Thread or discussion id of that comment.")), + ], + &["workspaceId", "body"], + ) + }, + |arguments| { + let invocation = numbered(arguments, &["comment"])? + .option_if("--reply-to", arguments.integer("replyToCommentId").map(|id| id.to_string())) + .option_if("--thread-id", arguments.string("threadId")); + Ok(with_body(invocation, arguments.string("body"))) + }, + )), + slow(execute( + "edit_pull_request_comment", + "Edit Pull Request Comment", + "Replace the text of one of your comments. Use the id, source, and threadId the comment has in get_pull_request.", + || { + object( + &[ + workspace(), + number(), + ("commentId", integer("Comment id.", 1, u64::MAX >> 11)), + ("source", one_of("Where the comment lives.", &["conversation", "reviewSummary", "reviewThread"])), + ("threadId", string("Thread or discussion id (GitLab and Azure DevOps).")), + ("body", text("New comment text in Markdown.", PROMPT_LIMIT)), + ], + &["workspaceId", "commentId", "source", "body"], + ) + }, + |arguments| { + let invocation = numbered(arguments, &["comment-edit"])? + .option("--comment-id", arguments.integer("commentId").unwrap_or(0).to_string()) + .option("--source", arguments.required("source")?) + .option_if("--thread-id", arguments.string("threadId")); + Ok(with_body(invocation, arguments.string("body"))) + }, + )), + idempotent(slow(execute( + "set_pull_request_draft", + "Set Pull Request Draft", + "Mark the pull request as a draft (draft true) or ready for review (draft false).", + || { + object( + &[workspace(), number(), ("draft", boolean("true for draft, false for ready for review."))], + &["workspaceId", "draft"], + ) + }, + |arguments| { + Ok(numbered(arguments, &["draft"])?.flag_if("--ready", !arguments.flag("draft"))) + }, + ))), + destructive(slow(execute( + "close_pull_request", + "Close Pull Request", + "Close the pull request without merging it (abandon on Azure DevOps).", + || object(&[workspace(), number()], &["workspaceId"]), + |arguments| numbered(arguments, &["close"]), + ))), + destructive(slow(execute( + "merge_pull_request", + "Merge Pull Request", + "Merge the pull request. Methods per forge: GitHub mergeCommit, squash, or rebase as the repository allows; GitLab providerDefault (the project's merge setting) or squash; Azure DevOps mergeCommit (no fast-forward) or squash. Without method the forge's preferred allowed method is used. Pass expectedHeadSha to merge only if nobody pushed since you checked.", + || { + object( + &[ + workspace(), + number(), + ("method", one_of("Merge method from mergeMethods in get_pull_request.", MERGE_METHODS)), + ("expectedHeadSha", string("Merge only while the head commit is this SHA.")), + ], + &["workspaceId"], + ) + }, + |arguments| { + Ok(numbered(arguments, &["merge"])? + .option_if("--method", arguments.string("method")) + .option_if("--expected-head", arguments.string("expectedHeadSha"))) + }, + ))), + ]; + tools.extend(flow::tools()); + tools +} + +fn slow(tool: ToolSpec) -> ToolSpec { + ToolSpec { + timeout_seconds: FORGE_TIMEOUT, + ..tool + } +} + +fn idempotent(tool: ToolSpec) -> ToolSpec { + ToolSpec { + idempotent: true, + ..tool + } +} + +fn destructive(tool: ToolSpec) -> ToolSpec { + ToolSpec { + destructive: true, + ..tool + } +} + +pub(super) fn workspace() -> (&'static str, Value) { + ("workspaceId", string("Workspace id.")) +} + +pub(super) fn number() -> (&'static str, Value) { + ( + "number", + integer( + "Pull request number. Defaults to the workspace's linked or detected pull request.", + 1, + NUMBER_MAX, + ), + ) +} + +fn base_branch() -> (&'static str, Value) { + ( + "baseBranch", + string("Base branch the pull request targets, such as main."), + ) +} + +fn workspace_only() -> Value { + object(&[workspace()], &["workspaceId"]) +} + +/// `alera pr --workspace-id=`. +pub(super) fn pr(arguments: &ToolArguments, action: &[&str]) -> Result { + Ok(Invocation::new("pr", action).option("--workspace-id", arguments.required("workspaceId")?)) +} + +/// [pr] plus the optional `--number`. +pub(super) fn numbered( + arguments: &ToolArguments, + action: &[&str], +) -> Result { + Ok(pr(arguments, action)?.option_if( + "--number", + arguments.integer("number").map(|number| number.to_string()), + )) +} + +/// Long text travels on stdin. +fn with_body(invocation: Invocation, body: Option) -> Invocation { + match body { + Some(body) => invocation.flag("--body-stdin").stdin(body), + None => invocation, + } +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/pull_requests_flow.rs b/rust/alera-cli/src/mcp_tools/catalog/pull_requests_flow.rs new file mode 100644 index 000000000..aebf5d1ae --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/pull_requests_flow.rs @@ -0,0 +1,262 @@ +//! Ship Changes, agent dispatch (Restack, Fix Failed Checks), Watch and Fix, +//! and GitHub stacks. + +use serde_json::{json, Value}; + +use super::super::{execute, read, LAUNCH_TIMEOUT}; +use super::{numbered, pr, workspace, MERGE_METHODS, NUMBER_MAX}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +pub(super) fn tools() -> Vec { + vec![ + slow(execute( + "ship_changes", + "Ship Changes", + "Ship the workspace like the Ship Changes button: stage (all changes or only staged ones), commit with an AI Assist message, move work off a shared base branch to a ship/ branch, push, and open a pull request with AI Assist details on GitHub, GitLab, or Azure DevOps. With followUpWatch it then starts Watch and Fix (mode fix) or Watch, Fix and Merge (mode fixAndMerge) on the new pull request with the given agent. Needs AI Assist. If the call times out the ship keeps running; read get_pull_request for the result.", + || { + object( + &[ + workspace(), + ("baseBranch", string("Base branch for the pull request, such as main.")), + ("scope", one_of("all (default) stages every change; staged ships only staged changes.", &["all", "staged"])), + ("draft", boolean("Open the pull request as a draft.")), + ("followUpWatch", json!({ + "type": "object", + "additionalProperties": false, + "description": "Watch the new pull request afterwards.", + "properties": { + "mode": { "type": "string", "enum": ["fix", "fixAndMerge"], "description": "fix sends problems to the agent; fixAndMerge also merges once clear." }, + "handle": { "type": "string", "minLength": 1, "description": "Running agent terminal handle to send fixes to." }, + "profile": { "type": "string", "minLength": 1, "description": "Agent profile id or name for a new tab when no terminal is running." }, + "checks": { "type": "boolean", "description": "Watch failing checks (default true)." }, + "comments": { "type": "boolean", "description": "Watch unresolved review threads (default true)." }, + "conflicts": { "type": "boolean", "description": "Watch merge conflicts (default true)." } + } + })), + ], + &["workspaceId", "baseBranch"], + ) + }, + ship, + )), + slow(execute( + "restack_pull_request", + "Restack Pull Request", + "Ask an agent to rewrite the workspace's committed and uncommitted changes into logical, easy-to-review commits without pushing, like the Restack button. Send it to a running agent (handle) or open a tab from a profile; with preview true only the prompt is returned.", + || dispatch_schema(false), + |arguments| dispatch(arguments, "restack"), + )), + slow(execute( + "fix_pull_request_checks", + "Fix Pull Request Checks", + "Ask an agent to fix the failed checks of the workspace's pull request, like the Fix Failed Checks button. Send it to a running agent (handle) or open a tab from a profile; with preview true only the prompt is returned.", + || dispatch_schema(true), + |arguments| dispatch(arguments, "fix-checks"), + )), + read( + "show_pull_request_watch", + "Show Pull Request Watch", + "Show the active Watch and Fix session of a workspace: the pull request, mode, watched problems, and agent. Returns watch null when nothing is watched.", + || object(&[workspace()], &["workspaceId"]), + |arguments| watch(arguments, "show"), + ), + execute( + "start_pull_request_watch", + "Start Pull Request Watch", + "Start Watch and Fix on the workspace's pull request (GitHub, GitLab, or Azure DevOps). The runtime sends failing checks, merge conflicts, and unresolved review threads to the agent; mode fixAndMerge also merges once checks pass and nothing is open. Replaces the workspace's current watch.", + || { + object( + &[ + workspace(), + ("mode", one_of("fix (default) or fixAndMerge.", &["fix", "fixAndMerge"])), + ("reviewNumber", integer("Pull request number. Defaults to the linked pull request.", 1, NUMBER_MAX)), + ("handle", string("Running agent terminal handle.")), + ("profile", string("Agent profile id or name used when the terminal is gone.")), + ("checks", boolean("Watch failing checks (default true).")), + ("comments", boolean("Watch unresolved review threads (default true).")), + ("conflicts", boolean("Watch merge conflicts (default true).")), + ], + &["workspaceId"], + ) + }, + |arguments| { + let invocation = watch(arguments, "start")? + .flag_if("--merge", arguments.string("mode").as_deref() == Some("fixAndMerge")) + .option_if("--review-number", arguments.integer("reviewNumber").map(|n| n.to_string())) + .option_if("--handle", arguments.string("handle")) + .flag_if("--no-checks", arguments.optional_flag("checks") == Some(false)) + .flag_if("--no-comments", arguments.optional_flag("comments") == Some(false)) + .flag_if("--no-conflicts", arguments.optional_flag("conflicts") == Some(false)); + Ok(with_profile(invocation, arguments.string("profile"))) + }, + ), + ToolSpec { + idempotent: true, + ..execute( + "stop_pull_request_watch", + "Stop Pull Request Watch", + "Stop Watch and Fix for a workspace. A merge the forge already accepted is not undone.", + || object(&[workspace()], &["workspaceId"]), + |arguments| watch(arguments, "stop"), + ) + }, + slow(read( + "get_pull_request_stack", + "Get Pull Request Stack", + "Show the GitHub stack that holds the workspace's pull request, bottom layer first, and whether the gh-stack extension is installed. Stacks exist only on GitHub.", + || object(&[workspace(), super::number()], &["workspaceId"]), + |arguments| numbered(arguments, &["stack", "show"]), + )), + slow(execute( + "create_pull_request_stack", + "Create Pull Request Stack", + "Build a GitHub stack from local workspaces, bottom to top: push each branch, open the pull requests they lack (each targeting the layer below), link them to their workspaces, and stack them with gh stack. The current workspace must be one of the layers of a new stack; with an existing stack the layers are appended. Needs the gh-stack extension.", + || { + object( + &[ + workspace(), + ("baseBranch", string("Base branch of a new stack, such as main.")), + ("workspaceIds", strings("Workspace of each layer, bottom to top.")), + ("titles", strings("Title for each layer that needs a new pull request, in layer order.")), + ("draft", boolean("Open new pull requests as drafts.")), + ], + &["workspaceId", "workspaceIds"], + ) + }, + |arguments| { + let mut invocation = pr(arguments, &["stack", "create"])? + .option_if("--base", arguments.string("baseBranch")) + .flag_if("--draft", arguments.flag("draft")); + for layer in arguments.list("workspaceIds").unwrap_or_default() { + invocation = invocation.option("--layer", layer); + } + for title in arguments.list("titles").unwrap_or_default() { + invocation = invocation.option("--title", title); + } + Ok(invocation) + }, + )), + slow(execute( + "link_pull_request_stack", + "Link Pull Request Stack", + "Stack existing GitHub pull requests, bottom to top, or append them to the stack of the workspace's pull request. A new stack needs at least two and must include the workspace's pull request; every branch must descend from the one below. Needs the gh-stack extension.", + || { + object( + &[workspace(), ("numbers", string("Pull request numbers, bottom to top, separated by commas, such as 12,13."))], + &["workspaceId", "numbers"], + ) + }, + |arguments| { + Ok(pr(arguments, &["stack", "link"])?.option("--numbers", arguments.required("numbers")?)) + }, + )), + ToolSpec { + destructive: true, + ..slow(execute( + "merge_pull_request_stack", + "Merge Pull Request Stack", + "Merge the GitHub stack atomically through the workspace's pull request: every layer at or below it merges. Every affected layer must be open and ready. Without method the first allowed one is used.", + || { + object( + &[ + workspace(), + super::number(), + ("method", one_of("Merge method.", &MERGE_METHODS[..3])), + ], + &["workspaceId"], + ) + }, + |arguments| { + Ok(numbered(arguments, &["stack", "merge"])? + .option_if("--method", arguments.string("method"))) + }, + )) + }, + ] +} + +fn slow(tool: ToolSpec) -> ToolSpec { + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..tool + } +} + +fn strings(description: &str) -> Value { + json!({ + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "description": description, + }) +} + +fn with_profile(invocation: Invocation, profile: Option) -> Invocation { + match profile { + Some(profile) if profile.starts_with("prof_") => invocation.option("--profile-id", profile), + Some(profile) => invocation.option("--profile", profile), + None => invocation, + } +} + +fn watch(arguments: &ToolArguments, action: &str) -> Result { + Ok(Invocation::new("workspace", &["pr-watch", action]) + .option("--workspace-id", arguments.required("workspaceId")?)) +} + +fn dispatch_schema(with_number: bool) -> Value { + let mut properties = vec![workspace()]; + if with_number { + properties.push(super::number()); + } + properties.extend([ + ("handle", string("Running agent terminal handle to send the prompt to.")), + ("tabId", string("Terminal tab to send the prompt to.")), + ("profile", string("Agent profile id or name to open a new tab with when no terminal is given or running.")), + ("preview", boolean("Only return the prompt; send nothing.")), + ]); + object(&properties, &["workspaceId"]) +} + +fn dispatch(arguments: &ToolArguments, action: &str) -> Result { + let invocation = numbered(arguments, &[action])? + .option_if("--handle", arguments.string("handle")) + .option_if("--tab-id", arguments.string("tabId")) + .flag_if("--preview", arguments.flag("preview")); + if !arguments.flag("preview") + && arguments.string("handle").is_none() + && arguments.string("tabId").is_none() + && arguments.string("profile").is_none() + { + return Err(ToolInputError( + "Pass handle, tabId, or profile, or preview true.".into(), + )); + } + Ok(with_profile(invocation, arguments.string("profile"))) +} + +fn ship(arguments: &ToolArguments) -> Result { + let invocation = pr(arguments, &["ship"])? + .option("--base", arguments.required("baseBranch")?) + .option_if("--scope", arguments.string("scope")) + .flag_if("--draft", arguments.flag("draft")); + let Some(follow) = arguments.object("followUpWatch") else { + return Ok(invocation); + }; + let text = |key: &str| follow.get(key).and_then(Value::as_str).map(str::to_owned); + let off = |key: &str| follow.get(key).and_then(Value::as_bool) == Some(false); + let mode = text("mode").unwrap_or_else(|| "fix".into()); + if !matches!(mode.as_str(), "fix" | "fixAndMerge") { + return Err(ToolInputError( + "followUpWatch.mode must be fix or fixAndMerge.".into(), + )); + } + let invocation = invocation + .option("--follow-up-watch", mode) + .option_if("--handle", text("handle")) + .flag_if("--no-checks", off("checks")) + .flag_if("--no-comments", off("comments")) + .flag_if("--no-conflicts", off("conflicts")); + Ok(with_profile(invocation, text("profile"))) +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/runtime.rs b/rust/alera-cli/src/mcp_tools/catalog/runtime.rs new file mode 100644 index 000000000..5540aa647 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/runtime.rs @@ -0,0 +1,14 @@ +//! The runtime itself. + +use super::{no_arguments, read}; +use crate::mcp_tools::{Invocation, ToolSpec}; + +pub(super) fn tools() -> Vec { + vec![read( + "runtime_status", + "Runtime Status", + "Show whether the Alera runtime host is running, its database, and its active sessions.", + no_arguments, + |_| Ok(Invocation::new("runtime", &["status"])), + )] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/runtime_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/runtime_manage.rs new file mode 100644 index 000000000..bfdbf1435 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/runtime_manage.rs @@ -0,0 +1,431 @@ +//! Runtime settings, integrations, quotas, resources, and voice. + +use serde_json::Value; + +use super::{admin, execute, no_arguments, read, WAIT_TIMEOUT}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, string_list, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; +use crate::runtime_settings_commands::selectable_ai_assist_agents; + +/// Each `update_runtime_settings` property and the `runtime settings set` +/// key it writes. The CLI holds the allowlist and checks every value. +const SETTING_PROPERTIES: &[(&str, &str)] = &[ + ("workspaceDirectory", "workspaceDirectory"), + ("confirmProjectRemoval", "confirmProjectRemoval"), + ("confirmWorkspaceRemoval", "confirmWorkspaceRemoval"), + ("defaultAgentProfileId", "defaultAgentProfileId"), + ("aiAssistEnabled", "aiAssist.enabled"), + ( + "aiAssistAutoGenerateAgentTitles", + "aiAssist.autoGenerateAgentTitles", + ), + ("aiAssistAgent", "aiAssist.agent"), + ("aiAssistTimeoutSeconds", "aiAssist.timeoutSeconds"), + ("automationStartAtLogin", "automation.startAtLogin"), + ("automationRunRetentionDays", "automation.runRetentionDays"), + ( + "automationAuditRetentionDays", + "automation.auditRetentionDays", + ), + ( + "automationTrashRetentionDays", + "automation.trashRetentionDays", + ), +]; + +/// Agent integration keys `runtime agents enable` accepts. +const INTEGRATION_AGENTS: &[&str] = &[ + "codex", + "claude", + "copilot", + "cursor", + "agy", + "opencode", + "opencode2", + "pi", + "amp", + "grok", + "fx", +]; + +/// `runtime agents enable` prints every runtime setting when a host is +/// running. Only the integration switches belong in this tool's result. +const SETTINGS_OUTSIDE_INTEGRATIONS: &[&str] = &[ + "workspaceDirectory", + "confirmProjectRemoval", + "confirmWorkspaceRemoval", + "defaultAgentProfileId", + "agentQuotas", + "mobilePushNotifications", + "aiTextGeneration", + "textActions", + "automation", + "voice", +]; + +fn settings_schema() -> Value { + let agents = selectable_ai_assist_agents(); + let retention = || integer("Days to keep, from 1 to 3650.", 1, 3650); + object( + &[ + ( + "workspaceDirectory", + string("Folder where new worktrees are created."), + ), + ( + "confirmProjectRemoval", + boolean("Ask before removing a project in the app."), + ), + ( + "confirmWorkspaceRemoval", + boolean("Ask before removing a workspace in the app."), + ), + ( + "defaultAgentProfileId", + string("Profile id used when a prompt names none."), + ), + ("aiAssistEnabled", boolean("Turn AI Assist on or off.")), + ( + "aiAssistAutoGenerateAgentTitles", + boolean("Name agent tabs from their conversations."), + ), + ("aiAssistAgent", one_of("Agent AI Assist runs.", &agents)), + ( + "aiAssistTimeoutSeconds", + integer("AI Assist time limit in seconds.", 10, 600), + ), + ( + "automationStartAtLogin", + boolean("Start the automation host when the user signs in."), + ), + ("automationRunRetentionDays", retention()), + ("automationAuditRetentionDays", retention()), + ("automationTrashRetentionDays", retention()), + ( + "clear", + string_list( + "Settings to clear.", + &["workspaceDirectory", "defaultAgentProfileId"], + ), + ), + ], + &[], + ) +} + +fn setting_value(arguments: &ToolArguments, property: &str) -> Option { + arguments + .string(property) + .or_else(|| { + arguments + .optional_flag(property) + .map(|value| value.to_string()) + }) + .or_else(|| arguments.integer(property).map(|value| value.to_string())) +} + +fn settings_invocation(arguments: &ToolArguments) -> Result { + let mut args = vec!["settings".to_owned(), "set".to_owned()]; + for (property, key) in SETTING_PROPERTIES { + if let Some(value) = setting_value(arguments, property) { + args.push(format!("{key}={value}")); + } + } + for key in arguments.list("clear").unwrap_or_default() { + args.push(format!("--unset={key}")); + } + if args.len() == 2 { + return Err(ToolInputError( + "Send at least one setting to change.".into(), + )); + } + Ok(Invocation { + group: "runtime", + args, + stdin: None, + }) +} + +pub(super) fn tools() -> Vec { + vec![ + read( + "get_version", + "Get Version", + "Show the Alera CLI and runtime host versions and the protocol and skill contract versions.", + no_arguments, + |_| Ok(Invocation::new("version", &[])), + ), + read( + "get_runtime_settings", + "Get Runtime Settings", + "Show the runtime settings open to MCP clients: default agent profile, worktree folder, removal confirmations, AI Assist, and automation retention.", + no_arguments, + |_| Ok(Invocation::new("runtime", &["settings", "show"])), + ), + ToolSpec { + idempotent: true, + ..admin( + "update_runtime_settings", + "Update Runtime Settings", + "Change runtime settings. Fields left out keep their values; list a setting in clear to remove it.", + settings_schema, + settings_invocation, + ) + }, + read( + "get_agent_integrations", + "Get Agent Integrations", + "Show which agents report their status to Alera through hooks.", + no_arguments, + |_| Ok(Invocation::new("runtime", &["agents", "status"])), + ), + ToolSpec { + idempotent: true, + omit_fields: SETTINGS_OUTSIDE_INTEGRATIONS, + ..admin( + "set_agent_integrations", + "Set Agent Integrations", + "Turn status hooks on or off for the listed agents.", + || { + object( + &[ + ("enabled", boolean("Turn the hooks on (true) or off (false).")), + ("agents", string_list("Agents to change.", INTEGRATION_AGENTS)), + ], + &["enabled", "agents"], + ) + }, + |arguments| { + let action = if arguments.flag("enabled") { "enable" } else { "disable" }; + let mut invocation = Invocation::new("runtime", &["agents", action]); + invocation.args.extend(arguments.list("agents").unwrap_or_default()); + Ok(invocation) + }, + ) + }, + read( + "get_resource_snapshot", + "Get Resource Snapshot", + "Show CPU and memory use of the host, the runtime, and each terminal session.", + no_arguments, + |_| Ok(Invocation::new("runtime", &["resources"])), + ), + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + ..read( + "get_agent_quotas", + "Get Agent Quotas", + "Show usage limits for the enabled agent providers. Set refresh to fetch new numbers instead of the cached ones.", + || object(&[("refresh", boolean("Fetch fresh numbers."))], &[]), + |arguments| { + Ok(Invocation::new("agent-quota", &["show"]) + .flag_if("--refresh", arguments.flag("refresh"))) + }, + ) + }, + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + idempotent: true, + ..execute( + "refresh_claude_quota", + "Refresh Claude Quota", + "Read one Claude account's usage again through the Claude terminal UI.", + || object(&[("accountId", string("Claude account from get_agent_quotas. Defaults to default."))], &[]), + |arguments| { + Ok(Invocation::new("agent-quota", &["refresh-claude"]) + .option_if("--account-id", arguments.string("accountId"))) + }, + ) + }, + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + destructive: true, + ..admin( + "consume_codex_reset_credit", + "Consume Codex Reset Credit", + "Spend the Codex rate-limit reset credit offered in get_agent_quotas. A changed offer is refused.", + || object(&[("offerRevision", string("Offer revision from the Codex quota snapshot."))], &["offerRevision"]), + |arguments| { + Ok(Invocation::new("agent-quota", &["consume-codex-reset"]) + .option("--offer-revision", arguments.required("offerRevision")?)) + }, + ) + }, + execute( + "voice_speak", + "Speak To The User", + "Say a short message to the user through the Alera voice home agent.", + || object(&[("text", text("What to say.", 2_000))], &["text"]), + |arguments| Ok(Invocation::new("voice", &["speak"]).option("--text", arguments.required("text")?)), + ), + read( + "voice_status", + "Voice Status", + "Show the voice home workspace, its agent session, and the speech pipeline.", + no_arguments, + |_| Ok(Invocation::new("voice", &["status"])), + ), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::SETTING_PROPERTIES; + use crate::mcp_tools::find_tool; + use crate::runtime_settings_commands::setting_keys; + + fn args(tool: &str, arguments: serde_json::Value) -> Vec { + find_tool(tool) + .unwrap() + .invocation(&arguments) + .unwrap() + .args + } + + #[test] + fn every_settings_property_writes_an_allowlisted_key() { + let keys = setting_keys().collect::>(); + assert_eq!(SETTING_PROPERTIES.len(), keys.len()); + for (_, key) in SETTING_PROPERTIES { + assert!(keys.contains(key), "{key} is not allowlisted"); + } + } + + #[test] + fn settings_become_key_value_pairs() { + assert_eq!( + args( + "update_runtime_settings", + json!({ + "aiAssistEnabled": false, + "automationRunRetentionDays": 10, + "aiAssistAgent": "claude", + "clear": ["workspaceDirectory"], + }), + ), + [ + "settings", + "set", + "aiAssist.enabled=false", + "aiAssist.agent=claude", + "automation.runRetentionDays=10", + "--unset=workspaceDirectory", + ] + ); + assert!(find_tool("update_runtime_settings") + .unwrap() + .invocation(&json!({})) + .is_err()); + assert!(find_tool("update_runtime_settings") + .unwrap() + .invocation(&json!({ "aiAssistCustomCommand": "rm -rf /" })) + .is_err()); + } + + #[test] + fn the_default_profile_is_a_runtime_setting() { + assert_eq!( + args( + "set_default_agent_profile", + json!({ "profileId": "prof_1" }) + ), + ["settings", "set", "defaultAgentProfileId=prof_1"] + ); + } + + #[test] + fn integrations_name_their_agents() { + assert_eq!( + args( + "set_agent_integrations", + json!({ "enabled": false, "agents": ["codex", "claude"] }), + ), + ["agents", "disable", "codex", "claude"] + ); + } + + #[test] + fn a_launch_sends_a_prompt_or_resumes_a_session() { + let launch = find_tool("launch_agent").unwrap(); + let resumed = launch + .invocation(&json!({ + "workspaceId": "ws", + "profile": "prof_1", + "resumeSessionId": "sess-1", + })) + .unwrap(); + assert!(resumed + .args + .contains(&"--resume-session-id=sess-1".to_owned())); + assert_eq!(resumed.stdin, None); + let prompted = launch + .invocation(&json!({ "workspaceId": "ws", "profile": "prof_1", "prompt": "Fix it" })) + .unwrap(); + assert!(prompted.args.contains(&"--prompt-stdin".to_owned())); + assert_eq!(prompted.stdin.as_deref(), Some("Fix it")); + assert!(launch + .invocation(&json!({ + "workspaceId": "ws", + "profile": "prof_1", + "prompt": "Fix it", + "resumeSessionId": "sess-1", + })) + .is_err()); + } + + #[test] + fn profile_changes_send_managed_config_on_stdin() { + let invocation = find_tool("update_agent_profile") + .unwrap() + .invocation(&json!({ + "profile": "prof_1", + "expectedRevision": 3, + "managedConfig": { "model": "opus" }, + "clearDescription": true, + "showInNewTabMenu": false, + })) + .unwrap(); + assert_eq!(invocation.stdin.as_deref(), Some(r#"{"model":"opus"}"#)); + for expected in [ + "--profile-id=prof_1", + "--expected-revision=3", + "--managed-config-stdin", + "--clear-description", + "--show-in-new-tab-menu=false", + ] { + assert!( + invocation.args.contains(&expected.to_owned()), + "{expected} missing from {:?}", + invocation.args + ); + } + assert!(find_tool("update_agent_profile") + .unwrap() + .invocation(&json!({ + "profile": "prof_1", + "description": "x", + "clearDescription": true, + })) + .is_err()); + } + + #[test] + fn pulse_keeps_left_out_fields_unset() { + assert_eq!( + args( + "configure_terminal_pulse", + json!({ "handle": "t1", "enter": false }) + ), + ["pulse", "set", "--handle=t1", "--enter=false"] + ); + assert_eq!( + args( + "configure_terminal_pulse", + json!({ "handle": "t1", "watch": "disarm" }) + ), + ["pulse", "set", "--handle=t1", "--disarm"] + ); + } +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/terminals.rs b/rust/alera-cli/src/mcp_tools/catalog/terminals.rs new file mode 100644 index 000000000..7b8746bf3 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/terminals.rs @@ -0,0 +1,115 @@ +//! Workspace tabs and live terminal sessions. + +use super::{execute, read, wait_schema, wait_seconds, PROMPT_LIMIT, WAIT_TIMEOUT}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolSpec}; + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_tabs", + "List Tabs", + "List the tabs of a workspace, including terminal and agent tabs.", + || object(&[("workspaceId", string("Workspace id."))], &["workspaceId"]), + |arguments| { + Ok(Invocation::new("tab", &["list"]) + .option("--workspace-id", arguments.required("workspaceId")?)) + }, + ), + read( + "list_terminals", + "List Terminals", + "List live terminal sessions with their handles, optionally for one workspace.", + || object(&[("workspaceId", string("Workspace id."))], &[]), + |arguments| { + Ok(Invocation::new("terminal", &["list"]) + .option_if("--workspace", arguments.string("workspaceId"))) + }, + ), + read( + "show_terminal", + "Show Terminal", + "Show one terminal session by handle, including its agent and lifecycle state.", + || object(&[("handle", string("Terminal handle from list_terminals."))], &["handle"]), + |arguments| { + Ok(Invocation::new("terminal", &["show"]) + .option("--handle", arguments.required("handle")?)) + }, + ), + ToolSpec { + omit_fields: &["dataBase64"], + ..read( + "read_terminal", + "Read Terminal", + "Read retained terminal output. Pass the returned cursor on the next call to read only new output.", + || { + object( + &[ + ("handle", string("Terminal handle from list_terminals.")), + ("cursor", integer("Cursor returned by a previous read.", 0, u64::MAX >> 11)), + ("maxBytes", integer("Maximum bytes to return (default 32768).", 1, 262_144)), + ], + &["handle"], + ) + }, + |arguments| { + Ok(Invocation::new("terminal", &["read"]) + .option("--handle", arguments.required("handle")?) + .option_if("--cursor", arguments.integer("cursor").map(|value| value.to_string())) + .option( + "--max-bytes", + arguments.integer("maxBytes").unwrap_or(32_768).to_string(), + )) + }, + ) + }, + ToolSpec { + timeout_seconds: WAIT_TIMEOUT, + ..read( + "wait_for_terminal", + "Wait For Terminal", + "Wait up to timeoutSeconds for a terminal to reach a lifecycle state, such as an agent becoming ready.", + || { + object( + &[ + ("handle", string("Terminal handle.")), + ("state", one_of("Lifecycle state.", &["process-started", "agent-detected", "agent-ready", "dispatch-accepted"])), + ("timeoutSeconds", wait_schema()), + ], + &["handle", "state"], + ) + }, + |arguments| { + Ok(Invocation::new("terminal", &["wait"]) + .option("--terminal", arguments.required("handle")?) + .option("--for", arguments.required("state")?) + .option("--timeout-ms", (wait_seconds(arguments, 30) * 1000).to_string())) + }, + ) + }, + execute( + "write_terminal", + "Write To Terminal", + "Type text into a terminal. Set submit to send it to an interactive agent as a prompt, or enter to press Enter after it.", + || { + object( + &[ + ("handle", string("Terminal handle from list_terminals.")), + ("text", text("Text to type.", PROMPT_LIMIT)), + ("submit", boolean("Submit to an interactive agent with bracketed paste and Enter.")), + ("enter", boolean("Press Enter after the text.")), + ], + &["handle", "text"], + ) + }, + |arguments| { + Ok(Invocation::new("terminal", &["write"]) + .option("--handle", arguments.required("handle")?) + .flag("--stdin") + .flag_if("--submit", arguments.flag("submit")) + .flag_if("--enter", arguments.flag("enter") && !arguments.flag("submit")) + .stdin(arguments.required("text")?)) + }, + ), + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/terminals_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/terminals_manage.rs new file mode 100644 index 000000000..a0614edcf --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/terminals_manage.rs @@ -0,0 +1,219 @@ +//! Tab and terminal lifecycle beyond reading and writing. + +use super::{admin, execute, read, LAUNCH_TIMEOUT}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolInputError, ToolSpec}; + +/// Terminal Pulse input limit, as the runtime host enforces it. +const PULSE_INPUT_LIMIT: u64 = 4_096; +const TITLE_LIMIT: u64 = 200; + +fn handle_schema() -> serde_json::Value { + object( + &[("handle", string("Terminal handle from list_terminals."))], + &["handle"], + ) +} + +fn tab_schema() -> serde_json::Value { + object(&[("tabId", string("Tab id from list_tabs."))], &["tabId"]) +} + +pub(super) fn tools() -> Vec { + vec![ + execute( + "create_tab", + "Create Tab", + "Open a terminal tab in a workspace and start it, optionally running a command such as an agent CLI.", + || { + object( + &[ + ("workspaceId", string("Workspace id.")), + ("title", text("Tab title.", TITLE_LIMIT)), + ("command", text("Command typed after the shell starts.", 8_192)), + ], + &["workspaceId", "title"], + ) + }, + |arguments| { + Ok(Invocation::new("tab", &["create"]) + .option("--workspace-id", arguments.required("workspaceId")?) + .option("--title", arguments.required("title")?) + .option_if("--command", arguments.string("command")) + .flag("--spawn")) + }, + ), + ToolSpec { + destructive: true, + idempotent: true, + ..execute( + "close_tab", + "Close Tab", + "Close a tab and end its terminal session, as closing it in the app does.", + tab_schema, + |arguments| { + Ok(Invocation::new("tab", &["remove"]) + .option("--id", arguments.required("tabId")?) + .flag("--terminate")) + }, + ) + }, + ToolSpec { + idempotent: true, + ..execute( + "rename_tab", + "Rename Tab", + "Rename a tab.", + || { + object( + &[ + ("tabId", string("Tab id from list_tabs.")), + ("title", text("New tab title.", TITLE_LIMIT)), + ], + &["tabId", "title"], + ) + }, + |arguments| { + Ok(Invocation::new("tab", &["rename"]) + .option("--id", arguments.required("tabId")?) + .option("--title", arguments.required("title")?)) + }, + ) + }, + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "generate_tab_title", + "Generate Tab Title", + "Name an agent tab from its conversation with AI Assist, as the app does.", + tab_schema, + |arguments| { + Ok(Invocation::new("tab", &["generate-title"]) + .option("--id", arguments.required("tabId")?)) + }, + ) + }, + ToolSpec { + idempotent: true, + ..execute( + "link_agent_to_tab", + "Link Agent To Tab", + "Bind a running agent conversation to a tab again so the agent's status updates that tab.", + || { + object( + &[ + ("tabId", string("Tab id from list_tabs.")), + ("agent", string("Agent type, such as claude or codex.")), + ("sessionId", string("The agent's conversation id.")), + ("state", one_of("Status shown until the agent reports again.", &["working", "waiting", "blocked", "done"])), + ], + &["tabId", "agent", "sessionId"], + ) + }, + |arguments| { + Ok(Invocation::new("tab", &["link-agent"]) + .option("--tab", arguments.required("tabId")?) + .option("--agent", arguments.required("agent")?) + .option("--session-id", arguments.required("sessionId")?) + .option_if("--state", arguments.string("state"))) + }, + ) + }, + ToolSpec { + destructive: true, + ..execute( + "restart_terminal", + "Restart Terminal", + "Replace a terminal's process, keeping its tab and scrollback, and start its command or agent again.", + handle_schema, + |arguments| { + Ok(Invocation::new("terminal", &["restart"]) + .option("--handle", arguments.required("handle")?)) + }, + ) + }, + ToolSpec { + destructive: true, + idempotent: true, + ..execute( + "terminate_terminal", + "Terminate Terminal", + "End a terminal session and close its tab, as the Resource Manager does.", + handle_schema, + |arguments| { + Ok(Invocation::new("terminal", &["terminate"]) + .option("--handle", arguments.required("handle")?)) + }, + ) + }, + ToolSpec { + destructive: true, + ..admin( + "prune_terminals", + "Prune Terminals", + "List stopped terminal tabs, or remove them with apply. Limit it to one workspace with workspaceId.", + || { + object( + &[ + ("workspaceId", string("Workspace id. Omit for every workspace.")), + ("apply", boolean("Remove the stopped terminals instead of listing them.")), + ], + &[], + ) + }, + |arguments| { + Ok(Invocation::new("terminal", &["prune"]) + .option_if("--workspace", arguments.string("workspaceId")) + .flag_if("--apply", arguments.flag("apply"))) + }, + ) + }, + read( + "get_terminal_pulse", + "Get Terminal Pulse", + "Show a terminal's Pulse: the input typed into it after workspace files change, its delay, and whether it is armed.", + handle_schema, + |arguments| { + Ok(Invocation::new("terminal", &["pulse", "show"]) + .option("--handle", arguments.required("handle")?)) + }, + ), + ToolSpec { + idempotent: true, + ..execute( + "configure_terminal_pulse", + "Configure Terminal Pulse", + "Change a terminal's Pulse and arm or disarm it. Fields left out keep their saved values.", + || { + object( + &[ + ("handle", string("Terminal handle from list_terminals.")), + ("input", text("Text typed into the terminal after files change.", PULSE_INPUT_LIMIT)), + ("enter", boolean("Press Enter after the input.")), + ("delayMs", integer("Quiet time after the last change before typing, in milliseconds.", 100, 3_600_000)), + ("watch", one_of("Arm or disarm the Pulse. Omit to keep its state.", &["arm", "disarm"])), + ], + &["handle"], + ) + }, + |arguments| { + let watch = match arguments.string("watch").as_deref() { + Some("arm") => Some("--arm"), + Some("disarm") => Some("--disarm"), + Some(_) => return Err(ToolInputError("Unsupported watch value.".into())), + None => None, + }; + let invocation = Invocation::new("terminal", &["pulse", "set"]) + .option("--handle", arguments.required("handle")?) + .option_if("--input", arguments.string("input")) + .option_if("--enter", arguments.optional_flag("enter").map(|value| value.to_string())) + .option_if("--delay-ms", arguments.integer("delayMs").map(|value| value.to_string())); + Ok(match watch { + Some(flag) => invocation.flag(flag), + None => invocation, + }) + }, + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/workflows.rs b/rust/alera-cli/src/mcp_tools/catalog/workflows.rs new file mode 100644 index 000000000..228205e9a --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/workflows.rs @@ -0,0 +1,401 @@ +//! Workflow recipes, proposals, plans, execution, and cleanup without human +//! decisions: approving or rejecting a plan, reviews, and signed decisions +//! stay in the Alera app, and no tool here reaches them. + +mod execution; +#[cfg(test)] +mod tests; + +use alera_core::runtime::{WORKFLOW_DOCUMENT_MAX_BYTES, WORKFLOW_PLAN_MAX_BYTES}; +use serde_json::{json, Value}; + +use super::{execute, read, LAUNCH_TIMEOUT}; +use crate::mcp_tools::schema::{boolean, integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec, CLIENT_REQUEST_ID}; + +const RECIPE_LIMIT: u64 = WORKFLOW_DOCUMENT_MAX_BYTES as u64; +const PLAN_LIMIT: u64 = WORKFLOW_PLAN_MAX_BYTES as u64; +const OBJECTIVE_LIMIT: u64 = 16_384; +const REQUEST_FLAG: &str = "--request-id"; + +pub(super) fn tools() -> Vec { + let mut tools = recipes(); + tools.extend(proposals()); + tools.extend(execution::tools()); + tools +} + +fn recipes() -> Vec { + vec![ + read( + "list_recipes", + "List Workflow Recipes", + "List built-in and personal workflow recipes, plus a workspace's project recipes when workspaceId is given.", + || object(&[("workspaceId", string("Workspace whose project recipes to include."))], &[]), + |arguments| { + Ok(Invocation::new("orchestration", &["recipes", "list"]) + .option_if("--workspace", arguments.string("workspaceId"))) + }, + ), + read( + "show_recipe", + "Show Workflow Recipe", + "Show one workflow recipe with its digest, roles, and stages. Name it as list_recipes reports its source.", + || object(&recipe_source_properties(""), &["origin", "id"]), + |arguments| { + Ok(Invocation::new("orchestration", &["recipes", "show"]) + .option("--source", recipe_source(arguments, "", None)?.to_string())) + }, + ), + read( + "validate_recipe", + "Validate Workflow Recipe", + "Check a portable workflow recipe (YAML) without saving or running it. Returns its digest and stage order.", + || object(&[("document", text("Recipe YAML.", RECIPE_LIMIT))], &["document"]), + |arguments| { + Ok(Invocation::new("orchestration", &["recipes", "validate", "--stdin"]) + .stdin(arguments.required("document")?)) + }, + ), + execute( + "save_personal_recipe", + "Save Personal Recipe", + "Create or update a personal workflow recipe from YAML. Updating one needs its current catalog revision.", + || { + object( + &[ + ("document", text("Recipe YAML.", RECIPE_LIMIT)), + ("expectedRevision", integer("Catalog revision of the recipe being replaced.", 1, u64::MAX >> 11)), + ], + &["document"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["recipes", "save-personal", "--stdin"]) + .option_if("--expected-revision", arguments.integer("expectedRevision").map(|value| value.to_string())) + .stdin(arguments.required("document")?)) + }, + ), + read( + "preview_recipe_export", + "Preview Recipe Export", + "Preview writing a recipe into a workspace's project catalog (.alera/workflows). Returns the file before and after and the digest export_recipe needs.", + export_schema, + |arguments| export(arguments, false), + ), + execute( + "export_recipe", + "Export Recipe", + "Write a recipe into a workspace's project catalog after preview_recipe_export. Fails if the file or document changed since the preview.", + || { + let mut schema = export_schema(); + schema["properties"]["expectedDigest"] = string("Digest from preview_recipe_export."); + schema["required"] = json!(["workspaceId", "filename", "document", "expectedDigest"]); + schema + }, + |arguments| export(arguments, true), + ), + ] +} + +fn proposals() -> Vec { + vec![ + read( + "list_workflow_proposals", + "List Workflow Proposals", + "List workflow proposals, newest first, with their coordinator and cancellation state.", + || { + object( + &[ + ("beforeCreatedAt", string("createdAt of the last entry of the previous page.")), + ("beforeId", string("id of the last entry of the previous page.")), + ], + &[], + ) + }, + |arguments| { + let (created, id) = (arguments.string("beforeCreatedAt"), arguments.string("beforeId")); + if created.is_some() != id.is_some() { + return Err(ToolInputError("Pass beforeCreatedAt and beforeId together.".into())); + } + Ok(Invocation::new("orchestration", &["proposals", "list"]) + .option_if("--before-created-at", created) + .option_if("--before-id", id)) + }, + ), + read( + "get_workflow_proposal", + "Get Workflow Proposal", + "Show a workflow proposal's lifecycle state, or with frozenSelection its frozen recipe, profiles, and source.", + || { + object( + &[ + ("proposalId", string("Proposal id.")), + ("frozenSelection", boolean("Return the frozen selection instead of the lifecycle state.")), + ], + &["proposalId"], + ) + }, + |arguments| { + let id = arguments.required("proposalId")?; + Ok(if arguments.flag("frozenSelection") { + Invocation::new("orchestration", &["plans", "proposal"]).option("--id", id) + } else { + Invocation::new("orchestration", &["proposals", "status"]).option("--id", id) + }) + }, + ), + ToolSpec { + client_request_flag: Some(REQUEST_FLAG), + ..execute( + "create_workflow_proposal", + "Create Workflow Proposal", + "Propose a workflow run from a recipe at the source workspace's current commit. Start its coordinator with start_workflow_coordinator; a person approves the plan in the Alera app.", + || { + let mut properties = vec![ + ("workspaceId", string("Active local Git workspace the workflow starts from.")), + ("objective", text("What the workflow should achieve.", OBJECTIVE_LIMIT)), + ("recipeDigest", string("Recipe digest from show_recipe.")), + ("coordinatorProfileId", string("Agent profile id for the coordinator, from list_agent_profiles.")), + ("roleProfiles", string_items("Agent profile per recipe role, each as role=profileId.")), + ("maxConcurrent", integer("Workers that may run at once (default 4).", 1, 16)), + ("runId", string("Revise this existing run instead of starting a new one.")), + ("expectedRevision", integer("Current revision of runId.", 1, u64::MAX >> 11)), + ]; + properties.extend(recipe_source_properties("recipe")); + object( + &properties, + &["workspaceId", "objective", "recipeOrigin", "recipeId", "recipeDigest", "coordinatorProfileId"], + ) + }, + create_proposal, + ) + }, + execute( + "submit_workflow_proposal", + "Submit Workflow Proposal Tasks", + "Submit the concrete task list for a proposal, as its coordinator would. Does not approve the plan or start workers.", + || { + object( + &[ + ("proposalId", string("Proposal id.")), + ("tasks", text("JSON array of plan tasks (id, title, spec, stageId, roleId, dependsOn, inputs, correctsTaskId).", PLAN_LIMIT)), + ], + &["proposalId", "tasks"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["plans", "submit-proposal", "--stdin"]) + .option("--id", arguments.required("proposalId")?) + .stdin(arguments.required("tasks")?)) + }, + ), + ToolSpec { + destructive: true, + idempotent: true, + ..execute( + "cancel_workflow_proposal", + "Cancel Workflow Proposal", + "Cancel a workflow proposal and its coordinator. Pass expectedSequence to retry a cancellation that did not finish.", + || { + object( + &[ + ("proposalId", string("Proposal id.")), + ("expectedSequence", integer("Cancellation sequence from get_workflow_proposal, to retry it.", 0, u64::MAX >> 11)), + ], + &["proposalId"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["proposals", "cancel"]) + .option("--id", arguments.required("proposalId")?) + .option_if("--expected-sequence", arguments.integer("expectedSequence").map(|value| value.to_string()))) + }, + ) + }, + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "start_workflow_coordinator", + "Start Workflow Coordinator", + "Start the coordinator agent of a workflow proposal. The coordinator drafts the plan; it does not approve it.", + || object(&[("proposalId", string("Proposal id."))], &["proposalId"]), + |arguments| { + Ok(Invocation::new("orchestration", &["proposals", "start-coordinator"]) + .option("--id", arguments.required("proposalId")?)) + }, + ) + }, + execute( + "prepare_workflow_plan", + "Prepare Workflow Plan", + "Prepare a durable workflow plan for review from a PrepareWorkflowPlan JSON document with a stable requestId. Starts no workers; a person approves it in the Alera app.", + || object(&[("document", text("PrepareWorkflowPlan JSON document.", PLAN_LIMIT))], &["document"]), + |arguments| { + Ok(Invocation::new("orchestration", &["plans", "prepare", "--stdin"]) + .stdin(arguments.required("document")?)) + }, + ), + read( + "show_workflow_plan", + "Show Workflow Plan", + "Show the current or a past revision of a workflow run's plan, with its digest, tasks, and frozen profiles.", + || { + object( + &[ + ("runId", string("Workflow run id.")), + ("revision", integer("Plan revision (default current).", 1, u64::MAX >> 11)), + ], + &["runId"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["plans", "show"]) + .option("--run", arguments.required("runId")?) + .option_if("--revision", arguments.integer("revision").map(|value| value.to_string()))) + }, + ), + ] +} + +fn create_proposal(arguments: &ToolArguments) -> Result { + if arguments.string("runId").is_some() != arguments.integer("expectedRevision").is_some() { + return Err(ToolInputError( + "Pass runId and expectedRevision together.".into(), + )); + } + let invocation = Invocation::new( + "orchestration", + &["proposals", "create", "--objective-stdin"], + ) + .option("--workspace-id", arguments.required("workspaceId")?) + .option( + "--recipe-source", + recipe_source(arguments, "recipe", arguments.string("workspaceId"))?.to_string(), + ) + .option("--recipe-digest", arguments.required("recipeDigest")?) + .option( + "--coordinator-profile-id", + arguments.required("coordinatorProfileId")?, + ) + .option_if( + "--max-concurrent", + arguments + .integer("maxConcurrent") + .map(|value| value.to_string()), + ) + .option_if("--run", arguments.string("runId")) + .option_if( + "--expected-revision", + arguments + .integer("expectedRevision") + .map(|value| value.to_string()), + ); + let invocation = arguments + .list("roleProfiles") + .unwrap_or_default() + .into_iter() + .fold(invocation, |invocation, role| { + invocation.option("--role-profile", role) + }); + Ok(with_request_id(invocation, arguments).stdin(arguments.required("objective")?)) +} + +fn export_schema() -> Value { + object( + &[ + ( + "workspaceId", + string("Active local Git workspace whose project receives the recipe."), + ), + ( + "filename", + string("File name such as release.yaml, inside .alera/workflows."), + ), + ("document", text("Recipe YAML.", RECIPE_LIMIT)), + ], + &["workspaceId", "filename", "document"], + ) +} + +fn export(arguments: &ToolArguments, apply: bool) -> Result { + let invocation = Invocation::new("orchestration", &["recipes", "export", "--stdin"]) + .option("--workspace-id", arguments.required("workspaceId")?) + .option("--filename", arguments.required("filename")?); + let invocation = if apply { + invocation + .option("--expected-digest", arguments.required("expectedDigest")?) + .flag("--apply") + } else { + invocation + }; + Ok(invocation.stdin(arguments.required("document")?)) +} + +/// The recipe source properties, prefixed (`recipeOrigin`) or bare (`origin`). +fn recipe_source_properties(prefix: &str) -> Vec<(&'static str, Value)> { + let names = source_names(prefix); + vec![ + ( + names[0], + one_of( + "Where the recipe lives, as list_recipes reports it.", + &["builtIn", "personal", "project"], + ), + ), + ( + names[1], + string("Recipe id, or for a project recipe its path, as list_recipes reports them."), + ), + (names[2], string("Workspace of a project recipe.")), + ] +} + +fn source_names(prefix: &str) -> [&'static str; 3] { + if prefix.is_empty() { + ["origin", "id", "workspaceId"] + } else { + ["recipeOrigin", "recipeId", "recipeWorkspaceId"] + } +} + +/// `{origin, id}`, or `{origin, workspaceId, path}` for a project recipe, +/// whose workspace defaults to `default_workspace`. +fn recipe_source( + arguments: &ToolArguments, + prefix: &str, + default_workspace: Option, +) -> Result { + let names = source_names(prefix); + let origin = arguments.required(names[0])?; + let id = arguments.required(names[1])?; + if origin != "project" { + return Ok(json!({ "origin": origin, "id": id })); + } + let workspace = arguments + .string(names[2]) + .or(default_workspace) + .ok_or_else(|| ToolInputError(format!("A project recipe needs `{}`.", names[2])))?; + Ok(json!({ "origin": origin, "workspaceId": workspace, "path": id })) +} + +/// An array of free-form strings. +fn string_items(description: &str) -> Value { + json!({ + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true, + "description": description, + }) +} + +/// Commands that dedupe by `--request-id` require one. Without a +/// clientRequestId the call gets a fresh key, so it is not deduplicated. +fn with_request_id(invocation: Invocation, arguments: &ToolArguments) -> Invocation { + if arguments.string(CLIENT_REQUEST_ID).is_some() { + invocation + } else { + invocation.option(REQUEST_FLAG, format!("mcp-{}", uuid::Uuid::new_v4())) + } +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/workflows/execution.rs b/rust/alera-cli/src/mcp_tools/catalog/workflows/execution.rs new file mode 100644 index 000000000..68b8ee9a9 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/workflows/execution.rs @@ -0,0 +1,355 @@ +//! Running an approved workflow: execution control, corrections, isolated +//! attempts, local integration, and the cleanup of retained workspaces. + +use super::super::{admin, execute, read, LAUNCH_TIMEOUT}; +use super::{string_items, with_request_id, REQUEST_FLAG}; +use crate::mcp_tools::schema::{integer, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +const REASON_LIMIT: u64 = 4_096; +const ROW_MAX: u64 = u64::MAX >> 11; + +pub(super) fn tools() -> Vec { + let mut tools = execution(); + tools.extend(attempts()); + tools.extend(cleanup()); + tools +} + +fn run_revision() -> [(&'static str, serde_json::Value); 2] { + [ + ("runId", string("Workflow run id.")), + ("revision", integer("Approved plan revision.", 1, ROW_MAX)), + ] +} + +fn with_run_revision( + invocation: Invocation, + arguments: &ToolArguments, +) -> Result { + Ok(invocation + .option("--run", arguments.required("runId")?) + .option("--revision", revision(arguments)?)) +} + +fn revision(arguments: &ToolArguments) -> Result { + arguments + .integer("revision") + .map(|value| value.to_string()) + .ok_or_else(|| ToolInputError("Missing required argument `revision`.".into())) +} + +fn execution() -> Vec { + vec![ + read( + "get_workflow_execution", + "Get Workflow Execution", + "Show whether a workflow run is scheduling, paused, or cancelled, with the command sequence control_workflow_execution needs.", + || { + object( + &[("runId", string("Workflow run id.")), ("revision", integer("Plan revision (default current).", 1, ROW_MAX))], + &["runId"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["execution", "show"]) + .option("--run", arguments.required("runId")?) + .option_if("--revision", arguments.integer("revision").map(|value| value.to_string()))) + }, + ), + ToolSpec { + destructive: true, + client_request_flag: Some(REQUEST_FLAG), + ..execute( + "control_workflow_execution", + "Control Workflow Execution", + "Start, pause, or cancel the scheduling of an approved workflow run revision. Fails if expectedSequence is stale.", + || { + let mut properties = run_revision().to_vec(); + properties.push(("expectedSequence", integer("Sequence from get_workflow_execution.", 0, ROW_MAX))); + properties.push(("action", one_of("What to do.", &["start", "pause", "cancel"]))); + object(&properties, &["runId", "revision", "expectedSequence", "action"]) + }, + |arguments| { + let sequence = arguments + .integer("expectedSequence") + .ok_or_else(|| ToolInputError("Missing required argument `expectedSequence`.".into()))?; + let invocation = with_run_revision(Invocation::new("orchestration", &["execution", "control"]), arguments)? + .option("--expected-sequence", sequence.to_string()) + .option("--action", arguments.required("action")?); + Ok(with_request_id(invocation, arguments)) + }, + ) + }, + ToolSpec { + client_request_flag: Some(REQUEST_FLAG), + ..execute( + "create_workflow_correction", + "Create Workflow Correction", + "Open a correction proposal for a workflow run revision, with the reason the coordinator receives. A person still approves the corrected plan.", + || { + let mut properties = run_revision().to_vec(); + properties.push(("planDigest", string("Plan digest from show_workflow_plan."))); + properties.push(("reason", text("Why the run needs a correction.", REASON_LIMIT))); + object(&properties, &["runId", "revision", "planDigest", "reason"]) + }, + |arguments| { + let invocation = with_run_revision(Invocation::new("orchestration", &["execution", "correct", "--reason-stdin"]), arguments)? + .option("--plan-digest", arguments.required("planDigest")?); + Ok(with_request_id(invocation, arguments).stdin(arguments.required("reason")?)) + }, + ) + }, + ] +} + +fn attempts() -> Vec { + vec![ + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + client_request_flag: Some(REQUEST_FLAG), + ..execute( + "prepare_workflow_attempt", + "Prepare Workflow Attempt", + "Prepare the integration workspace of an approved run, or a task's isolated attempt. retryOf names the latest failed attempt; a retry always gets a new worktree. Dispatches no worker.", + || { + let mut properties = run_revision().to_vec(); + properties.push(("taskId", string("Task to prepare an attempt for. Omit to prepare the integration workspace."))); + properties.push(("retryOf", string("Workspace id of the task's latest failed attempt."))); + object(&properties, &["runId", "revision"]) + }, + |arguments| { + if arguments.string("retryOf").is_some() && arguments.string("taskId").is_none() { + return Err(ToolInputError("retryOf needs taskId.".into())); + } + let invocation = with_run_revision(Invocation::new("orchestration", &["workspaces", "prepare"]), arguments)? + .option_if("--task", arguments.string("taskId")) + .option_if("--retry-of", arguments.string("retryOf")); + Ok(with_request_id(invocation, arguments)) + }, + ) + }, + task_workspace_tool( + "launch_workflow_task", + "Launch Workflow Task", + "Launch one approved task in its ready isolated attempt, at most once per clientRequestId.", + "launch", + ), + task_workspace_tool( + "integrate_workflow_result", + "Integrate Workflow Result", + "Squash a completed task's result into its run's local integration workspace. Use a new clientRequestId to retry a failed integration.", + "integrate", + ), + read( + "list_workflow_integrations", + "List Workflow Integrations", + "List a run's local integration outcomes, or show one integration receipt with any conflict paths.", + || { + object( + &[ + ("runId", string("Workflow run id.")), + ("integrationId", string("Show this integration instead of listing.")), + ("afterRow", integer("Continue after this row of a previous page.", 0, ROW_MAX)), + ], + &[], + ) + }, + |arguments| match (arguments.string("integrationId"), arguments.string("runId")) { + (Some(id), _) => Ok(Invocation::new("orchestration", &["workspaces", "integration"]).option("--id", id)), + (None, Some(run)) => Ok(Invocation::new("orchestration", &["workspaces", "integrations"]) + .option("--run", run) + .option_if("--after-row", arguments.integer("afterRow").map(|value| value.to_string()))), + (None, None) => Err(ToolInputError("Pass runId or integrationId.".into())), + }, + ), + read( + "list_workflow_workspaces", + "List Workflow Workspaces", + "List the workspaces a workflow run retains (integration and attempts) with their setup outcome.", + || { + object( + &[ + ("runId", string("Workflow run id.")), + ("beforeRow", integer("Continue before this row of a previous page.", 0, ROW_MAX)), + ("limit", integer("Maximum entries.", 1, 200)), + ], + &["runId"], + ) + }, + |arguments| { + Ok(Invocation::new("orchestration", &["workspaces", "list"]) + .option("--run", arguments.required("runId")?) + .option_if("--before-row", arguments.integer("beforeRow").map(|value| value.to_string())) + .option_if("--limit", arguments.integer("limit").map(|value| value.to_string()))) + }, + ), + ] +} + +/// `orchestration workspaces launch|integrate`, which share their arguments. +fn task_workspace_tool( + name: &'static str, + title: &'static str, + description: &'static str, + action: &'static str, +) -> ToolSpec { + let build: fn(&ToolArguments) -> Result = if action == "launch" { + |arguments| task_workspace(arguments, "launch") + } else { + |arguments| task_workspace(arguments, "integrate") + }; + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + client_request_flag: Some(REQUEST_FLAG), + ..execute( + name, + title, + description, + || { + let mut properties = run_revision().to_vec(); + properties.push(("taskId", string("Task id."))); + properties.push(("workspaceId", string("The task attempt's workspace id."))); + object(&properties, &["runId", "revision", "taskId", "workspaceId"]) + }, + build, + ) + } +} + +fn task_workspace( + arguments: &ToolArguments, + action: &'static str, +) -> Result { + let invocation = with_run_revision( + Invocation::new("orchestration", &["workspaces", action]), + arguments, + )? + .option("--task", arguments.required("taskId")?) + .option("--workspace-id", arguments.required("workspaceId")?); + Ok(with_request_id(invocation, arguments)) +} + +fn cleanup() -> Vec { + vec![ + read( + "list_workflow_cleanups", + "List Workflow Cleanups", + "List a workflow run's retained resources with their cleanup state, or its cleanup operations.", + || { + object( + &[ + ("runId", string("Workflow run id.")), + ("view", one_of("resources (default) or operations.", &["resources", "operations"])), + ("beforeRow", integer("Continue before this row of a previous page.", 0, ROW_MAX)), + ], + &["runId"], + ) + }, + |arguments| { + let action = match arguments.string("view").as_deref() { + Some("operations") => "list", + _ => "resources", + }; + Ok(Invocation::new("orchestration", &["cleanup", action]) + .option("--run", arguments.required("runId")?) + .option_if("--before-row", arguments.integer("beforeRow").map(|value| value.to_string()))) + }, + ), + read( + "preview_workflow_cleanup", + "Preview Workflow Cleanup", + "Preview removing up to 25 retained workspaces of a workflow run, optionally with their branches. Changes nothing; returns the id and digest the cleanup tools need.", + || { + object( + &[ + ("runId", string("Workflow run id.")), + ("workspaceIds", string_items("Retained workspaces to remove.")), + ("removeBranchFor", string_items("Selected workspaces whose branch is deleted too.")), + ], + &["runId", "workspaceIds"], + ) + }, + |arguments| { + let workspaces = arguments.list("workspaceIds").unwrap_or_default(); + let branches = arguments.list("removeBranchFor").unwrap_or_default(); + if let Some(stray) = branches.iter().find(|id| !workspaces.contains(id)) { + return Err(ToolInputError(format!("removeBranchFor names {stray}, which is not in workspaceIds."))); + } + let invocation = Invocation::new("orchestration", &["cleanup", "preview"]).option("--run", arguments.required("runId")?); + let invocation = workspaces.into_iter().fold(invocation, |invocation, id| invocation.option("--workspace", id)); + Ok(branches.into_iter().fold(invocation, |invocation, id| invocation.option("--remove-branch", id))) + }, + ), + read( + "get_workflow_cleanup", + "Get Workflow Cleanup", + "Show a workflow cleanup operation, its reviewed preview, and the outcome of each resource.", + || object(&[("cleanupId", string("Cleanup (preview) id."))], &["cleanupId"]), + |arguments| { + Ok(Invocation::new("orchestration", &["cleanup", "status"]).option("--id", arguments.required("cleanupId")?)) + }, + ), + cleanup_tool( + "apply_workflow_cleanup", + "Apply Workflow Cleanup", + "Remove the workspaces (and branches) of a previewed workflow cleanup. Fails if they changed since the preview.", + true, + |arguments| confirmation(arguments, "apply"), + ), + cleanup_tool( + "retry_workflow_cleanup", + "Retry Workflow Cleanup", + "Retry a workflow cleanup that did not finish, for the same reviewed preview.", + true, + |arguments| confirmation(arguments, "retry"), + ), + cleanup_tool( + "abandon_workflow_cleanup", + "Abandon Workflow Cleanup", + "Abandon an unfinished workflow cleanup and keep the resources it did not remove.", + false, + |arguments| confirmation(arguments, "abandon"), + ), + ] +} + +fn cleanup_tool( + name: &'static str, + title: &'static str, + description: &'static str, + destructive: bool, + build: fn(&ToolArguments) -> Result, +) -> ToolSpec { + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + destructive, + ..admin( + name, + title, + description, + || { + object( + &[ + ( + "cleanupId", + string("Cleanup id from preview_workflow_cleanup."), + ), + ("digest", string("Digest from preview_workflow_cleanup.")), + ], + &["cleanupId", "digest"], + ) + }, + build, + ) + } +} + +fn confirmation( + arguments: &ToolArguments, + action: &'static str, +) -> Result { + Ok(Invocation::new("orchestration", &["cleanup", action]) + .option("--id", arguments.required("cleanupId")?) + .option("--digest", arguments.required("digest")?)) +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/workflows/tests.rs b/rust/alera-cli/src/mcp_tools/catalog/workflows/tests.rs new file mode 100644 index 000000000..b157a9338 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/workflows/tests.rs @@ -0,0 +1,116 @@ +//! The workflow tools build commands the CLI parser accepts, keep decisions +//! out of reach, and pass retry keys through. + +use clap::Parser; +use serde_json::{json, Map, Value}; + +use crate::cli::Cli; +use crate::mcp_tools::{ToolAccess, ToolSpec, CLIENT_REQUEST_ID}; + +fn workflow_tools() -> Vec { + super::tools() +} + +fn samples(tool: &ToolSpec, all: bool) -> Value { + let schema = tool.schema(); + let required = schema["required"].as_array().cloned().unwrap_or_default(); + let mut arguments = Map::new(); + for (name, property) in schema["properties"].as_object().into_iter().flatten() { + if !all && !required.iter().any(|value| value == name) { + continue; + } + let value = match property["type"].as_str() { + Some("integer") => json!(property["minimum"].as_u64().unwrap_or(1).max(1)), + Some("boolean") => json!(true), + Some("array") => json!(["item"]), + _ => match property["enum"].as_array() { + Some(values) => values[0].clone(), + None if name == CLIENT_REQUEST_ID => json!("request-0001"), + None => json!("sample"), + }, + }; + arguments.insert(name.clone(), value); + } + Value::Object(arguments) +} + +fn argv(tool: &ToolSpec, arguments: &Value) -> Option> { + let invocation = tool.invocation(arguments).ok()?; + let mut argv = vec!["alera".into(), invocation.group.into(), "--json".into()]; + argv.extend(invocation.args); + Some(argv) +} + +#[test] +fn every_workflow_tool_builds_a_parsable_command() { + for tool in workflow_tools() { + let built: Vec<_> = [true, false] + .into_iter() + .filter_map(|all| argv(&tool, &samples(&tool, all))) + .collect(); + assert!(!built.is_empty(), "{} builds nothing", tool.name); + for argv in built { + if let Err(error) = Cli::try_parse_from(&argv) { + panic!("{} builds {argv:?}: {error}", tool.name); + } + let reaches_decision = argv.iter().any(|arg| { + ["approve", "reject", "decide", "review", "sign", "challenge"] + .contains(&arg.as_str()) + }); + assert!(!reaches_decision, "{} reaches a human decision", tool.name); + } + } +} + +#[test] +fn request_keys_are_generated_or_passed_through() { + let tool = workflow_tools() + .into_iter() + .find(|tool| tool.name == "launch_workflow_task") + .unwrap(); + let mut arguments = samples(&tool, false); + arguments.as_object_mut().unwrap().remove(CLIENT_REQUEST_ID); + let generated = argv(&tool, &arguments).unwrap(); + assert_eq!( + generated + .iter() + .filter(|arg| arg.starts_with("--request-id=mcp-")) + .count(), + 1 + ); + arguments[CLIENT_REQUEST_ID] = json!("request-0001"); + let passed = argv(&tool, &arguments).unwrap(); + assert!(passed.contains(&"--request-id=request-0001".to_owned())); + assert_eq!( + passed + .iter() + .filter(|arg| arg.starts_with("--request-id")) + .count(), + 1 + ); +} + +#[test] +fn cleanup_execution_is_admin_and_recipes_name_their_source() { + for tool in workflow_tools() { + let admin = tool.name.ends_with("_workflow_cleanup") + && !tool.name.starts_with("preview") + && !tool.name.starts_with("get"); + assert_eq!(tool.access == ToolAccess::Admin, admin, "{}", tool.name); + } + let show = workflow_tools() + .into_iter() + .find(|tool| tool.name == "show_recipe") + .unwrap(); + let project = argv( + &show, + &json!({"origin": "project", "id": "release.yaml", "workspaceId": "ws"}), + ) + .unwrap(); + assert!(project + .iter() + .any(|arg| arg.contains(r#""path":"release.yaml""#))); + assert!(show + .invocation(&json!({"origin": "project", "id": "x"})) + .is_err()); +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/workspaces.rs b/rust/alera-cli/src/mcp_tools/catalog/workspaces.rs new file mode 100644 index 000000000..6e0ee7f6d --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/workspaces.rs @@ -0,0 +1,209 @@ +//! Workspaces: listing, creating, starting agents in new ones, and sleeping. + +use super::{execute, profile_schema, read, with_profile, LAUNCH_TIMEOUT, PROMPT_LIMIT}; +use crate::mcp_tools::schema::{boolean, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +/// The creation flags shared by `workspace add` and `workspace start`. A +/// branch only exists on a worktree, so naming one without it is refused here +/// rather than by the CLI. +fn workspace_creation( + invocation: Invocation, + arguments: &ToolArguments, +) -> Result { + let names_branch = + arguments.string("branch").is_some() || arguments.string("sourceBranch").is_some(); + if names_branch && !arguments.flag("worktree") { + return Err(ToolInputError( + "branch and sourceBranch need worktree: true.".into(), + )); + } + Ok(invocation + .flag_if("--worktree", arguments.flag("worktree")) + .option_if("--name", arguments.string("name")) + .option_if("--branch", arguments.string("branch")) + .option_if("--source-branch", arguments.string("sourceBranch")) + .option_if("--host-id", arguments.string("hostId")) + .option_if("--issue", arguments.string("issueUrl")) + .option_if("--section", arguments.string("section"))) +} + +fn workspace_creation_properties() -> Vec<(&'static str, serde_json::Value)> { + vec![ + ( + "worktree", + boolean("Create an exclusive Git worktree instead of sharing the project folder."), + ), + ("name", string("Workspace display name.")), + ("branch", string("Branch to create or use.")), + ("sourceBranch", string("Branch the new branch starts from.")), + ( + "hostId", + string("SSH target that owns the worktree. Omit for this machine."), + ), + ("issueUrl", string("Issue URL to link to the workspace.")), + ("section", string("Sidebar section name.")), + ] +} + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_workspaces", + "List Workspaces", + "List workspaces (tasks with their branch and worktree) of one project, or of every project when projectId is omitted. Filter by host, section, tag, archive state, or parent.", + || { + object( + &[ + ("projectId", string("Project id from list_projects.")), + ("hostId", string("SSH target id, or `local`.")), + ( + "sectionId", + string("Section id from list_sections, or `none` for workspaces in Others."), + ), + ("tagId", string("Tag id from list_tags.")), + ( + "archived", + one_of( + "all (default), only archived, or only visible workspaces.", + &["all", "archived", "visible"], + ), + ), + ("parentWorkspaceId", string("Only the children of this workspace.")), + ], + &[], + ) + }, + |arguments| { + let project = arguments.string("projectId"); + let all = project.is_none(); + let archived = match arguments.string("archived").as_deref() { + Some("archived") => Some("true"), + Some("visible") => Some("false"), + _ => None, + }; + Ok(Invocation::new("workspace", &["list"]) + .option_if("--project-id", project) + .option_if("--host-id", arguments.string("hostId")) + .option_if("--section-id", arguments.string("sectionId")) + .option_if("--tag-id", arguments.string("tagId")) + .option_if("--archived", archived) + .option_if( + "--parent-workspace-id", + arguments.string("parentWorkspaceId"), + ) + .flag_if("--all", all)) + }, + ), + execute( + "create_workspace", + "Create Workspace", + "Create a workspace (task) in a project, on the project folder or on its own Git worktree and branch, as the app's manual New Workspace form does. A worktree on a new branch also needs sourceBranch, such as main. No agent is started.", + || { + let mut properties = vec![("projectId", string("Project id from list_projects."))]; + properties.extend(workspace_creation_properties()); + properties.extend([ + ("sectionId", string("Section id from list_sections. Use instead of section.")), + ("parentWorkspaceId", string("Workspace to nest the new one under.")), + ( + "reuseExistingBranch", + boolean("Check out an existing branch in the worktree instead of creating it."), + ), + ("path", string("Exact folder for the worktree.")), + ( + "workspaceRoot", + string("Folder under which Alera names the worktree. Defaults to the runtime's workspace folder."), + ), + ]); + object(&properties, &["projectId"]) + }, + create_workspace, + ), + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "start_agent_workspace", + "Start Agent In New Workspace", + "Create a workspace and launch an agent profile in it with a prompt. Pass projectId, or workspaceId to infer the project and source branch from an existing workspace. A worktree created from projectId also needs sourceBranch.", + || { + let mut properties = vec![ + ("profile", profile_schema()), + ("prompt", text("Prompt delivered to the agent.", PROMPT_LIMIT)), + ("projectId", string("Project id from list_projects.")), + ("workspaceId", string("Existing workspace used to infer the project.")), + ]; + properties.extend(workspace_creation_properties()); + object(&properties, &["profile", "prompt"]) + }, + start_agent_workspace, + ) + }, + ToolSpec { + destructive: true, + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "sleep_workspace", + "Sleep Workspace", + "Stop a workspace's terminal sessions while keeping its tabs, branch, and files. Opening it again wakes it.", + || object(&[("workspaceId", string("Workspace id."))], &["workspaceId"]), + |arguments| { + Ok(Invocation::new("workspace", &["sleep"]) + .option("--id", arguments.required("workspaceId")?)) + }, + ) + }, + ] +} + +fn create_workspace(arguments: &ToolArguments) -> Result { + let worktree = arguments.flag("worktree"); + let (path, root) = (arguments.string("path"), arguments.string("workspaceRoot")); + if path.is_some() && root.is_some() { + return Err(ToolInputError( + "Pass path or workspaceRoot, not both.".into(), + )); + } + if arguments.string("section").is_some() && arguments.string("sectionId").is_some() { + return Err(ToolInputError( + "Pass section or sectionId, not both.".into(), + )); + } + let worktree_only = arguments.flag("reuseExistingBranch") || path.is_some() || root.is_some(); + if worktree_only && !worktree { + return Err(ToolInputError( + "reuseExistingBranch, path, and workspaceRoot need worktree.".into(), + )); + } + let invocation = Invocation::new("workspace", &["add"]) + .option("--project-id", arguments.required("projectId")?) + .option_if("--section-id", arguments.string("sectionId")) + .option_if( + "--parent-workspace-id", + arguments.string("parentWorkspaceId"), + ) + .flag_if( + "--reuse-existing-branch", + arguments.flag("reuseExistingBranch"), + ) + .option_if("--path", path) + .option_if("--workspace-root", root); + workspace_creation(invocation, arguments) +} + +fn start_agent_workspace(arguments: &ToolArguments) -> Result { + let project = arguments.string("projectId"); + let workspace = arguments.string("workspaceId"); + if project.is_none() && workspace.is_none() { + return Err(ToolInputError("Pass projectId or workspaceId.".into())); + } + let invocation = with_profile( + Invocation::new("workspace", &["start"]), + arguments.required("profile")?, + ) + .flag("--prompt-stdin") + .flag("--no-parent") + .option_if("--project-id", project) + .option_if("--workspace", workspace) + .stdin(arguments.required("prompt")?); + workspace_creation(invocation, arguments) +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage.rs b/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage.rs new file mode 100644 index 000000000..b2e383874 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage.rs @@ -0,0 +1,178 @@ +//! Workspace organization, lifecycle, removal, and recovery. + +mod organize; +mod relocation; + +use serde_json::Value; + +use super::{execute, read, LAUNCH_TIMEOUT}; +use crate::mcp_tools::schema::{boolean, object, one_of, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +const NAME_LIMIT: u64 = 200; + +pub(super) fn workspace_id() -> Value { + string("Workspace id from list_workspaces.") +} + +fn workspace_schema() -> Value { + object(&[("workspaceId", workspace_id())], &["workspaceId"]) +} + +/// `alera workspace --id `. +fn by_id(action: &'static str, arguments: &ToolArguments) -> Result { + Ok(Invocation::new("workspace", &[action]).option("--id", arguments.required("workspaceId")?)) +} + +pub(super) fn tools() -> Vec { + let mut tools = vec![ + read( + "show_workspace", + "Show Workspace", + "Show one workspace with its project, section, tags, linked issue, linked pull request, Watch and Fix state, parent, children, and whether it is asleep.", + workspace_schema, + |arguments| by_id("show", arguments), + ), + ToolSpec { + idempotent: true, + ..execute( + "rename_workspace", + "Rename Workspace", + "Change a workspace's display name. Its branch and folder are not touched.", + || { + object( + &[ + ("workspaceId", workspace_id()), + ("name", text("New display name.", NAME_LIMIT)), + ], + &["workspaceId", "name"], + ) + }, + |arguments| { + Ok(by_id("rename", arguments)?.option("--name", arguments.required("name")?)) + }, + ) + }, + ToolSpec { + idempotent: true, + ..execute( + "set_workspace_pinned", + "Pin Or Unpin Workspace", + "Pin a workspace to the top of the sidebar, or unpin it. With tree, the same applies to every workspace below it.", + || { + object( + &[ + ("workspaceId", workspace_id()), + ("pinned", boolean("True to pin, false to unpin.")), + ("tree", boolean("Also apply to every descendant workspace.")), + ], + &["workspaceId", "pinned"], + ) + }, + |arguments| { + let action = if arguments.flag("pinned") { "pin" } else { "unpin" }; + Ok(by_id(action, arguments)?.flag_if("--tree", arguments.flag("tree"))) + }, + ) + }, + ToolSpec { + destructive: true, + idempotent: true, + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "archive_workspace", + "Archive Workspace", + "Archive a workspace: its terminal sessions stop and it leaves the sidebar, while its tabs, branch, and files are kept for unarchive_workspace.", + workspace_schema, + |arguments| by_id("archive", arguments), + ) + }, + ToolSpec { + idempotent: true, + ..execute( + "unarchive_workspace", + "Unarchive Workspace", + "Return an archived workspace to the sidebar.", + workspace_schema, + |arguments| by_id("unarchive", arguments), + ) + }, + ToolSpec { + idempotent: true, + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "wake_workspace", + "Wake Workspace", + "Wake a workspace put to sleep with sleep_workspace: its stopped terminals start again and agent tabs resume their sessions, as opening it in the Alera app does.", + workspace_schema, + |arguments| by_id("wake", arguments), + ) + }, + ToolSpec { + idempotent: true, + ..execute( + "focus_workspace", + "Focus Workspace", + "Select and show a workspace in the running Alera desktop app.", + workspace_schema, + |arguments| by_id("focus", arguments), + ) + }, + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..read( + "preview_workspace_removal", + "Preview Workspace Removal", + "Show what remove_workspace would do without changing anything: measured storage and what blocks cleanup, the automations it would pause, the linked workspaces it would unlink, live sessions, and what happens to the branch.", + workspace_schema, + |arguments| by_id("remove-preview", arguments), + ) + }, + ToolSpec { + destructive: true, + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "remove_workspace", + "Remove Workspace", + "Remove a workspace, active or archived, with the Remove flow of the Alera app: its sessions close, dependent automations are paused and their runs cancelled, editors open on it in the app are saved (or discarded), and its worktree is deleted. Uncommitted changes in the worktree are lost, as when removing from the Alera app. A project folder workspace keeps its files. The branch is deleted when the workspace created it, unless branch is keep; Git still keeps a branch with unmerged work. Fails with blocked, removing nothing, when cleanup is unavailable or an editor cannot be saved.", + || { + object( + &[ + ("workspaceId", workspace_id()), + ( + "branch", + one_of( + "delete removes the branch, keep preserves it. Default: delete when the workspace created the branch, as the Remove button does.", + &["delete", "keep"], + ), + ), + ( + "editorBuffers", + one_of( + "What to do with unsaved editors on this workspace in the Alera app: save them (default) or discard them.", + &["save", "discard"], + ), + ), + ], + &["workspaceId"], + ) + }, + remove_workspace, + ) + }, + ]; + tools.extend(relocation::tools()); + tools.extend(organize::tools()); + tools +} + +fn remove_workspace(arguments: &ToolArguments) -> Result { + let editor_buffers = arguments + .string("editorBuffers") + .unwrap_or_else(|| "save".into()); + let branch = arguments.string("branch"); + Ok(by_id("remove", arguments)? + .option("--editor-buffers", editor_buffers) + .flag_if("--delete-branch", branch.as_deref() == Some("delete")) + .flag_if("--keep-branch", branch.as_deref() == Some("keep"))) +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage/organize.rs b/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage/organize.rs new file mode 100644 index 000000000..29559eefb --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage/organize.rs @@ -0,0 +1,293 @@ +//! Sidebar organization (sections, tags, parent and child links) and the +//! issue a workspace was created for. + +use serde_json::{json, Value}; + +use super::{workspace_id, NAME_LIMIT}; +use crate::mcp_tools::catalog::{execute, no_arguments, read, LAUNCH_TIMEOUT}; +use crate::mcp_tools::schema::{boolean, object, string, text}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +const URL_LIMIT: u64 = 2_048; + +fn ids(description: &str) -> Value { + json!({ + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "minItems": 1, + "uniqueItems": true, + "description": description, + }) +} + +fn tree() -> Value { + boolean("Also apply to every descendant workspace, like the sidebar's Tree actions.") +} + +fn section(action: &'static str, arguments: &ToolArguments) -> Result { + Ok(Invocation::new("workspace", &["section", action]) + .option("--workspace-id", arguments.required("workspaceId")?) + .flag_if("--tree", arguments.flag("tree"))) +} + +fn relation_schema() -> Value { + object( + &[ + ("parentWorkspaceId", string("Parent workspace id.")), + ("childWorkspaceId", string("Child workspace id.")), + ], + &["parentWorkspaceId", "childWorkspaceId"], + ) +} + +fn relation(action: &'static str, arguments: &ToolArguments) -> Result { + Ok(Invocation::new("workspace", &[action]) + .option( + "--parent-workspace-id", + arguments.required("parentWorkspaceId")?, + ) + .option( + "--child-workspace-id", + arguments.required("childWorkspaceId")?, + )) +} + +fn tag_schema() -> Value { + object( + &[ + ("workspaceId", workspace_id()), + ("tagId", string("Tag id from list_tags.")), + ], + &["workspaceId", "tagId"], + ) +} + +fn tagging(action: &'static str, arguments: &ToolArguments) -> Result { + Ok(Invocation::new("workspace", &[action]) + .option("--workspace-id", arguments.required("workspaceId")?) + .option("--tag-id", arguments.required("tagId")?)) +} + +fn issue(action: &'static str, arguments: &ToolArguments) -> Result { + Ok(Invocation::new("workspace", &["issue", action]) + .option("--workspace-id", arguments.required("workspaceId")?)) +} + +fn workspace_only() -> Value { + object(&[("workspaceId", workspace_id())], &["workspaceId"]) +} + +fn idempotent(tool: ToolSpec) -> ToolSpec { + ToolSpec { + idempotent: true, + ..tool + } +} + +pub(super) fn tools() -> Vec { + vec![ + read( + "list_sections", + "List Sections", + "List the sidebar sections workspaces are grouped in. A workspace without a section is in Others.", + no_arguments, + |_| Ok(Invocation::new("workspace", &["section", "list"])), + ), + execute( + "create_section", + "Create Section", + "Create a sidebar section and put a first workspace in it.", + || { + object( + &[ + ("name", text("Section name.", NAME_LIMIT)), + ("workspaceId", workspace_id()), + ("tree", tree()), + ], + &["name", "workspaceId"], + ) + }, + |arguments| { + Ok(section("create", arguments)?.option("--name", arguments.required("name")?)) + }, + ), + idempotent(execute( + "set_workspace_section", + "Set Workspace Section", + "Move a workspace to a section, given by sectionId or by its unique name.", + || { + object( + &[ + ("workspaceId", workspace_id()), + ("sectionId", string("Section id from list_sections.")), + ("section", string("Section name, matched without case.")), + ("tree", tree()), + ], + &["workspaceId"], + ) + }, + |arguments| { + let (id, name) = (arguments.string("sectionId"), arguments.string("section")); + if id.is_some() == name.is_some() { + return Err(ToolInputError("Pass sectionId or section.".into())); + } + Ok(section("set", arguments)? + .option_if("--section-id", id) + .option_if("--section", name)) + }, + )), + idempotent(execute( + "clear_workspace_section", + "Clear Workspace Section", + "Move a workspace to Others, out of any section.", + || object(&[("workspaceId", workspace_id()), ("tree", tree())], &["workspaceId"]), + |arguments| section("clear", arguments), + )), + ToolSpec { + destructive: true, + ..idempotent(execute( + "remove_section", + "Remove Section", + "Delete a sidebar section. Its workspaces are kept and move to Others.", + || object(&[("sectionId", string("Section id from list_sections."))], &["sectionId"]), + |arguments| { + Ok(Invocation::new("workspace", &["section", "remove"]) + .option("--id", arguments.required("sectionId")?)) + }, + )) + }, + read( + "list_tags", + "List Tags", + "List the workspace tags defined in this runtime.", + no_arguments, + |_| Ok(Invocation::new("tag", &["list"])), + ), + idempotent(execute( + "upsert_tag", + "Create Or Update Tag", + "Create a workspace tag, or rename or recolor one when tagId is given.", + || { + object( + &[ + ("name", text("Tag name.", NAME_LIMIT)), + ("tagId", string("Existing tag id to update.")), + ("color", string("Tag color, such as #3B82F6.")), + ], + &["name"], + ) + }, + |arguments| { + Ok(Invocation::new("tag", &["upsert"]) + .option("--name", arguments.required("name")?) + .option_if("--id", arguments.string("tagId")) + .option_if("--color", arguments.string("color"))) + }, + )), + ToolSpec { + destructive: true, + ..idempotent(execute( + "remove_tag", + "Remove Tag", + "Delete a workspace tag and take it off every workspace.", + || object(&[("tagId", string("Tag id from list_tags."))], &["tagId"]), + |arguments| { + Ok(Invocation::new("tag", &["remove"]).option("--id", arguments.required("tagId")?)) + }, + )) + }, + idempotent(execute( + "tag_workspace", + "Tag Workspace", + "Add a tag to a workspace.", + tag_schema, + |arguments| tagging("tag", arguments), + )), + idempotent(execute( + "untag_workspace", + "Untag Workspace", + "Take a tag off a workspace.", + tag_schema, + |arguments| tagging("untag", arguments), + )), + idempotent(execute( + "link_workspaces", + "Link Workspaces", + "Make one workspace the child of another, so it nests under it in the sidebar.", + relation_schema, + |arguments| relation("link", arguments), + )), + idempotent(execute( + "unlink_workspaces", + "Unlink Workspaces", + "Remove a parent and child link between two workspaces.", + relation_schema, + |arguments| relation("unlink", arguments), + )), + read( + "preview_workspace_cascade", + "Preview Workspace Cascade", + "List the workspaces an action would reach from some workspaces, optionally adding their descendants and every workspace sharing the given tags.", + || { + object( + &[ + ("workspaceIds", ids("Starting workspace ids.")), + ("tagIds", ids("Tag ids whose workspaces are included.")), + ("descendants", boolean("Include the descendants of the starting workspaces.")), + ("tags", boolean("Include every workspace with the given tags.")), + ], + &[], + ) + }, + |arguments| { + let mut invocation = Invocation::new("workspace", &["cascade-preview"]); + for id in arguments.list("workspaceIds").unwrap_or_default() { + invocation = invocation.option("--workspace-id", id); + } + for id in arguments.list("tagIds").unwrap_or_default() { + invocation = invocation.option("--tag-id", id); + } + Ok(invocation + .flag_if("--descendants", arguments.flag("descendants")) + .flag_if("--tags", arguments.flag("tags"))) + }, + ), + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..read( + "show_workspace_issue", + "Show Workspace Issue", + "Show the issue linked to a workspace, fetched fresh from GitHub, GitLab, or Azure DevOps, or only its saved title and state with cached.", + || object(&[("workspaceId", workspace_id()), ("cached", boolean("Skip the forge and show the saved title and state."))], &["workspaceId"]), + |arguments| Ok(issue("show", arguments)?.flag_if("--cached", arguments.flag("cached"))), + ) + }, + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..idempotent(execute( + "link_workspace_issue", + "Link Workspace Issue", + "Link an issue URL to a workspace, replacing its linked issue. Trackers Alera does not know are kept as a plain link.", + || object(&[("workspaceId", workspace_id()), ("url", text("Issue URL.", URL_LIMIT))], &["workspaceId", "url"]), + |arguments| Ok(issue("link", arguments)?.flag("--").flag(&arguments.required("url")?)), + )) + }, + idempotent(execute( + "unlink_workspace_issue", + "Unlink Workspace Issue", + "Remove the issue linked to a workspace.", + workspace_only, + |arguments| issue("unlink", arguments), + )), + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..read( + "fetch_issue", + "Fetch Issue", + "Read an issue or work item from GitHub, GitLab, or Azure DevOps by URL, with its title, state, labels, assignees, and body, through the forge CLI on this machine.", + || object(&[("url", text("Issue or work item URL.", URL_LIMIT))], &["url"]), + |arguments| Ok(Invocation::new("issue", &["show"]).flag("--").flag(&arguments.required("url")?)), + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage/relocation.rs b/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage/relocation.rs new file mode 100644 index 000000000..18dcba416 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/catalog/workspaces_manage/relocation.rs @@ -0,0 +1,220 @@ +//! Hand Off and Hand On, worktree setup and its recovery, and the low-level +//! workspace record repair kept for administrators. + +use serde_json::Value; + +use super::{by_id, workspace_id, workspace_schema}; +use crate::mcp_tools::catalog::{admin, execute, read, LAUNCH_TIMEOUT}; +use crate::mcp_tools::schema::{boolean, object, one_of, string}; +use crate::mcp_tools::{Invocation, ToolArguments, ToolInputError, ToolSpec}; + +fn location( + invocation: Invocation, + arguments: &ToolArguments, +) -> Result { + let path = arguments.string("path"); + let root = arguments.string("workspaceRoot"); + if path.is_some() && root.is_some() { + return Err(ToolInputError( + "Pass path or workspaceRoot, not both.".into(), + )); + } + Ok(invocation + .option_if("--path", path) + .option_if("--workspace-root", root)) +} + +fn location_properties() -> [(&'static str, Value); 2] { + [ + ("path", string("Exact folder for the new worktree.")), + ( + "workspaceRoot", + string("Folder under which Alera names the new worktree. Defaults to the runtime's workspace folder."), + ), + ] +} + +fn relocation_schema() -> Value { + object( + &[ + ("workspaceId", workspace_id()), + ( + "relocationId", + string("Relocation id from get_workspace_recovery."), + ), + ( + "attemptId", + string("Setup attempt id from get_workspace_recovery."), + ), + ], + &["workspaceId", "relocationId", "attemptId"], + ) +} + +fn setup_attempt(arguments: &ToolArguments, flag: &str) -> Result { + Ok(by_id("setup", arguments)? + .option("--relocation-id", arguments.required("relocationId")?) + .option("--attempt-id", arguments.required("attemptId")?) + .flag(flag)) +} + +pub(super) fn tools() -> Vec { + vec![ + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "hand_off_workspace", + "Hand Off Workspace", + "Move a task from its project folder to its own Git worktree on a new or existing branch, as Hand Off in the app does. Choose whether the uncommitted changes move with it or stay in the project folder.", + || { + let mut properties = vec![ + ("workspaceId", workspace_id()), + ("branch", string("Branch for the new worktree.")), + ("name", string("Display name of the moved task.")), + ( + "changes", + one_of( + "move takes the uncommitted changes to the worktree; leave keeps them in the project folder.", + &["move", "leave"], + ), + ), + ( + "reuseExistingBranch", + boolean("Use an existing branch instead of creating one. Needs replacementBranch and changes move."), + ), + ( + "replacementBranch", + string("Branch the project folder switches to when the worktree takes its current branch."), + ), + ]; + properties.extend(location_properties()); + object(&properties, &["workspaceId", "branch", "changes"]) + }, + |arguments| { + let changes = arguments.required("changes")?; + let invocation = by_id("hand-off", arguments)? + .option("--branch", arguments.required("branch")?) + .option_if("--name", arguments.string("name")) + .flag_if("--move-changes", changes == "move") + .flag_if("--leave-changes", changes == "leave") + .flag_if("--reuse-existing-branch", arguments.flag("reuseExistingBranch")) + .flag("--confirm-shared-impact"); + let replacement = arguments.string("replacementBranch"); + if replacement.is_some() && !arguments.flag("reuseExistingBranch") { + return Err(ToolInputError( + "replacementBranch needs reuseExistingBranch.".into(), + )); + } + location( + invocation.option_if("--replacement-branch", replacement), + arguments, + ) + }, + ) + }, + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "hand_on_workspace", + "Hand On Workspace", + "Bring a task from its own worktree back to its project folder on the same host, as Hand On in the app does. Every task on the project folder then shares that branch and its files.", + workspace_schema, + |arguments| Ok(by_id("hand-on", arguments)?.flag("--confirm-shared-impact")), + ) + }, + read( + "get_workspace_recovery", + "Get Workspace Recovery", + "Show a workspace's relocation phases and setup attempts, with the ids recover_workspace_setup and cancel_workspace_setup need. Runs nothing.", + workspace_schema, + |arguments| by_id("recovery", arguments), + ), + ToolSpec { + timeout_seconds: LAUNCH_TIMEOUT, + ..execute( + "run_workspace_setup", + "Run Workspace Setup", + "Run the project's worktree setup (copies and setup commands) in a workspace, or with relocationId the saved setup of that relocation once.", + || { + object( + &[ + ("workspaceId", workspace_id()), + ("copiesOnly", boolean("Only copy files; skip the setup commands.")), + ("relocationId", string("Relocation whose saved setup runs, from get_workspace_recovery.")), + ], + &["workspaceId"], + ) + }, + |arguments| { + let relocation = arguments.string("relocationId"); + if relocation.is_some() && arguments.flag("copiesOnly") { + return Err(ToolInputError( + "A relocation runs its whole saved setup; copiesOnly does not apply.".into(), + )); + } + Ok(by_id("setup", arguments)? + .flag_if("--copies-only", arguments.flag("copiesOnly")) + .option_if("--relocation-id", relocation)) + }, + ) + }, + execute( + "recover_workspace_setup", + "Recover Workspace Setup", + "Close an interrupted relocation setup attempt once its processes are verified gone, without running its commands again.", + relocation_schema, + |arguments| setup_attempt(arguments, "--recover"), + ), + execute( + "cancel_workspace_setup", + "Cancel Workspace Setup", + "Ask a running relocation setup attempt to stop.", + relocation_schema, + |arguments| setup_attempt(arguments, "--cancel"), + ), + admin( + "register_workspace_record", + "Register Workspace Record", + "Repair tool: write a workspace record for an existing folder without creating or touching any Git worktree.", + || { + object( + &[ + ("projectId", string("Project id from list_projects.")), + ("name", string("Display name.")), + ("path", string("Existing workspace folder.")), + ("workspaceId", string("Workspace id to use or replace.")), + ("hostId", string("SSH target id the record names. Metadata only.")), + ("branch", string("Branch checked out in the folder.")), + ("sourceBranch", string("Branch it started from.")), + ("kind", one_of("main for the project folder, linked for a worktree (default).", &["linked", "main"])), + ("reuseExistingBranch", boolean("The branch existed before the workspace.")), + ], + &["projectId", "name", "path"], + ) + }, + |arguments| { + Ok(Invocation::new("workspace", &["register"]) + .option("--project-id", arguments.required("projectId")?) + .option("--name", arguments.required("name")?) + .option("--path", arguments.required("path")?) + .option_if("--id", arguments.string("workspaceId")) + .option_if("--host-id", arguments.string("hostId")) + .option_if("--branch", arguments.string("branch")) + .option_if("--source-branch", arguments.string("sourceBranch")) + .option_if("--kind", arguments.string("kind")) + .flag_if("--reuse-existing-branch", arguments.flag("reuseExistingBranch"))) + }, + ), + ToolSpec { + destructive: true, + idempotent: true, + ..admin( + "unregister_workspace_record", + "Unregister Workspace Record", + "Repair tool: delete a workspace record and its tabs without touching any Git worktree or file.", + workspace_schema, + |arguments| by_id("unregister", arguments), + ) + }, + ] +} diff --git a/rust/alera-cli/src/mcp_tools/catalog_execute.rs b/rust/alera-cli/src/mcp_tools/catalog_execute.rs deleted file mode 100644 index 7e345a81f..000000000 --- a/rust/alera-cli/src/mcp_tools/catalog_execute.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Tools that change runtime state: workspaces, agents, terminals, and messages. -//! -//! Long text always travels on stdin, so it never meets an argument length -//! limit or a shell. - -use super::catalog_read::{wait_seconds, MCP_INBOX}; -use super::schema::{boolean, integer, object, one_of, string, text}; -use super::{Invocation, ToolAccess, ToolArguments, ToolInputError, ToolSpec, MAX_WAIT_SECONDS}; - -const MUTATION_TIMEOUT: u64 = 30; -const LAUNCH_TIMEOUT: u64 = MAX_WAIT_SECONDS + 8; -const PROMPT_LIMIT: u64 = 65_536; - -fn execute( - name: &'static str, - title: &'static str, - description: &'static str, - input_schema: fn() -> serde_json::Value, - build: fn(&ToolArguments) -> Result, -) -> ToolSpec { - ToolSpec { - name, - title, - description, - access: ToolAccess::Execute, - timeout_seconds: MUTATION_TIMEOUT, - destructive: false, - omit_fields: &[], - input_schema, - build, - } -} - -/// Profiles are addressed by stable id (`prof_...`) or by unique name. -fn with_profile(invocation: Invocation, profile: String) -> Invocation { - if profile.starts_with("prof_") { - invocation.option("--profile-id", profile) - } else { - invocation.option("--profile-name", profile) - } -} - -fn profile_schema() -> serde_json::Value { - string("Agent profile id or unique name from list_agent_profiles.") -} - -fn workspace_creation(invocation: Invocation, arguments: &ToolArguments) -> Invocation { - invocation - .flag_if("--worktree", arguments.flag("worktree")) - .option_if("--name", arguments.string("name")) - .option_if("--branch", arguments.string("branch")) - .option_if("--source-branch", arguments.string("sourceBranch")) - .option_if("--host-id", arguments.string("hostId")) - .option_if("--issue", arguments.string("issueUrl")) - .option_if("--section", arguments.string("section")) -} - -fn workspace_creation_properties() -> Vec<(&'static str, serde_json::Value)> { - vec![ - ( - "worktree", - boolean("Create an exclusive Git worktree instead of sharing the project folder."), - ), - ("name", string("Workspace display name.")), - ("branch", string("Branch to create or use.")), - ("sourceBranch", string("Branch the new branch starts from.")), - ( - "hostId", - string("SSH target that owns the worktree. Omit for this machine."), - ), - ("issueUrl", string("Issue URL to link to the workspace.")), - ("section", string("Sidebar section name.")), - ] -} - -pub(super) fn tools() -> Vec { - vec![ - execute( - "create_workspace", - "Create Workspace", - "Create a workspace (task) in a project, optionally on its own Git worktree and branch. A worktree also needs sourceBranch, such as main. No agent is started.", - || { - let mut properties = vec![("projectId", string("Project id from list_projects."))]; - properties.extend(workspace_creation_properties()); - object(&properties, &["projectId"]) - }, - |arguments| { - let invocation = Invocation::new("workspace", &["add"]) - .option("--project-id", arguments.required("projectId")?); - Ok(workspace_creation(invocation, arguments)) - }, - ), - ToolSpec { - timeout_seconds: LAUNCH_TIMEOUT, - ..execute( - "start_agent_workspace", - "Start Agent In New Workspace", - "Create a workspace and launch an agent profile in it with a prompt. Pass projectId, or workspaceId to infer the project and source branch from an existing workspace. A worktree created from projectId also needs sourceBranch.", - || { - let mut properties = vec![ - ("profile", profile_schema()), - ("prompt", text("Prompt delivered to the agent.", PROMPT_LIMIT)), - ("projectId", string("Project id from list_projects.")), - ("workspaceId", string("Existing workspace used to infer the project.")), - ]; - properties.extend(workspace_creation_properties()); - object(&properties, &["profile", "prompt"]) - }, - |arguments| { - let project = arguments.string("projectId"); - let workspace = arguments.string("workspaceId"); - if project.is_none() && workspace.is_none() { - return Err(ToolInputError("Pass projectId or workspaceId.".into())); - } - let invocation = with_profile( - Invocation::new("workspace", &["start"]), - arguments.required("profile")?, - ) - .flag("--prompt-stdin") - .flag("--no-parent") - .option_if("--project-id", project) - .option_if("--workspace", workspace) - .stdin(arguments.required("prompt")?); - Ok(workspace_creation(invocation, arguments)) - }, - ) - }, - ToolSpec { - timeout_seconds: LAUNCH_TIMEOUT, - ..execute( - "launch_agent", - "Launch Agent", - "Launch an agent profile in a new tab of an existing workspace with a prompt.", - || { - object( - &[ - ("workspaceId", string("Workspace id.")), - ("profile", profile_schema()), - ("prompt", text("Prompt delivered to the agent.", PROMPT_LIMIT)), - ], - &["workspaceId", "profile", "prompt"], - ) - }, - |arguments| { - Ok(with_profile( - Invocation::new("agent-profile", &["launch"]), - arguments.required("profile")?, - ) - .option("--workspace", arguments.required("workspaceId")?) - .flag("--prompt-stdin") - .stdin(arguments.required("prompt")?)) - }, - ) - }, - ToolSpec { - timeout_seconds: LAUNCH_TIMEOUT, - ..execute( - "delegate_task", - "Delegate Task", - "Create an orchestration task and start an agent profile that accepts it, in an existing workspace or a new child worktree. The coordinator is a running agent terminal (from list_terminals) that receives the worker's messages. Returns the task; follow it with wait_for_task. Without a coordinator terminal, use start_agent_workspace or ask_agent instead.", - || { - object( - &[ - ("profile", profile_schema()), - ("spec", text("Task brief the worker receives.", PROMPT_LIMIT)), - ("coordinator", string("Terminal handle of the coordinating agent, from list_terminals.")), - ("title", string("Short title for listings.")), - ("workspaceId", string("Workspace that owns the task, or the source workspace with newWorkspace.")), - ("newWorkspace", boolean("Create a child worktree and delegate into it.")), - ("projectId", string("Project for the new workspace.")), - ("name", string("Name for the new workspace.")), - ("branch", string("Branch for the new workspace.")), - ("sourceBranch", string("Branch the new workspace starts from.")), - ("timeoutSeconds", integer("Seconds to wait for the agent to accept.", 1, MAX_WAIT_SECONDS)), - ], - &["profile", "spec", "coordinator"], - ) - }, - |arguments| { - let new_workspace = arguments.flag("newWorkspace"); - let workspace = arguments.string("workspaceId"); - if workspace.is_none() && !(new_workspace && arguments.string("projectId").is_some()) { - return Err(ToolInputError( - "Pass workspaceId, or newWorkspace with projectId.".into(), - )); - } - let workspace_flag = if new_workspace { "--from-workspace" } else { "--workspace" }; - Ok(with_profile( - Invocation::new("orchestration", &["delegate"]), - arguments.required("profile")?, - ) - .flag("--spec-stdin") - .flag("--keep-on-failure") - .option("--from", arguments.required("coordinator")?) - .option_if("--task-title", arguments.string("title")) - .option_if(workspace_flag, workspace) - .flag_if("--new-workspace", new_workspace) - .option_if("--project-id", arguments.string("projectId")) - .option_if("--name", arguments.string("name")) - .option_if("--branch", arguments.string("branch")) - .option_if("--source-branch", arguments.string("sourceBranch")) - .flag_if("--no-parent", new_workspace && arguments.string("workspaceId").is_none()) - .option("--timeout-ms", (wait_seconds(arguments, MAX_WAIT_SECONDS) * 1000).to_string()) - .stdin(arguments.required("spec")?)) - }, - ) - }, - execute( - "write_terminal", - "Write To Terminal", - "Type text into a terminal. Set submit to send it to an interactive agent as a prompt, or enter to press Enter after it.", - || { - object( - &[ - ("handle", string("Terminal handle from list_terminals.")), - ("text", text("Text to type.", PROMPT_LIMIT)), - ("submit", boolean("Submit to an interactive agent with bracketed paste and Enter.")), - ("enter", boolean("Press Enter after the text.")), - ], - &["handle", "text"], - ) - }, - |arguments| { - Ok(Invocation::new("terminal", &["write"]) - .option("--handle", arguments.required("handle")?) - .flag("--stdin") - .flag_if("--submit", arguments.flag("submit")) - .flag_if("--enter", arguments.flag("enter") && !arguments.flag("submit")) - .stdin(arguments.required("text")?)) - }, - ), - execute( - "send_message", - "Send Orchestration Message", - "Send an orchestration message from one agent terminal to a terminal handle or a group such as @all, @idle, or @workspace:. To ask an agent a question from outside, use ask_agent.", - || { - object( - &[ - ("from", string("Sender terminal handle, from list_terminals.")), - ("to", string("Terminal handle or @group.")), - ("subject", string("Message subject.")), - ("body", text("Message body.", PROMPT_LIMIT)), - ("type", one_of("Message type.", &["status", "dispatch", "merge_ready", "escalation", "handoff", "decision_gate"])), - ("priority", one_of("Priority.", &["normal", "high", "urgent"])), - ("threadId", string("Thread to attach the message to.")), - ], - &["from", "to", "subject"], - ) - }, - |arguments| { - let invocation = Invocation::new("orchestration", &["send"]) - .option("--from", arguments.required("from")?) - .option("--to", arguments.required("to")?) - .option("--subject", arguments.required("subject")?) - .option_if("--type", arguments.string("type")) - .option_if("--priority", arguments.string("priority")) - .option_if("--thread-id", arguments.string("threadId")); - Ok(match arguments.string("body") { - Some(body) => invocation.flag("--body-stdin").stdin(body), - None => invocation, - }) - }, - ), - execute( - "ask_agent", - "Ask Agent", - "Ask a running agent a question by terminal handle, or the single agent of a workspace. Returns the question id; follow it with wait_for_reply.", - || { - object( - &[ - ("body", text("The question.", PROMPT_LIMIT)), - ("to", string("Terminal handle of the agent.")), - ("workspaceId", string("Ask the single running agent of this workspace.")), - ("agent", string("With workspaceId, only agents of this type (claude, codex, ...).")), - ("threadId", string("Continue an earlier question's thread.")), - ("subject", string("Short subject.")), - ("priority", one_of("Priority.", &["normal", "high", "urgent"])), - ("expiresIn", string("Drop the question if undelivered in time, such as 30m or 2h.")), - ], - &["body"], - ) - }, - |arguments| { - Ok(Invocation::new("inbox", &["ask"]) - .option("--inbox", MCP_INBOX) - .option_if("--to", arguments.string("to")) - .option_if("--workspace", arguments.string("workspaceId")) - .option_if("--agent", arguments.string("agent")) - .option_if("--thread", arguments.string("threadId")) - .option_if("--subject", arguments.string("subject")) - .option_if("--priority", arguments.string("priority")) - .option_if("--expires-in", arguments.string("expiresIn")) - .flag("--body-stdin") - .stdin(arguments.required("body")?)) - }, - ), - ToolSpec { - destructive: true, - ..execute( - "cancel_task", - "Cancel Task", - "Cancel an orchestration task and its not-yet-started descendants. Runs as an audited administrative cancellation, because an MCP client is not the task's coordinator terminal.", - || { - object( - &[("taskId", string("Task id.")), ("reason", string("Why it is cancelled."))], - &["taskId", "reason"], - ) - }, - |arguments| { - Ok(Invocation::new("orchestration", &["task-cancel"]) - .option("--id", arguments.required("taskId")?) - .option("--reason", format!("[mcp] {}", arguments.required("reason")?)) - .flag("--force")) - }, - ) - }, - ToolSpec { - destructive: true, - timeout_seconds: LAUNCH_TIMEOUT, - ..execute( - "sleep_workspace", - "Sleep Workspace", - "Stop a workspace's terminal sessions while keeping its tabs, branch, and files. Opening it again wakes it.", - || object(&[("workspaceId", string("Workspace id."))], &["workspaceId"]), - |arguments| { - Ok(Invocation::new("workspace", &["sleep"]) - .option("--id", arguments.required("workspaceId")?)) - }, - ) - }, - execute( - "run_automation", - "Run Automation", - "Start one run of an automation immediately.", - || object(&[("automationId", string("Automation id from list_automations."))], &["automationId"]), - |arguments| { - Ok(Invocation::new("automation", &["run-now"]) - .option("--id", arguments.required("automationId")?)) - }, - ), - ] -} diff --git a/rust/alera-cli/src/mcp_tools/catalog_read.rs b/rust/alera-cli/src/mcp_tools/catalog_read.rs deleted file mode 100644 index 35a2426ad..000000000 --- a/rust/alera-cli/src/mcp_tools/catalog_read.rs +++ /dev/null @@ -1,402 +0,0 @@ -//! Tools that only read runtime state, plus bounded waits. - -use super::schema::{integer, object, one_of, string, string_list}; -use super::{Invocation, ToolAccess, ToolArguments, ToolInputError, ToolSpec, MAX_WAIT_SECONDS}; - -/// Inbox used for questions an MCP client asks, kept apart from the user's own. -pub(super) const MCP_INBOX: &str = "ext:mcp"; -const LIST_TIMEOUT: u64 = 30; -const WAIT_TIMEOUT: u64 = MAX_WAIT_SECONDS + 8; -const TASK_STATES: &[&str] = &[ - "pending", - "ready", - "dispatched", - "completed", - "failed", - "blocked", - "stalled", - "cancelled", -]; - -fn read( - name: &'static str, - title: &'static str, - description: &'static str, - input_schema: fn() -> serde_json::Value, - build: fn(&ToolArguments) -> Result, -) -> ToolSpec { - ToolSpec { - name, - title, - description, - access: ToolAccess::Read, - timeout_seconds: LIST_TIMEOUT, - destructive: false, - omit_fields: &[], - input_schema, - build, - } -} - -fn no_arguments() -> serde_json::Value { - object(&[], &[]) -} - -pub(super) fn wait_seconds(arguments: &ToolArguments, default: u64) -> u64 { - arguments - .integer("timeoutSeconds") - .unwrap_or(default) - .clamp(1, MAX_WAIT_SECONDS) -} - -fn wait_schema() -> serde_json::Value { - integer( - "Seconds to wait before returning the current state. Call again to keep waiting.", - 1, - MAX_WAIT_SECONDS, - ) -} - -pub(super) fn tools() -> Vec { - let mut tools = vec![ - read( - "runtime_status", - "Runtime Status", - "Show whether the Alera runtime host is running, its database, and its active sessions.", - no_arguments, - |_| Ok(Invocation::new("runtime", &["status"])), - ), - read( - "list_projects", - "List Projects", - "List the projects registered in this Alera runtime with their ids, names, and the hosts each one is on.", - no_arguments, - |_| Ok(Invocation::new("project", &["list"])), - ), - read( - "list_workspaces", - "List Workspaces", - "List workspaces (tasks with their branch and worktree) of one project, or of every project when projectId is omitted. Filter by host with hostId.", - || { - object( - &[ - ("projectId", string("Project id from list_projects.")), - ("hostId", string("SSH target id, or `local`.")), - ], - &[], - ) - }, - |arguments| { - let project = arguments.string("projectId"); - let all = project.is_none(); - Ok(Invocation::new("workspace", &["list"]) - .option_if("--project-id", project) - .option_if("--host-id", arguments.string("hostId")) - .flag_if("--all", all)) - }, - ), - read( - "list_tabs", - "List Tabs", - "List the tabs of a workspace, including terminal and agent tabs.", - || object(&[("workspaceId", string("Workspace id."))], &["workspaceId"]), - |arguments| { - Ok(Invocation::new("tab", &["list"]) - .option("--workspace-id", arguments.required("workspaceId")?)) - }, - ), - read( - "list_agent_profiles", - "List Agent Profiles", - "List the agent profiles (Claude, Codex, and others) that can be launched or delegated to, with their ids and names.", - no_arguments, - |_| Ok(Invocation::new("agent-profile", &["list"])), - ), - read( - "list_terminals", - "List Terminals", - "List live terminal sessions with their handles, optionally for one workspace.", - || object(&[("workspaceId", string("Workspace id."))], &[]), - |arguments| { - Ok(Invocation::new("terminal", &["list"]) - .option_if("--workspace", arguments.string("workspaceId"))) - }, - ), - read( - "show_terminal", - "Show Terminal", - "Show one terminal session by handle, including its agent and lifecycle state.", - || object(&[("handle", string("Terminal handle from list_terminals."))], &["handle"]), - |arguments| { - Ok(Invocation::new("terminal", &["show"]) - .option("--handle", arguments.required("handle")?)) - }, - ), - ToolSpec { - omit_fields: &["dataBase64"], - ..read( - "read_terminal", - "Read Terminal", - "Read retained terminal output. Pass the returned cursor on the next call to read only new output.", - || { - object( - &[ - ("handle", string("Terminal handle from list_terminals.")), - ("cursor", integer("Cursor returned by a previous read.", 0, u64::MAX >> 11)), - ("maxBytes", integer("Maximum bytes to return (default 32768).", 1, 262_144)), - ], - &["handle"], - ) - }, - |arguments| { - Ok(Invocation::new("terminal", &["read"]) - .option("--handle", arguments.required("handle")?) - .option_if("--cursor", arguments.integer("cursor").map(|value| value.to_string())) - .option( - "--max-bytes", - arguments.integer("maxBytes").unwrap_or(32_768).to_string(), - )) - }, - ) - }, - read( - "list_tasks", - "List Tasks", - "List orchestration tasks, optionally filtered by status, coordinator run, or workspace.", - || { - object( - &[ - ("status", one_of("Task status.", TASK_STATES)), - ("runId", string("Coordinator run id.")), - ("workspaceId", string("Workspace id.")), - ], - &[], - ) - }, - |arguments| { - Ok(Invocation::new("orchestration", &["task-list"]) - .option_if("--status", arguments.string("status")) - .option_if("--run", arguments.string("runId")) - .option_if("--workspace", arguments.string("workspaceId"))) - }, - ), - read( - "show_task", - "Show Task", - "Show one orchestration task with its active dispatch.", - || object(&[("taskId", string("Task id."))], &["taskId"]), - |arguments| { - Ok(Invocation::new("orchestration", &["task-show"]) - .option("--id", arguments.required("taskId")?)) - }, - ), - read( - "list_runs", - "List Coordinator Runs", - "List durable orchestration coordinator runs, optionally for one workspace.", - || object(&[("workspaceId", string("Workspace id."))], &[]), - |arguments| { - Ok(Invocation::new("orchestration", &["run-list"]) - .option_if("--workspace", arguments.string("workspaceId"))) - }, - ), - read( - "orchestration_status", - "Orchestration Status", - "Aggregate the run, task, worker, and escalation state of one coordinator run.", - || object(&[("runId", string("Coordinator run id from list_runs."))], &["runId"]), - |arguments| { - Ok(Invocation::new("orchestration", &["status"]) - .option("--id", arguments.required("runId")?)) - }, - ), - read( - "list_messages", - "List Orchestration Messages", - "List recent orchestration messages across all recipients, or the inbox or outbox of one terminal.", - || { - object( - &[ - ("terminal", string("Terminal handle.")), - ("direction", one_of("Direction relative to terminal.", &["inbox", "outbox"])), - ("limit", integer("Maximum messages (default 50).", 1, 200)), - ], - &[], - ) - }, - |arguments| { - Ok(Invocation::new("orchestration", &["inbox"]) - .option_if("--terminal", arguments.string("terminal")) - .option_if("--direction", arguments.string("direction")) - .option_if("--limit", arguments.integer("limit").map(|value| value.to_string()))) - }, - ), - read( - "list_inbox_targets", - "List Askable Agents", - "List running agents that ask_agent can reach, optionally for one workspace.", - || object(&[("workspaceId", string("Workspace id."))], &[]), - |arguments| { - Ok(Invocation::new("inbox", &["targets"]) - .option_if("--workspace", arguments.string("workspaceId"))) - }, - ), - read( - "list_inbox_threads", - "List Question Threads", - "List question threads started through ask_agent, newest first.", - || { - object( - &[ - ("status", one_of("Thread status.", &["pending", "received", "delivered", "expired", "answered", "cancelled"])), - ("workspaceId", string("Workspace id.")), - ("limit", integer("Maximum threads (default 50).", 1, 200)), - ("before", integer("Continue from the nextBefore value of a previous listing.", 0, u64::MAX >> 11)), - ], - &[], - ) - }, - |arguments| { - Ok(Invocation::new("inbox", &["threads"]) - .option("--inbox", MCP_INBOX) - .option_if("--status", arguments.string("status")) - .option_if("--workspace", arguments.string("workspaceId")) - .option_if("--limit", arguments.integer("limit").map(|value| value.to_string())) - .option_if("--before", arguments.integer("before").map(|value| value.to_string()))) - }, - ), - read( - "show_inbox_thread", - "Show Question Thread", - "Show a question thread with every question and reply.", - || object(&[("questionId", string("Any question id in the thread."))], &["questionId"]), - |arguments| { - Ok(Invocation::new("inbox", &["show"]) - .option("--question", arguments.required("questionId")?)) - }, - ), - read( - "list_automations", - "List Automations", - "List runtime automations, optionally filtered by state, project, or search text.", - || { - object( - &[ - ("state", string("Automation state.")), - ("projectId", string("Project id.")), - ("search", string("Text to search for.")), - ], - &[], - ) - }, - |arguments| { - Ok(Invocation::new("automation", &["list"]) - .option_if("--state", arguments.string("state")) - .option_if("--project-id", arguments.string("projectId")) - .option_if("--search", arguments.string("search"))) - }, - ), - read( - "list_automation_runs", - "List Automation Runs", - "List automation runs, newest first, optionally for one automation.", - || { - object( - &[ - ("automationId", string("Automation id.")), - ("limit", integer("Maximum runs (default 20).", 1, 100)), - ], - &[], - ) - }, - |arguments| { - Ok(Invocation::new("automation", &["runs"]) - .option_if("--automation-id", arguments.string("automationId")) - .option("--limit", arguments.integer("limit").unwrap_or(20).to_string())) - }, - ), - ]; - tools.extend(wait_tools()); - tools -} - -fn wait_tools() -> Vec { - vec![ - ToolSpec { - timeout_seconds: WAIT_TIMEOUT, - ..read( - "wait_for_task", - "Wait For Task", - "Wait up to timeoutSeconds for an orchestration task to reach one of the given states, then return it. Returns the current state when the wait ends first.", - || { - object( - &[ - ("taskId", string("Task id.")), - ("states", string_list("States to wait for (default completed, failed, stalled, cancelled).", TASK_STATES)), - ("timeoutSeconds", wait_schema()), - ], - &["taskId"], - ) - }, - |arguments| { - let states = arguments - .list("states") - .unwrap_or_else(|| ["completed", "failed", "stalled", "cancelled"].map(String::from).to_vec()); - Ok(Invocation::new("orchestration", &["task-wait"]) - .option("--task", arguments.required("taskId")?) - .option("--for", states.join(",")) - .option("--timeout-ms", (wait_seconds(arguments, 30) * 1000).to_string())) - }, - ) - }, - ToolSpec { - timeout_seconds: WAIT_TIMEOUT, - ..read( - "wait_for_terminal", - "Wait For Terminal", - "Wait up to timeoutSeconds for a terminal to reach a lifecycle state, such as an agent becoming ready.", - || { - object( - &[ - ("handle", string("Terminal handle.")), - ("state", one_of("Lifecycle state.", &["process-started", "agent-detected", "agent-ready", "dispatch-accepted"])), - ("timeoutSeconds", wait_schema()), - ], - &["handle", "state"], - ) - }, - |arguments| { - Ok(Invocation::new("terminal", &["wait"]) - .option("--terminal", arguments.required("handle")?) - .option("--for", arguments.required("state")?) - .option("--timeout-ms", (wait_seconds(arguments, 30) * 1000).to_string())) - }, - ) - }, - ToolSpec { - timeout_seconds: WAIT_TIMEOUT, - ..read( - "wait_for_reply", - "Wait For Reply", - "Wait up to timeoutSeconds for news about a question asked with ask_agent. Pass the returned cursor as after to skip what was already seen.", - || { - object( - &[ - ("questionId", string("Question id returned by ask_agent.")), - ("after", integer("Cursor from a previous result.", 0, u64::MAX >> 11)), - ("timeoutSeconds", wait_schema()), - ], - &["questionId"], - ) - }, - |arguments| { - Ok(Invocation::new("inbox", &["wait"]) - .option("--inbox", MCP_INBOX) - .option("--question", arguments.required("questionId")?) - .option_if("--after", arguments.integer("after").map(|value| value.to_string())) - .option("--timeout", format!("{}s", wait_seconds(arguments, 30)))) - }, - ) - }, - ] -} diff --git a/rust/alera-cli/src/mcp_tools/executor.rs b/rust/alera-cli/src/mcp_tools/executor.rs index 792f4561f..c0c36befd 100644 --- a/rust/alera-cli/src/mcp_tools/executor.rs +++ b/rust/alera-cli/src/mcp_tools/executor.rs @@ -9,7 +9,8 @@ use serde_json::{json, Value}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::sync::oneshot; -use super::ToolSpec; +use super::origin::ORIGIN_VARIABLE; +use super::{CallOrigin, ToolSpec}; /// One relay frame is capped at 1 MiB, and the result travels inside JSON. pub(crate) const MAX_STDOUT_BYTES: usize = 768 * 1024; @@ -27,6 +28,8 @@ const CONTEXT_VARIABLES: &[&str] = &[ "ALERA_AGENT_CONVERSATION_ID", "ALERA_EXTERNAL_INBOX", "ALERA_RUNTIME_DIR", + "ALERA_AUTOMATION_RUN_ID", + "ALERA_AUTOMATION_ATTEMPT_ID", ]; #[derive(Debug, Clone)] @@ -44,32 +47,88 @@ impl ToolExecution { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub(crate) struct ToolResult { pub(crate) text: String, pub(crate) is_error: bool, + /// The result object for clients that read `structuredContent`; errors + /// carry `{ error: { code, message, retryable } }`. + pub(crate) structured: Option, } impl ToolResult { pub(crate) fn error(text: impl Into) -> Self { + let text = text.into(); Self { - text: text.into(), + structured: Some(error_object(&text)), + text, is_error: true, } } pub(crate) fn to_mcp(&self) -> Value { - json!({ + let mut value = json!({ "content": [{ "type": "text", "text": self.text }], "isError": self.is_error, - }) + }); + if let Some(structured) = self.structured.as_ref().filter(|value| value.is_object()) { + value["structuredContent"] = structured.clone(); + } + value } } +/// Error codes shared by every tool, so a client can branch on them instead +/// of parsing messages. +pub(crate) fn error_code(message: &str) -> (&'static str, bool) { + let lower = message.to_lowercase(); + // The app's own refusals, such as Cleanup Unavailable or unsaved editors. + if lower.starts_with("blocked:") { + ("blocked", false) + } else if lower.contains("unknown terminal host request") || lower.contains("update alera") { + ("capability_missing", false) + // A forge CLI (gh, glab, az) or its sign-in is missing on the checkout's host. + } else if lower.contains("install and authenticate") + || lower.contains("auth login on") + || lower.contains("sign in with az login") + { + ("provider_unavailable", false) + } else if lower.contains("only available for github") { + ("provider_unsupported", false) + } else if lower.contains("did not finish within") { + ("timeout_pending", true) + } else if lower.contains("not running") || lower.contains("could not connect") { + ("runtime_unavailable", true) + } else if lower.contains("not found") + || lower.contains("no such") + || lower.contains("does not exist") + { + ("not_found", false) + } else if lower.contains("already exists") || lower.contains("conflict") { + ("conflict", false) + } else if lower.contains("required") + || lower.contains("invalid") + || lower.contains("argument") + || lower.contains("must be") + { + ("invalid_argument", false) + } else if lower.contains("cancelled") { + ("cancelled", false) + } else { + ("failed", false) + } +} + +fn error_object(message: &str) -> Value { + let (code, retryable) = error_code(message); + json!({ "error": { "code": code, "message": message, "retryable": retryable } }) +} + pub(crate) async fn run_tool( execution: &ToolExecution, tool: &ToolSpec, arguments: &Value, + origin: &CallOrigin, cancelled: Option>, ) -> ToolResult { let invocation = match tool.invocation(arguments) { @@ -93,6 +152,7 @@ pub(crate) async fn run_tool( for variable in CONTEXT_VARIABLES { command.env_remove(variable); } + command.env(ORIGIN_VARIABLE, origin.to_env_value()); let mut child = match command.spawn() { Ok(child) => child, Err(error) => return ToolResult::error(format!("Could not start the Alera CLI: {error}")), @@ -154,12 +214,22 @@ pub(crate) async fn run_tool( detail } }; + let structured = if !succeeded { + Some(error_object(&text)) + } else if stdout_truncated { + None + } else { + serde_json::from_str::(&text) + .ok() + .filter(Value::is_object) + }; if stdout_truncated { text.push_str("\n[output truncated; narrow the request or read in smaller pages]"); } ToolResult { text, is_error: !succeeded, + structured, } } diff --git a/rust/alera-cli/src/mcp_tools/mod.rs b/rust/alera-cli/src/mcp_tools/mod.rs index 79dec8bda..da2b35cae 100644 --- a/rust/alera-cli/src/mcp_tools/mod.rs +++ b/rust/alera-cli/src/mcp_tools/mod.rs @@ -5,14 +5,17 @@ //! edge serves a generated copy of this catalog (`edge/src/mcp/tool_catalog.json`). mod arguments; -mod catalog_execute; -mod catalog_read; +mod catalog; mod executor; +mod origin; mod schema; mod stdio_server; +mod subscriptions; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_catalog; use std::time::Duration; @@ -20,17 +23,25 @@ use serde_json::{json, Value}; pub(crate) use arguments::{ToolArguments, ToolInputError}; pub(crate) use executor::{run_tool, ToolExecution, ToolResult}; +pub(crate) use origin::{CallOrigin, ORIGIN_VARIABLE}; pub(crate) use stdio_server::serve_stdio; -pub(crate) const CATALOG_VERSION: u64 = 1; +/// Version 2 added the `admin` access class and `idempotentHint`. +pub(crate) const CATALOG_VERSION: u64 = 2; /// Hosted MCP clients abandon a call after about a minute, so every wait stays /// below that and the agent polls again. pub(crate) const MAX_WAIT_SECONDS: u64 = 50; +/// Optional retry key of every tool that changes state through a CLI flag +/// that deduplicates repeated requests. +pub(crate) const CLIENT_REQUEST_ID: &str = "clientRequestId"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// The class of a tool, ordered from least to most privileged. A grant or a +/// runtime level that allows one class also allows every class below it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum ToolAccess { Read, Execute, + Admin, } impl ToolAccess { @@ -38,6 +49,17 @@ impl ToolAccess { match self { Self::Read => "read", Self::Execute => "execute", + Self::Admin => "admin", + } + } + + /// The class a cloud call grant allows (`read`, `execute`, or `admin`). + pub(crate) fn from_grant(value: &str) -> Option { + match value { + "read" => Some(Self::Read), + "execute" => Some(Self::Execute), + "admin" => Some(Self::Admin), + _ => None, } } } @@ -100,6 +122,11 @@ pub(crate) struct ToolSpec { /// Upper bound for the whole CLI process, including any wait it performs. pub(crate) timeout_seconds: u64, pub(crate) destructive: bool, + /// Repeating the call with the same arguments has no further effect. + pub(crate) idempotent: bool, + /// CLI flag that receives `clientRequestId`, for tools whose command + /// deduplicates retries. The argument is added to the schema for them. + pub(crate) client_request_flag: Option<&'static str>, /// Top-level result fields dropped before returning, such as a base64 copy /// of text the result already carries. pub(crate) omit_fields: &'static [&'static str], @@ -112,9 +139,22 @@ impl ToolSpec { Duration::from_secs(self.timeout_seconds) } + /// The tool's input schema, including `clientRequestId` when it applies. + pub(crate) fn schema(&self) -> Value { + let mut schema = (self.input_schema)(); + if self.client_request_flag.is_some() { + schema["properties"][CLIENT_REQUEST_ID] = schema::client_request_id(); + } + schema + } + pub(crate) fn invocation(&self, arguments: &Value) -> Result { - let arguments = ToolArguments::parse(arguments, &(self.input_schema)())?; - (self.build)(&arguments) + let arguments = ToolArguments::parse(arguments, &self.schema())?; + let invocation = (self.build)(&arguments)?; + Ok(match self.client_request_flag { + Some(flag) => invocation.option_if(flag, arguments.string(CLIENT_REQUEST_ID)), + None => invocation, + }) } pub(crate) fn to_json(&self) -> Value { @@ -124,11 +164,12 @@ impl ToolSpec { "description": self.description, "access": self.access.as_str(), "timeoutSeconds": self.timeout_seconds, - "inputSchema": (self.input_schema)(), + "inputSchema": self.schema(), "annotations": { "title": self.title, "readOnlyHint": self.access == ToolAccess::Read, "destructiveHint": self.destructive, + "idempotentHint": self.idempotent || self.access == ToolAccess::Read, "openWorldHint": false, }, }) @@ -136,9 +177,7 @@ impl ToolSpec { } pub(crate) fn catalog() -> Vec { - let mut tools = catalog_read::tools(); - tools.extend(catalog_execute::tools()); - tools + catalog::tools() } pub(crate) fn find_tool(name: &str) -> Option { diff --git a/rust/alera-cli/src/mcp_tools/origin.rs b/rust/alera-cli/src/mcp_tools/origin.rs new file mode 100644 index 000000000..6ff370dbe --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/origin.rs @@ -0,0 +1,118 @@ +//! Who made an MCP call, handed to the CLI process that runs the tool. +//! +//! A remote call names the OAuth client and grant the cloud signed; a local +//! call names the client from its `initialize` request. Commands that record +//! an origin, such as `inbox ask`, read it from [`ORIGIN_VARIABLE`]. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Environment variable that carries the JSON-encoded [`CallOrigin`]. +pub(crate) const ORIGIN_VARIABLE: &str = "ALERA_MCP_ORIGIN"; +const MAX_FIELD_CHARS: usize = 256; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CallOrigin { + /// `remote` (through the Alera cloud) or `local` (`alera mcp serve`). + pub(crate) transport: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) client_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, +} + +impl CallOrigin { + pub(crate) fn remote( + client_id: &str, + client_name: &str, + grant_id: &str, + call_id: &str, + ) -> Self { + Self { + transport: "remote".to_owned(), + client_id: bounded(client_id), + client_name: bounded(client_name), + grant_id: bounded(grant_id), + call_id: bounded(call_id), + } + } + + /// The local client as its `initialize` request named itself. + pub(crate) fn local(client_info: &Value) -> Self { + let name = client_info["title"] + .as_str() + .or_else(|| client_info["name"].as_str()) + .unwrap_or_default(); + Self { + transport: "local".to_owned(), + client_id: client_info["name"].as_str().and_then(bounded), + client_name: bounded(name), + grant_id: None, + call_id: None, + } + } + + pub(crate) fn with_call_id(mut self, call_id: &str) -> Self { + self.call_id = bounded(call_id); + self + } + + pub(crate) fn to_env_value(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } + + /// The origin of the current process, when an MCP tool started it. + pub(crate) fn from_env() -> Option { + let text = std::env::var(ORIGIN_VARIABLE).ok()?; + serde_json::from_str::(&text) + .ok() + .filter(|origin| matches!(origin.transport.as_str(), "remote" | "local")) + } +} + +/// Keeps a self-asserted value short enough to show and store. +fn bounded(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.chars().take(MAX_FIELD_CHARS).collect()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::CallOrigin; + + #[test] + fn local_origin_prefers_the_client_title() { + let origin = CallOrigin::local(&json!({ "name": "codex-mcp", "title": "Codex" })); + assert_eq!(origin.transport, "local"); + assert_eq!(origin.client_name.as_deref(), Some("Codex")); + assert_eq!(origin.client_id.as_deref(), Some("codex-mcp")); + let unnamed = CallOrigin::local(&json!({})); + assert!(unnamed.client_name.is_none()); + } + + #[test] + fn remote_origin_round_trips_through_the_environment_value() { + let origin = CallOrigin::remote("https://chatgpt.com/client", "ChatGPT", "g-1", "c-1"); + let text = origin.to_env_value(); + let parsed: CallOrigin = serde_json::from_str(&text).unwrap(); + assert_eq!(parsed, origin); + assert!(!text.contains("null")); + } + + #[test] + fn long_and_blank_values_are_bounded() { + let origin = CallOrigin::remote(&"x".repeat(5000), " ", "g", "c"); + assert_eq!(origin.client_id.unwrap().chars().count(), 256); + assert!(origin.client_name.is_none()); + } +} diff --git a/rust/alera-cli/src/mcp_tools/schema.rs b/rust/alera-cli/src/mcp_tools/schema.rs index c8afbae24..6cf44501b 100644 --- a/rust/alera-cli/src/mcp_tools/schema.rs +++ b/rust/alera-cli/src/mcp_tools/schema.rs @@ -58,3 +58,14 @@ pub(super) fn string_list(description: &str, values: &[&str]) -> Value { "description": description, }) } + +/// `clientRequestId`: the same key on a retry returns the first call's result +/// instead of repeating its effect. +pub(super) fn client_request_id() -> Value { + json!({ + "type": "string", + "minLength": 8, + "maxLength": 128, + "description": "Optional retry key. Reuse it when retrying this call so the change happens once.", + }) +} diff --git a/rust/alera-cli/src/mcp_tools/stdio_server.rs b/rust/alera-cli/src/mcp_tools/stdio_server.rs index 7ec3036ef..85de2c5c1 100644 --- a/rust/alera-cli/src/mcp_tools/stdio_server.rs +++ b/rust/alera-cli/src/mcp_tools/stdio_server.rs @@ -10,7 +10,9 @@ use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::{mpsc, oneshot}; -use super::{catalog, find_tool, run_tool, ToolAccess, ToolExecution, ToolSpec}; +use super::subscriptions::{listed_resources, resolve, Subscriptions}; +use super::{catalog, find_tool, run_tool, CallOrigin, ToolExecution, ToolSpec}; +use crate::mcp_settings::McpAccess; pub(crate) const PROTOCOL_VERSIONS: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"]; pub(crate) const INSTRUCTIONS: &str = "Alera runs coding agents in workspaces (Git worktrees) and orchestrates them. Start with list_projects, list_workspaces, and list_agent_profiles. Use start_agent_workspace or delegate_task to start work, then wait_for_task, read_terminal, or ask_agent with wait_for_reply to follow it. Waits return after at most 50 seconds; call them again to keep waiting."; @@ -18,7 +20,10 @@ const MAX_CONCURRENT_CALLS: usize = 4; type Pending = Arc>>>; -pub(crate) async fn serve_stdio(execution: ToolExecution, read_only: bool) -> anyhow::Result<()> { +/// Serves the catalog up to `access`: `read` lists only reading tools, `full` +/// adds execution tools, and `admin` adds administrative ones. +pub(crate) async fn serve_stdio(execution: ToolExecution, access: McpAccess) -> anyhow::Result<()> { + let mut origin = CallOrigin::local(&Value::Null); let (output, mut outgoing) = mpsc::channel::(64); let pending: Pending = Arc::default(); let writer_pending = pending.clone(); @@ -35,6 +40,7 @@ pub(crate) async fn serve_stdio(execution: ToolExecution, read_only: bool) -> an } }); let permits = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_CALLS)); + let mut subscriptions = Subscriptions::new(execution.runtime_dir.clone(), output.clone()); let mut lines = BufReader::new(tokio::io::stdin()).lines(); while let Some(line) = lines.next_line().await? { if output.is_closed() { @@ -64,13 +70,57 @@ pub(crate) async fn serve_stdio(execution: ToolExecution, read_only: bool) -> an }; match method.as_str() { "initialize" => { + origin = CallOrigin::local(¶ms["clientInfo"]); let _ = output.send(success(id, initialize_result(¶ms))).await; } "ping" => { let _ = output.send(success(id, json!({}))).await; } + "resources/list" => { + let resources = json!({ "resources": listed_resources() }); + let _ = output.send(success(id, resources)).await; + } + "resources/read" => { + let uri = params["uri"].as_str().unwrap_or_default().to_owned(); + let response = match resolve(&uri) { + Some((resource, arguments)) => match find_tool(resource.tool) { + Some(tool) => { + let result = + run_tool(&execution, &tool, &arguments, &origin, None).await; + if result.is_error { + error(id, -32603, &result.text) + } else { + success( + id, + json!({ "contents": [{ + "uri": uri, + "mimeType": "application/json", + "text": result.text, + }]}), + ) + } + } + None => error(id, -32002, &format!("Resource not found: {uri}")), + }, + None => error(id, -32002, &format!("Resource not found: {uri}")), + }; + let _ = output.send(response).await; + } + "resources/subscribe" => { + let uri = params["uri"].as_str().unwrap_or_default(); + let response = if subscriptions.subscribe(uri) { + success(id, json!({})) + } else { + error(id, -32002, &format!("Resource not found: {uri}")) + }; + let _ = output.send(response).await; + } + "resources/unsubscribe" => { + subscriptions.unsubscribe(params["uri"].as_str().unwrap_or_default()); + let _ = output.send(success(id, json!({}))).await; + } "tools/list" => { - let tools = visible_tools(read_only) + let tools = visible_tools(access) .iter() .map(listed_tool) .collect::>(); @@ -78,9 +128,7 @@ pub(crate) async fn serve_stdio(execution: ToolExecution, read_only: bool) -> an } "tools/call" => { let name = params["name"].as_str().unwrap_or_default().to_owned(); - let Some(tool) = - find_tool(&name).filter(|tool| !read_only || tool.access == ToolAccess::Read) - else { + let Some(tool) = find_tool(&name).filter(|tool| access.allows(tool.access)) else { let _ = output .send(error(id, -32602, &format!("Unknown tool: {name}"))) .await; @@ -93,6 +141,7 @@ pub(crate) async fn serve_stdio(execution: ToolExecution, read_only: bool) -> an } let output = output.clone(); let execution = execution.clone(); + let origin = origin.clone().with_call_id(key.trim_matches('"')); let pending = pending.clone(); let permits = permits.clone(); tokio::spawn(async move { @@ -107,7 +156,8 @@ pub(crate) async fn serve_stdio(execution: ToolExecution, read_only: bool) -> an return; } let arguments = params.get("arguments").cloned().unwrap_or(Value::Null); - let result = run_tool(&execution, &tool, &arguments, Some(cancelled)).await; + let result = + run_tool(&execution, &tool, &arguments, &origin, Some(cancelled)).await; let still_pending = pending .lock() .ok() @@ -128,6 +178,7 @@ pub(crate) async fn serve_stdio(execution: ToolExecution, read_only: bool) -> an // The client is gone: stop running calls and keep queued ones from // starting, since nobody can receive their results. cancel_all(&pending); + drop(subscriptions); drop(output); let _ = writer.await; Ok(()) @@ -141,10 +192,10 @@ fn cancel_all(pending: &Pending) { } } -fn visible_tools(read_only: bool) -> Vec { +fn visible_tools(access: McpAccess) -> Vec { catalog() .into_iter() - .filter(|tool| !read_only || tool.access == ToolAccess::Read) + .filter(|tool| access.allows(tool.access)) .collect() } @@ -162,7 +213,10 @@ pub(crate) fn negotiate_version(requested: Option<&str>) -> &'static str { fn initialize_result(params: &Value) -> Value { json!({ "protocolVersion": negotiate_version(params["protocolVersion"].as_str()), - "capabilities": { "tools": { "listChanged": false } }, + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": true, "listChanged": false }, + }, "serverInfo": { "name": "alera", "title": "Alera", diff --git a/rust/alera-cli/src/mcp_tools/subscriptions.rs b/rust/alera-cli/src/mcp_tools/subscriptions.rs new file mode 100644 index 000000000..920707b4a --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/subscriptions.rs @@ -0,0 +1,261 @@ +//! Resources a local MCP client can read and subscribe to. +//! +//! `alera mcp serve` lists a few resources that mirror polling tools. A client +//! that subscribes to one gets `notifications/resources/updated` when the +//! runtime reports a change, then reads it again. The listener connects to +//! the runtime only while some resource is subscribed, so an idle server never +//! keeps the runtime awake. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::runtime_host_client::RuntimeHostRpcClient; + +pub(crate) struct ResourceSpec { + pub(crate) uri: &'static str, + pub(crate) name: &'static str, + pub(crate) description: &'static str, + /// The tool whose result is the resource's content. + pub(crate) tool: &'static str, + /// Runtime events that change the resource. + pub(crate) events: &'static [&'static str], +} + +pub(crate) const RESOURCES: &[ResourceSpec] = &[ + ResourceSpec { + uri: "alera://events", + name: "Runtime Events", + description: "The runtime event journal: inbox replies, agent states, tasks, runs, and workspace starts. Read alera://events?after= for what is new.", + tool: "list_events", + events: &[ + "runtimeEventsAppended", + "promptWorkspaceOperationsChanged", + "inboxChanged", + "orchestrationBoardChanged", + ], + }, + ResourceSpec { + uri: "alera://workspace-starts", + name: "Workspace Starts", + description: "Recent New Workspace from Prompt operations and their status.", + tool: "list_workspace_starts", + events: &["promptWorkspaceOperationsChanged"], + }, + ResourceSpec { + uri: "alera://inbox", + name: "MCP Inbox", + description: "Question threads asked through ask_agent, with their replies.", + tool: "list_inbox_threads", + events: &["inboxChanged"], + }, +]; + +const RECONNECT_DELAY: Duration = Duration::from_secs(3); + +/// The resource and tool arguments a `resources/read` URI names. +pub(crate) fn resolve(uri: &str) -> Option<(&'static ResourceSpec, Value)> { + let (base, query) = uri.split_once('?').unwrap_or((uri, "")); + let resource = RESOURCES.iter().find(|resource| resource.uri == base)?; + let mut arguments = json!({}); + for pair in query.split('&').filter(|pair| !pair.is_empty()) { + let (key, value) = pair.split_once('=')?; + match (resource.tool, key) { + ("list_events", "after") => arguments["after"] = json!(value.parse::().ok()?), + _ => return None, + } + } + Some((resource, arguments)) +} + +pub(crate) fn listed_resources() -> Vec { + RESOURCES + .iter() + .map(|resource| { + json!({ + "uri": resource.uri, + "name": resource.name, + "description": resource.description, + "mimeType": "application/json", + }) + }) + .collect() +} + +/// Subscribed URIs and the listener that serves them. +pub(crate) struct Subscriptions { + runtime_dir: PathBuf, + output: mpsc::Sender, + uris: Arc>>, + listener: Option>, +} + +impl Subscriptions { + pub(crate) fn new(runtime_dir: PathBuf, output: mpsc::Sender) -> Self { + Self { + runtime_dir, + output, + uris: Arc::default(), + listener: None, + } + } + + pub(crate) fn subscribe(&mut self, uri: &str) -> bool { + if resolve(uri).is_none() { + return false; + } + // The exact URI is kept: a notification names the resource the + // client subscribed to, cursor included, and each one unsubscribes + // on its own. + if let Ok(mut uris) = self.uris.lock() { + uris.insert(uri.to_owned()); + } + if self.listener.as_ref().is_none_or(JoinHandle::is_finished) { + self.listener = Some(tokio::spawn(listen( + self.runtime_dir.clone(), + self.uris.clone(), + self.output.clone(), + ))); + } + true + } + + pub(crate) fn unsubscribe(&mut self, uri: &str) { + let empty = self.uris.lock().map_or(true, |mut uris| { + uris.remove(uri); + uris.is_empty() + }); + if empty { + if let Some(listener) = self.listener.take() { + listener.abort(); + } + } + } +} + +impl Drop for Subscriptions { + fn drop(&mut self) { + if let Some(listener) = self.listener.take() { + listener.abort(); + } + } +} + +/// The subscribed URIs a runtime event updates, as they were subscribed. +pub(crate) fn updated_uris(event: &str, subscribed: &HashSet) -> Vec { + let mut updated: Vec = subscribed + .iter() + .filter(|uri| resolve(uri).is_some_and(|(resource, _)| resource.events.contains(&event))) + .cloned() + .collect(); + updated.sort(); + updated +} + +async fn listen( + runtime_dir: PathBuf, + uris: Arc>>, + output: mpsc::Sender, +) { + loop { + if let Ok(Some(client)) = RuntimeHostRpcClient::connect(&runtime_dir).await { + // The write half stays open: closing it would end the session. + let (mut lines, _writer, _) = client.into_terminal_transport(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(frame) = serde_json::from_str::(&line) else { + continue; + }; + let Some(event) = frame.get("event").and_then(Value::as_str) else { + continue; + }; + let targets = match uris.lock() { + Ok(subscribed) => updated_uris(event, &subscribed), + Err(_) => return, + }; + for uri in targets { + let notification = json!({ + "jsonrpc": "2.0", + "method": "notifications/resources/updated", + "params": { "uri": uri }, + }); + if output.send(notification).await.is_err() { + return; + } + } + } + } + tokio::time::sleep(RECONNECT_DELAY).await; + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use serde_json::json; + + use super::{resolve, updated_uris, RESOURCES}; + use crate::mcp_tools::find_tool; + + #[test] + fn every_resource_reads_through_an_existing_reading_tool() { + for resource in RESOURCES { + let tool = find_tool(resource.tool).expect("resource tool exists"); + assert_eq!( + tool.access, + crate::mcp_tools::ToolAccess::Read, + "{}", + resource.uri + ); + } + } + + #[test] + fn uris_resolve_with_their_query() { + let (resource, arguments) = resolve("alera://events?after=42").unwrap(); + assert_eq!(resource.tool, "list_events"); + assert_eq!(arguments, json!({ "after": 42 })); + assert!(resolve("alera://events?before=1").is_none()); + assert!(resolve("alera://unknown").is_none()); + } + + #[test] + fn events_update_only_subscribed_resources() { + let subscribed: HashSet = ["alera://inbox".to_owned()].into(); + assert_eq!( + updated_uris("inboxChanged", &subscribed), + vec!["alera://inbox"] + ); + assert!(updated_uris("promptWorkspaceOperationsChanged", &subscribed).is_empty()); + } + + #[test] + fn a_cursor_subscription_is_notified_and_removed_by_its_exact_uri() { + let (sender, _receiver) = tokio::sync::mpsc::channel(1); + let mut subscriptions = + super::Subscriptions::new(std::path::PathBuf::from("/nonexistent"), sender); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let _guard = runtime.enter(); + assert!(subscriptions.subscribe("alera://events?after=42")); + assert!(subscriptions.subscribe("alera://events?after=7")); + let subscribed = subscriptions.uris.lock().unwrap().clone(); + assert_eq!( + updated_uris("runtimeEventsAppended", &subscribed), + vec!["alera://events?after=42", "alera://events?after=7"] + ); + subscriptions.unsubscribe("alera://events?after=42"); + let subscribed = subscriptions.uris.lock().unwrap().clone(); + assert_eq!( + updated_uris("runtimeEventsAppended", &subscribed), + vec!["alera://events?after=7"] + ); + } +} diff --git a/rust/alera-cli/src/mcp_tools/tests.rs b/rust/alera-cli/src/mcp_tools/tests.rs index d12ba9d37..5d6ba0ed7 100644 --- a/rust/alera-cli/src/mcp_tools/tests.rs +++ b/rust/alera-cli/src/mcp_tools/tests.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use serde_json::{json, Value}; -use super::{catalog, catalog_json, find_tool, run_tool, ToolAccess, ToolExecution}; +use super::{catalog, catalog_json, find_tool, run_tool, CallOrigin, ToolAccess, ToolExecution}; fn invocation_args(tool: &str, arguments: Value) -> (Vec, Option) { let tool = find_tool(tool).expect("tool exists"); @@ -23,7 +23,7 @@ fn tool_names_are_unique_short_and_snake_case() { .name .bytes() .all(|byte| byte.is_ascii_lowercase() || byte == b'_')); - let schema = (tool.input_schema)(); + let schema = tool.schema(); assert_eq!(schema["type"], "object"); assert_eq!(schema["additionalProperties"], false); assert!( @@ -89,6 +89,20 @@ fn profiles_are_addressed_by_id_or_name() { assert!(by_name.contains(&"--profile-name=Codex Sol".to_owned())); } +#[test] +fn pull_request_details_answer_within_the_deadline_and_resume_by_retry_key() { + let tool = find_tool("generate_pull_request_details").unwrap(); + assert!(tool.timeout_seconds <= super::MAX_WAIT_SECONDS + 8); + let arguments = json!({ "workspaceId": "ws", "baseBranch": "main" }); + let (args, _) = invocation_args("generate_pull_request_details", arguments.clone()); + assert!(args.contains(&"--wait-seconds=45".to_owned())); + assert!(!args.iter().any(|arg| arg.starts_with("--operation-id"))); + let mut resumed = arguments; + resumed["clientRequestId"] = json!("cli-0001-retry"); + let (args, _) = invocation_args("generate_pull_request_details", resumed); + assert!(args.contains(&"--operation-id=cli-0001-retry".to_owned())); +} + #[test] fn waits_are_clamped_below_the_client_deadline() { let tool = find_tool("wait_for_task").unwrap(); @@ -142,9 +156,14 @@ async fn reports_invalid_arguments_without_spawning() { runtime_dir: PathBuf::from("/nonexistent"), }; let tool = find_tool("show_task").unwrap(); - let result = run_tool(&execution, &tool, &json!({}), None).await; + let result = run_tool(&execution, &tool, &json!({}), &CallOrigin::default(), None).await; assert!(result.is_error); assert!(result.text.contains("taskId")); + let mcp = result.to_mcp(); + assert_eq!( + mcp["structuredContent"]["error"]["code"], + "invalid_argument" + ); } /// The edge serves a copy of the catalog. Set `ALERA_UPDATE_MCP_CATALOG=1` to diff --git a/rust/alera-cli/src/mcp_tools/tests_catalog.rs b/rust/alera-cli/src/mcp_tools/tests_catalog.rs new file mode 100644 index 000000000..6526cf914 --- /dev/null +++ b/rust/alera-cli/src/mcp_tools/tests_catalog.rs @@ -0,0 +1,258 @@ +//! Catalog-wide guarantees: every tool builds a command the real CLI parser +//! accepts, nothing reaches an excluded command, and the access classes match +//! the decisions in `docs/mcp-parity-implementation-plan.md`. + +use clap::Parser; +use serde_json::{json, Map, Value}; + +use super::{catalog, ToolAccess, ToolSpec, CLIENT_REQUEST_ID}; +use crate::cli::Cli; + +/// Commands no MCP tool may run: the security control plane, device pairing, +/// SSH credentials and sidecar installs, the runtime's own lifecycle, and the +/// decisions that only a person makes in the Alera app. +const EXCLUDED: &[(&str, Option<&str>)] = &[ + ("account", None), + ("mcp", None), + ("mobile", None), + ("runtime-host", None), + ("terminal-host", None), + ("runtime-proxy", None), + ("ssh-target", Some("add")), + ("ssh-target", Some("remove")), + ("ssh-target", Some("bootstrap")), + ("ssh-target", Some("bootstrap-plan")), + ("ssh-target", Some("bootstrap-cancel")), + ("ssh-target", Some("link")), + ("runtime", Some("start")), + ("runtime", Some("stop")), + ("runtime", Some("clear")), + ("runtime", Some("rename")), + ("orchestration", Some("gate-resolve")), + ("orchestration", Some("run-policy-approve")), + ("orchestration", Some("run-policy-reject")), +]; + +/// Tools that need the `admin` class (F1 option B and F2). +const ADMIN_TOOLS: &[&str] = &[ + "install_agent_skills", + "update_runtime_settings", + "set_agent_integrations", + "consume_codex_reset_credit", + "list_webhooks", + "create_webhook", + "delete_webhook", + "test_webhook", + "create_agent_profile", + "update_agent_profile", + "remove_agent_profile", + "reorder_agent_profiles", + "set_default_agent_profile", + "reset_orchestration", + "recover_task", + "transfer_coordinator", + "prune_terminals", + "apply_workflow_cleanup", + "retry_workflow_cleanup", + "abandon_workflow_cleanup", + "register_workspace_record", + "unregister_workspace_record", + "purge_inbox", +]; + +/// Tools the user decided stay below `admin` (F1). +const FULL_TOOLS: &[&str] = &[ + "remove_workspace", + "remove_project", + "merge_pull_request", + "merge_pull_request_stack", + "purge_automations", + "create_automation", + "update_automation", +]; + +/// Properties whose CLI flag parses a specific format. +const FORMATTED_SAMPLES: &[(&str, &str)] = &[("expiresIn", "30m"), ("numbers", "12,13")]; + +/// Arguments for a tool: every property (`all`) or only the required ones, +/// each filled with a valid placeholder. +fn sample_arguments(tool: &ToolSpec, all: bool) -> Value { + let schema = tool.schema(); + let required = schema["required"].as_array().cloned().unwrap_or_default(); + let mut arguments = Map::new(); + for (name, property) in schema["properties"].as_object().into_iter().flatten() { + if !all && !required.iter().any(|value| value == name) { + continue; + } + let value = match property["type"].as_str() { + Some("integer") => json!(property["minimum"].as_u64().unwrap_or(1).max(1)), + // Some values require their flag, such as branch with worktree. + Some("boolean") => json!(true), + Some("array") => json!([property["items"]["enum"][0].as_str().unwrap_or("item")]), + Some("object") => json!({}), + _ if name == CLIENT_REQUEST_ID => json!("request-0001"), + _ => match FORMATTED_SAMPLES.iter().find(|(key, _)| key == name) { + Some((_, sample)) => json!(sample), + None => match property["enum"].as_array() { + Some(values) => values[0].clone(), + None => json!("sample"), + }, + }, + }; + arguments.insert(name.clone(), value); + } + Value::Object(arguments) +} + +fn cli_argv(invocation: super::Invocation) -> Vec { + let mut argv = vec![ + "alera".to_owned(), + invocation.group.to_owned(), + "--runtime-dir=/nonexistent".to_owned(), + "--json".to_owned(), + ]; + argv.extend(invocation.args); + argv +} + +/// Sample argument sets for a tool: every property, only the required ones, +/// and every property but one, which covers properties that exclude each +/// other, such as a path or a clone URL. +fn sample_variants(tool: &ToolSpec) -> Vec { + let all = sample_arguments(tool, true); + let required = tool.schema()["required"] + .as_array() + .cloned() + .unwrap_or_default(); + let mut variants = vec![all.clone(), sample_arguments(tool, false)]; + for key in all.as_object().into_iter().flatten().map(|(key, _)| key) { + if required.iter().any(|value| value == key) { + continue; + } + let mut variant = all.clone(); + variant.as_object_mut().unwrap().remove(key); + variants.push(variant); + } + variants +} + +/// A tool may refuse a combination of arguments itself, but any command it +/// does build must parse: the CLI never sees an argument list it rejects. +#[test] +fn every_tool_builds_a_command_the_cli_accepts() { + for tool in catalog() { + let mut built = 0; + for arguments in sample_variants(&tool) { + let Ok(invocation) = tool.invocation(&arguments) else { + continue; + }; + built += 1; + let argv = cli_argv(invocation); + if let Err(error) = Cli::try_parse_from(&argv) { + panic!( + "{} builds {:?}, which the CLI rejects: {error}", + tool.name, argv + ); + } + } + assert!( + built > 0, + "{} builds no command from its samples", + tool.name + ); + } +} + +#[test] +fn no_tool_reaches_an_excluded_command() { + for tool in catalog() { + for arguments in sample_variants(&tool) { + let Ok(invocation) = tool.invocation(&arguments) else { + continue; + }; + // The subcommand path is every word before the first flag, so a + // nested action (`ssh-target link`) is caught wherever it sits. + let path: Vec<&str> = invocation + .args + .iter() + .map(String::as_str) + .take_while(|word| !word.starts_with('-')) + .collect(); + for (group, excluded_action) in EXCLUDED { + let hit = invocation.group == *group + && excluded_action.is_none_or(|excluded| path.contains(&excluded)); + assert!( + !hit, + "{} runs the excluded command {group} {path:?}", + tool.name + ); + } + } + } +} + +#[test] +fn access_classes_follow_the_plan() { + for tool in catalog() { + if ADMIN_TOOLS.contains(&tool.name) { + assert_eq!( + tool.access, + ToolAccess::Admin, + "{} must be admin", + tool.name + ); + } else { + assert_ne!( + tool.access, + ToolAccess::Admin, + "{} must not be admin", + tool.name + ); + } + if FULL_TOOLS.contains(&tool.name) { + assert_eq!( + tool.access, + ToolAccess::Execute, + "{} must be full", + tool.name + ); + } + } +} + +#[test] +fn client_request_ids_reach_the_cli() { + for tool in catalog() + .into_iter() + .filter(|tool| tool.client_request_flag.is_some()) + { + let invocation = sample_variants(&tool) + .into_iter() + .find_map(|mut arguments| { + arguments[CLIENT_REQUEST_ID] = json!("request-0001"); + tool.invocation(&arguments).ok() + }) + .unwrap(); + let flag = tool.client_request_flag.unwrap(); + assert!( + invocation.args.contains(&format!("{flag}=request-0001")), + "{} drops clientRequestId", + tool.name + ); + } +} + +#[test] +fn reading_tools_are_marked_idempotent() { + for tool in catalog() + .into_iter() + .filter(|tool| tool.access == ToolAccess::Read) + { + assert_eq!( + tool.to_json()["annotations"]["idempotentHint"], + true, + "{}", + tool.name + ); + } +} diff --git a/rust/alera-cli/src/orchestration_commands.rs b/rust/alera-cli/src/orchestration_commands.rs index dd76fd784..c1e680c4a 100644 --- a/rust/alera-cli/src/orchestration_commands.rs +++ b/rust/alera-cli/src/orchestration_commands.rs @@ -54,10 +54,26 @@ pub async fn run_orchestration_command(command: OrchestrationCommand) -> i32 { OrchestrationAction::Workspaces(args) => { crate::workflow_workspace_commands::run(&runtime, args).await } + OrchestrationAction::Proposals(args) => { + crate::workflow_plan_commands::run_proposals(&runtime, args.action).await + } + OrchestrationAction::Execution(args) => { + crate::workflow_plan_commands::run_execution(&runtime, args.action).await + } + OrchestrationAction::Cleanup(args) => { + crate::workflow_plan_commands::run_cleanup(&runtime, args.action).await + } OrchestrationAction::Recipes(args) => { crate::workflow_recipe_commands::run_workflow_recipes(&runtime, args, json_output).await } OrchestrationAction::AgentSpawn(args) => run_agent_spawn(&runtime, args, json_output).await, + OrchestrationAction::Board(args) => board::board(&runtime, args, json_output).await, + OrchestrationAction::RunSnapshot(args) => { + board::run_snapshot(&runtime, args, json_output).await + } + OrchestrationAction::TaskInspect(args) => { + board::task_inspect(&runtime, args, json_output).await + } OrchestrationAction::Delegate(args) => { crate::orchestration_delegate::run(&runtime, args, json_output).await } @@ -96,6 +112,10 @@ pub async fn run_orchestration_command(command: OrchestrationCommand) -> i32 { } OrchestrationAction::Ask(args) => run_ask(&runtime, args, json_output).await, OrchestrationAction::TaskCreate(args) => { + let spec = match args.spec.read() { + Ok(spec) => spec, + Err(error) => return usage_error(&error.to_string()), + }; let contract_fields = match crate::orchestration_contract_commands::contract_fields( args.role_contract.as_deref(), args.contract_inputs.as_deref(), @@ -126,7 +146,7 @@ pub async fn run_orchestration_command(command: OrchestrationCommand) -> i32 { }, crate::orchestration_contract_commands::with_contract_fields( json!({ - "spec": args.spec, + "spec": spec, "taskTitle": args.task_title, "deps": deps, "parent": args.parent, @@ -549,6 +569,10 @@ pub async fn run_orchestration_command(command: OrchestrationCommand) -> i32 { .await } OrchestrationAction::Run(args) => { + let spec = match args.spec.read() { + Ok(spec) => spec, + Err(error) => return usage_error(&error.to_string()), + }; let Some(from) = args.from.or_else(terminal_handle_env) else { return usage_error( "--from is required (or set ALERA_TERMINAL_HANDLE) so workers can contact the coordinator.", @@ -563,7 +587,7 @@ pub async fn run_orchestration_command(command: OrchestrationCommand) -> i32 { &runtime, "orchestration.run", json!({ - "spec": args.spec, + "spec": spec, "from": from, "pollIntervalMs": args.poll_interval_ms, "maxConcurrent": args.max_concurrent, @@ -1106,151 +1130,6 @@ pub(crate) fn usage_error(message: &str) -> i32 { USAGE_EXIT_CODE } +mod board; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn structured_payload_assembles_json() { - let payload = build_structured_payload( - None, - Some("task_1".to_string()), - Some("ctx_1".to_string()), - Some("a.rs, b.rs".to_string()), - Some("/tmp/report.md".to_string()), - Some("implementing".to_string()), - ) - .unwrap() - .unwrap(); - let value: Value = serde_json::from_str(&payload).unwrap(); - assert_eq!(value["taskId"], "task_1"); - assert_eq!(value["dispatchId"], "ctx_1"); - assert_eq!(value["filesModified"], json!(["a.rs", "b.rs"])); - assert_eq!(value["reportPath"], "/tmp/report.md"); - assert_eq!(value["phase"], "implementing"); - } - - #[test] - fn raw_payload_must_be_valid_json() { - assert!(build_structured_payload( - Some("{not json".to_string()), - None, - None, - None, - None, - None - ) - .is_err()); - let passthrough = - build_structured_payload(Some("{\"x\":1}".to_string()), None, None, None, None, None) - .unwrap(); - assert_eq!(passthrough.as_deref(), Some("{\"x\":1}")); - } - - #[test] - fn empty_structured_payload_is_none() { - let payload = build_structured_payload(None, None, None, None, None, None).unwrap(); - assert!(payload.is_none()); - } - - #[test] - fn dispatch_summary_prints_dry_run_preamble() { - let summary = human_summary( - "orchestration.dispatch", - &json!({ - "dryRun": true, - "preamble": "handoff text to paste" - }), - ); - assert_eq!(summary, "handoff text to paste"); - } - - #[test] - fn dispatch_requires_override_capability_only_when_assuming_an_agent() { - assert_eq!( - dispatch_required_capability(None), - RUNTIME_HOST_ORCHESTRATION_CAPABILITY - ); - assert_eq!( - dispatch_required_capability(Some("codex")), - RUNTIME_HOST_ORCHESTRATION_ASSUME_AGENT_CAPABILITY - ); - } - - #[test] - fn dispatch_summary_prints_returned_preamble() { - let summary = human_summary( - "orchestration.dispatch", - &json!({ - "dispatch": { - "id": "ctx_1" - }, - "preamble": "manual injection text" - }), - ); - assert_eq!(summary, "manual injection text"); - } - - #[test] - fn dispatch_summary_labels_dry_run_without_preamble() { - let summary = human_summary("orchestration.dispatch", &json!({ "dryRun": true })); - assert_eq!(summary, "dispatch dry run"); - } - - #[test] - fn default_check_summary_prints_message_contents() { - let summary = human_summary( - "orchestration.check", - &json!({ - "messages": [{ - "id": "msg_1", - "from_handle": "coord", - "to_handle": "worker", - "subject": "Follow up", - "body": "Please rerun tests.", - "type": "status", - "priority": "normal", - "thread_id": null, - "payload": "{\"taskId\":\"task_1\"}", - "read": false, - "sequence": 1, - "created_at": "2026-07-05 19:00:00", - "delivered_at": null - }] - }), - ); - assert!(summary.contains("From: coord (status)")); - assert!(summary.contains("Subject: Follow up")); - assert!(summary.contains("Please rerun tests.")); - assert!(summary.contains("alera orchestration reply --id msg_1")); - } - - #[test] - fn terminal_startup_failure_is_detected_without_waiting_for_timeout() { - assert_eq!( - terminal_startup_error(&json!({ - "startupState": "failed", - "startupError": "agent exited" - })), - Some("agent exited") - ); - assert_eq!( - terminal_startup_error(&json!({"startupState": "process_started"})), - None - ); - } - - #[test] - fn result_extra_adds_schema_defined_completion_fields() { - let mut result = json!({ - "summary": "done", - "completionKind": "success", - "artifacts": [], - "filesModified": [], - "validation": [] - }); - merge_result_extra(&mut result, Some(r#"{"ticket":42}"#)).unwrap(); - assert_eq!(result["ticket"], json!(42)); - assert!(merge_result_extra(&mut result, Some("[]")).is_err()); - } -} +mod tests; diff --git a/rust/alera-cli/src/orchestration_commands/board.rs b/rust/alera-cli/src/orchestration_commands/board.rs new file mode 100644 index 000000000..314c2c819 --- /dev/null +++ b/rust/alera-cli/src/orchestration_commands/board.rs @@ -0,0 +1,109 @@ +//! Read-only views of the run board: `board`, `run-snapshot`, and +//! `task-inspect`, over the host's paginated board reads. + +use serde_json::{json, Map, Value}; + +use crate::cli::RuntimeDirArgs; +use crate::cli_orchestration::{ + OrchestrationBoardArgs, OrchestrationRunSnapshotArgs, OrchestrationTaskInspectArgs, +}; +use crate::terminal_host::protocol::RUNTIME_HOST_ORCHESTRATION_BOARD_CAPABILITY; + +use super::{request_with_capability, usage_error}; + +pub(super) async fn board( + runtime: &RuntimeDirArgs, + args: OrchestrationBoardArgs, + json: bool, +) -> i32 { + let cursor = match parse_cursor(args.cursor.as_deref()) { + Ok(cursor) => cursor, + Err(message) => return usage_error(&message), + }; + let mut payload = Map::new(); + insert(&mut payload, "project_id", args.project_id); + insert(&mut payload, "workspace_id", args.workspace); + insert(&mut payload, "search", args.search); + insert(&mut payload, "bucket", args.bucket); + insert(&mut payload, "cursor", cursor); + insert(&mut payload, "limit", args.limit); + read(runtime, "orchestration.boardSnapshot", payload, json).await +} + +pub(super) async fn run_snapshot( + runtime: &RuntimeDirArgs, + args: OrchestrationRunSnapshotArgs, + json: bool, +) -> i32 { + let mut payload = Map::new(); + payload.insert("run_id".into(), json!(args.run)); + insert(&mut payload, "after_task_id", args.after_task); + insert(&mut payload, "revision", args.revision); + insert(&mut payload, "limit", args.limit); + read(runtime, "orchestration.runSnapshot", payload, json).await +} + +pub(super) async fn task_inspect( + runtime: &RuntimeDirArgs, + args: OrchestrationTaskInspectArgs, + json: bool, +) -> i32 { + let cursor = match parse_cursor(args.cursor.as_deref()) { + Ok(cursor) => cursor, + Err(message) => return usage_error(&message), + }; + let mut payload = Map::new(); + payload.insert("run_id".into(), json!(args.run)); + payload.insert("task_id".into(), json!(args.task)); + insert(&mut payload, "cursor", cursor); + insert(&mut payload, "limit", args.limit); + read(runtime, "orchestration.taskInspection", payload, json).await +} + +async fn read( + runtime: &RuntimeDirArgs, + verb: &str, + payload: Map, + json: bool, +) -> i32 { + request_with_capability( + runtime, + RUNTIME_HOST_ORCHESTRATION_BOARD_CAPABILITY, + verb, + Value::Object(payload), + json, + None, + ) + .await +} + +fn insert>(payload: &mut Map, key: &str, value: Option) { + if let Some(value) = value { + payload.insert(key.to_string(), value.into()); + } +} + +/// A page cursor is the JSON object a previous page returned. +fn parse_cursor(raw: Option<&str>) -> Result, String> { + let Some(raw) = raw else { + return Ok(None); + }; + match serde_json::from_str::(raw) { + Ok(cursor @ Value::Object(_)) => Ok(Some(cursor)), + _ => Err("--cursor must be the JSON object a previous page returned.".to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::parse_cursor; + + #[test] + fn cursors_must_be_json_objects() { + assert_eq!(parse_cursor(None), Ok(None)); + let cursor = parse_cursor(Some(r#"{"id":"run_1","created_at":"t","revision":3}"#)).unwrap(); + assert_eq!(cursor.unwrap()["revision"], 3); + assert!(parse_cursor(Some("run_1")).is_err()); + assert!(parse_cursor(Some("[1]")).is_err()); + } +} diff --git a/rust/alera-cli/src/orchestration_commands/tests.rs b/rust/alera-cli/src/orchestration_commands/tests.rs new file mode 100644 index 000000000..d4b60c308 --- /dev/null +++ b/rust/alera-cli/src/orchestration_commands/tests.rs @@ -0,0 +1,140 @@ +use super::*; + +#[test] +fn structured_payload_assembles_json() { + let payload = build_structured_payload( + None, + Some("task_1".to_string()), + Some("ctx_1".to_string()), + Some("a.rs, b.rs".to_string()), + Some("/tmp/report.md".to_string()), + Some("implementing".to_string()), + ) + .unwrap() + .unwrap(); + let value: Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(value["taskId"], "task_1"); + assert_eq!(value["dispatchId"], "ctx_1"); + assert_eq!(value["filesModified"], json!(["a.rs", "b.rs"])); + assert_eq!(value["reportPath"], "/tmp/report.md"); + assert_eq!(value["phase"], "implementing"); +} + +#[test] +fn raw_payload_must_be_valid_json() { + assert!( + build_structured_payload(Some("{not json".to_string()), None, None, None, None, None) + .is_err() + ); + let passthrough = + build_structured_payload(Some("{\"x\":1}".to_string()), None, None, None, None, None) + .unwrap(); + assert_eq!(passthrough.as_deref(), Some("{\"x\":1}")); +} + +#[test] +fn empty_structured_payload_is_none() { + let payload = build_structured_payload(None, None, None, None, None, None).unwrap(); + assert!(payload.is_none()); +} + +#[test] +fn dispatch_summary_prints_dry_run_preamble() { + let summary = human_summary( + "orchestration.dispatch", + &json!({ + "dryRun": true, + "preamble": "handoff text to paste" + }), + ); + assert_eq!(summary, "handoff text to paste"); +} + +#[test] +fn dispatch_requires_override_capability_only_when_assuming_an_agent() { + assert_eq!( + dispatch_required_capability(None), + RUNTIME_HOST_ORCHESTRATION_CAPABILITY + ); + assert_eq!( + dispatch_required_capability(Some("codex")), + RUNTIME_HOST_ORCHESTRATION_ASSUME_AGENT_CAPABILITY + ); +} + +#[test] +fn dispatch_summary_prints_returned_preamble() { + let summary = human_summary( + "orchestration.dispatch", + &json!({ + "dispatch": { + "id": "ctx_1" + }, + "preamble": "manual injection text" + }), + ); + assert_eq!(summary, "manual injection text"); +} + +#[test] +fn dispatch_summary_labels_dry_run_without_preamble() { + let summary = human_summary("orchestration.dispatch", &json!({ "dryRun": true })); + assert_eq!(summary, "dispatch dry run"); +} + +#[test] +fn default_check_summary_prints_message_contents() { + let summary = human_summary( + "orchestration.check", + &json!({ + "messages": [{ + "id": "msg_1", + "from_handle": "coord", + "to_handle": "worker", + "subject": "Follow up", + "body": "Please rerun tests.", + "type": "status", + "priority": "normal", + "thread_id": null, + "payload": "{\"taskId\":\"task_1\"}", + "read": false, + "sequence": 1, + "created_at": "2026-07-05 19:00:00", + "delivered_at": null + }] + }), + ); + assert!(summary.contains("From: coord (status)")); + assert!(summary.contains("Subject: Follow up")); + assert!(summary.contains("Please rerun tests.")); + assert!(summary.contains("alera orchestration reply --id msg_1")); +} + +#[test] +fn terminal_startup_failure_is_detected_without_waiting_for_timeout() { + assert_eq!( + terminal_startup_error(&json!({ + "startupState": "failed", + "startupError": "agent exited" + })), + Some("agent exited") + ); + assert_eq!( + terminal_startup_error(&json!({"startupState": "process_started"})), + None + ); +} + +#[test] +fn result_extra_adds_schema_defined_completion_fields() { + let mut result = json!({ + "summary": "done", + "completionKind": "success", + "artifacts": [], + "filesModified": [], + "validation": [] + }); + merge_result_extra(&mut result, Some(r#"{"ticket":42}"#)).unwrap(); + assert_eq!(result["ticket"], json!(42)); + assert!(merge_result_extra(&mut result, Some("[]")).is_err()); +} diff --git a/rust/alera-cli/src/project_manage_commands.rs b/rust/alera-cli/src/project_manage_commands.rs new file mode 100644 index 000000000..ece578b80 --- /dev/null +++ b/rust/alera-cli/src/project_manage_commands.rs @@ -0,0 +1,256 @@ +//! `alera project rename | clone | remove-preview | branches | config`: thin +//! clients of the host verbs the app's project dialogs use. + +use std::io::Read as _; + +use anyhow::{anyhow, bail, Result}; +use serde_json::{json, Map, Value}; + +use crate::cli::{ + ProjectAction, ProjectCloneAction, ProjectConfigAction, ProjectConfigSetArgs, RuntimeDirArgs, +}; +use crate::runtime_host_client::RuntimeHostRpcClient; + +/// A config is a handful of commands and copy rules, like the host's cap on a +/// remote `alera.toml`. +const MAX_CONFIG_BYTES: usize = 64 * 1024; +/// Listing branches on an SSH host runs a command there. +const BRANCHES_DEADLINE_MS: u64 = 60_000; + +pub(super) async fn run(runtime: &RuntimeDirArgs, action: ProjectAction, json_output: bool) -> i32 { + let result = async { + let mut client = crate::runtime_host_required(runtime).await?; + execute(&mut client, action).await + } + .await; + match result { + Ok((value, message)) => { + crate::print_value(&value, json_output, &message); + 0 + } + Err(error) => crate::print_error(error), + } +} + +pub(crate) async fn execute( + client: &mut RuntimeHostRpcClient, + action: ProjectAction, +) -> Result<(Value, String)> { + Ok(match action { + ProjectAction::Rename(args) => ( + client + .request_value( + "project.rename", + &json!({"id": args.id, "name": args.name.trim()}), + ) + .await?, + "project renamed".into(), + ), + ProjectAction::Clone(command) => clone(client, command.action).await?, + ProjectAction::RemovePreview(args) => { + let mut preview = client + .request_value("project.remove.preview", &json!({"id": args.id})) + .await?; + preview["automations"] = client + .request_value("project.removalDependencies", &json!({"id": args.id})) + .await?; + // Removing a project never deletes files: its folder and every + // worktree stay on disk. + preview["filesDeleted"] = json!(false); + (preview, "project removal previewed".into()) + } + ProjectAction::Branches(args) => { + let mut catalog = client + .request_value_with_deadline( + "project.branches.list", + &json!({"projectId": args.project_id, "hostId": args.host_id}), + BRANCHES_DEADLINE_MS, + ) + .await?; + let config = client + .request_value( + "projectConfig.effective", + &json!({"projectId": args.project_id}), + ) + .await + .unwrap_or(Value::Null); + catalog["preferredSourceBranch"] = config["config"]["newWorkspace"]["sourceBranch"] + .as_str() + .filter(|branch| !branch.trim().is_empty()) + .map_or(Value::Null, |branch| json!(branch)); + (catalog, "project branches listed".into()) + } + ProjectAction::Config(command) => match command.action { + ProjectConfigAction::Show(args) => ( + show_config(client, &args.project_id).await?, + "project settings".into(), + ), + ProjectConfigAction::Set(args) => ( + set_config(client, args).await?, + "project settings saved".into(), + ), + ProjectConfigAction::Remove(args) => { + client + .request_value( + "projectConfig.remove", + &json!({"projectId": args.project_id}), + ) + .await?; + ( + show_config(client, &args.project_id).await?, + "project settings reset".into(), + ) + } + }, + _ => bail!("Unsupported project action"), + }) +} + +async fn clone( + client: &mut RuntimeHostRpcClient, + action: ProjectCloneAction, +) -> Result<(Value, String)> { + Ok(match action { + ProjectCloneAction::Start(args) => { + let directory_name = match args.directory_name { + Some(name) => name, + None => repository_name(&args.url)?, + }; + ( + client + .request_value( + "project.clone.start", + &json!({ + "url": args.url, "parentPath": args.parent_path, + "directoryName": directory_name, "name": args.name, + }), + ) + .await?, + "project clone started".into(), + ) + } + ProjectCloneAction::List => { + let jobs = client + .request_value("project.clone.list", &json!({})) + .await?; + ( + json!({"kind": "projectClones", "items": jobs}), + "project clones listed".into(), + ) + } + ProjectCloneAction::Show(args) => { + let jobs = client + .request_value("project.clone.list", &json!({})) + .await?; + let job = jobs + .as_array() + .into_iter() + .flatten() + .find(|job| job["id"] == args.id.as_str()) + .cloned() + .ok_or_else(|| anyhow!("Clone job not found: {}", args.id))?; + (job, "project clone".into()) + } + ProjectCloneAction::Cancel(args) => ( + client + .request_value("project.clone.cancel", &json!({"id": args.id})) + .await?, + "project clone cancelling".into(), + ), + }) +} + +/// The folder `git clone` would pick: the last path segment without `.git`. +pub(crate) fn repository_name(url: &str) -> Result { + let trimmed = url.trim().trim_end_matches('/'); + let last = trimmed.rsplit(['/', ':', '\\']).next().unwrap_or_default(); + let name = last.strip_suffix(".git").unwrap_or(last).trim(); + if name.is_empty() || name == "." || name == ".." { + bail!("Could not derive a folder name from {url}; pass --directory-name."); + } + Ok(name.to_string()) +} + +async fn show_config(client: &mut RuntimeHostRpcClient, project_id: &str) -> Result { + let mut effective = client + .request_value("projectConfig.effective", &json!({"projectId": project_id})) + .await?; + let override_config = client + .request_value("projectConfig.find", &json!({"projectId": project_id})) + .await?; + effective["projectId"] = json!(project_id); + effective["hasOverride"] = json!(!override_config.is_null()); + Ok(effective) +} + +async fn set_config( + client: &mut RuntimeHostRpcClient, + args: ProjectConfigSetArgs, +) -> Result { + let text = match args.config { + Some(text) => text, + None => { + let mut text = String::new(); + std::io::stdin() + .take(MAX_CONFIG_BYTES as u64 + 1) + .read_to_string(&mut text)?; + text + } + }; + let changes = parse_config_changes(&text)?; + let current = client + .request_value( + "projectConfig.effective", + &json!({"projectId": args.project_id}), + ) + .await?; + let config = merge_config(current["config"].clone(), changes); + client + .request_value( + "projectConfig.upsert", + &json!({"projectId": args.project_id, "config": config}), + ) + .await?; + show_config(client, &args.project_id).await +} + +pub(crate) fn parse_config_changes(text: &str) -> Result> { + if text.len() > MAX_CONFIG_BYTES { + bail!("Project settings must be at most {MAX_CONFIG_BYTES} bytes."); + } + let Value::Object(changes) = serde_json::from_str(text)? else { + bail!("Project settings must be a JSON object."); + }; + if let Some(key) = changes.keys().find(|key| { + !matches!( + key.as_str(), + "worktree" | "newWorkspace" | "gitHostingProvider" + ) + }) { + bail!( + "Unknown project setting `{key}`: use worktree, newWorkspace, or gitHostingProvider." + ); + } + Ok(changes) +} + +/// Each section given replaces that section; the others keep their current +/// value, which is what editing one tab of the app's settings dialog does. +pub(crate) fn merge_config(current: Value, changes: Map) -> Value { + let mut config = match current { + Value::Object(config) => config, + _ => Map::new(), + }; + for (key, value) in changes { + if value.is_null() || value == "auto" && key == "gitHostingProvider" { + config.remove(&key); + } else { + config.insert(key, value); + } + } + Value::Object(config) +} + +#[cfg(test)] +#[path = "project_manage_commands_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/project_manage_commands_tests.rs b/rust/alera-cli/src/project_manage_commands_tests.rs new file mode 100644 index 000000000..5dfe03920 --- /dev/null +++ b/rust/alera-cli/src/project_manage_commands_tests.rs @@ -0,0 +1,47 @@ +use serde_json::json; + +use super::*; + +#[test] +fn clone_folders_follow_git_clone_naming() { + for (url, name) in [ + ("https://github.com/acme/widgets.git", "widgets"), + ("https://github.com/acme/widgets/", "widgets"), + ("git@github.com:acme/widgets.git", "widgets"), + ("/srv/repos/tools", "tools"), + ] { + assert_eq!(repository_name(url).unwrap(), name, "{url}"); + } + for url in ["", " ", "/", "https://example.com/.git"] { + assert!(repository_name(url).is_err(), "{url}"); + } +} + +#[test] +fn settings_changes_replace_only_their_sections() { + let current = json!({ + "worktree": {"copy": [{"from": ".env", "overwrite": false}], "setup": ["npm ci"]}, + "newWorkspace": {"promptAppend": "Be brief", "sourceBranch": "main"}, + "gitHostingProvider": "github", + }); + let changes = parse_config_changes( + r#"{"newWorkspace": {"promptAppend": "", "sourceBranch": "develop"}, "gitHostingProvider": "auto"}"#, + ) + .unwrap(); + let merged = merge_config(current, changes); + assert_eq!(merged["worktree"]["setup"], json!(["npm ci"])); + assert_eq!(merged["newWorkspace"]["sourceBranch"], "develop"); + assert!(merged.get("gitHostingProvider").is_none()); + let config: alera_core::runtime::ProjectConfig = serde_json::from_value(merged).unwrap(); + assert_eq!(config.new_workspace.source_branch, "develop"); +} + +#[test] +fn settings_reject_unknown_keys_and_non_objects() { + assert!(parse_config_changes(r#"{"theme": "dark"}"#) + .unwrap_err() + .to_string() + .contains("Unknown project setting `theme`")); + assert!(parse_config_changes("[]").is_err()); + assert!(parse_config_changes(&"x".repeat(MAX_CONFIG_BYTES + 1)).is_err()); +} diff --git a/rust/alera-cli/src/pull_request_commands.rs b/rust/alera-cli/src/pull_request_commands.rs new file mode 100644 index 000000000..f24bd38d1 --- /dev/null +++ b/rust/alera-cli/src/pull_request_commands.rs @@ -0,0 +1,459 @@ +//! `alera pr`: thin JSON clients of the runtime's pull request verbs +//! (`mobile.pullRequest.*`, `aiText.pullRequestDetails.generate`, +//! `pullRequest.agentDispatch`, `pullRequestStack.*`, `pullRequestWatch.start`), +//! so the CLI and MCP see exactly what the phone and desktop see on GitHub, +//! GitLab, and Azure DevOps. + +use std::io::Read; + +use anyhow::{anyhow, bail, Result}; +use serde_json::{json, Value}; + +use crate::cli::{ + PrAgentTargetArgs, PrBodyArgs, PrDispatchArgs, PrGenerateDetailsArgs, PrNumberArgs, PrShipArgs, + PrTargetArgs, PrWatchMode, PullRequestAction, PullRequestCommand, RuntimeDirArgs, +}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::{ + RUNTIME_HOST_PULL_REQUEST_AGENT_DISPATCH_CAPABILITY, + RUNTIME_HOST_PULL_REQUEST_FORGES_CAPABILITY, +}; +use crate::workspace_context::resolve_requested_workspace_id; +use crate::{print_error, print_value, runtime_dir}; + +#[path = "pull_request_stack_commands.rs"] +mod stack; + +/// The host never waits longer than this, which outlasts AI Assist's longest +/// timeout, so the default still waits for the result. +const DETAILS_MAX_WAIT_SECONDS: u64 = 900; + +pub async fn run(command: PullRequestCommand) -> i32 { + let json_output = command.output.json; + match execute(&command.runtime, command.action).await { + Ok((value, message)) => { + print_value(&value, json_output, &message); + 0 + } + Err(error) => print_error(error), + } +} + +async fn execute(runtime: &RuntimeDirArgs, action: PullRequestAction) -> Result<(Value, String)> { + match action { + PullRequestAction::Show(target) => { + let mut session = Session::open(runtime, &target).await?; + let snapshot = session.snapshot().await?; + let message = review_line(&snapshot); + Ok((snapshot, message)) + } + PullRequestAction::Summaries(target) => { + let mut client = forge_client(runtime).await?; + let mut value = client + .request_value("mobile.pullRequest.summaries", &json!({})) + .await?; + if let Some(id) = target.workspace_id.as_deref() { + if let Some(items) = value["summaries"].as_array_mut() { + items.retain(|item| item["workspaceId"] == id); + } + } + let count = value["summaries"].as_array().map_or(0, Vec::len); + Ok((value, format!("{count} pull request summary(ies)"))) + } + PullRequestAction::GenerateDetails(args) => generate_details(runtime, args).await, + PullRequestAction::Create(args) => { + let mut session = Session::open(runtime, &args.base.target).await?; + let payload = json!({ + "baseBranch": args.base.base.trim(), + "title": args.title, + "body": read_body(&args.body, false)?, + "draft": args.draft, + }); + session.write("mobile.pullRequest.create", payload).await + } + PullRequestAction::Link(args) => { + let mut session = Session::open(runtime, &args.target).await?; + session + .write( + "mobile.pullRequest.link", + json!({ "reference": args.reference }), + ) + .await + } + PullRequestAction::Unlink(args) => { + let mut session = Session::open(runtime, &args.target).await?; + let snapshot = session.snapshot().await?; + let number = args + .number + .or_else(|| snapshot["review"]["number"].as_i64()) + .or_else(|| snapshot["linkedReview"]["number"].as_i64()) + .ok_or_else(no_review)?; + let url = snapshot["review"]["url"].clone(); + session + .write( + "mobile.pullRequest.unlink", + json!({ "number": number, "url": url }), + ) + .await + } + PullRequestAction::Comment(args) => { + let mut session = Session::open(runtime, &args.number.target).await?; + let number = session.number(&args.number).await?; + let mut payload = json!({ "number": number, "body": read_body(&args.body, true)? }); + if let Some(reply_to) = args.reply_to { + payload["replyToCommentId"] = json!(reply_to); + payload["replyToThreadId"] = json!(args.thread_id); + } + session.write("mobile.pullRequest.comment", payload).await + } + PullRequestAction::CommentEdit(args) => { + let mut session = Session::open(runtime, &args.number.target).await?; + let number = session.number(&args.number).await?; + let payload = json!({ + "number": number, + "commentId": args.comment_id, + "source": args.source.wire(), + "threadId": args.thread_id, + "body": read_body(&args.body, true)?, + }); + session + .write("mobile.pullRequest.commentUpdate", payload) + .await + } + PullRequestAction::Draft(args) => { + let mut session = Session::open(runtime, &args.number.target).await?; + let number = session.number(&args.number).await?; + let payload = json!({ "number": number, "draft": !args.ready }); + session + .write("mobile.pullRequest.draftStatus", payload) + .await + } + PullRequestAction::Close(args) => { + let mut session = Session::open(runtime, &args.target).await?; + let number = session.number(&args).await?; + session + .write("mobile.pullRequest.close", json!({ "number": number })) + .await + } + PullRequestAction::Merge(args) => { + let mut session = Session::open(runtime, &args.number.target).await?; + let snapshot = session.snapshot().await?; + let number = args + .number + .number + .or_else(|| snapshot["review"]["number"].as_i64()) + .ok_or_else(no_review)?; + let method = match args.method { + Some(method) => method.wire().to_string(), + None => preferred_method(&snapshot)?, + }; + let payload = json!({ + "number": number, + "method": method, + "expectedHeadSha": args.expected_head, + }); + session.write("mobile.pullRequest.merge", payload).await + } + PullRequestAction::Ship(args) => ship(runtime, args).await, + PullRequestAction::Restack(args) => dispatch(runtime, "restack", args).await, + PullRequestAction::FixChecks(args) => dispatch(runtime, "fixFailedChecks", args).await, + PullRequestAction::Stack(command) => stack::run(runtime, command.action).await, + } +} + +/// `aiText.pullRequestDetails.generate` as a resumable job: the runtime keeps +/// generating after the wait ends, and the same `--operation-id` resumes it. +async fn generate_details( + runtime: &RuntimeDirArgs, + args: PrGenerateDetailsArgs, +) -> Result<(Value, String)> { + let session = Session::open(runtime, &args.base.target).await?; + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &runtime_dir(runtime), + crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_RESUME_CAPABILITY, + ) + .await?; + let operation_id = args + .operation_id + .map(|id| id.trim().to_owned()) + .filter(|id| !id.is_empty()) + .unwrap_or_else(|| format!("cli-{}", uuid::Uuid::new_v4())); + let wait_seconds = args.wait_seconds.unwrap_or(DETAILS_MAX_WAIT_SECONDS); + let value = client + .request_value( + "aiText.pullRequestDetails.generate", + &json!({ + "operationId": operation_id, + "workspaceId": session.workspace_id, + "baseBranch": args.base.base.trim(), + "waitMs": wait_seconds * 1000, + "origin": crate::mcp_tools::CallOrigin::from_env(), + }), + ) + .await?; + let message = if value["status"] == "running" { + format!("Still generating; run again with --operation-id {operation_id}") + } else { + value["title"].as_str().unwrap_or_default().to_string() + }; + Ok((value, message)) +} + +/// One workspace's pull request verbs over one runtime connection. +pub(crate) struct Session { + pub(crate) workspace_id: String, + pub(crate) client: RuntimeHostRpcClient, +} + +impl Session { + pub(crate) async fn open(runtime: &RuntimeDirArgs, target: &PrTargetArgs) -> Result { + let workspace_id = resolve_requested_workspace_id(runtime, target.workspace_id.as_deref()) + .await? + .ok_or_else(|| { + anyhow!("--workspace-id is required (or run inside an Alera terminal where ALERA_WORKSPACE_ID is set).") + })?; + Ok(Self { + workspace_id, + client: forge_client(runtime).await?, + }) + } + + pub(crate) async fn snapshot(&mut self) -> Result { + self.client + .request_value( + "mobile.pullRequest.snapshot", + &json!({ "workspaceId": self.workspace_id }), + ) + .await + } + + /// [args]'s number, or the review the snapshot shows. + pub(crate) async fn number(&mut self, args: &PrNumberArgs) -> Result { + if let Some(number) = args.number { + return Ok(number); + } + self.snapshot().await?["review"]["number"] + .as_i64() + .ok_or_else(no_review) + } + + /// Runs a write verb; the answer is the refreshed snapshot. + pub(crate) async fn write( + &mut self, + verb: &str, + mut payload: Value, + ) -> Result<(Value, String)> { + payload["workspaceId"] = json!(self.workspace_id); + let value = self.client.request_value(verb, &payload).await?; + let message = review_line(&value); + Ok((value, message)) + } +} + +async fn forge_client(runtime: &RuntimeDirArgs) -> Result { + RuntimeHostRpcClient::connect_or_start_with_required_capability( + &runtime_dir(runtime), + RUNTIME_HOST_PULL_REQUEST_FORGES_CAPABILITY, + ) + .await +} + +fn no_review() -> anyhow::Error { + anyhow!("No pull request is linked to this workspace or open for its branch. Pass --number.") +} + +/// The method an unattended merge uses: the forge's default, else the first allowed. +fn preferred_method(snapshot: &Value) -> Result { + let methods = snapshot["mergeMethods"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if methods.contains(&"providerDefault") { + return Ok("providerDefault".into()); + } + methods + .first() + .map(|method| method.to_string()) + .ok_or_else(|| { + anyhow!("The forge offers no merge method for this pull request. Pass --method.") + }) +} + +fn read_body(args: &PrBodyArgs, required: bool) -> Result { + let body = if args.body_stdin { + let mut text = String::new(); + std::io::stdin().read_to_string(&mut text)?; + text + } else { + args.body.clone().unwrap_or_default() + }; + if required && body.trim().is_empty() { + bail!("--body or --body-stdin is required."); + } + Ok(body) +} + +pub(crate) fn review_line(snapshot: &Value) -> String { + let review = &snapshot["review"]; + match review["number"].as_i64() { + Some(number) => format!( + "#{number} {} ({})", + review["title"].as_str().unwrap_or(""), + review["state"].as_str().unwrap_or("") + ), + None => snapshot["unavailableReason"] + .as_str() + .unwrap_or("No pull request.") + .to_string(), + } +} + +/// Agent profile id from --profile-id, or looked up by --profile name. +pub(crate) async fn profile_id( + client: &mut RuntimeHostRpcClient, + agent: &PrAgentTargetArgs, +) -> Result> { + if let Some(id) = agent + .profile_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + return Ok(Some(id.to_string())); + } + let Some(name) = agent + .profile + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return Ok(None); + }; + let profiles = client + .request_value("agentProfile.list", &json!({})) + .await?; + profiles["items"] + .as_array() + .into_iter() + .flatten() + .find(|profile| { + profile["name"] + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case(name)) + }) + .and_then(|profile| profile["id"].as_str()) + .map(|id| Some(id.to_string())) + .ok_or_else(|| anyhow!("agent profile not found: {name}")) +} + +async fn dispatch( + runtime: &RuntimeDirArgs, + kind: &str, + args: PrDispatchArgs, +) -> Result<(Value, String)> { + let workspace_id = + resolve_requested_workspace_id(runtime, args.number.target.workspace_id.as_deref()) + .await? + .ok_or_else(|| anyhow!("--workspace-id is required (or run inside an Alera terminal where ALERA_WORKSPACE_ID is set)."))?; + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &runtime_dir(runtime), + RUNTIME_HOST_PULL_REQUEST_AGENT_DISPATCH_CAPABILITY, + ) + .await?; + let mut payload = + json!({ "workspaceId": workspace_id, "kind": kind, "number": args.number.number }); + if kind == "fixFailedChecks" && args.number.number.is_none() { + let snapshot = client + .request_value( + "mobile.pullRequest.snapshot", + &json!({ "workspaceId": workspace_id }), + ) + .await?; + payload["number"] = snapshot["review"]["number"].clone(); + } + if !args.preview { + let handle = args + .agent + .handle + .clone() + .or_else(crate::orchestration_commands::terminal_handle_env); + let profile = profile_id(&mut client, &args.agent).await?; + if handle.is_none() && args.agent.tab_id.is_none() && profile.is_none() { + bail!("--handle, --tab-id, or --profile is required (or run inside an Alera terminal where ALERA_TERMINAL_HANDLE is set). Use --preview to print the prompt only."); + } + payload["handle"] = json!(handle); + payload["tabId"] = json!(args.agent.tab_id); + payload["profileId"] = json!(profile); + } + let value = client + .request_value("pullRequest.agentDispatch", &payload) + .await?; + let message = if value["dispatched"] == true { + format!( + "Sent {} to the agent", + value["title"].as_str().unwrap_or("the prompt") + ) + } else { + value["prompt"].as_str().unwrap_or_default().to_string() + }; + Ok((value, message)) +} + +async fn ship(runtime: &RuntimeDirArgs, args: PrShipArgs) -> Result<(Value, String)> { + let mut session = Session::open(runtime, &args.base.target).await?; + let mut watch_payload = None; + if let Some(mode) = args.follow_up_watch { + if args.no_checks && args.no_comments && args.no_conflicts { + bail!("Choose at least one of checks, comments, or conflicts."); + } + // Resolve the agent first, so a bad target fails before anything ships. + let handle = args + .agent + .handle + .clone() + .or_else(crate::orchestration_commands::terminal_handle_env); + let profile = profile_id(&mut session.client, &args.agent).await?; + if handle.is_none() && args.agent.tab_id.is_none() && profile.is_none() { + bail!("--handle, --tab-id, or --profile is required for --follow-up-watch (or run inside an Alera terminal where ALERA_TERMINAL_HANDLE is set)."); + } + watch_payload = Some(json!({ + "workspaceId": session.workspace_id, + "mode": match mode { PrWatchMode::Fix => "fix", PrWatchMode::FixAndMerge => "fixAndMerge" }, + "checks": !args.no_checks, + "comments": !args.no_comments, + "conflicts": !args.no_conflicts, + "handle": handle, + "tabId": args.agent.tab_id, + "profileId": profile, + })); + } + let payload = json!({ + "baseBranch": args.base.base.trim(), + "draft": args.draft, + "scope": match args.scope { crate::cli::PrShipScope::All => "all", crate::cli::PrShipScope::Staged => "staged" }, + }); + let (mut value, mut message) = session.write("mobile.pullRequest.ship", payload).await?; + if let Some(mut watch) = watch_payload { + let Some(number) = value["review"]["number"].as_i64() else { + value["followUpWatch"] = json!({ "started": false, "error": "The new pull request could not be read back; start Watch and Fix with alera workspace pr-watch start." }); + return Ok((value, message)); + }; + watch["reviewNumber"] = json!(number); + match session + .client + .request_value("pullRequestWatch.start", &watch) + .await + { + Ok(started) => { + value["followUpWatch"] = json!({ "started": true, "watch": started }); + message.push_str("\nWatching the pull request"); + } + Err(error) => { + value["followUpWatch"] = json!({ "started": false, "error": error.to_string() }); + } + } + } + Ok((value, message)) +} diff --git a/rust/alera-cli/src/pull_request_stack_commands.rs b/rust/alera-cli/src/pull_request_stack_commands.rs new file mode 100644 index 000000000..3ea7b467d --- /dev/null +++ b/rust/alera-cli/src/pull_request_stack_commands.rs @@ -0,0 +1,95 @@ +//! `alera pr stack show|create|link|merge` over `pullRequestStack.*`. GitHub +//! only, like the desktop; other forges answer `provider_unsupported`. + +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; + +use crate::cli::{PrStackAction, PrTargetArgs, RuntimeDirArgs}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_STACKS_CAPABILITY; +use crate::workspace_context::resolve_requested_workspace_id; + +pub(super) async fn run( + runtime: &RuntimeDirArgs, + action: PrStackAction, +) -> Result<(Value, String)> { + let (verb, target, payload) = match action { + PrStackAction::Show(args) => ( + "pullRequestStack.get", + args.target, + json!({ "number": args.number }), + ), + PrStackAction::Create(args) => { + let layers = args + .layers + .iter() + .enumerate() + .map(|(index, workspace_id)| { + json!({ + "workspaceId": workspace_id, + "title": args.titles.get(index), + "draft": args.draft, + }) + }) + .collect::>(); + ( + "pullRequestStack.create", + args.target, + json!({ "baseBranch": args.base, "layers": layers }), + ) + } + PrStackAction::Link(args) => ( + "pullRequestStack.link", + args.target, + json!({ "numbers": args.numbers }), + ), + PrStackAction::Merge(args) => ( + "pullRequestStack.merge", + args.number.target, + json!({ + "number": args.number.number, + "method": args.method.map(|method| method.wire()), + }), + ), + }; + let value = request(runtime, &target, verb, payload).await?; + Ok((value.clone(), stack_line(&value))) +} + +async fn request( + runtime: &RuntimeDirArgs, + target: &PrTargetArgs, + verb: &str, + mut payload: Value, +) -> Result { + let workspace_id = resolve_requested_workspace_id(runtime, target.workspace_id.as_deref()) + .await? + .ok_or_else(|| { + anyhow!("--workspace-id is required (or run inside an Alera terminal where ALERA_WORKSPACE_ID is set).") + })?; + payload["workspaceId"] = json!(workspace_id); + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &crate::runtime_dir(runtime), + RUNTIME_HOST_PULL_REQUEST_STACKS_CAPABILITY, + ) + .await?; + client.request_value(verb, &payload).await +} + +fn stack_line(value: &Value) -> String { + if value["merged"] == true { + return format!("Merged the stack through #{}", value["reviewNumber"]); + } + let stack = &value["stack"]; + let Some(number) = stack["number"].as_i64() else { + return "The pull request is not in a stack.".to_string(); + }; + let members = stack["entries"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| entry["review"]["number"].as_i64()) + .map(|number| format!("#{number}")) + .collect::>(); + format!("Stack {number}: {}", members.join(" <- ")) +} diff --git a/rust/alera-cli/src/runtime_commands.rs b/rust/alera-cli/src/runtime_commands.rs index c3aa2a508..551073a69 100644 --- a/rust/alera-cli/src/runtime_commands.rs +++ b/rust/alera-cli/src/runtime_commands.rs @@ -100,6 +100,18 @@ pub(crate) async fn run_runtime_command(command: RuntimeCommand) -> i32 { RuntimeAction::Agents(agents) => { run_runtime_agents_command(&command.runtime, command.output.json, agents.action).await } + RuntimeAction::Settings(settings) => { + crate::runtime_settings_commands::run_settings( + &command.runtime, + settings, + command.output.json, + ) + .await + } + RuntimeAction::Resources => { + crate::runtime_settings_commands::run_resources(&command.runtime, command.output.json) + .await + } } } diff --git a/rust/alera-cli/src/runtime_host_client.rs b/rust/alera-cli/src/runtime_host_client.rs index 8ae8d1295..820315006 100644 --- a/rust/alera-cli/src/runtime_host_client.rs +++ b/rust/alera-cli/src/runtime_host_client.rs @@ -148,7 +148,10 @@ impl RuntimeHostRpcClient { .arg(DEFAULT_SCROLLBACK_BYTES.to_string()) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::null()) + // A host started by an MCP tool call outlives that call, and its + // terminals must not claim the call's origin. + .env_remove(crate::mcp_tools::ORIGIN_VARIABLE); if persistent { command.arg("--persistent"); } diff --git a/rust/alera-cli/src/runtime_settings_commands.rs b/rust/alera-cli/src/runtime_settings_commands.rs new file mode 100644 index 000000000..37ce43e2d --- /dev/null +++ b/rust/alera-cli/src/runtime_settings_commands.rs @@ -0,0 +1,349 @@ +//! `alera runtime settings` and `alera runtime resources`. +//! +//! Only an explicit allowlist of runtime settings is readable or writable +//! here. Credentials, push notifications, voice, quota environment names, text +//! actions, AI Assist commands, and MCP access stay in the Alera app. + +use alera_core::runtime::{RuntimeAiAssistSettings, RuntimeSettings, AI_ASSIST_AGENTS}; +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{json, Map, Value}; + +use crate::cli::{RuntimeDirArgs, RuntimeSettingsAction, RuntimeSettingsCommand}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::{ + RUNTIME_HOST_AGENT_PROFILES_CAPABILITY, RUNTIME_HOST_RESOURCE_MONITOR_CAPABILITY, +}; + +/// How a setting's text value is parsed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Kind { + Flag, + /// A string that `--unset` clears. + ClearableText, + AiAssistAgent, + Number { + min: u64, + max: u64, + }, +} + +/// One allowlisted setting: its CLI key, the settings object that holds it +/// (`None` for a top-level field), and the field name on the wire. +struct Setting { + key: &'static str, + group: Option<&'static str>, + field: &'static str, + kind: Kind, +} + +const AI_ASSIST: &str = "aiTextGeneration"; +const AUTOMATION: &str = "automation"; +const RETENTION_DAYS: Kind = Kind::Number { min: 1, max: 3650 }; + +const SETTINGS: &[Setting] = &[ + setting( + "workspaceDirectory", + None, + "workspaceDirectory", + Kind::ClearableText, + ), + setting( + "confirmProjectRemoval", + None, + "confirmProjectRemoval", + Kind::Flag, + ), + setting( + "confirmWorkspaceRemoval", + None, + "confirmWorkspaceRemoval", + Kind::Flag, + ), + setting( + "defaultAgentProfileId", + None, + "defaultAgentProfileId", + Kind::ClearableText, + ), + setting("aiAssist.enabled", Some(AI_ASSIST), "enabled", Kind::Flag), + setting( + "aiAssist.autoGenerateAgentTitles", + Some(AI_ASSIST), + "autoGenerateAgentTitles", + Kind::Flag, + ), + setting( + "aiAssist.agent", + Some(AI_ASSIST), + "agent", + Kind::AiAssistAgent, + ), + setting( + "aiAssist.timeoutSeconds", + Some(AI_ASSIST), + "timeoutSeconds", + Kind::Number { min: 10, max: 600 }, + ), + setting( + "automation.startAtLogin", + Some(AUTOMATION), + "autostart", + Kind::Flag, + ), + setting( + "automation.runRetentionDays", + Some(AUTOMATION), + "runRetentionDays", + RETENTION_DAYS, + ), + setting( + "automation.auditRetentionDays", + Some(AUTOMATION), + "auditRetentionDays", + RETENTION_DAYS, + ), + setting( + "automation.trashRetentionDays", + Some(AUTOMATION), + "trashRetentionDays", + RETENTION_DAYS, + ), +]; + +const fn setting( + key: &'static str, + group: Option<&'static str>, + field: &'static str, + kind: Kind, +) -> Setting { + Setting { + key, + group, + field, + kind, + } +} + +/// Every key `runtime settings set` accepts, in display order. +pub(crate) fn setting_keys() -> impl Iterator { + SETTINGS.iter().map(|setting| setting.key) +} + +/// AI Assist agents a setting may select. `custom` needs a command, which is +/// not open to this surface. +pub(crate) fn selectable_ai_assist_agents() -> Vec<&'static str> { + AI_ASSIST_AGENTS + .iter() + .copied() + .filter(|agent| *agent != "custom") + .collect() +} + +fn find_setting(key: &str) -> Result<&'static Setting> { + SETTINGS + .iter() + .find(|setting| setting.key == key) + .ok_or_else(|| { + anyhow!( + "Unsupported runtime setting: {key}. Supported keys: {}.", + setting_keys().collect::>().join(", ") + ) + }) +} + +fn parse_value(setting: &Setting, raw: &str) -> Result { + let raw = raw.trim(); + match setting.kind { + Kind::Flag => match raw { + "true" => Ok(json!(true)), + "false" => Ok(json!(false)), + _ => bail!("{} must be true or false.", setting.key), + }, + Kind::ClearableText if raw.is_empty() => { + bail!( + "{} cannot be empty. Use --unset {} to clear it.", + setting.key, + setting.key + ) + } + Kind::ClearableText => Ok(json!(raw)), + Kind::AiAssistAgent if selectable_ai_assist_agents().contains(&raw) => Ok(json!(raw)), + Kind::AiAssistAgent => bail!( + "{} must be one of: {}.", + setting.key, + selectable_ai_assist_agents().join(", ") + ), + Kind::Number { min, max } => match raw.parse::() { + Ok(value) if (min..=max).contains(&value) => Ok(json!(value)), + _ => bail!( + "{} must be a whole number from {min} to {max}.", + setting.key + ), + }, + } +} + +/// The allowlisted settings, as `runtime settings show` prints them. +pub(crate) fn settings_view(settings: &RuntimeSettings) -> Result { + let wire = settings_wire(settings)?; + let mut view = Map::new(); + for setting in SETTINGS { + let value = match setting.group { + Some(group) => wire[group][setting.field].clone(), + None => wire[setting.field].clone(), + }; + match setting.key.split_once('.') { + Some((section, name)) => { + let section = view + .entry(section) + .or_insert_with(|| Value::Object(Map::new())); + section[name] = value; + } + None => { + view.insert(setting.key.to_string(), value); + } + } + } + Ok(Value::Object(view)) +} + +/// Settings on the wire, with AI Assist defaults filled in when it was never +/// configured, as the host applies them. +fn settings_wire(settings: &RuntimeSettings) -> Result { + let mut wire = serde_json::to_value(settings).context("could not encode runtime settings")?; + if wire[AI_ASSIST].is_null() { + wire[AI_ASSIST] = serde_json::to_value(RuntimeAiAssistSettings::default())?; + } + Ok(wire) +} + +/// The `runtimeSettings.update` payload for `assignments` (`key=value`) and +/// `unset` keys. Settings that live in an object (AI Assist, automation) are +/// sent whole, so the payload starts from the current values. +pub(crate) fn settings_update_payload( + current: &RuntimeSettings, + assignments: &[String], + unset: &[String], +) -> Result { + let wire = settings_wire(current)?; + let mut payload = Map::new(); + let mut seen = std::collections::HashSet::new(); + let mut apply = |setting: &'static Setting, value: Value| -> Result<()> { + if !seen.insert(setting.key) { + bail!("{} is set more than once.", setting.key); + } + match setting.group { + Some(group) => { + let object = payload.entry(group).or_insert_with(|| wire[group].clone()); + object[setting.field] = value; + } + None => { + payload.insert(setting.field.to_string(), value); + } + } + Ok(()) + }; + for assignment in assignments { + let (key, raw) = assignment + .split_once('=') + .ok_or_else(|| anyhow!("Expected key=value, got {assignment}."))?; + let setting = find_setting(key.trim())?; + apply(setting, parse_value(setting, raw)?)?; + } + for key in unset { + let setting = find_setting(key.trim())?; + if setting.kind != Kind::ClearableText { + bail!("{} cannot be unset.", setting.key); + } + apply(setting, Value::Null)?; + } + if payload.is_empty() { + bail!("Name at least one setting to change."); + } + Ok(Value::Object(payload)) +} + +pub(crate) async fn run_settings( + runtime: &RuntimeDirArgs, + command: RuntimeSettingsCommand, + json_output: bool, +) -> i32 { + match settings(runtime, command).await { + Ok((view, message)) => { + crate::print_value(&view, json_output, message); + 0 + } + Err(error) => crate::print_error(error), + } +} + +async fn settings( + runtime: &RuntimeDirArgs, + command: RuntimeSettingsCommand, +) -> Result<(Value, &'static str)> { + let mut client = RuntimeHostRpcClient::connect_or_start(&crate::runtime_dir(runtime)).await?; + let current: RuntimeSettings = client.request("runtimeSettings.get", &json!({})).await?; + let RuntimeSettingsAction::Set(args) = command.action else { + return Ok((settings_view(¤t)?, "runtime settings")); + }; + let payload = settings_update_payload(¤t, &args.assignments, &args.unset)?; + if let Some(profile_id) = payload["defaultAgentProfileId"].as_str() { + ensure_profile_exists(&mut client, profile_id).await?; + } + let saved: RuntimeSettings = client.request("runtimeSettings.update", &payload).await?; + Ok((settings_view(&saved)?, "runtime settings updated")) +} + +async fn ensure_profile_exists(client: &mut RuntimeHostRpcClient, profile_id: &str) -> Result<()> { + crate::agent_profile_commands::ensure_capabilities( + client, + &[RUNTIME_HOST_AGENT_PROFILES_CAPABILITY], + ) + .await?; + let (_, profiles) = crate::agent_profile_commands::list_profiles(client).await?; + if profiles.iter().any(|profile| profile.id == profile_id) { + Ok(()) + } else { + bail!("Agent profile not found: {profile_id}. Use an id from agent-profile list.") + } +} + +/// The first snapshot after the monitor starts only says it is warming up, +/// so wait briefly for a measured one. +const RESOURCE_ATTEMPTS: u32 = 12; +const RESOURCE_RETRY: std::time::Duration = std::time::Duration::from_millis(500); + +pub(crate) async fn run_resources(runtime: &RuntimeDirArgs, json_output: bool) -> i32 { + match resources(runtime).await { + Ok(snapshot) => { + crate::print_value(&snapshot, json_output, "resource snapshot"); + 0 + } + Err(error) => crate::print_error(error), + } +} + +async fn resources(runtime: &RuntimeDirArgs) -> Result { + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &crate::runtime_dir(runtime), + RUNTIME_HOST_RESOURCE_MONITOR_CAPABILITY, + ) + .await?; + let mut snapshot = Value::Null; + for attempt in 0..RESOURCE_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(RESOURCE_RETRY).await; + } + snapshot = client + .request_value("resources.snapshot", &json!({})) + .await?; + if snapshot["warming"] != true { + break; + } + } + Ok(snapshot) +} + +#[cfg(test)] +#[path = "runtime_settings_commands_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/runtime_settings_commands_tests.rs b/rust/alera-cli/src/runtime_settings_commands_tests.rs new file mode 100644 index 000000000..df21027ad --- /dev/null +++ b/rust/alera-cli/src/runtime_settings_commands_tests.rs @@ -0,0 +1,151 @@ +use alera_core::runtime::{RuntimeAiAssistSettings, RuntimeSettings}; +use serde_json::json; + +use super::{settings_update_payload, settings_view}; + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn configured() -> RuntimeSettings { + let mut settings = RuntimeSettings { + workspace_directory: Some("/work".into()), + default_agent_profile_id: Some("prof_1".into()), + ai_assist: Some(RuntimeAiAssistSettings { + custom_command: "secret-tool --token abc".into(), + ..RuntimeAiAssistSettings::default() + }), + ..RuntimeSettings::default() + }; + settings.mobile_push_notifications.enabled = true; + settings.voice.tts_voice = Some("nova".into()); + settings +} + +#[test] +fn the_view_holds_only_allowlisted_settings() { + let view = settings_view(&configured()).unwrap(); + assert_eq!( + view, + json!({ + "workspaceDirectory": "/work", + "confirmProjectRemoval": true, + "confirmWorkspaceRemoval": true, + "defaultAgentProfileId": "prof_1", + "aiAssist": { + "enabled": true, + "autoGenerateAgentTitles": true, + "agent": "codex", + "timeoutSeconds": 120, + }, + "automation": { + "startAtLogin": false, + "runRetentionDays": 30, + "auditRetentionDays": 90, + "trashRetentionDays": 30, + }, + }) + ); + let text = view.to_string(); + for hidden in [ + "secret-tool", + "nova", + "mobilePushNotifications", + "agentQuotas", + ] { + assert!(!text.contains(hidden), "{hidden} leaked into {text}"); + } +} + +#[test] +fn the_view_fills_ai_assist_defaults_when_it_was_never_configured() { + let view = settings_view(&RuntimeSettings::default()).unwrap(); + assert_eq!(view["aiAssist"]["agent"], "codex"); + assert_eq!(view["workspaceDirectory"], serde_json::Value::Null); +} + +#[test] +fn top_level_settings_are_sent_alone() { + let payload = settings_update_payload( + &configured(), + &strings(&["confirmWorkspaceRemoval=false", "workspaceDirectory= /new "]), + &strings(&["defaultAgentProfileId"]), + ) + .unwrap(); + assert_eq!( + payload, + json!({ + "confirmWorkspaceRemoval": false, + "workspaceDirectory": "/new", + "defaultAgentProfileId": null, + }) + ); +} + +#[test] +fn grouped_settings_keep_the_rest_of_their_object() { + let payload = settings_update_payload( + &configured(), + &strings(&[ + "aiAssist.timeoutSeconds=45", + "aiAssist.agent=claude", + "automation.startAtLogin=true", + ]), + &[], + ) + .unwrap(); + assert_eq!(payload["aiTextGeneration"]["timeoutSeconds"], 45); + assert_eq!(payload["aiTextGeneration"]["agent"], "claude"); + assert_eq!( + payload["aiTextGeneration"]["customCommand"], + "secret-tool --token abc" + ); + assert_eq!(payload["automation"]["autostart"], true); + assert_eq!(payload["automation"]["runRetentionDays"], 30); + assert!(payload.get("voice").is_none()); +} + +#[test] +fn keys_outside_the_allowlist_are_refused() { + for assignment in [ + "aiAssist.customCommand=rm -rf /", + "mobilePushNotifications.enabled=true", + "voice.ttsVoice=nova", + "agentQuotas=[]", + "mcp.access=admin", + ] { + let error = settings_update_payload(&configured(), &strings(&[assignment]), &[]) + .unwrap_err() + .to_string(); + assert!(error.contains("Unsupported runtime setting"), "{error}"); + } +} + +#[test] +fn values_are_checked_before_reaching_the_host() { + for (assignment, message) in [ + ("confirmProjectRemoval=yes", "true or false"), + ("aiAssist.timeoutSeconds=5", "from 10 to 600"), + ("automation.trashRetentionDays=0", "from 1 to 3650"), + ("aiAssist.agent=custom", "must be one of"), + ("workspaceDirectory=", "--unset"), + ("confirmProjectRemoval", "key=value"), + ] { + let error = settings_update_payload(&configured(), &strings(&[assignment]), &[]) + .unwrap_err() + .to_string(); + assert!(error.contains(message), "{assignment}: {error}"); + } + let error = settings_update_payload(&configured(), &[], &strings(&["confirmProjectRemoval"])) + .unwrap_err() + .to_string(); + assert!(error.contains("cannot be unset"), "{error}"); + let error = settings_update_payload( + &configured(), + &strings(&["workspaceDirectory=/a"]), + &strings(&["workspaceDirectory"]), + ) + .unwrap_err() + .to_string(); + assert!(error.contains("more than once"), "{error}"); +} diff --git a/rust/alera-cli/src/skill_commands.rs b/rust/alera-cli/src/skill_commands.rs new file mode 100644 index 000000000..b80f54271 --- /dev/null +++ b/rust/alera-cli/src/skill_commands.rs @@ -0,0 +1,170 @@ +//! `alera skill`: the Alera skills coding agents use, on this machine. +//! +//! The install runs as a runtime job, so it finishes even when this command +//! stops waiting for it. + +use anyhow::{Context, Result}; +use serde_json::{json, Value}; + +use crate::agent_profile_commands::ensure_capabilities; +use crate::cli::{SkillAction, SkillCommand, SkillInstallArgs, SkillName, SkillRunnerName}; +use crate::host_tools::SkillKind; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::RUNTIME_HOST_AGENT_SKILL_INSTALL_JOBS_CAPABILITY; + +const INSTALL_RUNNING: &str = "already running"; + +pub(crate) async fn run(command: SkillCommand) -> i32 { + let json_output = command.output.json; + match run_command(command).await { + Ok((value, message, succeeded)) => { + crate::print_value(&value, json_output, message); + if succeeded { + 0 + } else { + 1 + } + } + Err(error) => crate::print_error(error), + } +} + +async fn run_command(command: SkillCommand) -> Result<(Value, &'static str, bool)> { + let home = dirs::home_dir().context("This machine has no home directory")?; + let runtime_dir = crate::runtime_dir(&command.runtime); + match command.action { + SkillAction::Status => { + let mut value = crate::agent_skills::status(&home); + // A runtime that is not running has no install in progress. + if let Ok(Some(mut client)) = RuntimeHostRpcClient::connect(&runtime_dir).await { + if let Ok(state) = client.request_value("agentSkill.state", &json!({})).await { + value["install"] = state; + } + } + Ok((value, "agent skills", true)) + } + SkillAction::Install(args) => { + let mut client = RuntimeHostRpcClient::connect_or_start(&runtime_dir).await?; + ensure_capabilities( + &mut client, + &[RUNTIME_HOST_AGENT_SKILL_INSTALL_JOBS_CAPABILITY], + ) + .await?; + let operation_id = uuid::Uuid::new_v4().to_string(); + let wait_ms = args.wait_seconds.saturating_mul(1000); + let answer = client + .request_value_with_deadline( + "agentSkill.install", + &install_payload(&operation_id, &args), + wait_ms, + ) + .await; + let (mut value, message, succeeded) = match answer { + Ok(mut result) => { + let succeeded = result["succeeded"] == true; + result["state"] = json!(if succeeded { "completed" } else { "failed" }); + let message = if succeeded { + "agent skills installed" + } else { + "agent skill install failed" + }; + (result, message, succeeded) + } + Err(error) if still_running(&error.to_string()) => ( + running_answer(&operation_id), + "agent skill install still running", + true, + ), + Err(error) => return Err(error), + }; + value["status"] = crate::agent_skills::status(&home); + Ok((value, message, succeeded)) + } + } +} + +fn install_payload(operation_id: &str, args: &SkillInstallArgs) -> Value { + let skills = if args.skills.is_empty() { + SkillKind::ALL.to_vec() + } else { + args.skills.iter().copied().map(skill_kind).collect() + }; + json!({ + "operationId": operation_id, + "skills": skills.iter().map(|kind| kind.id()).collect::>(), + "runner": runner_name(args.runner), + }) +} + +/// The wait ended first, or another request's install is still running. +/// Either way the runtime finishes it, and `skill status` shows the result. +fn still_running(message: &str) -> bool { + message.contains("did not answer") || message.contains(INSTALL_RUNNING) +} + +fn running_answer(operation_id: &str) -> Value { + json!({ + "state": "running", + "operationId": operation_id, + "message": "The install is still running on the runtime. Run `alera skill status` (check_agent_skills) later: `install.running` is null once it has finished, and `install.last` has its result. Do not start another install meanwhile.", + }) +} + +fn skill_kind(name: SkillName) -> SkillKind { + match name { + SkillName::Cli => SkillKind::Cli, + SkillName::Orchestration => SkillKind::Orchestration, + SkillName::Automations => SkillKind::Automations, + SkillName::AgentProfiles => SkillKind::AgentProfiles, + } +} + +fn runner_name(runner: SkillRunnerName) -> &'static str { + match runner { + SkillRunnerName::Auto => "auto", + SkillRunnerName::Npx => "npx", + SkillRunnerName::Bunx => "bunx", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_install_names_every_skill_unless_some_are_given() { + let all = SkillInstallArgs { + skills: Vec::new(), + runner: SkillRunnerName::Auto, + wait_seconds: 45, + }; + assert_eq!( + install_payload("op", &all), + json!({ + "operationId": "op", + "skills": ["cli", "orchestration", "automations", "agentProfiles"], + "runner": "auto", + }) + ); + let some = SkillInstallArgs { + skills: vec![SkillName::AgentProfiles], + runner: SkillRunnerName::Bunx, + wait_seconds: 45, + }; + assert_eq!( + install_payload("op", &some)["skills"], + json!(["agentProfiles"]) + ); + assert_eq!(install_payload("op", &some)["runner"], "bunx"); + } + + #[test] + fn a_wait_that_ends_first_or_a_running_install_is_reported_as_running() { + assert!(still_running( + "runtime host did not answer agentSkill.install within 45000ms" + )); + assert!(still_running("An agent skill install is already running.")); + assert!(!still_running("runner must be auto, npx, or bunx.")); + assert_eq!(running_answer("op")["state"], "running"); + } +} diff --git a/rust/alera-cli/src/tab_commands.rs b/rust/alera-cli/src/tab_commands.rs new file mode 100644 index 000000000..90c3df88a --- /dev/null +++ b/rust/alera-cli/src/tab_commands.rs @@ -0,0 +1,86 @@ +//! `alera tab`: list, create, remove, rename, and link workspace tabs. + +use serde_json::json; + +use crate::cli::{TabAction, TabCommand, TabRemoveArgs}; +use crate::tab_record_factory::tab_from_args; +use crate::{ + open_store, print_error, print_value, runtime_host_or_store, runtime_host_or_store_unit, + USAGE_EXIT_CODE, +}; + +pub(crate) async fn run(command: TabCommand) -> i32 { + let runtime = command.runtime; + let json_output = command.output.json; + match command.action { + action @ (TabAction::Rename(_) | TabAction::GenerateTitle(_)) => { + return crate::terminal_lifecycle_commands::run_tab(&runtime, action, json_output) + .await; + } + TabAction::Remove(args) if args.terminate => { + return crate::terminal_lifecycle_commands::run_tab( + &runtime, + TabAction::Remove(args), + json_output, + ) + .await; + } + TabAction::LinkAgent(args) => { + return crate::tab_agent_link_command::run(&runtime, args, json_output).await; + } + TabAction::List(args) => match open_store(&runtime).await { + Ok(store) => match store.list_workspace_tabs(&args.workspace_id).await { + Ok(tabs) => print_value( + &json!({ "kind": "tabs", "items": tabs, "filters": { "workspaceId": args.workspace_id } }), + json_output, + "tabs listed", + ), + Err(error) => return print_error(error), + }, + Err(error) => return print_error(error), + }, + TabAction::Create(args) => { + let tab = match tab_from_args(args) { + Ok(tab) => tab, + Err(error) => { + eprintln!("{error}"); + return USAGE_EXIT_CODE; + } + }; + let fallback_tab = tab.clone(); + match runtime_host_or_store(&runtime, "tab.upsert", &tab, |store| async move { + store.upsert_workspace_tab(fallback_tab).await + }) + .await + { + Ok(tab) => print_value(&tab, json_output, "tab saved"), + Err(error) => return print_error(error), + } + } + TabAction::Remove(TabRemoveArgs { id, .. }) => { + let payload = json!({ "id": id }); + let removed_id = id.clone(); + match runtime_host_or_store_unit(&runtime, "tab.remove", &payload, |store| async move { + if let Some(tab) = store.find_workspace_tab(&id).await? { + if tab.kind == "terminal" { + if let Some(workspace) = store.find_workspace(&tab.workspace_id).await? { + if workspace.host_id != alera_core::runtime::LOCAL_HOST_ID { + anyhow::bail!("The Home runtime must be available to verify SSH terminal closure before removing this tab"); + } + } + } + } + let retentions = crate::hosted_review_retention::for_tab(&store, &id).await; + store.remove_workspace_tab(&id).await?; + crate::hosted_review_retention::release(retentions); + Ok(()) + }) + .await + { + Ok(()) => print_value(&json!({ "id": removed_id }), json_output, "tab removed"), + Err(error) => return print_error(error), + } + } + } + 0 +} diff --git a/rust/alera-cli/src/terminal_alias_commands.rs b/rust/alera-cli/src/terminal_alias_commands.rs index b13d2e80c..152fa2975 100644 --- a/rust/alera-cli/src/terminal_alias_commands.rs +++ b/rust/alera-cli/src/terminal_alias_commands.rs @@ -18,6 +18,12 @@ pub(crate) fn required_capability(action: &TerminalAction) -> Option<&'static st crate::terminal_host::protocol::RUNTIME_HOST_ORCHESTRATION_TERMINAL_INSPECTION_CAPABILITY, ) } + TerminalAction::Restart(_) => { + Some(crate::terminal_host::protocol::RUNTIME_HOST_TERMINAL_HEADLESS_RESTART_CAPABILITY) + } + TerminalAction::Pulse(_) => { + Some(crate::terminal_host::protocol::RUNTIME_HOST_TERMINAL_PULSE_CAPABILITY) + } _ => None, } } @@ -91,7 +97,11 @@ pub async fn run( } Err(error) => print_error(error), }, - TerminalAction::Read(_) | TerminalAction::Write(_) => unreachable!(), + TerminalAction::Read(_) + | TerminalAction::Write(_) + | TerminalAction::Restart(_) + | TerminalAction::Terminate(_) + | TerminalAction::Pulse(_) => unreachable!(), } } diff --git a/rust/alera-cli/src/terminal_host/ai_assist_capabilities.rs b/rust/alera-cli/src/terminal_host/ai_assist_capabilities.rs index 57c5b71b5..04be492f0 100644 --- a/rust/alera-cli/src/terminal_host/ai_assist_capabilities.rs +++ b/rust/alera-cli/src/terminal_host/ai_assist_capabilities.rs @@ -11,6 +11,13 @@ pub const RUNTIME_HOST_AI_ASSIST_COMMIT_MESSAGE_CAPABILITY: &str = "aiTextCommit /// `aiText.pullRequestDetails.generate`. pub const RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_CAPABILITY: &str = "aiTextPullRequestDetailsV1"; +/// `aiText.pullRequestDetails.generate` accepts `waitMs`: the runtime keeps the +/// generation running past the caller, answers `status: running` when the wait +/// ends first, and a request with the same `operationId` attaches to it or +/// reads its result for 15 minutes. Additive: do not bump +/// `aleraTerminalHostProtocolVersion`. +pub const RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_RESUME_CAPABILITY: &str = + "aiTextPullRequestDetailsResumeV1"; /// Direct OpenCode Go HTTP completion and model discovery for AI Assist. /// Additive: do not bump `aleraTerminalHostProtocolVersion`. pub const RUNTIME_HOST_AI_ASSIST_OPENCODE_GO_CAPABILITY: &str = "aiAssistOpenCodeGoV1"; diff --git a/rust/alera-cli/src/terminal_host/control_file.rs b/rust/alera-cli/src/terminal_host/control_file.rs index 060fd1a9a..9798a9039 100644 --- a/rust/alera-cli/src/terminal_host/control_file.rs +++ b/rust/alera-cli/src/terminal_host/control_file.rs @@ -101,11 +101,19 @@ pub fn write_control_file( RUNTIME_HOST_MOBILE_MUTATIONS_CAPABILITY, RUNTIME_HOST_MOBILE_PROJECT_MANAGEMENT_CAPABILITY, RUNTIME_HOST_WORKSPACE_SECTIONS_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PROMPT_WORKSPACE_SERVICE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY, RUNTIME_HOST_WORKSPACE_ARCHIVE_CAPABILITY, RUNTIME_HOST_WORKSPACE_SLEEP_STATE_CAPABILITY, RUNTIME_HOST_WORKSPACE_FOCUS_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_WORKSPACE_WAKE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_CHECKOUT_BUFFER_SAVE_CAPABILITY, RUNTIME_HOST_LINKED_ISSUES_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_V2_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_FORGES_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_AGENT_DISPATCH_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_STACKS_CAPABILITY, RUNTIME_HOST_MOBILE_SIDEBAR_PARITY_CAPABILITY, RUNTIME_HOST_MOBILE_TAB_RENAME_CAPABILITY, RUNTIME_HOST_MOBILE_TERMINAL_TITLES_CAPABILITY, @@ -123,6 +131,8 @@ pub fn write_control_file( RUNTIME_HOST_AI_ASSIST_SPEECH_MESSAGE_CAPABILITY, RUNTIME_HOST_AI_ASSIST_OPENCODE_GO_CAPABILITY, RUNTIME_HOST_AI_ASSIST_CHATGPT_CAPABILITY, RUNTIME_HOST_AI_ASSIST_CHATGPT_OPTIONS_CAPABILITY, + crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_CAPABILITY, + crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_RESUME_CAPABILITY, RUNTIME_HOST_REMOTE_AI_DICTATION_CAPABILITY, RUNTIME_HOST_AGENT_PROFILE_PROMPT_LAUNCH_CAPABILITY, RUNTIME_HOST_AGENT_PROFILE_LAUNCH_IDEMPOTENCY_CAPABILITY, @@ -131,6 +141,7 @@ pub fn write_control_file( RUNTIME_HOST_ORCHESTRATION_TERMINAL_INSPECTION_CAPABILITY, RUNTIME_HOST_ORCHESTRATION_WAIT_CAPABILITY, crate::terminal_host::protocol::RUNTIME_HOST_INBOX_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_INBOX_ORIGIN_CAPABILITY, RUNTIME_HOST_RUN_POLICY_CAPABILITY, RUNTIME_HOST_ORCHESTRATION_BOARD_CAPABILITY, RUNTIME_HOST_WORKFLOW_CATALOG_CAPABILITY, @@ -143,6 +154,7 @@ pub fn write_control_file( RUNTIME_HOST_TERMINAL_DRIVER_CAPABILITY, RUNTIME_HOST_TERMINAL_RESTART_CAPABILITY, RUNTIME_HOST_TERMINAL_PULSE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_TERMINAL_HEADLESS_RESTART_CAPABILITY, RUNTIME_HOST_LIFECYCLE_CAPABILITY, RUNTIME_HOST_RESTART_CAPABILITY, RUNTIME_HOST_AGENT_STATUS_CAPABILITY, @@ -239,12 +251,20 @@ mod tests { RUNTIME_HOST_MOBILE_MUTATIONS_CAPABILITY, RUNTIME_HOST_MOBILE_PROJECT_MANAGEMENT_CAPABILITY, RUNTIME_HOST_WORKSPACE_SECTIONS_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PROMPT_WORKSPACE_SERVICE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY, RUNTIME_HOST_WORKSPACE_ARCHIVE_CAPABILITY, RUNTIME_HOST_WORKSPACE_SLEEP_STATE_CAPABILITY, RUNTIME_HOST_WORKSPACE_FOCUS_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_WORKSPACE_WAKE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_CHECKOUT_BUFFER_SAVE_CAPABILITY, RUNTIME_HOST_LINKED_ISSUES_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_V2_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_FORGES_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_AGENT_DISPATCH_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_STACKS_CAPABILITY, RUNTIME_HOST_MOBILE_SIDEBAR_PARITY_CAPABILITY, RUNTIME_HOST_MOBILE_TAB_RENAME_CAPABILITY, RUNTIME_HOST_MOBILE_TERMINAL_TITLES_CAPABILITY, @@ -263,6 +283,8 @@ mod tests { RUNTIME_HOST_AI_ASSIST_OPENCODE_GO_CAPABILITY, RUNTIME_HOST_AI_ASSIST_CHATGPT_CAPABILITY, RUNTIME_HOST_AI_ASSIST_CHATGPT_OPTIONS_CAPABILITY, + crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_CAPABILITY, + crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_RESUME_CAPABILITY, RUNTIME_HOST_REMOTE_AI_DICTATION_CAPABILITY, RUNTIME_HOST_AGENT_PROFILE_PROMPT_LAUNCH_CAPABILITY, RUNTIME_HOST_AGENT_PROFILE_LAUNCH_IDEMPOTENCY_CAPABILITY, @@ -271,6 +293,7 @@ mod tests { RUNTIME_HOST_ORCHESTRATION_TERMINAL_INSPECTION_CAPABILITY, RUNTIME_HOST_ORCHESTRATION_WAIT_CAPABILITY, crate::terminal_host::protocol::RUNTIME_HOST_INBOX_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_INBOX_ORIGIN_CAPABILITY, RUNTIME_HOST_RUN_POLICY_CAPABILITY, RUNTIME_HOST_ORCHESTRATION_BOARD_CAPABILITY, RUNTIME_HOST_WORKFLOW_CATALOG_CAPABILITY, @@ -283,6 +306,7 @@ mod tests { RUNTIME_HOST_TERMINAL_DRIVER_CAPABILITY, RUNTIME_HOST_TERMINAL_RESTART_CAPABILITY, RUNTIME_HOST_TERMINAL_PULSE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_TERMINAL_HEADLESS_RESTART_CAPABILITY, RUNTIME_HOST_LIFECYCLE_CAPABILITY, RUNTIME_HOST_RESTART_CAPABILITY, RUNTIME_HOST_AGENT_STATUS_CAPABILITY, diff --git a/rust/alera-cli/src/terminal_host/orchestration/message_formatter.rs b/rust/alera-cli/src/terminal_host/orchestration/message_formatter.rs index 79d87ba32..3f7ad1edb 100644 --- a/rust/alera-cli/src/terminal_host/orchestration/message_formatter.rs +++ b/rust/alera-cli/src/terminal_host/orchestration/message_formatter.rs @@ -70,16 +70,29 @@ pub fn format_message_banner(message: &OrchestrationMessage) -> String { lines.join("\n") } -fn external_origin_suffix(message: &OrchestrationMessage) -> &'static str { - let surface = message +/// Longest MCP client name shown in a banner; the name is self-asserted. +const ORIGIN_CLIENT_NAME_MAX_CHARS: usize = 64; + +fn external_origin_suffix(message: &OrchestrationMessage) -> String { + let origin = message .external_meta .as_ref() - .and_then(|meta| meta.pointer("/origin/surface")) - .and_then(|surface| surface.as_str()); - match surface { - Some("desktop") => " via Alera desktop", - Some("mobile") => " via Alera mobile", - _ => "", + .and_then(|meta| meta.get("origin")); + let field = |name: &str| origin.and_then(|origin| origin.get(name)?.as_str()); + match field("surface") { + Some("desktop") => " via Alera desktop".to_string(), + Some("mobile") => " via Alera mobile".to_string(), + Some("mcp") => { + let name: String = field("clientName") + .or_else(|| field("clientId")) + .unwrap_or("an MCP client") + .chars() + .filter(|character| !character.is_control()) + .take(ORIGIN_CLIENT_NAME_MAX_CHARS) + .collect(); + format!(" via {} (MCP)", name.trim()) + } + _ => String::new(), } } @@ -252,6 +265,20 @@ mod tests { assert!(!banner.contains("From: EXT:USER")); } + #[test] + fn mcp_questions_name_the_client_that_asked() { + let mut question = message(OrchestrationMessagePriority::High); + question.from_handle = "ext:mcp".to_string(); + question.external_meta = Some(serde_json::json!({ + "origin": {"surface": "mcp", "transport": "remote", "clientId": "c-1", "clientName": "Chat\u{7}GPT"}, + })); + let banner = format_message_banner(&question); + assert!(banner.starts_with("──── External question from ext:mcp via ChatGPT (MCP) [HIGH]")); + question.external_meta = Some(serde_json::json!({"origin": {"surface": "mcp"}})); + let banner = format_message_banner(&question); + assert!(banner.starts_with("──── External question from ext:mcp via an MCP client (MCP)")); + } + #[test] fn injected_count_keeps_whole_messages_within_one_paste() { let mut oversized = message(OrchestrationMessagePriority::Normal); diff --git a/rust/alera-cli/src/terminal_host/orchestration/message_waiters.rs b/rust/alera-cli/src/terminal_host/orchestration/message_waiters.rs index d9993db98..b3e2e82a9 100644 --- a/rust/alera-cli/src/terminal_host/orchestration/message_waiters.rs +++ b/rust/alera-cli/src/terminal_host/orchestration/message_waiters.rs @@ -27,6 +27,8 @@ pub enum WaitKind { Inbox { question_id: Option, after_sequence: i64, + /// Inbox waits only: count only threads this MCP client started. + origin_client_id: Option, }, } diff --git a/rust/alera-cli/src/terminal_host/protocol.rs b/rust/alera-cli/src/terminal_host/protocol.rs index 8cbe305dc..8d527714a 100644 --- a/rust/alera-cli/src/terminal_host/protocol.rs +++ b/rust/alera-cli/src/terminal_host/protocol.rs @@ -11,6 +11,15 @@ pub const PROTOCOL_VERSION: i64 = 4; pub const ORCHESTRATION_PROTOCOL_VERSION: i64 = 2; pub const DISPATCH_PREAMBLE_VERSION: i64 = 2; pub const ORCHESTRATION_SKILL_VERSION: i64 = 3; +/// Versions of the other agent skills in `skills/`, kept in step with each +/// SKILL.md's `metadata.version` so `alera skill status` can tell whether an +/// installed copy matches this runtime. +pub const CLI_SKILL_VERSION: i64 = 1; +pub const AUTOMATIONS_SKILL_VERSION: i64 = 1; +pub const AGENT_PROFILES_SKILL_VERSION: i64 = 1; +/// `agentSkill.install` takes a `skills` list, runs as a runtime job that one +/// request at a time may start, and `agentSkill.state` reports it. +pub const RUNTIME_HOST_AGENT_SKILL_INSTALL_JOBS_CAPABILITY: &str = "agentSkillInstallJobsV1"; pub const ORCHESTRATION_ACCEPTANCE_TIMEOUT_MS: u64 = 90_000; /// Longest wait the host will hold a parked orchestration request for. Shared /// with the CLI so `--timeout-ms` can refuse a budget the host would silently diff --git a/rust/alera-cli/src/terminal_host/protocol_capabilities.rs b/rust/alera-cli/src/terminal_host/protocol_capabilities.rs index 72735f7ac..4856047d8 100644 --- a/rust/alera-cli/src/terminal_host/protocol_capabilities.rs +++ b/rust/alera-cli/src/terminal_host/protocol_capabilities.rs @@ -44,6 +44,11 @@ pub const RUNTIME_HOST_MOBILE_REMOTE_WORKSPACES_CAPABILITY: &str = "mobileRemote pub const RUNTIME_HOST_MOBILE_CAPABILITY: &str = "mobileCompanionAccess"; pub const RUNTIME_HOST_MOBILE_NETBIRD_CAPABILITY: &str = "mobileNetBirdGatewayV1"; pub const RUNTIME_HOST_WORKSPACE_SECTIONS_CAPABILITY: &str = "workspaceSectionsV1"; +/// The host runs New Workspace from Prompt as a persisted operation +/// (`workspace.promptStart.*`). +pub const RUNTIME_HOST_PROMPT_WORKSPACE_SERVICE_CAPABILITY: &str = "promptWorkspaceServiceV1"; +/// The host keeps a cursor-ordered journal of domain events (`runtimeEvents.list`). +pub const RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY: &str = "runtimeEventsV1"; /// The host archives workspaces (`workspace.archive` / `workspace.unarchive`) /// instead of deleting them: live sessions stop, but tab records, layout, /// branch, and files are preserved so agent sessions can resume on unarchive. @@ -62,6 +67,17 @@ pub const TERMINAL_SESSION_REMOVED_BY_SLEEP: &str = "workspaceSleep"; /// the connected desktop apps that announced `workspaceFocusV1` in `hello`. /// Additive: an older host rejects the verb, so the CLI feature-checks this. pub const RUNTIME_HOST_WORKSPACE_FOCUS_CAPABILITY: &str = "workspaceFocusV1"; +/// The host answers `workspace.wake` by starting a session again for every +/// terminal tab a workspace sleep stopped, as opening it in an app does. +/// Additive: an older host rejects the verb, so the CLI feature-checks this. +pub const RUNTIME_HOST_WORKSPACE_WAKE_CAPABILITY: &str = "workspaceWakeV1"; +/// `workspace.bufferGuard.acquire` accepts `resolution: save | discard`. With +/// `save`, desktop apps that announced this same name in `hello` receive +/// `checkoutBuffersSaveRequested` and save their dirty editors in scope before +/// they acknowledge; with `discard` their lock event carries the resolution +/// and they discard those editors first. Other apps get the plain lock and +/// report dirty editors as blockers. Additive. +pub const RUNTIME_HOST_CHECKOUT_BUFFER_SAVE_CAPABILITY: &str = "checkoutBufferSaveV1"; /// The host stores one linked issue per workspace (`linkedIssue.*`), fetches /// issues through `issue.fetch`, and links one from `workspace.createManaged` /// when it carries `issueUrl`. Additive: an older host rejects the verbs and @@ -74,6 +90,20 @@ pub const RUNTIME_HOST_LINKED_ISSUES_CAPABILITY: &str = "linkedIssuesV1"; pub const RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_CAPABILITY: &str = "pullRequestWatchExecutionV1"; pub const RUNTIME_HOST_PULL_REQUEST_WATCH_CAPABILITY: &str = "pullRequestWatchV1"; +/// The runtime runs Watch and Fix (evaluation, dispatch, and the fixAndMerge +/// merge) for GitHub, GitLab, and Azure DevOps, so clients stop watching those +/// forges themselves. Additive to `pullRequestWatchExecutionV1`, which keeps +/// meaning GitHub. +pub const RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_V2_CAPABILITY: &str = + "pullRequestWatchExecutionV2"; +/// `mobile.pullRequest.*` (snapshot, summaries, writes, and Ship) work on +/// GitHub, GitLab, and Azure DevOps through the runtime's forge providers. +pub const RUNTIME_HOST_PULL_REQUEST_FORGES_CAPABILITY: &str = "pullRequestForgesV1"; +/// `pullRequest.agentDispatch` answers or delivers the Restack and Fix Failed +/// Checks prompts, which the runtime owns. +pub const RUNTIME_HOST_PULL_REQUEST_AGENT_DISPATCH_CAPABILITY: &str = "pullRequestAgentDispatchV1"; +/// `pullRequestStack.get|create|link|merge` for GitHub-native stacks. +pub const RUNTIME_HOST_PULL_REQUEST_STACKS_CAPABILITY: &str = "pullRequestStacksV1"; // Advertised once mobile clients may call workspace mutations (pin, link, // create/remove managed, tab removal). Mobile apps feature-check this instead // of the strict-equality mobile protocol version. @@ -164,6 +194,9 @@ pub const RUNTIME_HOST_ORCHESTRATION_WAIT_CAPABILITY: &str = "orchestrationWaitV // Advertised once the host answers `inbox.*` for questions from addresses // outside Alera (`ext:`), on desktop, CLI and paired phones alike. pub const RUNTIME_HOST_INBOX_CAPABILITY: &str = "inboxV1"; +// Advertised once `inbox.ask` records an MCP client's `externalOrigin` from +// local clients and `inbox.threads`/`inbox.wait` filter by `originClientId`. +pub const RUNTIME_HOST_INBOX_ORIGIN_CAPABILITY: &str = "inboxOriginV1"; // Advertised once dispatch honors the explicit agent adapter override. Older // hosts ignore assumeAgent, so callers must negotiate this capability first. pub const RUNTIME_HOST_ORCHESTRATION_ASSUME_AGENT_CAPABILITY: &str = "orchestrationAssumeAgentV1"; @@ -196,6 +229,9 @@ pub const RUNTIME_HOST_TERMINAL_DRIVER_CAPABILITY: &str = "terminalDriverPresenc // preserving its handle and scrollback. Older hosts remain attachable. pub const RUNTIME_HOST_TERMINAL_RESTART_CAPABILITY: &str = "terminalRestartV1"; pub const RUNTIME_HOST_TERMINAL_PULSE_CAPABILITY: &str = "terminalPulseV1"; +// Advertised once `terminal.restart` with `headless: true` picks the launch +// and types the tab's startup command for a caller that renders nothing. +pub const RUNTIME_HOST_TERMINAL_HEADLESS_RESTART_CAPABILITY: &str = "terminalHeadlessRestartV1"; /// The client may ask, in its `hello`, to switch this connection to /// length-prefixed binary frames. Negotiated per client, so an older app and /// the `alera` CLI keep getting newline-delimited JSON from the same host. diff --git a/rust/alera-cli/src/terminal_host/relay_mcp.rs b/rust/alera-cli/src/terminal_host/relay_mcp.rs index b392d35b3..dddde4fbe 100644 --- a/rust/alera-cli/src/terminal_host/relay_mcp.rs +++ b/rust/alera-cli/src/terminal_host/relay_mcp.rs @@ -16,7 +16,7 @@ use tokio_tungstenite::tungstenite::Message; use super::relay_runtime_auth::{CallGrantClaims, GrantVerifier}; use super::relay_wire; use crate::mcp_settings::McpAccess; -use crate::mcp_tools::{find_tool, run_tool, ToolAccess, ToolExecution, ToolResult}; +use crate::mcp_tools::{find_tool, run_tool, CallOrigin, ToolAccess, ToolExecution, ToolResult}; pub(super) const MCP_CLIENT_ID: &str = "~mcp"; const MAX_CONCURRENT_CALLS: usize = 4; @@ -186,12 +186,16 @@ impl McpLink { _ = &mut cancelled => return Err(cancelled_error()), prepared = self.prepare(id, grant, tool_name, account_id, runtime_id) => prepared?, }; - let (tool, _permit, client_name) = permit; + let (tool, _permit, origin) = permit; if cancelled.try_recv().is_ok() { return Err(cancelled_error()); } - tracing::info!(tool = tool_name, client = %client_name, "running MCP tool"); - Ok(run_tool(&self.execution, &tool, arguments, Some(cancelled)).await) + tracing::info!( + tool = tool_name, + client = origin.client_name.as_deref().unwrap_or_default(), + "running MCP tool" + ); + Ok(run_tool(&self.execution, &tool, arguments, &origin, Some(cancelled)).await) } async fn prepare( @@ -205,7 +209,7 @@ impl McpLink { ( crate::mcp_tools::ToolSpec, tokio::sync::SemaphorePermit<'_>, - String, + CallOrigin, ), (&'static str, String), > { @@ -221,17 +225,26 @@ impl McpLink { format!("This runtime does not provide {tool_name}. Update Alera on it."), ) })?; - if tool.access != ToolAccess::Read && claims.access != "execute" { + let granted = ToolAccess::from_grant(&claims.access).unwrap_or(ToolAccess::Read); + if tool.access > granted { return Err(( "access_denied", - "The call grant only allows reading.".into(), + format!("The call grant only allows {} tools.", granted.as_str()), )); } if !self.access.allows(tool.access) { - return Err(( - "runtime_read_only", - "MCP Control on this runtime only allows reading.".into(), - )); + let (code, message) = if tool.access == ToolAccess::Admin { + ( + "runtime_not_admin", + "MCP Control on this runtime does not allow administrative tools.", + ) + } else { + ( + "runtime_read_only", + "MCP Control on this runtime only allows reading.", + ) + }; + return Err((code, message.into())); } let permit = self.permits.acquire().await.map_err(|_| { ( @@ -239,7 +252,13 @@ impl McpLink { "The runtime is shutting down.".to_owned(), ) })?; - Ok((tool, permit, claims.client_name)) + let origin = CallOrigin::remote( + &claims.client_id, + &claims.client_name, + &claims.grant_id, + &claims.jti, + ); + Ok((tool, permit, origin)) } fn authorize( diff --git a/rust/alera-cli/src/terminal_host/relay_runtime_auth.rs b/rust/alera-cli/src/terminal_host/relay_runtime_auth.rs index fcdbf737a..eef41e6be 100644 --- a/rust/alera-cli/src/terminal_host/relay_runtime_auth.rs +++ b/rust/alera-cli/src/terminal_host/relay_runtime_auth.rs @@ -283,7 +283,7 @@ impl GrantVerifier { // authorization server accepts. || claims.client_id.is_empty() || claims.client_id.len() > 2048 - || !matches!(claims.access.as_str(), "read" | "execute") + || !matches!(claims.access.as_str(), "read" | "execute" | "admin") { anyhow::bail!("MCP call grant is expired or invalid"); } diff --git a/rust/alera-cli/src/terminal_host/server.rs b/rust/alera-cli/src/terminal_host/server.rs index 98215c880..1a386bb9d 100644 --- a/rust/alera-cli/src/terminal_host/server.rs +++ b/rust/alera-cli/src/terminal_host/server.rs @@ -61,6 +61,7 @@ mod agent_presence_reconciliation; mod agent_profile_launch_requests; mod agent_profile_session_resume; mod agent_prompt_composition; +mod agent_skill_installs; mod agent_title_context; mod agent_title_events; mod agent_title_generation; @@ -81,7 +82,10 @@ mod ai_assist_opencode_go; mod ai_assist_opencode_go_requests; mod ai_assist_operation_registry; mod ai_assist_process_journal; +mod ai_assist_project_inference; mod ai_assist_pull_request_details; +mod ai_assist_pull_request_details_jobs; +mod ai_assist_pull_request_details_resume; mod ai_assist_requests; mod ai_assist_speech_message; mod ai_assist_workspace_identity; @@ -246,9 +250,15 @@ mod prompt_file_requests; mod prompt_file_store; mod prompt_image_requests; mod prompt_image_store; +mod prompt_workspace_creation; +mod prompt_workspace_operation; +mod prompt_workspace_pipeline; +mod prompt_workspace_requests; +mod prompt_workspace_setup; mod pty_event_forwarder; mod pty_events; mod pty_exit_deferral; +mod pull_request_forges; mod pull_request_watch_evaluation; mod pull_request_watch_requests; #[cfg(test)] @@ -260,6 +270,8 @@ mod request_payloads; mod requests; mod resource_requests; mod runtime_change_broadcasts; +mod runtime_event_forwarder; +mod runtime_event_requests; mod runtime_mutation_barrier; #[path = "server/runtime_mutation_completion.rs"] mod runtime_mutation_completion; @@ -311,6 +323,7 @@ mod voice_stt; mod voice_transcript; mod voice_tts; mod voice_turn_jobs; +mod webhook_requests; mod workflow_catalog_requests; #[cfg(test)] mod workflow_catalog_tests; @@ -373,6 +386,9 @@ struct ClientState { authenticated: bool, shared_checkout_workspaces: bool, checkout_buffer_guards: bool, + /// The desktop app said in `hello` that it saves or discards its editor + /// buffers when a buffer guard asks it to (`checkoutBufferSaveV1`). + checkout_buffer_save: bool, /// The desktop app said in `hello` that it handles `workspaceFocusRequested`. workspace_focus: bool, binary_frames: bool, @@ -407,6 +423,8 @@ struct ServerActor { ssh_bootstrap_jobs: HashMap, host_links: crate::terminal_host::host_link_registry::HostLinkRegistry, project_clone_jobs: HashMap>, + /// Cancel handles of running New Workspace from Prompt operations. + prompt_workspace_operations: HashMap>, agent_title_jobs: HashMap, managed_workspace_jobs: usize, workflow_execution: workflow_launch_requests::execution::ExecutionPump, @@ -668,6 +686,7 @@ impl ServerActor { authenticated: false, shared_checkout_workspaces: false, checkout_buffer_guards: false, + checkout_buffer_save: false, workspace_focus: false, binary_frames: false, kind, @@ -1011,6 +1030,12 @@ impl ServerActor { ServerCommand::ProjectCloneFinished { job_id } => { self.handle_project_clone_finished(job_id).await } + ServerCommand::PromptWorkspaceOperationChanged { operation_id } => { + self.handle_prompt_workspace_operation_changed(operation_id) + } + ServerCommand::PromptWorkspaceOperationFinished { operation_id } => { + self.handle_prompt_workspace_operation_finished(operation_id) + } ServerCommand::CoordinatorTick { run_id } => { self.handle_board_coordinator_tick(run_id).await } diff --git a/rust/alera-cli/src/terminal_host/server/actor_test_harness.rs b/rust/alera-cli/src/terminal_host/server/actor_test_harness.rs index 36dddbdd7..fab0ea518 100644 --- a/rust/alera-cli/src/terminal_host/server/actor_test_harness.rs +++ b/rust/alera-cli/src/terminal_host/server/actor_test_harness.rs @@ -28,6 +28,7 @@ impl ClientState { authenticated: true, shared_checkout_workspaces: true, checkout_buffer_guards: true, + checkout_buffer_save: true, workspace_focus: app_client, binary_frames: false, kind: ClientKind::Local, @@ -50,6 +51,7 @@ pub(super) fn mobile_client(handle: ClientHandle, device: &str) -> ClientState { authenticated: true, shared_checkout_workspaces: true, checkout_buffer_guards: true, + checkout_buffer_save: true, workspace_focus: false, binary_frames: false, kind: ClientKind::Mobile, @@ -67,6 +69,7 @@ pub(super) fn local_client(handle: ClientHandle) -> ClientState { authenticated: true, shared_checkout_workspaces: true, checkout_buffer_guards: true, + checkout_buffer_save: true, workspace_focus: false, binary_frames: false, kind: ClientKind::Local, @@ -106,6 +109,7 @@ pub(super) async fn test_actor( inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), diff --git a/rust/alera-cli/src/terminal_host/server/agent_skill_installs.rs b/rust/alera-cli/src/terminal_host/server/agent_skill_installs.rs new file mode 100644 index 000000000..ffd2f4abf --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/agent_skill_installs.rs @@ -0,0 +1,191 @@ +//! `agentSkill.install` and `agentSkill.state`: installing the Alera skills +//! coding agents use, as a runtime job. +//! +//! An install can take minutes on a cold clone, longer than an MCP client +//! waits for one call. The job belongs to the runtime, not to the request, so +//! it finishes even when the caller stops waiting, and only one runs at a +//! time: a second request while one is running is refused instead of starting +//! another installer against the same folders. `agentSkill.state` reports the +//! running job and the last result. + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use chrono::Utc; +use serde_json::{json, Value}; + +use crate::agent_status::reconcile_agent_integrations; +use crate::host_tools::{install_skills, SkillKind, SkillRunner}; +use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::protocol::event; + +use super::host_service_requests::required_non_blank; +use super::{ServerActor, ServerCommand}; + +/// The message a request gets while another install is running. +pub(crate) const INSTALL_RUNNING_MESSAGE: &str = "An agent skill install is already running."; + +#[derive(Default)] +struct InstallState { + running: Option, + last: Option, +} + +fn state() -> MutexGuard<'static, InstallState> { + static STATE: OnceLock> = OnceLock::new(); + STATE + .get_or_init(Mutex::default) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// The running install, if any, and the last finished one. +pub(super) fn install_state() -> Value { + let state = state(); + json!({ "running": state.running, "last": state.last }) +} + +pub(super) fn install_running() -> bool { + state().running.is_some() +} + +/// Clears the running install when its task ends, even by panic, so a failed +/// install never blocks the next one. +struct RunningInstall; + +impl Drop for RunningInstall { + fn drop(&mut self) { + state().running = None; + } +} + +fn claim(operation_id: &str, skills: &[SkillKind]) -> HostResult { + let mut state = state(); + if state.running.is_some() { + return Err(HostError::state(INSTALL_RUNNING_MESSAGE)); + } + state.running = Some(json!({ + "operationId": operation_id, + "skills": skills.iter().map(|kind| kind.id()).collect::>(), + "startedAt": Utc::now(), + })); + Ok(RunningInstall) +} + +/// `skills` lists several skills; `skill` names one, as the mobile app sends. +fn requested_skills(payload: &Value) -> HostResult> { + let names = match payload.get("skills").and_then(Value::as_array) { + Some(names) => names + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + None => vec![required_non_blank(payload, "skill")?], + }; + let kinds = names + .iter() + .map(|name| { + SkillKind::parse(name).ok_or_else(|| { + HostError::format( + "skill must be cli, orchestration, automations, or agentProfiles.", + ) + }) + }) + .collect::>>()?; + if kinds.is_empty() { + return Err(HostError::format("skills must name at least one skill.")); + } + Ok(kinds) +} + +impl ServerActor { + pub(super) fn start_skill_install_request( + &mut self, + client_id: u64, + request_id: i64, + payload: &Value, + ) -> HostResult<()> { + let operation_id = required_non_blank(payload, "operationId")?; + let skills = requested_skills(payload)?; + let runner_name = required_non_blank(payload, "runner")?; + let runner = SkillRunner::parse(&runner_name) + .ok_or_else(|| HostError::format("runner must be auto, npx, or bunx."))?; + let running = claim(&operation_id, &skills)?; + // The progress events name one skill, as the apps expect; a batch + // reports under its first. + let skill_name = skills[0].id().to_owned(); + self.broadcast_authenticated(event( + "agentSkillInstallProgress", + json!({ + "operationId": operation_id, + "skill": skill_name, + "phase": "installing", + "message": "Installing Skill", + }), + )); + let store = self.runtime_store.clone(); + let runtime_dir = self.runtime_dir.clone(); + let inbox = self.inbox.clone(); + tokio::spawn(async move { + let install_result = install_skills(&skills, runner).await; + let mut value = serde_json::to_value(&install_result) + .map_err(|error| HostError::state(error.to_string())); + if install_result.succeeded && skills.contains(&SkillKind::Orchestration) { + // The agent status hooks are part of the orchestration contract. + if let Ok(settings) = store.agent_status_hook_settings().await { + let warnings = tokio::task::spawn_blocking(move || { + reconcile_agent_integrations(&runtime_dir, &settings) + }) + .await + .unwrap_or_else(|error| vec![error.to_string()]); + if let Ok(Value::Object(object)) = &mut value { + object.insert("hookWarnings".to_string(), json!(warnings)); + } + } + } + if let Ok(Value::Object(object)) = &mut value { + object.insert("operationId".to_string(), json!(operation_id)); + object.insert("finishedAt".to_string(), json!(Utc::now())); + state().last = Some(Value::Object(object.clone())); + } + drop(running); + let _ = inbox + .send_wait(ServerCommand::HostToolFinished { + client_id, + request_id, + result: value, + operation_id: Some(operation_id), + skill: Some(skill_name), + }) + .await; + }); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_batch_or_a_single_skill_is_accepted() { + let batch = requested_skills(&json!({ "skills": ["cli", "agentProfiles"] })).unwrap(); + assert_eq!(batch, [SkillKind::Cli, SkillKind::AgentProfiles]); + let single = requested_skills(&json!({ "skill": "orchestration" })).unwrap(); + assert_eq!(single, [SkillKind::Orchestration]); + assert!(requested_skills(&json!({ "skills": ["nope"] })).is_err()); + assert!(requested_skills(&json!({ "skills": [] })).is_err()); + assert!(requested_skills(&json!({})).is_err()); + } + + #[test] + fn only_one_install_runs_and_a_finished_one_frees_the_slot() { + let first = claim("op-1", &[SkillKind::Cli]).unwrap(); + assert!(install_running()); + assert_eq!(install_state()["running"]["operationId"], "op-1"); + let refused = claim("op-2", &[SkillKind::Cli]).err().unwrap(); + assert_eq!(refused.wire_message(), INSTALL_RUNNING_MESSAGE); + drop(first); + assert!(!install_running()); + drop(claim("op-3", &[SkillKind::Cli]).unwrap()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_project_inference.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_project_inference.rs new file mode 100644 index 000000000..84b962ae6 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_project_inference.rs @@ -0,0 +1,191 @@ +//! Workspace identity for a prompt that does not name its project. +//! +//! `aiText.workspaceIdentity.generate` with `inferProject` and no `projectId` +//! asks AI Assist to pick one of the registered projects together with the +//! name, branch, and section, in one call. An answer that names no listed +//! project returns `projectUnknown` instead of a guess. + +use alera_core::runtime::{Project, WorkspaceSection}; +use serde_json::{json, Value}; + +use super::ai_assist_operation_registry::active_generations; +use super::ai_assist_workspace_identity::parse_workspace_identity; +use super::host_service_requests::required_non_blank; +use super::{ServerActor, ServerCommand}; +use crate::terminal_host::host_error::{HostError, HostResult}; + +/// Projects listed in the prompt; more would crowd out the task itself. +const MAX_PROJECT_CHOICES: usize = 50; +const UNKNOWN_PROJECT: &str = "Unknown"; + +impl ServerActor { + pub(super) fn start_ai_assist_project_identity( + &mut self, + client_id: u64, + request_id: i64, + payload: &Value, + ) -> HostResult<()> { + let operation_id = required_non_blank(payload, "operationId")?; + let prompt = required_non_blank(payload, "prompt")?; + let auto_assign_section = payload + .get("autoAssignSection") + .and_then(Value::as_bool) + .unwrap_or(false); + let store = self.runtime_store.clone(); + let inbox = self.inbox.clone(); + let (registration, cancel_rx) = active_generations().register(operation_id, None)?; + tokio::spawn(async move { + let result = async { + let projects = store + .list_projects() + .await + .map_err(|error| HostError::state(error.to_string()))?; + if projects.is_empty() { + return Err(HostError::state("No projects are registered in Alera.")); + } + let settings = store + .effective_ai_assist_settings() + .await + .map_err(|error| HostError::state(error.to_string()))?; + if !settings.enabled { + return Err(HostError::state("AI Assist is disabled.")); + } + let sections = if auto_assign_section { + store.list_workspace_sections().await.unwrap_or_default() + } else { + Vec::new() + }; + let choices = &projects[..projects.len().min(MAX_PROJECT_CHOICES)]; + let custom = settings + .instructions_by_operation + .get("workspaceIdentity") + .cloned() + .unwrap_or_default(); + let text = project_identity_prompt(&prompt, &custom, choices, §ions); + let directory = neutral_working_directory(); + let (raw, _) = super::ai_assist_generation::generate_ai_assist_output( + &settings, + "workspaceIdentity", + &text, + &directory, + cancel_rx, + ) + .await?; + parse_project_identity(&raw, choices, §ions) + } + .await; + drop(registration); + let _ = inbox + .send_wait(ServerCommand::AiAssistFinished { + client_id, + request_id, + result, + }) + .await; + }); + Ok(()) + } +} + +/// AI Assist runs outside any one project when choosing between them. +fn neutral_working_directory() -> String { + std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().into_owned()) +} + +pub(super) fn project_identity_prompt( + task: &str, + custom_instructions: &str, + projects: &[Project], + sections: &[WorkspaceSection], +) -> String { + let fields = if sections.is_empty() { + "project, workspaceName, and branchName" + } else { + "project, workspaceName, branchName, and section" + }; + let mut lines = vec![ + "Choose the project for a new development workspace and generate its identity from the user's task.".to_owned(), + format!("Return only one compact JSON object with exactly these string fields: {fields}."), + String::new(), + "Rules:".to_owned(), + format!("- project: exactly one project name from the list below that the task belongs to, or \"{UNKNOWN_PROJECT}\" when the task does not clearly belong to one of them."), + "- workspaceName: concise human-readable title, title case, 2 to 6 words.".to_owned(), + "- branchName: lowercase valid Git branch, use kebab-case, and start with feat/, fix/, chore/, docs/, refactor/, test/, or perf/.".to_owned(), + ]; + if !sections.is_empty() { + lines.push("- section: the workspace section the task belongs to. Answer with exactly one of the section names listed below, or \"Others\" when none fits.".to_owned()); + } + lines.extend([ + "- Describe the requested outcome, not the implementation process.".to_owned(), + "- Do not include markdown, explanations, quotes around the whole object, or extra fields." + .to_owned(), + String::new(), + "Projects:".to_owned(), + ]); + for project in projects { + let name: String = project.name.trim().chars().take(200).collect(); + let path: String = project.repo_path.trim().chars().take(500).collect(); + lines.push(format!("- {name} ({path})")); + } + if !sections.is_empty() { + lines.push(String::new()); + lines.push("Sections:".to_owned()); + for section in sections { + lines.push(format!("- {}", section.name)); + } + } + lines.extend([ + String::new(), + "User task:".to_owned(), + task.trim().to_owned(), + ]); + if !custom_instructions.trim().is_empty() { + lines.extend([ + String::new(), + "Additional user instructions:".to_owned(), + custom_instructions.trim().to_owned(), + ]); + } + lines.join("\n") +} + +/// The identity plus `projectId`, or `projectUnknown: true` with no identity +/// when the answer names no listed project. A name shared by two projects is +/// unknown too: guessing between them is what this avoids. +pub(super) fn parse_project_identity( + raw: &str, + projects: &[Project], + sections: &[WorkspaceSection], +) -> HostResult { + let project_answer = project_field(raw); + let matches = project_answer + .as_deref() + .map(|answer| { + projects + .iter() + .filter(|project| project.name.trim().eq_ignore_ascii_case(answer)) + .collect::>() + }) + .unwrap_or_default(); + let [project] = matches.as_slice() else { + return Ok(json!({ "projectUnknown": true, "projectAnswer": project_answer })); + }; + let mut identity = parse_workspace_identity(raw, sections)?; + identity["projectId"] = json!(project.id); + Ok(identity) +} + +fn project_field(raw: &str) -> Option { + let start = raw.find('{')?; + let end = raw.rfind('}')?; + let value: Value = serde_json::from_str(raw.get(start..=end)?).ok()?; + let answer = value.get("project")?.as_str()?.trim(); + (!answer.is_empty() && !answer.eq_ignore_ascii_case(UNKNOWN_PROJECT) && answer.len() <= 200) + .then(|| answer.to_owned()) +} + +#[cfg(test)] +#[path = "ai_assist_project_inference_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_project_inference_tests.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_project_inference_tests.rs new file mode 100644 index 000000000..f93d22354 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_project_inference_tests.rs @@ -0,0 +1,70 @@ +use alera_core::runtime::{Project, ProjectKind, WorkspaceSection}; +use chrono::Utc; + +use super::{parse_project_identity, project_identity_prompt}; + +fn project(id: &str, name: &str) -> Project { + Project { + id: id.to_owned(), + name: name.to_owned(), + repo_path: format!("/repos/{id}"), + created_at: Utc::now(), + updated_at: Utc::now(), + kind: ProjectKind::GitRepository, + } +} + +fn section(id: &str, name: &str) -> WorkspaceSection { + WorkspaceSection { + id: id.to_owned(), + name: name.to_owned(), + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +#[test] +fn prompt_lists_projects_and_sections_and_asks_for_the_project() { + let text = project_identity_prompt( + "Fix the login screen", + "Use fix/ branches.", + &[project("p1", "Alera"), project("p2", "EducUp")], + &[section("s1", "Alera")], + ); + assert!(text.contains("project, workspaceName, branchName, and section")); + assert!(text.contains("- Alera (/repos/p1)")); + assert!(text.contains("- EducUp (/repos/p2)")); + assert!(text.contains("Sections:\n- Alera")); + assert!(text.contains("Fix the login screen")); + assert!(text.contains("Use fix/ branches.")); +} + +#[test] +fn a_listed_project_resolves_to_its_id_with_section() { + let projects = [project("p1", "Alera"), project("p2", "EducUp")]; + let sections = [section("s1", "Alera")]; + let value = parse_project_identity( + r#"{"project":"alera","workspaceName":"Fix Login","branchName":"fix/login","section":"Alera"}"#, + &projects, + §ions, + ) + .unwrap(); + assert_eq!(value["projectId"], "p1"); + assert_eq!(value["branchName"], "fix/login"); + assert_eq!(value["sectionId"], "s1"); +} + +#[test] +fn unknown_unlisted_or_ambiguous_projects_are_not_guessed() { + let projects = [project("p1", "Alera"), project("p2", "alera")]; + for raw in [ + r#"{"project":"Unknown","workspaceName":"X","branchName":"fix/x"}"#, + r#"{"project":"Other","workspaceName":"X","branchName":"fix/x"}"#, + r#"{"project":"ALERA","workspaceName":"X","branchName":"fix/x"}"#, + r#"{"workspaceName":"X","branchName":"fix/x"}"#, + ] { + let value = parse_project_identity(raw, &projects, &[]).unwrap(); + assert_eq!(value["projectUnknown"], true, "{raw}"); + assert!(value.get("projectId").is_none()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details.rs index f4f6d2d36..f4a7d8ca2 100644 --- a/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details.rs +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details.rs @@ -15,6 +15,7 @@ use super::ai_assist_commit_message::{ }; use super::ai_assist_generation::generate_ai_assist_output; use super::ai_assist_operation_registry::active_generations; +use super::ai_assist_pull_request_details_resume::is_resumable_pull_request_details; use super::host_service_requests::required_non_blank; use super::mobile_source_control_snapshot::git_host_error; use super::mobile_workspace_file_requests::spawn_blocking_workspace; @@ -22,6 +23,7 @@ use super::remote_ai_assist_requests::{effective_ai_assist_settings, hub_ai_assi use super::{ServerActor, ServerCommand}; const OPERATION: &str = "pullRequestDetails"; +const VERB: &str = "aiText.pullRequestDetails.generate"; const COMMITS_BUDGET: usize = 8000; const FILES_BUDGET: usize = 6000; const INSTRUCTIONS_BUDGET: usize = 4000; @@ -39,6 +41,9 @@ impl ServerActor { request_id: i64, payload: &Value, ) -> HostResult<()> { + if is_resumable_pull_request_details(VERB, payload) { + return self.start_resumable_pull_request_details(client_id, request_id, payload); + } let operation_id = required_non_blank(payload, "operationId")?; let workspace_id = required_non_blank(payload, "workspaceId")?; let base_branch = required_non_blank(payload, "baseBranch")?; @@ -55,13 +60,7 @@ impl ServerActor { cancel_rx, ) .await - .map(|(details, agent_label)| { - json!({ - "title": details.title, - "body": details.body, - "agentLabel": agent_label, - }) - }); + .map(|(details, agent_label)| details_value(&details, &agent_label)); drop(registration); let _ = inbox .send_wait(ServerCommand::AiAssistFinished { @@ -75,6 +74,15 @@ impl ServerActor { } } +/// The verb's answer: the details and the label of the agent that wrote them. +pub(super) fn details_value(details: &PullRequestDetails, agent_label: &str) -> Value { + json!({ + "title": details.title, + "body": details.body, + "agentLabel": agent_label, + }) +} + /// Generates the details for the range between `base_branch` and HEAD, and /// the label of the agent that wrote them. pub(super) async fn generate_pull_request_details( diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_jobs.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_jobs.rs new file mode 100644 index 000000000..5058230d9 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_jobs.rs @@ -0,0 +1,233 @@ +//! Resumable pull request details generations. +//! +//! A generation can outlast the caller: AI Assist allows up to ten minutes, +//! while an MCP client abandons a call after about one. A request that names +//! a wait therefore starts the generation as a job owned by the runtime, not +//! by the connection, and answers `running` when the wait ends first. Calling +//! again with the same retry key attaches to that job, or reads its result, +//! instead of generating again. Results stay for [`RESULT_TTL`]; a failure is +//! reported once and then forgotten, so the next call with that key retries. + +use std::collections::HashMap; +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use tokio::sync::watch; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +/// How long a finished result can still be read with its retry key. +pub(super) const RESULT_TTL: Duration = Duration::from_secs(15 * 60); +/// Jobs kept at once, running or finished. Finished ones make room first. +pub(super) const JOB_CAPACITY: usize = 64; + +pub(super) type Outcome = HostResult; +type OutcomeReceiver = watch::Receiver>; + +pub(super) struct GenerationJobs { + state: Mutex, + ttl: Duration, + capacity: usize, +} + +#[derive(Default)] +struct JobsState { + jobs: HashMap, + next_serial: u64, +} + +struct Job { + /// Tells a late completion apart from a newer job under the same key. + serial: u64, + outcome: watch::Sender>, + finished_at: Option, +} + +pub(super) enum Attachment { + /// A job under this key already exists; this receiver follows it. + Joined(OutcomeReceiver), + /// No job existed: the caller runs one and reports through `completion`. + Started { + outcome: OutcomeReceiver, + completion: JobCompletion, + }, +} + +/// Reports a job's outcome. Dropping it unreported, as an aborted or +/// panicking job does, reports a failure so the key does not stay running. +pub(super) struct JobCompletion { + jobs: Arc, + key: String, + serial: u64, + reported: bool, +} + +impl GenerationJobs { + pub(super) fn new(ttl: Duration, capacity: usize) -> Self { + Self { + state: Mutex::new(JobsState::default()), + ttl, + capacity, + } + } + + fn lock(&self) -> HostResult> { + self.state + .lock() + .map_err(|_| HostError::state("AI Assist state is unavailable.")) + } + + pub(super) fn attach(self: &Arc, key: &str, now: Instant) -> HostResult { + let mut state = self.lock()?; + let ttl = self.ttl; + state.jobs.retain(|_, job| { + job.finished_at + .is_none_or(|finished| now.saturating_duration_since(finished) < ttl) + }); + if let Some(job) = state.jobs.get(key) { + let receiver = job.outcome.subscribe(); + let failed = matches!(&*receiver.borrow(), Some(Err(_))); + if failed { + state.jobs.remove(key); + } + return Ok(Attachment::Joined(receiver)); + } + if state.jobs.len() >= self.capacity { + let oldest = state + .jobs + .iter() + .filter_map(|(key, job)| Some((job.finished_at?, key.clone()))) + .min(); + let Some((_, oldest)) = oldest else { + return Err(HostError::state( + "Too many pull request details are being generated. Try again when one finishes.", + )); + }; + state.jobs.remove(&oldest); + } + let serial = state.next_serial; + state.next_serial += 1; + let (sender, receiver) = watch::channel(None); + state.jobs.insert( + key.to_owned(), + Job { + serial, + outcome: sender, + finished_at: None, + }, + ); + Ok(Attachment::Started { + outcome: receiver, + completion: JobCompletion { + jobs: self.clone(), + key: key.to_owned(), + serial, + reported: false, + }, + }) + } + + /// Whether a job is still generating, which keeps the runtime alive. + pub(super) fn has_running(&self) -> bool { + self.lock() + .is_ok_and(|state| state.jobs.values().any(|job| job.finished_at.is_none())) + } + + fn finish(&self, key: &str, serial: u64, outcome: Outcome, now: Instant) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let Some(job) = state.jobs.get_mut(key).filter(|job| job.serial == serial) else { + return; + }; + let failed = outcome.is_err(); + job.outcome.send_replace(Some(outcome)); + job.finished_at = Some(now); + // A caller that is waiting receives the failure now, so the key is free + // for a retry. Without one, the next call with the key receives it. + if failed && job.outcome.receiver_count() > 0 { + state.jobs.remove(key); + } + } +} + +impl JobCompletion { + pub(super) fn finish(mut self, outcome: Outcome) { + self.report(outcome); + } + + fn report(&mut self, outcome: Outcome) { + if !self.reported { + self.reported = true; + self.jobs + .finish(&self.key, self.serial, outcome, Instant::now()); + } + } +} + +impl Drop for JobCompletion { + fn drop(&mut self) { + self.report(Err(HostError::state( + "Pull request details generation stopped before it finished.", + ))); + } +} + +/// Attaches to the job under `key`, or starts `generate` as a new one that +/// runs on its own task, then waits up to `wait`. `None` means the job is +/// still running; it keeps running whether or not anyone calls again. +pub(super) async fn attach_or_start( + jobs: &Arc, + key: &str, + wait: Duration, + generate: F, +) -> HostResult> +where + F: FnOnce() -> Fut, + Fut: Future + Send + 'static, +{ + let mut outcome = match jobs.attach(key, Instant::now())? { + Attachment::Joined(outcome) => outcome, + Attachment::Started { + outcome, + completion, + } => { + let job = generate(); + tokio::spawn(async move { + let result = job.await; + completion.finish(result); + }); + outcome + } + }; + match tokio::time::timeout(wait, wait_for_outcome(&mut outcome)).await { + Ok(result) => result.map(Some), + Err(_) => Ok(None), + } +} + +async fn wait_for_outcome(receiver: &mut OutcomeReceiver) -> Outcome { + loop { + if let Some(outcome) = receiver.borrow_and_update().clone() { + return outcome; + } + if receiver.changed().await.is_err() { + return receiver.borrow().clone().unwrap_or_else(|| { + Err(HostError::state( + "Pull request details generation stopped before it finished.", + )) + }); + } + } +} + +pub(super) fn pull_request_details_jobs() -> &'static Arc { + static JOBS: OnceLock> = OnceLock::new(); + JOBS.get_or_init(|| Arc::new(GenerationJobs::new(RESULT_TTL, JOB_CAPACITY))) +} + +#[cfg(test)] +#[path = "ai_assist_pull_request_details_jobs_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_jobs_tests.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_jobs_tests.rs new file mode 100644 index 000000000..32df18f4a --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_jobs_tests.rs @@ -0,0 +1,185 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use serde_json::json; +use tokio::sync::oneshot; + +use super::*; + +const SHORT: Duration = Duration::from_millis(20); +const LONG: Duration = Duration::from_secs(5); + +fn jobs(capacity: usize) -> Arc { + Arc::new(GenerationJobs::new(Duration::from_secs(60), capacity)) +} + +fn started(attachment: Attachment) -> (OutcomeReceiver, JobCompletion) { + match attachment { + Attachment::Started { + outcome, + completion, + } => (outcome, completion), + Attachment::Joined(_) => panic!("expected a new job"), + } +} + +fn joined(attachment: Attachment) -> OutcomeReceiver { + match attachment { + Attachment::Joined(outcome) => outcome, + Attachment::Started { .. } => panic!("expected the existing job"), + } +} + +/// A generation that finishes when `release` fires, counting its starts. +fn gated( + starts: &Arc, + release: oneshot::Receiver<()>, +) -> impl FnOnce() -> std::pin::Pin + Send>> { + let starts = starts.clone(); + move || { + starts.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + let _ = release.await; + Ok(json!({ "title": "Add resumable details" })) + }) + } +} + +fn never_started( + starts: &Arc, +) -> impl FnOnce() -> std::pin::Pin + Send>> { + let starts = starts.clone(); + move || { + starts.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Err(HostError::state("started twice")) }) + } +} + +#[tokio::test] +async fn a_job_keeps_running_after_its_caller_gives_up_and_a_retry_reads_it() { + let jobs = jobs(4); + let starts = Arc::new(AtomicUsize::new(0)); + let (release, gate) = oneshot::channel(); + let first = attach_or_start(&jobs, "key", SHORT, gated(&starts, gate)) + .await + .unwrap(); + assert_eq!(first, None, "the wait ends before the generation"); + assert!(jobs.has_running()); + + // The caller is gone; the job finishes on its own task. + release.send(()).unwrap(); + let retry = attach_or_start(&jobs, "key", LONG, never_started(&starts)) + .await + .unwrap(); + assert_eq!(retry, Some(json!({ "title": "Add resumable details" }))); + assert_eq!(starts.load(Ordering::SeqCst), 1); + assert!(!jobs.has_running()); + + // The finished result stays readable without generating again. + let again = attach_or_start(&jobs, "key", SHORT, never_started(&starts)) + .await + .unwrap(); + assert_eq!(again, Some(json!({ "title": "Add resumable details" }))); + assert_eq!(starts.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_second_caller_attaches_to_the_running_job() { + let jobs = jobs(4); + let starts = Arc::new(AtomicUsize::new(0)); + let (release, gate) = oneshot::channel(); + let first = { + let jobs = jobs.clone(); + let generate = gated(&starts, gate); + tokio::spawn(async move { attach_or_start(&jobs, "key", LONG, generate).await }) + }; + while !jobs.has_running() { + tokio::task::yield_now().await; + } + let second = { + let jobs = jobs.clone(); + let generate = never_started(&starts); + tokio::spawn(async move { attach_or_start(&jobs, "key", LONG, generate).await }) + }; + tokio::task::yield_now().await; + release.send(()).unwrap(); + let expected = Some(json!({ "title": "Add resumable details" })); + assert_eq!(first.await.unwrap().unwrap(), expected); + assert_eq!(second.await.unwrap().unwrap(), expected); + assert_eq!(starts.load(Ordering::SeqCst), 1); +} + +#[test] +fn finished_results_expire_after_the_ttl() { + let jobs = Arc::new(GenerationJobs::new(Duration::from_secs(60), 4)); + let now = Instant::now(); + let (_outcome, completion) = started(jobs.attach("key", now).unwrap()); + completion.finish(Ok(json!({ "title": "x" }))); + let receiver = joined(jobs.attach("key", now).unwrap()); + assert!(matches!(&*receiver.borrow(), Some(Ok(value)) if value == &json!({ "title": "x" }))); + let later = Instant::now() + Duration::from_secs(61); + let (_outcome, _completion) = started(jobs.attach("key", later).unwrap()); +} + +#[test] +fn a_failure_is_reported_once_then_the_key_retries() { + let jobs = jobs(4); + let now = Instant::now(); + let (outcome, completion) = started(jobs.attach("key", now).unwrap()); + drop(outcome); + completion.finish(Err(HostError::state("AI Assist timed out."))); + let receiver = joined(jobs.attach("key", now).unwrap()); + assert!( + matches!(&*receiver.borrow(), Some(Err(error)) if error.to_string() == "AI Assist timed out.") + ); + let (_outcome, _completion) = started(jobs.attach("key", now).unwrap()); +} + +#[test] +fn a_failure_with_a_waiting_caller_frees_the_key_at_once() { + let jobs = jobs(4); + let now = Instant::now(); + let (outcome, completion) = started(jobs.attach("key", now).unwrap()); + completion.finish(Err(HostError::state("boom"))); + assert!(matches!(&*outcome.borrow(), Some(Err(_)))); + let (_outcome, _completion) = started(jobs.attach("key", now).unwrap()); +} + +#[test] +fn a_job_dropped_without_an_outcome_reports_a_failure() { + let jobs = jobs(4); + let (outcome, completion) = started(jobs.attach("key", Instant::now()).unwrap()); + assert!(jobs.has_running()); + drop(completion); + assert!(!jobs.has_running()); + assert!(matches!(&*outcome.borrow(), Some(Err(_)))); +} + +#[test] +fn capacity_evicts_the_oldest_finished_job_and_refuses_when_all_run() { + let jobs = jobs(2); + let now = Instant::now(); + let (_a, a) = started(jobs.attach("a", now).unwrap()); + let (_b, _b_running) = started(jobs.attach("b", now).unwrap()); + assert!( + jobs.attach("c", now).is_err(), + "both jobs are still running" + ); + a.finish(Ok(json!({}))); + let (_c, _c_running) = started(jobs.attach("c", now).unwrap()); + // "a" made room for "c", and with both remaining jobs running it cannot + // start over yet. + assert!(jobs.attach("a", now).is_err()); +} + +#[test] +fn a_late_completion_does_not_overwrite_a_newer_job() { + let jobs = Arc::new(GenerationJobs::new(Duration::from_millis(0), 4)); + let now = Instant::now(); + let (_old, old) = started(jobs.attach("key", now).unwrap()); + // Simulate an expired entry replaced by a new job under the same key. + jobs.lock().unwrap().jobs.remove("key"); + let (fresh, _fresh_running) = started(jobs.attach("key", now).unwrap()); + old.finish(Ok(json!({ "title": "stale" }))); + assert!(fresh.borrow().is_none()); + assert!(jobs.has_running()); +} diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_resume.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_resume.rs new file mode 100644 index 000000000..3145d92c4 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_resume.rs @@ -0,0 +1,178 @@ +//! `aiText.pullRequestDetails.generate` with `waitMs`: the runtime owns the +//! generation as a job keyed by who asked and by the caller's `operationId`, +//! answers `running` when the wait ends first, and lets a later request with +//! the same key attach to the job or read its result. Without `waitMs` the +//! verb keeps its original one-shot behavior, which the apps rely on. +//! +//! The key includes the caller's origin, workspace and base branch, because +//! an MCP client chooses its own retry keys: two clients that pick the same +//! key never share a job, and one client reusing a key for another range +//! gets a new generation rather than the wrong text. + +use std::time::Duration; + +use alera_core::runtime::{RuntimeAiAssistSettings, RuntimeStore}; +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::host_link_registry::HostLinkRegistry; + +use super::ai_assist_operation_registry::active_generations; +use super::ai_assist_pull_request_details::{details_value, generate_pull_request_details}; +use super::ai_assist_pull_request_details_jobs::{ + attach_or_start, pull_request_details_jobs, Outcome, RESULT_TTL, +}; +use super::client_delivery::LocalClientRole; +use super::host_service_requests::required_non_blank; +use super::remote_ai_assist_requests::{forward_generation, hub_ai_assist_settings}; +use super::{ClientKind, ServerActor, ServerCommand}; + +const VERB: &str = "aiText.pullRequestDetails.generate"; +const WAIT_KEY: &str = "waitMs"; +/// A single request never waits longer than a result is kept. +const MAX_WAIT: Duration = RESULT_TTL; +const OPERATION_ID_MAX_CHARS: usize = 256; + +/// Whether this request asked for a resumable generation. +pub(super) fn is_resumable_pull_request_details(request_type: &str, payload: &Value) -> bool { + request_type == VERB && payload.get(WAIT_KEY).is_some_and(|wait| !wait.is_null()) +} + +fn requested_wait(payload: &Value) -> HostResult { + payload + .get(WAIT_KEY) + .and_then(Value::as_u64) + .map(|millis| Duration::from_millis(millis).min(MAX_WAIT)) + .ok_or_else(|| HostError::format(format!("{WAIT_KEY} must be a non-negative integer."))) +} + +impl ServerActor { + pub(super) fn start_resumable_pull_request_details( + &mut self, + client_id: u64, + request_id: i64, + payload: &Value, + ) -> HostResult<()> { + let wait = requested_wait(payload)?; + let operation_id = required_non_blank(payload, "operationId")?; + if operation_id.chars().count() > OPERATION_ID_MAX_CHARS { + return Err(HostError::format(format!( + "operationId must be at most {OPERATION_ID_MAX_CHARS} characters." + ))); + } + let workspace_id = required_non_blank(payload, "workspaceId")?; + let base_branch = required_non_blank(payload, "baseBranch")?; + let hub_settings = hub_ai_assist_settings(payload)?; + let scope = self.pull_request_details_scope(client_id, payload)?; + let key = json!([scope, workspace_id, base_branch, operation_id]).to_string(); + let job = DetailsJob { + store: self.runtime_store.clone(), + links: self.host_links.clone(), + workspace_id, + base_branch, + hub_settings, + }; + let inbox = self.inbox.clone(); + tokio::spawn(async move { + let result = + attach_or_start(pull_request_details_jobs(), &key, wait, move || job.run()) + .await + .map(|details| resumable_answer(details, &operation_id)); + let _ = inbox + .send_wait(ServerCommand::AiAssistFinished { + client_id, + request_id, + result, + }) + .await; + }); + Ok(()) + } + + /// Who asked. Only a local connection, the CLI process an MCP tool runs, + /// may name the MCP client it acts for; a phone is its paired device. + fn pull_request_details_scope(&self, client_id: u64, payload: &Value) -> HostResult { + let client = self.clients.get(&client_id); + let local = client.is_some_and(|client| client.kind == ClientKind::Local); + let mcp_origin = payload + .get("origin") + .filter(|origin| origin.get("transport").is_some()); + if let (true, Some(origin)) = (local, mcp_origin) { + let origin = super::inbox_requests::external_origin(origin)?; + return Ok(json!([ + "mcp", + origin["transport"], + origin["clientId"], + origin["grantId"] + ])); + } + Ok(match client { + Some(client) if client.kind == ClientKind::Mobile => { + json!(["mobile", client.mobile_device_id]) + } + Some(client) if client.local_role == LocalClientRole::App => json!(["desktop"]), + _ => json!(["cli"]), + }) + } +} + +/// The details with `status: completed`, or `status: running` while the job +/// is still generating. Both name the caller's `operationId` to resume with. +pub(super) fn resumable_answer(details: Option, operation_id: &str) -> Value { + let mut answer = match details { + Some(Value::Object(fields)) => Value::Object(fields), + Some(_) => json!({}), + None => return json!({ "status": "running", "operationId": operation_id }), + }; + answer["status"] = json!("completed"); + answer["operationId"] = json!(operation_id); + answer +} + +/// One generation, run where the workspace's checkout lives. +struct DetailsJob { + store: RuntimeStore, + links: HostLinkRegistry, + workspace_id: String, + base_branch: String, + hub_settings: Option, +} + +impl DetailsJob { + async fn run(self) -> Outcome { + let workspace = self + .store + .find_workspace(&self.workspace_id) + .await + .map_err(|error| HostError::state(error.to_string()))? + .ok_or_else(|| { + HostError::state(format!("Workspace not found: {}", self.workspace_id)) + })?; + // The job's own id, never the caller's key: retry keys are chosen by + // clients and must not collide with another generation's cancel id. + let generation_id = format!("pull-request-details-{}", uuid::Uuid::new_v4()); + if crate::ssh_remote::is_remote_host_id(Some(&workspace.host_id)) { + let payload = json!({ + "operationId": generation_id, + "workspaceId": workspace.id, + "baseBranch": self.base_branch, + }); + return forward_generation(&self.store, &self.links, VERB, &workspace, payload).await; + } + let (registration, cancel_rx) = active_generations().register(generation_id, None)?; + let result = generate_pull_request_details( + &self.store, + &self.workspace_id, + &self.base_branch, + self.hub_settings, + cancel_rx, + ) + .await; + drop(registration); + result.map(|(details, agent_label)| details_value(&details, &agent_label)) + } +} + +#[cfg(test)] +#[path = "ai_assist_pull_request_details_resume_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_resume_tests.rs b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_resume_tests.rs new file mode 100644 index 000000000..3b694d682 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/ai_assist_pull_request_details_resume_tests.rs @@ -0,0 +1,143 @@ +use std::collections::HashMap; + +use super::super::actor_test_harness::{local_client, mobile_client, test_actor}; +use super::super::{ClientHandle, ClientState}; +use super::*; + +fn mcp(client: &str, call: &str) -> Value { + json!({ "transport": "remote", "clientId": client, "grantId": "g-1", "callId": call }) +} + +async fn actor_with_clients(root: &tempfile::TempDir) -> ServerActor { + let mut clients = HashMap::new(); + clients.insert(1, local_client(ClientHandle::test_channels().0)); + clients.insert(2, ClientState::local(ClientHandle::test_channels().0, true)); + clients.insert(3, mobile_client(ClientHandle::test_channels().0, "pixel")); + test_actor(root, clients, HashMap::new()).await +} + +#[test] +fn only_a_request_with_a_wait_is_resumable() { + assert!(is_resumable_pull_request_details( + VERB, + &json!({ "waitMs": 0 }) + )); + assert!(!is_resumable_pull_request_details(VERB, &json!({}))); + assert!(!is_resumable_pull_request_details( + VERB, + &json!({ "waitMs": null }) + )); + assert!(!is_resumable_pull_request_details( + "aiText.commitMessage.generate", + &json!({ "waitMs": 10 }) + )); +} + +#[test] +fn waits_are_bounded_and_must_be_integers() { + assert_eq!( + requested_wait(&json!({ "waitMs": 1500 })).unwrap(), + Duration::from_millis(1500) + ); + assert_eq!( + requested_wait(&json!({ "waitMs": u64::MAX })).unwrap(), + MAX_WAIT + ); + assert!(requested_wait(&json!({ "waitMs": -1 })).is_err()); + assert!(requested_wait(&json!({ "waitMs": "10" })).is_err()); +} + +#[test] +fn answers_name_the_status_and_the_callers_operation() { + assert_eq!( + resumable_answer(None, "op-1"), + json!({ "status": "running", "operationId": "op-1" }) + ); + assert_eq!( + resumable_answer( + Some(json!({ "title": "Fix", "body": null, "agentLabel": "Codex" })), + "op-1" + ), + json!({ + "status": "completed", + "operationId": "op-1", + "title": "Fix", + "body": null, + "agentLabel": "Codex", + }) + ); +} + +#[tokio::test] +async fn retry_keys_are_scoped_to_the_caller() { + let root = tempfile::tempdir().unwrap(); + let actor = actor_with_clients(&root).await; + let scope = |client_id, payload: Value| { + actor + .pull_request_details_scope(client_id, &payload) + .unwrap() + }; + assert_eq!(scope(1, json!({})), json!(["cli"])); + assert_eq!(scope(2, json!({})), json!(["desktop"])); + assert_eq!(scope(3, json!({})), json!(["mobile", "pixel"])); + let first = scope(1, json!({ "origin": mcp("chatgpt", "c-1") })); + assert_eq!(first, json!(["mcp", "remote", "chatgpt", "g-1"])); + assert_ne!( + first, + scope(1, json!({ "origin": mcp("other-client", "c-1") })) + ); + // The call id changes on every call, so it never splits one client's key. + assert_eq!(first, scope(1, json!({ "origin": mcp("chatgpt", "c-2") }))); + // Only a local connection may name the MCP client it acts for. + assert_eq!( + scope(3, json!({ "origin": mcp("chatgpt", "c-1") })), + json!(["mobile", "pixel"]) + ); + assert!(actor + .pull_request_details_scope(1, &json!({ "origin": { "transport": "carrier" } })) + .is_err()); +} + +#[tokio::test] +async fn malformed_resumable_requests_are_refused_before_starting() { + let root = tempfile::tempdir().unwrap(); + let mut actor = actor_with_clients(&root).await; + for overrides in [ + json!({ "waitMs": -1 }), + json!({ "waitMs": "soon" }), + json!({ "operationId": "x".repeat(OPERATION_ID_MAX_CHARS + 1) }), + json!({ "operationId": " " }), + json!({ "baseBranch": "" }), + ] { + let mut payload = json!({ + "operationId": "op-1", + "workspaceId": "ws-1", + "baseBranch": "main", + "waitMs": 10, + }); + for (key, value) in overrides.as_object().unwrap() { + payload[key] = value.clone(); + } + assert!( + actor + .start_resumable_pull_request_details(1, 7, &payload) + .is_err(), + "{overrides}" + ); + } +} + +#[tokio::test] +async fn a_job_for_a_missing_workspace_fails_without_generating() { + let root = tempfile::tempdir().unwrap(); + let actor = actor_with_clients(&root).await; + let job = DetailsJob { + store: actor.runtime_store.clone(), + links: actor.host_links.clone(), + workspace_id: "missing".into(), + base_branch: "main".into(), + hub_settings: None, + }; + let error = job.run().await.unwrap_err(); + assert!(error.to_string().contains("Workspace not found"), "{error}"); +} diff --git a/rust/alera-cli/src/terminal_host/server/checkout_buffer_guards.rs b/rust/alera-cli/src/terminal_host/server/checkout_buffer_guards.rs index 0050fbe7a..e180ee05f 100644 --- a/rust/alera-cli/src/terminal_host/server/checkout_buffer_guards.rs +++ b/rust/alera-cli/src/terminal_host/server/checkout_buffer_guards.rs @@ -212,12 +212,26 @@ impl ServerActor { ) -> HostResult { let workspace_id = super::requests::require_string_key(payload, "id")?; let operation = super::requests::require_string_key(payload, "operation")?; + let resolution = buffer_guard_resolution(payload)?; if !matches!( operation.as_str(), "removeShared" | "removeManaged" | "handOff" | "handOn" ) { return Err(HostError::format("Choose a supported workspace operation")); } + // Saving or discarding other clients' editors is part of removing a + // workspace on this machine only; a phone or a satellite never asks. + let local = self + .clients + .get(&client_id) + .is_some_and(|client| client.kind == super::ClientKind::Local); + if resolution.is_some() + && (!local || !matches!(operation.as_str(), "removeShared" | "removeManaged")) + { + return Err(HostError::format( + "Only a local workspace removal can save or discard open editors", + )); + } let workspace = self .runtime_store .find_workspace(&workspace_id) @@ -240,9 +254,11 @@ impl ServerActor { "Another workspace operation is awaiting buffer verification on this project and host", )); } + let mut resolving = HashSet::new(); let participants: HashMap<_, _> = self.clients.iter().filter(|(_, client)| client.authenticated && client.kind == ClientKind::Local && client.local_role == LocalClientRole::App) .map(|(id, client)| { if !client.checkout_buffer_guards { return Err(HostError::state(format!("Desktop client {id} must update or disconnect before buffer safety can be verified"))); } + if client.checkout_buffer_save { resolving.insert(*id); } Ok((*id, None)) }).collect::>()?; let scope = self.checkout_buffer_scope(&workspace, &operation).await?; @@ -276,13 +292,12 @@ impl ServerActor { }; let status = guard.status(&id); for participant in guard.participants.keys() { - self.client_write( - *participant, - event( - "checkoutBuffersLock", - json!({"guardId": id, "scope": guard.scope}), - ), + let (name, payload) = lock_event( + &id, + &guard.scope, + resolution.filter(|_| resolving.contains(participant)), ); + self.client_write(*participant, event(name, payload)); } self.checkout_buffer_guards.insert(id.clone(), guard); let inbox = self.inbox.clone(); @@ -373,3 +388,51 @@ impl ServerActor { fn state_error(error: anyhow::Error) -> HostError { HostError::state(error.to_string()) } + +/// What a guard asks the desktop apps to do with dirty editors in its scope +/// before they acknowledge. Without one they only report them as blockers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum BufferGuardResolution { + Save, + Discard, +} + +fn buffer_guard_resolution(payload: &Value) -> HostResult> { + match payload.get("resolution") { + None | Some(Value::Null) => Ok(None), + Some(value) => match value.as_str() { + Some("save") => Ok(Some(BufferGuardResolution::Save)), + Some("discard") => Ok(Some(BufferGuardResolution::Discard)), + _ => Err(HostError::format( + "Buffer guard resolution must be save or discard", + )), + }, + } +} + +/// The event one participant receives. Only an app that announced +/// `checkoutBufferSaveV1` gets a resolution; any other app locks as before. +pub(super) fn lock_event( + id: &str, + scope: &BufferGuardScope, + resolution: Option, +) -> (&'static str, Value) { + match resolution { + Some(BufferGuardResolution::Save) => ( + "checkoutBuffersSaveRequested", + json!({"guardId": id, "scope": scope}), + ), + Some(BufferGuardResolution::Discard) => ( + "checkoutBuffersLock", + json!({"guardId": id, "scope": scope, "resolution": "discard"}), + ), + None => ( + "checkoutBuffersLock", + json!({"guardId": id, "scope": scope}), + ), + } +} + +#[cfg(test)] +#[path = "checkout_buffer_save_tests.rs"] +mod save_tests; diff --git a/rust/alera-cli/src/terminal_host/server/checkout_buffer_save_tests.rs b/rust/alera-cli/src/terminal_host/server/checkout_buffer_save_tests.rs new file mode 100644 index 000000000..921bf2267 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/checkout_buffer_save_tests.rs @@ -0,0 +1,103 @@ +use std::collections::HashMap; + +use serde_json::{json, Value}; +use tokio::sync::mpsc::UnboundedReceiver; + +use crate::terminal_host::client::{ClientFrame, ClientHandle}; + +use super::super::checkout_buffer_guards_tests::fixture; +use super::super::ClientState; + +fn events(receiver: &mut UnboundedReceiver) -> Vec { + let mut events = Vec::new(); + while let Ok(frame) = receiver.try_recv() { + let value = match frame { + ClientFrame::Json(value) => value, + ClientFrame::OrderedControl { frame, .. } => match *frame { + ClientFrame::Json(value) => value, + _ => continue, + }, + _ => continue, + }; + if value.get("event").is_some() { + events.push(value); + } + } + events +} + +/// App 1 can save or discard, app 2 is an older desktop, client 3 is the CLI. +async fn acquire(resolution: Value) -> (Value, Vec, Vec) { + let (_root, mut actor) = fixture().await; + let mut receivers = HashMap::new(); + for (id, save) in [(1, true), (2, false)] { + let (handle, receiver) = ClientHandle::test_channels(); + let mut client = ClientState::local(handle, true); + client.checkout_buffer_save = save; + actor.clients.insert(id, client); + receivers.insert(id, receiver); + } + let status = actor + .checkout_buffer_guard_request( + 3, + "workspace.bufferGuard.acquire", + &json!({"id": "task", "operation": "removeShared", "resolution": resolution}), + ) + .await + .unwrap(); + let saving = events(receivers.get_mut(&1).unwrap()); + let older = events(receivers.get_mut(&2).unwrap()); + (status, saving, older) +} + +#[tokio::test] +async fn save_asks_capable_apps_to_save_and_locks_the_others() { + let (status, saving, older) = acquire(json!("save")).await; + + assert_eq!(status["pendingClients"], 2); + assert_eq!(saving.len(), 1); + assert_eq!(saving[0]["event"], "checkoutBuffersSaveRequested"); + assert_eq!(saving[0]["payload"]["guardId"], status["guardId"]); + assert_eq!( + saving[0]["payload"]["scope"]["tabIds"], + json!(["task-editor"]) + ); + assert_eq!(older.len(), 1); + assert_eq!(older[0]["event"], "checkoutBuffersLock"); + assert!(older[0]["payload"].get("resolution").is_none()); +} + +#[tokio::test] +async fn discard_rides_on_the_lock_for_capable_apps_only() { + let (_, saving, older) = acquire(json!("discard")).await; + + assert_eq!(saving[0]["event"], "checkoutBuffersLock"); + assert_eq!(saving[0]["payload"]["resolution"], "discard"); + assert_eq!(older[0]["event"], "checkoutBuffersLock"); + assert!(older[0]["payload"].get("resolution").is_none()); +} + +#[tokio::test] +async fn a_guard_without_resolution_locks_as_before() { + let (_, saving, older) = acquire(Value::Null).await; + + for events in [saving, older] { + assert_eq!(events[0]["event"], "checkoutBuffersLock"); + assert!(events[0]["payload"].get("resolution").is_none()); + } +} + +#[tokio::test] +async fn an_unknown_resolution_is_rejected() { + let (_root, mut actor) = fixture().await; + let error = actor + .checkout_buffer_guard_request( + 3, + "workspace.bufferGuard.acquire", + &json!({"id": "task", "operation": "removeShared", "resolution": "ignore"}), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("save or discard"), "{error}"); + assert!(actor.checkout_buffer_guards.is_empty()); +} diff --git a/rust/alera-cli/src/terminal_host/server/client_delivery.rs b/rust/alera-cli/src/terminal_host/server/client_delivery.rs index 20c4b3f1b..272be841a 100644 --- a/rust/alera-cli/src/terminal_host/server/client_delivery.rs +++ b/rust/alera-cli/src/terminal_host/server/client_delivery.rs @@ -66,6 +66,10 @@ impl ServerActor { .get("checkoutBufferGuardsV1") .and_then(Value::as_bool) .unwrap_or(false); + client.checkout_buffer_save = payload + .get(crate::terminal_host::protocol::RUNTIME_HOST_CHECKOUT_BUFFER_SAVE_CAPABILITY) + .and_then(Value::as_bool) + .unwrap_or(false); client.binary_frames = binary_frames; if client.kind == ClientKind::Local { client.local_role = local_role; @@ -320,6 +324,7 @@ mod tests { inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), @@ -340,6 +345,7 @@ mod tests { authenticated: true, shared_checkout_workspaces: true, checkout_buffer_guards: true, + checkout_buffer_save: true, workspace_focus: false, binary_frames: false, kind: ClientKind::Local, diff --git a/rust/alera-cli/src/terminal_host/server/deferred_requests.rs b/rust/alera-cli/src/terminal_host/server/deferred_requests.rs index c0bd42fff..274b44af9 100644 --- a/rust/alera-cli/src/terminal_host/server/deferred_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/deferred_requests.rs @@ -76,6 +76,9 @@ impl ServerActor { if self.try_start_mcp_request(client_id, request_id, request_type, payload)? { return Ok(true); } + if self.try_start_webhook_request(client_id, request_id, request_type, payload)? { + return Ok(true); + } if self.try_start_deferred_workspace_setup(client_id, request_id, request_type, payload)? { return Ok(true); } @@ -221,7 +224,14 @@ impl ServerActor { "aiText.workspaceIdentity.generate" => { self.require_auth(client_id)?; self.require_request_allowed(client_id, request_type)?; - self.start_ai_assist_workspace_identity(client_id, request_id, payload)?; + let infer_project = payload.get("inferProject").and_then(Value::as_bool) + == Some(true) + && payload.get("projectId").and_then(Value::as_str).is_none(); + if infer_project { + self.start_ai_assist_project_identity(client_id, request_id, payload)?; + } else { + self.start_ai_assist_workspace_identity(client_id, request_id, payload)?; + } Ok(true) } "aiText.commitMessage.generate" => { @@ -301,6 +311,10 @@ impl ServerActor { | "mobile.pullRequest.unlink" | "mobile.pullRequest.create" | "mobile.pullRequest.ship" + | "pullRequestStack.get" + | "pullRequestStack.create" + | "pullRequestStack.link" + | "pullRequestStack.merge" | "workspace.files.list" | "workspace.files.read" | "workspace.files.write" diff --git a/rust/alera-cli/src/terminal_host/server/host_service_requests.rs b/rust/alera-cli/src/terminal_host/server/host_service_requests.rs index 1b139b683..d7cd6e194 100644 --- a/rust/alera-cli/src/terminal_host/server/host_service_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/host_service_requests.rs @@ -6,9 +6,7 @@ use serde::Serialize; use serde_json::{json, Value}; use crate::agent_status::reconcile_agent_integrations; -use crate::host_tools::{ - cli_registration_status, install_cli_registration, install_skill, SkillKind, SkillRunner, -}; +use crate::host_tools::{cli_registration_status, install_cli_registration}; use crate::terminal_host::host_error::{HostError, HostResult}; use crate::terminal_host::protocol::{error_response, event, ok_response}; @@ -220,66 +218,6 @@ impl ServerActor { }); } - pub(super) fn start_skill_install_request( - &mut self, - client_id: u64, - request_id: i64, - payload: &Value, - ) -> HostResult<()> { - let operation_id = required_non_blank(payload, "operationId")?; - let skill_name = required_non_blank(payload, "skill")?; - let runner_name = required_non_blank(payload, "runner")?; - let skill = SkillKind::parse(&skill_name) - .ok_or_else(|| HostError::format("skill must be cli or orchestration."))?; - let runner = SkillRunner::parse(&runner_name) - .ok_or_else(|| HostError::format("runner must be auto, npx, or bunx."))?; - self.broadcast_authenticated(event( - "agentSkillInstallProgress", - json!({ - "operationId": operation_id, - "skill": skill_name, - "phase": "installing", - "message": "Installing Skill", - }), - )); - let store = self.runtime_store.clone(); - let runtime_dir = self.runtime_dir.clone(); - let inbox = self.inbox.clone(); - let operation_for_task = operation_id.clone(); - let skill_for_task = skill_name.clone(); - tokio::spawn(async move { - let install_result = install_skill(skill, runner).await; - let mut value = serde_json::to_value(&install_result) - .map_err(|error| HostError::state(error.to_string())); - if install_result.succeeded && matches!(skill, SkillKind::Orchestration) { - let settings = store - .agent_status_hook_settings() - .await - .map_err(|error| HostError::state(error.to_string())); - if let Ok(settings) = settings { - let warnings = tokio::task::spawn_blocking(move || { - reconcile_agent_integrations(&runtime_dir, &settings) - }) - .await - .unwrap_or_else(|error| vec![error.to_string()]); - if let Ok(Value::Object(object)) = &mut value { - object.insert("hookWarnings".to_string(), json!(warnings)); - } - } - } - let _ = inbox - .send_wait(ServerCommand::HostToolFinished { - client_id, - request_id, - result: value, - operation_id: Some(operation_for_task), - skill: Some(skill_for_task), - }) - .await; - }); - Ok(()) - } - pub(super) fn handle_host_tool_finished( &mut self, client_id: u64, diff --git a/rust/alera-cli/src/terminal_host/server/host_status.rs b/rust/alera-cli/src/terminal_host/server/host_status.rs index c32373dab..082513578 100644 --- a/rust/alera-cli/src/terminal_host/server/host_status.rs +++ b/rust/alera-cli/src/terminal_host/server/host_status.rs @@ -89,11 +89,19 @@ impl ServerActor { RUNTIME_HOST_MOBILE_MUTATIONS_CAPABILITY, RUNTIME_HOST_MOBILE_PROJECT_MANAGEMENT_CAPABILITY, RUNTIME_HOST_WORKSPACE_SECTIONS_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PROMPT_WORKSPACE_SERVICE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY, RUNTIME_HOST_WORKSPACE_ARCHIVE_CAPABILITY, RUNTIME_HOST_WORKSPACE_SLEEP_STATE_CAPABILITY, RUNTIME_HOST_WORKSPACE_FOCUS_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_WORKSPACE_WAKE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_CHECKOUT_BUFFER_SAVE_CAPABILITY, RUNTIME_HOST_LINKED_ISSUES_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_V2_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_FORGES_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_AGENT_DISPATCH_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_STACKS_CAPABILITY, RUNTIME_HOST_MOBILE_SIDEBAR_PARITY_CAPABILITY, RUNTIME_HOST_MOBILE_TAB_RENAME_CAPABILITY, RUNTIME_HOST_MOBILE_TERMINAL_TITLES_CAPABILITY, @@ -102,6 +110,7 @@ impl ServerActor { RUNTIME_HOST_AGENT_QUOTA_CLAUDE_TUI_CAPABILITY, RUNTIME_HOST_CODEX_RESET_CREDITS_CAPABILITY, RUNTIME_HOST_MOBILE_HOST_TOOLS_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_AGENT_SKILL_INSTALL_JOBS_CAPABILITY, RUNTIME_HOST_MOBILE_PROMPT_IMAGE_UPLOAD_CAPABILITY, RUNTIME_HOST_ORCHESTRATION_CAPABILITY, RUNTIME_HOST_AGENT_PROFILES_CAPABILITY, @@ -114,6 +123,8 @@ impl ServerActor { RUNTIME_HOST_AI_ASSIST_SPEECH_MESSAGE_CAPABILITY, RUNTIME_HOST_AI_ASSIST_OPENCODE_GO_CAPABILITY, RUNTIME_HOST_AI_ASSIST_CHATGPT_CAPABILITY, RUNTIME_HOST_AI_ASSIST_CHATGPT_OPTIONS_CAPABILITY, + crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_CAPABILITY, + crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_PULL_REQUEST_DETAILS_RESUME_CAPABILITY, RUNTIME_HOST_REMOTE_AI_DICTATION_CAPABILITY, RUNTIME_HOST_AGENT_PROFILE_PROMPT_LAUNCH_CAPABILITY, RUNTIME_HOST_AGENT_PROFILE_LAUNCH_IDEMPOTENCY_CAPABILITY, @@ -122,12 +133,14 @@ impl ServerActor { RUNTIME_HOST_ORCHESTRATION_TERMINAL_INSPECTION_CAPABILITY, RUNTIME_HOST_ORCHESTRATION_WAIT_CAPABILITY, crate::terminal_host::protocol::RUNTIME_HOST_INBOX_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_INBOX_ORIGIN_CAPABILITY, RUNTIME_HOST_RUN_POLICY_CAPABILITY, RUNTIME_HOST_ORCHESTRATION_BOARD_CAPABILITY, RUNTIME_HOST_TERMINAL_DEFERRED_INPUT_CAPABILITY, RUNTIME_HOST_TERMINAL_DRIVER_CAPABILITY, RUNTIME_HOST_TERMINAL_RESTART_CAPABILITY, RUNTIME_HOST_TERMINAL_PULSE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_TERMINAL_HEADLESS_RESTART_CAPABILITY, RUNTIME_HOST_LIFECYCLE_CAPABILITY, RUNTIME_HOST_RESTART_CAPABILITY, RUNTIME_HOST_AGENT_STATUS_CAPABILITY, diff --git a/rust/alera-cli/src/terminal_host/server/hub_reverse_policy.rs b/rust/alera-cli/src/terminal_host/server/hub_reverse_policy.rs index cb795de11..904cab555 100644 --- a/rust/alera-cli/src/terminal_host/server/hub_reverse_policy.rs +++ b/rust/alera-cli/src/terminal_host/server/hub_reverse_policy.rs @@ -76,6 +76,12 @@ const DENIED_VERBS: &[&str] = &[ "workspace.sshRelocationRecovery", "workspace.relocationRecovery", "workspace.focus", + // A From Prompt run and a wake start agents and terminals on the + // machine that answers; a satellite starts its own. + "workspace.promptStart.start", + "workspace.promptStart.retryLaunch", + "workspace.promptStart.cancel", + "workspace.wake", ]; /// Hub-owned records and the actions on them. `workspace.bufferGuard.*` is @@ -210,6 +216,11 @@ pub(super) fn apply_origin(request_type: &str, payload: Value, origin_host_id: & _ => serde_json::Map::new(), }; payload.insert("originHostId".into(), json!(origin_host_id)); + // The hub answers as a local client, so fields only a local caller may + // set (who asked, and resolving other clients' editors) never pass. + for local_only in ["externalOrigin", "origin", "resolution"] { + payload.remove(local_only); + } let host_id = payload.get("hostId"); let named_origin = host_id.and_then(Value::as_str) == Some(ORIGIN_HOST_ALIAS); let absent = match host_id { diff --git a/rust/alera-cli/src/terminal_host/server/hub_reverse_policy_tests.rs b/rust/alera-cli/src/terminal_host/server/hub_reverse_policy_tests.rs index a7f95422c..99f829c87 100644 --- a/rust/alera-cli/src/terminal_host/server/hub_reverse_policy_tests.rs +++ b/rust/alera-cli/src/terminal_host/server/hub_reverse_policy_tests.rs @@ -12,6 +12,7 @@ const ALLOWED: &[&str] = &[ "project.removalDependencies", "workspace.list", "workspace.find", + "workspace.show", "workspace.createShared", "workspace.createManaged", "workspace.removeShared", diff --git a/rust/alera-cli/src/terminal_host/server/inbox_requests.rs b/rust/alera-cli/src/terminal_host/server/inbox_requests.rs index b1656386e..c35aa362e 100644 --- a/rust/alera-cli/src/terminal_host/server/inbox_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/inbox_requests.rs @@ -10,9 +10,13 @@ use serde_json::{json, Value}; use crate::terminal_host::host_error::{HostError, HostResult}; use crate::terminal_host::protocol::event; -use super::client_delivery::LocalClientRole; use super::orchestration_validation::{optional_string, parse_priority, require_string}; -use super::{ClientKind, ServerActor}; +use super::ServerActor; + +mod origin; +pub(super) use origin::external_origin; +#[cfg(test)] +mod origin_tests; /// Where questions asked from the desktop and phone UIs live, so both show /// the same conversations. @@ -147,6 +151,7 @@ impl ServerActor { inbox: optional_string(payload, "inbox"), workspace_id: optional_string(payload, "workspaceId"), status, + origin_client_id: optional_string(payload, "originClientId"), before_sequence: payload.get("before").and_then(Value::as_i64), limit: payload.get("limit").and_then(Value::as_i64).unwrap_or(50), }; @@ -163,6 +168,7 @@ impl ServerActor { "inbox": filter.inbox, "workspaceId": filter.workspace_id, "status": filter.status, + "originClientId": filter.origin_client_id, }, "revision": self.inbox_revision().await?, })) @@ -346,26 +352,10 @@ impl ServerActor { } } - /// The surface a request came from, decided by the connection. - pub(super) fn inbox_origin(&self, client_id: u64) -> Value { - let Some(client) = self.clients.get(&client_id) else { - return json!({ "surface": "cli" }); - }; - match client.kind { - ClientKind::Mobile => json!({ - "surface": "mobile", - "deviceId": client.mobile_device_id, - "deviceName": client.mobile_device_name, - }), - ClientKind::Local if client.local_role == LocalClientRole::App => { - json!({ "surface": "desktop" }) - } - ClientKind::Local => json!({ "surface": "cli" }), - } - } - async fn inbox_ask(&mut self, client_id: u64, payload: &Value) -> HostResult { let body = require_string(payload, "body")?; + let origin = self.inbox_ask_origin(client_id, payload)?; + let request_key = origin::request_key(payload)?; // A follow-up names any question of its thread and inherits the // thread's inbox and recipient unless they are given explicitly; a // workspace only narrows the recipient of a new thread. @@ -382,6 +372,12 @@ impl ServerActor { let inbox = optional_string(payload, "inbox") .or_else(|| thread_root.as_ref().map(|root| root.from_handle.clone())) .unwrap_or_else(|| UI_INBOX.to_string()); + if let Some(earlier) = self + .repeated_inbox_ask(&inbox, request_key.as_deref(), &origin) + .await? + { + return Ok(earlier); + } let recipient = match &thread_root { Some(root) if optional_string(payload, "to").is_none() => { if !self.sessions.contains_key(&root.to_handle) { @@ -430,15 +426,18 @@ impl ServerActor { .map(|workspace| workspace.name), None => None, }; - let external_meta = json!({ + let mut external_meta = json!({ "version": 1, - "origin": self.inbox_origin(client_id), + "origin": origin.clone(), "target": { "agent": self.agent_presence.get(&recipient).map(|entry| entry.agent_type.clone()), "tabTitle": self.inbox_tab_title(&recipient).await, "workspaceName": workspace_name, }, }); + if let Some(key) = request_key { + external_meta["requestKey"] = json!(key); + } let message = self .runtime_store .insert_inbox_question(NewInboxQuestion { @@ -462,13 +461,7 @@ impl ServerActor { // Wakes a coordinator parked on `check --wait`. self.notify_message_arrived(&recipient, OrchestrationMessageType::DecisionGate) .await; - Ok(json!({ - "questionId": message.id, - "threadId": message.thread_id.clone().unwrap_or_else(|| message.id.clone()), - "message": message, - "recipient": self.inbox_recipient(&recipient), - "revision": self.inbox_revision().await?, - })) + self.inbox_ask_result(message, origin, false).await } } diff --git a/rust/alera-cli/src/terminal_host/server/inbox_requests/origin.rs b/rust/alera-cli/src/terminal_host/server/inbox_requests/origin.rs new file mode 100644 index 000000000..40dc541b9 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/inbox_requests/origin.rs @@ -0,0 +1,217 @@ +//! Who asked an inbox question: the surface of the connection, or the MCP +//! client that a local Alera CLI process names in `externalOrigin`. + +use alera_core::runtime::OrchestrationMessage; +use serde_json::{json, Map, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::client_delivery::LocalClientRole; +use super::super::{ClientKind, ServerActor}; +use super::inbox_error; + +/// Longest value kept for each self-asserted origin field. +const ORIGIN_FIELD_MAX_CHARS: usize = 256; +const ORIGIN_FIELDS: &[&str] = &["clientId", "clientName", "grantId"]; +/// Retry keys follow the MCP `clientRequestId` bounds. +const REQUEST_KEY_CHARS: std::ops::RangeInclusive = 8..=128; + +impl ServerActor { + /// The surface a request came from, decided by the connection. + pub(in crate::terminal_host::server) fn inbox_origin(&self, client_id: u64) -> Value { + let Some(client) = self.clients.get(&client_id) else { + return json!({ "surface": "cli" }); + }; + match client.kind { + ClientKind::Mobile => json!({ + "surface": "mobile", + "deviceId": client.mobile_device_id, + "deviceName": client.mobile_device_name, + }), + ClientKind::Local if client.local_role == LocalClientRole::App => { + json!({ "surface": "desktop" }) + } + ClientKind::Local => json!({ "surface": "cli" }), + } + } + + /// The origin recorded with a new question. Only a local connection, the + /// CLI process an MCP tool runs, may name the MCP client it acts for; a + /// phone cannot claim to be one. + pub(in crate::terminal_host::server) fn inbox_ask_origin( + &self, + client_id: u64, + payload: &Value, + ) -> HostResult { + let Some(external) = payload + .get("externalOrigin") + .filter(|value| !value.is_null()) + else { + return Ok(self.inbox_origin(client_id)); + }; + let local = self + .clients + .get(&client_id) + .is_some_and(|client| matches!(client.kind, ClientKind::Local)); + if !local { + return Err(HostError::conflict( + "inbox_origin_forbidden", + "Only a local Alera CLI process may record an MCP client origin.", + json!({}), + )); + } + external_origin(external) + } + + /// The first result of an ask retried with the same `requestKey` by the + /// same origin client, so a retry after a timeout does not ask twice. + pub(in crate::terminal_host::server) async fn repeated_inbox_ask( + &self, + inbox: &str, + request_key: Option<&str>, + origin: &Value, + ) -> HostResult> { + let Some(key) = request_key else { + return Ok(None); + }; + let client = origin.get("clientId").and_then(Value::as_str); + let earlier = self + .runtime_store + .inbox_question_by_request_key(inbox, key, client) + .await + .map_err(inbox_error)?; + match earlier { + Some(message) => Ok(Some( + self.inbox_ask_result(message, origin.clone(), true).await?, + )), + None => Ok(None), + } + } + + /// `deduplicated` marks a retry that returned the question asked first. + pub(in crate::terminal_host::server) async fn inbox_ask_result( + &self, + message: OrchestrationMessage, + origin: Value, + deduplicated: bool, + ) -> HostResult { + Ok(json!({ + "questionId": message.id, + "threadId": message.thread_id.clone().unwrap_or_else(|| message.id.clone()), + "recipient": self.inbox_recipient(&message.to_handle), + "origin": origin, + "deduplicated": deduplicated, + "message": message, + "revision": self.inbox_revision().await?, + })) + } +} + +/// Normalizes `externalOrigin` to `{surface: "mcp", transport, clientId?, +/// clientName?, grantId?}`, dropping anything else it carries. +pub(in crate::terminal_host::server) fn external_origin(value: &Value) -> HostResult { + let invalid = |reason: String| { + HostError::conflict( + "inbox_invalid_origin", + format!("externalOrigin {reason}."), + json!({}), + ) + }; + let object = value + .as_object() + .ok_or_else(|| invalid("must be an object".into()))?; + let transport = object + .get("transport") + .and_then(Value::as_str) + .filter(|transport| matches!(*transport, "remote" | "local")) + .ok_or_else(|| invalid("needs transport remote or local".into()))?; + let mut origin = Map::new(); + origin.insert("surface".into(), json!("mcp")); + origin.insert("transport".into(), json!(transport)); + for field in ORIGIN_FIELDS { + match object.get(*field) { + None | Some(Value::Null) => {} + Some(Value::String(text)) + if !text.trim().is_empty() && text.chars().count() <= ORIGIN_FIELD_MAX_CHARS => + { + origin.insert((*field).into(), json!(text.trim())); + } + Some(_) => { + return Err(invalid(format!( + "{field} must be a non-empty string of at most {ORIGIN_FIELD_MAX_CHARS} characters" + ))) + } + } + } + Ok(Value::Object(origin)) +} + +/// The optional `requestKey` of an ask, checked against the retry key bounds. +pub(in crate::terminal_host::server) fn request_key(payload: &Value) -> HostResult> { + match payload.get("requestKey") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(key)) if REQUEST_KEY_CHARS.contains(&key.chars().count()) => { + Ok(Some(key.clone())) + } + Some(_) => Err(HostError::conflict( + "inbox_invalid_request_key", + "requestKey must be a string of 8 to 128 characters.", + json!({}), + )), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{external_origin, request_key}; + + #[test] + fn external_origins_keep_only_the_known_fields() { + let origin = external_origin(&json!({ + "transport": "remote", + "clientId": " https://chatgpt.com ", + "clientName": "ChatGPT", + "grantId": "g-1", + "callId": "c-1", + "surface": "desktop", + })) + .unwrap(); + assert_eq!( + origin, + json!({ + "surface": "mcp", + "transport": "remote", + "clientId": "https://chatgpt.com", + "clientName": "ChatGPT", + "grantId": "g-1", + }) + ); + } + + #[test] + fn malformed_external_origins_are_refused() { + for value in [ + json!("ChatGPT"), + json!({ "clientName": "ChatGPT" }), + json!({ "transport": "mobile" }), + json!({ "transport": "local", "clientName": 7 }), + json!({ "transport": "local", "clientName": " " }), + json!({ "transport": "local", "clientId": "x".repeat(257) }), + ] { + assert!(external_origin(&value).is_err(), "{value}"); + } + } + + #[test] + fn request_keys_follow_the_retry_key_bounds() { + assert_eq!(request_key(&json!({})).unwrap(), None); + assert_eq!( + request_key(&json!({ "requestKey": "request-0001" })).unwrap(), + Some("request-0001".to_string()) + ); + assert!(request_key(&json!({ "requestKey": "short" })).is_err()); + assert!(request_key(&json!({ "requestKey": 12345678 })).is_err()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/inbox_requests/origin_tests.rs b/rust/alera-cli/src/terminal_host/server/inbox_requests/origin_tests.rs new file mode 100644 index 000000000..03c936f15 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/inbox_requests/origin_tests.rs @@ -0,0 +1,125 @@ +//! Shared `ext:mcp` inbox: attribution, retry keys, and per-client filters. + +use serde_json::{json, Value}; + +use super::super::inbox_test_fixture::*; + +fn mcp_origin(client: &str) -> Value { + json!({ "transport": "remote", "clientId": client, "clientName": client.to_uppercase(), "callId": "c-1" }) +} + +async fn ask(fixture: &mut Fixture, client: &str, to: &str) -> String { + let asked = fixture + .ok( + CLI, + "inbox.ask", + json!({"to": to, "body": "Status?", "inbox": "ext:mcp", "externalOrigin": mcp_origin(client)}), + ) + .await; + asked["questionId"].as_str().unwrap().to_string() +} + +#[tokio::test] +async fn only_local_clients_record_an_mcp_origin() { + let mut fixture = fixture().await; + let asked = fixture + .ok( + CLI, + "inbox.ask", + json!({"to": "claude-term", "body": "?", "inbox": "ext:mcp", "externalOrigin": mcp_origin("chatgpt")}), + ) + .await; + let origin = &asked["message"]["external_meta"]["origin"]; + assert_eq!(origin["surface"], "mcp"); + assert_eq!(origin["clientName"], "CHATGPT"); + assert!(origin.get("callId").is_none()); + assert_eq!(asked["origin"], *origin); + let phone = fixture + .request( + PHONE, + "inbox.ask", + json!({"to": "claude-term", "body": "?", "externalOrigin": mcp_origin("chatgpt")}), + ) + .await; + assert_eq!(phone["errorCode"], "inbox_origin_forbidden"); + let malformed = fixture + .request( + CLI, + "inbox.ask", + json!({"to": "claude-term", "body": "?", "externalOrigin": {"clientName": "x"}}), + ) + .await; + assert_eq!(malformed["errorCode"], "inbox_invalid_origin"); +} + +#[tokio::test] +async fn a_retried_ask_with_the_same_key_returns_the_first_question() { + let mut fixture = fixture().await; + let payload = json!({ + "to": "claude-term", + "body": "?", + "inbox": "ext:mcp", + "externalOrigin": mcp_origin("chatgpt"), + "requestKey": "request-0001", + }); + let first = fixture.ok(CLI, "inbox.ask", payload.clone()).await; + let retry = fixture.ok(CLI, "inbox.ask", payload.clone()).await; + assert_eq!(first["deduplicated"], false); + assert_eq!(retry["deduplicated"], true); + assert_eq!(retry["questionId"], first["questionId"]); + let mut other_client = payload; + other_client["externalOrigin"] = mcp_origin("claude-ai"); + let separate = fixture.ok(CLI, "inbox.ask", other_client).await; + assert_ne!(separate["questionId"], first["questionId"]); +} + +#[tokio::test] +async fn threads_and_inbox_waits_filter_by_the_origin_client() { + let mut fixture = fixture().await; + let own = ask(&mut fixture, "chatgpt", "claude-term").await; + let foreign = ask(&mut fixture, "claude-ai", "codex-term").await; + let threads = fixture + .ok( + CLI, + "inbox.threads", + json!({"inbox": "ext:mcp", "originClientId": "chatgpt"}), + ) + .await; + let items = threads["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["threadId"], own.as_str()); + assert_eq!(items[0]["origin"]["clientId"], "chatgpt"); + + fixture.reply(&foreign, "Not yours").await; + let quiet = fixture + .ok( + CLI, + "inbox.wait", + json!({"inbox": "ext:mcp", "after": 0, "timeoutMs": 0, "originClientId": "chatgpt"}), + ) + .await; + assert_eq!(quiet["outcome"], "pending"); + let skipped = quiet["cursor"].as_i64().unwrap(); + assert!(skipped > 0, "the cursor moves past other clients' replies"); + let foreign_thread = fixture + .ok(CLI, "inbox.thread", json!({"threadId": foreign})) + .await; + assert_eq!(foreign_thread["thread"]["unreadReplyCount"], 1); + + let wait_id = fixture + .send( + DESKTOP, + "inbox.wait", + json!({"inbox": "ext:mcp", "after": skipped, "timeoutMs": 60000, "originClientId": "chatgpt"}), + ) + .await; + fixture.reply(&own, "Yours").await; + let woken = fixture.take_response(DESKTOP, wait_id).unwrap(); + let payload = &woken["payload"]; + assert_eq!(payload["outcome"], "message"); + assert_eq!(payload["messages"].as_array().unwrap().len(), 1); + assert_eq!( + payload["threadOrigins"][own.as_str()]["clientId"], + "chatgpt" + ); +} diff --git a/rust/alera-cli/src/terminal_host/server/inbox_wait.rs b/rust/alera-cli/src/terminal_host/server/inbox_wait.rs index c0373c441..43917f128 100644 --- a/rust/alera-cli/src/terminal_host/server/inbox_wait.rs +++ b/rust/alera-cli/src/terminal_host/server/inbox_wait.rs @@ -1,5 +1,7 @@ +use std::collections::HashMap; + use alera_core::runtime::{InboxMessageKind, InboxQuestionStatus}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use crate::terminal_host::host_error::{HostError, HostResult}; use crate::terminal_host::orchestration::message_waiters::{MessageWaiter, WaitKind}; @@ -10,6 +12,9 @@ use super::orchestration_validation::{optional_string, wait_timeout_ms}; use super::ServerActor; const INBOX_WAIT_PAGE: i64 = 50; +/// Pages an origin-filtered inbox wait scans past other clients' messages +/// before it reports its progress as the cursor. +const INBOX_WAIT_SCAN_PAGES: usize = 20; /// What `inbox.wait` reports, and whether waiting can stop. struct InboxWaitState { @@ -28,6 +33,7 @@ impl ServerActor { payload: &Value, ) -> HostResult> { let after = payload.get("after").and_then(Value::as_i64).unwrap_or(0); + let origin_client_id = optional_string(payload, "originClientId"); let (inbox, question_id) = match optional_string(payload, "questionId") { Some(question_id) => { let question = self @@ -54,7 +60,12 @@ impl ServerActor { alera_core::runtime::validate_inbox_address(&inbox).map_err(inbox_error)?; } let state = self - .inbox_wait_state(&inbox, question_id.as_deref(), after) + .inbox_wait_state( + &inbox, + question_id.as_deref(), + after, + origin_client_id.as_deref(), + ) .await?; let timeout_ms = wait_timeout_ms(payload); if state.settled || payload.get("timeoutMs").and_then(Value::as_u64) == Some(0) { @@ -67,6 +78,7 @@ impl ServerActor { WaitKind::Inbox { question_id, after_sequence: after, + origin_client_id, }, ); self.spawn_wait_timeout(waiter_id, timeout_ms); @@ -77,12 +89,18 @@ impl ServerActor { let WaitKind::Inbox { question_id, after_sequence, + origin_client_id, } = waiter.kind.clone() else { return; }; match self - .inbox_wait_state(&waiter.handle, question_id.as_deref(), after_sequence) + .inbox_wait_state( + &waiter.handle, + question_id.as_deref(), + after_sequence, + origin_client_id.as_deref(), + ) .await { Ok(state) if !state.settled => self.orchestration_waiters.repark(waiter), @@ -105,12 +123,18 @@ impl ServerActor { let WaitKind::Inbox { question_id, after_sequence, + origin_client_id, } = waiter.kind.clone() else { return; }; let response = match self - .inbox_wait_state(&waiter.handle, question_id.as_deref(), after_sequence) + .inbox_wait_state( + &waiter.handle, + question_id.as_deref(), + after_sequence, + origin_client_id.as_deref(), + ) .await { Ok(mut state) => { @@ -133,26 +157,10 @@ impl ServerActor { inbox: &str, question_id: Option<&str>, after: i64, + origin_client_id: Option<&str>, ) -> HostResult { let Some(question_id) = question_id else { - let messages = self - .runtime_store - .inbox_messages_after(inbox, after, INBOX_WAIT_PAGE) - .await - .map_err(inbox_error)?; - let cursor = messages.last().map_or(after, |message| message.sequence); - self.mark_returned_read(messages.iter().map(|message| message.id.clone())) - .await?; - let settled = !messages.is_empty(); - return Ok(InboxWaitState { - body: json!({ - "outcome": if settled { "message" } else { "pending" }, - "inbox": inbox, - "messages": messages, - "cursor": cursor, - }), - settled, - }); + return self.inbox_news_state(inbox, after, origin_client_id).await; }; if !alera_core::runtime::is_external_inbox(inbox) { return self.agent_question_state(inbox, question_id, after).await; @@ -284,6 +292,78 @@ impl ServerActor { }) } + /// Anything sent to `inbox` after `after`. With an origin client, only + /// messages in threads that client started count: the others stay unread + /// for their own client, and the cursor moves past them. + async fn inbox_news_state( + &self, + inbox: &str, + after: i64, + origin_client_id: Option<&str>, + ) -> HostResult { + let mut origins: HashMap = HashMap::new(); + let mut messages = Vec::new(); + let mut cursor = after; + for _ in 0..INBOX_WAIT_SCAN_PAGES { + let page = self + .runtime_store + .inbox_messages_after(inbox, cursor, INBOX_WAIT_PAGE) + .await + .map_err(inbox_error)?; + let exhausted = (page.len() as i64) < INBOX_WAIT_PAGE; + for message in page { + cursor = message.sequence; + let thread_id = message.thread_id.clone().unwrap_or(message.id.clone()); + if !origins.contains_key(&thread_id) { + let origin = self.thread_origin(&thread_id).await?; + origins.insert(thread_id.clone(), origin); + } + let client = origins[&thread_id].get("clientId").and_then(Value::as_str); + if origin_client_id.is_none_or(|wanted| client == Some(wanted)) { + messages.push(message); + } + } + if exhausted || !messages.is_empty() { + break; + } + } + self.mark_returned_read(messages.iter().map(|message| message.id.clone())) + .await?; + let thread_origins: Map = messages + .iter() + .filter_map(|message| { + let thread_id = message.thread_id.clone().unwrap_or(message.id.clone()); + let origin = origins.get(&thread_id)?.clone(); + Some((thread_id, origin)) + }) + .collect(); + let settled = !messages.is_empty(); + Ok(InboxWaitState { + body: json!({ + "outcome": if settled { "message" } else { "pending" }, + "inbox": inbox, + "messages": messages, + "cursor": cursor, + "threadOrigins": thread_origins, + "originClientId": origin_client_id, + }), + settled, + }) + } + + /// The recorded origin of a thread's root question, or null. + async fn thread_origin(&self, thread_id: &str) -> HostResult { + let root = self + .runtime_store + .orchestration_message_by_id(thread_id) + .await + .map_err(inbox_error)?; + Ok(root + .and_then(|root| root.external_meta) + .and_then(|meta| meta.get("origin").cloned()) + .unwrap_or(Value::Null)) + } + async fn mark_returned_read(&self, ids: impl Iterator) -> HostResult<()> { let ids: Vec = ids.collect(); self.runtime_store diff --git a/rust/alera-cli/src/terminal_host/server/lifecycle.rs b/rust/alera-cli/src/terminal_host/server/lifecycle.rs index 6f6082c39..178e062f2 100644 --- a/rust/alera-cli/src/terminal_host/server/lifecycle.rs +++ b/rust/alera-cli/src/terminal_host/server/lifecycle.rs @@ -68,6 +68,7 @@ impl ServerActor { || !self.mutation_queue.pending_workspace_shutdowns.is_empty() || self.account_push.cloud_jobs > 0 || !self.project_clone_jobs.is_empty() + || !self.prompt_workspace_operations.is_empty() || self.mobile_gateway.is_some() || self.account_push.relay_task.is_some() || !self.coordinators.is_empty() @@ -165,6 +166,17 @@ impl ServerActor { } pub(super) async fn handle_shutdown_tick(&mut self, generation: u64) { + if generation == self.shutdown_gen + && (super::ai_assist_pull_request_details_jobs::pull_request_details_jobs() + .has_running() + || super::agent_skill_installs::install_running()) + { + // A resumable generation or a skill install outlives the caller + // that started it, so the runtime waits for it and checks again a + // delay later. + self.schedule_shutdown_if_idle(); + return; + } if generation == self.shutdown_gen && !self.disposed && !self.has_authenticated_clients() diff --git a/rust/alera-cli/src/terminal_host/server/managed_workspace_requests.rs b/rust/alera-cli/src/terminal_host/server/managed_workspace_requests.rs index 88361133b..8536bb8de 100644 --- a/rust/alera-cli/src/terminal_host/server/managed_workspace_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/managed_workspace_requests.rs @@ -18,6 +18,9 @@ use super::requests::json_result; use super::runtime_change_broadcasts::string_scope; use super::{ServerActor, ServerCommand}; +#[path = "remote_storage_impact.rs"] +mod remote_storage_impact; + impl ServerActor { pub(super) fn start_shared_workspace_create( &mut self, @@ -85,6 +88,7 @@ impl ServerActor { self.managed_workspace_jobs += 1; self.cancel_shutdown_timer(); let store = self.runtime_store.clone(); + let host_links = self.host_links.clone(); let inbox = self.inbox.clone(); tokio::spawn(async move { match workspace_has_active_automation_owner(&store, &workspace_id).await { @@ -92,8 +96,21 @@ impl ServerActor { Err(error) => blockers.push(format!("Could not verify automations: {error}")), _ => {} } - let result = - json_result(measure_workspace_storage(&store, &workspace_id, blockers).await); + let result = match remote_storage_impact::measure_on_owner( + &store, + &host_links, + &workspace_id, + close_sessions, + &blockers, + ) + .await + { + Ok(Some(impact)) => Ok(impact), + Ok(None) => { + json_result(measure_workspace_storage(&store, &workspace_id, blockers).await) + } + Err(error) => Err(error), + }; let _ = inbox .send_wait(ServerCommand::WorkspaceStorageMeasured { client_id, diff --git a/rust/alera-cli/src/terminal_host/server/mcp_requests.rs b/rust/alera-cli/src/terminal_host/server/mcp_requests.rs index e994e05c5..0bc99cd2d 100644 --- a/rust/alera-cli/src/terminal_host/server/mcp_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/mcp_requests.rs @@ -65,13 +65,12 @@ impl ServerActor { } "mcp.settings.update" => { let update: McpSettingsUpdate = parse_payload(payload)?; - let access = - match update.access.as_deref() { - Some(value) => Some(McpAccess::parse(value).ok_or_else(|| { - HostError::format("access must be off, read, or full") - })?), - None => None, - }; + let access = match update.access.as_deref() { + Some(value) => Some(McpAccess::parse(value).ok_or_else(|| { + HostError::format("access must be off, read, full, or admin") + })?), + None => None, + }; let name = update .runtime_name .as_deref() diff --git a/rust/alera-cli/src/terminal_host/server/mobile_gateway_surface.rs b/rust/alera-cli/src/terminal_host/server/mobile_gateway_surface.rs index 59019abf3..a6a103eb8 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_gateway_surface.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_gateway_surface.rs @@ -68,9 +68,13 @@ pub(super) const MOBILE_HELLO_CAPABILITIES: &[&str] = &[ RUNTIME_HOST_MOBILE_PROJECT_MANAGEMENT_CAPABILITY, RUNTIME_HOST_WORKSPACE_SECTIONS_CAPABILITY, RUNTIME_HOST_WORKSPACE_ARCHIVE_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PROMPT_WORKSPACE_SERVICE_CAPABILITY, RUNTIME_HOST_LINKED_ISSUES_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_CAPABILITY, RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_WATCH_EXECUTION_V2_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_FORGES_CAPABILITY, + crate::terminal_host::protocol::RUNTIME_HOST_PULL_REQUEST_AGENT_DISPATCH_CAPABILITY, RUNTIME_HOST_MOBILE_SIDEBAR_PARITY_CAPABILITY, RUNTIME_HOST_MOBILE_TAB_RENAME_CAPABILITY, RUNTIME_HOST_MOBILE_TERMINAL_TITLES_CAPABILITY, @@ -169,6 +173,11 @@ pub(super) fn mobile_request_allowed(request_type: &str) -> bool { | "workspace.repositoryWebUrl" | "workspace.createManaged" | "workspace.createShared" + | "workspace.promptStart.start" + | "workspace.promptStart.get" + | "workspace.promptStart.list" + | "workspace.promptStart.cancel" + | "workspace.promptStart.retryLaunch" | "checkout.list" | "workspace.bufferGuard.acquire" | "workspace.bufferGuard.status" @@ -177,6 +186,7 @@ pub(super) fn mobile_request_allowed(request_type: &str) -> bool { | "workspace.checkout" | "workspace.relocationRecovery" | "workspace.sshRelocationRecovery" + | "workspace.runSetup" | "workspace.prepareRelocationSetup" | "workspace.recoverRelocationSetup" | "workspace.cancelRelocationSetup" @@ -236,6 +246,7 @@ pub(super) fn mobile_request_allowed(request_type: &str) -> bool { | "mobile.pullRequest.unlink" | "mobile.pullRequest.create" | "mobile.pullRequest.ship" + | "pullRequest.agentDispatch" | "mobile.promptFile.start" | "mobile.promptFile.chunk" | "mobile.promptFile.complete" @@ -360,6 +371,28 @@ mod workflow_lifecycle_mobile_tests; #[cfg(test)] #[path = "mobile_gateway_surface_codex_tests.rs"] mod mobile_codex_file_surface_tests; +#[cfg(test)] +#[path = "mobile_gateway_surface_setup_tests.rs"] +mod mobile_setup_surface_tests; +#[cfg(test)] +mod prompt_workspace_surface_tests { + /// The phone's New Workspace from Prompt delegates to the runtime service + /// once the hello list names it. + #[test] + fn mobile_may_run_new_workspace_from_prompt_on_the_runtime() { + assert!(super::MOBILE_HELLO_CAPABILITIES.contains(&"promptWorkspaceServiceV1")); + for request in [ + "workspace.promptStart.start", + "workspace.promptStart.get", + "workspace.promptStart.list", + "workspace.promptStart.cancel", + "workspace.promptStart.retryLaunch", + ] { + assert!(super::mobile_request_allowed(request), "{request}"); + } + } +} + #[cfg(test)] mod relay_renewal_tests { #[test] diff --git a/rust/alera-cli/src/terminal_host/server/mobile_gateway_surface_setup_tests.rs b/rust/alera-cli/src/terminal_host/server/mobile_gateway_surface_setup_tests.rs new file mode 100644 index 000000000..57a76c7ee --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/mobile_gateway_surface_setup_tests.rs @@ -0,0 +1,38 @@ +use std::collections::HashMap; + +use super::super::actor_test_harness::{mobile_client, test_actor}; +use super::*; +use crate::terminal_host::client::ClientHandle; + +/// Recovery's "Run Saved Setup" on the phone sends `workspace.runSetup`, next +/// to the relocation setup verbs it already could call. +#[test] +fn mobile_may_run_saved_workspace_setup() { + for request in [ + "workspace.runSetup", + "workspace.prepareRelocationSetup", + "workspace.recoverRelocationSetup", + "workspace.cancelRelocationSetup", + ] { + assert!(mobile_request_allowed(request), "{request}"); + } +} + +#[tokio::test] +async fn a_paired_phone_passes_the_request_gate_for_run_setup() { + let dir = tempfile::tempdir().unwrap(); + let (handle, _) = ClientHandle::test_channels(); + let actor = test_actor( + &dir, + HashMap::from([(7, mobile_client(handle, "phone"))]), + HashMap::new(), + ) + .await; + + actor + .require_request_allowed(7, "workspace.runSetup") + .expect("mobile Run Saved Setup reaches its handler"); + assert!(actor + .require_request_allowed(7, "runtimeMetadata.set") + .is_err()); +} diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions.rs index b70a1b3bd..e84308a62 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions.rs @@ -1,26 +1,19 @@ -//! Pull request writes for a paired phone: comments, replies, edits, merge, -//! draft status, close, link, unlink and create. Each verb runs the same `gh` -//! command the desktop forge layer runs (`github_review_actions.dart`, -//! `github_review_comments.dart`) and answers with a fresh snapshot, so the -//! phone never has to guess what a write changed. - -use alera_core::git as core_git; -use alera_core::runtime::{RuntimeStore, Workspace, LOCAL_HOST_ID}; +//! Pull request writes for a paired phone, the CLI, and MCP: comments, +//! replies, edits, merge, draft status, close, link, unlink, create and Ship. +//! The verbs run through `pull_request_forges` for every forge and answer with +//! a fresh snapshot, so a client never has to guess what a write changed. The +//! `gh` argv here is GitHub's, the same command the desktop forge layer runs +//! (`github_review_actions.dart`, `github_review_comments.dart`). + +use alera_core::runtime::RuntimeStore; use serde_json::{json, Value}; -use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::host_error::HostResult; use crate::terminal_host::protocol::event; -use super::mobile_pull_request_busy::BusyGuard; -use super::mobile_pull_request_failures::{gh_failure, gh_missing}; -use super::mobile_pull_request_identity::{parse_github_identity, GitHubIdentity}; -use super::mobile_pull_request_links::{ - parse_review_reference, save_link, workspace_review_reference, -}; -use super::mobile_pull_request_requests::{run_gh, snapshot_mobile_pull_request, view_review}; -use super::mobile_pull_request_ship::{ship_pull_request, ShipRequest, ShipScope}; +use super::mobile_pull_request_identity::GitHubIdentity; +use super::mobile_pull_request_requests::snapshot_mobile_pull_request; use super::mobile_workspace_file_requests::workspace_for_mobile_file_request; -use super::requests::{optional_string_key, require_string_key}; use super::ServerActor; /// Verbs that change the workspace link, so the runtime broadcasts @@ -30,6 +23,9 @@ pub(super) const LINK_CHANGING_ACTIONS: &[&str] = &[ "mobile.pullRequest.unlink", "mobile.pullRequest.create", "mobile.pullRequest.ship", + "pullRequestStack.create", + "pullRequestStack.link", + "pullRequestStack.merge", ]; #[derive(Debug, PartialEq)] @@ -56,20 +52,12 @@ pub(super) enum Action { Close { number: i64, }, - Link { - reference: String, - }, - Unlink { - number: i64, - url: Option, - }, Create { base: String, title: String, body: String, draft: bool, }, - Ship(ShipRequest), } #[derive(Debug, Clone, Copy, PartialEq)] @@ -123,53 +111,8 @@ async fn run_mobile_pull_request_action( request_type: &str, payload: &Value, ) -> HostResult { - let action = parse_action(request_type, payload)?; let workspace = workspace_for_mobile_file_request(store, payload).await?; - let host_id = workspace.host_id.trim(); - if !host_id.is_empty() && host_id != LOCAL_HOST_ID { - return Err(HostError::state( - "Pull request actions are only available for workspaces on this runtime.", - )); - } - let identity = github_identity(&workspace.path).await?; - let _busy = BusyGuard::acquire(&workspace.id)?; - match action { - Action::Link { reference } => { - let number = workspace_review_reference(&reference, &identity)?; - let review = view_review(&workspace.path, &identity.slug, number) - .await? - .ok_or_else(|| { - HostError::state(format!("Pull request #{number} was not found.")) - })?; - let url = review - .get("url") - .and_then(Value::as_str) - .map(ToOwned::to_owned); - save_link(store, &workspace.id, number, url, false).await?; - } - Action::Unlink { number, url } => { - save_link(store, &workspace.id, number, url, true).await? - } - Action::Create { - ref base, - ref title, - ref body, - draft, - } => { - let head = current_branch(&workspace.path).await?; - create_and_link( - store, &workspace, &identity, base, &head, title, body, draft, - ) - .await?; - } - Action::Ship(request) => { - let hub_settings = super::remote_ai_assist_requests::hub_ai_assist_settings(payload)?; - ship_pull_request(store, &workspace, &identity, request, hub_settings).await? - } - _ => { - run_checked(&workspace.path, &gh_args(&action, &identity, "")).await?; - } - } + super::pull_request_forges::run_forge_action(store, &workspace, request_type, payload).await?; Ok(completed_action_snapshot( snapshot_mobile_pull_request(store, payload).await, )) @@ -185,151 +128,6 @@ fn completed_action_snapshot(snapshot: HostResult) -> Value { } } -/// `gh pr create` from [head] into [base], then links the new pull request to -/// the workspace like the desktop does after it creates one. -#[allow(clippy::too_many_arguments)] -pub(super) async fn create_and_link( - store: &RuntimeStore, - workspace: &Workspace, - identity: &GitHubIdentity, - base: &str, - head: &str, - title: &str, - body: &str, - draft: bool, -) -> HostResult<()> { - let action = Action::Create { - base: base.to_string(), - title: title.to_string(), - body: body.to_string(), - draft, - }; - let stdout = run_checked(&workspace.path, &gh_args(&action, identity, head)).await?; - let url = stdout - .lines() - .map(str::trim) - .find(|line| line.starts_with("http")); - if let Some(number) = url.and_then(parse_review_reference) { - save_link( - store, - &workspace.id, - number, - url.map(ToOwned::to_owned), - false, - ) - .await?; - } - Ok(()) -} - -pub(super) fn parse_action(request_type: &str, payload: &Value) -> HostResult { - let number = || positive_i64(payload, "number"); - let body = || { - let body = require_string_key(payload, "body")?; - if body.trim().is_empty() { - return Err(HostError::state("Enter a comment before posting.")); - } - Ok(body) - }; - Ok(match request_type { - "mobile.pullRequest.comment" => Action::Comment { - number: number()?, - body: body()?, - reply_to: payload.get("replyToCommentId").and_then(Value::as_i64), - }, - "mobile.pullRequest.commentUpdate" => Action::CommentUpdate { - number: number()?, - comment_id: positive_i64(payload, "commentId")?, - source: match require_string_key(payload, "source")?.as_str() { - "conversation" => CommentSource::Conversation, - "reviewSummary" => CommentSource::ReviewSummary, - "reviewThread" => CommentSource::ReviewThread, - other => return Err(HostError::state(format!("Unknown comment source: {other}"))), - }, - body: body()?, - }, - "mobile.pullRequest.merge" => Action::Merge { - number: number()?, - method: match require_string_key(payload, "method")?.as_str() { - "mergeCommit" => MergeMethod::MergeCommit, - "squash" => MergeMethod::Squash, - "rebase" => MergeMethod::Rebase, - "providerDefault" => { - return Err(HostError::state( - "GitHub does not expose a provider-default merge method through gh.", - )); - } - other => return Err(HostError::state(format!("Unknown merge method: {other}"))), - }, - }, - "mobile.pullRequest.draftStatus" => Action::DraftStatus { - number: number()?, - draft: payload - .get("draft") - .and_then(Value::as_bool) - .ok_or_else(|| HostError::state("draft must be a boolean."))?, - }, - "mobile.pullRequest.close" => Action::Close { number: number()? }, - "mobile.pullRequest.link" => Action::Link { - reference: require_string_key(payload, "reference")?, - }, - "mobile.pullRequest.unlink" => Action::Unlink { - number: number()?, - url: optional_string_key(payload, "url"), - }, - "mobile.pullRequest.create" => { - let title = require_string_key(payload, "title")?; - if title.trim().is_empty() { - return Err(HostError::state( - "Enter a title before creating the pull request.", - )); - } - let base = require_string_key(payload, "baseBranch")? - .trim() - .to_string(); - if base.is_empty() { - return Err(HostError::state("Select a base branch.")); - } - Action::Create { - base, - title: title.trim().to_string(), - body: optional_string_key(payload, "body").unwrap_or_default(), - draft: payload - .get("draft") - .and_then(Value::as_bool) - .unwrap_or(false), - } - } - "mobile.pullRequest.ship" => { - let base = require_string_key(payload, "baseBranch")? - .trim() - .to_string(); - if base.is_empty() { - return Err(HostError::state("Select a base branch before shipping.")); - } - Action::Ship(ShipRequest { - base, - draft: payload - .get("draft") - .and_then(Value::as_bool) - .unwrap_or(false), - scope: match optional_string_key(payload, "scope").as_deref() { - None | Some("all") => ShipScope::All, - Some("staged") => ShipScope::Staged, - Some(other) => { - return Err(HostError::state(format!("Unknown ship scope: {other}"))); - } - }, - }) - } - other => { - return Err(HostError::state(format!( - "Unsupported pull request action: {other}" - ))); - } - }) -} - /// The `gh` argv for an action. `head` is only read by create. pub(super) fn gh_args(action: &Action, identity: &GitHubIdentity, head: &str) -> Vec { let slug = identity.slug.clone(); @@ -433,64 +231,9 @@ pub(super) fn gh_args(action: &Action, identity: &GitHubIdentity, head: &str) -> } args } - Action::Link { .. } | Action::Unlink { .. } | Action::Ship(_) => Vec::new(), } } -async fn run_checked(repo_path: &str, args: &[String]) -> HostResult { - let args: Vec<&str> = args.iter().map(String::as_str).collect(); - let (code, stdout, stderr) = match run_gh(repo_path, &args).await { - Ok(output) => output, - Err(error) if error.wire_message().starts_with("failed to run gh") => { - return Err(gh_missing()); - } - Err(error) => return Err(error), - }; - if code != 0 { - return Err(gh_failure(&stderr, &stdout)); - } - Ok(stdout) -} - -async fn github_identity(repo_path: &str) -> HostResult { - let repo_path = repo_path.to_string(); - let remote = tokio::task::spawn_blocking(move || core_git::repository_remote_url(&repo_path)) - .await - .map_err(|error| HostError::state(format!("Could not read the git remote: {error}")))? - .ok() - .flatten(); - remote - .as_deref() - .and_then(parse_github_identity) - .ok_or_else(|| { - HostError::state( - "Pull request actions on mobile are available for GitHub repositories.", - ) - }) -} - -async fn current_branch(repo_path: &str) -> HostResult { - let repo_path = repo_path.to_string(); - let branch = tokio::task::spawn_blocking(move || core_git::current_branch(&repo_path)) - .await - .map_err(|error| HostError::state(format!("Could not read the current branch: {error}")))? - .map_err(|error| HostError::state(error.to_string()))?; - if branch.is_empty() || branch == "HEAD" { - return Err(HostError::state( - "Check out a branch before creating a pull request.", - )); - } - Ok(branch) -} - -fn positive_i64(payload: &Value, key: &str) -> HostResult { - payload - .get(key) - .and_then(Value::as_i64) - .filter(|value| *value > 0) - .ok_or_else(|| HostError::state(format!("{key} must be a positive integer."))) -} - #[cfg(test)] #[path = "mobile_pull_request_actions_tests.rs"] mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions_tests.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions_tests.rs index d0fddc5ec..80c12f729 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions_tests.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_actions_tests.rs @@ -1,6 +1,12 @@ use serde_json::json; +use super::super::mobile_pull_request_busy::BusyGuard; +use super::super::mobile_pull_request_links::workspace_review_reference; +use super::super::pull_request_forges::{ + parse_forge_action, ForgeAction, ForgeCommentSource, ForgeMergeMethod, +}; use super::*; +use crate::terminal_host::host_error::HostError; fn identity() -> GitHubIdentity { GitHubIdentity { @@ -11,9 +17,56 @@ fn identity() -> GitHubIdentity { } } +/// The GitHub command a parsed request becomes, as `GitHubForge` builds it. +fn github_action(action: ForgeAction) -> Action { + match action { + ForgeAction::Comment { + number, + body, + reply_to, + } => Action::Comment { + number, + body, + reply_to: reply_to.map(|locator| locator.comment_id), + }, + ForgeAction::CommentUpdate { + number, + locator, + body, + } => Action::CommentUpdate { + number, + comment_id: locator.comment_id, + source: match locator.source { + ForgeCommentSource::Conversation => CommentSource::Conversation, + ForgeCommentSource::ReviewSummary => CommentSource::ReviewSummary, + ForgeCommentSource::ReviewThread => CommentSource::ReviewThread, + }, + body, + }, + ForgeAction::Merge { number, method, .. } => Action::Merge { + number, + method: match method { + ForgeMergeMethod::MergeCommit => MergeMethod::MergeCommit, + ForgeMergeMethod::Squash => MergeMethod::Squash, + ForgeMergeMethod::Rebase => MergeMethod::Rebase, + ForgeMergeMethod::ProviderDefault => panic!("GitHub has no provider default"), + }, + }, + ForgeAction::DraftStatus { number, draft } => Action::DraftStatus { number, draft }, + ForgeAction::Close { number } => Action::Close { number }, + ForgeAction::Create(input) => Action::Create { + base: input.base, + title: input.title, + body: input.body, + draft: input.draft, + }, + other => panic!("not a gh command: {other:?}"), + } +} + fn args(request_type: &str, payload: Value) -> Vec { - let action = parse_action(request_type, &payload).unwrap(); - gh_args(&action, &identity(), "feat/x") + let action = parse_forge_action(request_type, &payload).unwrap(); + gh_args(&github_action(action), &identity(), "feat/x") } #[test] @@ -129,7 +182,8 @@ fn creates_from_the_current_branch() { #[test] fn rejects_malformed_payloads() { - let fails = |request_type: &str, payload: Value| parse_action(request_type, &payload).is_err(); + let fails = + |request_type: &str, payload: Value| parse_forge_action(request_type, &payload).is_err(); assert!(fails( "mobile.pullRequest.comment", json!({"number": 7, "body": " "}) @@ -138,10 +192,6 @@ fn rejects_malformed_payloads() { "mobile.pullRequest.comment", json!({"number": 0, "body": "x"}) )); - assert!(fails( - "mobile.pullRequest.merge", - json!({"number": 7, "method": "providerDefault"}) - )); assert!(fails( "mobile.pullRequest.merge", json!({"number": 7, "method": "fast"}) @@ -169,24 +219,40 @@ fn rejects_malformed_payloads() { #[test] fn parses_link_and_unlink() { assert_eq!( - parse_action("mobile.pullRequest.link", &json!({"reference": "#12"})).unwrap(), - Action::Link { + parse_forge_action("mobile.pullRequest.link", &json!({"reference": "#12"})).unwrap(), + ForgeAction::Link { reference: "#12".into() } ); assert_eq!( - parse_action( + parse_forge_action( "mobile.pullRequest.unlink", &json!({"number": 12, "url": "u"}) ) .unwrap(), - Action::Unlink { + ForgeAction::Unlink { number: 12, url: Some("u".into()) } ); } +#[test] +fn merges_carry_the_expected_head_and_any_forge_method() { + assert_eq!( + parse_forge_action( + "mobile.pullRequest.merge", + &json!({"number": 7, "method": "providerDefault", "expectedHeadSha": "abc"}) + ) + .unwrap(), + ForgeAction::Merge { + number: 7, + method: ForgeMergeMethod::ProviderDefault, + expected_head: Some("abc".into()), + } + ); +} + #[test] fn a_workspace_runs_one_write_at_a_time() { let first = BusyGuard::acquire("busy-test").unwrap(); diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_identity.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_identity.rs index 9ea1ac72d..b5e003ab8 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_identity.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_identity.rs @@ -34,21 +34,6 @@ pub(super) fn parse_github_identity(url: &str) -> Option { }) } -pub(super) fn detect_provider(url: Option<&str>) -> Option<&'static str> { - let url = url?; - let parsed = parse_remote_url(url)?; - if parsed.hostname == "gitlab.com" || parsed.hostname.contains("gitlab") { - return Some("gitlab"); - } - if parsed.hostname == "dev.azure.com" - || parsed.hostname.ends_with(".visualstudio.com") - || parsed.hostname.contains("azure") - { - return Some("azureDevops"); - } - None -} - pub(super) fn remote_identity_json(url: Option<&str>, provider: Option<&str>) -> Value { let parsed = url.and_then(parse_remote_url); json!({ @@ -115,10 +100,6 @@ mod tests { assert_eq!(ssh.owner, "leynier"); assert_eq!(ssh.repo, "alera"); assert!(parse_github_identity("https://gitlab.com/group/project.git").is_none()); - assert_eq!( - detect_provider(Some("https://gitlab.com/group/project.git")), - Some("gitlab") - ); } #[test] diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_links.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_links.rs index 10a40975f..0902c6646 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_links.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_links.rs @@ -1,19 +1,15 @@ -//! The workspace-to-review link a phone can change. Writes the same -//! `LinkedReview` records the desktop keeps (`linked_review.dart`): a link -//! names the review to show, and unlinking stores a dismissal of that exact -//! review so auto-detection stops surfacing it while a different review on the -//! branch can still appear. +//! Reading the workspace-to-review link the desktop keeps +//! (`linked_review.dart`): a link names the review to show, and unlinking +//! stores a dismissal of that exact review so auto-detection stops surfacing it +//! while a different review on the branch can still appear. The writes live in +//! `pull_request_forges::links`. -use alera_core::runtime::{LinkedReview, RuntimeStore}; -use chrono::Utc; -use serde_json::{json, Value}; +use alera_core::runtime::LinkedReview; use crate::terminal_host::host_error::{HostError, HostResult}; use super::mobile_pull_request_identity::GitHubIdentity; -pub(super) const PROVIDER: &str = "github"; - /// Parses `123`, `#123`, or a review URL into a number, like the desktop's /// `parseReviewReference`. pub(super) fn parse_review_reference(input: &str) -> Option { @@ -87,46 +83,16 @@ pub(super) fn dismissed_number(linked: Option<&LinkedReview>) -> Option { .and_then(|review| review.number) } -pub(super) async fn save_link( - store: &RuntimeStore, - workspace_id: &str, - number: i64, - url: Option, - dismissed: bool, -) -> HostResult<()> { - store - .upsert_linked_review(LinkedReview { - workspace_id: workspace_id.to_string(), - dismissed, - provider: Some(PROVIDER.to_string()), - number: Some(number), - url, - linked_at: Utc::now(), - }) - .await - .map(|_| ()) - .map_err(|error| HostError::state(error.to_string())) -} - -/// Shown in place of a review the user unlinked, so the phone can offer to -/// link it again. -pub(super) fn suggested_review_json(review: &Value) -> Value { - json!({ - "number": review.get("number"), - "title": review.get("title"), - "url": review.get("url"), - }) -} - #[cfg(test)] mod tests { use super::*; + use chrono::Utc; fn record(dismissed: bool, number: Option) -> LinkedReview { LinkedReview { workspace_id: "w".into(), dismissed, - provider: Some(PROVIDER.into()), + provider: Some("github".into()), number, url: None, linked_at: Utc::now(), diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_requests.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_requests.rs index 00449798f..4a8dd0195 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_requests.rs @@ -1,18 +1,15 @@ +//! The pull request snapshot verb and the `gh` reads GitHub's provider uses. + use std::collections::BTreeMap; use std::process::Stdio; use std::time::Duration; -use alera_core::git as core_git; -use alera_core::runtime::{RuntimeStore, Workspace}; +use alera_core::runtime::RuntimeStore; use serde_json::{json, Value}; use tokio::time::timeout; use crate::terminal_host::host_error::{HostError, HostResult}; -use super::mobile_pull_request_identity::{ - detect_provider, parse_github_identity, remote_identity_json, -}; -use super::mobile_pull_request_links::{dismissed_number, linked_number, suggested_review_json}; use super::mobile_workspace_file_requests::workspace_for_mobile_file_request; const GH_TIMEOUT: Duration = Duration::from_secs(45); @@ -25,7 +22,7 @@ pub(super) async fn snapshot_mobile_pull_request( payload: &Value, ) -> HostResult { let workspace = workspace_for_mobile_file_request(runtime_store, payload).await?; - let mut snapshot = load_snapshot(runtime_store, &workspace).await?; + let mut snapshot = super::pull_request_forges::load_snapshot(runtime_store, &workspace).await?; super::mobile_pull_request_snapshot_extras::decorate_snapshot( runtime_store, &workspace, @@ -36,191 +33,6 @@ pub(super) async fn snapshot_mobile_pull_request( Ok(snapshot) } -async fn load_snapshot(runtime_store: &RuntimeStore, workspace: &Workspace) -> HostResult { - let linked = runtime_store - .find_linked_review(&workspace.id) - .await - .map_err(|error| HostError::state(error.to_string()))?; - let repo_path = workspace.path.clone(); - let branch = tokio::task::spawn_blocking({ - let repo_path = repo_path.clone(); - move || core_git::current_branch(&repo_path) - }) - .await - .map_err(|error| HostError::state(format!("Could not read the current branch: {error}")))? - .ok(); - let remote_url = tokio::task::spawn_blocking({ - let repo_path = repo_path.clone(); - move || core_git::repository_remote_url(&repo_path) - }) - .await - .map_err(|error| HostError::state(format!("Could not read the git remote: {error}")))? - .ok() - .flatten(); - - let identity = remote_url.as_deref().and_then(parse_github_identity); - let provider = identity - .as_ref() - .map(|_| "github") - .or_else(|| detect_provider(remote_url.as_deref())); - let linked_json = linked.as_ref().map(|review| { - json!({ - "number": review.number, - "url": review.url, - "provider": review.provider, - "dismissed": review.dismissed, - }) - }); - - if provider != Some("github") { - return Ok(snapshot_envelope( - branch, - remote_url, - provider, - if provider.is_some() { - "unsupported" - } else { - "undetectable" - }, - linked_json, - Value::Null, - Some( - if provider.is_some() { - "This hosting provider is available on desktop. Mobile v1 loads GitHub pull requests through gh." - } else { - "No GitHub remote was detected for this workspace." - } - .to_string(), - ), - )); - } - let identity = identity.unwrap(); - - let auth_status = match run_gh( - &repo_path, - &["auth", "status", "--hostname", &identity.host], - ) - .await - { - Ok((0, _, _)) => "authenticated", - Ok(_) => "notAuthenticated", - Err(error) if looks_like_missing_cli(&error) => "cliMissing", - Err(error) => { - return Ok(snapshot_envelope( - branch, - remote_url, - Some("github"), - "cliMissing", - linked_json, - Value::Null, - Some(error.wire_message()), - )); - } - }; - if auth_status != "authenticated" { - return Ok(snapshot_envelope( - branch, - remote_url, - Some("github"), - auth_status, - linked_json, - Value::Null, - Some( - match auth_status { - "cliMissing" => { - "Install and authenticate the GitHub CLI (gh) on the paired computer." - } - _ => "Sign in with gh auth login on the paired computer.", - } - .to_string(), - ), - )); - } - - let mut suggested_review = None; - let review = if let Some(number) = linked_number(linked.as_ref()) { - view_review(&repo_path, &identity.slug, number).await? - } else if let Some(branch) = branch.as_deref() { - let detected = list_review_for_branch(&repo_path, &identity.slug, branch).await?; - // An unlinked review stays hidden until the user links it again. - match (detected, dismissed_number(linked.as_ref())) { - (Some(review), Some(dismissed)) - if review.get("number").and_then(Value::as_i64) == Some(dismissed) => - { - suggested_review = Some(suggested_review_json(&review)); - None - } - (detected, _) => detected, - } - } else { - None - }; - - let review_json = if let Some(review) = review { - let number = review.get("number").and_then(Value::as_i64).unwrap_or(0); - let checks = load_checks(&repo_path, &identity.slug, number).await; - let (comments, comments_truncated) = super::mobile_pull_request_comments::load_comments( - &repo_path, - &identity.host, - &identity.owner, - &identity.repo, - number, - ) - .await; - let mut review = review; - if let Some(object) = review.as_object_mut() { - object.insert("checks".into(), json!(checks)); - object.insert("comments".into(), json!(comments)); - object.insert("commentsTruncated".into(), json!(comments_truncated)); - } - review - } else { - Value::Null - }; - - let unavailable = match (&review_json, &suggested_review) { - (Value::Null, Some(_)) => { - Some("The pull request for this branch was unlinked from the workspace.".to_string()) - } - (Value::Null, None) => Some("No open pull request is linked to this branch.".to_string()), - _ => None, - }; - let mut snapshot = snapshot_envelope( - branch, - remote_url, - Some("github"), - auth_status, - linked_json, - review_json, - unavailable, - ); - if let Some(suggested) = suggested_review { - snapshot["suggestedReview"] = suggested; - } - Ok(snapshot) -} - -fn snapshot_envelope( - branch: Option, - remote_url: Option, - provider: Option<&str>, - auth_status: &str, - linked_review: Option, - review: Value, - unavailable_reason: Option, -) -> Value { - json!({ - "branch": branch, - "remoteUrl": remote_url, - "provider": provider, - "identity": remote_identity_json(remote_url.as_deref(), provider), - "authStatus": auth_status, - "linkedReview": linked_review, - "review": review, - "unavailableReason": unavailable_reason, - }) -} - pub(super) async fn view_review( repo_path: &str, slug: &str, @@ -255,7 +67,7 @@ pub(super) async fn view_review( parse_review_object(&stdout) } -async fn list_review_for_branch( +pub(super) async fn list_review_for_branch( repo_path: &str, slug: &str, branch: &str, @@ -293,7 +105,7 @@ async fn list_review_for_branch( Ok(items.first().cloned().and_then(normalize_review)) } -async fn load_checks(repo_path: &str, slug: &str, number: i64) -> Vec { +pub(super) async fn load_checks(repo_path: &str, slug: &str, number: i64) -> Vec { let number = number.to_string(); let Ok((_, stdout, _)) = run_gh( repo_path, @@ -386,14 +198,6 @@ async fn github_cli_output( .map_err(|error| HostError::state(format!("failed to run gh: {error}"))) } -fn looks_like_missing_cli(error: &HostError) -> bool { - let message = error.wire_message().to_ascii_lowercase(); - message.contains("no such file") - || message.contains("not found") - || message.contains("cannot find") - || message.contains("program not found") -} - #[cfg(all(test, unix))] mod process_lifetime_tests { use super::*; diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_ship.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_ship.rs index 0d7d46f40..840d0e825 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_ship.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_ship.rs @@ -1,9 +1,10 @@ -//! Ship for a paired phone: the desktop's one-tap path from local changes to a -//! pull request (`workspace_pull_request_ship_actions.dart`). It stages, writes -//! the commit message with AI Assist, moves the work off a shared base branch, -//! commits, pushes, and opens the pull request. It runs on the runtime rather -//! than as a sequence of phone requests, so a phone that sleeps halfway cannot -//! leave a commit without its pull request. +//! Ship for a paired phone, the CLI, and MCP: the desktop's one-tap path from +//! local changes to a pull request (`workspace_pull_request_ship_actions.dart`) +//! on any forge. It stages, writes the commit message with AI Assist, moves the +//! work off a shared base branch, commits, pushes, and opens the pull request; +//! only that last create-and-link step depends on the forge. It runs on the +//! runtime rather than as a sequence of client requests, so a phone that +//! sleeps halfway cannot leave a commit without its pull request. use alera_core::git as core_git; use alera_core::runtime::{RuntimeAiAssistSettings, RuntimeStore, Workspace}; @@ -18,11 +19,10 @@ use super::ai_assist_commit_message::generate_commit_message; use super::ai_assist_pull_request_details::{ generate_pull_request_details, parse_pull_request_details, }; -use super::mobile_pull_request_actions::create_and_link; -use super::mobile_pull_request_identity::GitHubIdentity; use super::mobile_source_control_snapshot::git_host_error; use super::mobile_source_control_write_requests::WorkspaceWriteGuard; use super::mobile_workspace_file_requests::spawn_blocking_workspace; +use super::pull_request_forges::{CreateInput, ForgeProvider}; use super::remote_ai_assist_requests::effective_ai_assist_settings; const MAX_BRANCH_CANDIDATES: usize = 100; @@ -56,7 +56,7 @@ pub(super) struct ShipPlan { pub(super) async fn ship_pull_request( store: &RuntimeStore, workspace: &Workspace, - identity: &GitHubIdentity, + forge: &dyn ForgeProvider, request: ShipRequest, hub_settings: Option, ) -> HostResult<()> { @@ -147,17 +147,16 @@ pub(super) async fn ship_pull_request( move || source_control::git_push(root).map_err(git_host_error) }) .await?; - create_and_link( - store, - workspace, - identity, - &request.base, - &head, - &details.title, - details.body.as_deref().unwrap_or_default(), - request.draft, - ) - .await + let input = CreateInput { + base: request.base.clone(), + head: head.clone(), + title: details.title.clone(), + body: details.body.clone().unwrap_or_default(), + draft: request.draft, + }; + super::pull_request_forges::create_and_link(store, workspace, forge, &input) + .await + .map(|_| ()) } .await; finish.map_err(|error| { diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_snapshot_extras.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_snapshot_extras.rs index 29d0d571f..b14edfa6c 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_snapshot_extras.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_snapshot_extras.rs @@ -1,17 +1,13 @@ -//! Additive snapshot fields a phone needs to offer pull request actions: -//! who is signed in (to allow editing only their own comments), which merge -//! methods apply, which base branches a new pull request can target, and -//! whether AI Assist can write its details. An older phone ignores all of +//! Additive snapshot fields a client needs to offer pull request actions on +//! any forge: who is signed in (to allow editing only their own comments), +//! which merge methods apply, which base branches a new pull request can +//! target, and whether AI Assist can write its details. An older phone ignores all of //! them; none of this bumps `aleraMobileProtocolVersion`. use alera_core::runtime::{RuntimeStore, Workspace}; use git2::{BranchType, Repository}; use serde_json::{json, Value}; -use super::mobile_pull_request_identity::parse_github_identity; -use super::mobile_pull_request_merge_methods::allowed_merge_methods; -use super::mobile_pull_request_requests::run_gh; - pub(super) async fn decorate_snapshot( store: &RuntimeStore, workspace: &Workspace, @@ -47,13 +43,11 @@ pub(super) async fn decorate_snapshot( if snapshot["authStatus"].as_str() != Some("authenticated") || !snapshot["review"].is_object() { return; } - let Some(identity) = snapshot["remoteUrl"] - .as_str() - .and_then(parse_github_identity) + let Some(forge) = super::pull_request_forges::snapshot_forge(snapshot, &workspace.path, None) else { return; }; - let viewer = viewer_login(&workspace.path, &identity.host).await; + let viewer = forge.viewer().await; mark_editable_comments(snapshot, viewer.as_deref()); snapshot["viewerLogin"] = json!(viewer); if !open { @@ -62,7 +56,7 @@ pub(super) async fn decorate_snapshot( let base = snapshot["review"]["baseRefName"] .as_str() .map(ToOwned::to_owned); - match allowed_merge_methods(&workspace.path, &identity, base.as_deref()).await { + match forge.merge_methods(base.as_deref()).await { Ok(methods) => snapshot["mergeMethods"] = json!(methods), Err(error) => { snapshot["mergeMethods"] = json!([]); @@ -71,17 +65,6 @@ pub(super) async fn decorate_snapshot( } } -async fn viewer_login(repo_path: &str, host: &str) -> Option { - let (code, stdout, _) = run_gh( - repo_path, - &["api", "--hostname", host, "user", "--jq", ".login"], - ) - .await - .ok()?; - let login = stdout.trim(); - (code == 0 && !login.is_empty()).then(|| login.to_string()) -} - /// Only the author may edit a comment from the phone. fn mark_editable_comments(snapshot: &mut Value, viewer: Option<&str>) { let Some(comments) = snapshot["review"]["comments"].as_array_mut() else { diff --git a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_summaries.rs b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_summaries.rs index bc8a2e93e..3de5ea453 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_pull_request_summaries.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_pull_request_summaries.rs @@ -4,8 +4,9 @@ //! branch lookups, linked numbers, and the check rollup. Project batches run //! concurrently so a slow `gh` in one repo cannot starve the rest of the list //! past the phone's two-minute timeout. A failed batch stays out of -//! `evaluatedWorkspaceIds` so the phone keeps last-known icons. Non-GitHub -//! remotes are a quiet empty group. A project whose folder lives on another +//! `evaluatedWorkspaceIds` so the phone keeps last-known icons. GitLab and +//! Azure DevOps projects are read one workspace at a time through their forge +//! (`pull_request_forges::summaries`); other remotes are a quiet empty group. A project whose folder lives on another //! host is answered by that host (`mobile_pull_request_summaries_remote`). use std::collections::{BTreeMap, BTreeSet}; @@ -22,7 +23,7 @@ use crate::terminal_host::host_link_registry::HostLinkRegistry; use super::mobile_pull_request_check_counts::{ count_check_contexts, status_rollup_contexts, CheckCounts, }; -use super::mobile_pull_request_identity::{parse_github_identity, GitHubIdentity}; +use super::mobile_pull_request_identity::GitHubIdentity; use super::mobile_pull_request_requests::run_gh; /// What one project contributed to the batch: its summaries and the @@ -135,7 +136,7 @@ fn summaries_envelope( }) } -async fn workspace_lookup_branch(workspace: &Workspace) -> Option { +pub(super) async fn workspace_lookup_branch(workspace: &Workspace) -> Option { if let Some(branch) = resolved_workspace_branch(workspace.branch.as_deref(), None) { return Some(branch); } @@ -170,10 +171,32 @@ async fn pull_request_summaries_for_project( .ok() .flatten() }; - let identity = remote_url.as_deref().and_then(parse_github_identity); - let Some(identity) = identity else { + let forced = match group.first() { + Some(workspace) => { + super::pull_request_forges::project_forge_override(runtime_store, &workspace.project_id) + .await + } + None => None, + }; + let (Some(url), Some(forge)) = ( + remote_url.as_deref(), + remote_url + .as_deref() + .and_then(|url| super::pull_request_forges::resolve_identity(url, forced)), + ) else { return Ok(Vec::new()); }; + if forge.kind != super::pull_request_forges::ForgeKind::GitHub { + return super::pull_request_forges::forge_project_summaries( + runtime_store, + repo_path, + forge, + url, + group, + ) + .await; + } + let identity = forge.github(url); let mut linked_numbers = BTreeMap::<&str, Option>::new(); let mut dismissed_numbers = BTreeMap::<&str, Option>::new(); diff --git a/rust/alera-cli/src/terminal_host/server/mobile_workspace_file_requests.rs b/rust/alera-cli/src/terminal_host/server/mobile_workspace_file_requests.rs index 6c98f30a5..c4e9fd260 100644 --- a/rust/alera-cli/src/terminal_host/server/mobile_workspace_file_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/mobile_workspace_file_requests.rs @@ -218,6 +218,9 @@ async fn handle_mobile_workspace_file_request( ) .await } + verb if super::pull_request_forges::is_stack_verb(verb) => { + super::pull_request_forges::handle_stack_request(&runtime_store, verb, payload).await + } _ => Err(HostError::state( "Unsupported mobile workspace file operation.", )), diff --git a/rust/alera-cli/src/terminal_host/server/prompt_workspace_creation.rs b/rust/alera-cli/src/terminal_host/server/prompt_workspace_creation.rs new file mode 100644 index 000000000..b4c56e2ad --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/prompt_workspace_creation.rs @@ -0,0 +1,338 @@ +//! Creating the workspace of a From Prompt operation: its identity, a branch +//! nobody uses yet, the worktree or project folder, and its section. + +use std::time::Duration; + +use alera_core::git as core_git; +use alera_core::runtime::{Project, ProjectKind, WorkspaceStatus, LOCAL_HOST_ID}; +use serde_json::{json, Value}; + +use super::prompt_workspace_operation::{SectionPolicy, StartMode}; +use super::prompt_workspace_pipeline::{state, PromptWorkspaceRun, Step, Stop}; +use crate::terminal_host::host_error::HostError; + +const CREATE_DEADLINE: Duration = Duration::from_secs(10 * 60); +const SECTION_DEADLINE: Duration = Duration::from_secs(60); +/// The same retry hint the app's form adds when a generated branch is taken. +const RETRY_IDENTITY_HINT: &str = "\n\nThe previous generated workspace identity was unavailable. Generate a different workspace name and branch."; +/// Numbered branches tried after a second generated identity is also taken. +const NUMBERED_SUFFIXES: std::ops::RangeInclusive = 2..=9; + +/// Whether the workspace gets its own worktree. Auto follows the app: a +/// worktree for Git projects, the project folder for plain folders. +pub(super) fn uses_worktree(mode: StartMode, kind: ProjectKind) -> Result { + match (mode, kind) { + (StartMode::ProjectCheckout, _) => Ok(false), + (StartMode::Worktree, ProjectKind::GitRepository) => Ok(true), + (StartMode::Worktree, ProjectKind::Folder) => Err(HostError::state( + "This project is not a Git repository, so it cannot have a worktree. Use mode projectCheckout.", + )), + (StartMode::Auto, kind) => Ok(kind == ProjectKind::GitRepository), + } +} + +pub(super) fn numbered_branch(branch: &str, number: u32) -> String { + format!("{}-{number}", branch.trim_end_matches('/')) +} + +fn looks_like_collision(error: &HostError) -> bool { + let message = error.to_string().to_lowercase(); + message.contains("already exists") || message.contains("workspace for branch") +} + +impl PromptWorkspaceRun { + pub(super) async fn create_workspace( + &mut self, + project: &Project, + prompt: &str, + inferred: Option, + ) -> Step<()> { + let worktree = uses_worktree(self.operation.request.mode, project.kind)?; + let host_id = self + .operation + .request + .host_id + .clone() + .unwrap_or_else(|| LOCAL_HOST_ID.to_owned()); + let local = crate::project_hosts::project_folder_is_local(&self.store, project).await + && !crate::ssh_remote::is_remote_host_id(Some(&host_id)); + let source_branch = if worktree { + self.source_branch(project, local).await + } else { + None + }; + let mut identity = match inferred { + Some(identity) => { + self.operation.identity = Some(identity.clone()); + identity + } + None => self.project_identity(project, prompt).await?, + }; + for attempt in 0..2 { + let branch = identity_field(&identity, "branchName")?; + if worktree && self.branch_taken(project, &host_id, &branch, local).await { + if attempt == 0 { + identity = self + .project_identity(project, &format!("{prompt}{RETRY_IDENTITY_HINT}")) + .await?; + } + continue; + } + match self + .create( + project, + &identity, + &branch, + worktree, + source_branch.as_deref(), + ) + .await + { + Ok(()) => return Ok(()), + Err(Stop::Failed(error)) if attempt == 0 && looks_like_collision(&error) => { + identity = self + .project_identity(project, &format!("{prompt}{RETRY_IDENTITY_HINT}")) + .await?; + } + Err(stop) => return Err(stop), + } + } + let base = identity_field(&identity, "branchName")?; + for number in NUMBERED_SUFFIXES { + let branch = numbered_branch(&base, number); + if self.branch_taken(project, &host_id, &branch, local).await { + continue; + } + match self + .create( + project, + &identity, + &branch, + worktree, + source_branch.as_deref(), + ) + .await + { + Ok(()) => return Ok(()), + Err(Stop::Failed(error)) if looks_like_collision(&error) => continue, + Err(stop) => return Err(stop), + } + } + Err( + HostError::state("AI Assist could not generate an available workspace identity.") + .into(), + ) + } + + async fn project_identity(&mut self, project: &Project, prompt: &str) -> Step { + self.set_phase("generatingIdentity").await?; + let identity = self + .generate_identity(json!({ + "projectId": project.id, + "prompt": prompt, + "autoAssignSection": self.auto_section(), + })) + .await?; + self.operation.identity = Some(identity.clone()); + Ok(identity) + } + + /// The requested branch, else the project's preferred source branch, else + /// the repository's default or current branch. + async fn source_branch(&self, project: &Project, local: bool) -> Option { + if let Some(branch) = self.operation.request.source_branch.clone() { + return Some(branch); + } + if let Some(branch) = + crate::worktree_setup::preferred_source_branch(&self.store, project).await + { + return Some(branch); + } + if !local { + return None; + } + core_git::default_branch(&project.repo_path) + .ok() + .or_else(|| core_git::current_branch(&project.repo_path).ok()) + } + + /// A branch an active workspace of the project already uses on that host, + /// or one that exists in the local repository. + async fn branch_taken( + &self, + project: &Project, + host_id: &str, + branch: &str, + local: bool, + ) -> bool { + let used = self + .store + .list_workspaces(&project.id) + .await + .unwrap_or_default() + .iter() + .any(|workspace| { + workspace.status == WorkspaceStatus::Active + && workspace.host_id == host_id + && workspace.branch.as_deref().map(str::trim) == Some(branch) + }); + used || (local && core_git::branch_exists(&project.repo_path, branch).unwrap_or(false)) + } + + async fn create( + &mut self, + project: &Project, + identity: &Value, + branch: &str, + worktree: bool, + source_branch: Option<&str>, + ) -> Step<()> { + self.set_phase("creatingWorkspace").await?; + let request = self.operation.request.clone(); + let name = identity_field(identity, "workspaceName")?; + let (request_type, payload) = if worktree { + ( + "workspace.createManaged", + json!({ + "projectId": project.id, + "name": name, + "branch": branch, + "sourceBranch": source_branch, + "reuseExistingBranch": false, + "parentWorkspaceId": request.parent_workspace_id, + "hostId": request.host_id, + "issueUrl": request.issue_url, + "deferSetup": true, + }), + ) + } else { + ( + "workspace.createShared", + json!({ + "projectId": project.id, + "name": name, + "parentWorkspaceId": request.parent_workspace_id, + "hostId": request.host_id, + "issueUrl": request.issue_url, + }), + ) + }; + let created = self + .call_to_completion(request_type, payload, CREATE_DEADLINE) + .await?; + let workspace = created + .get("workspace") + .cloned() + .ok_or_else(|| HostError::state("The workspace was created without a record."))?; + self.operation.workspace = Some(workspace); + if let Some(command) = created + .get("deferredSetupCommand") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + { + self.operation.setup = Some(json!({ "command": command })); + } + self.save().await; + // A cancel that arrived during creation stops here, with the + // workspace recorded so its launch can still be retried. + self.check_cancelled() + } + + /// Joins the section AI Assist picked, or the one the request names. + /// Like the app, a failure here leaves the workspace in place with a + /// warning; no section ("Others") is a valid outcome. + pub(super) async fn assign_section(&mut self) { + let Some(workspace_id) = self.operation.workspace_id().map(str::to_owned) else { + return; + }; + let section_id = match self.wanted_section().await { + Ok(Some(section_id)) => section_id, + Ok(None) => return, + Err(message) => { + self.operation.warnings.push(message); + return; + } + }; + if self.set_phase("assigningSection").await.is_err() { + return; + } + let payload = json!({ "workspaceId": workspace_id, "sectionId": section_id }); + match self + .call( + "workspaceSection.setForWorkspace", + payload, + SECTION_DEADLINE, + ) + .await + { + Ok(_) => self.operation.section_id = Some(section_id), + Err(Stop::Failed(error)) => self.operation.warnings.push(format!( + "The workspace was not added to its section: {error}" + )), + Err(Stop::Cancelled) => {} + } + self.save().await; + } + + async fn wanted_section(&self) -> Result, String> { + let sections = || async { + self.store + .list_workspace_sections() + .await + .map_err(|error| format!("Sections are unavailable: {error}")) + }; + match &self.operation.request.section { + SectionPolicy::None => Ok(None), + SectionPolicy::Auto => Ok(self + .operation + .identity + .as_ref() + .and_then(|identity| identity["sectionId"].as_str()) + .map(str::to_owned)), + SectionPolicy::Id(id) => sections() + .await? + .into_iter() + .find(|section| §ion.id == id) + .map(|section| Some(section.id)) + .ok_or_else(|| format!("Section not found: {id}")), + SectionPolicy::Name(name) => sections() + .await? + .into_iter() + .find(|section| section.name.eq_ignore_ascii_case(name)) + .map(|section| Some(section.id)) + .ok_or_else(|| format!("Section not found: {name}")), + } + } +} + +fn identity_field(identity: &Value, key: &str) -> Step { + identity + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| Stop::Failed(state(format!("AI Assist returned no {key}.")))) +} + +#[cfg(test)] +mod tests { + use alera_core::runtime::ProjectKind; + + use super::{numbered_branch, uses_worktree}; + use crate::terminal_host::server::prompt_workspace_operation::StartMode; + + #[test] + fn auto_mode_uses_a_worktree_only_for_git_projects() { + assert!(uses_worktree(StartMode::Auto, ProjectKind::GitRepository).unwrap()); + assert!(!uses_worktree(StartMode::Auto, ProjectKind::Folder).unwrap()); + assert!(!uses_worktree(StartMode::ProjectCheckout, ProjectKind::GitRepository).unwrap()); + assert!(uses_worktree(StartMode::Worktree, ProjectKind::Folder).is_err()); + } + + #[test] + fn numbered_branches_keep_the_generated_prefix() { + assert_eq!(numbered_branch("feat/login", 2), "feat/login-2"); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/prompt_workspace_operation.rs b/rust/alera-cli/src/terminal_host/server/prompt_workspace_operation.rs new file mode 100644 index 000000000..0f8e34331 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/prompt_workspace_operation.rs @@ -0,0 +1,239 @@ +//! The record of one New Workspace from Prompt operation, as clients read it. +//! +//! The runtime host runs the same steps as the desktop's From Prompt form: +//! choose the project, generate the identity and section, create the +//! workspace, start its setup, and launch the agent. Each step updates this +//! record, which `workspace.promptStart.get` returns and a retry reuses. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +pub(super) const RUNNING: &str = "running"; +pub(super) const NEEDS_INPUT: &str = "needsInput"; +pub(super) const COMPLETED: &str = "completed"; +pub(super) const FAILED: &str = "failed"; +pub(super) const CANCELLED: &str = "cancelled"; +pub(super) const MAX_PROMPT_CHARS: usize = 65_536; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum StartMode { + /// A worktree for Git projects, the project folder otherwise. + #[default] + Auto, + Worktree, + ProjectCheckout, +} + +/// Which sidebar section the new workspace joins. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum SectionPolicy { + /// AI Assist picks a section, or none ("Others") when nothing fits. + #[default] + Auto, + None, + Id(String), + Name(String), +} + +impl SectionPolicy { + /// `"auto"`, `"none"`, `{ "id": … }` or `{ "name": … }`. + pub(super) fn parse(value: Option<&Value>) -> HostResult { + let invalid = || HostError::format("section must be auto, none, {id}, or {name}"); + match value { + None | Some(Value::Null) => Ok(Self::Auto), + Some(Value::String(text)) => match text.as_str() { + "auto" => Ok(Self::Auto), + "none" => Ok(Self::None), + _ => Err(invalid()), + }, + Some(Value::Object(object)) => { + let field = |key: &str| { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }; + match (field("id"), field("name")) { + (Some(id), None) => Ok(Self::Id(id)), + (None, Some(name)) => Ok(Self::Name(name)), + _ => Err(invalid()), + } + } + Some(_) => Err(invalid()), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PromptWorkspaceRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) project_id: Option, + /// Agent profile id (`prof_…`) or unique name. Omitted means the + /// runtime's default profile, as in the app. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) profile: Option, + #[serde(default)] + pub(super) mode: StartMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) source_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) host_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) parent_workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) issue_url: Option, + #[serde(default)] + pub(super) section: SectionPolicy, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct PromptWorkspaceOperation { + pub(super) id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) request_id: Option, + pub(super) status: String, + pub(super) phase: String, + pub(super) request: PromptWorkspaceRequest, + /// Kept while the operation can still use it; a finished operation keeps + /// only its hash. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) prompt: Option, + pub(super) prompt_hash: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) candidates: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) project_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) profile_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) identity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) workspace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) section_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) setup: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) warnings: Vec, + pub(super) client_mutation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) origin: Option, +} + +impl PromptWorkspaceOperation { + pub(super) fn new( + id: String, + request_id: Option, + prompt: String, + request: PromptWorkspaceRequest, + origin: Option, + ) -> Self { + Self { + client_mutation_id: format!("prompt-workspace-{id}"), + prompt_hash: prompt_hash(&prompt), + id, + request_id, + status: RUNNING.to_owned(), + phase: "resolvingProject".to_owned(), + request, + prompt: Some(prompt), + candidates: Vec::new(), + project_id: None, + profile_id: None, + identity: None, + workspace: None, + section_id: None, + agent: None, + setup: None, + warnings: Vec::new(), + error: None, + origin, + } + } + + pub(super) fn workspace_id(&self) -> Option<&str> { + self.workspace.as_ref()?.get("id")?.as_str() + } + + /// A launch can be retried once the workspace exists and nothing runs. + pub(super) fn can_retry_launch(&self) -> bool { + self.workspace_id().is_some() + && self.agent.is_none() + && matches!(self.status.as_str(), FAILED | CANCELLED) + } + + pub(super) fn fail(&mut self, code: &str, message: &str, retryable: bool) { + self.status = FAILED.to_owned(); + self.error = Some(json!({ "code": code, "message": message, "retryable": retryable })); + self.forget_prompt_unless_retryable(); + } + + pub(super) fn finish(&mut self, status: &str) { + self.status = status.to_owned(); + if status == COMPLETED { + self.phase = "done".to_owned(); + self.prompt = None; + } else { + self.forget_prompt_unless_retryable(); + } + } + + /// A launch retry needs the prompt again, so it stays only for that. + fn forget_prompt_unless_retryable(&mut self) { + if !self.can_retry_launch() && self.status != NEEDS_INPUT { + self.prompt = None; + } + } + + pub(super) fn to_value(&self) -> Value { + serde_json::to_value(self).unwrap_or_else(|_| json!({ "id": self.id })) + } + + /// What clients see: never the prompt text, which they sent themselves. + pub(super) fn public_value(&self) -> Value { + let mut value = self.to_value(); + if let Some(object) = value.as_object_mut() { + object.remove("prompt"); + } + value + } +} + +pub(super) fn prompt_hash(prompt: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(prompt.as_bytes()); + hex::encode(hasher.finalize()) +} + +/// A host error as the shared `{ code, message, retryable }` shape. +pub(super) fn error_code(error: &HostError) -> (&'static str, bool) { + let message = error.to_string().to_lowercase(); + if message.contains("ai assist") { + ("ai_assist_unavailable", false) + } else if message.contains("already exists") || message.contains("workspace for branch") { + ("conflict", false) + } else if message.contains("not found") { + ("not_found", false) + } else if message.contains("did not finish") || message.contains("closed the connection") { + ("runtime_unavailable", true) + } else { + ("failed", false) + } +} + +#[cfg(test)] +#[path = "prompt_workspace_operation_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/prompt_workspace_operation_tests.rs b/rust/alera-cli/src/terminal_host/server/prompt_workspace_operation_tests.rs new file mode 100644 index 000000000..03ff49248 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/prompt_workspace_operation_tests.rs @@ -0,0 +1,79 @@ +use serde_json::json; + +use super::{ + PromptWorkspaceOperation, PromptWorkspaceRequest, SectionPolicy, StartMode, CANCELLED, + COMPLETED, FAILED, +}; + +fn operation() -> PromptWorkspaceOperation { + PromptWorkspaceOperation::new( + "op-1".to_owned(), + Some("req-1".to_owned()), + "Fix the login screen".to_owned(), + PromptWorkspaceRequest::default(), + None, + ) +} + +#[test] +fn section_policy_accepts_the_four_forms() { + assert_eq!(SectionPolicy::parse(None).unwrap(), SectionPolicy::Auto); + assert_eq!( + SectionPolicy::parse(Some(&json!("none"))).unwrap(), + SectionPolicy::None + ); + assert_eq!( + SectionPolicy::parse(Some(&json!({ "id": "s-1" }))).unwrap(), + SectionPolicy::Id("s-1".to_owned()) + ); + assert_eq!( + SectionPolicy::parse(Some(&json!({ "name": " Alera " }))).unwrap(), + SectionPolicy::Name("Alera".to_owned()) + ); + assert!(SectionPolicy::parse(Some(&json!("Alera"))).is_err()); + assert!(SectionPolicy::parse(Some(&json!({ "id": "a", "name": "b" }))).is_err()); +} + +#[test] +fn requests_default_to_auto_mode_and_section() { + let request: PromptWorkspaceRequest = serde_json::from_value(json!({})).unwrap(); + assert_eq!(request.mode, StartMode::Auto); + assert_eq!(request.section, SectionPolicy::Auto); + let checkout: PromptWorkspaceRequest = + serde_json::from_value(json!({ "mode": "projectCheckout" })).unwrap(); + assert_eq!(checkout.mode, StartMode::ProjectCheckout); +} + +#[test] +fn public_values_never_carry_the_prompt() { + let operation = operation(); + assert!(operation.to_value().get("prompt").is_some()); + let public = operation.public_value(); + assert!(public.get("prompt").is_none()); + assert_eq!(public["promptHash"].as_str().unwrap().len(), 64); + assert_eq!(public["clientMutationId"], "prompt-workspace-op-1"); +} + +#[test] +fn finished_operations_drop_the_prompt_unless_a_launch_retry_needs_it() { + let mut done = operation(); + done.finish(COMPLETED); + assert!(done.prompt.is_none()); + assert_eq!(done.phase, "done"); + + let mut before_creation = operation(); + before_creation.fail("failed", "boom", false); + assert!(before_creation.prompt.is_none()); + + let mut after_creation = operation(); + after_creation.workspace = Some(json!({ "id": "w-1" })); + after_creation.fail("failed", "launch failed", true); + assert_eq!(after_creation.status, FAILED); + assert!(after_creation.can_retry_launch()); + assert!(after_creation.prompt.is_some()); + + let mut cancelled = operation(); + cancelled.workspace = Some(json!({ "id": "w-1" })); + cancelled.finish(CANCELLED); + assert!(cancelled.can_retry_launch()); +} diff --git a/rust/alera-cli/src/terminal_host/server/prompt_workspace_pipeline.rs b/rust/alera-cli/src/terminal_host/server/prompt_workspace_pipeline.rs new file mode 100644 index 000000000..5bcc08064 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/prompt_workspace_pipeline.rs @@ -0,0 +1,390 @@ +//! The steps of a New Workspace from Prompt operation. +//! +//! The run calls this runtime's own requests through a local client, so each +//! step goes through the same handlers, checks, and broadcasts as the app's +//! From Prompt form: AI Assist names the workspace and picks its section, +//! `workspace.createManaged` or `workspace.createShared` creates it with the +//! setup deferred, the agent profile launches idempotently, and the setup +//! starts in a tab named "Setup". + +use std::path::PathBuf; +use std::time::Duration; + +use alera_core::runtime::{Project, RuntimeStore}; +use serde_json::{json, Value}; +use tokio::sync::oneshot; +use uuid::Uuid; + +use super::hub_self_client::HubSelfClientPool; +use super::prompt_workspace_operation::{ + error_code, PromptWorkspaceOperation, CANCELLED, COMPLETED, NEEDS_INPUT, +}; +use super::ServerCommand; +use crate::terminal_host::host_error::HostError; +use crate::terminal_host::ServerInbox; + +pub(super) const IDENTITY_DEADLINE: Duration = Duration::from_secs(11 * 60); +const CREATE_DEADLINE: Duration = Duration::from_secs(10 * 60); +pub(super) const SHORT_DEADLINE: Duration = Duration::from_secs(60); + +pub(super) enum Stop { + Cancelled, + Failed(HostError), +} + +impl From for Stop { + fn from(error: HostError) -> Self { + Self::Failed(error) + } +} + +pub(super) type Step = Result; + +pub(super) struct PromptWorkspaceRun { + pub(super) store: RuntimeStore, + inbox: ServerInbox, + runtime_dir: PathBuf, + pub(super) operation: PromptWorkspaceOperation, + cancel: oneshot::Receiver<()>, + clients: HubSelfClientPool, + /// The last status and phase written to the event journal. + last_recorded: Option<(String, String)>, +} + +impl PromptWorkspaceRun { + pub(super) fn new( + store: RuntimeStore, + inbox: ServerInbox, + runtime_dir: PathBuf, + operation: PromptWorkspaceOperation, + cancel: oneshot::Receiver<()>, + ) -> Self { + Self { + store, + inbox, + runtime_dir, + operation, + cancel, + clients: HubSelfClientPool::default(), + last_recorded: None, + } + } + + /// Runs every step, or only the launch when the workspace already exists. + pub(super) async fn run(mut self, launch_only: bool) { + let outcome = if launch_only { + self.launch_and_setup().await + } else { + self.create_and_launch().await + }; + match outcome { + Ok(()) => {} + Err(Stop::Cancelled) => self.operation.finish(CANCELLED), + Err(Stop::Failed(error)) => { + let (code, retryable) = error_code(&error); + let retryable = retryable || self.operation.workspace_id().is_some(); + self.operation.fail(code, &error.to_string(), retryable); + } + } + self.save().await; + let operation_id = self.operation.id.clone(); + let _ = self + .inbox + .send_wait(ServerCommand::PromptWorkspaceOperationFinished { operation_id }) + .await; + } + + async fn create_and_launch(&mut self) -> Step<()> { + let prompt = self.prompt()?; + let Some((project, inferred)) = self.resolve_project(&prompt).await? else { + self.operation.finish(NEEDS_INPUT); + return Ok(()); + }; + self.operation.project_id = Some(project.id.clone()); + self.resolve_profile().await?; + self.create_workspace(&project, &prompt, inferred).await?; + self.assign_section().await; + self.launch_and_setup().await + } + + pub(super) fn prompt(&self) -> Step { + self.operation.prompt.clone().ok_or_else(|| { + Stop::Failed(HostError::state( + "The prompt of this operation is no longer available.", + )) + }) + } + + pub(super) async fn set_phase(&mut self, phase: &str) -> Step<()> { + self.check_cancelled()?; + self.operation.phase = phase.to_owned(); + self.save().await; + Ok(()) + } + + pub(super) fn check_cancelled(&mut self) -> Step<()> { + match self.cancel.try_recv() { + Err(oneshot::error::TryRecvError::Empty) => Ok(()), + _ => Err(Stop::Cancelled), + } + } + + pub(super) async fn save(&mut self) { + let status = self.operation.status.clone(); + let transition = (status.clone(), self.operation.phase.clone()); + if self.last_recorded.as_ref() != Some(&transition) { + super::runtime_event_requests::record_runtime_event( + &self.store, + "workspace.start.state", + self.operation.workspace_id(), + self.operation.project_id.as_deref(), + &json!({ + "operationId": self.operation.id, + "status": transition.0, + "phase": transition.1, + }), + ) + .await; + self.last_recorded = Some(transition); + } + let data = self.operation.to_value(); + if let Err(error) = self + .store + .update_prompt_workspace_operation(&self.operation.id, &status, &data) + .await + { + tracing::warn!("could not save prompt workspace operation: {error}"); + } + let operation_id = self.operation.id.clone(); + let _ = self + .inbox + .send_wait(ServerCommand::PromptWorkspaceOperationChanged { operation_id }) + .await; + } + + /// One request to this runtime, abandoned when the operation is cancelled. + pub(super) async fn call( + &mut self, + request_type: &str, + payload: Value, + deadline: Duration, + ) -> Step { + self.check_cancelled()?; + let key = format!("prompt-workspace:{}", self.operation.id); + let request = + self.clients + .request(&self.runtime_dir, &key, request_type, payload, deadline); + tokio::select! { + biased; + _ = &mut self.cancel => Err(Stop::Cancelled), + result = request => result.map_err(Stop::Failed), + } + } + + /// A request that must finish once sent. Creating a workspace runs on + /// past an abandoned call, so cancelling mid-way would leave the new + /// workspace outside the operation and its launch retry. + pub(super) async fn call_to_completion( + &mut self, + request_type: &str, + payload: Value, + deadline: Duration, + ) -> Step { + self.check_cancelled()?; + let key = format!("prompt-workspace:{}", self.operation.id); + self.clients + .request(&self.runtime_dir, &key, request_type, payload, deadline) + .await + .map_err(Stop::Failed) + } + + pub(super) async fn generate_identity(&mut self, payload: Value) -> Step { + let operation_id = Uuid::new_v4().to_string(); + let mut payload = payload; + payload["operationId"] = json!(operation_id); + let result = self + .call( + "aiText.workspaceIdentity.generate", + payload, + IDENTITY_DEADLINE, + ) + .await; + if matches!(result, Err(Stop::Cancelled)) { + // The generation runs on its own; stop it too. + let _ = self + .clients + .request( + &self.runtime_dir, + &format!("prompt-workspace:{}", self.operation.id), + "aiText.cancel", + json!({ "operationId": operation_id }), + SHORT_DEADLINE, + ) + .await; + } + result + } + + /// The project the request names, the only registered one, or the one AI + /// Assist recognizes in the prompt. `None` means the answer was unclear + /// and the operation now lists candidates instead of guessing. + async fn resolve_project(&mut self, prompt: &str) -> Step)>> { + self.set_phase("resolvingProject").await?; + if let Some(project_id) = self.operation.request.project_id.clone() { + return Ok(Some((self.find_project(&project_id).await?, None))); + } + let projects = self.store.list_projects().await.map_err(state)?; + match projects.len() { + 0 => return Err(HostError::state("No projects are registered in Alera.").into()), + 1 => return Ok(Some((projects[0].clone(), None))), + _ => {} + } + self.set_phase("generatingIdentity").await?; + let answer = self + .generate_identity(json!({ + "prompt": prompt, + "inferProject": true, + "autoAssignSection": self.auto_section(), + })) + .await?; + if let Some(project_id) = answer.get("projectId").and_then(Value::as_str) { + let project = self.find_project(project_id).await?; + return Ok(Some((project, Some(answer)))); + } + self.operation.candidates = self.project_candidates(projects).await; + self.operation.error = Some(json!({ + "code": "needs_input", + "message": "The prompt does not clearly name a project. Retry with projectId set to one of the candidates.", + "retryable": false, + })); + Ok(None) + } + + async fn find_project(&self, project_id: &str) -> Step { + self.store + .find_project(project_id) + .await + .map_err(state)? + .ok_or_else(|| HostError::state(format!("Project not found: {project_id}")).into()) + } + + /// Projects ordered by their most recent workspace activity. + async fn project_candidates(&self, projects: Vec) -> Vec { + let workspaces = self.store.list_all_workspaces().await.unwrap_or_default(); + let mut ranked = projects + .into_iter() + .map(|project| { + let latest = workspaces + .iter() + .filter(|workspace| workspace.project_id == project.id) + .map(|workspace| workspace.updated_at) + .max() + .unwrap_or(project.updated_at); + (latest, project) + }) + .collect::>(); + ranked.sort_by_key(|(latest, _)| std::cmp::Reverse(*latest)); + ranked + .into_iter() + .map(|(_, project)| { + json!({ "projectId": project.id, "name": project.name, "path": project.repo_path }) + }) + .collect() + } + + /// The named profile, else the runtime's default, else the first one, as + /// the app's form picks it. + async fn resolve_profile(&mut self) -> Step<()> { + let listed = self + .call("agentProfile.list", json!({}), SHORT_DEADLINE) + .await?; + let profiles = listed["items"].as_array().cloned().unwrap_or_default(); + let id_of = |profile: &Value| profile["id"].as_str().map(str::to_owned); + let selected = match self.operation.request.profile.as_deref() { + Some(wanted) => profiles + .iter() + .find(|profile| { + profile["id"].as_str() == Some(wanted) + || profile["name"] + .as_str() + .is_some_and(|name| name.eq_ignore_ascii_case(wanted)) + }) + .and_then(id_of) + .ok_or_else(|| HostError::state(format!("Agent profile not found: {wanted}")))?, + None => { + let default = self.store.default_agent_profile_id().await.ok().flatten(); + default + .filter(|id| { + profiles + .iter() + .any(|profile| profile["id"].as_str() == Some(id)) + }) + .or_else(|| profiles.first().and_then(id_of)) + .ok_or_else(|| HostError::state("No agent profiles are declared."))? + } + }; + self.operation.profile_id = Some(selected); + Ok(()) + } + + async fn launch_and_setup(&mut self) -> Step<()> { + let workspace_id = self + .operation + .workspace_id() + .map(str::to_owned) + .ok_or_else(|| HostError::state("The operation has no workspace to launch in."))?; + let launch = self.launch_agent(&workspace_id).await; + // The app opens the setup even when the launch failed. + self.start_setup(&workspace_id).await; + launch?; + self.operation.finish(COMPLETED); + Ok(()) + } + + async fn launch_agent(&mut self, workspace_id: &str) -> Step<()> { + self.set_phase("launchingAgent").await?; + let prompt = self.prompt()?; + let profile_id = self + .operation + .profile_id + .clone() + .ok_or_else(|| HostError::state("The operation has no agent profile."))?; + let payload = json!({ + "workspaceId": workspace_id, + "profileId": profile_id, + "prompt": prompt, + "clientMutationId": self.operation.client_mutation_id, + }); + let launched = match self + .call( + "agentProfile.launchIdempotent", + payload.clone(), + CREATE_DEADLINE, + ) + .await + { + Err(Stop::Failed(error)) + if error.to_string().contains("Unknown terminal host request") => + { + self.call("agentProfile.launch", payload, CREATE_DEADLINE) + .await? + } + other => other?, + }; + self.operation.agent = Some(json!({ + "tabId": launched["tab"]["id"], + "agentType": launched["agentType"], + "profileId": launched["profileId"], + })); + self.save().await; + Ok(()) + } + + pub(super) fn auto_section(&self) -> bool { + self.operation.request.section == super::prompt_workspace_operation::SectionPolicy::Auto + } +} + +pub(super) fn state(error: impl std::fmt::Display) -> HostError { + HostError::state(error.to_string()) +} diff --git a/rust/alera-cli/src/terminal_host/server/prompt_workspace_requests.rs b/rust/alera-cli/src/terminal_host/server/prompt_workspace_requests.rs new file mode 100644 index 000000000..598ddd048 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/prompt_workspace_requests.rs @@ -0,0 +1,323 @@ +//! `workspace.promptStart.*`: New Workspace from Prompt as a runtime operation. +//! +//! `start` records the operation and returns at once; the run continues in the +//! background and every client follows it with `get`, `list`, or the +//! `promptWorkspaceOperationsChanged` event. A start with a `requestId` already +//! used returns that operation, so a retried call never creates a second +//! workspace. `retryLaunch` relaunches the agent of an operation whose +//! workspace exists, with the same idempotency key as the first launch. + +use serde_json::{json, Value}; +use tokio::sync::oneshot; +use uuid::Uuid; + +use super::prompt_workspace_operation::{ + PromptWorkspaceOperation, PromptWorkspaceRequest, SectionPolicy, FAILED, MAX_PROMPT_CHARS, + RUNNING, +}; +use super::prompt_workspace_pipeline::PromptWorkspaceRun; +use super::ServerActor; +use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::protocol::event; + +const EVENT: &str = "promptWorkspaceOperationsChanged"; +const DEFAULT_LIST_LIMIT: i64 = 20; +const MAX_REQUEST_ID_CHARS: usize = 128; + +impl ServerActor { + pub(super) async fn prompt_workspace_start_request( + &mut self, + client_id: u64, + payload: &Value, + ) -> HostResult { + let prompt = payload + .get("prompt") + .and_then(Value::as_str) + .map(str::trim) + .filter(|prompt| !prompt.is_empty()) + .ok_or_else(|| HostError::format("prompt is required"))? + .to_owned(); + if prompt.chars().count() > MAX_PROMPT_CHARS { + return Err(HostError::format("prompt is too long")); + } + let mut request: PromptWorkspaceRequest = serde_json::from_value(json!({ + "projectId": text(payload, "projectId"), + "profile": text(payload, "profile"), + "mode": payload.get("mode").cloned().unwrap_or(json!("auto")), + "sourceBranch": text(payload, "sourceBranch"), + "hostId": text(payload, "hostId"), + "parentWorkspaceId": text(payload, "parentWorkspaceId"), + "issueUrl": text(payload, "issueUrl"), + })) + .map_err(|error| HostError::format(format!("invalid request: {error}")))?; + request.section = SectionPolicy::parse(payload.get("section"))?; + let request_id = text(payload, "requestId"); + if request_id + .as_ref() + .is_some_and(|key| key.chars().count() > MAX_REQUEST_ID_CHARS) + { + return Err(HostError::format("requestId is too long")); + } + let origin = self.prompt_workspace_origin(client_id, payload)?; + let request_key = scoped_request_key(request_id.as_deref(), origin.as_ref()); + let operation = PromptWorkspaceOperation::new( + Uuid::new_v4().to_string(), + request_id.clone(), + prompt, + request, + origin, + ); + let record = self + .runtime_store + .insert_prompt_workspace_operation( + &operation.id, + request_key.as_deref(), + RUNNING, + &operation.to_value(), + ) + .await + .map_err(store_error)?; + if record.id != operation.id { + return public(&record.data); + } + self.spawn_prompt_workspace_run(operation.clone(), false); + Ok(operation.public_value()) + } + + pub(super) async fn prompt_workspace_get_request(&self, payload: &Value) -> HostResult { + let id = required(payload, "id")?; + let record = self + .runtime_store + .find_prompt_workspace_operation(&id) + .await + .map_err(store_error)? + .ok_or_else(|| { + HostError::state(format!("Prompt workspace operation not found: {id}")) + })?; + public(&record.data) + } + + pub(super) async fn prompt_workspace_list_request(&self, payload: &Value) -> HostResult { + let limit = payload + .get("limit") + .and_then(Value::as_i64) + .unwrap_or(DEFAULT_LIST_LIMIT); + let records = self + .runtime_store + .list_prompt_workspace_operations(limit) + .await + .map_err(store_error)?; + let items = records + .iter() + .map(|record| public(&record.data)) + .collect::>>()?; + Ok(json!({ "kind": "promptWorkspaceOperations", "items": items })) + } + + pub(super) fn prompt_workspace_cancel_request(&mut self, payload: &Value) -> HostResult { + let id = required(payload, "id")?; + let cancelling = match self.prompt_workspace_operations.remove(&id) { + Some(cancel) => cancel.send(()).is_ok(), + None => false, + }; + Ok(json!({ "id": id, "cancelling": cancelling })) + } + + pub(super) async fn prompt_workspace_retry_launch_request( + &mut self, + payload: &Value, + ) -> HostResult { + let id = required(payload, "id")?; + if self.prompt_workspace_operations.contains_key(&id) { + return Err(HostError::state("This operation is still running.")); + } + let record = self + .runtime_store + .find_prompt_workspace_operation(&id) + .await + .map_err(store_error)? + .ok_or_else(|| { + HostError::state(format!("Prompt workspace operation not found: {id}")) + })?; + let mut operation: PromptWorkspaceOperation = + serde_json::from_value(record.data).map_err(store_error)?; + if !operation.can_retry_launch() || operation.prompt.is_none() { + return Err(HostError::state( + "Only an operation whose workspace exists and whose agent did not launch can retry its launch.", + )); + } + operation.status = RUNNING.to_owned(); + operation.error = None; + self.runtime_store + .update_prompt_workspace_operation(&operation.id, RUNNING, &operation.to_value()) + .await + .map_err(store_error)?; + self.spawn_prompt_workspace_run(operation.clone(), true); + Ok(operation.public_value()) + } + + fn spawn_prompt_workspace_run( + &mut self, + operation: PromptWorkspaceOperation, + launch_only: bool, + ) { + let (cancel_tx, cancel_rx) = oneshot::channel(); + self.prompt_workspace_operations + .insert(operation.id.clone(), cancel_tx); + self.cancel_shutdown_timer(); + let id = operation.id.clone(); + let run = PromptWorkspaceRun::new( + self.runtime_store.clone(), + self.inbox.clone(), + self.runtime_dir.clone(), + operation, + cancel_rx, + ); + tokio::spawn(run.run(launch_only)); + self.broadcast_authenticated(event(EVENT, json!({ "id": id }))); + } + + /// A runtime restart stops every run; their records say so, and those + /// whose workspace exists can still retry the launch. + pub(super) async fn reconcile_interrupted_prompt_workspaces(&mut self) { + let records = match self + .runtime_store + .list_running_prompt_workspace_operations() + .await + { + Ok(records) => records, + Err(error) => { + tracing::warn!("prompt workspace recovery unavailable: {error}"); + return; + } + }; + for record in records { + let Ok(mut operation) = serde_json::from_value::(record.data) + else { + continue; + }; + let retryable = operation.workspace_id().is_some(); + operation.fail( + "interrupted", + "The runtime restarted during this operation.", + retryable, + ); + let _ = self + .runtime_store + .update_prompt_workspace_operation(&operation.id, FAILED, &operation.to_value()) + .await; + } + let _ = self.runtime_store.prune_prompt_workspace_operations().await; + } + + pub(super) fn handle_prompt_workspace_operation_changed(&self, operation_id: String) { + self.broadcast_authenticated(event(EVENT, json!({ "id": operation_id }))); + } + + pub(super) fn handle_prompt_workspace_operation_finished(&mut self, operation_id: String) { + self.prompt_workspace_operations.remove(&operation_id); + self.broadcast_authenticated(event(EVENT, json!({ "id": operation_id }))); + self.schedule_shutdown_if_idle(); + } +} + +impl ServerActor { + /// Who started an operation. The connection decides the surface; only a + /// local connection, the CLI process an MCP tool runs, may name the MCP + /// client it acts for, so a phone or the hub cannot claim to be one. + fn prompt_workspace_origin( + &self, + client_id: u64, + payload: &Value, + ) -> HostResult> { + let local = self + .clients + .get(&client_id) + .is_some_and(|client| client.kind == super::ClientKind::Local); + match mcp_origin(payload) { + Some(origin) if local => super::inbox_requests::external_origin(origin).map(Some), + _ => Ok(Some(self.inbox_origin(client_id))), + } + } +} + +/// The `origin` that names an MCP client. An app may still describe its own +/// surface there; only an origin with an MCP transport names a client, and +/// the connection decides the rest. +fn mcp_origin(payload: &Value) -> Option<&Value> { + payload + .get("origin") + .filter(|origin| origin.get("transport").is_some()) +} + +/// The stored retry key. Keys an MCP client chose are kept apart per client, +/// so two clients that happen to pick the same key never share an operation. +fn scoped_request_key(request_id: Option<&str>, origin: Option<&Value>) -> Option { + let request_id = request_id?; + let client = origin + .and_then(|origin| origin.get("clientId")) + .and_then(Value::as_str); + Some(match client { + Some(client) => format!("mcp:{client}:{request_id}"), + None => request_id.to_owned(), + }) +} + +fn text(payload: &Value, key: &str) -> Option { + payload + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +fn required(payload: &Value, key: &str) -> HostResult { + text(payload, key).ok_or_else(|| HostError::format(format!("{key} is required"))) +} + +fn public(data: &Value) -> HostResult { + let operation: PromptWorkspaceOperation = + serde_json::from_value(data.clone()).map_err(store_error)?; + Ok(operation.public_value()) +} + +fn store_error(error: impl std::fmt::Display) -> HostError { + HostError::state(error.to_string()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{mcp_origin, scoped_request_key}; + + #[test] + fn only_an_origin_with_an_mcp_transport_names_a_client() { + assert!(mcp_origin(&json!({"origin": {"surface": "desktop"}})).is_none()); + assert!(mcp_origin(&json!({"origin": null})).is_none()); + assert!(mcp_origin(&json!({})).is_none()); + let named = json!({"origin": {"transport": "remote", "clientId": "chatgpt"}}); + assert_eq!(mcp_origin(&named).unwrap()["clientId"], "chatgpt"); + } + + #[test] + fn retry_keys_are_kept_apart_per_mcp_client() { + let codex = json!({"surface": "mcp", "transport": "local", "clientId": "codex"}); + let chatgpt = json!({"surface": "mcp", "transport": "remote", "clientId": "chatgpt"}); + let phone = json!({"surface": "mobile", "deviceId": "d-1"}); + assert_eq!( + scoped_request_key(Some("key-0001"), Some(&codex)).as_deref(), + Some("mcp:codex:key-0001") + ); + assert_ne!( + scoped_request_key(Some("key-0001"), Some(&codex)), + scoped_request_key(Some("key-0001"), Some(&chatgpt)) + ); + assert_eq!( + scoped_request_key(Some("key-0001"), Some(&phone)).as_deref(), + Some("key-0001") + ); + assert_eq!(scoped_request_key(None, Some(&codex)), None); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/prompt_workspace_setup.rs b/rust/alera-cli/src/terminal_host/server/prompt_workspace_setup.rs new file mode 100644 index 000000000..ba0f31059 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/prompt_workspace_setup.rs @@ -0,0 +1,151 @@ +//! The deferred worktree setup of a From Prompt workspace, started by the +//! host in a tab named "Setup" as the app does after a launch. +//! +//! The setup runs at most once. Its tab id is saved before the tab is +//! created, so a launch retry after a lost answer or a restart finds the tab +//! it may already have started; when that tab is gone (a successful setup +//! closes it) the host cannot tell, so it leaves the command to the user +//! instead of running the project's setup a second time. + +use serde_json::{json, Value}; +use uuid::Uuid; + +use super::prompt_workspace_pipeline::{PromptWorkspaceRun, Stop, SHORT_DEADLINE}; + +const UNCONFIRMED_WARNING: &str = + "The worktree setup may not have run. Run it from the workspace's Setup action if it is missing."; + +/// What the Setup step does with the record it finds. +#[derive(Debug, PartialEq, Eq)] +enum SetupStep { + /// Nothing to start, already started, or left to the user. + Skip, + /// A first attempt. + Start(String), + /// An earlier attempt claimed this tab id; it may have started. + Reconcile { command: String, tab_id: String }, +} + +fn setup_step(setup: Option<&Value>) -> SetupStep { + let Some(setup) = setup else { + return SetupStep::Skip; + }; + if setup.get("tabId").is_some() || setup.get("unconfirmed").is_some() { + return SetupStep::Skip; + } + let Some(command) = setup["command"].as_str().map(str::to_owned) else { + return SetupStep::Skip; + }; + match setup["pendingTabId"].as_str() { + Some(tab_id) => SetupStep::Reconcile { + command, + tab_id: tab_id.to_owned(), + }, + None => SetupStep::Start(command), + } +} + +fn unconfirmed(command: &str) -> Value { + json!({ "command": command, "unconfirmed": true }) +} + +impl PromptWorkspaceRun { + pub(super) async fn start_setup(&mut self, workspace_id: &str) { + let command = match setup_step(self.operation.setup.as_ref()) { + SetupStep::Skip => return, + SetupStep::Reconcile { command, tab_id } => { + let started = self + .store + .list_workspace_tabs(workspace_id) + .await + .is_ok_and(|tabs| tabs.iter().any(|tab| tab.id == tab_id)); + if started { + self.operation.setup = Some(json!({ "tabId": tab_id })); + } else { + self.operation.setup = Some(unconfirmed(&command)); + self.operation.warnings.push(UNCONFIRMED_WARNING.to_owned()); + } + self.save().await; + return; + } + SetupStep::Start(command) => command, + }; + if self.set_phase("startingSetup").await.is_err() { + return; + } + let tab_id = Uuid::new_v4().to_string(); + self.operation.setup = Some(json!({ "command": command, "pendingTabId": tab_id })); + self.save().await; + let now = chrono::Utc::now().to_rfc3339(); + let tab = json!({ + "id": tab_id, + "workspaceId": workspace_id, + "kind": "terminal", + "title": "Setup", + "createdAt": now, + "updatedAt": now, + "payload": { + "terminalSessionId": tab_id, + "manualTitle": true, + "initialCommand": command, + "initialCommandOnce": true, + "spawnOnCreate": true, + "autoCloseOnSuccess": true, + }, + }); + match self + .call_to_completion("tab.upsert", tab, SHORT_DEADLINE) + .await + { + Ok(_) => self.operation.setup = Some(json!({ "tabId": tab_id })), + Err(Stop::Failed(error)) => { + // A timeout may still have started it, so it is not retried. + self.operation.setup = Some(unconfirmed(&command)); + self.operation + .warnings + .push(format!("The worktree setup did not start: {error}")); + } + Err(Stop::Cancelled) => {} + } + self.save().await; + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{setup_step, SetupStep}; + + #[test] + fn a_first_attempt_starts_and_a_claimed_tab_is_reconciled() { + assert_eq!(setup_step(None), SetupStep::Skip); + assert_eq!( + setup_step(Some(&json!({ "command": "make setup" }))), + SetupStep::Start("make setup".into()) + ); + assert_eq!( + setup_step(Some( + &json!({ "command": "make setup", "pendingTabId": "t-1" }) + )), + SetupStep::Reconcile { + command: "make setup".into(), + tab_id: "t-1".into() + } + ); + } + + #[test] + fn a_started_or_unconfirmed_setup_is_never_run_again() { + assert_eq!( + setup_step(Some(&json!({ "tabId": "t-1" }))), + SetupStep::Skip + ); + assert_eq!( + setup_step(Some( + &json!({ "command": "make setup", "unconfirmed": true }) + )), + SetupStep::Skip + ); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/actions.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/actions.rs new file mode 100644 index 000000000..637218a37 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/actions.rs @@ -0,0 +1,227 @@ +//! The `mobile.pullRequest.*` write verbs for every forge: comment, reply, +//! edit, merge, draft status, close, link, unlink, create, and Ship. Parsing +//! is forge-neutral; each forge decides what it supports (GitHub has no +//! provider-default merge, GitLab no review summaries). + +use alera_core::runtime::{RuntimeStore, Workspace}; +use serde_json::Value; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::mobile_pull_request_busy::BusyGuard; +use super::super::mobile_pull_request_ship::{ship_pull_request, ShipRequest, ShipScope}; +use super::super::requests::{optional_string_key, require_string_key}; +use super::links::{create_and_link, save_link}; +use super::model::MergeMethod; +use super::provider::{CommentLocator, CommentSource, CreateInput}; +use super::{require_local_workspace, workspace_forge}; + +#[derive(Debug, PartialEq)] +pub(crate) enum ForgeAction { + Comment { + number: i64, + body: String, + reply_to: Option, + }, + CommentUpdate { + number: i64, + locator: CommentLocator, + body: String, + }, + Merge { + number: i64, + method: MergeMethod, + expected_head: Option, + }, + DraftStatus { + number: i64, + draft: bool, + }, + Close { + number: i64, + }, + Link { + reference: String, + }, + Unlink { + number: i64, + url: Option, + }, + Create(CreateInput), + Ship(ShipRequest), +} + +pub(crate) fn parse_forge_action(request_type: &str, payload: &Value) -> HostResult { + let number = || positive_i64(payload, "number"); + let body = || { + let body = require_string_key(payload, "body")?; + if body.trim().is_empty() { + return Err(HostError::state("Enter a comment before posting.")); + } + Ok(body) + }; + let thread_id = |key: &str| optional_string_key(payload, key).filter(|id| !id.is_empty()); + Ok(match request_type { + "mobile.pullRequest.comment" => ForgeAction::Comment { + number: number()?, + body: body()?, + reply_to: payload + .get("replyToCommentId") + .and_then(Value::as_i64) + .map(|comment_id| CommentLocator { + source: CommentSource::ReviewThread, + comment_id, + thread_id: thread_id("replyToThreadId"), + }), + }, + "mobile.pullRequest.commentUpdate" => ForgeAction::CommentUpdate { + number: number()?, + locator: CommentLocator { + source: CommentSource::parse(&require_string_key(payload, "source")?)?, + comment_id: positive_i64(payload, "commentId")?, + thread_id: thread_id("threadId"), + }, + body: body()?, + }, + "mobile.pullRequest.merge" => { + let method = require_string_key(payload, "method")?; + ForgeAction::Merge { + number: number()?, + method: MergeMethod::parse(&method) + .ok_or_else(|| HostError::state(format!("Unknown merge method: {method}")))?, + expected_head: optional_string_key(payload, "expectedHeadSha") + .filter(|sha| !sha.trim().is_empty()), + } + } + "mobile.pullRequest.draftStatus" => ForgeAction::DraftStatus { + number: number()?, + draft: payload + .get("draft") + .and_then(Value::as_bool) + .ok_or_else(|| HostError::state("draft must be a boolean."))?, + }, + "mobile.pullRequest.close" => ForgeAction::Close { number: number()? }, + "mobile.pullRequest.link" => ForgeAction::Link { + reference: require_string_key(payload, "reference")?, + }, + "mobile.pullRequest.unlink" => ForgeAction::Unlink { + number: number()?, + url: optional_string_key(payload, "url"), + }, + "mobile.pullRequest.create" => { + let title = require_string_key(payload, "title")?; + if title.trim().is_empty() { + return Err(HostError::state( + "Enter a title before creating the pull request.", + )); + } + ForgeAction::Create(CreateInput { + base: required_base(payload, "Select a base branch.")?, + head: String::new(), + title: title.trim().to_string(), + body: optional_string_key(payload, "body").unwrap_or_default(), + draft: payload + .get("draft") + .and_then(Value::as_bool) + .unwrap_or(false), + }) + } + "mobile.pullRequest.ship" => ForgeAction::Ship(ShipRequest { + base: required_base(payload, "Select a base branch before shipping.")?, + draft: payload + .get("draft") + .and_then(Value::as_bool) + .unwrap_or(false), + scope: match optional_string_key(payload, "scope").as_deref() { + None | Some("all") => ShipScope::All, + Some("staged") => ShipScope::Staged, + Some(other) => { + return Err(HostError::state(format!("Unknown ship scope: {other}"))); + } + }, + }), + other => { + return Err(HostError::state(format!( + "Unsupported pull request action: {other}" + ))); + } + }) +} + +fn required_base(payload: &Value, message: &str) -> HostResult { + let base = require_string_key(payload, "baseBranch")? + .trim() + .to_string(); + if base.is_empty() { + return Err(HostError::state(message)); + } + Ok(base) +} + +pub(crate) fn positive_i64(payload: &Value, key: &str) -> HostResult { + payload + .get(key) + .and_then(Value::as_i64) + .filter(|value| *value > 0) + .ok_or_else(|| HostError::state(format!("{key} must be a positive integer."))) +} + +/// Runs one write verb on a checkout of this runtime, one write per +/// workspace at a time. +pub(crate) async fn run_forge_action( + store: &RuntimeStore, + workspace: &Workspace, + request_type: &str, + payload: &Value, +) -> HostResult<()> { + let action = parse_forge_action(request_type, payload)?; + require_local_workspace(workspace)?; + let (forge, remote) = workspace_forge(store, workspace).await?; + let _busy = BusyGuard::acquire(&workspace.id)?; + match action { + ForgeAction::Link { reference } => { + let number = forge.review_reference(&reference)?; + let review = forge.review_by_number(number).await?.ok_or_else(|| { + HostError::state(format!("Pull request #{number} was not found.")) + })?; + let url = Some(review.url).filter(|url| !url.is_empty()); + save_link(store, &workspace.id, forge.kind(), number, url, false).await + } + ForgeAction::Unlink { number, url } => { + save_link(store, &workspace.id, forge.kind(), number, url, true).await + } + ForgeAction::Create(mut input) => { + input.head = remote + .branch + .filter(|branch| !branch.is_empty() && branch != "HEAD") + .ok_or_else(|| { + HostError::state("Check out a branch before creating a pull request.") + })?; + create_and_link(store, workspace, forge.as_ref(), &input) + .await + .map(|_| ()) + } + ForgeAction::Ship(request) => { + let hub_settings = + super::super::remote_ai_assist_requests::hub_ai_assist_settings(payload)?; + ship_pull_request(store, workspace, forge.as_ref(), request, hub_settings).await + } + ForgeAction::Comment { + number, + body, + reply_to, + } => forge.comment(number, &body, reply_to.as_ref()).await, + ForgeAction::CommentUpdate { + number, + locator, + body, + } => forge.update_comment(number, &locator, &body).await, + ForgeAction::Merge { + number, + method, + expected_head, + } => forge.merge(number, method, expected_head.as_deref()).await, + ForgeAction::DraftStatus { number, draft } => forge.set_draft(number, draft).await, + ForgeAction::Close { number } => forge.close(number).await, + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/agent_dispatch.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/agent_dispatch.rs new file mode 100644 index 000000000..07dcd27ae --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/agent_dispatch.rs @@ -0,0 +1,206 @@ +//! `pullRequest.agentDispatch`: Restack and Fix Failed Checks, with their +//! prompts kept here as the single source the desktop, mobile, the CLI, and +//! MCP share (`pullRequestAgentDispatchV1`). Without a target the request only +//! answers the prompt, so a client can keep its own agent picker; with a +//! `tabId`, `handle`, or `profileId` the runtime delivers it. + +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::requests::{ + optional_string_key, require_string_key, terminal_session_id_from_tab, +}; +use super::super::ServerActor; + +/// Rewrites local history since the merge base without pushing. The prompt +/// may reach a shell as a launch argument, so it avoids backticks and other +/// characters a shell would expand, and names no files, SHAs, or messages. +const RESTACK_PROMPT: &str = "Refactor all committed and uncommitted changes since the merge base into logical, easy-to-review commits. Inspect the complete diff first, then reorder, split, squash, and edit commits as needed. Preserve the final tree and behavior. Do not push."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DispatchKind { + Restack, + FixFailedChecks, +} + +impl DispatchKind { + pub(crate) fn parse(value: &str) -> HostResult { + match value { + "restack" => Ok(Self::Restack), + "fixFailedChecks" => Ok(Self::FixFailedChecks), + other => Err(HostError::format(format!( + "kind must be restack or fixFailedChecks, not {other}." + ))), + } + } + + fn wire(self) -> &'static str { + match self { + Self::Restack => "restack", + Self::FixFailedChecks => "fixFailedChecks", + } + } + + fn title(self) -> &'static str { + match self { + Self::Restack => "Restack Changes", + Self::FixFailedChecks => "Fix Failed Checks", + } + } +} + +/// The prompt for [kind]. Check names and logs stay out of the failed-checks +/// prompt so the agent reads the current CI state itself. +pub(crate) fn agent_dispatch_prompt(kind: DispatchKind, review_number: Option) -> String { + match (kind, review_number) { + (DispatchKind::Restack, _) => RESTACK_PROMPT.to_string(), + (DispatchKind::FixFailedChecks, Some(number)) => { + format!("Pull request #{number} checks failed. Please fix them.") + } + (DispatchKind::FixFailedChecks, None) => { + "The pull request checks failed. Please fix them.".to_string() + } + } +} + +impl ServerActor { + pub(in crate::terminal_host::server) async fn pull_request_agent_dispatch( + &mut self, + client_id: u64, + payload: &Value, + ) -> HostResult { + self.require_auth(client_id)?; + let workspace_id = require_string_key(payload, "workspaceId")?; + let kind = DispatchKind::parse(&require_string_key(payload, "kind")?)?; + let workspace = self + .runtime_store + .find_workspace(&workspace_id) + .await + .map_err(|error| HostError::state(error.to_string()))? + .ok_or_else(|| HostError::format("Workspace not found."))?; + let number = match payload.get("number").and_then(Value::as_i64) { + Some(number) if number > 0 => Some(number), + Some(_) => return Err(HostError::format("number must be a positive integer.")), + None => self + .runtime_store + .find_linked_review(&workspace.id) + .await + .map_err(|error| HostError::state(error.to_string()))? + .filter(|review| !review.dismissed) + .and_then(|review| review.number), + }; + if kind == DispatchKind::FixFailedChecks && number.is_none() { + return Err(HostError::format( + "Link or open a pull request for this workspace first, or pass its number.", + )); + } + let prompt = agent_dispatch_prompt(kind, number); + let mut result = json!({ + "workspaceId": workspace.id, + "kind": kind.wire(), + "number": number, + "title": kind.title(), + "prompt": prompt, + "dispatched": false, + }); + let tab_id = optional_string_key(payload, "tabId"); + let handle = optional_string_key(payload, "handle"); + let profile_id = optional_string_key(payload, "profileId"); + if tab_id.is_none() && handle.is_none() && profile_id.is_none() { + return Ok(result); + } + if let Some(session_id) = self + .running_dispatch_session(&workspace.id, tab_id.as_deref(), handle.as_deref()) + .await? + { + if !self.agent_presence.is_injection_ready(&session_id) { + return Err(HostError::state( + "The agent in that terminal is busy. Try again when it is idle, or choose a profile.", + )); + } + self.queue_orchestration_paste(&session_id, &prompt, Vec::new(), true)?; + result["dispatched"] = json!(true); + result["target"] = json!({ "terminalHandle": session_id, "openedNewTab": false }); + return Ok(result); + } + let Some(profile_id) = profile_id else { + return Err(HostError::state( + "The chosen agent terminal is not running. Choose a running agent or a profile.", + )); + }; + let launched = self + .launch_agent_profile( + None, + &json!({"workspaceId": workspace.id, "profileId": profile_id, "prompt": prompt}), + ) + .await?; + result["dispatched"] = json!(true); + result["target"] = json!({ + "profileId": profile_id, + "tabId": launched["tab"]["id"].as_str().or(launched["tabId"].as_str()), + "openedNewTab": true, + }); + Ok(result) + } + + /// The running terminal a tab or handle names in [workspace_id]. + async fn running_dispatch_session( + &self, + workspace_id: &str, + tab_id: Option<&str>, + handle: Option<&str>, + ) -> HostResult> { + let session_id = match (handle, tab_id) { + (Some(handle), _) => Some(handle.to_string()), + (None, Some(tab_id)) => { + let tab = self + .runtime_store + .find_workspace_tab(tab_id) + .await + .map_err(|error| HostError::state(error.to_string()))? + .ok_or_else(|| HostError::format("Terminal tab not found."))?; + if tab.workspace_id != workspace_id { + return Err(HostError::format( + "Terminal tab does not belong to this workspace.", + )); + } + terminal_session_id_from_tab(&tab) + } + (None, None) => None, + }; + let Some(session_id) = session_id else { + return Ok(None); + }; + match self.sessions.get(&session_id) { + Some(session) if session.workspace_id != workspace_id => Err(HostError::format( + "Terminal handle does not belong to this workspace.", + )), + Some(session) if session.running() => Ok(Some(session_id)), + _ => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prompts_match_the_desktop_and_avoid_shell_expansion() { + let restack = agent_dispatch_prompt(DispatchKind::Restack, Some(4)); + assert!(restack.starts_with("Refactor all committed and uncommitted changes")); + assert!(restack.ends_with("Do not push.")); + assert_eq!( + agent_dispatch_prompt(DispatchKind::FixFailedChecks, Some(42)), + "Pull request #42 checks failed. Please fix them." + ); + for prompt in [ + restack, + agent_dispatch_prompt(DispatchKind::FixFailedChecks, None), + ] { + assert!(!prompt.contains('`') && !prompt.contains('$')); + } + assert!(DispatchKind::parse("rebase").is_err()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/azure.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/azure.rs new file mode 100644 index 000000000..374bc8cbe --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/azure.rs @@ -0,0 +1,462 @@ +//! Azure DevOps pull requests through `az` and its azure-devops extension, +//! ported from the desktop's `azure_devops_forge_provider.dart`, +//! `azure_devops_review_actions.dart`, `azure_devops_review_comments.dart`, and +//! `azure_devops_cli_failures.dart`. Checks are the pull request's policy +//! evaluations. Titles, descriptions and comment bodies travel as REST bodies +//! in a private `--in-file`, never on the command line. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::azure_requests::{branch_argument, completion_body, create_body, reject_file_expansion}; +use super::identity::{ForgeIdentity, ForgeKind}; +use super::mappers::{ + azure_check, azure_comments, azure_reply_body, azure_review, azure_thread_body, +}; +use super::model::{ + fixed_merge_methods, forge_failure, newest, provider_unavailable, MergeMethod, Review, +}; +use super::provider::{ + foreign_review_url, plain_review_number, AuthStatus, CommentLocator, CreateInput, Created, + ForgeProvider, +}; +use super::runner::{ForgeOutput, ForgeRunner}; + +pub(crate) struct AzureDevOpsForge { + pub(crate) identity: ForgeIdentity, + pub(crate) runner: Arc, +} + +pub(crate) fn looks_missing(output: &ForgeOutput) -> bool { + let combined = format!("{} {}", output.stdout, output.stderr).to_ascii_lowercase(); + output.code == 127 + || combined.contains("command not found") + || combined.contains("is not recognized") + || combined.contains("no such file") + || combined.contains("'repos' is misspelled") + || combined.contains("az extension add") +} + +fn mentions_not_found(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + lower.contains("does not exist") || lower.contains("not found") || lower.contains("tf401180") +} + +impl AzureDevOpsForge { + pub(super) fn org(&self) -> String { + self.identity.azure_org_url() + } + + fn project(&self) -> HostResult { + self.identity + .project + .clone() + .filter(|project| !project.is_empty()) + .ok_or_else(|| { + HostError::state( + "The Azure DevOps project could not be determined from the remote.", + ) + }) + } + + async fn run(&self, args: Vec, allow_not_found: bool) -> HostResult> { + self.run_with_stdin(args, allow_not_found, None).await + } + + async fn run_with_stdin( + &self, + args: Vec, + allow_not_found: bool, + stdin: Option<&str>, + ) -> HostResult> { + reject_file_expansion(&args)?; + let output = self.runner.run_with_stdin("az", &args, &[], stdin).await?; + if output.code == 0 { + return Ok(Some(output.stdout)); + } + if looks_missing(&output) { + return Err(provider_unavailable(ForgeKind::AzureDevOps, false)); + } + if allow_not_found && mentions_not_found(&output.stderr) { + return Ok(None); + } + let lower = output.stderr.to_ascii_lowercase(); + if lower.contains("az login") || lower.contains("not logged in") { + return Err(provider_unavailable(ForgeKind::AzureDevOps, true)); + } + Err(forge_failure( + ForgeKind::AzureDevOps, + &output.stderr, + &output.stdout, + )) + } + + async fn run_json(&self, args: Vec, allow_not_found: bool) -> HostResult { + self.run_json_with_stdin(args, allow_not_found, None).await + } + + pub(super) async fn run_json_with_stdin( + &self, + args: Vec, + allow_not_found: bool, + stdin: Option<&str>, + ) -> HostResult { + let Some(output) = self.run_with_stdin(args, allow_not_found, stdin).await? else { + return Ok(Value::Null); + }; + let trimmed = output.trim(); + if trimmed.is_empty() { + return Ok(Value::Null); + } + serde_json::from_str(trimmed) + .map_err(|_| HostError::state(format!("Unexpected az output: {trimmed}"))) + } + + fn pr_args(&self, verb: &str, extra: &[&str]) -> Vec { + let mut args = vec!["repos", "pr", verb] + .into_iter() + .map(String::from) + .collect::>(); + args.extend(extra.iter().map(|value| value.to_string())); + args + } + + fn update_args(&self, number: i64, extra: &[&str]) -> Vec { + let number = number.to_string(); + let org = self.org(); + let mut args = vec!["--id", &number, "--organization", &org]; + args.extend_from_slice(extra); + self.pr_args("update", &args) + } + + /// `az devops invoke` against a pull request resource. The body travels + /// in a private temporary file, as the desktop does, removed when the + /// call ends however it ends. + fn repository_route(&self) -> HostResult> { + Ok(vec![ + format!("project={}", self.project()?), + format!("repositoryId={}", self.identity.repo), + ]) + } + + fn thread_route(&self, number: i64) -> HostResult> { + let mut route = self.repository_route()?; + route.push(format!("pullRequestId={number}")); + Ok(route) + } + + /// The pull request as `az repos pr show` returns it; null when absent. + async fn pull_request(&self, number: i64) -> HostResult { + let number = number.to_string(); + let org = self.org(); + let args = self.pr_args( + "show", + &["--id", &number, "--organization", &org, "--output", "json"], + ); + self.run_json(args, true).await + } + + /// The thread that owns [locator]. Comment ids repeat across threads, so + /// the lookup only succeeds when exactly one thread has that id. + async fn thread_id(&self, number: i64, locator: &CommentLocator) -> HostResult { + if let Some(id) = locator.thread_id.as_deref().filter(|id| !id.is_empty()) { + return Ok(id.to_string()); + } + let (comments, _) = self.comments(number).await; + let mut owners = comments + .iter() + .filter(|comment| comment["id"].as_i64() == Some(locator.comment_id)) + .filter_map(|comment| comment["threadId"].as_str()); + match (owners.next(), owners.next()) { + (Some(thread), None) => Ok(thread.to_string()), + _ => Err(HostError::state( + "The Azure DevOps comment thread could not be determined. Pass its threadId.", + )), + } + } +} + +#[async_trait] +impl ForgeProvider for AzureDevOpsForge { + fn identity(&self) -> &ForgeIdentity { + &self.identity + } + + async fn auth_status(&self) -> HostResult { + let args = ["account", "show", "--output", "json"].map(String::from); + let output = self.runner.run("az", &args, &[]).await?; + Ok(if output.code == 0 { + AuthStatus::Authenticated + } else if looks_missing(&output) { + AuthStatus::CliMissing + } else { + AuthStatus::NotAuthenticated + }) + } + + async fn review_by_number(&self, number: i64) -> HostResult> { + let value = self.pull_request(number).await?; + Ok(value + .is_object() + .then(|| azure_review(&self.identity, &value))) + } + + async fn review_for_branch(&self, branch: &str) -> HostResult> { + let org = self.org(); + let project = self.identity.project.clone().unwrap_or_default(); + let branch = branch_argument(branch); + let args = self.pr_args( + "list", + &[ + "--organization", + &org, + "--project", + &project, + "--repository", + &self.identity.repo, + "--source-branch", + &branch, + "--status", + "active", + "--top", + "100", + "--output", + "json", + ], + ); + let value = self.run_json(args, false).await?; + Ok(newest( + value + .as_array() + .into_iter() + .flatten() + .filter(|item| item.is_object()) + .map(|item| azure_review(&self.identity, item)), + )) + } + + async fn checks(&self, number: i64) -> Vec { + let number = number.to_string(); + let org = self.org(); + let args = self.pr_args( + "policy", + &[ + "list", + "--id", + &number, + "--organization", + &org, + "--output", + "json", + ], + ); + match self.run_json(args, true).await { + Ok(Value::Array(entries)) => entries + .iter() + .filter(|entry| entry.is_object()) + .map(|entry| azure_check(&self.identity, entry)) + .collect(), + _ => Vec::new(), + } + } + + async fn comments(&self, number: i64) -> (Vec, bool) { + let Ok(route) = self.thread_route(number) else { + return (Vec::new(), true); + }; + match self.invoke("pullRequestThreads", &route, "GET", None).await { + Ok(threads) => (azure_comments(&threads), false), + Err(_) => (Vec::new(), true), + } + } + + async fn viewer(&self) -> Option { + None + } + + async fn merge_methods(&self, _base_branch: Option<&str>) -> Result, String> { + Ok(fixed_merge_methods(ForgeKind::AzureDevOps) + .iter() + .map(|method| method.to_string()) + .collect()) + } + + /// `POST pullRequests`, the request `az repos pr create` makes. + async fn create(&self, input: &CreateInput) -> HostResult { + let route = self.repository_route()?; + let body = create_body(input); + let value = self + .invoke("pullRequests", &route, "POST", Some(&body)) + .await?; + if !value.is_object() { + return Err(HostError::state( + "The pull request was created but could not be read back.", + )); + } + let review = azure_review(&self.identity, &value); + Ok(Created { + number: review.number, + url: Some(review.url), + }) + } + + async fn comment( + &self, + number: i64, + body: &str, + reply_to: Option<&CommentLocator>, + ) -> HostResult<()> { + let mut route = self.thread_route(number)?; + match reply_to { + None => { + self.invoke( + "pullRequestThreads", + &route, + "POST", + Some(&azure_thread_body(body)), + ) + .await?; + } + Some(locator) => { + route.push(format!( + "threadId={}", + self.thread_id(number, locator).await? + )); + let payload = azure_reply_body(body, locator.comment_id); + self.invoke("pullRequestThreadComments", &route, "POST", Some(&payload)) + .await?; + } + } + Ok(()) + } + + async fn update_comment( + &self, + number: i64, + locator: &CommentLocator, + body: &str, + ) -> HostResult<()> { + let mut route = self.thread_route(number)?; + route.push(format!( + "threadId={}", + self.thread_id(number, locator).await? + )); + route.push(format!("commentId={}", locator.comment_id)); + let payload = serde_json::json!({ "content": body }); + self.invoke("pullRequestThreadComments", &route, "PATCH", Some(&payload)) + .await + .map(|_| ()) + } + + /// With an expected head, completes through `PATCH pullRequests/{id}` + /// carrying `lastMergeSourceCommit`, so Azure DevOps itself refuses a head + /// that moved after it was read, on this machine or on a checkout's host. + /// Without an expected head it completes through `az repos pr update`. + async fn merge( + &self, + number: i64, + method: MergeMethod, + expected_head: Option<&str>, + ) -> HostResult<()> { + if matches!(method, MergeMethod::Rebase | MergeMethod::ProviderDefault) { + return Err(HostError::state( + "Azure DevOps does not support rebase and merge through its CLI.", + )); + } + if let Some(expected) = expected_head { + let current = self.pull_request(number).await?; + let current_head = current + .is_object() + .then(|| azure_review(&self.identity, ¤t).head_sha) + .flatten(); + if current_head.as_deref() != Some(expected) { + return Err(HostError::state( + "The pull request head changed before it could be merged.", + )); + } + let mut route = self.repository_route()?; + route.push(format!("pullRequestId={number}")); + let body = completion_body(¤t, expected, method == MergeMethod::Squash); + return self + .invoke("pullRequests", &route, "PATCH", Some(&body)) + .await + .map(|_| ()); + } + let squash = if method == MergeMethod::Squash { + "true" + } else { + "false" + }; + let args = self.update_args( + number, + &[ + "--status", + "completed", + "--squash", + squash, + "--output", + "none", + ], + ); + self.run(args, false).await.map(|_| ()) + } + + async fn set_draft(&self, number: i64, draft: bool) -> HostResult<()> { + let draft = draft.to_string(); + let args = self.update_args(number, &["--draft", &draft, "--output", "none"]); + self.run(args, false).await.map(|_| ()) + } + + async fn close(&self, number: i64) -> HostResult<()> { + let args = self.update_args(number, &["--status", "abandoned", "--output", "none"]); + self.run(args, false).await.map(|_| ()) + } + + /// `.../{project}/_git/{repo}/pullrequest/{n}` on the organization's host. + fn review_reference(&self, input: &str) -> HostResult { + if let Some(number) = plain_review_number(input) { + return number; + } + let org_url = url::Url::parse(&self.org()).map_err(|_| foreign_review_url())?; + let segments = super::provider::url_segments(input, org_url.host_str().unwrap_or(""))?; + let modern = !org_url + .host_str() + .unwrap_or("") + .ends_with("visualstudio.com"); + if modern + && !segments + .first() + .is_some_and(|org| org.eq_ignore_ascii_case(&self.identity.owner)) + { + return Err(foreign_review_url()); + } + let git = segments + .iter() + .position(|segment| segment == "_git") + .ok_or_else(foreign_review_url)?; + let repo_matches = segments + .get(git + 1) + .is_some_and(|repo| repo.eq_ignore_ascii_case(&self.identity.repo)); + let project_matches = git >= 1 + && self + .identity + .project + .as_deref() + .is_some_and(|project| segments[git - 1].eq_ignore_ascii_case(project)); + if !repo_matches + || !project_matches + || segments.get(git + 2).map(String::as_str) != Some("pullrequest") + { + return Err(foreign_review_url()); + } + segments + .get(git + 3) + .and_then(|number| number.parse::().ok()) + .filter(|number| *number > 0) + .ok_or_else(|| HostError::state("Enter a valid pull request URL.")) + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/azure_requests.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/azure_requests.rs new file mode 100644 index 000000000..045ada93e --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/azure_requests.rs @@ -0,0 +1,155 @@ +//! The REST bodies the Azure DevOps provider sends through `az devops invoke +//! --in-file`, and the guard that keeps the azure-cli `@file` expansion away +//! from every argument it still passes on the command line. + +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::azure::AzureDevOpsForge; +use super::input_file::json_input_file; +use super::provider::CreateInput; + +impl AzureDevOpsForge { + /// One `az devops invoke` call on the git area, with [body] as its JSON + /// request body. + pub(super) async fn invoke( + &self, + resource: &str, + route: &[String], + method: &str, + body: Option<&Value>, + ) -> HostResult { + // A checkout on another host cannot read a file written here, so its + // body travels on stdin. A unix host reads that as /dev/stdin; a + // Windows host has no such path, so `az` fails and the request is + // refused rather than sent without its body. + let (file, stdin) = match body { + Some(body) if !self.runner.shares_local_files() => ( + None, + Some( + serde_json::to_string(body) + .map_err(|error| HostError::state(error.to_string()))?, + ), + ), + body => (body.map(json_input_file).transpose()?, None), + }; + let mut args = ["devops", "invoke", "--area", "git", "--resource", resource] + .map(String::from) + .to_vec(); + args.push("--route-parameters".into()); + args.extend(route.iter().cloned()); + args.extend(["--http-method", method, "--api-version", "7.1"].map(String::from)); + if let Some(file) = &file { + args.extend([ + "--in-file".to_string(), + file.path().to_string_lossy().into_owned(), + ]); + } else if stdin.is_some() { + args.extend(["--in-file", "/dev/stdin"].map(String::from)); + } + args.extend(["--organization".to_string(), self.org()]); + args.extend(["--output", "json"].map(String::from)); + let result = self + .run_json_with_stdin(args, false, stdin.as_deref()) + .await; + drop(file); + result + } +} + +/// `refs/heads/` unless [branch] is already a full ref, as +/// `az repos pr create` and `az repos pr list` qualify it. +pub(super) fn qualified_ref(branch: &str) -> String { + if branch.starts_with("refs/") { + branch.to_string() + } else { + format!("refs/heads/{branch}") + } +} + +/// The value for `--source-branch`. azure-cli reads an argument that starts +/// with `@` as a file to load, so such a branch is passed as its full ref, +/// which `az repos pr list` treats the same. +pub(super) fn branch_argument(branch: &str) -> String { + if branch.starts_with('@') { + qualified_ref(branch) + } else { + branch.to_string() + } +} + +/// azure-cli replaces an argument of the form `@path` with that file's +/// contents. Nothing the provider passes should, so any such argument is +/// refused before `az` runs rather than letting it read a local file. +pub(super) fn reject_file_expansion(args: &[String]) -> HostResult<()> { + match args.iter().find(|arg| arg.starts_with('@')) { + Some(arg) => Err(HostError::state(format!( + "Refusing to pass {arg:?} to az: azure-cli would read it as a file." + ))), + None => Ok(()), + } +} + +/// The `POST pullRequests` body `az repos pr create` sends for [input]. +pub(super) fn create_body(input: &CreateInput) -> Value { + json!({ + "sourceRefName": qualified_ref(&input.head), + "targetRefName": qualified_ref(&input.base), + "title": input.title, + "description": input.body, + "isDraft": input.draft, + }) +} + +/// The `PATCH pullRequests/{id}` body that completes [existing]. Azure +/// DevOps rejects it when the source branch has moved past [expected_head], +/// so the head check holds on the server instead of between two calls. The +/// pull request's own completion options are kept, as `az repos pr update +/// --status completed` keeps them, with the merge strategy set explicitly. +pub(super) fn completion_body(existing: &Value, expected_head: &str, squash: bool) -> Value { + let mut options = existing["completionOptions"] + .as_object() + .cloned() + .unwrap_or_default(); + options.insert("squashMerge".into(), Value::Bool(squash)); + options.insert( + "mergeStrategy".into(), + Value::String(if squash { "squash" } else { "noFastForward" }.into()), + ); + json!({ + "status": "completed", + "lastMergeSourceCommit": { "commitId": expected_head }, + "completionOptions": options, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn at_arguments_are_refused_or_qualified() { + let args = ["repos", "pr", "list", "--title", "@/etc/passwd"].map(String::from); + assert!(reject_file_expansion(&args).is_err()); + assert!(reject_file_expansion(&["a@b".to_string()]).is_ok()); + assert_eq!(branch_argument("@secrets"), "refs/heads/@secrets"); + assert_eq!(branch_argument("feature"), "feature"); + assert_eq!(qualified_ref("refs/heads/main"), "refs/heads/main"); + } + + #[test] + fn completion_keeps_the_pull_request_options_and_binds_the_head() { + let existing = json!({ + "completionOptions": { "deleteSourceBranch": true, "mergeStrategy": "rebase" } + }); + let body = completion_body(&existing, "abc", false); + assert_eq!(body["status"], "completed"); + assert_eq!(body["lastMergeSourceCommit"]["commitId"], "abc"); + assert_eq!(body["completionOptions"]["deleteSourceBranch"], true); + assert_eq!(body["completionOptions"]["squashMerge"], false); + assert_eq!(body["completionOptions"]["mergeStrategy"], "noFastForward"); + let body = completion_body(&Value::Null, "abc", true); + assert_eq!(body["completionOptions"]["mergeStrategy"], "squash"); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/fixture_tests.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/fixture_tests.rs new file mode 100644 index 000000000..3a166d639 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/fixture_tests.rs @@ -0,0 +1,188 @@ +//! The recorded `glab` and `az` output under `test/fixtures/forges/`, which +//! the desktop's Dart tests read too (`forge_shared_fixtures_test.dart`), run +//! through the Rust providers. A drift between the two ports fails here or +//! there. + +use std::sync::Arc; + +use serde_json::Value; + +use super::azure::AzureDevOpsForge; +use super::gitlab::GitLabForge; +use super::identity::{ForgeIdentity, ForgeKind}; +use super::model::Review; +use super::provider::ForgeProvider; +use super::runner::fake::{ok, FakeRunner}; + +macro_rules! fixture { + ($name:literal) => { + serde_json::from_str::(include_str!(concat!( + "../../../../../../test/fixtures/forges/", + $name + ))) + .unwrap() + }; +} + +fn gitlab(stdout: &str) -> (GitLabForge, Arc) { + let runner = Arc::new(FakeRunner::new([ok(stdout)])); + let forge = GitLabForge { + identity: ForgeIdentity { + kind: ForgeKind::GitLab, + host: "gitlab.acme.test:8443".into(), + owner: "platform/mobile".into(), + repo: "alera".into(), + project: None, + }, + runner: runner.clone(), + }; + (forge, runner) +} + +fn azure(fixture: &Value) -> (AzureDevOpsForge, Arc) { + let identity = &fixture["identity"]; + let text = |key: &str| identity[key].as_str().unwrap().to_string(); + let runner = Arc::new(FakeRunner::new([ok(fixture["stdout"].as_str().unwrap())])); + let forge = AzureDevOpsForge { + identity: ForgeIdentity { + kind: ForgeKind::AzureDevOps, + host: text("host"), + owner: text("owner"), + repo: text("repo"), + project: Some(text("project")), + }, + runner: runner.clone(), + }; + (forge, runner) +} + +/// Compares a review with the neutral expectation the Dart test also reads. +fn assert_review(review: &Review, expected: &Value) { + let state = match (review.state, review.is_draft) { + ("MERGED", _) => "merged", + ("CLOSED", _) => "closed", + (_, true) => "draft", + _ => "open", + }; + assert_eq!(review.number, expected["number"].as_i64().unwrap()); + assert_eq!(review.title, expected["title"].as_str().unwrap()); + assert_eq!(state, expected["state"]); + assert_eq!(review.author.as_deref(), expected["author"].as_str()); + assert_eq!( + review.base_branch.as_deref(), + expected["baseBranch"].as_str() + ); + assert_eq!( + review.head_branch.as_deref(), + expected["headBranch"].as_str() + ); + assert_eq!(review.head_sha.as_deref(), expected["headSha"].as_str()); + assert_eq!(review.mergeable.to_ascii_lowercase(), expected["mergeable"]); + assert_eq!(review.url, expected["url"].as_str().unwrap()); +} + +fn assert_checks(checks: &[Value], expected: &Value) { + let expected = expected.as_array().unwrap(); + assert_eq!(checks.len(), expected.len()); + for (check, expected) in checks.iter().zip(expected) { + assert_eq!(check["name"], expected["name"]); + assert_eq!(check["bucket"], expected["bucket"]); + assert_eq!(check["url"], expected["url"]); + } +} + +fn assert_comments(comments: &[Value], expected: &Value) { + let expected = expected.as_array().unwrap(); + assert_eq!(comments.len(), expected.len()); + for (comment, expected) in comments.iter().zip(expected) { + for key in [ + "id", "author", "body", "kind", "path", "line", "resolved", "threadId", + ] { + assert_eq!(comment[key], expected[key], "{key} of {comment}"); + } + } +} + +#[tokio::test] +async fn gitlab_merge_requests_and_pipelines_match_the_desktop() { + for fixture in [ + fixture!("gitlab_merge_request.json"), + fixture!("gitlab_draft_conflicting_merge_request.json"), + ] { + let stdout = fixture["stdout"].as_str().unwrap(); + let (forge, _) = gitlab(stdout); + let number = fixture["expected"]["review"]["number"].as_i64().unwrap(); + let review = forge.review_by_number(number).await.unwrap().unwrap(); + assert_review(&review, &fixture["expected"]["review"]); + let (forge, runner) = gitlab(stdout); + assert_checks(&forge.checks(number).await, &fixture["expected"]["checks"]); + let call = &runner.calls()[0]; + assert_eq!(call.program, "glab"); + assert!(call.args[1].ends_with(&format!("/merge_requests/{number}"))); + } +} + +#[tokio::test] +async fn gitlab_discussions_match_the_desktop() { + let fixture = fixture!("gitlab_discussions.json"); + let (forge, runner) = gitlab(fixture["stdout"].as_str().unwrap()); + let (comments, truncated) = forge.comments(42).await; + assert!(!truncated); + assert_comments(&comments, &fixture["expected"]["comments"]); + let call = &runner.calls()[0]; + assert_eq!(call.option("output"), Some("ndjson")); + assert!(call.args.contains(&"--paginate".to_string())); + // A host with a port is addressed through GITLAB_REPO, not --hostname. + assert!(!call.args.contains(&"--hostname".to_string())); + assert_eq!( + call.environment, + vec![( + "GITLAB_REPO".to_string(), + "https://gitlab.acme.test:8443/platform/mobile/alera".to_string() + )] + ); +} + +#[tokio::test] +async fn azure_pull_requests_match_the_desktop() { + let fixture = fixture!("azure_pull_requests.json"); + let (forge, runner) = azure(&fixture); + let review = forge.review_for_branch("feature").await.unwrap().unwrap(); + assert_review(&review, &fixture["expected"]["review"]); + let call = &runner.calls()[0]; + assert_eq!(call.program, "az"); + assert_eq!(call.args[..3], ["repos", "pr", "list"]); + assert_eq!( + call.option("organization"), + Some("https://dev.azure.com/myorg") + ); + assert_eq!(call.option("project"), Some("myproject")); + assert_eq!(call.option("source-branch"), Some("feature")); + assert_eq!(call.option("top"), Some("100")); + + let fixture = fixture!("azure_completed_pull_request.json"); + let (forge, _) = azure(&fixture); + let review = forge.review_by_number(9).await.unwrap().unwrap(); + assert_review(&review, &fixture["expected"]["review"]); +} + +#[tokio::test] +async fn azure_policies_and_threads_match_the_desktop() { + let fixture = fixture!("azure_policies.json"); + let (forge, runner) = azure(&fixture); + assert_checks(&forge.checks(42).await, &fixture["expected"]["checks"]); + assert_eq!( + runner.calls()[0].args[..4], + ["repos", "pr", "policy", "list"] + ); + + let fixture = fixture!("azure_threads.json"); + let (forge, runner) = azure(&fixture); + let (comments, truncated) = forge.comments(42).await; + assert!(!truncated); + assert_comments(&comments, &fixture["expected"]["comments"]); + let call = &runner.calls()[0]; + assert_eq!(call.args[..2], ["devops", "invoke"]); + assert_eq!(call.option("resource"), Some("pullRequestThreads")); + assert_eq!(call.option("http-method"), Some("GET")); +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/forge_override_tests.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/forge_override_tests.rs new file mode 100644 index 000000000..a0d5ecb5a --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/forge_override_tests.rs @@ -0,0 +1,54 @@ +use alera_core::runtime::{Project, ProjectConfig, ProjectKind, RuntimeStore}; +use chrono::Utc; + +use super::project_forge_override; +use super::ForgeKind; + +async fn project_with_repo_file(contents: &str) -> (tempfile::TempDir, RuntimeStore) { + let directory = tempfile::tempdir().unwrap(); + let repo = directory.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::write(repo.join("alera.toml"), contents).unwrap(); + let store = RuntimeStore::open(&directory.path().join("runtime")) + .await + .unwrap(); + let now = Utc::now(); + store + .upsert_project(Project { + id: "p".to_string(), + name: "p".to_string(), + repo_path: repo.display().to_string(), + created_at: now, + updated_at: now, + kind: ProjectKind::GitRepository, + }) + .await + .unwrap(); + (directory, store) +} + +#[tokio::test] +async fn a_repository_file_forces_the_forge_without_a_settings_override() { + let (_directory, store) = project_with_repo_file("git_hosting_provider = \"gitlab\"").await; + assert_eq!( + project_forge_override(&store, "p").await, + Some(ForgeKind::GitLab) + ); +} + +#[tokio::test] +async fn a_settings_override_wins_over_the_repository_file() { + let (_directory, store) = project_with_repo_file("git_hosting_provider = \"gitlab\"").await; + let config = ProjectConfig { + git_hosting_provider: Some("azureDevops".to_string()), + ..ProjectConfig::default() + }; + store + .upsert_project_config("p", config, Utc::now()) + .await + .unwrap(); + assert_eq!( + project_forge_override(&store, "p").await, + Some(ForgeKind::AzureDevOps) + ); +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/github.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/github.rs new file mode 100644 index 000000000..a3524fa89 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/github.rs @@ -0,0 +1,280 @@ +//! GitHub behind `ForgeProvider`. The commands, parsing, and failure wording +//! are the runtime's existing `gh` code (`mobile_pull_request_*`), moved behind +//! the trait without changing what runs. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::mobile_pull_request_actions::{ + gh_args, Action, CommentSource as GhCommentSource, MergeMethod as GhMergeMethod, +}; +use super::super::mobile_pull_request_failures::{gh_failure, gh_missing}; +use super::super::mobile_pull_request_identity::GitHubIdentity; +use super::super::mobile_pull_request_links::{parse_review_reference, workspace_review_reference}; +use super::super::mobile_pull_request_requests::{ + list_review_for_branch, load_checks, run_gh, view_review, +}; +use super::identity::ForgeIdentity; +use super::model::{MergeMethod, Review}; +use super::provider::{ + AuthStatus, CommentLocator, CommentSource, CreateInput, Created, ForgeProvider, +}; +use super::runner::{ForgeOutput, ForgeRunner}; + +pub(crate) struct GitHubForge { + pub(crate) identity: ForgeIdentity, + pub(crate) github: GitHubIdentity, + pub(crate) repo_path: String, + /// Writes go through it, so Watch and Fix can merge on a remote host. + pub(crate) runner: Arc, +} + +/// Runs `gh` exactly as the runtime always has, for a checkout on this host. +pub(crate) struct GhRunner { + pub(crate) cwd: String, +} + +#[async_trait] +impl ForgeRunner for GhRunner { + async fn run_with_stdin( + &self, + _program: &str, + args: &[String], + _environment: &[(String, String)], + stdin: Option<&str>, + ) -> HostResult { + if stdin.is_some() { + return Err(HostError::state("gh requests here take no standard input.")); + } + let args = args.iter().map(String::as_str).collect::>(); + match run_gh(&self.cwd, &args).await { + Ok((code, stdout, stderr)) => Ok(ForgeOutput { + code, + stdout, + stderr, + }), + Err(error) if error.wire_message().starts_with("failed to run gh") => Ok(ForgeOutput { + code: 127, + stdout: String::new(), + stderr: error.wire_message(), + }), + Err(error) => Err(error), + } + } +} + +impl GitHubForge { + async fn run_checked(&self, args: Vec) -> HostResult { + let output = self.runner.run("gh", &args, &[]).await?; + if output.code == 127 { + return Err(gh_missing()); + } + if output.code != 0 { + return Err(gh_failure(&output.stderr, &output.stdout)); + } + Ok(output.stdout) + } + + async fn run_action(&self, action: Action) -> HostResult { + self.run_checked(gh_args(&action, &self.github, "")).await + } +} + +pub(crate) fn review_from_json(value: &Value) -> Review { + let text = |key: &str| value[key].as_str().map(ToOwned::to_owned); + Review { + number: value["number"].as_i64().unwrap_or(0), + title: text("title").unwrap_or_default(), + state: match value["state"].as_str().unwrap_or("OPEN") { + "MERGED" => "MERGED", + "CLOSED" => "CLOSED", + _ => "OPEN", + }, + url: text("url").unwrap_or_default(), + is_draft: value["isDraft"].as_bool().unwrap_or(false), + author: text("author"), + head_branch: text("headRefName"), + base_branch: text("baseRefName"), + created_at: text("createdAt"), + mergeable: match value["mergeable"].as_str() { + Some("MERGEABLE") => "MERGEABLE", + Some("CONFLICTING") => "CONFLICTING", + _ => "UNKNOWN", + }, + head_sha: text("headSha"), + } +} + +#[async_trait] +impl ForgeProvider for GitHubForge { + fn identity(&self) -> &ForgeIdentity { + &self.identity + } + + async fn auth_status(&self) -> HostResult { + let args = ["auth", "status", "--hostname", self.github.host.as_str()]; + match run_gh(&self.repo_path, &args).await { + Ok((0, _, _)) => Ok(AuthStatus::Authenticated), + Ok(_) => Ok(AuthStatus::NotAuthenticated), + Err(error) if looks_like_missing_cli(&error) => Ok(AuthStatus::CliMissing), + Err(error) => Err(error), + } + } + + async fn review_by_number(&self, number: i64) -> HostResult> { + Ok(view_review(&self.repo_path, &self.github.slug, number) + .await? + .map(|review| review_from_json(&review))) + } + + async fn review_for_branch(&self, branch: &str) -> HostResult> { + Ok( + list_review_for_branch(&self.repo_path, &self.github.slug, branch) + .await? + .map(|review| review_from_json(&review)), + ) + } + + async fn checks(&self, number: i64) -> Vec { + load_checks(&self.repo_path, &self.github.slug, number).await + } + + async fn comments(&self, number: i64) -> (Vec, bool) { + super::super::mobile_pull_request_comments::load_comments( + &self.repo_path, + &self.github.host, + &self.github.owner, + &self.github.repo, + number, + ) + .await + } + + async fn viewer(&self) -> Option { + let args = [ + "api", + "--hostname", + &self.github.host, + "user", + "--jq", + ".login", + ]; + let (code, stdout, _) = run_gh(&self.repo_path, &args).await.ok()?; + let login = stdout.trim(); + (code == 0 && !login.is_empty()).then(|| login.to_string()) + } + + async fn merge_methods(&self, base_branch: Option<&str>) -> Result, String> { + super::super::mobile_pull_request_merge_methods::allowed_merge_methods( + &self.repo_path, + &self.github, + base_branch, + ) + .await + .map(|methods| methods.into_iter().map(ToOwned::to_owned).collect()) + } + + async fn create(&self, input: &CreateInput) -> HostResult { + let action = Action::Create { + base: input.base.clone(), + title: input.title.clone(), + body: input.body.clone(), + draft: input.draft, + }; + let stdout = self + .run_checked(gh_args(&action, &self.github, &input.head)) + .await?; + let url = stdout + .lines() + .map(str::trim) + .find(|line| line.starts_with("http")) + .map(ToOwned::to_owned); + // An unreadable URL leaves the review unlinked, as it always has. + let number = url.as_deref().and_then(parse_review_reference).unwrap_or(0); + Ok(Created { number, url }) + } + + async fn comment( + &self, + number: i64, + body: &str, + reply_to: Option<&CommentLocator>, + ) -> HostResult<()> { + self.run_action(Action::Comment { + number, + body: body.to_string(), + reply_to: reply_to.map(|locator| locator.comment_id), + }) + .await + .map(|_| ()) + } + + async fn update_comment( + &self, + number: i64, + locator: &CommentLocator, + body: &str, + ) -> HostResult<()> { + self.run_action(Action::CommentUpdate { + number, + comment_id: locator.comment_id, + source: match locator.source { + CommentSource::Conversation => GhCommentSource::Conversation, + CommentSource::ReviewSummary => GhCommentSource::ReviewSummary, + CommentSource::ReviewThread => GhCommentSource::ReviewThread, + }, + body: body.to_string(), + }) + .await + .map(|_| ()) + } + + async fn merge( + &self, + number: i64, + method: MergeMethod, + expected_head: Option<&str>, + ) -> HostResult<()> { + let method = match method { + MergeMethod::MergeCommit => GhMergeMethod::MergeCommit, + MergeMethod::Squash => GhMergeMethod::Squash, + MergeMethod::Rebase => GhMergeMethod::Rebase, + MergeMethod::ProviderDefault => { + return Err(HostError::state( + "GitHub does not expose a provider-default merge method through gh.", + )); + } + }; + let mut args = gh_args(&Action::Merge { number, method }, &self.github, ""); + if let Some(head) = expected_head { + args.extend(["--match-head-commit".to_string(), head.to_string()]); + } + self.run_checked(args).await.map(|_| ()) + } + + async fn set_draft(&self, number: i64, draft: bool) -> HostResult<()> { + self.run_action(Action::DraftStatus { number, draft }) + .await + .map(|_| ()) + } + + async fn close(&self, number: i64) -> HostResult<()> { + self.run_action(Action::Close { number }).await.map(|_| ()) + } + + fn review_reference(&self, input: &str) -> HostResult { + workspace_review_reference(input, &self.github) + } +} + +fn looks_like_missing_cli(error: &HostError) -> bool { + let message = error.wire_message().to_ascii_lowercase(); + message.contains("no such file") + || message.contains("not found") + || message.contains("cannot find") + || message.contains("program not found") +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/gitlab.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/gitlab.rs new file mode 100644 index 000000000..697c57da6 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/gitlab.rs @@ -0,0 +1,464 @@ +//! GitLab merge requests through `glab`, ported from the desktop's +//! `gitlab_forge_provider.dart`, `gitlab_review_actions.dart`, and +//! `gitlab_review_comments.dart`: the same API endpoints and the same failure +//! classification. Titles, descriptions, branch names and comment bodies are +//! request bodies piped to `glab api --input -`, never command-line arguments, +//! so no shell or `glab` flag parsing ever reads free text. + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::identity::{ForgeIdentity, ForgeKind}; +use super::mappers::{gitlab_comments, gitlab_pipeline, gitlab_review}; +use super::model::{ + fixed_merge_methods, forge_failure, newest, provider_unavailable, MergeMethod, Review, +}; +use super::provider::{ + foreign_review_url, plain_review_number, url_segments, AuthStatus, CommentLocator, + CommentSource, CreateInput, Created, ForgeProvider, +}; +use super::runner::{ForgeOutput, ForgeRunner}; + +pub(crate) struct GitLabForge { + pub(crate) identity: ForgeIdentity, + pub(crate) runner: Arc, +} + +/// Same as Dart's `Uri.encodeComponent`. +pub(crate) fn encode_component(value: &str) -> String { + value + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + | b'-' + | b'_' + | b'.' + | b'!' + | b'~' + | b'*' + | b'\'' + | b'(' + | b')' => (byte as char).to_string(), + _ => format!("%{byte:02X}"), + }) + .collect() +} + +fn looks_missing(output: &ForgeOutput) -> bool { + let stderr = output.stderr.to_ascii_lowercase(); + output.code == 127 + || stderr.contains("command not found") + || stderr.contains("is not recognized") + || stderr.contains("no such file") +} + +fn looks_unauthenticated(stderr: &str) -> bool { + let lower = stderr.to_ascii_lowercase(); + lower.contains("not logged") + || lower.contains("unauthorized") + || lower.contains("authentication") + || lower.contains("glab auth login") +} + +impl GitLabForge { + pub(crate) fn repo_url(&self) -> String { + format!( + "https://{}/{}/{}", + self.identity.host, self.identity.owner, self.identity.repo + ) + } + + fn project_endpoint(&self) -> String { + format!( + "projects/{}", + encode_component(&format!("{}/{}", self.identity.owner, self.identity.repo)) + ) + } + + fn mr_endpoint(&self, number: i64) -> String { + format!("{}/merge_requests/{number}", self.project_endpoint()) + } + + /// `glab api`. `glab api --hostname` rejects an authority with a port, so + /// such a host is addressed through `GITLAB_REPO` instead. A [body] is + /// sent as JSON on standard input. + async fn api( + &self, + endpoint: &str, + method: Option<&str>, + body: Option<&Value>, + paginate: bool, + allow_not_found: bool, + ) -> HostResult> { + let override_repo = self.identity.host.contains(':'); + let mut args = vec!["api".to_string(), endpoint.to_string()]; + if !override_repo { + args.extend(["--hostname".to_string(), self.identity.host.clone()]); + } + if paginate { + args.extend(["--paginate", "--output", "ndjson"].map(String::from)); + } + if let Some(method) = method { + args.extend(["--method".to_string(), method.to_string()]); + } + let stdin = body.map(Value::to_string); + if stdin.is_some() { + args.extend( + ["--header", "Content-Type: application/json", "--input", "-"].map(String::from), + ); + } + let environment = if override_repo { + vec![("GITLAB_REPO".to_string(), self.repo_url())] + } else { + Vec::new() + }; + self.run(args, &environment, stdin.as_deref(), allow_not_found) + .await + } + + async fn run( + &self, + args: Vec, + environment: &[(String, String)], + stdin: Option<&str>, + allow_not_found: bool, + ) -> HostResult> { + let output = self + .runner + .run_with_stdin("glab", &args, environment, stdin) + .await?; + if output.code == 0 { + return Ok(Some(output.stdout)); + } + if looks_missing(&output) { + return Err(provider_unavailable(ForgeKind::GitLab, false)); + } + let lower = output.stderr.to_ascii_lowercase(); + if allow_not_found && (lower.contains("404") || lower.contains("not found")) { + return Ok(None); + } + if looks_unauthenticated(&output.stderr) { + return Err(provider_unavailable(ForgeKind::GitLab, true)); + } + Err(forge_failure( + ForgeKind::GitLab, + &output.stderr, + &output.stdout, + )) + } + + async fn run_mr(&self, mut args: Vec) -> HostResult { + args.splice(0..0, ["mr".to_string()]); + Ok(self.run(args, &[], None, false).await?.unwrap_or_default()) + } + + async fn merge_request(&self, number: i64) -> HostResult> { + let Some(output) = self + .api(&self.mr_endpoint(number), None, None, false, true) + .await? + else { + return Ok(None); + }; + decode(&output).map(|value| value.filter(Value::is_object)) + } + + /// The discussion that owns [locator], reading it from the review when + /// the caller did not name it. + async fn discussion_id(&self, number: i64, locator: &CommentLocator) -> HostResult { + if let Some(id) = locator.thread_id.as_deref().filter(|id| !id.is_empty()) { + return Ok(id.to_string()); + } + let (comments, _) = self.comments(number).await; + comments + .iter() + .find(|comment| comment["id"].as_i64() == Some(locator.comment_id)) + .and_then(|comment| comment["discussionId"].as_str()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + HostError::state("The GitLab comment discussion could not be determined.") + }) + } +} + +pub(crate) fn decode(raw: &str) -> HostResult> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + serde_json::from_str(trimmed) + .map(Some) + .map_err(|_| HostError::state(format!("Unexpected glab output: {trimmed}"))) +} + +pub(crate) fn decode_ndjson(raw: &str) -> HostResult> { + raw.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + serde_json::from_str(line).map_err(|_| { + HostError::state(format!("Unexpected glab NDJSON output: {}", raw.trim())) + }) + }) + .collect() +} + +/// The `POST merge_requests` body for [input]. +pub(crate) fn create_body(input: &CreateInput) -> Value { + let title = if input.draft { + format!("Draft: {}", input.title) + } else { + input.title.clone() + }; + json!({ + "source_branch": input.head, + "target_branch": input.base, + "title": title, + "description": input.body, + }) +} + +pub(crate) fn merge_request_number(output: &str) -> Option<(i64, String)> { + let start = output.find("http")?; + let url = output[start..].split_whitespace().next()?.to_string(); + let (_, rest) = url.split_once("/-/merge_requests/")?; + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + Some((digits.parse().ok()?, url)) +} + +#[async_trait] +impl ForgeProvider for GitLabForge { + fn identity(&self) -> &ForgeIdentity { + &self.identity + } + + async fn auth_status(&self) -> HostResult { + let args = ["auth", "status", "--hostname", &self.identity.host].map(String::from); + let output = self.runner.run("glab", &args, &[]).await?; + Ok(if output.code == 0 { + AuthStatus::Authenticated + } else if looks_missing(&output) { + AuthStatus::CliMissing + } else { + AuthStatus::NotAuthenticated + }) + } + + async fn review_by_number(&self, number: i64) -> HostResult> { + Ok(self + .merge_request(number) + .await? + .map(|value| gitlab_review(&value))) + } + + async fn review_for_branch(&self, branch: &str) -> HostResult> { + let endpoint = format!( + "{}/merge_requests?state=opened&source_branch={}&per_page=100", + self.project_endpoint(), + url::form_urlencoded::byte_serialize(branch.as_bytes()).collect::() + ); + let output = self.api(&endpoint, None, None, true, false).await?; + let records = decode_ndjson(&output.unwrap_or_default())?; + Ok(newest( + records + .iter() + .filter(|record| record.is_object()) + .map(gitlab_review), + )) + } + + async fn checks(&self, number: i64) -> Vec { + match self.merge_request(number).await { + Ok(Some(value)) if value["head_pipeline"].is_object() => { + vec![gitlab_pipeline(&value["head_pipeline"])] + } + _ => Vec::new(), + } + } + + /// A failed read reports a cut-short list, so Watch, Fix and Merge never + /// mistakes an unreadable conversation for a clear one. + async fn comments(&self, number: i64) -> (Vec, bool) { + let endpoint = format!("{}/discussions?per_page=100", self.mr_endpoint(number)); + match self.api(&endpoint, None, None, true, false).await { + Ok(output) => match decode_ndjson(&output.unwrap_or_default()) { + Ok(discussions) => (gitlab_comments(&discussions), false), + Err(_) => (Vec::new(), true), + }, + Err(_) => (Vec::new(), true), + } + } + + async fn viewer(&self) -> Option { + let output = self.api("user", None, None, false, false).await.ok()??; + let user = decode(&output).ok()??; + user["username"].as_str().map(ToOwned::to_owned) + } + + async fn merge_methods(&self, _base_branch: Option<&str>) -> Result, String> { + Ok(fixed_merge_methods(ForgeKind::GitLab) + .iter() + .map(|method| method.to_string()) + .collect()) + } + + /// `POST merge_requests`, as `glab mr create` sends it: a draft is the + /// `Draft: ` title prefix. + async fn create(&self, input: &CreateInput) -> HostResult { + let endpoint = format!("{}/merge_requests", self.project_endpoint()); + let body = create_body(input); + let stdout = self + .api(&endpoint, Some("POST"), Some(&body), false, false) + .await? + .unwrap_or_default(); + let created = decode(&stdout).ok().flatten().unwrap_or(Value::Null); + if let Some(number) = created["iid"].as_i64() { + return Ok(Created { + number, + url: created["web_url"].as_str().map(ToOwned::to_owned), + }); + } + if let Some((number, url)) = merge_request_number(&stdout) { + return Ok(Created { + number, + url: Some(url), + }); + } + match self.review_for_branch(&input.head).await? { + Some(review) => Ok(Created { + number: review.number, + url: Some(review.url), + }), + None => Err(HostError::state( + "The merge request was created but could not be read back.", + )), + } + } + + async fn comment( + &self, + number: i64, + body: &str, + reply_to: Option<&CommentLocator>, + ) -> HostResult<()> { + let endpoint = match reply_to { + None => format!("{}/notes", self.mr_endpoint(number)), + Some(locator) => format!( + "{}/discussions/{}/notes", + self.mr_endpoint(number), + encode_component(&self.discussion_id(number, locator).await?) + ), + }; + let payload = json!({ "body": body }); + self.api(&endpoint, Some("POST"), Some(&payload), false, false) + .await + .map(|_| ()) + } + + async fn update_comment( + &self, + number: i64, + locator: &CommentLocator, + body: &str, + ) -> HostResult<()> { + let note = encode_component(&locator.comment_id.to_string()); + let endpoint = match locator.source { + CommentSource::ReviewSummary => { + return Err(HostError::state( + "GitLab does not expose pull-request review summaries as comments.", + )); + } + CommentSource::ReviewThread => format!( + "{}/discussions/{}/notes/{note}", + self.mr_endpoint(number), + encode_component(&self.discussion_id(number, locator).await?) + ), + CommentSource::Conversation => format!("{}/notes/{note}", self.mr_endpoint(number)), + }; + let payload = json!({ "body": body }); + self.api(&endpoint, Some("PUT"), Some(&payload), false, false) + .await + .map(|_| ()) + } + + async fn merge( + &self, + number: i64, + method: MergeMethod, + expected_head: Option<&str>, + ) -> HostResult<()> { + if matches!(method, MergeMethod::Rebase | MergeMethod::MergeCommit) { + return Err(HostError::state( + "GitLab merge topology is controlled by the project settings.", + )); + } + let mut args = vec![ + "merge".to_string(), + number.to_string(), + "--repo".into(), + self.repo_url(), + ]; + if method == MergeMethod::Squash { + args.push("--squash".into()); + } + if let Some(head) = expected_head { + args.extend(["--sha".to_string(), head.to_string()]); + } + args.extend(["--auto-merge=false".to_string(), "--yes".to_string()]); + self.run_mr(args).await.map(|_| ()) + } + + async fn set_draft(&self, number: i64, draft: bool) -> HostResult<()> { + let args = vec![ + "update".to_string(), + number.to_string(), + "--repo".into(), + self.repo_url(), + if draft { "--draft" } else { "--ready" }.into(), + "--yes".into(), + ]; + self.run_mr(args).await.map(|_| ()) + } + + async fn close(&self, number: i64) -> HostResult<()> { + let args = vec![ + "close".to_string(), + number.to_string(), + "--repo".into(), + self.repo_url(), + ]; + self.run_mr(args).await.map(|_| ()) + } + + fn review_reference(&self, input: &str) -> HostResult { + if let Some(number) = plain_review_number(input) { + return number; + } + let segments = url_segments(input, &self.identity.host)?; + let repo_path = format!("{}/{}", self.identity.owner, self.identity.repo); + let marker = segments + .iter() + .position(|segment| segment == "-") + .filter(|index| segments.get(index + 1).map(String::as_str) == Some("merge_requests")) + .ok_or_else(foreign_review_url)?; + if !segments[..marker] + .join("/") + .eq_ignore_ascii_case(&repo_path) + { + return Err(foreign_review_url()); + } + segments + .get(marker + 2) + .and_then(|number| number.parse::().ok()) + .filter(|number| *number > 0) + .ok_or_else(|| HostError::state("Enter a valid merge request URL.")) + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/identity.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/identity.rs new file mode 100644 index 000000000..ca0d816bf --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/identity.rs @@ -0,0 +1,323 @@ +//! Which forge a remote URL belongs to and the coordinates its CLI needs. +//! Ported from the desktop's `git_remote_parser.dart` and +//! `hosting_provider_resolver.dart`: a project may force the provider, and the +//! coordinates still come from the remote URL. + +use super::super::mobile_pull_request_identity::{parse_github_identity, GitHubIdentity}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ForgeKind { + GitHub, + GitLab, + AzureDevOps, +} + +impl ForgeKind { + pub(crate) const ALL: [ForgeKind; 3] = [Self::GitHub, Self::GitLab, Self::AzureDevOps]; + + /// The name `LinkedReview.provider`, snapshots, and the desktop use. + pub(crate) fn wire(self) -> &'static str { + match self { + Self::GitHub => "github", + Self::GitLab => "gitlab", + Self::AzureDevOps => "azureDevops", + } + } + + pub(crate) fn from_wire(value: &str) -> Option { + Self::ALL.into_iter().find(|kind| kind.wire() == value) + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::GitHub => "GitHub", + Self::GitLab => "GitLab", + Self::AzureDevOps => "Azure DevOps", + } + } + + pub(crate) fn cli(self) -> &'static str { + match self { + Self::GitHub => "gh", + Self::GitLab => "glab", + Self::AzureDevOps => "az", + } + } + + /// The word each forge uses for a review. + pub(crate) fn review_noun(self) -> &'static str { + match self { + Self::GitLab => "merge request", + _ => "pull request", + } + } +} + +/// Repository coordinates. GitLab's owner can hold nested groups; Azure DevOps +/// keeps the organization in `owner` and needs `project` too. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ForgeIdentity { + pub(crate) kind: ForgeKind, + pub(crate) host: String, + pub(crate) owner: String, + pub(crate) repo: String, + pub(crate) project: Option, +} + +impl ForgeIdentity { + /// The `gh` coordinates, keeping the runtime's existing GitHub parsing + /// (which accepts `*.github.com` hosts) when the remote allows it. + pub(crate) fn github(&self, remote_url: &str) -> GitHubIdentity { + parse_github_identity(remote_url).unwrap_or_else(|| GitHubIdentity { + host: self.host.clone(), + owner: self.owner.clone(), + repo: self.repo.clone(), + slug: if self.host == "github.com" { + format!("{}/{}", self.owner, self.repo) + } else { + format!("{}/{}/{}", self.host, self.owner, self.repo) + }, + }) + } + + /// `https://dev.azure.com/{org}`, or the legacy `{org}.visualstudio.com`. + pub(crate) fn azure_org_url(&self) -> String { + if self.host.contains("visualstudio.com") { + format!("https://{}.visualstudio.com", self.owner) + } else { + format!("https://dev.azure.com/{}", self.owner) + } + } +} + +/// The forge of [url], forced to [forced] when the project overrides it. +pub(crate) fn resolve_identity(url: &str, forced: Option) -> Option { + let parsed = parse_remote_url(url)?; + match forced { + Some(kind) => as_kind(&parsed, kind), + None => detect(&parsed), + } +} + +fn detect(parsed: &ParsedRemote) -> Option { + let host = parsed.hostname.as_str(); + if host == "github.com" || host.ends_with(".github.com") { + return as_kind(parsed, ForgeKind::GitHub); + } + if host == "dev.azure.com" + || host == "ssh.dev.azure.com" + || host == "vs-ssh.visualstudio.com" + || host.ends_with(".visualstudio.com") + { + return as_kind(parsed, ForgeKind::AzureDevOps); + } + // Self-hosted GitLab usually keeps the product name in its host; the + // project setting covers the rest. + if host == "gitlab.com" || host.contains("gitlab") { + return as_kind(parsed, ForgeKind::GitLab); + } + None +} + +fn as_kind(parsed: &ParsedRemote, kind: ForgeKind) -> Option { + let segments = &parsed.segments; + let identity = |owner: String, repo: String, project: Option| ForgeIdentity { + kind, + host: parsed.host.clone(), + owner, + repo, + project, + }; + match kind { + ForgeKind::GitHub if segments.len() >= 2 => { + Some(identity(segments[0].clone(), segments[1].clone(), None)) + } + ForgeKind::GitLab if segments.len() >= 2 => Some(identity( + segments[..segments.len() - 1].join("/"), + segments[segments.len() - 1].clone(), + None, + )), + ForgeKind::AzureDevOps => { + azure(parsed).map(|(org, project, repo)| identity(org, repo, Some(project))) + } + _ => None, + } +} + +/// `(org, project, repo)` from `[..]/org/project/_git/repo` (dev.azure.com), +/// `[..]/project/_git/repo` on `org.visualstudio.com`, or SSH +/// `v3/org/project/repo`. +fn azure(parsed: &ParsedRemote) -> Option<(String, String, String)> { + let segments = &parsed.segments; + if let Some(git) = segments.iter().position(|segment| segment == "_git") { + if git >= 1 && git + 1 < segments.len() { + let repo = segments[git + 1].clone(); + if let Some(org) = parsed.hostname.strip_suffix(".visualstudio.com") { + return Some((org.to_string(), segments[git - 1].clone(), repo)); + } + if git >= 2 { + return Some((segments[git - 2].clone(), segments[git - 1].clone(), repo)); + } + return None; + } + } + let base = if segments.first().map(String::as_str) == Some("v3") { + &segments[1..] + } else { + &segments[..] + }; + (base.len() >= 3).then(|| (base[0].clone(), base[1].clone(), base[2].clone())) +} + +struct ParsedRemote { + /// Lowercased host, with the port only for http(s) remotes. + host: String, + hostname: String, + segments: Vec, +} + +fn parse_remote_url(raw: &str) -> Option { + let url = raw.trim(); + if url.is_empty() { + return None; + } + let (host, hostname, path) = if url.contains("://") { + let parsed = url::Url::parse(url).ok()?; + let hostname = parsed.host_str()?.to_ascii_lowercase(); + let keeps_port = matches!(parsed.scheme(), "http" | "https"); + let host = match parsed.port() { + Some(port) if keeps_port => format!("{hostname}:{port}"), + _ => hostname.clone(), + }; + (host, hostname, parsed.path().to_string()) + } else { + let (authority, path) = url.split_once(':')?; + let authority = authority.rsplit('@').next().unwrap_or(authority); + if authority.is_empty() { + return None; + } + let host = authority.to_ascii_lowercase(); + (host.clone(), host, path.to_string()) + }; + // Forge CLIs encode names themselves, so `My%20Project` must reach them + // as `My Project` or they ask for `My%2520Project`. + let mut segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(decode_segment) + .collect::>(); + let last = segments.last_mut()?; + if let Some(stripped) = last.strip_suffix(".git") { + *last = stripped.to_string(); + } + if last.is_empty() { + return None; + } + Some(ParsedRemote { + host, + hostname, + segments, + }) +} + +/// A URL path segment with its `%XX` escapes decoded. One that does not +/// decode to UTF-8 is kept as it was. +pub(crate) fn decode_segment(segment: &str) -> String { + let bytes = segment.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let escaped = (bytes[index] == b'%') + .then(|| segment.get(index + 1..index + 3)) + .flatten() + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + match escaped { + Some(byte) => { + decoded.push(byte); + index += 3; + } + None => { + decoded.push(bytes[index]); + index += 1; + } + } + } + String::from_utf8(decoded).unwrap_or_else(|_| segment.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escaped_azure_names_are_decoded() { + let azure = parse("https://dev.azure.com/org/My%20Project/_git/My%20Repo"); + assert_eq!(azure.project.as_deref(), Some("My Project")); + assert_eq!(azure.repo, "My Repo"); + assert_eq!( + super::super::mappers::azure_web_url(&azure, 7), + "https://dev.azure.com/org/My%20Project/_git/My%20Repo/pullrequest/7" + ); + assert_eq!(decode_segment("100%"), "100%"); + assert_eq!(decode_segment("a%zzb"), "a%zzb"); + } + + fn parse(url: &str) -> ForgeIdentity { + resolve_identity(url, None).unwrap() + } + + #[test] + fn detects_each_forge_like_the_desktop() { + let github = parse("git@github.com:leynier/alera.git"); + assert_eq!( + (github.kind, github.owner.as_str(), github.repo.as_str()), + (ForgeKind::GitHub, "leynier", "alera") + ); + let gitlab = parse("https://gitlab.acme.test:8443/platform/mobile/alera.git"); + assert_eq!(gitlab.kind, ForgeKind::GitLab); + assert_eq!(gitlab.host, "gitlab.acme.test:8443"); + assert_eq!(gitlab.owner, "platform/mobile"); + assert_eq!(gitlab.repo, "alera"); + let azure = parse("https://myorg@dev.azure.com/myorg/myproject/_git/myrepo"); + assert_eq!(azure.kind, ForgeKind::AzureDevOps); + assert_eq!( + ( + azure.owner.as_str(), + azure.project.as_deref(), + azure.repo.as_str() + ), + ("myorg", Some("myproject"), "myrepo") + ); + assert_eq!(azure.azure_org_url(), "https://dev.azure.com/myorg"); + let ssh = parse("git@ssh.dev.azure.com:v3/myorg/myproject/myrepo"); + assert_eq!(ssh.project.as_deref(), Some("myproject")); + let legacy = + parse("https://myorg.visualstudio.com/DefaultCollection/myproject/_git/myrepo"); + assert_eq!(legacy.owner, "myorg"); + assert_eq!(legacy.azure_org_url(), "https://myorg.visualstudio.com"); + assert!(resolve_identity("https://example.com/a/b.git", None).is_none()); + } + + #[test] + fn a_project_override_forces_the_provider() { + let forced = + resolve_identity("git@code.acme.test:team/app.git", Some(ForgeKind::GitLab)).unwrap(); + assert_eq!(forced.kind, ForgeKind::GitLab); + assert_eq!(forced.host, "code.acme.test"); + let enterprise = + resolve_identity("https://ghe.acme.test/team/app", Some(ForgeKind::GitHub)).unwrap(); + let github = enterprise.github("https://ghe.acme.test/team/app"); + assert_eq!(github.slug, "ghe.acme.test/team/app"); + assert!( + resolve_identity("https://ghe.acme.test/app", Some(ForgeKind::AzureDevOps)).is_none() + ); + } + + #[test] + fn wire_names_round_trip() { + for kind in ForgeKind::ALL { + assert_eq!(ForgeKind::from_wire(kind.wire()), Some(kind)); + } + assert_eq!(ForgeKind::from_wire("bitbucket"), None); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/input_file.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/input_file.rs new file mode 100644 index 000000000..f4485dfbe --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/input_file.rs @@ -0,0 +1,79 @@ +//! A request body handed to a forge CLI as a file (`az devops invoke +//! --in-file`), so free text never reaches its command line. +//! +//! This is what `tempfile::NamedTempFile` provides, written out because +//! `tempfile` is only a dev-dependency of this crate: a fresh name opened with +//! `create_new` (`O_EXCL`, so a planted file or symlink is refused, never +//! followed), owner-only on unix, and removed when the guard drops. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +/// The temporary body file; dropping it removes the file, including when the +/// request times out or its future is cancelled. +#[derive(Debug)] +pub(super) struct InputFile { + path: PathBuf, +} + +impl InputFile { + pub(super) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for InputFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// [body] in a new file in the per-user temp directory that only its owner +/// can read (0600 on unix; on Windows the user's temp directory already +/// carries an owner-only ACL). The handle is closed before the CLI opens it. +pub(super) fn json_input_file(body: &Value) -> HostResult { + let failed = + |error: std::io::Error| HostError::state(format!("Could not write the request: {error}")); + let path = std::env::temp_dir().join(format!("alera-pr-{}.json", uuid::Uuid::new_v4())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&path).map_err(failed)?; + // From here on the guard owns the file, so a failed write removes it. + let guard = InputFile { path }; + file.write_all(body.to_string().as_bytes()) + .and_then(|_| file.flush()) + .map_err(failed)?; + Ok(guard) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_body_file_is_private_and_removed_on_drop() { + let file = json_input_file(&serde_json::json!({ "content": "secret" })).unwrap(); + let location = file.path().to_path_buf(); + assert_eq!( + std::fs::read_to_string(&location).unwrap(), + r#"{"content":"secret"}"# + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&location).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + drop(file); + assert!(!location.exists()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/links.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/links.rs new file mode 100644 index 000000000..a67a6a52d --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/links.rs @@ -0,0 +1,57 @@ +//! The workspace-to-review link for every forge. Writes the same +//! `LinkedReview` records the desktop keeps (`linked_review.dart`), with the +//! forge's own provider name. + +use alera_core::runtime::{LinkedReview, RuntimeStore, Workspace}; +use chrono::Utc; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::identity::ForgeKind; +use super::provider::{CreateInput, Created, ForgeProvider}; + +pub(crate) async fn save_link( + store: &RuntimeStore, + workspace_id: &str, + kind: ForgeKind, + number: i64, + url: Option, + dismissed: bool, +) -> HostResult<()> { + store + .upsert_linked_review(LinkedReview { + workspace_id: workspace_id.to_string(), + dismissed, + provider: Some(kind.wire().to_string()), + number: Some(number), + url, + linked_at: Utc::now(), + }) + .await + .map(|_| ()) + .map_err(|error| HostError::state(error.to_string())) +} + +/// Creates the review and links it to [workspace], like the desktop does +/// after it creates one. +pub(crate) async fn create_and_link( + store: &RuntimeStore, + workspace: &Workspace, + forge: &dyn ForgeProvider, + input: &CreateInput, +) -> HostResult { + let created = forge.create(input).await?; + if created.number <= 0 { + return Ok(created); + } + save_link( + store, + &workspace.id, + forge.kind(), + created.number, + created.url.clone(), + false, + ) + .await?; + Ok(created) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/local_command.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/local_command.rs new file mode 100644 index 000000000..f95cdc475 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/local_command.rs @@ -0,0 +1,120 @@ +//! The process a forge CLI (`glab`, `az`) runs as on this machine. +//! +//! On unix it goes through `alera_core::shell_command`, whose `/bin/sh -c` +//! line single-quotes every token, so nothing in an argument is reinterpreted. +//! On Windows that helper builds a `cmd.exe /c` line whose `\"` escaping +//! `cmd.exe` does not honour, so `&`, `|` or `%VAR%` in an argument would run +//! or expand. The CLI is spawned directly there instead: the name is resolved +//! against `PATH` and `PATHEXT` (what `cmd.exe` did for the `.cmd` shim that +//! `az` installs), and the standard library quotes the arguments, applying its +//! batch-file escaping, or refusing an argument it cannot pass safely, when the +//! target is a `.cmd` or `.bat`. Unlike `cmd.exe`, the checkout itself is never +//! searched, so a repository cannot plant its own `glab.cmd`. + +use std::collections::HashMap; +#[cfg(any(windows, test))] +use std::path::{Path, PathBuf}; + +pub(super) fn forge_command( + program: &str, + args: &[String], + cwd: &str, + environment: &HashMap, +) -> tokio::process::Command { + #[cfg(windows)] + { + let lookup = |name: &str| { + environment + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.clone()) + .or_else(|| std::env::var(name).ok()) + }; + let resolved = resolve_on_path( + program, + lookup("PATH").as_deref(), + lookup("PATHEXT").as_deref(), + |candidate| candidate.is_file(), + ); + let mut command = alera_core::child_process::windowless_async_command(resolved); + command + .args(args) + .current_dir(cwd) + .envs(environment) + .kill_on_drop(true); + command + } + #[cfg(not(windows))] + { + alera_core::shell_command::shell_command(program, args, Some(cwd), Some(environment), true) + } +} + +/// The first `PATH` entry holding [program] with one of the `PATHEXT` +/// extensions. A name with a directory, or one that is not found, is +/// returned unchanged, so a missing CLI still fails to start. +#[cfg(any(windows, test))] +fn resolve_on_path( + program: &str, + path: Option<&str>, + path_extensions: Option<&str>, + is_file: impl Fn(&Path) -> bool, +) -> PathBuf { + if program.contains(['/', '\\']) { + return PathBuf::from(program); + } + let extensions = if Path::new(program).extension().is_some() { + vec![String::new()] + } else { + path_extensions + .unwrap_or(".COM;.EXE;.BAT;.CMD") + .split(';') + .filter(|extension| !extension.is_empty()) + .map(str::to_string) + .collect() + }; + let directories = path + .unwrap_or_default() + .split(';') + .map(|directory| directory.trim().trim_matches('"')) + .filter(|directory| !directory.is_empty()); + for directory in directories { + for extension in &extensions { + let candidate = Path::new(directory).join(format!("{program}{extension}")); + if is_file(&candidate) { + return candidate; + } + } + } + PathBuf::from(program) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_the_cmd_shim_from_path_and_never_the_checkout() { + let shim = Path::new("bin").join("az.CMD"); + let planted = Path::new("repo").join("az.CMD"); + let present = |candidate: &Path| candidate == shim || candidate == planted; + assert_eq!( + resolve_on_path("az", Some("\"bin\";other"), Some(".EXE;.CMD"), present), + shim + ); + assert_eq!( + resolve_on_path("az", Some("other"), Some(".EXE;.CMD"), present), + PathBuf::from("az"), + "an unresolved name stays bare, so the spawn fails as missing" + ); + assert_eq!( + resolve_on_path(r"C:\tools\glab.exe", Some("bin"), None, present), + PathBuf::from(r"C:\tools\glab.exe") + ); + let glab = Path::new("bin").join("glab.exe"); + assert_eq!( + resolve_on_path("glab.exe", Some("bin"), None, |candidate| candidate == glab), + glab + ); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/mappers.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/mappers.rs new file mode 100644 index 000000000..1c736f3d4 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/mappers.rs @@ -0,0 +1,284 @@ +//! Pure JSON mappers for `glab` and `az` output, ported 1:1 from the desktop's +//! `gitlab_review_mappers.dart`, `gitlab_review_comments.dart`, +//! `azure_devops_review_mappers.dart`, and `azure_devops_review_comments.dart`. +//! The shared fixtures under `test/fixtures/forges/` keep both ports honest. + +use serde_json::{json, Value}; + +use super::identity::ForgeIdentity; +use super::model::{check_json, Review}; + +fn text(value: &Value, key: &str) -> Option { + value[key] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + +pub(crate) fn gitlab_review(value: &Value) -> Review { + let state = value["state"] + .as_str() + .unwrap_or("opened") + .to_ascii_lowercase(); + let draft = value["draft"] + .as_bool() + .or_else(|| value["work_in_progress"].as_bool()) + .unwrap_or(false); + let detailed = value["detailed_merge_status"] + .as_str() + .unwrap_or("") + .to_ascii_lowercase(); + Review { + number: value["iid"].as_i64().unwrap_or(0), + title: value["title"].as_str().unwrap_or("").to_string(), + state: match state.as_str() { + "merged" => "MERGED", + "closed" => "CLOSED", + _ => "OPEN", + }, + url: value["web_url"].as_str().unwrap_or("").to_string(), + is_draft: draft, + author: text(&value["author"], "username").or_else(|| text(&value["author"], "name")), + head_branch: text(value, "source_branch"), + base_branch: text(value, "target_branch"), + created_at: text(value, "created_at"), + mergeable: if value["has_conflicts"] == true { + "CONFLICTING" + } else { + match detailed.as_str() { + "mergeable" | "can_be_merged" => "MERGEABLE", + "conflict" | "cannot_be_merged" => "CONFLICTING", + _ => "UNKNOWN", + } + }, + head_sha: text(value, "sha"), + } +} + +/// The MR's head pipeline as one check, like `mapGitLabPipeline`. +pub(crate) fn gitlab_pipeline(pipeline: &Value) -> Value { + let status = pipeline["status"] + .as_str() + .unwrap_or("") + .to_ascii_lowercase(); + let bucket = match status.as_str() { + "success" => "pass", + "failed" | "manual" => "fail", + "canceled" | "cancelled" => "cancel", + "skipped" => "skipping", + _ => "pending", + }; + let name = match pipeline["id"].as_i64() { + Some(id) => format!("Pipeline #{id}"), + None => "Pipeline".to_string(), + }; + check_json(&name, &status, bucket, text(pipeline, "web_url").as_deref()) +} + +/// Notes of every discussion, without system notes, oldest first. +pub(crate) fn gitlab_comments(discussions: &[Value]) -> Vec { + let mut comments = Vec::new(); + for discussion in discussions { + let discussion_id = discussion["id"] + .as_str() + .map(ToOwned::to_owned) + .or_else(|| discussion["id"].as_i64().map(|id| id.to_string())); + for note in discussion["notes"].as_array().into_iter().flatten() { + if note["system"] == true { + continue; + } + let position = ¬e["position"]; + let positioned = position.is_object(); + let resolvable = positioned || note["resolvable"] == true; + let line = position["new_line"] + .as_i64() + .or_else(|| position["old_line"].as_i64()); + comments.push(json!({ + "id": note["id"].as_i64().unwrap_or(0), + "author": text(¬e["author"], "username").or_else(|| text(¬e["author"], "name")), + "body": note["body"].as_str().unwrap_or(""), + "createdAt": text(note, "created_at"), + "url": text(note, "url"), + "kind": if positioned { "review" } else { "conversation" }, + "source": if positioned { "reviewThread" } else { "conversation" }, + "path": if positioned { + text(position, "new_path").or_else(|| text(position, "old_path")) + } else { + None + }, + "line": if positioned { line } else { None }, + "resolved": resolvable && note["resolved"] == true, + "outdated": false, + "threadId": if resolvable { discussion_id.clone() } else { None }, + "discussionId": discussion_id, + })); + } + } + sort_by_created_at(&mut comments); + comments +} + +pub(crate) fn azure_web_url(identity: &ForgeIdentity, number: i64) -> String { + let project = identity.project.as_deref().unwrap_or(""); + let number = number.to_string(); + let segments = [ + project, + "_git", + identity.repo.as_str(), + "pullrequest", + &number, + ]; + let Ok(mut url) = url::Url::parse(&identity.azure_org_url()) else { + return format!("{}/{}", identity.azure_org_url(), segments.join("/")); + }; + if let Ok(mut path) = url.path_segments_mut() { + path.pop_if_empty().extend(segments); + } + url.to_string() +} + +pub(crate) fn short_azure_ref(reference: Option<&str>) -> Option { + reference.map(|reference| { + reference + .strip_prefix("refs/heads/") + .unwrap_or(reference) + .to_string() + }) +} + +pub(crate) fn azure_review(identity: &ForgeIdentity, value: &Value) -> Review { + let number = value["pullRequestId"].as_i64().unwrap_or(0); + let status = value["status"] + .as_str() + .unwrap_or("active") + .to_ascii_lowercase(); + Review { + number, + title: value["title"].as_str().unwrap_or("").to_string(), + state: match status.as_str() { + "completed" => "MERGED", + "abandoned" => "CLOSED", + _ => "OPEN", + }, + url: azure_web_url(identity, number), + is_draft: value["isDraft"].as_bool().unwrap_or(false), + author: text(&value["createdBy"], "displayName"), + head_branch: short_azure_ref(value["sourceRefName"].as_str()), + base_branch: short_azure_ref(value["targetRefName"].as_str()), + created_at: text(value, "creationDate"), + mergeable: match value["mergeStatus"] + .as_str() + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("succeeded") => "MERGEABLE", + Some("conflicts") => "CONFLICTING", + _ => "UNKNOWN", + }, + head_sha: text(&value["lastMergeSourceCommit"], "commitId"), + } +} + +pub(crate) fn azure_policy_name(evaluation: &Value) -> String { + text(&evaluation["configuration"]["type"], "displayName").unwrap_or_else(|| "Policy".into()) +} + +/// One policy evaluation as a check, like `mapAzureCheck`. +pub(crate) fn azure_check(identity: &ForgeIdentity, evaluation: &Value) -> Value { + let status = evaluation["status"] + .as_str() + .unwrap_or("") + .to_ascii_lowercase(); + let bucket = match status.as_str() { + "approved" => "pass", + "rejected" => "fail", + "queued" | "running" => "pending", + // `notApplicable` and anything Alera does not know read as neutral. + _ => "skipping", + }; + let url = evaluation["context"]["buildId"].as_i64().map(|build| { + format!( + "{}/{}/_build/results?buildId={build}", + identity.azure_org_url(), + identity.project.as_deref().unwrap_or("") + ) + }); + check_json( + &azure_policy_name(evaluation), + &status, + bucket, + url.as_deref(), + ) +} + +/// Comments of every live thread, without system comments, oldest first. +pub(crate) fn azure_comments(threads: &Value) -> Vec { + let threads = threads["value"] + .as_array() + .or_else(|| threads.as_array()) + .cloned() + .unwrap_or_default(); + let mut comments = Vec::new(); + for thread in threads.iter().filter(|thread| thread["isDeleted"] != true) { + let thread_id = match &thread["id"] { + Value::Number(id) => id.to_string(), + Value::String(id) => id.clone(), + _ => continue, + }; + let context = &thread["threadContext"]; + let path = text(context, "filePath"); + let line = context["rightFileStart"]["line"].as_i64(); + let status = match &thread["status"] { + Value::String(status) => status.to_ascii_lowercase(), + other => other.to_string().to_ascii_lowercase(), + }; + let resolved = matches!(status.as_str(), "fixed" | "closed" | "bydesign" | "wontfix"); + for comment in thread["comments"].as_array().into_iter().flatten() { + let system = comment["commentType"] == 3 + || comment["commentType"] + .as_str() + .is_some_and(|kind| kind.eq_ignore_ascii_case("system")); + if comment["isDeleted"] == true || system { + continue; + } + let author = &comment["author"]; + comments.push(json!({ + "id": comment["id"].as_i64().unwrap_or(0), + "author": text(author, "displayName").or_else(|| text(author, "uniqueName")), + "body": comment["content"].as_str().unwrap_or(""), + "createdAt": text(comment, "publishedDate"), + "url": Value::Null, + "kind": if path.is_some() { "review" } else { "conversation" }, + "source": if path.is_some() { "reviewThread" } else { "conversation" }, + "path": path, + "line": line, + "resolved": resolved, + "outdated": false, + "threadId": thread_id, + })); + } + } + sort_by_created_at(&mut comments); + comments +} + +fn sort_by_created_at(comments: &mut [Value]) { + comments.sort_by(|a, b| { + let created = |value: &Value| value["createdAt"].as_str().unwrap_or("").to_owned(); + created(a).cmp(&created(b)) + }); +} + +/// The `az devops invoke` body that opens a conversation thread. +pub(crate) fn azure_thread_body(body: &str) -> Value { + json!({ + "comments": [{ "parentCommentId": 0, "content": body, "commentType": 1 }], + "status": 1, + }) +} + +/// The body of a reply in an existing thread. +pub(crate) fn azure_reply_body(body: &str, parent_comment_id: i64) -> Value { + json!({ "content": body, "parentCommentId": parent_comment_id, "commentType": 1 }) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/mod.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/mod.rs new file mode 100644 index 000000000..a7e61c4ac --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/mod.rs @@ -0,0 +1,170 @@ +//! Pull requests on GitHub, GitLab, and Azure DevOps behind one +//! `ForgeProvider` (docs/mcp-parity-implementation-plan.md, section 6.4). +//! +//! The `mobile.pullRequest.*` verbs, Ship, Watch and Fix, stacks, and the +//! agent dispatch prompts all go through this module, so the phone, the CLI, +//! MCP, and the desktop get one behavior per forge. Each forge runs through its +//! own CLI (`gh`, `glab`, `az`) on the host that owns the checkout, so Alera +//! never holds a forge token. Everything here is additive to the strict +//! terminal-host and mobile protocols and is advertised by capability. + +mod actions; +mod agent_dispatch; +mod azure; +mod azure_requests; +mod github; +mod gitlab; +mod identity; +mod input_file; +mod links; +mod local_command; +mod mappers; +mod model; +mod provider; +mod runner; +mod snapshot; +mod stack_actions; +mod stack_requests; +mod summaries; + +#[cfg(test)] +mod fixture_tests; +#[cfg(test)] +mod forge_override_tests; +#[cfg(test)] +mod provider_security_tests; +#[cfg(test)] +mod provider_tests; + +use std::sync::Arc; + +use alera_core::git as core_git; +use alera_core::runtime::{RuntimeStore, Workspace}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +pub(crate) use actions::run_forge_action; +#[cfg(test)] +pub(crate) use actions::{parse_forge_action, ForgeAction}; +pub(crate) use identity::{resolve_identity, ForgeIdentity, ForgeKind}; +pub(crate) use links::create_and_link; +#[cfg(test)] +pub(crate) use model::MergeMethod as ForgeMergeMethod; +pub(crate) use model::{preferred_merge_method, MergeMethod}; +#[cfg(test)] +pub(crate) use provider::CommentSource as ForgeCommentSource; +pub(crate) use provider::{CreateInput, ForgeProvider}; +pub(crate) use runner::{ForgeRunner, WorkspaceRunner}; +pub(crate) use snapshot::{load_snapshot, snapshot_forge}; +pub(crate) use stack_requests::{handle_stack_request, is_stack_verb}; +pub(crate) use summaries::forge_project_summaries; + +/// A provider for [identity]. Without [runner] the CLI runs in [repo_path] on +/// this machine. +pub(crate) fn build_forge( + identity: ForgeIdentity, + remote_url: &str, + repo_path: &str, + runner: Option>, +) -> Box { + match identity.kind { + ForgeKind::GitHub => { + let runner = runner.unwrap_or_else(|| { + Arc::new(github::GhRunner { + cwd: repo_path.to_string(), + }) + }); + Box::new(github::GitHubForge { + github: identity.github(remote_url), + identity, + repo_path: repo_path.to_string(), + runner, + }) + } + ForgeKind::GitLab => Box::new(gitlab::GitLabForge { + identity, + runner: runner.unwrap_or_else(|| local_runner(repo_path)), + }), + ForgeKind::AzureDevOps => Box::new(azure::AzureDevOpsForge { + identity, + runner: runner.unwrap_or_else(|| local_runner(repo_path)), + }), + } +} + +fn local_runner(repo_path: &str) -> Arc { + Arc::new(runner::LocalRunner { + cwd: repo_path.to_string(), + }) +} + +/// What a checkout says about its forge: the branch, the remote, and the +/// provider (forced by the project's setting when it has one). +#[derive(Debug, Clone, Default)] +pub(crate) struct WorkspaceRemote { + pub(crate) branch: Option, + pub(crate) remote_url: Option, + pub(crate) identity: Option, +} + +pub(crate) async fn read_workspace_remote( + store: &RuntimeStore, + workspace: &Workspace, +) -> HostResult { + let path = workspace.path.clone(); + let (branch, remote_url) = tokio::task::spawn_blocking(move || { + ( + core_git::current_branch(&path).ok(), + core_git::repository_remote_url(&path).ok().flatten(), + ) + }) + .await + .map_err(|error| HostError::state(format!("Could not read the git remote: {error}")))?; + let forced = project_forge_override(store, &workspace.project_id).await; + let identity = remote_url + .as_deref() + .and_then(|url| identity::resolve_identity(url, forced)); + Ok(WorkspaceRemote { + branch, + remote_url, + identity, + }) +} + +/// The provider a project forces (`gitHostingProvider`): the Settings +/// override, else the repository's `alera.toml`, as the app resolves it. +pub(crate) async fn project_forge_override( + store: &RuntimeStore, + project_id: &str, +) -> Option { + let effective = crate::project_management::effective_project_config(store, project_id) + .await + .ok()?; + ForgeKind::from_wire(effective.config.git_hosting_provider.as_deref()?) +} + +/// The forge of a workspace whose checkout is on this machine. +pub(crate) async fn workspace_forge( + store: &RuntimeStore, + workspace: &Workspace, +) -> HostResult<(Box, WorkspaceRemote)> { + let remote = read_workspace_remote(store, workspace).await?; + let (Some(identity), Some(url)) = (remote.identity.clone(), remote.remote_url.as_deref()) + else { + return Err(HostError::state( + "No GitHub, GitLab, or Azure DevOps remote was detected for this workspace.", + )); + }; + Ok((build_forge(identity, url, &workspace.path, None), remote)) +} + +/// Refuses work the CLI would do on the wrong machine. +pub(crate) fn require_local_workspace(workspace: &Workspace) -> HostResult<()> { + let host_id = workspace.host_id.trim(); + if !host_id.is_empty() && host_id != alera_core::runtime::LOCAL_HOST_ID { + return Err(HostError::state( + "Pull request actions are only available for workspaces on this runtime.", + )); + } + Ok(()) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/model.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/model.rs new file mode 100644 index 000000000..4414f8aaa --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/model.rs @@ -0,0 +1,233 @@ +//! The provider-neutral shapes every forge maps into: the review object and +//! `checks[]` of the GitHub snapshot the phone, CLI, MCP, and Watch and Fix +//! already read, merge methods, and the errors a missing CLI produces. + +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::HostError; + +use super::identity::ForgeKind; + +/// One review as the snapshot carries it: `state` is `OPEN`, `CLOSED`, or +/// `MERGED`; `mergeable` is `MERGEABLE`, `CONFLICTING`, or `UNKNOWN`. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct Review { + pub(crate) number: i64, + pub(crate) title: String, + pub(crate) state: &'static str, + pub(crate) url: String, + pub(crate) is_draft: bool, + pub(crate) author: Option, + pub(crate) head_branch: Option, + pub(crate) base_branch: Option, + pub(crate) created_at: Option, + pub(crate) mergeable: &'static str, + pub(crate) head_sha: Option, +} + +impl Review { + pub(crate) fn to_json(&self) -> Value { + json!({ + "number": self.number, + "title": self.title, + "state": self.state, + "url": self.url, + "isDraft": self.is_draft, + "author": self.author, + "headRefName": self.head_branch, + "baseRefName": self.base_branch, + "createdAt": self.created_at, + "mergeable": self.mergeable, + "headSha": self.head_sha, + }) + } + + pub(crate) fn is_open(&self) -> bool { + self.state == "OPEN" + } +} + +/// The newest review, like the desktop's `pickNewestHostedReview`. +pub(crate) fn newest(reviews: impl IntoIterator) -> Option { + reviews + .into_iter() + .max_by(|a, b| a.created_at.as_deref().cmp(&b.created_at.as_deref())) +} + +/// A check in the snapshot's shape. `bucket` uses `gh pr checks` names (`pass`, +/// `fail`, `pending`, `skipping`, `cancel`) so Watch and Fix and the summary +/// counts read every forge the same way; `state` keeps the forge's own word. +pub(crate) fn check_json(name: &str, state: &str, bucket: &str, url: Option<&str>) -> Value { + json!({ "name": name, "state": state, "bucket": bucket, "url": url }) +} + +/// `(name, failed, pending)` for the summary counts, from a normalized check. +pub(crate) fn classify_check(check: &Value) -> (String, bool, bool) { + let bucket = check["bucket"].as_str().unwrap_or(""); + ( + check["name"].as_str().unwrap_or("check").to_string(), + matches!(bucket, "fail" | "cancel"), + bucket == "pending", + ) +} + +/// Merge methods by their wire names. `providerDefault` lets the forge's +/// project settings decide, which is GitLab's ordinary merge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MergeMethod { + MergeCommit, + Squash, + Rebase, + ProviderDefault, +} + +impl MergeMethod { + pub(crate) fn parse(value: &str) -> Option { + match value { + "mergeCommit" | "merge" | "noFastForward" => Some(Self::MergeCommit), + "squash" => Some(Self::Squash), + "rebase" => Some(Self::Rebase), + "providerDefault" => Some(Self::ProviderDefault), + _ => None, + } + } + + pub(crate) fn wire(self) -> &'static str { + match self { + Self::MergeCommit => "mergeCommit", + Self::Squash => "squash", + Self::Rebase => "rebase", + Self::ProviderDefault => "providerDefault", + } + } +} + +/// The methods a forge offers when it has no per-repository rules to read, +/// ported from the desktop providers' `allowedMergeMethods`. +pub(crate) fn fixed_merge_methods(kind: ForgeKind) -> &'static [&'static str] { + match kind { + ForgeKind::GitHub => &["mergeCommit", "squash", "rebase"], + ForgeKind::GitLab => &["providerDefault", "squash"], + ForgeKind::AzureDevOps => &["mergeCommit", "squash"], + } +} + +/// The method an automatic merge uses: provider-default first, then the first +/// one offered, like `preferredReviewMergeMethod`. +pub(crate) fn preferred_merge_method(methods: &[&str]) -> Option { + if methods.contains(&"providerDefault") { + return Some(MergeMethod::ProviderDefault); + } + methods.iter().find_map(|method| MergeMethod::parse(method)) +} + +/// The forge CLI is missing, its extension is missing, or nobody signed in. +pub(crate) fn provider_unavailable(kind: ForgeKind, signed_out: bool) -> HostError { + let message = match (kind, signed_out) { + (ForgeKind::GitHub, false) => { + "Install and authenticate the GitHub CLI (gh) on the computer that owns this checkout." + } + (ForgeKind::GitHub, true) => { + "Sign in with gh auth login on the computer that owns this checkout." + } + (ForgeKind::GitLab, false) => { + "Install and authenticate the GitLab CLI (glab) on the computer that owns this checkout." + } + (ForgeKind::GitLab, true) => { + "Sign in with glab auth login on the computer that owns this checkout." + } + (ForgeKind::AzureDevOps, false) => { + "Install and authenticate the Azure CLI (az) with the azure-devops extension on the computer that owns this checkout." + } + (ForgeKind::AzureDevOps, true) => { + "Sign in with az login on the computer that owns this checkout." + } + }; + HostError::conflict( + "provider_unavailable", + message, + json!({ "provider": kind.wire(), "cli": kind.cli() }), + ) +} + +/// A feature Alera only has for some forges, such as stacks outside GitHub. +pub(crate) fn provider_unsupported(message: impl Into) -> HostError { + HostError::conflict("provider_unsupported", message, json!({})) +} + +/// A forge CLI call that failed for a reason other than a missing CLI. +pub(crate) fn forge_failure(kind: ForgeKind, stderr: &str, stdout: &str) -> HostError { + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + let lower = detail.to_ascii_lowercase(); + if lower.contains("already exists") + || lower.contains("another open merge request") + || lower.contains("active pull request") + { + return HostError::conflict( + "alreadyExists", + format!("A {} already exists for this branch.", kind.review_noun()), + json!({ "detail": detail }), + ); + } + let message = if detail.is_empty() { + format!("The {} CLI ({}) failed.", kind.label(), kind.cli()) + } else { + detail.to_string() + }; + HostError::conflict("forgeFailed", message, json!({ "detail": detail })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefers_the_provider_default_then_the_first_method() { + assert_eq!( + preferred_merge_method(fixed_merge_methods(ForgeKind::GitLab)), + Some(MergeMethod::ProviderDefault) + ); + assert_eq!( + preferred_merge_method(&["squash", "rebase"]), + Some(MergeMethod::Squash) + ); + assert_eq!(preferred_merge_method(&[]), None); + assert_eq!( + MergeMethod::parse("noFastForward"), + Some(MergeMethod::MergeCommit) + ); + assert_eq!(MergeMethod::parse("fast"), None); + } + + #[test] + fn unavailable_providers_name_the_cli_to_install() { + for kind in ForgeKind::ALL { + for signed_out in [false, true] { + let error = provider_unavailable(kind, signed_out); + assert_eq!(error.wire_response(1)["errorCode"], "provider_unavailable"); + assert!( + error.wire_message().contains(&format!("{} ", kind.cli())) + || error.wire_message().contains(&format!("({})", kind.cli())) + ); + } + } + } + + #[test] + fn newest_review_wins() { + let review = |number, created: &str| Review { + number, + created_at: Some(created.into()), + ..Review::default() + }; + let picked = newest([ + review(1, "2026-07-10T00:00:00Z"), + review(2, "2026-07-11T00:00:00Z"), + ]); + assert_eq!(picked.unwrap().number, 2); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider.rs new file mode 100644 index 000000000..ed0ba0dea --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider.rs @@ -0,0 +1,176 @@ +//! `ForgeProvider`: every pull request operation the runtime performs, one +//! implementation per forge. The snapshot, the write verbs, Ship, Watch and +//! Fix, and stacks call only this surface, so GitHub, GitLab, and Azure DevOps +//! behave the same way to every client. + +use async_trait::async_trait; +use serde_json::Value; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::identity::{ForgeIdentity, ForgeKind}; +use super::model::{MergeMethod, Review}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AuthStatus { + Authenticated, + NotAuthenticated, + CliMissing, +} + +impl AuthStatus { + pub(crate) fn wire(self) -> &'static str { + match self { + Self::Authenticated => "authenticated", + Self::NotAuthenticated => "notAuthenticated", + Self::CliMissing => "cliMissing", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CreateInput { + pub(crate) base: String, + pub(crate) head: String, + pub(crate) title: String, + pub(crate) body: String, + pub(crate) draft: bool, +} + +/// What a create answered: enough to link the new review. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Created { + pub(crate) number: i64, + pub(crate) url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommentSource { + Conversation, + ReviewSummary, + ReviewThread, +} + +impl CommentSource { + pub(crate) fn parse(value: &str) -> HostResult { + match value { + "conversation" => Ok(Self::Conversation), + "reviewSummary" => Ok(Self::ReviewSummary), + "reviewThread" => Ok(Self::ReviewThread), + other => Err(HostError::state(format!("Unknown comment source: {other}"))), + } + } +} + +/// Addresses one comment. `thread_id` is the GitLab discussion or Azure +/// DevOps thread; GitHub does not need it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CommentLocator { + pub(crate) source: CommentSource, + pub(crate) comment_id: i64, + pub(crate) thread_id: Option, +} + +#[async_trait] +pub(crate) trait ForgeProvider: Send + Sync { + fn identity(&self) -> &ForgeIdentity; + + fn kind(&self) -> ForgeKind { + self.identity().kind + } + + async fn auth_status(&self) -> HostResult; + + async fn review_by_number(&self, number: i64) -> HostResult>; + + /// The newest open review whose head is [branch]. + async fn review_for_branch(&self, branch: &str) -> HostResult>; + + /// Checks, pipelines, or policies in the snapshot's `checks[]` shape. A + /// failed read yields nothing so one source never hides the review. + async fn checks(&self, number: i64) -> Vec; + + /// Comments in the snapshot's shape and whether the list was cut short. + async fn comments(&self, number: i64) -> (Vec, bool); + + /// Who is signed in, so only their own comments are editable. + async fn viewer(&self) -> Option; + + /// Wire names of the merge methods this review may use. + async fn merge_methods(&self, base_branch: Option<&str>) -> Result, String>; + + async fn create(&self, input: &CreateInput) -> HostResult; + + /// A new comment, or a reply to [reply_to]. + async fn comment( + &self, + number: i64, + body: &str, + reply_to: Option<&CommentLocator>, + ) -> HostResult<()>; + + async fn update_comment( + &self, + number: i64, + locator: &CommentLocator, + body: &str, + ) -> HostResult<()>; + + /// Merges, only while the head is still [expected_head] when one is given. + async fn merge( + &self, + number: i64, + method: MergeMethod, + expected_head: Option<&str>, + ) -> HostResult<()>; + + async fn set_draft(&self, number: i64, draft: bool) -> HostResult<()>; + + async fn close(&self, number: i64) -> HostResult<()>; + + /// The review number a link names: `123`, `#123`, or a review URL of this + /// repository. + fn review_reference(&self, input: &str) -> HostResult; +} + +/// `123` or `#123`, shared by every forge's `review_reference`. +pub(crate) fn plain_review_number(input: &str) -> Option> { + let input = input.trim(); + let digits = input.strip_prefix('#').unwrap_or(input); + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + Some( + digits + .parse::() + .ok() + .filter(|number| *number > 0) + .ok_or_else(|| HostError::state("Enter a positive pull request number.")), + ) +} + +/// The path segments of a review URL on [host], or an error naming the +/// repository rule. +pub(crate) fn url_segments(input: &str, host: &str) -> HostResult> { + let url = url::Url::parse(input.trim()) + .map_err(|_| HostError::state("Enter a pull request number or URL."))?; + let url_host = match url.port() { + Some(port) => format!("{}:{port}", url.host_str().unwrap_or_default()), + None => url.host_str().unwrap_or_default().to_string(), + }; + if !matches!(url.scheme(), "https" | "http") + || !url.username().is_empty() + || url.password().is_some() + || !url_host.eq_ignore_ascii_case(host) + { + return Err(foreign_review_url()); + } + Ok(url + .path_segments() + .map(|segments| segments.map(super::identity::decode_segment).collect()) + .unwrap_or_default()) +} + +pub(crate) fn foreign_review_url() -> HostError { + HostError::state("The pull request URL must belong to this workspace repository.") +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider_security_tests.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider_security_tests.rs new file mode 100644 index 000000000..6ebf46f6e --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider_security_tests.rs @@ -0,0 +1,203 @@ +//! Free text never reaches a forge CLI's command line, azure-cli never sees +//! an `@file` argument, and request body files never outlive their call. + +use std::sync::Arc; + +use super::azure::AzureDevOpsForge; +use super::gitlab::GitLabForge; +use super::identity::resolve_identity; +use super::model::MergeMethod; +use super::provider::{CommentLocator, CommentSource, CreateInput, ForgeProvider}; +use super::runner::fake::{ok, FakeRunner}; +use super::runner::ForgeOutput; + +const HOSTILE_TITLE: &str = "Fix \" & calc & %PATH% $(id) `id` | more"; +const HOSTILE_BODY: &str = "@/etc/passwd\r\n^& del /q *"; +const HOSTILE_COMMENT: &str = "\"&calc&\" %USERPROFILE%"; + +fn hostile_input() -> CreateInput { + CreateInput { + base: "main&calc".into(), + head: "feat/%PATH%|x".into(), + title: HOSTILE_TITLE.into(), + body: HOSTILE_BODY.into(), + draft: false, + } +} + +fn reply() -> CommentLocator { + CommentLocator { + source: CommentSource::ReviewThread, + comment_id: 5, + thread_id: Some("7".into()), + } +} + +fn assert_no_free_text(runner: &FakeRunner) { + let input = hostile_input(); + let free_text = [ + HOSTILE_TITLE, + HOSTILE_BODY, + HOSTILE_COMMENT, + &input.head, + &input.base, + ]; + for call in runner.calls() { + for arg in &call.args { + for text in free_text { + assert!( + !arg.contains(text), + "{text:?} reached argv: {:?}", + call.args + ); + } + } + } +} + +fn gitlab(outputs: Vec) -> (GitLabForge, Arc) { + let runner = Arc::new(FakeRunner::new(outputs)); + let identity = resolve_identity("git@gitlab.com:group/app.git", None).unwrap(); + ( + GitLabForge { + identity, + runner: runner.clone(), + }, + runner, + ) +} + +fn azure(runner: FakeRunner) -> (AzureDevOpsForge, Arc) { + let runner = Arc::new(runner); + let identity = + resolve_identity("https://dev.azure.com/myorg/myproject/_git/myrepo", None).unwrap(); + ( + AzureDevOpsForge { + identity, + runner: runner.clone(), + }, + runner, + ) +} + +#[tokio::test] +async fn gitlab_free_text_travels_on_stdin_only() { + let (forge, runner) = gitlab(vec![ok(r#"{"iid":3}"#), ok("{}"), ok("{}"), ok("{}")]); + let created = forge.create(&hostile_input()).await.unwrap(); + assert_eq!(created.number, 3); + forge.comment(3, HOSTILE_COMMENT, None).await.unwrap(); + forge + .comment(3, HOSTILE_COMMENT, Some(&reply())) + .await + .unwrap(); + forge + .update_comment(3, &reply(), HOSTILE_COMMENT) + .await + .unwrap(); + assert_no_free_text(&runner); + let calls = runner.calls(); + let create: serde_json::Value = + serde_json::from_str(calls[0].stdin.as_deref().unwrap()).unwrap(); + assert_eq!(create["title"], HOSTILE_TITLE); + assert_eq!(create["description"], HOSTILE_BODY); + assert_eq!(create["source_branch"], "feat/%PATH%|x"); + for call in &calls[1..] { + let body: serde_json::Value = serde_json::from_str(call.stdin.as_deref().unwrap()).unwrap(); + assert_eq!(body["body"], HOSTILE_COMMENT); + assert_eq!(call.option("input"), Some("-")); + } +} + +#[tokio::test] +async fn azure_free_text_travels_in_removed_body_files() { + let created = r#"{"pullRequestId":43,"status":"active"}"#; + let (forge, runner) = azure(FakeRunner::new([ok(created), ok("{}"), ok("{}"), ok("{}")])); + assert_eq!(forge.create(&hostile_input()).await.unwrap().number, 43); + forge.comment(43, HOSTILE_COMMENT, None).await.unwrap(); + forge + .comment(43, HOSTILE_COMMENT, Some(&reply())) + .await + .unwrap(); + forge + .update_comment(43, &reply(), HOSTILE_COMMENT) + .await + .unwrap(); + assert_no_free_text(&runner); + let calls = runner.calls(); + for (index, call) in calls.iter().enumerate() { + assert_eq!(call.args[..2], ["devops", "invoke"]); + let path = call.option("in-file").unwrap(); + assert!( + !std::path::Path::new(path).exists(), + "call {index} left {path}" + ); + assert!(runner.in_file(index).is_some(), "call {index} had a body"); + } + let create: serde_json::Value = serde_json::from_str(&runner.in_file(0).unwrap()).unwrap(); + assert_eq!(create["title"], HOSTILE_TITLE); + assert_eq!(create["description"], HOSTILE_BODY); + assert_eq!(create["sourceRefName"], "refs/heads/feat/%PATH%|x"); + assert!(runner.in_file(3).unwrap().contains("USERPROFILE")); +} + +#[tokio::test] +async fn azure_never_passes_an_at_argument() { + let (forge, runner) = azure(FakeRunner::new([ok("[]")])); + assert!(forge.review_for_branch("@secrets").await.unwrap().is_none()); + assert_eq!( + runner.calls()[0].option("source-branch"), + Some("refs/heads/@secrets") + ); + + let (mut forge, runner) = azure(FakeRunner::new([ok("[]")])); + forge.identity.project = Some("@/etc/passwd".into()); + let error = forge.review_for_branch("main").await.unwrap_err(); + assert!(error + .wire_message() + .contains("azure-cli would read it as a file")); + assert!(runner.calls().is_empty(), "az never ran"); +} + +#[tokio::test] +async fn azure_merge_binds_the_head_on_the_server() { + let current = r#"{"pullRequestId":43,"lastMergeSourceCommit":{"commitId":"abc"}, + "completionOptions":{"deleteSourceBranch":true}}"#; + let (forge, runner) = azure(FakeRunner::new([ok(current), ok("{}")])); + forge + .merge(43, MergeMethod::Squash, Some("abc")) + .await + .unwrap(); + let calls = runner.calls(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[1].option("http-method"), Some("PATCH")); + assert!(!std::path::Path::new(calls[1].option("in-file").unwrap()).exists()); + let body: serde_json::Value = serde_json::from_str(&runner.in_file(1).unwrap()).unwrap(); + assert_eq!(body["lastMergeSourceCommit"]["commitId"], "abc"); + assert_eq!(body["status"], "completed"); + assert_eq!(body["completionOptions"]["deleteSourceBranch"], true); + assert_eq!(body["completionOptions"]["mergeStrategy"], "squash"); +} + +#[tokio::test] +async fn a_remote_checkout_gets_its_body_on_stdin_and_keeps_the_head_guard() { + let current = r#"{"pullRequestId":43,"lastMergeSourceCommit":{"commitId":"abc"}}"#; + let (forge, runner) = azure(FakeRunner::remote([ok(current), ok("{}")])); + forge + .merge(43, MergeMethod::Squash, Some("abc")) + .await + .unwrap(); + let calls = runner.calls(); + assert_eq!(calls[1].option("http-method"), Some("PATCH")); + assert_eq!(calls[1].option("in-file"), Some("/dev/stdin")); + let body: serde_json::Value = + serde_json::from_str(calls[1].stdin.as_deref().expect("a body on stdin")).unwrap(); + assert_eq!(body["lastMergeSourceCommit"]["commitId"], "abc"); + assert_eq!(body["status"], "completed"); + + let (forge, runner) = azure(FakeRunner::remote([ok("{}")])); + forge.comment(43, "hi & %PATH%", None).await.unwrap(); + let calls = runner.calls(); + assert_eq!(calls[0].option("in-file"), Some("/dev/stdin")); + assert!(calls[0].stdin.as_deref().unwrap().contains("hi & %PATH%")); + assert!(calls[0].args.iter().all(|arg| !arg.contains("%PATH%"))); +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider_tests.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider_tests.rs new file mode 100644 index 000000000..839a1f6a8 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/provider_tests.rs @@ -0,0 +1,344 @@ +//! Write commands and failure classification of every provider against a +//! fake runner: no forge CLI or network is touched. + +use std::sync::Arc; + +use super::super::mobile_pull_request_identity::GitHubIdentity; +use super::azure::AzureDevOpsForge; +use super::github::GitHubForge; +use super::gitlab::{merge_request_number, GitLabForge}; +use super::identity::{resolve_identity, ForgeIdentity, ForgeKind}; +use super::model::MergeMethod; +use super::provider::{AuthStatus, CommentLocator, CommentSource, CreateInput, ForgeProvider}; +use super::runner::fake::{failed, ok, FakeRunner}; +use super::runner::ForgeOutput; + +fn gitlab(outputs: Vec) -> (GitLabForge, Arc) { + let runner = Arc::new(FakeRunner::new(outputs)); + let identity = resolve_identity("git@gitlab.com:group/sub/app.git", None).unwrap(); + ( + GitLabForge { + identity, + runner: runner.clone(), + }, + runner, + ) +} + +fn azure(outputs: Vec) -> (AzureDevOpsForge, Arc) { + let runner = Arc::new(FakeRunner::new(outputs)); + let identity = + resolve_identity("https://dev.azure.com/myorg/myproject/_git/myrepo", None).unwrap(); + ( + AzureDevOpsForge { + identity, + runner: runner.clone(), + }, + runner, + ) +} + +fn input(draft: bool) -> CreateInput { + CreateInput { + base: "main".into(), + head: "feat/x".into(), + title: "Add X".into(), + body: "Why".into(), + draft, + } +} + +fn stdin_json(call: &super::runner::fake::RecordedCall) -> serde_json::Value { + serde_json::from_str(call.stdin.as_deref().expect("a body on stdin")).unwrap() +} + +fn in_file_json(runner: &FakeRunner, index: usize) -> serde_json::Value { + serde_json::from_str(&runner.in_file(index).expect("an --in-file body")).unwrap() +} + +fn error_code(error: crate::terminal_host::host_error::HostError) -> String { + error.wire_response(1)["errorCode"] + .as_str() + .unwrap_or("") + .to_string() +} + +#[tokio::test] +async fn gitlab_writes_run_the_desktop_glab_commands() { + let (forge, runner) = gitlab(vec![ + ok(r#"{"iid":12,"web_url":"https://gitlab.com/group/sub/app/-/merge_requests/12"}"#), + ok(""), + ok(""), + ok(""), + ok("{}"), + ok("{}"), + ]); + let created = forge.create(&input(true)).await.unwrap(); + assert_eq!(created.number, 12); + assert_eq!( + created.url.as_deref(), + Some("https://gitlab.com/group/sub/app/-/merge_requests/12") + ); + forge + .merge(12, MergeMethod::Squash, Some("abc")) + .await + .unwrap(); + forge.set_draft(12, false).await.unwrap(); + forge.close(12).await.unwrap(); + forge.comment(12, "Ready", None).await.unwrap(); + let reply = CommentLocator { + source: CommentSource::ReviewThread, + comment_id: 5, + thread_id: Some("d1".into()), + }; + forge + .update_comment(12, &reply, "- [x] done") + .await + .unwrap(); + let calls = runner.calls(); + let repo = "https://gitlab.com/group/sub/app"; + assert_eq!( + calls[0].args, + [ + "api", + "projects/group%2Fsub%2Fapp/merge_requests", + "--hostname", + "gitlab.com", + "--method", + "POST", + "--header", + "Content-Type: application/json", + "--input", + "-" + ] + ); + assert_eq!( + stdin_json(&calls[0]), + serde_json::json!({ + "source_branch": "feat/x", + "target_branch": "main", + "title": "Draft: Add X", + "description": "Why", + }) + ); + assert_eq!( + calls[1].args, + [ + "mr", + "merge", + "12", + "--repo", + repo, + "--squash", + "--sha", + "abc", + "--auto-merge=false", + "--yes" + ] + ); + assert_eq!( + calls[2].args, + ["mr", "update", "12", "--repo", repo, "--ready", "--yes"] + ); + assert_eq!(calls[3].args, ["mr", "close", "12", "--repo", repo]); + assert_eq!( + calls[4].args[1], + "projects/group%2Fsub%2Fapp/merge_requests/12/notes" + ); + assert_eq!(calls[4].option("method"), Some("POST")); + assert_eq!(calls[4].option("input"), Some("-")); + assert_eq!( + stdin_json(&calls[4]), + serde_json::json!({ "body": "Ready" }) + ); + assert_eq!(calls[4].option("hostname"), Some("gitlab.com")); + assert_eq!( + calls[5].args[1], + "projects/group%2Fsub%2Fapp/merge_requests/12/discussions/d1/notes/5" + ); + assert_eq!(calls[5].option("method"), Some("PUT")); + assert_eq!( + stdin_json(&calls[5]), + serde_json::json!({ "body": "- [x] done" }) + ); + assert!(forge.merge(12, MergeMethod::Rebase, None).await.is_err()); +} + +#[tokio::test] +async fn azure_writes_run_the_desktop_az_commands() { + let created = r#"{"pullRequestId":43,"title":"Add X","status":"active"}"#; + let (forge, runner) = azure(vec![ + ok(created), + ok(r#"{"pullRequestId":43,"lastMergeSourceCommit":{"commitId":"abc"}}"#), + ok("{}"), + ok(""), + ok(""), + ok("{}"), + ]); + let created = forge.create(&input(true)).await.unwrap(); + assert_eq!(created.number, 43); + assert_eq!( + created.url.as_deref(), + Some("https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/43") + ); + forge + .merge(43, MergeMethod::MergeCommit, Some("abc")) + .await + .unwrap(); + forge.set_draft(43, true).await.unwrap(); + forge.close(43).await.unwrap(); + forge.comment(43, "Looks good", None).await.unwrap(); + let calls = runner.calls(); + assert_eq!(calls[0].args[..2], ["devops", "invoke"]); + assert_eq!(calls[0].option("resource"), Some("pullRequests")); + assert_eq!(calls[0].option("http-method"), Some("POST")); + assert_eq!( + in_file_json(&runner, 0), + serde_json::json!({ + "sourceRefName": "refs/heads/feat/x", + "targetRefName": "refs/heads/main", + "title": "Add X", + "description": "Why", + "isDraft": true, + }) + ); + assert_eq!(calls[1].args[..3], ["repos", "pr", "show"]); + assert_eq!(calls[2].option("resource"), Some("pullRequests")); + assert_eq!(calls[2].option("http-method"), Some("PATCH")); + assert!(calls[2].args.contains(&"pullRequestId=43".to_string())); + let completion = in_file_json(&runner, 2); + assert_eq!(completion["status"], "completed"); + assert_eq!(completion["lastMergeSourceCommit"]["commitId"], "abc"); + assert_eq!(completion["completionOptions"]["squashMerge"], false); + assert_eq!(calls[3].option("draft"), Some("true")); + assert_eq!(calls[4].option("status"), Some("abandoned")); + assert_eq!(calls[5].option("resource"), Some("pullRequestThreads")); + assert_eq!(calls[5].option("http-method"), Some("POST")); + let body_file = calls[5].option("in-file").unwrap(); + assert!( + !std::path::Path::new(body_file).exists(), + "the body file is removed" + ); + assert!(forge.merge(43, MergeMethod::Rebase, None).await.is_err()); +} + +#[tokio::test] +async fn azure_refuses_to_merge_a_moved_head() { + let (forge, runner) = azure(vec![ok( + r#"{"pullRequestId":43,"lastMergeSourceCommit":{"commitId":"new"}}"#, + )]); + let error = forge + .merge(43, MergeMethod::Squash, Some("old")) + .await + .unwrap_err(); + assert!(error.wire_message().contains("head changed")); + assert_eq!(runner.calls().len(), 1, "nothing was completed"); +} + +#[tokio::test] +async fn missing_or_signed_out_clis_are_provider_unavailable() { + let (forge, _) = gitlab(vec![failed(127, "glab: command not found")]); + let error = forge.close(1).await.unwrap_err(); + assert!(error.wire_message().contains("(glab)")); + assert_eq!(error_code(error), "provider_unavailable"); + let (forge, _) = gitlab(vec![failed(1, "401 Unauthorized")]); + let error = forge.close(1).await.unwrap_err(); + assert!(error.wire_message().contains("glab auth login")); + let (forge, _) = azure(vec![failed(1, "Please run 'az login' to setup account.")]); + let error = forge.close(1).await.unwrap_err(); + assert!(error.wire_message().contains("az login")); + assert_eq!(error_code(error), "provider_unavailable"); + let (forge, _) = azure(vec![failed(2, "'repos' is misspelled or not recognized")]); + assert!(forge + .close(1) + .await + .unwrap_err() + .wire_message() + .contains("azure-devops extension")); + let (forge, _) = azure(vec![failed(1, "TF401180: not found")]); + assert!(forge.review_by_number(1).await.unwrap().is_none()); + let (forge, _) = gitlab(vec![failed(127, "")]); + assert_eq!(forge.auth_status().await.unwrap(), AuthStatus::CliMissing); + let (forge, _) = azure(vec![failed(1, "Please run 'az login'")]); + assert_eq!( + forge.auth_status().await.unwrap(), + AuthStatus::NotAuthenticated + ); +} + +#[tokio::test] +async fn github_merges_bind_the_evaluated_head() { + let runner = Arc::new(FakeRunner::new([ok(""), failed(127, "")])); + let forge = GitHubForge { + identity: resolve_identity("https://github.com/leynier/alera", None).unwrap(), + github: GitHubIdentity { + host: "github.com".into(), + owner: "leynier".into(), + repo: "alera".into(), + slug: "leynier/alera".into(), + }, + repo_path: "/repo".into(), + runner: runner.clone(), + }; + forge + .merge(7, MergeMethod::Rebase, Some("abc")) + .await + .unwrap(); + assert_eq!( + runner.calls()[0].args, + [ + "pr", + "merge", + "7", + "--repo", + "leynier/alera", + "--rebase", + "--match-head-commit", + "abc" + ] + ); + assert_eq!(error_code(forge.close(7).await.unwrap_err()), "ghMissing"); + assert!(forge + .merge(7, MergeMethod::ProviderDefault, None) + .await + .is_err()); +} + +#[test] +fn review_references_must_name_this_repository() { + let (gitlab, _) = gitlab(Vec::new()); + assert_eq!(gitlab.review_reference("#3").unwrap(), 3); + assert_eq!( + gitlab + .review_reference("https://gitlab.com/group/sub/app/-/merge_requests/3/diffs") + .unwrap(), + 3 + ); + for foreign in [ + "https://gitlab.com/group/other/-/merge_requests/3", + "https://example.com/group/sub/app/-/merge_requests/3", + "https://gitlab.com/group/sub/app/-/issues/3", + ] { + assert!(gitlab.review_reference(foreign).is_err(), "{foreign}"); + } + let (azure, _) = azure(Vec::new()); + assert_eq!( + azure + .review_reference("https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/43") + .unwrap(), + 43 + ); + for foreign in [ + "https://dev.azure.com/other/myproject/_git/myrepo/pullrequest/43", + "https://dev.azure.com/myorg/myproject/_git/other/pullrequest/43", + "https://dev.azure.com/myorg/myproject/_git/myrepo/commit/43", + ] { + assert!(azure.review_reference(foreign).is_err(), "{foreign}"); + } + assert_eq!( + merge_request_number("Creating...\nhttps://h/g/p/-/merge_requests/9\n"), + Some((9, "https://h/g/p/-/merge_requests/9".to_string())) + ); + let identity: ForgeIdentity = resolve_identity("git@gitlab.com:g/p.git", None).unwrap(); + assert_eq!(identity.kind, ForgeKind::GitLab); +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/runner.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/runner.rs new file mode 100644 index 000000000..f3560aa3b --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/runner.rs @@ -0,0 +1,327 @@ +//! Runs a forge CLI (`gh`, `glab`, `az`) for one checkout. Injected so the +//! providers can be tested against recorded CLI output, and so Watch and Fix +//! can merge a remote checkout on the host that owns it. + +use std::collections::{BTreeMap, HashMap}; +use std::time::Duration; + +use alera_core::runtime::{RuntimeStore, Workspace}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::host_link_registry::HostLinkRegistry; + +const FORGE_CLI_TIMEOUT: Duration = Duration::from_secs(45); +const MAX_OUTPUT_BYTES: usize = alera_core::captured_process::DEFAULT_MAX_OUTPUT_BYTES; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ForgeOutput { + pub(crate) code: i32, + pub(crate) stdout: String, + pub(crate) stderr: String, +} + +#[async_trait] +pub(crate) trait ForgeRunner: Send + Sync { + /// Runs [program] with [args]. A program that cannot start answers exit + /// code 127, the shell's "command not found", so callers classify one + /// shape of failure. + async fn run( + &self, + program: &str, + args: &[String], + environment: &[(String, String)], + ) -> HostResult { + self.run_with_stdin(program, args, environment, None).await + } + + /// [run] with [stdin] piped to the program. Request bodies travel this + /// way (or in a file) and never on the command line, where free text + /// would reach a shell or a CLI's own `@file` expansion. + async fn run_with_stdin( + &self, + program: &str, + args: &[String], + environment: &[(String, String)], + stdin: Option<&str>, + ) -> HostResult; + + /// Whether a file this process writes is visible to the program it runs. + /// A runner for a remote checkout answers false, so callers do not hand + /// it a path that only exists here. + fn shares_local_files(&self) -> bool { + true + } +} + +/// Runs in a checkout on this machine, through the login-shell environment +/// so a GUI-launched sidecar still finds Homebrew and version-manager CLIs. +pub(crate) struct LocalRunner { + pub(crate) cwd: String, +} + +#[async_trait] +impl ForgeRunner for LocalRunner { + async fn run_with_stdin( + &self, + program: &str, + args: &[String], + environment: &[(String, String)], + stdin: Option<&str>, + ) -> HostResult { + let mut env = environment.iter().cloned().collect::>(); + env.entry("GH_PROMPT_DISABLED".into()) + .or_insert_with(|| "1".into()); + env.entry("NO_PROMPT".into()).or_insert_with(|| "1".into()); + env.entry("NO_COLOR".into()).or_insert_with(|| "1".into()); + let mut command = super::local_command::forge_command(program, args, &self.cwd, &env); + crate::login_shell_environment::apply_login_shell_environment( + &mut command, + &env.clone().into_iter().collect::>(), + ) + .await; + match alera_core::captured_process::run_command( + command, + program, + stdin.map(|input| input.as_bytes().to_vec()), + MAX_OUTPUT_BYTES, + Some(FORGE_CLI_TIMEOUT), + ) + .await + { + Ok(output) => Ok(ForgeOutput { + code: output.exit_code, + stdout: output.stdout, + stderr: output.stderr, + }), + Err(error) if error.starts_with("failed to run") => Ok(ForgeOutput { + code: 127, + stdout: String::new(), + stderr: error, + }), + Err(error) if error.to_ascii_lowercase().contains("timed out") => { + Err(HostError::state(format!( + "{program} did not answer within 45 seconds. Verify the pull request before retrying a write." + ))) + } + Err(error) => Err(HostError::state(error)), + } + } +} + +/// Runs in the workspace's checkout, on the host that owns it: forwarded as +/// `host.process.run` over the host link for a remote workspace, locally +/// otherwise. +pub(crate) struct WorkspaceRunner { + pub(crate) store: RuntimeStore, + pub(crate) links: HostLinkRegistry, + pub(crate) workspace: Workspace, +} + +#[async_trait] +impl ForgeRunner for WorkspaceRunner { + async fn run_with_stdin( + &self, + program: &str, + args: &[String], + environment: &[(String, String)], + stdin: Option<&str>, + ) -> HostResult { + let mut payload = json!({ + "workspaceId": self.workspace.id, + "executable": program, + "arguments": args, + "environment": environment.iter().cloned().collect::>(), + "timeoutMs": FORGE_CLI_TIMEOUT.as_millis() as u64, + }); + if let Some(stdin) = stdin { + payload["stdin"] = Value::String(stdin.to_string()); + } + let forwarded = super::super::host_link_routing::forward_workspace_scoped_request( + &self.store, + &self.links, + super::super::host_process_requests::HOST_PROCESS_RUN, + &payload, + ) + .await?; + let Some(output) = forwarded else { + let cwd = self.workspace.path.clone(); + // `gh` keeps the exact spawn path the runtime always used. + return if program == "gh" { + super::github::GhRunner { cwd } + .run_with_stdin(program, args, environment, stdin) + .await + } else { + LocalRunner { cwd } + .run_with_stdin(program, args, environment, stdin) + .await + }; + }; + let text = |key: &str| output[key].as_str().unwrap_or_default().to_string(); + Ok(ForgeOutput { + code: output + .get("exitCode") + .and_then(Value::as_i64) + .and_then(|code| i32::try_from(code).ok()) + .unwrap_or(1), + stdout: text("stdout"), + stderr: text("stderr"), + }) + } + + fn shares_local_files(&self) -> bool { + !crate::ssh_remote::is_remote_host_id(Some(&self.workspace.host_id)) + } +} + +#[cfg(test)] +pub(crate) mod fake { + use std::collections::VecDeque; + use std::sync::Mutex; + + use super::*; + + /// One recorded call: the program, its argv, its environment and stdin. + #[derive(Debug, Clone)] + pub(crate) struct RecordedCall { + pub(crate) program: String, + pub(crate) args: Vec, + pub(crate) environment: Vec<(String, String)>, + pub(crate) stdin: Option, + } + + impl RecordedCall { + /// The value after `--name`, like the Dart fake's `optionValue`. + pub(crate) fn option(&self, name: &str) -> Option<&str> { + let flag = format!("--{name}"); + self.args + .iter() + .position(|arg| *arg == flag) + .and_then(|index| self.args.get(index + 1)) + .map(String::as_str) + } + } + + /// Answers queued outputs in order and records every call. A call that + /// names an `--in-file` also records that file's contents, read while + /// the call runs, since the provider removes it afterwards. + pub(crate) struct FakeRunner { + pub(crate) outputs: Mutex>, + pub(crate) calls: Mutex>, + pub(crate) in_files: Mutex>>, + pub(crate) local_files: bool, + } + + impl FakeRunner { + pub(crate) fn new(outputs: impl IntoIterator) -> Self { + Self { + outputs: Mutex::new(outputs.into_iter().collect()), + calls: Mutex::default(), + in_files: Mutex::default(), + local_files: true, + } + } + + /// A fake for a checkout on another host. + pub(crate) fn remote(outputs: impl IntoIterator) -> Self { + Self { + local_files: false, + ..Self::new(outputs) + } + } + + /// The `--in-file` contents of call [index], if it named one. + pub(crate) fn in_file(&self, index: usize) -> Option { + self.in_files.lock().unwrap().get(index).cloned().flatten() + } + + pub(crate) fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } + } + + pub(crate) fn ok(stdout: &str) -> ForgeOutput { + ForgeOutput { + code: 0, + stdout: stdout.to_string(), + stderr: String::new(), + } + } + + pub(crate) fn failed(code: i32, stderr: &str) -> ForgeOutput { + ForgeOutput { + code, + stdout: String::new(), + stderr: stderr.to_string(), + } + } + + #[async_trait] + impl ForgeRunner for FakeRunner { + async fn run_with_stdin( + &self, + program: &str, + args: &[String], + environment: &[(String, String)], + stdin: Option<&str>, + ) -> HostResult { + let call = RecordedCall { + program: program.to_string(), + args: args.to_vec(), + environment: environment.to_vec(), + stdin: stdin.map(str::to_string), + }; + let in_file = call + .option("in-file") + .and_then(|path| std::fs::read_to_string(path).ok()); + self.in_files.lock().unwrap().push(in_file); + self.calls.lock().unwrap().push(call); + Ok(self + .outputs + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| failed(1, "no fake output queued"))) + } + + fn shares_local_files(&self) -> bool { + self.local_files + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[tokio::test] + async fn a_missing_cli_reads_as_exit_127() { + let dir = std::env::temp_dir(); + let output = LocalRunner { + cwd: dir.to_string_lossy().into_owned(), + } + .run("alera-no-such-forge-cli", &[], &[]) + .await + .unwrap(); + assert_eq!(output.code, 127); + } + + #[tokio::test] + async fn stdin_reaches_the_program_and_argv_is_not_reinterpreted() { + let dir = tempfile::tempdir().unwrap(); + let runner = LocalRunner { + cwd: dir.path().to_string_lossy().into_owned(), + }; + let output = runner + .run_with_stdin("cat", &[], &[], Some("{\"body\":\"$(id) & %PATH%\"}")) + .await + .unwrap(); + assert_eq!(output.stdout, "{\"body\":\"$(id) & %PATH%\"}"); + let output = runner + .run("printf", &["%s".into(), "$(id);`id`".into()], &[]) + .await + .unwrap(); + assert_eq!(output.stdout, "$(id);`id`"); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/snapshot.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/snapshot.rs new file mode 100644 index 000000000..9cc4ffc88 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/snapshot.rs @@ -0,0 +1,169 @@ +//! The `mobile.pullRequest.snapshot` body for every forge: the linked or +//! detected review, its checks and conversation, in the shape the phone, the +//! CLI, MCP, and Watch and Fix read. + +use std::sync::Arc; + +use alera_core::runtime::{RuntimeStore, Workspace}; +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::mobile_pull_request_identity::remote_identity_json; +use super::super::mobile_pull_request_links::{dismissed_number, linked_number}; +use super::provider::{AuthStatus, ForgeProvider}; +use super::{build_forge, read_workspace_remote, ForgeKind}; + +pub(crate) async fn load_snapshot( + store: &RuntimeStore, + workspace: &Workspace, +) -> HostResult { + let linked = store + .find_linked_review(&workspace.id) + .await + .map_err(|error| HostError::state(error.to_string()))?; + let remote = read_workspace_remote(store, workspace).await?; + let linked_json = linked.as_ref().map(|review| { + json!({ + "number": review.number, + "url": review.url, + "provider": review.provider, + "dismissed": review.dismissed, + }) + }); + let envelope = + |provider: Option, auth: &str, review: Value, reason: Option<&str>| { + snapshot_envelope( + remote.branch.clone(), + remote.remote_url.clone(), + provider.map(ForgeKind::wire), + auth, + linked_json.clone(), + review, + reason.map(ToOwned::to_owned), + ) + }; + let (Some(identity), Some(remote_url)) = (remote.identity.clone(), remote.remote_url.clone()) + else { + return Ok(envelope( + None, + "undetectable", + Value::Null, + Some("No GitHub, GitLab, or Azure DevOps remote was detected for this workspace."), + )); + }; + let kind = identity.kind; + let forge = build_forge(identity, &remote_url, &workspace.path, None); + let auth = match forge.auth_status().await { + Ok(auth) => auth, + Err(error) => { + let mut snapshot = envelope(Some(kind), "cliMissing", Value::Null, None); + snapshot["unavailableReason"] = json!(error.wire_message()); + return Ok(snapshot); + } + }; + if auth != AuthStatus::Authenticated { + return Ok(envelope( + Some(kind), + auth.wire(), + Value::Null, + Some(&unavailable_reason(kind, auth)), + )); + } + + let mut suggested_review = None; + let review = if let Some(number) = linked_number(linked.as_ref()) { + forge.review_by_number(number).await? + } else if let Some(branch) = remote.branch.as_deref() { + let detected = forge.review_for_branch(branch).await?; + // An unlinked review stays hidden until the user links it again. + match (detected, dismissed_number(linked.as_ref())) { + (Some(review), Some(dismissed)) if review.number == dismissed => { + suggested_review = Some(json!({ + "number": review.number, + "title": review.title, + "url": review.url, + })); + None + } + (detected, _) => detected, + } + } else { + None + }; + let review_json = match review { + Some(review) => review_with_activity(forge.as_ref(), review.to_json()).await, + None => Value::Null, + }; + let reason = match (&review_json, &suggested_review) { + (Value::Null, Some(_)) => { + Some("The pull request for this branch was unlinked from the workspace.") + } + (Value::Null, None) => Some("No open pull request is linked to this branch."), + _ => None, + }; + let mut snapshot = envelope(Some(kind), auth.wire(), review_json, reason); + if let Some(suggested) = suggested_review { + snapshot["suggestedReview"] = suggested; + } + Ok(snapshot) +} + +async fn review_with_activity(forge: &dyn ForgeProvider, mut review: Value) -> Value { + let number = review["number"].as_i64().unwrap_or(0); + let (checks, (comments, truncated)) = + tokio::join!(forge.checks(number), forge.comments(number)); + review["checks"] = json!(checks); + review["comments"] = json!(comments); + review["commentsTruncated"] = json!(truncated); + review +} + +/// The phone's wording for GitHub stays as it was; the other forges name +/// their own CLI. +fn unavailable_reason(kind: ForgeKind, auth: AuthStatus) -> String { + match (kind, auth) { + (ForgeKind::GitHub, AuthStatus::CliMissing) => { + "Install and authenticate the GitHub CLI (gh) on the paired computer.".into() + } + (ForgeKind::GitHub, _) => "Sign in with gh auth login on the paired computer.".into(), + (_, AuthStatus::CliMissing) => { + super::model::provider_unavailable(kind, false).wire_message() + } + _ => super::model::provider_unavailable(kind, true).wire_message(), + } +} + +pub(crate) fn snapshot_envelope( + branch: Option, + remote_url: Option, + provider: Option<&str>, + auth_status: &str, + linked_review: Option, + review: Value, + unavailable_reason: Option, +) -> Value { + json!({ + "branch": branch, + "remoteUrl": remote_url, + "provider": provider, + "identity": remote_identity_json(remote_url.as_deref(), provider), + "authStatus": auth_status, + "linkedReview": linked_review, + "review": review, + "unavailableReason": unavailable_reason, + }) +} + +/// The forge a snapshot already resolved, for work that follows it (merge +/// methods, Watch and Fix merges), running through [runner] when given. +pub(crate) fn snapshot_forge( + snapshot: &Value, + repo_path: &str, + runner: Option>, +) -> Option> { + let kind = ForgeKind::from_wire(snapshot["provider"].as_str()?)?; + let remote_url = snapshot["remoteUrl"].as_str()?; + let identity = super::identity::resolve_identity(remote_url, Some(kind))?; + Some(build_forge(identity, remote_url, repo_path, runner)) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_actions.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_actions.rs new file mode 100644 index 000000000..e8318f13b --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_actions.rs @@ -0,0 +1,270 @@ +//! GitHub-native pull request stacks through `gh api` and the `gh-stack` +//! extension, ported from the desktop's `github_stack_actions.dart` and +//! `github_stack_mappers.dart`. Stacks exist only on GitHub, as on desktop. + +use std::sync::Arc; + +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::mobile_pull_request_identity::GitHubIdentity; +use super::identity::ForgeKind; +use super::model::{forge_failure, provider_unavailable, MergeMethod}; +use super::runner::{ForgeOutput, ForgeRunner}; + +pub(crate) struct GitHubStacks { + pub(crate) github: GitHubIdentity, + pub(crate) runner: Arc, +} + +fn looks_missing(output: &ForgeOutput) -> bool { + let combined = format!("{} {}", output.stdout, output.stderr).to_ascii_lowercase(); + output.code == 127 + || combined.contains("command not found") + || combined.contains("is not recognized") + || combined.contains("no such file") +} + +fn looks_like_missing_extension(output: &ForgeOutput) -> bool { + let combined = format!("{}\n{}", output.stdout, output.stderr).to_ascii_lowercase(); + combined.contains("unknown command \"stack\"") + || combined.contains("unknown command 'stack'") + || combined.contains("'stack' is not a gh command") + || combined.contains("extension stack not found") + || combined.contains("gh-stack extension was not found") +} + +fn missing_extension() -> HostError { + HostError::conflict( + "provider_unavailable", + "Install and authenticate the gh-stack extension (gh extension install github/gh-stack) on the computer that owns this checkout.", + json!({ "provider": "github", "cli": "gh-stack" }), + ) +} + +fn classify(output: &ForgeOutput) -> HostError { + if looks_missing(output) { + return provider_unavailable(ForgeKind::GitHub, false); + } + if looks_like_missing_extension(output) { + return missing_extension(); + } + let lower = output.stderr.to_ascii_lowercase(); + if lower.contains("not logged") + || lower.contains("authentication") + || lower.contains("gh auth login") + { + return provider_unavailable(ForgeKind::GitHub, true); + } + forge_failure(ForgeKind::GitHub, &output.stderr, &output.stdout) +} + +impl GitHubStacks { + fn ensure_supported_host(&self) -> HostResult<()> { + if self.github.host.contains(':') { + return Err(HostError::state( + "GitHub Enterprise Server hosts with custom HTTPS ports are not supported by the gh CLI. Use a standard HTTPS hostname without a port.", + )); + } + Ok(()) + } + + fn repository_url(&self) -> String { + format!( + "https://{}/{}/{}", + self.github.host, self.github.owner, self.github.repo + ) + } + + async fn api(&self, endpoint: String) -> HostResult> { + self.ensure_supported_host()?; + let args = vec![ + "api".to_string(), + "--hostname".to_string(), + self.github.host.clone(), + endpoint, + ]; + let output = self.runner.run("gh", &args, &[]).await?; + if output.code != 0 { + let lower = output.stderr.to_ascii_lowercase(); + if !looks_missing(&output) && (lower.contains("404") || lower.contains("not found")) { + return Ok(None); + } + return Err(classify(&output)); + } + serde_json::from_str(output.stdout.trim()) + .map(Some) + .map_err(|_| HostError::state("Unexpected GitHub stack response.")) + } + + async fn run_stack(&self, args: Vec) -> HostResult<()> { + self.ensure_supported_host()?; + let output = self.runner.run("gh", &args, &[]).await?; + if output.code == 0 { + return Ok(()); + } + Err(classify(&output)) + } + + /// Whether `gh extension list` names the gh-stack extension. + pub(crate) async fn extension_installed(&self) -> bool { + let args = ["extension", "list"].map(String::from); + match self.runner.run("gh", &args, &[]).await { + Ok(output) if output.code == 0 => { + output.stdout.to_ascii_lowercase().contains("gh-stack") + } + _ => false, + } + } + + /// The stack that holds [review_number], or `None` when it is in none. + pub(crate) async fn stack_for_review(&self, review_number: i64) -> HostResult> { + let endpoint = format!( + "repos/{}/{}/stacks?pull_request={review_number}", + self.github.owner, self.github.repo + ); + let Some(found) = self.api(endpoint).await? else { + return Ok(None); + }; + let entries = found + .as_array() + .ok_or_else(|| HostError::state("Unexpected GitHub stack search response."))?; + let Some(first) = entries.first() else { + return Ok(None); + }; + let number = first["number"] + .as_i64() + .filter(|number| *number > 0) + .ok_or_else(|| HostError::state("GitHub returned a stack without a valid number."))?; + self.stack_by_number(number).await + } + + pub(crate) async fn stack_by_number(&self, number: i64) -> HostResult> { + let endpoint = format!( + "repos/{}/{}/stacks/{number}", + self.github.owner, self.github.repo + ); + match self.api(endpoint).await? { + Some(stack) if stack.is_object() => Ok(Some(map_stack(&stack, &self.repository_url()))), + Some(_) => Err(HostError::state("Unexpected GitHub stack response.")), + None => Ok(None), + } + } + + /// `gh stack link`: a new stack from [numbers] (bottom to top), or those + /// numbers appended to [stack_number]. + pub(crate) async fn link( + &self, + numbers: &[i64], + stack_number: Option, + base_branch: Option<&str>, + ) -> HostResult { + let mut args = vec!["stack".to_string(), "link".to_string()]; + args.extend(stack_number.map(|number| number.to_string())); + args.extend(numbers.iter().map(i64::to_string)); + if let Some(base) = base_branch.map(str::trim).filter(|base| !base.is_empty()) { + args.extend(["--base".to_string(), base.to_string()]); + } + self.run_stack(args).await?; + let mut stack = match numbers.last() { + Some(last) => self.stack_for_review(*last).await?, + None => None, + }; + if stack.is_none() { + if let Some(number) = stack_number { + stack = self.stack_by_number(number).await?; + } + } + stack.ok_or_else(|| { + HostError::state( + "The stack was linked but GitHub did not return it yet. Refresh and try again.", + ) + }) + } + + /// Atomically merges the stack through [review_number]. + pub(crate) async fn merge(&self, review_number: i64, method: MergeMethod) -> HostResult<()> { + let method = match method { + MergeMethod::MergeCommit => "merge", + MergeMethod::Squash => "squash", + MergeMethod::Rebase => "rebase", + MergeMethod::ProviderDefault => { + return Err(HostError::state( + "GitHub stacks require an explicit merge method.", + )); + } + }; + let args = [ + "stack", + "merge", + &review_number.to_string(), + "--yes", + "--merge-method", + method, + ] + .map(String::from) + .to_vec(); + self.run_stack(args).await + } +} + +/// The stacks REST response in a provider-neutral shape, bottom layer first, +/// like `mapGitHubStack`. +pub(crate) fn map_stack(stack: &Value, repository_url: &str) -> Value { + let entries = stack["pull_requests"] + .as_array() + .into_iter() + .flatten() + .filter(|entry| entry.is_object()) + .enumerate() + .map(|(index, review)| { + json!({ + "position": index + 1, + "review": map_stack_review(review, repository_url), + }) + }) + .collect::>(); + json!({ + "number": stack["number"].as_i64().unwrap_or(0), + "baseBranch": text(&stack["base"], "ref").unwrap_or_default(), + "open": stack["open"].as_bool().unwrap_or(false), + "createdAt": text(stack, "created_at"), + "entries": entries, + }) +} + +fn map_stack_review(review: &Value, repository_url: &str) -> Value { + let number = review["number"].as_i64().unwrap_or(0); + let head = text(&review["head"], "ref"); + let draft = review["draft"].as_bool().unwrap_or(false); + let state = if text(review, "merged_at").is_some() { + "MERGED" + } else if text(review, "state").is_some_and(|state| state.eq_ignore_ascii_case("closed")) { + "CLOSED" + } else { + "OPEN" + }; + json!({ + "number": number, + "title": text(review, "title") + .or_else(|| head.clone()) + .unwrap_or_else(|| format!("Pull Request #{number}")), + "state": state, + "isDraft": state == "OPEN" && draft, + "url": text(review, "html_url").unwrap_or_else(|| format!("{repository_url}/pull/{number}")), + "createdAt": text(review, "created_at"), + "author": text(&review["user"], "login"), + "baseRefName": text(&review["base"], "ref"), + "headRefName": head, + "headSha": text(&review["head"], "sha"), + }) +} + +fn text(value: &Value, key: &str) -> Option { + value[key] + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_create.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_create.rs new file mode 100644 index 000000000..27d877308 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_create.rs @@ -0,0 +1,188 @@ +//! `pullRequestStack.create`: pushes local workspace branches, opens the +//! pull requests they lack (each targeting the layer below), links them to +//! their workspaces, and stacks them, like the desktop's +//! `createReviewStackFromWorkspaces`. + +use std::collections::BTreeSet; + +use alera_core::runtime::{RuntimeStore, WorkspaceStatus}; +use alera_core::source_control; +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::identity::resolve_identity; +use super::{ + create_layer_review, git_host_error, require_local_workspace, spawn_blocking_workspace, + stack_numbers, stack_top_branch, validate_chain, CreateInput, ForgeKind, NewLayer, + StackContext, +}; + +pub(super) async fn create( + store: &RuntimeStore, + context: &StackContext, + payload: &Value, +) -> HostResult { + let current_branch = context + .branch + .clone() + .or_else(|| { + context + .review + .as_ref() + .and_then(|review| review.head_branch.clone()) + }) + .ok_or_else(|| { + HostError::state("The current workspace must have an active branch to create a stack.") + })?; + let requested = payload["layers"].as_array().cloned().unwrap_or_default(); + let existing = context.current_stack().await?; + if existing.is_none() && requested.len() < 2 { + return Err(HostError::state( + "Choose at least two workspaces in bottom-to-top order.", + )); + } + if existing.is_some() && requested.is_empty() { + return Err(HostError::state("Choose at least one workspace to add.")); + } + let base = match &existing { + None => payload["baseBranch"] + .as_str() + .unwrap_or("") + .trim() + .to_string(), + Some(stack) => stack_top_branch(stack).unwrap_or_default(), + }; + if base.is_empty() { + return Err(HostError::state( + "Choose a valid base branch for the stack.", + )); + } + let layers = resolve_layers(store, context, &requested).await?; + let branches = layers + .iter() + .map(|layer| layer.branch.clone()) + .collect::>(); + if existing.is_none() && !branches.contains(¤t_branch) { + return Err(HostError::state( + "The current workspace must be included in the new stack.", + )); + } + if let Some(stack) = &existing { + let used = stack["entries"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| entry["review"]["headRefName"].as_str()) + .collect::>(); + if let Some(duplicate) = branches + .iter() + .find(|branch| used.contains(branch.as_str())) + { + return Err(HostError::state(format!( + "Branch `{duplicate}` is already in this stack." + ))); + } + } + validate_chain(&context.workspace.path, &base, &branches).await?; + for layer in &layers { + let path = layer.workspace.path.clone(); + spawn_blocking_workspace("Stack push", move || { + source_control::git_push(path).map_err(git_host_error) + }) + .await?; + } + let mut numbers = Vec::new(); + let mut previous = base.clone(); + for mut layer in layers { + layer.input.base = previous.clone(); + let (number, _) = create_layer_review(store, context.forge.as_ref(), &layer).await?; + numbers.push(number); + previous = layer.branch; + } + let stack_number = existing.as_ref().and_then(|stack| stack["number"].as_i64()); + let new_base = existing.is_none().then_some(base.as_str()); + let stack = context + .stacks + .link(&numbers, stack_number, new_base) + .await?; + Ok(json!({ + "provider": "github", + "reviewNumbers": numbers, + "stack": stack, + "previousMembers": existing.as_ref().map(stack_numbers), + })) +} + +/// Each requested layer's workspace, live branch, and pull request details. +/// Every layer must be a distinct local workspace of the same repository. +async fn resolve_layers( + store: &RuntimeStore, + context: &StackContext, + requested: &[Value], +) -> HostResult> { + let identity = context.forge.identity(); + let mut workspace_ids = BTreeSet::new(); + let mut branches = BTreeSet::new(); + let mut layers = Vec::new(); + for raw in requested { + let workspace_id = raw["workspaceId"] + .as_str() + .map(str::trim) + .filter(|id| !id.is_empty()) + .ok_or_else(|| HostError::state("Every stack layer needs a workspaceId."))?; + if !workspace_ids.insert(workspace_id.to_string()) { + return Err(HostError::state( + "A workspace can appear only once in a stack.", + )); + } + let workspace = store + .find_workspace(workspace_id) + .await + .map_err(|error| HostError::state(error.to_string()))? + .filter(|workspace| workspace.status == WorkspaceStatus::Active) + .ok_or_else(|| HostError::state(format!("Workspace not found: {workspace_id}")))?; + require_local_workspace(&workspace)?; + let path = workspace.path.clone(); + let (branch, remote) = spawn_blocking_workspace("Stack layer", move || { + Ok(( + alera_core::git::current_branch(&path).unwrap_or_default(), + alera_core::git::repository_remote_url(&path).ok().flatten(), + )) + }) + .await?; + let branch = branch.trim().to_string(); + if branch.is_empty() || branch == "HEAD" || !branches.insert(branch.clone()) { + return Err(HostError::state( + "Each stack layer must use a unique branch.", + )); + } + let same_repository = remote + .as_deref() + .and_then(|url| resolve_identity(url, Some(ForgeKind::GitHub))) + .is_some_and(|layer| { + layer.host == identity.host + && layer.owner.eq_ignore_ascii_case(&identity.owner) + && layer.repo.eq_ignore_ascii_case(&identity.repo) + }); + if !same_repository { + return Err(HostError::state(format!( + "Workspace `{branch}` does not belong to {}/{}/{}.", + identity.host, identity.owner, identity.repo + ))); + } + let text = |key: &str| raw[key].as_str().map(str::trim).unwrap_or("").to_string(); + layers.push(NewLayer { + input: CreateInput { + base: String::new(), + head: branch.clone(), + title: text("title"), + body: text("body"), + draft: raw["draft"].as_bool().unwrap_or(false), + }, + workspace, + branch, + }); + } + Ok(layers) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_requests.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_requests.rs new file mode 100644 index 000000000..8e698073c --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/stack_requests.rs @@ -0,0 +1,452 @@ +//! `pullRequestStack.get|create|link|merge`: the desktop's stack actions +//! (`workspace_pull_request_stack_actions.dart` and +//! `workspace_pull_request_stack_validation.dart`) on the runtime, with the +//! same validation and messages. GitHub only, like the desktop; other forges +//! answer `provider_unsupported`. + +use std::sync::Arc; + +use alera_core::runtime::{LinkedReview, RuntimeStore, Workspace}; +use alera_core::source_control::{self, GitErrorKind}; +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::mobile_pull_request_busy::BusyGuard; +use super::super::mobile_source_control_snapshot::git_host_error; +use super::super::mobile_workspace_file_requests::{ + spawn_blocking_workspace, workspace_for_mobile_file_request, +}; +use super::super::requests::optional_string_key; +use super::github::GhRunner; +use super::links::{create_and_link, save_link}; +use super::model::{provider_unsupported, MergeMethod, Review}; +use super::provider::{CreateInput, ForgeProvider}; +use super::stack_actions::GitHubStacks; +use super::{require_local_workspace, workspace_forge, ForgeKind}; + +pub(crate) fn is_stack_verb(request_type: &str) -> bool { + matches!( + request_type, + "pullRequestStack.get" + | "pullRequestStack.create" + | "pullRequestStack.link" + | "pullRequestStack.merge" + ) +} + +struct StackContext { + workspace: Workspace, + forge: Box, + stacks: GitHubStacks, + branch: Option, + linked: Option, + review: Option, +} + +pub(crate) async fn handle_stack_request( + store: &RuntimeStore, + request_type: &str, + payload: &Value, +) -> HostResult { + if let Some(workspace_id) = payload.get("workspaceId").and_then(Value::as_str) { + super::super::remote_pull_request_routing::adopt_hub_linked_review( + store, + workspace_id, + payload, + ) + .await?; + } + let workspace = workspace_for_mobile_file_request(store, payload).await?; + let context = open(store, workspace, payload).await?; + let mut result = match request_type { + "pullRequestStack.get" => get(&context).await?, + "pullRequestStack.link" => { + let _busy = BusyGuard::acquire(&context.workspace.id)?; + link(store, &context, payload).await? + } + "pullRequestStack.create" => { + let _busy = BusyGuard::acquire(&context.workspace.id)?; + create(store, &context, payload).await? + } + "pullRequestStack.merge" => { + let _busy = BusyGuard::acquire(&context.workspace.id)?; + merge(store, &context, payload).await? + } + other => { + return Err(HostError::format(format!( + "Unknown pull request stack request: {other}" + ))) + } + }; + // A hub adopts the link the satellite reports, as for the other verbs. + let linked = store + .find_linked_review(&context.workspace.id) + .await + .map_err(|error| HostError::state(error.to_string()))?; + result["linkedReview"] = linked.map_or(Value::Null, |review| { + json!({ + "number": review.number, + "url": review.url, + "provider": review.provider, + "dismissed": review.dismissed, + }) + }); + Ok(result) +} + +async fn open( + store: &RuntimeStore, + workspace: Workspace, + payload: &Value, +) -> HostResult { + require_local_workspace(&workspace)?; + let (forge, remote) = workspace_forge(store, &workspace).await?; + if forge.kind() != ForgeKind::GitHub { + return Err(provider_unsupported( + "Pull request stacks are only available for GitHub repositories, as on desktop.", + )); + } + let github = forge + .identity() + .github(remote.remote_url.as_deref().unwrap_or_default()); + let stacks = GitHubStacks { + github, + runner: Arc::new(GhRunner { + cwd: workspace.path.clone(), + }), + }; + let linked = store + .find_linked_review(&workspace.id) + .await + .map_err(|error| HostError::state(error.to_string()))?; + let requested = payload.get("number").and_then(Value::as_i64); + let linked_number = linked + .as_ref() + .filter(|review| !review.dismissed) + .and_then(|review| review.number); + let review = match (requested.or(linked_number), remote.branch.as_deref()) { + (Some(number), _) => forge.review_by_number(number).await?, + (None, Some(branch)) => forge.review_for_branch(branch).await?, + (None, None) => None, + }; + Ok(StackContext { + workspace, + forge, + stacks, + branch: remote + .branch + .filter(|branch| !branch.is_empty() && branch != "HEAD"), + linked, + review, + }) +} + +impl StackContext { + fn linked_manually(&self) -> bool { + let current = self.review.as_ref().map(|review| review.number); + self.linked + .as_ref() + .is_some_and(|review| !review.dismissed && review.number == current) + } + + async fn current_stack(&self) -> HostResult> { + match &self.review { + Some(review) => self.stacks.stack_for_review(review.number).await, + None => Ok(None), + } + } + + /// Links the current review the way the desktop does after a stack + /// action, unless the user already pinned one. + async fn keep_link(&self, store: &RuntimeStore) -> HostResult<()> { + let Some(review) = &self.review else { + return Ok(()); + }; + if self.linked_manually() { + return Ok(()); + } + let url = Some(review.url.clone()).filter(|url| !url.is_empty()); + save_link( + store, + &self.workspace.id, + ForgeKind::GitHub, + review.number, + url, + false, + ) + .await + } +} + +async fn get(context: &StackContext) -> HostResult { + let stack = context.current_stack().await?; + Ok(json!({ + "provider": "github", + "reviewNumber": context.review.as_ref().map(|review| review.number), + "stack": stack, + "extensionInstalled": context.stacks.extension_installed().await, + })) +} + +fn normalize(numbers: impl IntoIterator) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + numbers + .into_iter() + .filter(|number| *number > 0 && seen.insert(*number)) + .collect() +} + +fn stack_numbers(stack: &Value) -> Vec { + stack["entries"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| entry["review"]["number"].as_i64()) + .collect() +} + +fn stack_top_branch(stack: &Value) -> Option { + stack["entries"].as_array()?.last()?["review"]["headRefName"] + .as_str() + .map(ToOwned::to_owned) +} + +async fn link(store: &RuntimeStore, context: &StackContext, payload: &Value) -> HostResult { + let current = context + .review + .as_ref() + .ok_or_else(|| HostError::state("Native pull request stacks are not available."))?; + let numbers = normalize( + payload["numbers"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_i64), + ); + let existing = context.current_stack().await?; + match &existing { + None if numbers.len() < 2 => { + return Err(HostError::state( + "Choose at least two pull requests in bottom-to-top order.", + )); + } + None if !numbers.contains(¤t.number) => { + return Err(HostError::state( + "The current pull request must be included in the new stack.", + )); + } + Some(_) if numbers.is_empty() => { + return Err(HostError::state("Choose at least one pull request to add.")); + } + Some(stack) => { + let members = stack_numbers(stack); + if let Some(duplicate) = numbers.iter().find(|number| members.contains(number)) { + return Err(HostError::state(format!( + "Pull request #{duplicate} is already in this stack." + ))); + } + } + None => {} + } + let mut reviews = Vec::new(); + for number in &numbers { + let review = context + .forge + .review_by_number(*number) + .await? + .ok_or_else(|| HostError::state(format!("No pull request #{number} was found.")))?; + if !review.is_open() { + return Err(HostError::state(format!( + "Pull request #{number} is not open." + ))); + } + reviews.push(review); + } + let base = existing + .as_ref() + .and_then(stack_top_branch) + .or_else(|| { + reviews + .first() + .and_then(|review| review.base_branch.clone()) + }) + .ok_or_else(|| HostError::state("Could not determine the base branch for this stack."))?; + let mut branches = Vec::new(); + for review in &reviews { + branches.push( + review + .head_branch + .clone() + .filter(|b| !b.trim().is_empty()) + .ok_or_else(|| { + HostError::state(format!( + "Pull request #{} does not expose a head branch.", + review.number + )) + })?, + ); + } + validate_chain(&context.workspace.path, &base, &branches).await?; + let stack_number = existing.as_ref().and_then(|stack| stack["number"].as_i64()); + let first_base = reviews + .first() + .and_then(|review| review.base_branch.clone()); + let stack = context + .stacks + .link( + &numbers, + stack_number, + if existing.is_none() { + first_base.as_deref() + } else { + None + }, + ) + .await?; + context.keep_link(store).await?; + Ok(json!({ "provider": "github", "stack": stack })) +} + +/// Every branch descends from the one below it, starting at [base]. A +/// missing branch triggers one fetch, as on desktop. +async fn validate_chain(repo_path: &str, base: &str, branches: &[String]) -> HostResult<()> { + let repo_path = repo_path.to_string(); + let base = base.trim().to_string(); + let branches = branches.to_vec(); + spawn_blocking_workspace("Stack validation", move || { + let mut previous = base; + if previous.is_empty() { + return Err(HostError::state("Could not determine the base branch for this stack.")); + } + let mut fetched = false; + for branch in branches.iter().map(|branch| branch.trim()) { + if branch.is_empty() { + return Err(HostError::state("Every stack layer needs a branch.")); + } + if branch == previous { + return Err(HostError::state(format!( + "Branch `{branch}` is also used by the layer below it." + ))); + } + let check = || { + source_control::is_ancestor(repo_path.clone(), previous.clone(), branch.to_string()) + }; + let descends = match check() { + Err(error) if error.kind == GitErrorKind::BranchNotFound && !fetched => { + source_control::git_fetch(repo_path.clone()).map_err(git_host_error)?; + fetched = true; + check().map_err(git_host_error)? + } + other => other.map_err(git_host_error)?, + }; + if !descends { + return Err(HostError::state(format!( + "Branch `{branch}` must descend from `{previous}` before its pull request can join the stack." + ))); + } + previous = branch.to_string(); + } + Ok(()) + }) + .await +} + +#[path = "stack_create.rs"] +mod stack_create; +use stack_create::create; + +async fn merge(store: &RuntimeStore, context: &StackContext, payload: &Value) -> HostResult { + let (Some(review), Some(stack)) = (&context.review, context.current_stack().await?) else { + return Err(HostError::state( + "No pull request stack is available to merge.", + )); + }; + let allowed = context + .forge + .merge_methods(review.base_branch.as_deref()) + .await + .map_err(HostError::state)?; + let method = match optional_string_key(payload, "method") { + Some(method) => method, + None => allowed.first().cloned().unwrap_or_default(), + }; + if !allowed.contains(&method) || method == "providerDefault" { + return Err(HostError::state( + "This stack cannot use the selected merge method.", + )); + } + let entries = stack["entries"].as_array().cloned().unwrap_or_default(); + let position = entries + .iter() + .find(|entry| entry["review"]["number"].as_i64() == Some(review.number)) + .and_then(|entry| entry["position"].as_i64()) + .ok_or_else(|| HostError::state("The current pull request is not in this stack."))?; + let blocked = entries.iter().find(|entry| { + entry["position"].as_i64().unwrap_or(i64::MAX) <= position + && (entry["review"]["isDraft"] == true || entry["review"]["state"] == "CLOSED") + }); + if let Some(entry) = blocked { + return Err(HostError::state(format!( + "Pull request #{} must be open and ready before merging the stack.", + entry["review"]["number"] + ))); + } + let method = MergeMethod::parse(&method) + .ok_or_else(|| HostError::state("This stack cannot use the selected merge method."))?; + context.stacks.merge(review.number, method).await?; + context.keep_link(store).await?; + Ok(json!({ + "provider": "github", + "merged": true, + "reviewNumber": review.number, + "method": method.wire(), + "mergedThrough": position, + })) +} + +/// Inputs shared with `stack_create`. +pub(super) struct NewLayer { + pub(super) workspace: Workspace, + pub(super) branch: String, + pub(super) input: CreateInput, +} + +pub(super) async fn create_layer_review( + store: &RuntimeStore, + context_forge: &dyn ForgeProvider, + layer: &NewLayer, +) -> HostResult<(i64, Option)> { + if let Some(review) = context_forge.review_for_branch(&layer.branch).await? { + if !review.is_open() { + return Err(HostError::state(format!( + "Pull request #{} for `{}` is not open.", + review.number, layer.branch + ))); + } + let url = Some(review.url).filter(|url| !url.is_empty()); + save_link( + store, + &layer.workspace.id, + ForgeKind::GitHub, + review.number, + url.clone(), + false, + ) + .await?; + return Ok((review.number, url)); + } + if layer.input.title.trim().is_empty() { + return Err(HostError::state("Every new pull request needs a title.")); + } + let created = create_and_link(store, &layer.workspace, context_forge, &layer.input) + .await + .map_err(|error| { + HostError::state(format!( + "Could not create the pull request for `{}`: {}", + layer.branch, + error.wire_message() + )) + })?; + Ok((created.number, created.url)) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_forges/summaries.rs b/rust/alera-cli/src/terminal_host/server/pull_request_forges/summaries.rs new file mode 100644 index 000000000..975798e2a --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/pull_request_forges/summaries.rs @@ -0,0 +1,86 @@ +//! Per-workspace summary rows for GitLab and Azure DevOps projects. Their CLIs +//! have no batch query like GitHub's GraphQL, so each workspace is read on its +//! own (linked number, else the open review of its branch, then its checks), +//! a few at a time. + +use alera_core::runtime::{RuntimeStore, Workspace}; +use futures_util::future::join_all; +use serde_json::Value; + +use crate::terminal_host::host_error::{HostError, HostResult}; + +use super::super::mobile_pull_request_check_counts::count_classified_checks; +use super::super::mobile_pull_request_summaries::{review_summary_json, workspace_lookup_branch}; +use super::build_forge; +use super::identity::ForgeIdentity; +use super::model::classify_check; +use super::provider::ForgeProvider; + +const CONCURRENT_WORKSPACES: usize = 4; + +pub(crate) async fn forge_project_summaries( + store: &RuntimeStore, + repo_path: &str, + identity: ForgeIdentity, + remote_url: &str, + group: &[Workspace], +) -> HostResult> { + let forge = build_forge(identity, remote_url, repo_path, None); + let forge = forge.as_ref(); + let mut rows = Vec::with_capacity(group.len()); + for chunk in group.chunks(CONCURRENT_WORKSPACES) { + rows.extend( + join_all( + chunk + .iter() + .map(|workspace| workspace_summary(store, forge, workspace)), + ) + .await, + ); + } + // One failed read fails the project, so the phone keeps last-known icons + // instead of clearing them. + rows.into_iter() + .filter_map(Result::transpose) + .collect::>>() +} + +async fn workspace_summary( + store: &RuntimeStore, + forge: &dyn ForgeProvider, + workspace: &Workspace, +) -> HostResult> { + let linked = store + .find_linked_review(&workspace.id) + .await + .map_err(|error| HostError::state(error.to_string()))?; + let linked_number = linked + .as_ref() + .filter(|review| !review.dismissed) + .and_then(|review| review.number); + let dismissed = linked + .filter(|review| review.dismissed) + .and_then(|review| review.number); + let review = match linked_number { + Some(number) => forge.review_by_number(number).await?, + None => { + let Some(branch) = workspace_lookup_branch(workspace).await else { + return Ok(None); + }; + forge + .review_for_branch(&branch) + .await? + .filter(|review| review.is_open() && Some(review.number) != dismissed) + } + }; + let Some(review) = review else { + return Ok(None); + }; + let checks = forge.checks(review.number).await; + let counts = count_classified_checks(checks.iter().map(classify_check)); + Ok(Some(review_summary_json( + &workspace.id, + &review.to_json(), + &counts, + ))) +} diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_watch_evaluation.rs b/rust/alera-cli/src/terminal_host/server/pull_request_watch_evaluation.rs index 8380970ac..570be1e60 100644 --- a/rust/alera-cli/src/terminal_host/server/pull_request_watch_evaluation.rs +++ b/rust/alera-cli/src/terminal_host/server/pull_request_watch_evaluation.rs @@ -1,4 +1,6 @@ -//! Runtime policy shared by every client that activates a GitHub watch. +//! Runtime policy shared by every client that activates a watch, on GitHub, +//! GitLab, and Azure DevOps alike: every forge's snapshot carries the same +//! review, `checks[]`, and comment shape. use std::collections::{BTreeMap, BTreeSet}; use alera_core::runtime::{PullRequestWatch, PullRequestWatchDispatchMark}; @@ -20,7 +22,11 @@ pub(super) enum Evaluation { pub(super) fn evaluate(watch: &PullRequestWatch, snapshot: &Value) -> Evaluation { // Missing authentication or partial network results are not evidence that a PR disappeared. - if snapshot["authStatus"] != "authenticated" || snapshot["provider"] != "github" { + let supported = snapshot["provider"] + .as_str() + .and_then(super::pull_request_forges::ForgeKind::from_wire) + .is_some(); + if snapshot["authStatus"] != "authenticated" || !supported { return Evaluation::Wait; } let review = &snapshot["review"]; @@ -154,15 +160,16 @@ pub(super) fn evaluate(watch: &PullRequestWatch, snapshot: &Value) -> Evaluation && thread_ids.is_empty() { if let Some(head) = head.filter(|head| Some(head) != watch.last_merged_head_sha.as_ref()) { - if let Some(method) = snapshot["mergeMethods"].as_array().and_then(|methods| { - methods - .iter() - .filter_map(Value::as_str) - .find(|method| matches!(*method, "mergeCommit" | "squash" | "rebase")) - }) { + let methods = snapshot["mergeMethods"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if let Some(method) = super::pull_request_forges::preferred_merge_method(&methods) { return Evaluation::Merge { head, - method: method.into(), + method: method.wire().into(), }; } } @@ -289,6 +296,36 @@ mod tests { watch.last_dispatch = Some(mark); assert_eq!(evaluate(&watch, &snapshot), Evaluation::Wait); } + #[test] + fn gitlab_and_azure_snapshots_merge_with_their_own_methods() { + for (provider, methods, expected) in [ + ( + "gitlab", + json!(["providerDefault", "squash"]), + "providerDefault", + ), + ( + "azureDevops", + json!(["mergeCommit", "squash"]), + "mergeCommit", + ), + ] { + let mut snapshot = snapshot(); + snapshot["provider"] = json!(provider); + snapshot["mergeMethods"] = methods; + assert_eq!( + evaluate(&watch(), &snapshot), + Evaluation::Merge { + head: "abc".into(), + method: expected.into() + } + ); + } + let mut snapshot = snapshot(); + snapshot["provider"] = json!("bitbucket"); + assert_eq!(evaluate(&watch(), &snapshot), Evaluation::Wait); + } + #[test] fn scope_and_fix_mode_are_preserved() { let mut watch = watch(); diff --git a/rust/alera-cli/src/terminal_host/server/pull_request_watch_runtime.rs b/rust/alera-cli/src/terminal_host/server/pull_request_watch_runtime.rs index 72625cc4a..f17740c9e 100644 --- a/rust/alera-cli/src/terminal_host/server/pull_request_watch_runtime.rs +++ b/rust/alera-cli/src/terminal_host/server/pull_request_watch_runtime.rs @@ -158,12 +158,14 @@ impl ServerActor { .is_ok() { self.broadcast_pull_request_watch_changed(Some(&watch.workspace_id)); + record_watch_event(&self.runtime_store, &watch, "stopped").await; } } Evaluation::Dispatch { mark, prompt } => { match self.dispatch_pull_request_watch(&mut watch, &prompt).await { Ok(true) => { watch.last_dispatch = Some(mark); + record_watch_event(&self.runtime_store, &watch, "dispatched").await; self.save_runtime_watch(watch).await; } Ok(false) => {} @@ -210,8 +212,9 @@ impl ServerActor { .await .is_ok_and(|current| current.as_ref() == Some(&watch)) { - // GitHub may only have queued the merge. Keep watching until a snapshot confirms it. + // The forge may only have queued the merge. Keep watching until a snapshot confirms it. watch.last_merged_head_sha = Some(head); + record_watch_event(&self.runtime_store, &watch, "merged").await; self.broadcast_authenticated(crate::terminal_host::protocol::event( "linkedReviewsChanged", json!({"workspaceId": watch.workspace_id}), @@ -291,6 +294,26 @@ impl ServerActor { } } +/// Appends a `pullRequest.watch` journal entry; the journal never fails the +/// watch. +async fn record_watch_event( + store: &alera_core::runtime::RuntimeStore, + watch: &PullRequestWatch, + action: &str, +) { + super::runtime_event_requests::record_runtime_event( + store, + "pullRequest.watch", + Some(&watch.workspace_id), + None, + &json!({ "number": watch.review_number, "action": action }), + ) + .await; +} + +/// Merges through the watched review's forge on the host that owns the +/// checkout, bound to the evaluated head so a newer push is never merged +/// unseen. async fn merge( store: &alera_core::runtime::RuntimeStore, links: &crate::terminal_host::host_link_registry::HostLinkRegistry, @@ -299,40 +322,23 @@ async fn merge( head: &str, method: &str, ) -> HostResult { + use super::pull_request_forges::{snapshot_forge, MergeMethod, WorkspaceRunner}; let _guard = super::mobile_pull_request_busy::BusyGuard::acquire(&watch.workspace_id)?; let workspace = store .find_workspace(&watch.workspace_id) .await .map_err(|e| HostError::state(e.to_string()))? .ok_or_else(|| HostError::state("Workspace disappeared."))?; - let identity = snapshot["remoteUrl"] - .as_str() - .and_then(super::mobile_pull_request_identity::parse_github_identity) - .ok_or_else(|| HostError::state("GitHub remote unavailable."))?; - let flag = match method { - "squash" => "--squash", - "rebase" => "--rebase", - _ => "--merge", - }; - let (code, _, stderr) = super::remote_pull_request_routing::run_gh_for_workspace( - store, - links, - &workspace, - &[ - "pr", - "merge", - &watch.review_number.to_string(), - "--repo", - &identity.slug, - flag, - "--match-head-commit", - head, - ], - ) - .await?; - if code != 0 { - return Err(HostError::state(stderr)); - } + let method = MergeMethod::parse(method) + .ok_or_else(|| HostError::state(format!("Unknown merge method: {method}")))?; + let runner = Arc::new(WorkspaceRunner { + store: store.clone(), + links: links.clone(), + workspace: workspace.clone(), + }); + let forge = snapshot_forge(snapshot, &workspace.path, Some(runner)) + .ok_or_else(|| HostError::state("The pull request remote is unavailable."))?; + forge.merge(watch.review_number, method, Some(head)).await?; Ok(head.to_string()) } diff --git a/rust/alera-cli/src/terminal_host/server/push_delivery.rs b/rust/alera-cli/src/terminal_host/server/push_delivery.rs index e800bbf65..306d99228 100644 --- a/rust/alera-cli/src/terminal_host/server/push_delivery.rs +++ b/rust/alera-cli/src/terminal_host/server/push_delivery.rs @@ -42,6 +42,10 @@ impl ServerActor { state_started_at: DateTime, transitioned: bool, ) { + if transitioned { + self.journal_agent_status(session_id, agent_type, state.as_str()) + .await; + } let settings = match self.runtime_store.mobile_push_settings().await { Ok(settings) if settings.enabled => settings, _ => return, @@ -77,6 +81,7 @@ impl ServerActor { session_id: &str, exit_code: Option, ) { + self.journal_terminal_exit(session_id, exit_code).await; let settings = match self.runtime_store.mobile_push_settings().await { Ok(settings) if settings.enabled && settings.terminal_exit => settings, _ => return, @@ -96,6 +101,8 @@ impl ServerActor { } pub(super) async fn queue_gate_push(&mut self, task_id: &str, question: &str) { + self.journal_task_attention("orchestration.gate.created", task_id) + .await; let settings = match self.runtime_store.mobile_push_settings().await { Ok(settings) if settings.enabled && settings.attention => settings, _ => return, @@ -110,6 +117,7 @@ impl ServerActor { /// One push per message an agent sends to an inbox, in the attention /// category so the cloud contract stays unchanged. pub(super) async fn queue_inbox_reply_push(&mut self, message: &OrchestrationMessage) { + self.journal_inbox_reply(message).await; if !alera_core::runtime::is_external_inbox(&message.to_handle) { return; } @@ -125,6 +133,8 @@ impl ServerActor { } pub(super) async fn queue_escalation_push(&mut self, task_id: &str, subject: &str) { + self.journal_task_attention("orchestration.escalation", task_id) + .await; let settings = match self.runtime_store.mobile_push_settings().await { Ok(settings) if settings.enabled && settings.attention => settings, _ => return, @@ -143,6 +153,7 @@ impl ServerActor { status: AutomationRunStatus, summary: Option<&str>, ) { + self.journal_automation_run(run, status).await; let settings = match self.runtime_store.mobile_push_settings().await { Ok(settings) if settings.enabled => settings, _ => return, diff --git a/rust/alera-cli/src/terminal_host/server/remote_ai_assist_requests.rs b/rust/alera-cli/src/terminal_host/server/remote_ai_assist_requests.rs index 90dbeaa1e..eaaa2d80d 100644 --- a/rust/alera-cli/src/terminal_host/server/remote_ai_assist_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/remote_ai_assist_requests.rs @@ -107,7 +107,14 @@ impl ServerActor { request_type: &str, payload: &Value, ) -> HostResult { - if !is_workspace_generation_verb(request_type) { + // A resumable generation is a runtime-owned job that forwards on its + // own (`ai_assist_pull_request_details_resume.rs`). + if !is_workspace_generation_verb(request_type) + || super::ai_assist_pull_request_details_resume::is_resumable_pull_request_details( + request_type, + payload, + ) + { return Ok(false); } let Some(workspace_id) = payload.get("workspaceId").and_then(Value::as_str) else { @@ -185,7 +192,7 @@ impl ServerActor { } } -async fn forward_generation( +pub(super) async fn forward_generation( store: &RuntimeStore, links: &HostLinkRegistry, request_type: &str, diff --git a/rust/alera-cli/src/terminal_host/server/remote_pull_request_routing.rs b/rust/alera-cli/src/terminal_host/server/remote_pull_request_routing.rs index 526caf23f..4b4a5cdce 100644 --- a/rust/alera-cli/src/terminal_host/server/remote_pull_request_routing.rs +++ b/rust/alera-cli/src/terminal_host/server/remote_pull_request_routing.rs @@ -1,7 +1,8 @@ //! Pull request work for a workspace whose checkout lives on another host. //! -//! `gh` has to run where the checkout and its credentials are, so the hub -//! forwards the `mobile.pullRequest.*` verbs to the satellite. The one piece of +//! The forge CLI (`gh`, `glab`, `az`) has to run where the checkout and its +//! credentials are, so the hub forwards the `mobile.pullRequest.*` and +//! single-checkout `pullRequestStack.*` verbs to the satellite. The one piece of //! hub-owned state those verbs read and write is the workspace's linked //! review, and the satellite's copy is disposable: every forwarded request //! carries the hub's record (`hubLinkedReview`, an object or an explicit null), @@ -9,7 +10,7 @@ //! changes the link the hub adopts what the satellite answered. The record //! therefore never has two owners. -use alera_core::runtime::{LinkedReview, RuntimeStore, Workspace}; +use alera_core::runtime::{LinkedReview, RuntimeStore}; use chrono::Utc; use serde_json::{json, Value}; @@ -21,10 +22,16 @@ use super::mobile_pull_request_actions::LINK_CHANGING_ACTIONS; pub(super) const HUB_LINKED_REVIEW_KEY: &str = "hubLinkedReview"; /// `summaries` spans every workspace of a project and has no `workspaceId`, -/// so it is answered where it is asked. +/// so it is answered where it is asked, and so is a stack create, whose +/// layers are workspaces of this runtime. The other stack verbs act on one +/// checkout and follow it. pub(super) fn is_forwarded_pull_request_verb(request_type: &str) -> bool { - request_type.starts_with("mobile.pullRequest.") - && request_type != "mobile.pullRequest.summaries" + (request_type.starts_with("mobile.pullRequest.") + && request_type != "mobile.pullRequest.summaries") + || matches!( + request_type, + "pullRequestStack.get" | "pullRequestStack.link" | "pullRequestStack.merge" + ) } /// Hub side: the payload to send to the satellite, carrying the hub's link and @@ -152,46 +159,6 @@ pub(super) async fn snapshot_for_workspace( } } -/// Runs `gh` in the workspace's checkout, locally or over the host link. -pub(super) async fn run_gh_for_workspace( - store: &RuntimeStore, - links: &HostLinkRegistry, - workspace: &Workspace, - args: &[&str], -) -> HostResult<(i32, String, String)> { - let payload = json!({ - "workspaceId": workspace.id, - "executable": "gh", - "arguments": args, - }); - let Some(output) = super::host_link_routing::forward_workspace_scoped_request( - store, - links, - super::host_process_requests::HOST_PROCESS_RUN, - &payload, - ) - .await? - else { - return super::mobile_pull_request_requests::run_gh(&workspace.path, args).await; - }; - let text = |key: &str| { - output - .get(key) - .and_then(Value::as_str) - .unwrap_or_default() - .to_string() - }; - Ok(( - output - .get("exitCode") - .and_then(Value::as_i64) - .and_then(|code| i32::try_from(code).ok()) - .unwrap_or(1), - text("stdout"), - text("stderr"), - )) -} - #[cfg(test)] #[path = "remote_pull_request_routing_tests.rs"] mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/remote_relay.rs b/rust/alera-cli/src/terminal_host/server/remote_relay.rs index 2a32fb2a4..c2df84f22 100644 --- a/rust/alera-cli/src/terminal_host/server/remote_relay.rs +++ b/rust/alera-cli/src/terminal_host/server/remote_relay.rs @@ -61,6 +61,7 @@ impl ServerActor { authenticated: false, shared_checkout_workspaces: false, checkout_buffer_guards: false, + checkout_buffer_save: false, workspace_focus: false, binary_frames: false, kind: ClientKind::Mobile, diff --git a/rust/alera-cli/src/terminal_host/server/remote_storage_impact.rs b/rust/alera-cli/src/terminal_host/server/remote_storage_impact.rs new file mode 100644 index 000000000..ad6808359 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/remote_storage_impact.rs @@ -0,0 +1,68 @@ +//! `workspace.storageImpact` for a linked workspace on an SSH host. +//! +//! Its worktree lives on the satellite, so only the satellite can measure it +//! and check that it sits in Alera-managed storage there. Measuring it on the +//! hub always reported "Workspace is not owned by the local host", which made +//! the Remove action show Cleanup Unavailable for every remote worktree. + +use alera_core::runtime::{RuntimeStore, WorkspaceKind}; +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::host_link_registry::HostLinkRegistry; + +/// Measures a remote linked workspace on the host that owns it. `Ok(None)` +/// means the workspace is local (or a shared checkout) and the hub measures +/// it as before. `hub_blockers` are the checks only the hub can make, such as +/// its own sessions and automations; they come first in the answer. +pub(super) async fn measure_on_owner( + store: &RuntimeStore, + links: &HostLinkRegistry, + workspace_id: &str, + close_sessions: bool, + hub_blockers: &[String], +) -> HostResult> { + let Some(workspace) = store + .find_workspace(workspace_id) + .await + .map_err(|error| HostError::state(error.to_string()))? + else { + return Ok(None); + }; + if workspace.kind == WorkspaceKind::Main + || !crate::ssh_remote::is_remote_host_id(Some(&workspace.host_id)) + { + return Ok(None); + } + let (link, _) = crate::terminal_host::server::host_link_routing::mirror_workspace( + store, + links, + workspace_id, + ) + .await?; + let measured = link + .request_with_timeout( + "workspace.storageImpact", + json!({ "id": workspace_id, "closeSessions": close_sessions }), + crate::terminal_host::host_link::DEFAULT_REQUEST_TIMEOUT, + ) + .await?; + Ok(Some(merge_blockers(measured, workspace_id, hub_blockers))) +} + +fn merge_blockers(mut measured: Value, workspace_id: &str, hub_blockers: &[String]) -> Value { + let mut blockers: Vec = hub_blockers.iter().cloned().map(Value::String).collect(); + for blocker in measured["blockers"].as_array().into_iter().flatten() { + if !blockers.contains(blocker) { + blockers.push(blocker.clone()); + } + } + measured["workspaceId"] = json!(workspace_id); + measured["safeToClean"] = json!(blockers.is_empty()); + measured["blockers"] = Value::Array(blockers); + measured +} + +#[cfg(all(test, unix))] +#[path = "remote_storage_impact_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/remote_storage_impact_tests.rs b/rust/alera-cli/src/terminal_host/server/remote_storage_impact_tests.rs new file mode 100644 index 000000000..0de78f53f --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/remote_storage_impact_tests.rs @@ -0,0 +1,167 @@ +use std::sync::Arc; + +use alera_core::runtime::{ + Project, ProjectKind, SshAuthKind, SshBootstrapStatus, SshTarget, Workspace, WorkspaceStatus, +}; +use chrono::Utc; + +use super::*; +use crate::terminal_host::host_link::HostLinkLauncher; + +/// A satellite that registers the mirrored workspace and measures it: the +/// worktree is clean unless the hub asked to keep its sessions running. +const SATELLITE: &str = r#" +printf '%s\n' '{"event":"hostLink.attached","payload":{"runtimeDir":"/sat","platform":"linux","arch":"x86_64","hostVersion":"9.9.9","runtimeCapabilities":["remoteSatelliteV1"]}}' +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"type":"workspace.storageImpact"'*'"closeSessions":true'*|*'"closeSessions":true'*'"type":"workspace.storageImpact"'*) + printf '{"id":%s,"ok":true,"payload":{"workspaceId":"task","path":"/remote/task","sizeBytes":42,"entryCount":3,"measuredAt":"2026-10-10T00:00:00Z","lastActivityAt":"2026-10-10T00:00:00Z","safeToClean":true,"blockers":[]}}\n' "$id" ;; + *'"type":"workspace.storageImpact"'*) + printf '{"id":%s,"ok":true,"payload":{"workspaceId":"task","path":"/remote/task","sizeBytes":42,"entryCount":3,"measuredAt":"2026-10-10T00:00:00Z","lastActivityAt":"2026-10-10T00:00:00Z","safeToClean":false,"blockers":["Workspace has a live terminal session or process"]}}\n' "$id" ;; + *) printf '{"id":%s,"ok":true,"payload":{"id":"task"}}\n' "$id" ;; + esac +done +"#; + +fn launcher() -> Arc { + Arc::new(|_target: &SshTarget| { + let mut command = alera_core::child_process::windowless_async_command("sh"); + command.arg("-c").arg(SATELLITE); + Ok(command) + }) +} + +fn workspace(id: &str, host_id: &str, kind: WorkspaceKind) -> Workspace { + Workspace { + id: id.into(), + instance_id: format!("{id}-instance"), + host_id: host_id.into(), + project_id: "project".into(), + name: id.into(), + branch: Some(id.into()), + path: "/remote/task".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + kind, + status: WorkspaceStatus::Active, + source_branch: None, + reuses_existing_branch: false, + is_pinned: false, + is_archived: false, + tag_ids: vec![], + tag_names: vec![], + section_id: None, + parent_workspace_id: None, + child_count: 0, + } +} + +async fn remote_fixture(directory: &tempfile::TempDir) -> (RuntimeStore, HostLinkRegistry) { + let store = RuntimeStore::open(directory.path()).await.unwrap(); + let now = Utc::now(); + store + .upsert_ssh_target(SshTarget { + id: "ssh".into(), + alias: "Lab".into(), + host: "lab.local".into(), + port: 22, + username: "dev".into(), + platform: Some("linux".into()), + arch: None, + auth_kind: SshAuthKind::Agent, + created_at: now, + updated_at: now, + last_status: None, + install_dir: Some("~/.alera/sidecar".into()), + projects_dir: None, + runtime_version: None, + runtime_platform: Some("linux".into()), + runtime_arch: None, + bootstrap_status: SshBootstrapStatus::Installed, + last_bootstrap_at: None, + last_checked_at: None, + last_error: None, + }) + .await + .unwrap(); + store + .upsert_project(Project { + id: "project".into(), + name: "Project".into(), + repo_path: "/home-only".into(), + kind: ProjectKind::GitRepository, + created_at: now, + updated_at: now, + }) + .await + .unwrap(); + store + .register_project_checkout("project", "ssh", "/remote/project") + .await + .unwrap(); + store + .insert_workspace_with_repository( + workspace("task", "ssh", WorkspaceKind::Linked), + "/remote/project", + ) + .await + .unwrap(); + let (inbox, _rx) = crate::terminal_host::ServerInbox::channel(); + let links = HostLinkRegistry::with_launcher(store.clone(), inbox, launcher()); + (store, links) +} + +#[tokio::test] +async fn a_remote_linked_workspace_is_measured_by_its_satellite() { + let directory = tempfile::tempdir().unwrap(); + let (store, links) = remote_fixture(&directory).await; + + let impact = measure_on_owner(&store, &links, "task", true, &[]) + .await + .unwrap() + .expect("a remote linked workspace is measured remotely"); + + assert_eq!(impact["workspaceId"], "task"); + assert_eq!(impact["sizeBytes"], 42); + assert_eq!(impact["safeToClean"], true, "{impact}"); + assert_eq!(impact["blockers"], json!([])); +} + +#[tokio::test] +async fn hub_and_satellite_blockers_are_merged() { + let directory = tempfile::tempdir().unwrap(); + let (store, links) = remote_fixture(&directory).await; + let hub = vec!["Workspace is owned by an active automation".to_string()]; + + let impact = measure_on_owner(&store, &links, "task", false, &hub) + .await + .unwrap() + .unwrap(); + + assert_eq!(impact["safeToClean"], false); + assert_eq!( + impact["blockers"], + json!([ + "Workspace is owned by an active automation", + "Workspace has a live terminal session or process", + ]) + ); +} + +#[tokio::test] +async fn local_and_unknown_workspaces_stay_on_the_hub() { + let directory = tempfile::tempdir().unwrap(); + let (store, links) = remote_fixture(&directory).await; + store + .upsert_workspace(workspace("local", "local", WorkspaceKind::Linked)) + .await + .unwrap(); + + for id in ["local", "missing"] { + assert!(measure_on_owner(&store, &links, id, true, &[]) + .await + .unwrap() + .is_none()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/requests.rs b/rust/alera-cli/src/terminal_host/server/requests.rs index f3f37ebf2..61bc7ead0 100644 --- a/rust/alera-cli/src/terminal_host/server/requests.rs +++ b/rust/alera-cli/src/terminal_host/server/requests.rs @@ -313,6 +313,8 @@ impl ServerActor { } if self.is_mobile_client(client_id) { self.restart_mobile_terminal(client_id, payload).await + } else if payload["headless"] == true { + self.restart_terminal_headless(client_id, payload).await } else { self.restart_terminal(client_id, payload).await } @@ -401,6 +403,10 @@ impl ServerActor { self.pull_request_watch_request(client_id, request_type, payload) .await } + "pullRequest.agentDispatch" => { + self.require_request_allowed(client_id, request_type)?; + self.pull_request_agent_dispatch(client_id, payload).await + } "workspaceSection.list" | "workspaceSection.create" | "workspaceSection.setForWorkspace" @@ -415,6 +421,7 @@ impl ServerActor { } "workspaceActivity.list" => self.workspace_activity(client_id).await, "workspace.sleptTabs" => self.slept_workspace_tabs(client_id).await, + "workspace.wake" => self.wake_workspace_request(client_id, payload).await, "workspaceActivity.upsertAll" => { self.upsert_workspace_activity(client_id, payload).await } @@ -472,6 +479,37 @@ impl ServerActor { self.require_auth(client_id)?; self.project_clone_cancel_request(payload).await } + "runtimeEvents.list" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + self.runtime_events_list_request(payload).await + } + "workspace.promptStart.start" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + self.prompt_workspace_start_request(client_id, payload) + .await + } + "workspace.promptStart.get" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + self.prompt_workspace_get_request(payload).await + } + "workspace.promptStart.list" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + self.prompt_workspace_list_request(payload).await + } + "workspace.promptStart.cancel" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + self.prompt_workspace_cancel_request(payload) + } + "workspace.promptStart.retryLaunch" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + self.prompt_workspace_retry_launch_request(payload).await + } "project.upsert" => { self.require_auth(client_id)?; @@ -600,6 +638,34 @@ impl ServerActor { serde_json::to_value(workspaces) .map_err(|error| HostError::state(error.to_string())) } + other => { + // Two halves keep each debug poll frame small enough for the + // nested lifecycle requests on the default test stack. + Box::pin(self.handle_more_authenticated_requests(client_id, other, payload)).await + } + } + } + + async fn handle_more_authenticated_requests( + &mut self, + client_id: u64, + request_type: &str, + payload: &Value, + ) -> HostResult { + match request_type { + "agentSkill.state" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + Ok(super::agent_skill_installs::install_state()) + } + "workspace.show" => { + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + let id = require_string_key(payload, "id")?; + crate::workspace_show::show(&self.runtime_store, &id) + .await + .map_err(|error| HostError::state(error.to_string())) + } "workspace.find" => { self.require_auth(client_id)?; let id = require_string_key(payload, "id")?; diff --git a/rust/alera-cli/src/terminal_host/server/runtime_event_forwarder.rs b/rust/alera-cli/src/terminal_host/server/runtime_event_forwarder.rs new file mode 100644 index 000000000..9e1800968 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/runtime_event_forwarder.rs @@ -0,0 +1,220 @@ +//! Sends the runtime event journal to the Alera cloud for webhooks and MCP +//! Events, only while some subscription wants this runtime's events. +//! +//! Turning on MCP Control or signing in never starts a flow of events by +//! itself: with no subscription, the forwarder marks events as handled +//! without sending them, so a later subscription does not replay a backlog. +//! It only skips events that occurred before the last refresh that reported +//! no subscriptions; newer events wait for the next refresh (at most one +//! minute, or right away after `webhook.create`), so a subscription created +//! in between still receives them. +//! +//! A batch never blocks the journal: the cloud stores the valid events of a +//! batch and reports the others as `rejected`, and a batch that the cloud +//! refuses as a whole with a definitive client error is logged and skipped. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; + +use alera_core::runtime::{RuntimeEvent, RuntimeStore}; +use chrono::{DateTime, Utc}; +use tokio::sync::Notify; +use tokio::task::JoinHandle; + +use crate::terminal_host::alera_account::AleraAccountService; + +/// The edge allows ten account mutations a minute per token, shared with +/// the runtime's other cloud calls, so batches go out at most every ten seconds. +const TICK: Duration = Duration::from_secs(10); +const SUBSCRIPTION_REFRESH: Duration = Duration::from_secs(60); +const BATCH: i64 = 100; +const BACKOFF_LIMIT: Duration = Duration::from_secs(300); + +/// Bumped by [`request_subscription_refresh`]; a forwarder that sees a new +/// value refreshes its subscription count on its next tick. +static REFRESH_REQUESTS: AtomicU64 = AtomicU64::new(0); +static REFRESH_WAKE: LazyLock = LazyLock::new(Notify::new); + +/// Asks the forwarder to refresh the subscription count now, for example +/// after this runtime created a webhook. +pub(super) fn request_subscription_refresh() { + REFRESH_REQUESTS.fetch_add(1, Ordering::SeqCst); + REFRESH_WAKE.notify_waiters(); +} + +pub(super) fn spawn(store: RuntimeStore, service: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + let mut forwarder = Forwarder::new(store, service); + loop { + tokio::select! { + () = tokio::time::sleep(forwarder.next_delay()) => {} + () = REFRESH_WAKE.notified() => {} + } + forwarder.tick().await; + } + }) +} + +struct Forwarder { + store: RuntimeStore, + service: Arc, + active_subscriptions: usize, + refreshed_at: Option, + /// When the cloud last reported no subscriptions; only events that + /// occurred before it may be skipped. + idle_since: Option>, + refresh_requests: u64, + failures: u32, + pruned_at: Option, +} + +impl Forwarder { + fn new(store: RuntimeStore, service: Arc) -> Self { + Self { + store, + service, + active_subscriptions: 0, + refreshed_at: None, + idle_since: None, + refresh_requests: REFRESH_REQUESTS.load(Ordering::SeqCst), + failures: 0, + pruned_at: None, + } + } + + fn next_delay(&self) -> Duration { + let backoff = TICK.saturating_mul(2u32.saturating_pow(self.failures.min(6))); + backoff.min(BACKOFF_LIMIT) + } + + fn record_count(&mut self, count: usize) { + self.active_subscriptions = count; + self.refreshed_at = Some(Instant::now()); + self.idle_since = (count == 0).then(Utc::now); + } + + async fn tick(&mut self) { + self.prune_daily().await; + if !matches!(self.service.local_account().await, Ok(Some(_))) { + return; + } + let requests = REFRESH_REQUESTS.load(Ordering::SeqCst); + if requests != self.refresh_requests { + self.refresh_requests = requests; + self.refreshed_at = None; + } + if self + .refreshed_at + .is_none_or(|refreshed| refreshed.elapsed() >= SUBSCRIPTION_REFRESH) + { + match self.service.event_subscription_count().await { + Ok(count) => self.record_count(count), + Err(error) => { + // An older cloud without the endpoint has no subscriptions. + tracing::debug!("event subscriptions unavailable: {error}"); + self.failures = self.failures.saturating_add(1); + return; + } + } + } + let Ok(batch) = self.store.list_unforwarded_runtime_events(BATCH).await else { + return; + }; + let Some(last) = batch.last().map(|event| event.seq) else { + return; + }; + if self.active_subscriptions == 0 { + let skippable = self + .idle_since + .and_then(|idle_since| skippable_through(&batch, idle_since)); + if let Some(seq) = skippable { + let _ = self.store.mark_runtime_events_forwarded(seq).await; + } + return; + } + match self.service.forward_domain_events(&batch).await { + Ok(Some(count)) => { + self.record_count(count); + self.failures = 0; + let _ = self.store.mark_runtime_events_forwarded(last).await; + } + Ok(None) => { + // Resending the same batch would be refused again and hold every + // later event back, so it is skipped (the service logged why). + self.failures = 0; + let _ = self.store.mark_runtime_events_forwarded(last).await; + } + Err(error) => { + tracing::warn!("could not forward runtime events: {error}"); + self.failures = self.failures.saturating_add(1); + } + } + } + + async fn prune_daily(&mut self) { + if self + .pruned_at + .is_some_and(|pruned| pruned.elapsed() < Duration::from_secs(24 * 60 * 60)) + { + return; + } + self.pruned_at = Some(Instant::now()); + if let Err(error) = self.store.prune_runtime_events().await { + tracing::warn!("could not prune runtime events: {error}"); + } + } +} + +/// The last sequence number of the leading events that occurred before +/// `idle_since`. Events with an unreadable timestamp count as old. +fn skippable_through(batch: &[RuntimeEvent], idle_since: DateTime) -> Option { + batch + .iter() + .take_while(|event| { + DateTime::parse_from_rfc3339(&event.occurred_at) + .map_or(true, |occurred| occurred.with_timezone(&Utc) < idle_since) + }) + .last() + .map(|event| event.seq) +} + +#[cfg(test)] +mod tests { + use alera_core::runtime::RuntimeEvent; + use chrono::{DateTime, TimeDelta, Utc}; + use serde_json::json; + + use super::skippable_through; + + fn event(seq: i64, occurred_at: String) -> RuntimeEvent { + RuntimeEvent { + seq, + event_id: format!("event-{seq}"), + kind: "agent.status".to_owned(), + workspace_id: None, + project_id: None, + data: json!({}), + occurred_at, + } + } + + fn at(base: DateTime, seconds: i64) -> String { + (base + TimeDelta::seconds(seconds)).to_rfc3339() + } + + #[test] + fn runtime_event_skipping_stops_at_events_newer_than_the_idle_refresh() { + let idle_since = Utc::now(); + let batch = [ + event(1, at(idle_since, -30)), + event(2, "not a time".to_owned()), + event(3, at(idle_since, -1)), + event(4, at(idle_since, 5)), + event(5, at(idle_since, -2)), + ]; + assert_eq!(skippable_through(&batch, idle_since), Some(3)); + assert_eq!(skippable_through(&batch[3..], idle_since), None); + assert_eq!(skippable_through(&[], idle_since), None); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/runtime_event_requests.rs b/rust/alera-cli/src/terminal_host/server/runtime_event_requests.rs new file mode 100644 index 000000000..7f567ac04 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/runtime_event_requests.rs @@ -0,0 +1,322 @@ +//! The runtime event journal: recording domain events and reading them back. +//! +//! Every event carries identifiers and states only, never prompts, terminal +//! output, source code, or message text, so it is safe to forward to a client +//! or a webhook. Readers page with `runtimeEvents.list` and a cursor; local +//! clients also receive `runtimeEventsAppended` as a hint to read again. + +use alera_core::runtime::{RuntimeEventFilter, RuntimeStore}; +use serde_json::{json, Map, Value}; + +use super::ServerActor; +use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::protocol::event; + +/// Every kind the journal records, with the only data keys it may carry. +pub(crate) const EVENT_KINDS: &[(&str, &[&str])] = &[ + ( + "inbox.reply", + &[ + "inbox", + "threadId", + "questionId", + "messageId", + "originClientId", + ], + ), + ( + "inbox.question.status", + &["inbox", "threadId", "questionId", "status"], + ), + ( + "agent.status", + &["tabId", "sessionId", "agentType", "state"], + ), + ("terminal.exit", &["tabId", "sessionId", "exitCode"]), + ("orchestration.task.state", &["taskId", "runId", "state"]), + ("orchestration.gate.created", &["gateId", "taskId", "runId"]), + ( + "orchestration.escalation", + &["taskId", "runId", "messageId"], + ), + ("automation.run.state", &["automationId", "runId", "status"]), + ("workspace.start.state", &["operationId", "status", "phase"]), + ("workspace.lifecycle", &["action"]), + ("pullRequest.watch", &["number", "action"]), +]; + +/// Keeps only the keys the kind allows, so a caller can never put text +/// beyond identifiers and states into the journal. +pub(crate) fn allowed_event_data(kind: &str, data: &Value) -> Option { + let (_, keys) = EVENT_KINDS.iter().find(|(name, _)| *name == kind)?; + let mut allowed = Map::new(); + for key in *keys { + if let Some(value) = data.get(*key).filter(|value| is_scalar(value)) { + allowed.insert((*key).to_owned(), value.clone()); + } + } + Some(Value::Object(allowed)) +} + +/// `(workspaceId, projectId, action)` for each workspace a mutation changed. +fn lifecycle_events( + effect: &super::runtime_mutations::RuntimeMutationEffect, +) -> Vec<(String, Option, &'static str)> { + use super::runtime_mutations::RuntimeMutationEffect as Effect; + match effect { + Effect::WorkspaceRemoved { workspace_id } => { + vec![(workspace_id.clone(), None, "removed")] + } + Effect::ManagedWorkspaceRemoved { + project_id, + workspace_id, + } => vec![(workspace_id.clone(), Some(project_id.clone()), "removed")], + Effect::ProjectRemoved { + project_id, + workspace_ids, + } + | Effect::ProjectWorkspacesRemoved { + project_id, + workspace_ids, + } => workspace_ids + .iter() + .map(|id| (id.clone(), Some(project_id.clone()), "removed")) + .collect(), + Effect::WorkspaceRelocated { + project_id, + workspace_id, + .. + } => vec![(workspace_id.clone(), Some(project_id.clone()), "relocated")], + Effect::WorkspaceSlept { workspace_id } => vec![(workspace_id.clone(), None, "slept")], + Effect::WorkspaceArchived { workspace_id } => { + vec![(workspace_id.clone(), None, "archived")] + } + Effect::SetupFinished | Effect::TabRemoved { .. } | Effect::WorkspaceTabsRemoved { .. } => { + Vec::new() + } + } +} + +fn is_scalar(value: &Value) -> bool { + match value { + Value::String(text) => text.len() <= 256, + Value::Number(_) | Value::Bool(_) | Value::Null => true, + _ => false, + } +} + +/// Appends one event; a write failure only logs, it never fails the action +/// that produced the event. +pub(crate) async fn record_runtime_event( + store: &RuntimeStore, + kind: &str, + workspace_id: Option<&str>, + project_id: Option<&str>, + data: &Value, +) -> Option { + let Some(data) = allowed_event_data(kind, data) else { + tracing::warn!(kind, "unknown runtime event kind"); + return None; + }; + match store + .append_runtime_event(kind, workspace_id, project_id, &data) + .await + { + Ok(seq) => Some(seq), + Err(error) => { + tracing::warn!(kind, "could not record runtime event: {error}"); + None + } + } +} + +impl ServerActor { + /// Records an event and tells local clients to read the journal again. + pub(super) async fn record_event( + &self, + kind: &str, + workspace_id: Option<&str>, + project_id: Option<&str>, + data: Value, + ) { + if let Some(seq) = + record_runtime_event(&self.runtime_store, kind, workspace_id, project_id, &data).await + { + self.broadcast_runtime_events_appended(seq, kind); + } + } + + /// Workspace and tab of a live session, for events about it. + fn session_place(&self, session_id: &str) -> (Option, Option) { + self.sessions + .get(session_id) + .map_or((None, None), |session| { + ( + Some(session.workspace_id.clone()), + Some(session.tab_id.clone()), + ) + }) + } + + pub(super) async fn journal_agent_status( + &self, + session_id: &str, + agent_type: &str, + state: &str, + ) { + let (workspace_id, tab_id) = self.session_place(session_id); + let data = json!({ "sessionId": session_id, "tabId": tab_id, "agentType": agent_type, "state": state }); + self.record_event("agent.status", workspace_id.as_deref(), None, data) + .await; + } + + pub(super) async fn journal_terminal_exit(&self, session_id: &str, exit_code: Option) { + let (workspace_id, tab_id) = self.session_place(session_id); + let data = json!({ "sessionId": session_id, "tabId": tab_id, "exitCode": exit_code }); + self.record_event("terminal.exit", workspace_id.as_deref(), None, data) + .await; + } + + pub(super) async fn journal_task_attention(&self, kind: &str, task_id: &str) { + let task = self + .runtime_store + .orchestration_task_by_id(task_id) + .await + .ok() + .flatten(); + let (workspace_id, run_id) = + task.map_or((None, None), |task| (Some(task.workspace_id), task.run_id)); + let data = json!({ "taskId": task_id, "runId": run_id }); + self.record_event(kind, workspace_id.as_deref(), None, data) + .await; + } + + pub(super) async fn journal_inbox_reply( + &self, + message: &alera_core::runtime::OrchestrationMessage, + ) { + if !alera_core::runtime::is_external_inbox(&message.to_handle) { + return; + } + let data = json!({ + "inbox": message.to_handle, + "threadId": message.thread_id.clone().unwrap_or_else(|| message.id.clone()), + "questionId": message.reply_to_id, + "messageId": message.id, + }); + self.record_event("inbox.reply", message.workspace_id.as_deref(), None, data) + .await; + } + + pub(super) async fn journal_automation_run( + &self, + run: &alera_core::runtime::AutomationRun, + status: alera_core::runtime::AutomationRunStatus, + ) { + let data = json!({ "automationId": run.automation_id, "runId": run.id, "status": status }); + self.record_event( + "automation.run.state", + run.workspace_id.as_deref(), + None, + data, + ) + .await; + } + + /// Journals the workspace lifecycle change a runtime mutation applied. + pub(super) async fn record_workspace_lifecycle( + &self, + effect: &super::runtime_mutations::RuntimeMutationEffect, + ) { + for (workspace_id, project_id, action) in lifecycle_events(effect) { + self.record_event( + "workspace.lifecycle", + Some(&workspace_id), + project_id.as_deref(), + json!({ "action": action }), + ) + .await; + } + } + + pub(super) fn broadcast_runtime_events_appended(&self, seq: i64, kind: &str) { + self.broadcast_authenticated_local(event( + "runtimeEventsAppended", + json!({ "seq": seq, "kind": kind }), + )); + } + + pub(super) async fn runtime_events_list_request(&self, payload: &Value) -> HostResult { + let kinds = payload + .get("kinds") + .and_then(Value::as_array) + .map(|kinds| { + kinds + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + if let Some(unknown) = kinds + .iter() + .find(|kind| !EVENT_KINDS.iter().any(|(name, _)| name == kind)) + { + return Err(HostError::format(format!("Unknown event kind: {unknown}"))); + } + let filter = RuntimeEventFilter { + after: payload + .get("after") + .and_then(Value::as_i64) + .unwrap_or(0) + .max(0), + kinds, + workspace_id: payload + .get("workspaceId") + .and_then(Value::as_str) + .map(str::to_owned), + limit: payload.get("limit").and_then(Value::as_i64).unwrap_or(100), + }; + let page = self + .runtime_store + .list_runtime_events(&filter) + .await + .map_err(|error| HostError::state(error.to_string()))?; + serde_json::to_value(page).map_err(|error| HostError::state(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{allowed_event_data, EVENT_KINDS}; + + #[test] + fn event_data_keeps_only_allowed_scalar_keys() { + let data = allowed_event_data( + "inbox.reply", + &json!({ + "threadId": "t-1", + "questionId": "q-1", + "body": "the reply text", + "messageId": { "nested": true }, + }), + ) + .unwrap(); + assert_eq!(data, json!({ "threadId": "t-1", "questionId": "q-1" })); + assert!(allowed_event_data("made.up", &json!({})).is_none()); + } + + #[test] + fn no_kind_allows_text_payload_keys() { + for (kind, keys) in EVENT_KINDS { + for key in *keys { + let lower = key.to_lowercase(); + for forbidden in ["prompt", "body", "text", "output", "command", "subject"] { + assert!(!lower.contains(forbidden), "{kind} allows {key}"); + } + } + } + } +} diff --git a/rust/alera-cli/src/terminal_host/server/runtime_mutation_barrier.rs b/rust/alera-cli/src/terminal_host/server/runtime_mutation_barrier.rs index d668b156c..2b0b3b72e 100644 --- a/rust/alera-cli/src/terminal_host/server/runtime_mutation_barrier.rs +++ b/rust/alera-cli/src/terminal_host/server/runtime_mutation_barrier.rs @@ -47,6 +47,7 @@ pub(super) fn conflicts_with_runtime_mutation(request_type: &str) -> bool { | "workspace.rename" | "workspace.setPinned" | "workspace.unarchive" + | "workspace.wake" | "workspace.upsert" | "workspaceActivity.remove" | "workspaceActivity.upsertAll" diff --git a/rust/alera-cli/src/terminal_host/server/server_actor_gateway_tests.rs b/rust/alera-cli/src/terminal_host/server/server_actor_gateway_tests.rs index ccddd99d1..b8043c5aa 100644 --- a/rust/alera-cli/src/terminal_host/server/server_actor_gateway_tests.rs +++ b/rust/alera-cli/src/terminal_host/server/server_actor_gateway_tests.rs @@ -34,6 +34,7 @@ async fn stale_ssh_bootstrap_progress_is_not_broadcast() { inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), @@ -126,6 +127,7 @@ async fn mobile_gateway_rebinds_same_port_after_releasing_old_listener() { inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), @@ -236,6 +238,7 @@ async fn run_stop_clears_persisted_run_without_in_memory_ticker() { inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), diff --git a/rust/alera-cli/src/terminal_host/server/server_actor_orchestration_tests.rs b/rust/alera-cli/src/terminal_host/server/server_actor_orchestration_tests.rs index 08a65825b..160e84dc7 100644 --- a/rust/alera-cli/src/terminal_host/server/server_actor_orchestration_tests.rs +++ b/rust/alera-cli/src/terminal_host/server/server_actor_orchestration_tests.rs @@ -48,6 +48,7 @@ async fn terminal_exit_fails_active_orchestration_dispatch() { inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), @@ -175,6 +176,7 @@ async fn host_dispose_fails_active_orchestration_dispatch() { inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), @@ -275,6 +277,7 @@ async fn coordinator_does_not_spawn_worker_tab_for_cli_only_client() { inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), diff --git a/rust/alera-cli/src/terminal_host/server/server_command.rs b/rust/alera-cli/src/terminal_host/server/server_command.rs index f1488a249..814dd55b4 100644 --- a/rust/alera-cli/src/terminal_host/server/server_command.rs +++ b/rust/alera-cli/src/terminal_host/server/server_command.rs @@ -334,6 +334,12 @@ pub enum ServerCommand { ProjectCloneFinished { job_id: String, }, + PromptWorkspaceOperationChanged { + operation_id: String, + }, + PromptWorkspaceOperationFinished { + operation_id: String, + }, /// One coordinator loop iteration, enqueued by the ticker task. CoordinatorTick { run_id: String, diff --git a/rust/alera-cli/src/terminal_host/server/server_command_inbox_budget.rs b/rust/alera-cli/src/terminal_host/server/server_command_inbox_budget.rs index 38d5c8713..01fe927ce 100644 --- a/rust/alera-cli/src/terminal_host/server/server_command_inbox_budget.rs +++ b/rust/alera-cli/src/terminal_host/server/server_command_inbox_budget.rs @@ -121,6 +121,8 @@ fn command_control_bytes(command: &ServerCommand) -> usize { | ServerCommand::LinkedIssuesChanged { workspace_id: id } | ServerCommand::ProjectCloneChanged { job_id: id } | ServerCommand::ProjectCloneFinished { job_id: id } + | ServerCommand::PromptWorkspaceOperationChanged { operation_id: id } + | ServerCommand::PromptWorkspaceOperationFinished { operation_id: id } | ServerCommand::HubReverseRequestExpired { reverse_id: id } | ServerCommand::HostLinkStateChanged { host_id: id } | ServerCommand::HostLinkClosed { host_id: id, .. } @@ -457,6 +459,7 @@ fn is_completion_command(command: &ServerCommand) -> bool { | ServerCommand::AutomationSharedCleanupFinished { .. } | ServerCommand::WorkflowWorkspaceRecoveryFinished | ServerCommand::ProjectCloneFinished { .. } + | ServerCommand::PromptWorkspaceOperationFinished { .. } | ServerCommand::OrchestrationCompletionFinished(_) | ServerCommand::Account(_) | ServerCommand::Push( diff --git a/rust/alera-cli/src/terminal_host/server/session_termination.rs b/rust/alera-cli/src/terminal_host/server/session_termination.rs index f49f50e4e..bda8e6880 100644 --- a/rust/alera-cli/src/terminal_host/server/session_termination.rs +++ b/rust/alera-cli/src/terminal_host/server/session_termination.rs @@ -255,6 +255,7 @@ impl ServerActor { } pub(super) async fn apply_runtime_mutation_effect(&mut self, effect: RuntimeMutationEffect) { + self.record_workspace_lifecycle(&effect).await; match effect { RuntimeMutationEffect::SetupFinished => {} RuntimeMutationEffect::ProjectRemoved { diff --git a/rust/alera-cli/src/terminal_host/server/terminal_launch_defaults.rs b/rust/alera-cli/src/terminal_host/server/terminal_launch_defaults.rs index 7ce2131bf..bfbf98c06 100644 --- a/rust/alera-cli/src/terminal_host/server/terminal_launch_defaults.rs +++ b/rust/alera-cli/src/terminal_host/server/terminal_launch_defaults.rs @@ -87,6 +87,8 @@ fn default_terminal_launch_for( async fn terminal_environment() -> BTreeMap { let mut environment = std::env::vars().collect::>(); + // Only the CLI an MCP call runs carries its origin, never a terminal. + environment.remove(crate::mcp_tools::ORIGIN_VARIABLE); if !cfg!(windows) { if let Some(path) = crate::login_shell_environment::login_shell_merged_path( environment.get("PATH").map(String::as_str), diff --git a/rust/alera-cli/src/terminal_host/server/terminal_spawn.rs b/rust/alera-cli/src/terminal_host/server/terminal_spawn.rs index cd1fa858a..2ac3be99f 100644 --- a/rust/alera-cli/src/terminal_host/server/terminal_spawn.rs +++ b/rust/alera-cli/src/terminal_host/server/terminal_spawn.rs @@ -19,6 +19,7 @@ use super::ServerActor; const DEFAULT_TERMINAL_COLS: u16 = 80; const DEFAULT_TERMINAL_ROWS: u16 = 24; +mod headless_restart; mod tab_spawn; impl ServerActor { @@ -172,48 +173,65 @@ impl ServerActor { permit, ) .await?; - let command = match resolve_spawn_command(tab, &default_launch.interactive_shell)? { + if let Some(spent) = self + .deliver_tab_startup_command(tab, &session_id, default_launch.interactive_shell, permit) + .await? + { + return Ok(spent); + } + Ok(rearmed) + } + + /// Types the tab's startup command into its new PTY. Returns `Some` when + /// a one-shot command or prompt was spent, holding the tab as saved + /// without it (`None` inside when that save failed). + pub(super) async fn deliver_tab_startup_command( + &mut self, + tab: &WorkspaceTabRecord, + session_id: &str, + interactive_shell: String, + permit: Option<&WorkflowLaunchPermit>, + ) -> HostResult>> { + let command = match resolve_spawn_command(tab, &interactive_shell)? { Some(SpawnCommand::Stdin { command, prompt }) => Some(if permit.is_some() { let directory = self .setup_script_directory() .ok_or_else(|| HostError::state("workflow prompt directory is unavailable"))?; crate::agent_prompt_stdin_script::write_agent_prompt_stdin_script( - &directory, - &session_id, - &command, - &prompt, + &directory, session_id, &command, &prompt, ) .map_err(|error| HostError::state(error.to_string()))? .command } else { - self.stdin_prompt_command(&session_id, &command, &prompt) + self.stdin_prompt_command(session_id, &command, &prompt) }), Some(SpawnCommand::Line(command)) => Some(command), None => None, }; - if let Some(command) = command { - let instance_id = self - .sessions - .get(&session_id) - .map(Session::instance_id) - .expect("spawned terminal was inserted"); - self.schedule_terminal_startup_input( - session_id, - instance_id, - default_launch.interactive_shell, - command, - ); - // A one-shot command is spent as soon as it is on its way. Agent - // tabs deliberately do not set the flag: they re-mint their command - // on every new PTY, including after host recovery. - if delivers_initial_command_once(tab) { - return Ok(self.clear_initial_command(tab).await); - } - if delivers_initial_prompt_once(tab) { - return Ok(self.clear_initial_prompt(tab).await); - } + let Some(command) = command else { + return Ok(None); + }; + let instance_id = self + .sessions + .get(session_id) + .map(Session::instance_id) + .expect("spawned terminal was inserted"); + self.schedule_terminal_startup_input( + session_id.to_string(), + instance_id, + interactive_shell, + command, + ); + // A one-shot command is spent as soon as it is on its way. Agent + // tabs deliberately do not set the flag: they re-mint their command + // on every new PTY, including after host recovery. + if delivers_initial_command_once(tab) { + return Ok(Some(self.clear_initial_command(tab).await)); } - Ok(rearmed) + if delivers_initial_prompt_once(tab) { + return Ok(Some(self.clear_initial_prompt(tab).await)); + } + Ok(None) } #[allow(clippy::too_many_arguments)] diff --git a/rust/alera-cli/src/terminal_host/server/terminal_spawn/headless_restart.rs b/rust/alera-cli/src/terminal_host/server/terminal_spawn/headless_restart.rs new file mode 100644 index 000000000..8c4a45325 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/terminal_spawn/headless_restart.rs @@ -0,0 +1,197 @@ +//! `terminal.restart` for a caller that renders no terminal, such as the CLI +//! or an MCP client. The desktop computes the launch and types the tab's +//! startup command itself; here the host does both, the way it starts a +//! spawn-on-create tab. + +use serde_json::json; + +use super::super::terminal_startup_commands::{initial_command, initial_managed_agent_launch}; +use super::*; + +impl ServerActor { + pub(in crate::terminal_host::server) async fn restart_terminal_headless( + &mut self, + client_id: u64, + payload: &Value, + ) -> HostResult { + let session_id = super::super::requests::require_string_key(payload, "sessionId")?; + let tab = self + .terminal_tab_for_session(&session_id) + .await? + .ok_or_else(|| HostError::state(format!("Terminal not found: {session_id}")))?; + if tab.kind != "terminal" { + return Err(HostError::state(format!( + "Workspace tab is not a terminal: {}", + tab.id + ))); + } + let workspace = self + .runtime_store + .find_workspace(&tab.workspace_id) + .await + .map_err(|error| HostError::state(error.to_string()))? + .ok_or_else(|| { + HostError::state(format!("workspace not found: {}", tab.workspace_id)) + })?; + if workspace.status != WorkspaceStatus::Active { + return Err(HostError::state(format!( + "workspace is not active: {}", + workspace.id + ))); + } + let default_launch = + default_terminal_launch(&workspace.path, self.config.login_shell).await; + let request = json!({ + "sessionId": session_id, + "workspaceId": workspace.id, + "tabId": tab.id, + "workingDirectory": workspace.path, + "launch": default_launch.launch.to_json(), + "cols": DEFAULT_TERMINAL_COLS, + "rows": DEFAULT_TERMINAL_ROWS, + }); + self.restart_terminal(client_id, &request).await?; + // The caller shows no output, so it does not stay attached. + if let Some(session) = self.sessions.get_mut(&session_id) { + session.detach(client_id); + } + // The restart types a discovered resume line for a plain shell tab. + // A launch or a command is the client's to type, so type it here. + if initial_managed_agent_launch(&tab)?.is_some() || initial_command(&tab)?.is_some() { + self.deliver_tab_startup_command( + &tab, + &session_id, + default_launch.interactive_shell, + None, + ) + .await?; + } + Ok(json!({ + "sessionId": session_id, + "tabId": tab.id, + "workspaceId": tab.workspace_id, + "restarted": true, + })) + } +} + +impl ServerActor { + /// The tab of a terminal session. An exited session may no longer be + /// live, and its tab id need not equal the session id, so the tabs are + /// searched for it. + async fn terminal_tab_for_session( + &self, + session_id: &str, + ) -> HostResult> { + let store_error = |error: anyhow::Error| HostError::state(error.to_string()); + if let Some(session) = self.sessions.get(session_id) { + return self + .runtime_store + .find_workspace_tab(&session.tab_id) + .await + .map_err(store_error); + } + Ok(self + .runtime_store + .list_all_workspace_tabs() + .await + .map_err(store_error)? + .into_iter() + .find(|tab| terminal_session_id(tab) == session_id)) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use alera_core::runtime::WorkspaceTabRecord; + use serde_json::json; + + use super::super::super::actor_test_harness::{local_client, test_actor}; + use super::ServerActor; + use crate::terminal_host::client::ClientHandle; + + async fn actor_with_terminal_tab(directory: &tempfile::TempDir, kind: &str) -> ServerActor { + let (handle, _receiver) = ClientHandle::test_channels(); + let actor = test_actor( + directory, + HashMap::from([(1, local_client(handle))]), + HashMap::new(), + ) + .await; + let folder = directory.path().join("folder"); + std::fs::create_dir(&folder).unwrap(); + let project = crate::project_management::register_project( + &actor.runtime_store, + folder.to_str().unwrap(), + None, + ) + .await + .unwrap() + .project; + let workspace = actor + .runtime_store + .list_workspaces(&project.id) + .await + .unwrap() + .remove(0); + let now = chrono::Utc::now(); + actor + .runtime_store + .insert_workspace_tab(WorkspaceTabRecord { + id: "tab".into(), + workspace_id: workspace.id, + kind: kind.into(), + title: "Terminal".into(), + created_at: now, + updated_at: now, + payload: json!({ "terminalSessionId": "session", "initialCommand": "true" }), + }) + .await + .unwrap(); + actor + } + + #[tokio::test] + async fn headless_restart_starts_the_tab_without_keeping_the_caller_attached() { + let directory = tempfile::tempdir().unwrap(); + let mut actor = actor_with_terminal_tab(&directory, "terminal").await; + + let result = actor + .restart_terminal_headless(1, &json!({ "sessionId": "session", "headless": true })) + .await + .unwrap(); + + assert_eq!(result["restarted"], true); + assert_eq!(result["tabId"], "tab"); + let session = &actor.sessions["session"]; + assert!(session.running()); + assert!(!session.clients.contains(&1)); + actor + .terminate_session_request("session".into()) + .await + .unwrap(); + } + + #[tokio::test] + async fn headless_restart_refuses_unknown_terminals_and_other_tabs() { + let directory = tempfile::tempdir().unwrap(); + let mut actor = actor_with_terminal_tab(&directory, "editor").await; + + let missing = actor + .restart_terminal_headless(1, &json!({ "sessionId": "other" })) + .await + .unwrap_err(); + assert!( + missing.to_string().contains("Terminal not found"), + "{missing}" + ); + let editor = actor + .restart_terminal_headless(1, &json!({ "sessionId": "session" })) + .await + .unwrap_err(); + assert!(editor.to_string().contains("not a terminal"), "{editor}"); + assert!(actor.sessions.is_empty()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/webhook_requests.rs b/rust/alera-cli/src/terminal_host/server/webhook_requests.rs new file mode 100644 index 000000000..50b919a3c --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/webhook_requests.rs @@ -0,0 +1,144 @@ +//! `webhook.*`: signed webhooks that receive this runtime's event journal. +//! +//! The cloud stores and delivers them for the signed-in account; the runtime +//! only manages them on the account's behalf. Local clients only: a phone +//! cannot add an outbound destination for the runtime's events. + +use serde_json::{json, Value}; + +use super::account_requests::AccountOperation; +use super::ServerActor; +use crate::terminal_host::host_error::{HostError, HostResult}; + +const EVENT_KINDS_FIELD: &str = "kinds"; + +impl ServerActor { + pub(super) fn try_start_webhook_request( + &mut self, + client_id: u64, + request_id: i64, + request_type: &str, + payload: &Value, + ) -> HostResult { + if !matches!( + request_type, + "webhook.list" | "webhook.create" | "webhook.delete" | "webhook.test" + ) { + return Ok(false); + } + self.require_auth(client_id)?; + self.require_request_allowed(client_id, request_type)?; + let service = self.account_push.service.clone(); + match request_type { + "webhook.list" => self.start_account_operation( + client_id, + request_id, + AccountOperation::McpGrants, + async move { service.list_webhooks().await.map_err(cloud_error) }, + ), + "webhook.create" => { + let url = text(payload, "url")?; + let kinds = kinds(payload)?; + let runtime_ids = payload + .get("runtimeIds") + .and_then(Value::as_array) + .map(|ids| { + ids.iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>() + }); + self.start_account_operation( + client_id, + request_id, + AccountOperation::McpGrants, + async move { + let created = service + .create_webhook(&url, &kinds, runtime_ids.as_deref()) + .await + .map_err(cloud_error)?; + // The forwarder skips events while it believes nothing is + // subscribed; let it see the new webhook right away. + super::runtime_event_forwarder::request_subscription_refresh(); + Ok(created) + }, + ); + } + "webhook.delete" => { + let id = text(payload, "id")?; + self.start_account_operation( + client_id, + request_id, + AccountOperation::McpGrants, + async move { + service.delete_webhook(&id).await.map_err(cloud_error)?; + Ok(json!({ "deleted": true, "id": id })) + }, + ); + } + _ => { + let id = text(payload, "id")?; + self.start_account_operation( + client_id, + request_id, + AccountOperation::McpGrants, + async move { service.test_webhook(&id).await.map_err(cloud_error) }, + ); + } + } + Ok(true) + } +} + +fn text(payload: &Value, key: &str) -> HostResult { + payload + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| HostError::format(format!("{key} is required"))) +} + +/// Event kinds the webhook receives; every kind the journal knows by default. +fn kinds(payload: &Value) -> HostResult> { + let known = super::runtime_event_requests::EVENT_KINDS; + let Some(requested) = payload.get(EVENT_KINDS_FIELD).and_then(Value::as_array) else { + return Ok(known.iter().map(|(kind, _)| (*kind).to_owned()).collect()); + }; + requested + .iter() + .map(|kind| { + kind.as_str() + .filter(|kind| known.iter().any(|(name, _)| name == kind)) + .map(str::to_owned) + .ok_or_else(|| HostError::format(format!("Unknown event kind: {kind}"))) + }) + .collect() +} + +fn cloud_error(error: anyhow::Error) -> HostError { + match error.downcast_ref::() { + Some(request) => HostError::state(request.message().to_owned()), + None => HostError::state(error.to_string()), + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::kinds; + + #[test] + fn webhooks_receive_every_kind_unless_they_choose() { + assert!(kinds(&json!({})) + .unwrap() + .contains(&"inbox.reply".to_owned())); + assert_eq!( + kinds(&json!({ "kinds": ["agent.status"] })).unwrap(), + ["agent.status"] + ); + assert!(kinds(&json!({ "kinds": ["made.up"] })).is_err()); + } +} diff --git a/rust/alera-cli/src/terminal_host/server/workspace_sleep_requests.rs b/rust/alera-cli/src/terminal_host/server/workspace_sleep_requests.rs index 353f263d5..85cfba5ad 100644 --- a/rust/alera-cli/src/terminal_host/server/workspace_sleep_requests.rs +++ b/rust/alera-cli/src/terminal_host/server/workspace_sleep_requests.rs @@ -8,6 +8,9 @@ use crate::terminal_host::protocol::{event, TERMINAL_SESSION_REMOVED_BY_SLEEP}; use super::ServerActor; +#[path = "workspace_wake_requests.rs"] +mod wake; + impl ServerActor { /// Terminal tabs a workspace sleep stopped, by workspace. Clients show them /// as closed until the workspace wakes. diff --git a/rust/alera-cli/src/terminal_host/server/workspace_wake_requests.rs b/rust/alera-cli/src/terminal_host/server/workspace_wake_requests.rs new file mode 100644 index 000000000..7985d08dc --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/workspace_wake_requests.rs @@ -0,0 +1,152 @@ +//! `workspace.wake`: start again the terminals a workspace sleep stopped. +//! +//! Opening a slept workspace in the desktop or mobile app attaches each of its +//! terminal tabs, and the first session that starts clears the sleep. A client +//! without a terminal view (the CLI, an MCP tool) asks the host to do the same +//! attach for every slept tab, the way a phone attaches one tab: the host +//! resolves each tab's command and native agent session from its record. +//! An app types a command or agent launch tab's startup line itself on attach, +//! so for a caller that types nothing the host delivers it, the way +//! `terminal.restart` does for a headless caller. + +use serde_json::{json, Value}; + +use crate::terminal_host::host_error::{HostError, HostResult}; +use crate::terminal_host::server::requests::{require_string_key, terminal_session_id_from_tab}; +use crate::terminal_host::server::terminal_launch_defaults::default_terminal_launch; +use crate::terminal_host::server::terminal_startup_commands::{ + initial_command, initial_managed_agent_launch, +}; +use crate::terminal_host::server::ServerActor; + +impl ServerActor { + pub(in crate::terminal_host::server) async fn wake_workspace_request( + &mut self, + client_id: u64, + payload: &Value, + ) -> HostResult { + self.require_auth(client_id)?; + let workspace_id = require_string_key(payload, "workspaceId")?; + let workspace = self + .runtime_store + .find_workspace(&workspace_id) + .await + .map_err(state_error)? + .ok_or_else(|| HostError::state(format!("Workspace not found: {workspace_id}")))?; + if workspace.host_id != alera_core::runtime::LOCAL_HOST_ID { + return Err(HostError::state(format!( + "Workspace {workspace_id} runs on SSH host {}, whose runtime keeps its own terminals, so it is never asleep here.", + workspace.host_id + ))); + } + if workspace.is_archived { + return Err(HostError::state(format!( + "Workspace {workspace_id} is archived. Unarchive it before waking it." + ))); + } + let slept = self + .slept_workspace_tab_ids() + .await? + .remove(&workspace_id) + .unwrap_or_default(); + let mut woken = Vec::new(); + let mut failed = Vec::new(); + for tab_id in &slept { + let tab = self + .runtime_store + .find_workspace_tab(tab_id) + .await + .map_err(state_error)? + .filter(|tab| tab.kind == "terminal" && tab.workspace_id == workspace_id); + // A slept terminal closed since then has nothing to start. + let Some(tab) = tab else { continue }; + let session_id = terminal_session_id_from_tab(&tab).unwrap_or_else(|| tab.id.clone()); + let launch = default_terminal_launch(&workspace.path, self.config.login_shell).await; + let attachment = json!({ + "sessionId": session_id, + "workspaceId": workspace.id, + "tabId": tab.id, + "workingDirectory": workspace.path, + "launch": launch.launch.to_json(), + "cols": 80, + "rows": 24, + }); + let started = match self.create_or_attach(client_id, &attachment).await { + Ok(attached) => { + // The caller only asked for the wake; it does not view + // the terminal, so an app that opens it later drives it. + if let Some(session) = self.sessions.get_mut(&session_id) { + session.detach(client_id); + } + // Only a new PTY needs its startup line; a terminal still + // running already has its command. + if attached["created"] == true { + self.deliver_woken_tab_startup(&tab, &session_id, launch.interactive_shell) + .await + } else { + Ok(()) + } + } + Err(error) => Err(error), + }; + match started { + Ok(()) => woken.push(json!({ "tabId": tab.id, "sessionId": session_id })), + Err(error) => failed.push(json!({ "tabId": tab.id, "error": error.to_string() })), + } + } + if woken.is_empty() && !failed.is_empty() { + return Err(HostError::state(format!( + "No terminal of workspace {workspace_id} could start: {}", + failed + .iter() + .filter_map(|item| item["error"].as_str()) + .collect::>() + .join("; ") + ))); + } + // A started session already cleared the sleep. A terminal still + // running, or every slept terminal closed since, leaves it recorded. + if let Some(first) = slept.first() { + self.wake_workspace_for_tab(&workspace_id, first).await; + } + Ok(json!({ + "workspaceId": workspace_id, + "wasAsleep": !slept.is_empty(), + "woken": woken, + "failed": failed, + })) + } +} + +impl ServerActor { + /// The attach typed a discovered resume line for a plain shell tab. A + /// command or managed agent launch is the attaching client's to type, so + /// type it here, in its resume form once the agent reported a session. + async fn deliver_woken_tab_startup( + &mut self, + tab: &alera_core::runtime::WorkspaceTabRecord, + session_id: &str, + interactive_shell: String, + ) -> HostResult<()> { + if initial_managed_agent_launch(tab)?.is_none() && initial_command(tab)?.is_none() { + return Ok(()); + } + if self + .deliver_tab_startup_command(tab, session_id, interactive_shell, None) + .await? + .is_some() + { + // A one-shot command or prompt was spent with this delivery. + self.broadcast_workspace_tabs_changed(Some(&tab.workspace_id)); + } + Ok(()) + } +} + +fn state_error(error: anyhow::Error) -> HostError { + HostError::state(error.to_string()) +} + +#[cfg(test)] +#[path = "workspace_wake_requests_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/terminal_host/server/workspace_wake_requests_tests.rs b/rust/alera-cli/src/terminal_host/server/workspace_wake_requests_tests.rs new file mode 100644 index 000000000..06f82bb38 --- /dev/null +++ b/rust/alera-cli/src/terminal_host/server/workspace_wake_requests_tests.rs @@ -0,0 +1,318 @@ +use std::collections::HashMap; + +use alera_core::runtime::{ + Project, ProjectKind, Workspace, WorkspaceKind, WorkspaceStatus, WorkspaceTabRecord, +}; +use chrono::Utc; +use serde_json::json; + +use crate::terminal_host::client::ClientHandle; +use crate::terminal_host::orchestration::agent_session_resume::{ + AGENT_NATIVE_SESSION_AGENT_KEY, AGENT_NATIVE_SESSION_ID_KEY, +}; +use crate::terminal_host::server::actor_test_harness::{local_client, test_actor}; +use crate::terminal_host::server::terminal_launch_defaults::default_terminal_launch; +use crate::terminal_host::server::terminal_spawn_command::resumed_initial_command; +use crate::terminal_host::server::{ServerActor, ServerCommand, ServerInboxReceiver}; +use crate::terminal_host::session::Session; + +async fn actor_with_workspace(dir: &tempfile::TempDir, host_id: &str) -> ServerActor { + let (handle, _) = ClientHandle::test_channels(); + let actor = test_actor( + dir, + HashMap::from([(1, local_client(handle))]), + HashMap::new(), + ) + .await; + let now = Utc::now(); + let path = dir.path().to_string_lossy().to_string(); + actor + .runtime_store + .upsert_project(Project { + id: "project".into(), + name: "Project".into(), + repo_path: path.clone(), + kind: ProjectKind::Folder, + created_at: now, + updated_at: now, + }) + .await + .unwrap(); + actor + .runtime_store + .upsert_workspace(Workspace { + id: "w".into(), + instance_id: "w-instance".into(), + host_id: host_id.into(), + project_id: "project".into(), + name: "Task".into(), + branch: None, + path, + created_at: now, + updated_at: now, + kind: WorkspaceKind::Main, + status: WorkspaceStatus::Active, + source_branch: None, + reuses_existing_branch: false, + is_pinned: false, + is_archived: false, + tag_ids: vec![], + tag_names: vec![], + section_id: None, + parent_workspace_id: None, + child_count: 0, + }) + .await + .unwrap(); + for (tab, session) in [("tab-1", "s-1"), ("tab-2", "s-2")] { + actor + .runtime_store + .upsert_workspace_tab(WorkspaceTabRecord { + id: tab.into(), + workspace_id: "w".into(), + kind: "terminal".into(), + title: tab.into(), + created_at: now, + updated_at: now, + payload: json!({ "terminalSessionId": session }), + }) + .await + .unwrap(); + } + actor +} + +fn live_session(id: &str, tab_id: &str) -> Session { + let mut session = Session::driver_test_stub(id, 80, 24); + session.workspace_id = "w".into(); + session.tab_id = tab_id.into(); + session +} + +#[tokio::test] +async fn waking_attaches_every_slept_terminal_and_clears_the_sleep() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = actor_with_workspace(&dir, "local").await; + actor + .runtime_store + .record_workspace_sleep("w") + .await + .unwrap(); + // A terminal closed after the sleep has nothing to start. + actor + .runtime_store + .remove_workspace_tab("tab-2") + .await + .unwrap(); + actor + .sessions + .insert("s-1".into(), live_session("s-1", "tab-1")); + + let woken = actor + .wake_workspace_request(1, &json!({ "workspaceId": "w" })) + .await + .unwrap(); + + assert_eq!(woken["wasAsleep"], true); + assert_eq!( + woken["woken"], + json!([{ "tabId": "tab-1", "sessionId": "s-1" }]) + ); + assert_eq!(woken["failed"], json!([])); + assert!( + !actor.sessions["s-1"].clients.contains(&1), + "the waking client does not stay attached" + ); + assert!(actor + .runtime_store + .list_slept_workspace_tabs() + .await + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn an_awake_workspace_has_nothing_to_wake() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = actor_with_workspace(&dir, "local").await; + + let woken = actor + .wake_workspace_request(1, &json!({ "workspaceId": "w" })) + .await + .unwrap(); + + assert_eq!(woken["wasAsleep"], false); + assert_eq!(woken["woken"], json!([])); + assert!(actor.sessions.is_empty()); +} + +#[tokio::test] +async fn unknown_and_remote_workspaces_are_refused() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = actor_with_workspace(&dir, "ssh-lab").await; + + let remote = actor + .wake_workspace_request(1, &json!({ "workspaceId": "w" })) + .await + .unwrap_err(); + assert!(remote.to_string().contains("SSH host"), "{remote}"); + let missing = actor + .wake_workspace_request(1, &json!({ "workspaceId": "nope" })) + .await + .unwrap_err(); + assert!(missing.to_string().contains("not found"), "{missing}"); +} + +async fn set_tab_payload(actor: &ServerActor, tab_id: &str, payload: serde_json::Value) { + let mut tab = actor + .runtime_store + .find_workspace_tab(tab_id) + .await + .unwrap() + .unwrap(); + tab.payload = payload; + actor.runtime_store.upsert_workspace_tab(tab).await.unwrap(); +} + +/// The startup line the host types into a session, or `None` when nothing +/// is typed within the wait. +async fn startup_input( + actor: &mut ServerActor, + events: &mut ServerInboxReceiver, + session_id: &str, +) -> Option { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + while let Ok(Some(event)) = tokio::time::timeout_at(deadline, events.recv()).await { + if let ServerCommand::TerminalStartupInput { + session_id: target, + command, + .. + } = &event + { + if target == session_id { + return Some(command.clone()); + } + } + actor.handle(event).await; + } + None +} + +/// Sleeps the workspace with only `tab-1` left, wakes it and returns the line +/// typed into its new session. +async fn wake_and_capture_startup(actor: &mut ServerActor) -> Option { + actor + .runtime_store + .record_workspace_sleep("w") + .await + .unwrap(); + actor + .runtime_store + .remove_workspace_tab("tab-2") + .await + .unwrap(); + let (inbox, mut events) = crate::terminal_host::ServerInbox::channel(); + actor.inbox = inbox; + + let woken = actor + .wake_workspace_request(1, &json!({ "workspaceId": "w" })) + .await + .unwrap(); + assert_eq!( + woken["woken"], + json!([{ "tabId": "tab-1", "sessionId": "s-1" }]) + ); + assert!(actor.sessions["s-1"].running()); + let typed = startup_input(actor, &mut events, "s-1").await; + stop_session(actor, "s-1").await; + typed +} + +#[tokio::test] +async fn waking_a_slept_command_tab_types_its_command() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = actor_with_workspace(&dir, "local").await; + set_tab_payload( + &actor, + "tab-1", + json!({ "terminalSessionId": "s-1", "initialCommand": "echo woken-command" }), + ) + .await; + + let typed = wake_and_capture_startup(&mut actor).await; + + assert_eq!(typed.as_deref(), Some("echo woken-command")); +} + +#[tokio::test] +async fn waking_a_slept_agent_tab_resumes_its_reported_conversation() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = actor_with_workspace(&dir, "local").await; + set_tab_payload( + &actor, + "tab-1", + json!({ + "terminalSessionId": "s-1", + "initialCommand": "claude", + AGENT_NATIVE_SESSION_ID_KEY: "sess-1", + AGENT_NATIVE_SESSION_AGENT_KEY: "claude", + }), + ) + .await; + let tab = actor + .runtime_store + .find_workspace_tab("tab-1") + .await + .unwrap() + .unwrap(); + let path = dir.path().to_string_lossy().to_string(); + let shell = default_terminal_launch(&path, actor.config.login_shell) + .await + .interactive_shell; + // The resume form an app types when it attaches the slept tab. + let app_line = resumed_initial_command(&tab, &shell).unwrap().unwrap(); + + let typed = wake_and_capture_startup(&mut actor).await; + + assert_eq!(typed.as_deref(), Some(app_line.as_str())); + assert!(app_line.contains("sess-1")); +} + +#[tokio::test] +async fn waking_does_not_retype_into_a_terminal_still_running() { + let dir = tempfile::tempdir().unwrap(); + let mut actor = actor_with_workspace(&dir, "local").await; + set_tab_payload( + &actor, + "tab-1", + json!({ "terminalSessionId": "s-1", "initialCommand": "echo again" }), + ) + .await; + actor + .runtime_store + .record_workspace_sleep("w") + .await + .unwrap(); + actor + .sessions + .insert("s-1".into(), live_session("s-1", "tab-1")); + let (inbox, mut events) = crate::terminal_host::ServerInbox::channel(); + actor.inbox = inbox; + + actor + .wake_workspace_request(1, &json!({ "workspaceId": "w" })) + .await + .unwrap(); + + assert!(actor.sessions.contains_key("s-2"), "the plain tab started"); + assert_eq!(startup_input(&mut actor, &mut events, "s-1").await, None); + stop_session(&mut actor, "s-2").await; +} + +/// Kills a PTY a test started. The test owns the inbox and stops draining +/// it, so the history flush of a full termination would wait forever. +async fn stop_session(actor: &mut ServerActor, session_id: &str) { + if let Some(mut session) = actor.sessions.remove(session_id) { + session.terminate(true, &actor.store).await; + } +} diff --git a/rust/alera-cli/src/terminal_host/server_runner.rs b/rust/alera-cli/src/terminal_host/server_runner.rs index 44713ca55..1260579b2 100644 --- a/rust/alera-cli/src/terminal_host/server_runner.rs +++ b/rust/alera-cli/src/terminal_host/server_runner.rs @@ -41,6 +41,8 @@ pub async fn run_terminal_host_server( let (inbox, mut rx) = ServerInbox::channel(); let shutdown_signal = spawn_termination_listener(inbox.clone()); let watch_ticker = pull_request_watch_runtime::spawn(inbox.clone()); + let event_forwarder = + runtime_event_forwarder::spawn(runtime_store.clone(), account_push.service.clone()); let presence_sweep = agent_presence_reconciliation::spawn(inbox.clone()); let automation_wake = Arc::new(Notify::new()); let automation_ticker = automation_scheduler::spawn( @@ -80,6 +82,7 @@ pub async fn run_terminal_host_server( inbox.clone(), ), project_clone_jobs: HashMap::new(), + prompt_workspace_operations: HashMap::new(), agent_title_jobs: HashMap::new(), managed_workspace_jobs: 0, workflow_execution: Default::default(), @@ -132,6 +135,7 @@ pub async fn run_terminal_host_server( } actor.restart_remote_relay().await; actor.reconcile_interrupted_project_clones().await; + actor.reconcile_interrupted_prompt_workspaces().await; actor.runtime_store.recover_workflow_coordinators().await?; actor.start_workflow_workspace_recovery(); actor.reconcile_workflow_launches().await; @@ -192,6 +196,8 @@ pub async fn run_terminal_host_server( inbox.close(); watch_ticker.abort(); let _ = watch_ticker.await; + event_forwarder.abort(); + let _ = event_forwarder.await; presence_sweep.abort(); let _ = presence_sweep.await; automation_ticker.abort(); diff --git a/rust/alera-cli/src/terminal_lifecycle_commands.rs b/rust/alera-cli/src/terminal_lifecycle_commands.rs new file mode 100644 index 000000000..0fc1be56e --- /dev/null +++ b/rust/alera-cli/src/terminal_lifecycle_commands.rs @@ -0,0 +1,266 @@ +//! Tab and terminal lifecycle through the runtime host: closing a tab with +//! its session, renaming, AI titles, restart, terminate, and Terminal Pulse. + +use anyhow::{bail, Result}; +use serde_json::{json, Value}; + +use crate::agent_profile_commands::ensure_capabilities; +use crate::cli::{ + RuntimeDirArgs, TabAction, TerminalAction, TerminalPulseAction, TerminalPulseSetArgs, +}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::ai_assist_capabilities::RUNTIME_HOST_AI_ASSIST_AGENT_TITLE_CAPABILITY; +use crate::terminal_host::protocol::RUNTIME_HOST_MOBILE_TAB_RENAME_CAPABILITY; +use crate::{print_error, print_value}; + +/// `tab remove --terminate`, `tab rename`, and `tab generate-title`. +pub(crate) async fn run_tab(runtime: &RuntimeDirArgs, action: TabAction, json_output: bool) -> i32 { + match tab(runtime, action).await { + Ok((value, message)) => { + print_value(&value, json_output, message); + 0 + } + Err(error) => print_error(error), + } +} + +async fn tab(runtime: &RuntimeDirArgs, action: TabAction) -> Result<(Value, &'static str)> { + let mut client = RuntimeHostRpcClient::connect_or_start(&crate::runtime_dir(runtime)).await?; + match action { + TabAction::Remove(args) => { + // The host ends the tab's terminal sessions when it removes it. + client + .request_value("tab.remove", &json!({ "id": args.id })) + .await?; + Ok((json!({ "id": args.id, "terminated": true }), "tab closed")) + } + TabAction::Rename(args) => { + let title = args.title.trim(); + if title.is_empty() { + bail!("--title cannot be empty"); + } + ensure_capabilities(&mut client, &[RUNTIME_HOST_MOBILE_TAB_RENAME_CAPABILITY]).await?; + let tab = client + .request_value("tab.rename", &json!({ "id": args.id, "title": title })) + .await?; + Ok((tab, "tab renamed")) + } + TabAction::GenerateTitle(args) => { + ensure_capabilities( + &mut client, + &[RUNTIME_HOST_AI_ASSIST_AGENT_TITLE_CAPABILITY], + ) + .await?; + let store = crate::open_store(runtime).await?; + let Some(tab) = store.find_workspace_tab(&args.id).await? else { + bail!("Workspace tab not found: {}", args.id); + }; + let result = client + .request_value("aiText.agentTitle.generate", &title_request(&tab)) + .await?; + Ok((result, "tab title generated")) + } + _ => bail!("unsupported tab action"), + } +} + +/// The host refuses a title for a conversation that changed since the +/// caller looked, so the request names the tab's current conversation. +pub(crate) fn title_request(tab: &alera_core::runtime::WorkspaceTabRecord) -> Value { + json!({ + "tabId": tab.id, + "expectedConversationId": tab.payload["agentTitleConversationId"], + "expectedRevision": tab.payload["agentTitleRevision"], + }) +} + +/// `terminal restart`, `terminal terminate`, and `terminal pulse`. +pub(crate) async fn run_terminal( + client: &mut RuntimeHostRpcClient, + action: TerminalAction, + json_output: bool, +) -> i32 { + match terminal(client, action).await { + Ok((value, message)) => { + print_value(&value, json_output, message); + 0 + } + Err(error) => print_error(error), + } +} + +async fn terminal( + client: &mut RuntimeHostRpcClient, + action: TerminalAction, +) -> Result<(Value, &'static str)> { + match action { + TerminalAction::Restart(args) => { + let value = client + .request_value( + "terminal.restart", + &json!({ "sessionId": args.handle, "headless": true }), + ) + .await?; + Ok((value, "terminal restarted")) + } + TerminalAction::Terminate(args) => { + client + .request_value("terminate", &json!({ "sessionId": args.handle })) + .await?; + Ok(( + json!({ "handle": args.handle, "terminated": true }), + "terminal terminated", + )) + } + TerminalAction::Pulse(command) => match command.action { + TerminalPulseAction::Show(args) => { + let status = pulse_status(client, &args.handle).await?; + Ok((status, "terminal pulse")) + } + TerminalPulseAction::Set(args) => { + let status = pulse_status(client, &args.handle).await?; + let payload = pulse_configure_payload(&status, &args)?; + let value = client + .request_value("terminal.pulse.configure", &payload) + .await?; + Ok((value, "terminal pulse updated")) + } + }, + _ => bail!("unsupported terminal action"), + } +} + +async fn pulse_status(client: &mut RuntimeHostRpcClient, handle: &str) -> Result { + client + .request_value("terminal.pulse.status", &json!({ "sessionId": handle })) + .await +} + +/// The configure request: the saved configuration with the given changes, +/// armed as requested or as it already was. +pub(crate) fn pulse_configure_payload( + status: &Value, + args: &TerminalPulseSetArgs, +) -> Result { + let mut configuration = status["configuration"].clone(); + if !configuration.is_object() { + bail!("runtime host returned no Terminal Pulse configuration"); + } + if let Some(input) = &args.input { + if input.is_empty() { + bail!("--input cannot be empty"); + } + configuration["command"] = json!(input); + } + if let Some(enter) = args.enter { + configuration["appendEnter"] = json!(enter); + } + if let Some(delay) = args.delay_ms { + configuration["delayMs"] = json!(delay); + } + let armed = if args.arm { + true + } else if args.disarm { + false + } else { + status["armed"].as_bool().unwrap_or(false) + }; + Ok(json!({ + "sessionId": args.handle, + "configuration": configuration, + "armed": armed, + })) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{pulse_configure_payload, title_request}; + use crate::cli::TerminalPulseSetArgs; + + fn args() -> TerminalPulseSetArgs { + TerminalPulseSetArgs { + handle: "term-1".into(), + input: None, + enter: None, + delay_ms: None, + arm: false, + disarm: false, + } + } + + fn status(armed: bool) -> serde_json::Value { + json!({ + "configuration": { "command": "r", "appendEnter": true, "delayMs": 2000 }, + "armed": armed, + }) + } + + #[test] + fn pulse_keeps_what_was_not_changed() { + let payload = pulse_configure_payload(&status(true), &args()).unwrap(); + assert_eq!( + payload, + json!({ + "sessionId": "term-1", + "configuration": { "command": "r", "appendEnter": true, "delayMs": 2000 }, + "armed": true, + }) + ); + } + + #[test] + fn pulse_applies_changes_and_arming() { + let changed = TerminalPulseSetArgs { + input: Some("npm test".into()), + enter: Some(false), + delay_ms: Some(500), + arm: true, + ..args() + }; + let payload = pulse_configure_payload(&status(false), &changed).unwrap(); + assert_eq!(payload["configuration"]["command"], "npm test"); + assert_eq!(payload["configuration"]["appendEnter"], false); + assert_eq!(payload["configuration"]["delayMs"], 500); + assert_eq!(payload["armed"], true); + let disarm = TerminalPulseSetArgs { + disarm: true, + ..args() + }; + assert_eq!( + pulse_configure_payload(&status(true), &disarm).unwrap()["armed"], + false + ); + let empty = TerminalPulseSetArgs { + input: Some(String::new()), + ..args() + }; + assert!(pulse_configure_payload(&status(true), &empty).is_err()); + } + + #[test] + fn title_request_names_the_current_conversation() { + let mut tab = crate::tab_record_factory::tab_from_args(crate::cli::TabCreateArgs { + workspace_id: "ws".into(), + title: "Agent".into(), + kind: "terminal".into(), + command: None, + spawn: false, + }) + .unwrap(); + tab.payload["agentTitleConversationId"] = json!("conv-1"); + tab.payload["agentTitleRevision"] = json!(3); + let request = title_request(&tab); + assert_eq!(request["tabId"], tab.id.as_str()); + assert_eq!(request["expectedConversationId"], "conv-1"); + assert_eq!(request["expectedRevision"], 3); + tab.payload = json!({}); + let request = title_request(&tab); + assert!(request["expectedRevision"].is_null()); + assert!(request + .as_object() + .unwrap() + .contains_key("expectedRevision")); + } +} diff --git a/rust/alera-cli/src/webhook_commands.rs b/rust/alera-cli/src/webhook_commands.rs new file mode 100644 index 000000000..2d9510dfc --- /dev/null +++ b/rust/alera-cli/src/webhook_commands.rs @@ -0,0 +1,64 @@ +//! `alera webhook`: manage the signed webhooks that receive runtime events. + +use serde_json::{json, Value}; + +use crate::cli::{WebhookAction, WebhookCommand}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY; +use crate::{print_error, print_value, runtime_dir}; + +/// Cloud calls can take longer than local requests. +const CLOUD_DEADLINE_MS: u64 = 30_000; + +pub async fn run(command: WebhookCommand) -> i32 { + let json_output = command.output.json; + let (request, payload) = match &command.action { + WebhookAction::List => ("webhook.list", json!({})), + WebhookAction::Add(args) => { + let mut payload = json!({ "url": args.url }); + if !args.kinds.is_empty() { + payload["kinds"] = json!(args.kinds); + } + if !args.runtime_ids.is_empty() { + payload["runtimeIds"] = json!(args.runtime_ids); + } + ("webhook.create", payload) + } + WebhookAction::Remove(args) => ("webhook.delete", json!({ "id": args.id })), + WebhookAction::Test(args) => ("webhook.test", json!({ "id": args.id })), + }; + let result = async { + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &runtime_dir(&command.runtime), + RUNTIME_HOST_RUNTIME_EVENTS_CAPABILITY, + ) + .await?; + client + .request_value_with_deadline(request, &payload, CLOUD_DEADLINE_MS) + .await + } + .await; + match result { + Ok(value) => { + print_value(&value, json_output, &message(&command.action, &value)); + 0 + } + Err(error) => print_error(error), + } +} + +fn message(action: &WebhookAction, value: &Value) -> String { + match action { + WebhookAction::List => format!( + "{} webhook(s)", + value["webhooks"].as_array().map_or(0, Vec::len) + ), + WebhookAction::Add(_) => format!( + "webhook {} added; signing secret (shown once): {}", + value["webhook"]["id"].as_str().unwrap_or("?"), + value["secret"].as_str().unwrap_or("?") + ), + WebhookAction::Remove(args) => format!("webhook {} removed", args.id), + WebhookAction::Test(args) => format!("test delivery queued for webhook {}", args.id), + } +} diff --git a/rust/alera-cli/src/workflow_plan_commands.rs b/rust/alera-cli/src/workflow_plan_commands.rs index 198f5cca9..5e63f9aaf 100644 --- a/rust/alera-cli/src/workflow_plan_commands.rs +++ b/rust/alera-cli/src/workflow_plan_commands.rs @@ -1,5 +1,8 @@ use std::io::Read; +mod lifecycle; +pub(crate) use lifecycle::{run_cleanup, run_execution, run_proposals}; + use alera_core::runtime::{PrepareWorkflowPlan, WORKFLOW_PLAN_MAX_BYTES}; use anyhow::{bail, Result}; use serde_json::{json, Value}; diff --git a/rust/alera-cli/src/workflow_plan_commands/lifecycle.rs b/rust/alera-cli/src/workflow_plan_commands/lifecycle.rs new file mode 100644 index 000000000..61229db01 --- /dev/null +++ b/rust/alera-cli/src/workflow_plan_commands/lifecycle.rs @@ -0,0 +1,358 @@ +//! Proposals, execution control, corrections, and cleanup of workflow runs. +//! Every verb is one `workflows.*` request; none of them approves, reviews, or +//! signs a plan, which only a person does in the Alera app. + +use std::collections::BTreeMap; +use std::io::Read; + +use alera_core::runtime::WORKFLOW_PLAN_MAX_BYTES; +use anyhow::{anyhow, bail, Result}; +use serde_json::{json, Value}; + +use crate::cli::RuntimeDirArgs; +use crate::cli_workflow_plans::{ + WorkflowCleanupAction, WorkflowExecutionAction, WorkflowExecutionVerb, + WorkflowProposalCreateArgs, WorkflowProposalsAction, +}; +use crate::orchestration_commands::request_value_with_capability; +use crate::terminal_host::protocol::RUNTIME_HOST_WORKFLOW_LIFECYCLE_CAPABILITY; + +const READ_DEADLINE_MS: u64 = 30_000; +/// Coordinator launches and cleanups run git and agent start-up on the host. +const LONG_DEADLINE_MS: u64 = 50_000; +const OBJECTIVE_MAX_BYTES: usize = 16_384; +const REASON_MAX_BYTES: usize = 4_096; +const CLEANUP_MAX_RESOURCES: usize = 25; + +pub(crate) async fn run_proposals( + runtime: &RuntimeDirArgs, + action: WorkflowProposalsAction, +) -> i32 { + let request = match action { + WorkflowProposalsAction::Create(args) => return create_proposal(runtime, args).await, + action => proposal_request(action), + }; + match request { + Ok((verb, payload, deadline)) => send(runtime, verb, payload, deadline).await, + Err(error) => usage(error), + } +} + +async fn create_proposal(runtime: &RuntimeDirArgs, args: WorkflowProposalCreateArgs) -> i32 { + let objective = match text( + args.objective.clone(), + args.objective_stdin, + OBJECTIVE_MAX_BYTES, + ) { + Ok(objective) => objective, + Err(error) => return usage(error), + }; + // The proposal freezes the commit the source workspace is at now, exactly + // as the desktop's New Run form does. + let source = match request_value_with_capability( + runtime, + RUNTIME_HOST_WORKFLOW_LIFECYCLE_CAPABILITY, + "workflows.source", + json!({"workspaceId": args.workspace_id}), + Some(READ_DEADLINE_MS), + ) + .await + { + Ok(source) => source, + Err(error) => { + eprintln!("{error}"); + return 1; + } + }; + match proposal_document(&args, objective, &source) { + Ok(document) => { + let payload = json!({"document": document}); + send( + runtime, + "workflows.createProposal", + payload, + READ_DEADLINE_MS, + ) + .await + } + Err(error) => usage(error), + } +} + +pub(crate) async fn run_execution( + runtime: &RuntimeDirArgs, + action: WorkflowExecutionAction, +) -> i32 { + match execution_request(action, &mut std::io::stdin().lock()) { + Ok((verb, payload)) => send(runtime, verb, payload, READ_DEADLINE_MS).await, + Err(error) => usage(error), + } +} + +pub(crate) async fn run_cleanup(runtime: &RuntimeDirArgs, action: WorkflowCleanupAction) -> i32 { + match cleanup_request(action) { + Ok((verb, payload, deadline)) => send(runtime, verb, payload, deadline).await, + Err(error) => usage(error), + } +} + +fn proposal_request(action: WorkflowProposalsAction) -> Result<(&'static str, Value, u64)> { + Ok(match action { + WorkflowProposalsAction::List { + before_created_at, + before_id, + } => ( + "workflows.proposals", + json!({"beforeCreatedAt": before_created_at, "beforeId": before_id}), + READ_DEADLINE_MS, + ), + WorkflowProposalsAction::Status { id } => ( + "workflows.proposalStatus", + json!({"id": id}), + READ_DEADLINE_MS, + ), + WorkflowProposalsAction::Cancel { + id, + expected_sequence: Some(sequence), + } => ( + "workflows.retryProposalCancellation", + json!({"id": id, "expectedSequence": sequence}), + READ_DEADLINE_MS, + ), + WorkflowProposalsAction::Cancel { id, .. } => ( + "workflows.cancelProposal", + json!({"id": id}), + READ_DEADLINE_MS, + ), + WorkflowProposalsAction::StartCoordinator { id } => ( + "workflows.startCoordinator", + json!({"id": id}), + LONG_DEADLINE_MS, + ), + WorkflowProposalsAction::Create(_) => bail!("proposal creation needs the runtime"), + }) +} + +fn proposal_document( + args: &WorkflowProposalCreateArgs, + objective: String, + source: &Value, +) -> Result { + let recipe_source: Value = serde_json::from_str(&args.recipe_source) + .map_err(|_| anyhow!("--recipe-source must be the recipe's source JSON"))?; + let mut roles = BTreeMap::new(); + for entry in &args.role_profiles { + let (role, profile) = entry + .split_once('=') + .filter(|(role, profile)| !role.is_empty() && !profile.is_empty()) + .ok_or_else(|| anyhow!("--role-profile takes role=profileId, not `{entry}`"))?; + if roles.insert(role.to_owned(), profile.to_owned()).is_some() { + bail!("role `{role}` has more than one --role-profile"); + } + } + if !source["workspace"].is_object() || !source["sha"].is_string() { + bail!("the runtime returned no workflow source for this workspace"); + } + let document = serde_json::to_string(&json!({ + "expectedSource": source["workspace"], + "request": { + "requestId": args.request_id, + "workspaceId": args.workspace_id, + "runId": args.run, + "expectedRevision": args.expected_revision, + "proposal": { + "objective": objective, + "sourceSha": source["sha"], + "recipeSource": recipe_source, + "expectedRecipeDigest": args.recipe_digest, + "coordinatorProfileId": args.coordinator_profile_id, + "roleProfiles": roles, + "maxConcurrent": args.max_concurrent, + "tasks": [], + }, + }, + }))?; + if document.len() > WORKFLOW_PLAN_MAX_BYTES { + bail!("workflow proposal exceeds the byte limit"); + } + Ok(document) +} + +fn execution_request( + action: WorkflowExecutionAction, + stdin: &mut impl Read, +) -> Result<(&'static str, Value)> { + Ok(match action { + WorkflowExecutionAction::Show { run, revision } => ( + "workflows.execution", + json!({"runId": run, "revision": revision}), + ), + WorkflowExecutionAction::Control { + run, + revision, + expected_sequence, + action, + request_id, + } => { + let action = match action { + WorkflowExecutionVerb::Start => "start", + WorkflowExecutionVerb::Pause => "pause", + WorkflowExecutionVerb::Cancel => "cancel", + }; + let document = json!({ + "requestId": request_id, + "runId": run, + "revision": revision, + "expectedSequence": expected_sequence, + "action": action, + }); + ( + "workflows.controlExecution", + json!({"document": document.to_string()}), + ) + } + WorkflowExecutionAction::Correct { + run, + revision, + plan_digest, + request_id, + reason, + reason_stdin, + } => { + let reason = match reason { + Some(reason) => reason, + None if reason_stdin => read_limited(stdin, REASON_MAX_BYTES)?, + None => bail!("--reason or --reason-stdin is required"), + }; + if reason.trim().is_empty() || reason.len() > REASON_MAX_BYTES { + bail!("give a reason of at most {REASON_MAX_BYTES} bytes"); + } + let document = json!({ + "requestId": request_id, + "runId": run, + "revision": revision, + "planDigest": plan_digest, + "reason": reason, + }); + ( + "workflows.createCorrection", + json!({"document": document.to_string()}), + ) + } + }) +} + +fn cleanup_request(action: WorkflowCleanupAction) -> Result<(&'static str, Value, u64)> { + Ok(match action { + WorkflowCleanupAction::Resources { run, before_row } => ( + "workflows.cleanupResources", + json!({"runId": run, "beforeRow": before_row}), + READ_DEADLINE_MS, + ), + WorkflowCleanupAction::List { run, before_row } => ( + "workflows.cleanups", + json!({"runId": run, "beforeRow": before_row}), + READ_DEADLINE_MS, + ), + WorkflowCleanupAction::Preview { + run, + workspaces, + remove_branches, + id, + } => { + if workspaces.is_empty() || workspaces.len() > CLEANUP_MAX_RESOURCES { + bail!("select between one and {CLEANUP_MAX_RESOURCES} workspaces"); + } + if let Some(stray) = remove_branches.iter().find(|id| !workspaces.contains(id)) { + bail!("--remove-branch {stray} is not one of the selected workspaces"); + } + let resources: Vec = workspaces + .iter() + .map(|workspace| { + json!({ + "workspaceId": workspace, + "removeBranch": remove_branches.contains(workspace), + }) + }) + .collect(); + let id = id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let document = json!({"id": id, "runId": run, "resources": resources}); + ( + "workflows.previewCleanup", + json!({"document": document.to_string()}), + READ_DEADLINE_MS, + ) + } + WorkflowCleanupAction::Status { id } => ( + "workflows.cleanupStatus", + json!({"id": id}), + READ_DEADLINE_MS, + ), + WorkflowCleanupAction::Apply(confirm) => ( + "workflows.applyCleanup", + json!({"id": confirm.id, "digest": confirm.digest}), + LONG_DEADLINE_MS, + ), + WorkflowCleanupAction::Retry(confirm) => ( + "workflows.retryCleanup", + json!({"id": confirm.id, "digest": confirm.digest}), + LONG_DEADLINE_MS, + ), + WorkflowCleanupAction::Abandon(confirm) => ( + "workflows.abandonCleanup", + json!({"id": confirm.id, "digest": confirm.digest}), + LONG_DEADLINE_MS, + ), + }) +} + +fn text(inline: Option, from_stdin: bool, max_bytes: usize) -> Result { + let value = match inline { + Some(value) => value, + None if from_stdin => read_limited(&mut std::io::stdin().lock(), max_bytes)?, + None => bail!("the text is required"), + }; + if value.trim().is_empty() || value.len() > max_bytes || value.contains('\0') { + bail!("give a text of at most {max_bytes} bytes"); + } + Ok(value) +} + +fn read_limited(stdin: &mut impl Read, max_bytes: usize) -> Result { + let mut bytes = Vec::new(); + stdin.take(max_bytes as u64 + 1).read_to_end(&mut bytes)?; + Ok(String::from_utf8(bytes)?) +} + +async fn send(runtime: &RuntimeDirArgs, verb: &str, payload: Value, deadline_ms: u64) -> i32 { + match request_value_with_capability( + runtime, + RUNTIME_HOST_WORKFLOW_LIFECYCLE_CAPABILITY, + verb, + payload, + Some(deadline_ms), + ) + .await + { + Ok(value) => { + println!( + "{}", + serde_json::to_string_pretty(&value).unwrap_or_default() + ); + 0 + } + Err(error) => { + eprintln!("{error}"); + 1 + } + } +} + +fn usage(error: anyhow::Error) -> i32 { + eprintln!("{error}"); + crate::USAGE_EXIT_CODE +} + +#[cfg(test)] +#[path = "lifecycle_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/workflow_plan_commands/lifecycle_tests.rs b/rust/alera-cli/src/workflow_plan_commands/lifecycle_tests.rs new file mode 100644 index 000000000..1b2f0054d --- /dev/null +++ b/rust/alera-cli/src/workflow_plan_commands/lifecycle_tests.rs @@ -0,0 +1,109 @@ +use clap::Parser; +use serde_json::json; + +use super::*; +use crate::cli::Cli; + +fn parses(arguments: &[&str]) -> bool { + Cli::try_parse_from(["alera", "orchestration"].iter().chain(arguments)).is_ok() +} + +#[test] +fn lifecycle_cli_offers_no_human_decision() { + for arguments in [ + &["proposals", "approve", "--id", "p"][..], + &["execution", "approve", "--run", "r"], + &["cleanup", "decide", "--id", "c"], + &["plans", "review"], + ] { + assert!(!parses(arguments), "{arguments:?}"); + } + assert!(parses(&["proposals", "start-coordinator", "--id", "p"])); + assert!(parses(&["cleanup", "apply", "--id", "c", "--digest", "d"])); + assert!(!parses(&["cleanup", "preview", "--run", "r"])); + assert!(!parses(&["proposals", "list", "--before-id", "x"])); +} + +#[test] +fn proposal_document_freezes_the_source_and_maps_roles() { + let args = WorkflowProposalCreateArgs { + workspace_id: "ws".into(), + recipe_source: r#"{"origin":"builtIn","id":"quick-fix"}"#.into(), + recipe_digest: "d".into(), + coordinator_profile_id: "prof_c".into(), + role_profiles: vec!["builder=prof_b".into()], + max_concurrent: 2, + run: None, + expected_revision: None, + request_id: "request-1".into(), + objective: None, + objective_stdin: false, + }; + let source = json!({"workspace": {"workspaceId": "ws"}, "sha": "abc"}); + let document: Value = + serde_json::from_str(&proposal_document(&args, "Ship it".into(), &source).unwrap()) + .unwrap(); + assert_eq!(document["expectedSource"]["workspaceId"], "ws"); + let proposal = &document["request"]["proposal"]; + assert_eq!(proposal["sourceSha"], "abc"); + assert_eq!(proposal["roleProfiles"]["builder"], "prof_b"); + assert_eq!(proposal["recipeSource"]["origin"], "builtIn"); + let invalid = WorkflowProposalCreateArgs { + role_profiles: vec!["builder".into()], + ..args + }; + assert!(proposal_document(&invalid, "x".into(), &source).is_err()); +} + +#[test] +fn execution_and_cleanup_requests_wrap_their_documents() { + let (verb, payload) = execution_request( + WorkflowExecutionAction::Control { + run: "r".into(), + revision: 2, + expected_sequence: 3, + action: WorkflowExecutionVerb::Pause, + request_id: "request-1".into(), + }, + &mut std::io::empty(), + ) + .unwrap(); + assert_eq!(verb, "workflows.controlExecution"); + let document: Value = serde_json::from_str(payload["document"].as_str().unwrap()).unwrap(); + assert_eq!(document["action"], "pause"); + let (verb, payload) = execution_request( + WorkflowExecutionAction::Correct { + run: "r".into(), + revision: 2, + plan_digest: "d".into(), + request_id: "request-1".into(), + reason: None, + reason_stdin: true, + }, + &mut "Fix the tests".as_bytes(), + ) + .unwrap(); + assert_eq!(verb, "workflows.createCorrection"); + assert!(payload["document"] + .as_str() + .unwrap() + .contains("Fix the tests")); + let (verb, payload, _) = cleanup_request(WorkflowCleanupAction::Preview { + run: "r".into(), + workspaces: vec!["a".into(), "b".into()], + remove_branches: vec!["b".into()], + id: None, + }) + .unwrap(); + assert_eq!(verb, "workflows.previewCleanup"); + let document: Value = serde_json::from_str(payload["document"].as_str().unwrap()).unwrap(); + assert_eq!(document["resources"][0]["removeBranch"], false); + assert_eq!(document["resources"][1]["removeBranch"], true); + assert!(cleanup_request(WorkflowCleanupAction::Preview { + run: "r".into(), + workspaces: vec!["a".into()], + remove_branches: vec!["b".into()], + id: None, + }) + .is_err()); +} diff --git a/rust/alera-cli/src/workflow_recipe_commands.rs b/rust/alera-cli/src/workflow_recipe_commands.rs index 1ae8942c5..8f137f11b 100644 --- a/rust/alera-cli/src/workflow_recipe_commands.rs +++ b/rust/alera-cli/src/workflow_recipe_commands.rs @@ -9,7 +9,9 @@ use crate::cli_workflow_recipes::{ WorkflowRecipeDocumentArgs, WorkflowRecipesAction, WorkflowRecipesArgs, }; use crate::orchestration_commands::request_value_with_capability; -use crate::terminal_host::protocol::RUNTIME_HOST_WORKFLOW_CATALOG_CAPABILITY; +use crate::terminal_host::protocol::{ + RUNTIME_HOST_WORKFLOW_CATALOG_CAPABILITY, RUNTIME_HOST_WORKFLOW_EXPORT_CAPABILITY, +}; pub(crate) async fn run_workflow_recipes( runtime: &RuntimeDirArgs, @@ -23,15 +25,12 @@ pub(crate) async fn run_workflow_recipes( return 64; } }; - match request_value_with_capability( - runtime, - RUNTIME_HOST_WORKFLOW_CATALOG_CAPABILITY, - verb, - payload, - Some(30_000), - ) - .await - { + let capability = if verb.ends_with("RecipeExport") { + RUNTIME_HOST_WORKFLOW_EXPORT_CAPABILITY + } else { + RUNTIME_HOST_WORKFLOW_CATALOG_CAPABILITY + }; + match request_value_with_capability(runtime, capability, verb, payload, Some(30_000)).await { Ok(value) => { if json_output { println!( @@ -93,6 +92,25 @@ fn request_payload(action: WorkflowRecipesAction) -> Result<(&'static str, Value json!({"document": document(input)?, "expectedRevision": expected_revision}), ) } + WorkflowRecipesAction::Export { + input, + workspace_id, + filename, + expected_digest, + apply, + } => ( + if apply { + "workflows.applyRecipeExport" + } else { + "workflows.previewRecipeExport" + }, + json!({ + "workspaceId": workspace_id, + "filename": filename, + "document": document(input)?, + "expectedDigest": expected_digest, + }), + ), }) } @@ -165,6 +183,32 @@ mod tests { source: r#"{"id":"quick-fix"}"#.into() }) .is_err()); + let (verb, payload) = request_payload(WorkflowRecipesAction::Export { + input: WorkflowRecipeDocumentArgs { + document: Some("name: x".into()), + stdin: false, + }, + workspace_id: "ws".into(), + filename: "x.yaml".into(), + expected_digest: None, + apply: false, + }) + .unwrap(); + assert_eq!(verb, "workflows.previewRecipeExport"); + assert_eq!(payload["filename"], "x.yaml"); + assert!(crate::cli::Cli::try_parse_from([ + "alera", + "orchestration", + "recipes", + "export", + "--stdin", + "--workspace-id", + "ws", + "--filename", + "x.yaml", + "--apply", + ]) + .is_err()); assert!(document(WorkflowRecipeDocumentArgs { document: Some("x".repeat(WORKFLOW_DOCUMENT_MAX_BYTES + 1)), stdin: false diff --git a/rust/alera-cli/src/workspace_buffer_guard_request.rs b/rust/alera-cli/src/workspace_buffer_guard_request.rs index 5ab46fd84..865924ac6 100644 --- a/rust/alera-cli/src/workspace_buffer_guard_request.rs +++ b/rust/alera-cli/src/workspace_buffer_guard_request.rs @@ -7,6 +7,18 @@ pub async fn request_with_workspace_buffer_guard( client: &mut RuntimeHostRpcClient, operation: &str, payload: &Value, +) -> Result { + request_with_resolved_buffer_guard(client, operation, payload, None).await +} + +/// Like [`request_with_workspace_buffer_guard`], but asks the connected +/// desktop apps to `save` or `discard` their dirty editors in the guard's +/// scope before they acknowledge, as the app's own Remove dialog does. +pub async fn request_with_resolved_buffer_guard( + client: &mut RuntimeHostRpcClient, + operation: &str, + payload: &Value, + resolution: Option<&str>, ) -> Result { let workspace_id = payload .get("id") @@ -15,7 +27,7 @@ pub async fn request_with_workspace_buffer_guard( let mut status = client .request_value( "workspace.bufferGuard.acquire", - &json!({"id": workspace_id, "operation": operation, "expectedInstanceId": payload.get("expectedInstanceId")}), + &json!({"id": workspace_id, "operation": operation, "expectedInstanceId": payload.get("expectedInstanceId"), "resolution": resolution}), ) .await?; let id = status @@ -33,7 +45,7 @@ pub async fn request_with_workspace_buffer_guard( while status.get("ready") != Some(&Value::Bool(true)) { let blockers = status.get("blockers").and_then(Value::as_array).ok_or_else(|| anyhow!("Runtime did not verify editor buffers"))?; if !blockers.is_empty() { - bail!("Resolve editor buffers on the connected clients before continuing: {}", blockers.iter().map(|blocker| format!("{}: {}", blocker["path"].as_str().unwrap_or("editor"), blocker["reason"].as_str().unwrap_or("verification failed"))).collect::>().join("; ")); + bail!("{}{}", blocked_prefix(resolution), blockers.iter().map(|blocker| format!("{}: {}", blocker["path"].as_str().unwrap_or("editor"), blocker["reason"].as_str().unwrap_or("verification failed"))).collect::>().join("; ")); } if status.get("disconnectedClients").and_then(Value::as_u64) != Some(0) { bail!("A client disconnected during buffer verification. Prepare the operation again."); } tokio::time::sleep(std::time::Duration::from_millis(100)).await; @@ -52,3 +64,14 @@ pub async fn request_with_workspace_buffer_guard( .await; result } + +/// A guard that was asked to save reports what the apps could not save; one +/// asked to discard reports editors still busy saving. Both start with +/// `blocked:` so callers can tell them from transport failures. +fn blocked_prefix(resolution: Option<&str>) -> &'static str { + match resolution { + Some("save") => "blocked: Alera could not save every editor with unsaved changes in this workspace, so nothing was changed. Save or close them in the Alera app, or discard them (editorBuffers discard, --editor-buffers discard): ", + Some(_) => "blocked: Some editors in this workspace are still saving, so nothing was changed. Retry in a moment: ", + None => "Resolve editor buffers on the connected clients before continuing: ", + } +} diff --git a/rust/alera-cli/src/workspace_list.rs b/rust/alera-cli/src/workspace_list.rs new file mode 100644 index 000000000..fd7412cc9 --- /dev/null +++ b/rust/alera-cli/src/workspace_list.rs @@ -0,0 +1,123 @@ +//! `alera workspace list`, with the sidebar's filters: section, tag, +//! archived, parent, and host. + +use serde_json::{json, Value}; + +use crate::cli::{RuntimeDirArgs, WorkspaceListArgs}; + +pub async fn run(runtime: RuntimeDirArgs, args: WorkspaceListArgs, json_output: bool) -> i32 { + if !args.all && args.project_id.is_none() { + eprintln!("Missing --project-id or --all."); + return crate::USAGE_EXIT_CODE; + } + match list(&runtime, &args).await { + Ok(mut answer) => { + if let Some(items) = answer["items"].as_array_mut() { + items.retain(|item| matches(item, &args)); + } + answer["filters"] = filters(&args); + crate::print_value(&answer, json_output, "workspaces listed"); + 0 + } + Err(error) => crate::print_error(error), + } +} + +async fn list(runtime: &RuntimeDirArgs, args: &WorkspaceListArgs) -> anyhow::Result { + let store = alera_core::runtime::RuntimeStore::open(&crate::runtime_dir(runtime)).await?; + if let Some(answer) = crate::hub_federation::read_from_hub( + runtime, + &store, + "workspace.list", + json!({ "projectId": args.project_id, "hostId": args.host_id }), + ) + .await? + { + return Ok(json!({ + "kind": "workspaces", + "items": answer["items"], + "source": "hub", + "originHostId": answer["originHostId"], + })); + } + let mut workspaces = match &args.project_id { + Some(project_id) if !args.all => store.list_workspaces(project_id).await?, + _ => store.list_all_workspaces().await?, + }; + if let Some(host_id) = &args.host_id { + let host_id = crate::ssh_remote::normalized_host_id(Some(host_id)); + workspaces.retain(|workspace| workspace.host_id == host_id); + } + Ok(json!({ "kind": "workspaces", "items": workspaces })) +} + +fn filters(args: &WorkspaceListArgs) -> Value { + json!({ + "hostId": args.host_id + .as_deref() + .map(|host_id| crate::ssh_remote::normalized_host_id(Some(host_id))), + "sectionId": args.section_id, + "tagId": args.tag_id, + "archived": args.archived, + "parentWorkspaceId": args.parent_workspace_id, + }) +} + +/// Filters apply to the listed JSON so a hub's answer is filtered the same +/// way as this runtime's own records. +fn matches(item: &Value, args: &WorkspaceListArgs) -> bool { + let section_matches = match args.section_id.as_deref() { + None => true, + Some("none") => item["sectionId"].is_null(), + Some(section_id) => item["sectionId"].as_str() == Some(section_id), + }; + let tag_matches = args.tag_id.as_deref().is_none_or(|tag_id| { + item["tagIds"] + .as_array() + .is_some_and(|tags| tags.iter().any(|tag| tag.as_str() == Some(tag_id))) + }); + let archived_matches = args + .archived + .is_none_or(|archived| item["isArchived"].as_bool().unwrap_or(false) == archived); + let parent_matches = args + .parent_workspace_id + .as_deref() + .is_none_or(|parent| item["parentWorkspaceId"].as_str() == Some(parent)); + section_matches && tag_matches && archived_matches && parent_matches +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(section: Option<&str>, tag: Option<&str>, archived: Option) -> WorkspaceListArgs { + WorkspaceListArgs { + project_id: None, + all: true, + host_id: None, + section_id: section.map(str::to_string), + tag_id: tag.map(str::to_string), + archived, + parent_workspace_id: None, + } + } + + #[test] + fn filters_match_sections_tags_archive_and_parents() { + let item = json!({ + "id": "w", "sectionId": "s1", "tagIds": ["t1"], "isArchived": true, + "parentWorkspaceId": "p", + }); + let others = json!({ "id": "o", "sectionId": null, "tagIds": [], "isArchived": false }); + assert!(matches(&item, &args(Some("s1"), Some("t1"), Some(true)))); + assert!(!matches(&item, &args(Some("s2"), None, None))); + assert!(!matches(&item, &args(None, Some("t2"), None))); + assert!(!matches(&item, &args(None, None, Some(false)))); + assert!(matches(&others, &args(Some("none"), None, Some(false)))); + assert!(!matches(&item, &args(Some("none"), None, None))); + let mut by_parent = args(None, None, None); + by_parent.parent_workspace_id = Some("p".into()); + assert!(matches(&item, &by_parent)); + assert!(!matches(&others, &by_parent)); + } +} diff --git a/rust/alera-cli/src/workspace_pinning.rs b/rust/alera-cli/src/workspace_pinning.rs index 2a661c8f4..5ab47974e 100644 --- a/rust/alera-cli/src/workspace_pinning.rs +++ b/rust/alera-cli/src/workspace_pinning.rs @@ -3,14 +3,29 @@ use std::path::PathBuf; use alera_core::runtime::{RuntimeStore, Workspace}; use serde_json::json; +use crate::cli::WorkspacePinArgs; use crate::runtime_host_client::RuntimeHostRpcClient; -pub async fn run(runtime_dir: PathBuf, json_output: bool, id: String, is_pinned: bool) -> i32 { - match set_pinned(runtime_dir, id, is_pinned).await { - Ok(workspace) if json_output => { +pub async fn run( + runtime_dir: PathBuf, + json_output: bool, + args: WorkspacePinArgs, + is_pinned: bool, +) -> i32 { + let result = if args.tree { + set_tree_pinned(runtime_dir, &args.id, is_pinned) + .await + .map(|items| json!({ "kind": "workspaces", "items": items })) + } else { + set_pinned(runtime_dir, args.id, is_pinned) + .await + .map(|workspace| json!(workspace)) + }; + match result { + Ok(value) if json_output => { println!( "{}", - serde_json::to_string_pretty(&workspace).unwrap_or_else(|_| "{}".to_string()) + serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_string()) ); 0 } @@ -28,6 +43,24 @@ pub async fn run(runtime_dir: PathBuf, json_output: bool, id: String, is_pinned: } } +/// Pin Workspace Tree: the workspace and every descendant, as the sidebar +/// applies it one workspace at a time. +async fn set_tree_pinned( + runtime_dir: PathBuf, + id: &str, + is_pinned: bool, +) -> anyhow::Result> { + let workspaces = crate::workspace_tree::all_workspaces(&runtime_dir).await?; + if !workspaces.iter().any(|workspace| workspace.id == id) { + anyhow::bail!("Workspace not found: {id}"); + } + let mut updated = Vec::new(); + for workspace_id in crate::workspace_tree::tree_ids(&workspaces, id) { + updated.push(set_pinned(runtime_dir.clone(), workspace_id, is_pinned).await?); + } + Ok(updated) +} + async fn set_pinned( runtime_dir: PathBuf, id: String, diff --git a/rust/alera-cli/src/workspace_prompt_start.rs b/rust/alera-cli/src/workspace_prompt_start.rs new file mode 100644 index 000000000..0f0f6efa0 --- /dev/null +++ b/rust/alera-cli/src/workspace_prompt_start.rs @@ -0,0 +1,219 @@ +//! `alera workspace prompt-start`: New Workspace from Prompt as a runtime +//! operation. `run` returns as soon as the runtime records the operation, or +//! after `--wait` seconds; `wait` follows it with bounded polling. + +use std::time::{Duration, Instant}; + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::cli::{ + PromptStartModeArg, RuntimeDirArgs, WorkspacePromptStartAction, WorkspacePromptStartCommand, + WorkspacePromptStartRunArgs, +}; +use crate::mcp_tools::CallOrigin; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::RUNTIME_HOST_PROMPT_WORKSPACE_SERVICE_CAPABILITY; +use crate::{print_error, print_value, runtime_dir}; + +const POLL_INTERVAL: Duration = Duration::from_secs(1); + +pub async fn run( + runtime: RuntimeDirArgs, + command: WorkspacePromptStartCommand, + json_output: bool, +) -> i32 { + match run_inner(&runtime, command).await { + Ok(value) => { + print_value(&value, json_output, &summary(&value)); + 0 + } + Err(error) => print_error(error), + } +} + +async fn run_inner( + runtime: &RuntimeDirArgs, + command: WorkspacePromptStartCommand, +) -> Result { + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &runtime_dir(runtime), + RUNTIME_HOST_PROMPT_WORKSPACE_SERVICE_CAPABILITY, + ) + .await?; + match command.action { + WorkspacePromptStartAction::Run(args) => { + let wait = args.wait; + let started = client + .request_value("workspace.promptStart.start", &start_payload(&args)?) + .await?; + if wait == 0 { + return Ok(started); + } + let id = started["id"].as_str().unwrap_or_default().to_owned(); + wait_for(&mut client, &id, Duration::from_secs(wait)).await + } + WorkspacePromptStartAction::Show(args) => { + client + .request_value("workspace.promptStart.get", &json!({ "id": args.id })) + .await + } + WorkspacePromptStartAction::List(args) => { + client + .request_value( + "workspace.promptStart.list", + &json!({ "limit": args.limit }), + ) + .await + } + WorkspacePromptStartAction::Wait(args) => { + wait_for( + &mut client, + &args.id, + Duration::from_secs(args.timeout_seconds), + ) + .await + } + WorkspacePromptStartAction::Cancel(args) => { + client + .request_value("workspace.promptStart.cancel", &json!({ "id": args.id })) + .await + } + WorkspacePromptStartAction::RetryLaunch(args) => { + client + .request_value( + "workspace.promptStart.retryLaunch", + &json!({ "id": args.id }), + ) + .await + } + } +} + +pub(crate) fn start_payload(args: &WorkspacePromptStartRunArgs) -> Result { + let section = match ( + args.section_id.as_deref(), + args.section.as_deref().map(str::trim), + ) { + (Some(id), _) => json!({ "id": id }), + (None, None | Some("auto")) => json!("auto"), + (None, Some("none")) => json!("none"), + (None, Some(name)) => json!({ "name": name }), + }; + let mode = match args.mode { + PromptStartModeArg::Auto => "auto", + PromptStartModeArg::Worktree => "worktree", + PromptStartModeArg::ProjectCheckout => "projectCheckout", + }; + Ok(json!({ + "prompt": args.prompt.read()?, + "projectId": args.project_id, + "profile": args.profile, + "mode": mode, + "sourceBranch": args.source_branch, + "hostId": args.host_id, + "parentWorkspaceId": args.parent_workspace_id, + "issueUrl": args.issue_url, + "section": section, + "requestId": args.request_id, + "origin": CallOrigin::from_env(), + })) +} + +/// Polls until the operation stops running or the time is up, then returns +/// its latest state either way. +async fn wait_for(client: &mut RuntimeHostRpcClient, id: &str, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + let operation = client + .request_value("workspace.promptStart.get", &json!({ "id": id })) + .await?; + let running = operation["status"] == "running"; + let now = Instant::now(); + if !running || now >= deadline { + return Ok(operation); + } + tokio::time::sleep(POLL_INTERVAL.min(deadline - now)).await; + } +} + +fn summary(value: &Value) -> String { + if let Some(items) = value["items"].as_array() { + return format!("{} prompt workspace operation(s)", items.len()); + } + let status = value["status"].as_str().unwrap_or("unknown"); + let phase = value["phase"].as_str().unwrap_or_default(); + let mut line = format!( + "operation {}: {status}", + value["id"].as_str().unwrap_or("?") + ); + if status == "running" { + line.push_str(&format!(" ({phase})")); + } + if let Some(name) = value["workspace"]["name"].as_str() { + line.push_str(&format!(", workspace {name}")); + } + if let Some(message) = value["error"]["message"].as_str() { + line.push_str(&format!(": {message}")); + } + line +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use serde_json::json; + + use super::start_payload; + use crate::cli::{Cli, Command, WorkspaceAction, WorkspacePromptStartAction}; + + fn run_args(extra: &[&str]) -> crate::cli::WorkspacePromptStartRunArgs { + let mut argv = vec![ + "alera", + "workspace", + "prompt-start", + "run", + "--prompt", + "Fix login", + ]; + argv.extend_from_slice(extra); + let Command::Workspace(command) = Cli::try_parse_from(argv).unwrap().command else { + panic!("expected a workspace command"); + }; + let WorkspaceAction::PromptStart(prompt_start) = command.action else { + panic!("expected prompt-start"); + }; + let WorkspacePromptStartAction::Run(args) = prompt_start.action else { + panic!("expected run"); + }; + *args + } + + #[test] + fn sections_map_to_the_runtime_policy() { + assert_eq!( + start_payload(&run_args(&[])).unwrap()["section"], + json!("auto") + ); + assert_eq!( + start_payload(&run_args(&["--section", "none"])).unwrap()["section"], + json!("none") + ); + assert_eq!( + start_payload(&run_args(&["--section", "Alera"])).unwrap()["section"], + json!({ "name": "Alera" }) + ); + assert_eq!( + start_payload(&run_args(&["--section-id", "s-1"])).unwrap()["section"], + json!({ "id": "s-1" }) + ); + } + + #[test] + fn modes_use_the_runtime_spelling() { + let payload = start_payload(&run_args(&["--mode", "project-checkout"])).unwrap(); + assert_eq!(payload["mode"], "projectCheckout"); + assert_eq!(payload["prompt"], "Fix login"); + assert_eq!(start_payload(&run_args(&[])).unwrap()["mode"], "auto"); + } +} diff --git a/rust/alera-cli/src/workspace_removal_dependencies.rs b/rust/alera-cli/src/workspace_removal_dependencies.rs index e53afccaa..8e2e58ad3 100644 --- a/rust/alera-cli/src/workspace_removal_dependencies.rs +++ b/rust/alera-cli/src/workspace_removal_dependencies.rs @@ -19,7 +19,7 @@ pub async fn prepare_cli_removal_dependencies( client: &mut crate::runtime_host_client::RuntimeHostRpcClient, workspace_id: &str, approved: bool, -) -> Result<()> { +) -> Result> { prepare_cli_dependencies( client, workspace_id, @@ -34,7 +34,7 @@ pub async fn prepare_cli_project_removal_dependencies( client: &mut crate::runtime_host_client::RuntimeHostRpcClient, project_id: &str, approved: bool, -) -> Result<()> { +) -> Result> { prepare_cli_dependencies( client, project_id, @@ -51,12 +51,12 @@ async fn prepare_cli_dependencies( approved: bool, request_type: &str, owner_kind: &str, -) -> Result<()> { +) -> Result> { let dependencies: Vec = client .request(request_type, &serde_json::json!({"id": workspace_id})) .await?; if dependencies.is_empty() { - return Ok(()); + return Ok(dependencies); } if !approved { bail!("{owner_kind} removal affects these automations: {}. Review this impact and pass --pause-automations-and-cancel-runs to pause them and cancel all their active runs. Their history is preserved; their targets must be updated before resuming.", dependencies.iter().map(|dependency| format!("{} ({} active runs)", dependency.name, dependency.active_runs)).collect::>().join(", ")); @@ -76,7 +76,8 @@ async fn prepare_cli_dependencies( if pending.iter().any(|dependency| !approved_ids.contains(&dependency.id)) { bail!("Automation dependencies changed; review their impact again before removing the workspace"); } tokio::time::sleep(std::time::Duration::from_millis(250)).await; } - }).await.map_err(|_| anyhow::anyhow!("Automation shutdown has not completed. The target was preserved; retry after its runs stop."))? + }).await.map_err(|_| anyhow::anyhow!("Automation shutdown has not completed. The target was preserved; retry after its runs stop."))??; + Ok(dependencies) } pub async fn workspace_removal_dependencies( diff --git a/rust/alera-cli/src/workspace_remove.rs b/rust/alera-cli/src/workspace_remove.rs new file mode 100644 index 000000000..0152bf81f --- /dev/null +++ b/rust/alera-cli/src/workspace_remove.rs @@ -0,0 +1,259 @@ +//! `alera workspace remove`. +//! +//! Without `--editor-buffers` it is the low-level removal it always was: the +//! caller chooses the branch, session, and automation policy. With it, the +//! command runs the Remove flow of the Alera app +//! (`workspace_removal_launcher.dart`): the branch is deleted when it can be, +//! dependent automations are paused, storage blockers refuse the removal like +//! Cleanup Unavailable, sessions close, the editors open on the workspace in +//! connected apps are saved or discarded, and uncommitted changes in the +//! worktree are lost with it. + +use alera_core::runtime::{Workspace, WorkspaceKind}; +use anyhow::{anyhow, bail, Result}; +use serde_json::{json, Value}; + +use crate::cli::{EditorBuffersArg, RuntimeDirArgs, WorkspaceRemoveArgs}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::workspace_removal_dependencies::{ + prepare_cli_removal_dependencies, WorkspaceRemovalDependency, +}; + +/// The storage blocker that pausing the dependent automations clears, which +/// is why the app ignores it when it is about to pause them. +const AUTOMATION_OWNER_BLOCKER: &str = "Workspace is owned by an active automation"; +/// Measuring a large worktree walks every entry. +const STORAGE_DEADLINE_MS: u64 = 120_000; + +pub async fn run(runtime: RuntimeDirArgs, args: WorkspaceRemoveArgs, json_output: bool) -> i32 { + let result = async { + let mut client = crate::runtime_host_required(&runtime).await?; + match args.editor_buffers { + Some(editor_buffers) => { + let calling = crate::orchestration_commands::workspace_id_env(); + if calling.as_deref() == Some(args.id.trim()) { + bail!( + "Refusing to remove workspace {} from one of its own terminals: closing its sessions would stop this terminal too. Run the command from another workspace or use Remove in the app.", + args.id.trim() + ); + } + remove_like_app(&mut client, &args, editor_buffers).await + } + None => remove(&mut client, &args).await, + } + } + .await; + match result { + Ok(value) => { + crate::print_value(&value, json_output, "workspace removed"); + 0 + } + Err(error) => crate::print_error(error), + } +} + +async fn find(client: &mut RuntimeHostRpcClient, id: &str) -> Result { + let workspace: Option = client.request("workspace.find", &json!({"id": id})).await?; + workspace.ok_or_else(|| anyhow!("Workspace not found: {id}")) +} + +async fn remove(client: &mut RuntimeHostRpcClient, args: &WorkspaceRemoveArgs) -> Result { + let delete_branch = match (args.delete_branch, args.keep_branch) { + (true, _) => Some(true), + (_, true) => Some(false), + _ => None, + }; + let payload = json!({ + "id": args.id, + "deleteBranch": delete_branch, + "closeSessions": args.close_sessions, + }); + let workspace = find(client, &args.id).await?; + prepare_cli_removal_dependencies(client, &args.id, args.pause_automations_and_cancel_runs) + .await?; + if workspace.kind == WorkspaceKind::Main { + crate::workspace_buffer_guard_request::request_with_workspace_buffer_guard( + client, + "removeShared", + &payload, + ) + .await + } else { + client + .request_value("workspace.removeManaged", &payload) + .await + } +} + +/// The app's Remove button offers to delete the branch only for a workspace +/// that created its own branch. +pub(crate) fn can_delete_branch(workspace: &Workspace) -> bool { + workspace.kind != WorkspaceKind::Main + && !workspace.reuses_existing_branch + && workspace + .branch + .as_deref() + .is_some_and(|branch| !branch.trim().is_empty()) +} + +pub(crate) async fn remove_like_app( + client: &mut RuntimeHostRpcClient, + args: &WorkspaceRemoveArgs, + editor_buffers: EditorBuffersArg, +) -> Result { + let id = args.id.trim(); + let workspace = find(client, id).await?; + let shared = workspace.kind == WorkspaceKind::Main; + let deletable = can_delete_branch(&workspace); + let delete_branch = !shared && !args.keep_branch && (args.delete_branch || deletable); + let dependencies: Vec = client + .request("workspace.removalDependencies", &json!({"id": id})) + .await?; + let pausing = dependencies + .iter() + .any(|dependency| dependency.requires_pause); + if !shared { + let impact = client + .request_value_with_deadline( + "workspace.storageImpact", + &json!({"id": id, "closeSessions": true}), + STORAGE_DEADLINE_MS, + ) + .await?; + refuse_blocked_cleanup(&impact, pausing)?; + } + let all: Vec = client.request("workspace.listAll", &json!({})).await?; + let children: Vec = all + .iter() + .filter(|candidate| candidate.parent_workspace_id.as_deref() == Some(id)) + .map(|child| json!({"id": child.id, "name": child.name})) + .collect(); + let terminals = client + .request_value("orchestration.terminals", &json!({"workspace": id})) + .await?; + let closed_sessions = terminals["items"].as_array().map_or(0, |items| { + items.iter().filter(|item| item["running"] == true).count() + }); + let paused: Vec = prepare_cli_removal_dependencies(client, id, true) + .await? + .into_iter() + .filter(|dependency| dependency.requires_pause) + .map(|dependency| json!({"id": dependency.id, "name": dependency.name})) + .collect(); + let payload = json!({ + "id": id, + "deleteBranch": if shared { Value::Null } else { json!(delete_branch) }, + "closeSessions": true, + }); + let operation = if shared { + "removeShared" + } else { + "removeManaged" + }; + crate::workspace_buffer_guard_request::request_with_resolved_buffer_guard( + client, + operation, + &payload, + Some(editor_buffers.as_str()), + ) + .await + .map_err(|error| match paused.is_empty() { + true => error, + false => anyhow!( + "{error} The workspace was kept, but its dependent automations were already paused and their active runs cancelled: {}.", + paused + .iter() + .filter_map(|item| item["name"].as_str()) + .collect::>() + .join(", ") + ), + })?; + let branch = branch_outcome(client, &workspace, delete_branch, args.keep_branch).await; + Ok(json!({ + "removed": true, + "workspaceId": id, + "branch": branch, + "pausedAutomations": paused, + "unlinkedChildren": children, + "closedSessions": closed_sessions, + })) +} + +/// Cleanup Unavailable, without the automation blocker the pause clears. +fn refuse_blocked_cleanup(impact: &Value, pausing: bool) -> Result<()> { + let blockers: Vec<&str> = impact["blockers"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|blocker| !(pausing && *blocker == AUTOMATION_OWNER_BLOCKER)) + .collect(); + if blockers.is_empty() { + return Ok(()); + } + bail!( + "blocked: Cleanup Unavailable. Alera measured {} bytes across {} entries. Cleanup is blocked: {}", + impact["sizeBytes"].as_u64().unwrap_or(0), + impact["entryCount"].as_u64().unwrap_or(0), + blockers.join("; ") + ) +} + +/// Branch deletion is safe like the app's: Git keeps a branch with unmerged +/// work, a protected or default branch, and one checked out elsewhere. The +/// host does not report which happened, so the branch catalog tells. +async fn branch_outcome( + client: &mut RuntimeHostRpcClient, + workspace: &Workspace, + delete_requested: bool, + keep_requested: bool, +) -> Value { + let Some(name) = workspace + .branch + .as_deref() + .filter(|branch| !branch.trim().is_empty()) + else { + return json!({"name": null, "deleted": false, "retainedReason": "The workspace has no branch of its own."}); + }; + let retained = |reason: &str| json!({"name": name, "deleted": false, "retainedReason": reason}); + if workspace.kind == WorkspaceKind::Main { + return retained("The project folder's branch is never deleted."); + } + if workspace.reuses_existing_branch { + return retained("The workspace reused an existing branch, which Alera never deletes."); + } + if !delete_requested { + return retained(if keep_requested { + "Kept as requested." + } else { + "The branch cannot be deleted." + }); + } + let catalog = client + .request_value( + "project.branches.list", + &json!({"projectId": workspace.project_id, "hostId": workspace.host_id}), + ) + .await; + match catalog { + Ok(catalog) => { + let still_there = catalog["localBranches"] + .as_array() + .is_some_and(|branches| branches.iter().any(|branch| branch == name)); + if still_there { + retained("Git kept the branch: it has unmerged commits, is protected or the default branch, or is checked out elsewhere.") + } else { + json!({"name": name, "deleted": true}) + } + } + Err(error) => json!({ + "name": name, + "deleted": Value::Null, + "retainedReason": format!("Could not check the branch after removal: {error}"), + }), + } +} + +#[cfg(test)] +#[path = "workspace_remove_tests.rs"] +mod tests; diff --git a/rust/alera-cli/src/workspace_remove_preview.rs b/rust/alera-cli/src/workspace_remove_preview.rs new file mode 100644 index 000000000..d218918f9 --- /dev/null +++ b/rust/alera-cli/src/workspace_remove_preview.rs @@ -0,0 +1,94 @@ +//! `alera workspace remove-preview`: what the app's Remove flow would do, +//! without doing it. It measures storage (and its blockers), lists the +//! automations a removal pauses and the linked workspaces it unlinks, and says +//! what happens to the branch. Nothing changes. + +use alera_core::runtime::{Workspace, WorkspaceKind}; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; + +use crate::cli::{IdArgs, RuntimeDirArgs}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::workspace_removal_dependencies::WorkspaceRemovalDependency; + +const AUTOMATION_OWNER_BLOCKER: &str = "Workspace is owned by an active automation"; + +pub async fn run(runtime: RuntimeDirArgs, args: IdArgs, json_output: bool) -> i32 { + let result = async { + let mut client = crate::runtime_host_required(&runtime).await?; + preview(&mut client, args.id.trim()).await + } + .await; + match result { + Ok(value) => { + let message = if value["removable"] == true { + "workspace can be removed" + } else { + "workspace removal is blocked" + }; + crate::print_value(&value, json_output, message); + 0 + } + Err(error) => crate::print_error(error), + } +} + +pub(crate) async fn preview(client: &mut RuntimeHostRpcClient, id: &str) -> Result { + let workspace: Option = client.request("workspace.find", &json!({"id": id})).await?; + let workspace = workspace.ok_or_else(|| anyhow!("Workspace not found: {id}"))?; + let shared = workspace.kind == WorkspaceKind::Main; + let dependencies: Vec = client + .request("workspace.removalDependencies", &json!({"id": id})) + .await?; + let pausing = dependencies + .iter() + .any(|dependency| dependency.requires_pause); + let storage = if shared { + Value::Null + } else { + client + .request_value_with_deadline( + "workspace.storageImpact", + &json!({"id": id, "closeSessions": true}), + 120_000, + ) + .await? + }; + let blockers: Vec = storage["blockers"] + .as_array() + .into_iter() + .flatten() + .filter(|blocker| !(pausing && blocker.as_str() == Some(AUTOMATION_OWNER_BLOCKER))) + .cloned() + .collect(); + let all: Vec = client.request("workspace.listAll", &json!({})).await?; + let children: Vec = all + .iter() + .filter(|candidate| candidate.parent_workspace_id.as_deref() == Some(id)) + .map(|child| json!({"id": child.id, "name": child.name})) + .collect(); + let terminals = client + .request_value("orchestration.terminals", &json!({"workspace": id})) + .await?; + let live_sessions = terminals["items"].as_array().map_or(0, |items| { + items.iter().filter(|item| item["running"] == true).count() + }); + let can_delete = crate::workspace_remove::can_delete_branch(&workspace); + Ok(json!({ + "workspaceId": id, + "name": workspace.name, + "sharedCheckout": shared, + "removable": blockers.is_empty(), + "blockers": blockers, + "storage": storage, + "automations": dependencies, + "unlinkedChildren": children, + "liveSessions": live_sessions, + "branch": { + "name": workspace.branch, + "canDelete": can_delete, + "defaultAction": if can_delete { "delete" } else { "keep" }, + }, + "uncommittedChangesLost": !shared, + })) +} diff --git a/rust/alera-cli/src/workspace_remove_tests.rs b/rust/alera-cli/src/workspace_remove_tests.rs new file mode 100644 index 000000000..3c0fa6c78 --- /dev/null +++ b/rust/alera-cli/src/workspace_remove_tests.rs @@ -0,0 +1,262 @@ +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use super::*; +use crate::terminal_host::protocol::{ + PROTOCOL_VERSION, RUNTIME_HOST_BOOTSTRAP_CAPABILITY, RUNTIME_HOST_CAPABILITY, + RUNTIME_HOST_MANAGED_WORKSPACE_CAPABILITY, RUNTIME_HOST_SHARED_CHECKOUT_CAPABILITY, +}; + +type Requests = Arc>>; + +/// A host that answers a fixed sequence of requests and records them. Any +/// request out of order, or any request after the script, fails the test. +async fn scripted_host( + sequence: Vec<(&'static str, Value)>, +) -> (tempfile::TempDir, RuntimeHostRpcClient, Requests) { + let directory = tempfile::tempdir().unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + std::fs::write( + directory.path().join("host.json"), + serde_json::to_vec(&json!({ + "protocolVersion": PROTOCOL_VERSION, "port": port, "token": "fixture-token", + "runtimeCapabilities": [RUNTIME_HOST_CAPABILITY, RUNTIME_HOST_SHARED_CHECKOUT_CAPABILITY, + RUNTIME_HOST_BOOTSTRAP_CAPABILITY, RUNTIME_HOST_MANAGED_WORKSPACE_CAPABILITY], + })) + .unwrap(), + ) + .unwrap(); + let requests: Requests = Arc::default(); + let recorded = requests.clone(); + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let (read, mut write) = stream.into_split(); + let mut lines = BufReader::new(read).lines(); + for (expected, payload) in std::iter::once(("hello", json!({}))).chain(sequence) { + let line = lines.next_line().await.unwrap().unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["type"], expected, "{request}"); + recorded.lock().unwrap().push(request.clone()); + let response = json!({"id": request["id"], "ok": true, "payload": payload}); + write + .write_all(format!("{response}\n").as_bytes()) + .await + .unwrap(); + } + assert!( + lines.next_line().await.unwrap().is_none(), + "unexpected request after the script" + ); + }); + let client = RuntimeHostRpcClient::connect(directory.path()) + .await + .unwrap() + .unwrap(); + (directory, client, requests) +} + +fn workspace(kind: &str, reuses_existing_branch: bool) -> Value { + json!({ + "id": "task", "instanceId": "instance", "hostId": "local", "projectId": "project", + "name": "Task", "branch": "feature/task", "path": "/worktrees/task", + "createdAt": "2026-10-10T00:00:00Z", "updatedAt": "2026-10-10T00:00:00Z", + "kind": kind, "status": "active", "sourceBranch": "main", + "reusesExistingBranch": reuses_existing_branch, "parentWorkspaceId": null, + }) +} + +fn args(keep_branch: bool) -> WorkspaceRemoveArgs { + WorkspaceRemoveArgs { + id: "task".into(), + delete_branch: false, + keep_branch, + close_sessions: false, + pause_automations_and_cancel_runs: false, + editor_buffers: Some(EditorBuffersArg::Save), + } +} + +fn dependency(requires_pause: bool) -> Value { + json!([{"id": "nightly", "name": "Nightly Review", "activeRuns": 1, "requiresPause": requires_pause}]) +} + +fn request<'a>(requests: &'a [Value], kind: &str) -> &'a Value { + requests + .iter() + .find(|request| request["type"] == kind) + .unwrap_or_else(|| panic!("no {kind} request")) +} + +#[tokio::test] +async fn the_app_flow_pauses_saves_removes_and_reports_the_branch() { + let mut child = workspace("linked", false); + child["id"] = json!("child"); + child["parentWorkspaceId"] = json!("task"); + let (_directory, mut client, requests) = scripted_host(vec![ + ("workspace.find", workspace("linked", false)), + ("workspace.removalDependencies", dependency(true)), + ( + "workspace.storageImpact", + json!({"sizeBytes": 10, "entryCount": 2, "safeToClean": false, + "blockers": ["Workspace is owned by an active automation"]}), + ), + ( + "workspace.listAll", + json!([workspace("linked", false), child]), + ), + ( + "orchestration.terminals", + json!({"items": [{"running": true}, {"running": false}]}), + ), + ("workspace.removalDependencies", dependency(true)), + ("automation.pause", json!({})), + ("workspace.removalDependencies", dependency(false)), + ( + "workspace.bufferGuard.acquire", + json!({"guardId": "guard", "ready": true, "blockers": [], "disconnectedClients": 0}), + ), + ("workspace.removeManaged", workspace("linked", false)), + ("workspace.bufferGuard.release", json!({})), + ( + "project.branches.list", + json!({"branches": ["main"], "localBranches": ["main"]}), + ), + ]) + .await; + + let removed = remove_like_app(&mut client, &args(false), EditorBuffersArg::Save) + .await + .unwrap(); + + assert_eq!( + removed, + json!({ + "removed": true, + "workspaceId": "task", + "branch": {"name": "feature/task", "deleted": true}, + "pausedAutomations": [{"id": "nightly", "name": "Nightly Review"}], + "unlinkedChildren": [{"id": "child", "name": "Task"}], + "closedSessions": 1, + }) + ); + let requests = requests.lock().unwrap(); + let guard = &request(&requests, "workspace.bufferGuard.acquire")["payload"]; + assert_eq!(guard["resolution"], "save"); + assert_eq!(guard["operation"], "removeManaged"); + let removal = &request(&requests, "workspace.removeManaged")["payload"]; + assert_eq!(removal["deleteBranch"], true); + assert_eq!(removal["closeSessions"], true); + assert_eq!(removal["bufferGuardId"], "guard"); + assert_eq!( + request(&requests, "workspace.storageImpact")["payload"]["closeSessions"], + true + ); +} + +#[tokio::test] +async fn cleanup_blockers_refuse_before_anything_changes() { + let (_directory, mut client, requests) = scripted_host(vec![ + ("workspace.find", workspace("linked", false)), + ("workspace.removalDependencies", json!([])), + ( + "workspace.storageImpact", + json!({"sizeBytes": 10, "entryCount": 2, "safeToClean": false, + "blockers": ["Workspace path is outside Alera-managed storage"]}), + ), + ]) + .await; + + let error = remove_like_app(&mut client, &args(false), EditorBuffersArg::Discard) + .await + .unwrap_err() + .to_string(); + + assert!(error.starts_with("blocked: Cleanup Unavailable"), "{error}"); + assert!(error.contains("outside Alera-managed storage"), "{error}"); + assert_eq!(requests.lock().unwrap().len(), 4); +} + +#[tokio::test] +async fn a_kept_or_reused_branch_is_reported_without_deleting() { + for (keep, reused, reason) in [ + (true, false, "Kept as requested."), + (false, true, "reused an existing branch"), + ] { + let (_directory, mut client, requests) = scripted_host(vec![ + ("workspace.find", workspace("linked", reused)), + ("workspace.removalDependencies", json!([])), + ( + "workspace.storageImpact", + json!({"safeToClean": true, "blockers": []}), + ), + ("workspace.listAll", json!([])), + ("orchestration.terminals", json!({"items": []})), + ("workspace.removalDependencies", json!([])), + ( + "workspace.bufferGuard.acquire", + json!({"guardId": "g", "ready": true, "blockers": [], "disconnectedClients": 0}), + ), + ("workspace.removeManaged", workspace("linked", reused)), + ("workspace.bufferGuard.release", json!({})), + ]) + .await; + + let removed = remove_like_app(&mut client, &args(keep), EditorBuffersArg::Discard) + .await + .unwrap(); + + assert_eq!(removed["branch"]["deleted"], false); + let retained = removed["branch"]["retainedReason"].as_str().unwrap(); + assert!(retained.contains(reason), "{retained}"); + let requests = requests.lock().unwrap(); + assert_eq!( + request(&requests, "workspace.removeManaged")["payload"]["deleteBranch"], + false + ); + assert_eq!( + request(&requests, "workspace.bufferGuard.acquire")["payload"]["resolution"], + "discard" + ); + } +} + +#[tokio::test] +async fn unsaved_editors_block_with_a_hint_to_discard() { + let (_directory, mut client, _requests) = scripted_host(vec![ + ("workspace.find", workspace("main", false)), + ("workspace.removalDependencies", json!([])), + ("workspace.listAll", json!([])), + ("orchestration.terminals", json!({"items": []})), + ("workspace.removalDependencies", json!([])), + ( + "workspace.bufferGuard.acquire", + json!({"guardId": "g", "ready": false, "disconnectedClients": 0, "blockers": [ + {"path": "src/main.rs", "reason": "Could not save the changes: disk full"}]}), + ), + ("workspace.bufferGuard.release", json!({})), + ]) + .await; + + let error = remove_like_app(&mut client, &args(false), EditorBuffersArg::Save) + .await + .unwrap_err() + .to_string(); + + assert!(error.starts_with("blocked: "), "{error}"); + assert!(error.contains("editorBuffers discard"), "{error}"); + assert!(error.contains("src/main.rs"), "{error}"); +} + +#[test] +fn only_a_branch_the_workspace_created_can_be_deleted() { + let parse = |value: Value| serde_json::from_value::(value).unwrap(); + assert!(can_delete_branch(&parse(workspace("linked", false)))); + assert!(!can_delete_branch(&parse(workspace("linked", true)))); + assert!(!can_delete_branch(&parse(workspace("main", false)))); + let mut empty = workspace("linked", false); + empty["branch"] = json!(""); + assert!(!can_delete_branch(&parse(empty))); +} diff --git a/rust/alera-cli/src/workspace_sections.rs b/rust/alera-cli/src/workspace_sections.rs index ed363e8ab..d301dcc65 100644 --- a/rust/alera-cli/src/workspace_sections.rs +++ b/rust/alera-cli/src/workspace_sections.rs @@ -39,26 +39,45 @@ async fn execute(runtime_dir: &Path, action: WorkspaceSectionAction) -> Result<( "workspace sections listed".to_string(), )) } - WorkspaceSectionAction::Create(WorkspaceSectionCreateArgs { name, workspace_id }) => { + WorkspaceSectionAction::Create(WorkspaceSectionCreateArgs { + name, + workspace_id, + tree, + }) => { let section = backend.create(&name, &workspace_id).await?; - Ok((json!(section), "workspace section created".to_string())) + let descendants = tree_rest(runtime_dir, &workspace_id, tree).await?; + for descendant in &descendants { + backend.set(descendant, Some(§ion.id)).await?; + } + let mut value = json!(section); + value["treeWorkspaceIds"] = json!(descendants); + Ok((value, "workspace section created".to_string())) } WorkspaceSectionAction::Set(args) => { - let payload = assign( + let mut payload = assign( &mut backend, &args.workspace_id, args.section, args.section_id, ) .await?; + let section_id = payload["sectionId"].as_str().map(str::to_string); + let descendants = tree_rest(runtime_dir, &args.workspace_id, args.tree).await?; + for descendant in &descendants { + backend.set(descendant, section_id.as_deref()).await?; + } + payload["treeWorkspaceIds"] = json!(descendants); Ok((payload, "workspace section assigned".to_string())) } - WorkspaceSectionAction::Clear(WorkspaceSectionWorkspaceArgs { workspace_id }) => { + WorkspaceSectionAction::Clear(WorkspaceSectionWorkspaceArgs { workspace_id, tree }) => { backend.set(&workspace_id, None).await?; - Ok(( - set_for_workspace_payload(&workspace_id, None), - "workspace section cleared".to_string(), - )) + let descendants = tree_rest(runtime_dir, &workspace_id, tree).await?; + for descendant in &descendants { + backend.set(descendant, None).await?; + } + let mut payload = set_for_workspace_payload(&workspace_id, None); + payload["treeWorkspaceIds"] = json!(descendants); + Ok((payload, "workspace section cleared".to_string())) } WorkspaceSectionAction::Remove(IdArgs { id }) => { backend.remove(&id).await?; @@ -67,6 +86,18 @@ async fn execute(runtime_dir: &Path, action: WorkspaceSectionAction) -> Result<( } } +/// The descendants a Tree action also applies to, without the workspace. +async fn tree_rest(runtime_dir: &Path, workspace_id: &str, tree: bool) -> Result> { + if !tree { + return Ok(Vec::new()); + } + let workspaces = crate::workspace_tree::all_workspaces(runtime_dir).await?; + Ok(crate::workspace_tree::tree_ids(&workspaces, workspace_id) + .into_iter() + .skip(1) + .collect()) +} + async fn assign( backend: &mut Backend, workspace_id: &str, diff --git a/rust/alera-cli/src/workspace_show.rs b/rust/alera-cli/src/workspace_show.rs new file mode 100644 index 000000000..7c885ee25 --- /dev/null +++ b/rust/alera-cli/src/workspace_show.rs @@ -0,0 +1,99 @@ +//! `alera workspace show`: one workspace with everything the sidebar and its +//! context menu show about it. Read-only; it reads the runtime store, so it +//! works with or without a running host. A satellite asks its hub instead +//! (`workspace.show`), because its store holds only mirrored copies. + +use alera_core::runtime::RuntimeStore; +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; + +use crate::cli::{IdArgs, RuntimeDirArgs}; + +pub async fn run(runtime: RuntimeDirArgs, args: IdArgs, json_output: bool) -> i32 { + let result = async { + let store = RuntimeStore::open(&crate::runtime_dir(&runtime)).await?; + let id = args.id.trim(); + let forwarded = crate::hub_federation::read_from_hub( + &runtime, + &store, + "workspace.show", + json!({ "id": id }), + ) + .await?; + match forwarded { + Some(value) => Ok(value), + None => show(&store, id).await, + } + } + .await; + match result { + Ok(value) => { + let name = value["workspace"]["name"].as_str().unwrap_or_default(); + crate::print_value(&value, json_output, &format!("workspace {name}")); + 0 + } + Err(error) => crate::print_error(error), + } +} + +pub(crate) async fn show(store: &RuntimeStore, id: &str) -> Result { + let workspace = store + .find_workspace(id) + .await? + .ok_or_else(|| anyhow!("Workspace not found: {id}"))?; + let project = store.find_project(&workspace.project_id).await?; + let section = match workspace.section_id.as_deref() { + Some(section_id) => store + .list_workspace_sections() + .await? + .into_iter() + .find(|section| section.id == section_id) + .map(|section| json!({ "id": section.id, "name": section.name })), + None => None, + }; + let tags: Vec = store + .list_tags() + .await? + .into_iter() + .filter(|tag| workspace.tag_ids.contains(&tag.id)) + .map(|tag| json!({ "id": tag.id, "name": tag.name, "color": tag.color })) + .collect(); + let all = store.list_all_workspaces().await?; + let summary = |candidate: &alera_core::runtime::Workspace| { + json!({ + "id": candidate.id, + "name": candidate.name, + "branch": candidate.branch, + "isArchived": candidate.is_archived, + }) + }; + let parent = workspace + .parent_workspace_id + .as_deref() + .and_then(|parent_id| all.iter().find(|candidate| candidate.id == parent_id)) + .map(summary); + let children: Vec = all + .iter() + .filter(|candidate| candidate.parent_workspace_id.as_deref() == Some(id)) + .map(summary) + .collect(); + let slept_tab_ids = store + .list_slept_workspace_tabs() + .await? + .remove(id) + .unwrap_or_default(); + Ok(json!({ + "workspace": workspace, + "project": project.map(|project| json!({ "id": project.id, "name": project.name })), + "section": section, + "tags": tags, + "parent": parent, + "children": children, + "linkedIssue": store.find_linked_issue(id).await?, + "linkedPullRequest": store.find_linked_review(id).await?, + "pullRequestWatch": store.find_pull_request_watch(id).await?, + "asleep": !slept_tab_ids.is_empty(), + "sleptTabIds": slept_tab_ids, + "tabCount": store.list_workspace_tabs(id).await?.len(), + })) +} diff --git a/rust/alera-cli/src/workspace_tree.rs b/rust/alera-cli/src/workspace_tree.rs new file mode 100644 index 000000000..be46b15e4 --- /dev/null +++ b/rust/alera-cli/src/workspace_tree.rs @@ -0,0 +1,97 @@ +//! Workspace trees: a workspace and every workspace below it through parent +//! links, which is what the sidebar's Tree actions (Pin Workspace Tree, Set +//! Section Tree) apply to. + +use std::collections::{BTreeMap, HashSet, VecDeque}; +use std::path::Path; + +use alera_core::runtime::{RuntimeStore, Workspace}; +use anyhow::Result; +use serde_json::json; + +use crate::runtime_host_client::RuntimeHostRpcClient; + +/// `root` first, then its descendants breadth first. A stale relation cycle +/// cannot loop: each workspace is visited once. +pub(crate) fn tree_ids(workspaces: &[Workspace], root: &str) -> Vec { + let mut children: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for workspace in workspaces { + if let Some(parent) = workspace.parent_workspace_id.as_deref() { + children.entry(parent).or_default().push(&workspace.id); + } + } + let mut seen = HashSet::from([root.to_string()]); + let mut ordered = vec![root.to_string()]; + let mut pending = VecDeque::from([root.to_string()]); + while let Some(id) = pending.pop_front() { + for child in children.get(id.as_str()).into_iter().flatten() { + if seen.insert((*child).to_string()) { + ordered.push((*child).to_string()); + pending.push_back((*child).to_string()); + } + } + } + ordered +} + +/// Every workspace with its parent link, from the running host when there is +/// one so the answer matches what the apps show. +pub(crate) async fn all_workspaces(runtime_dir: &Path) -> Result> { + if let Some(mut client) = RuntimeHostRpcClient::connect(runtime_dir).await? { + return client.request("workspace.listAll", &json!({})).await; + } + RuntimeStore::open(runtime_dir) + .await? + .list_all_workspaces() + .await +} + +#[cfg(test)] +mod tests { + use alera_core::runtime::{WorkspaceKind, WorkspaceStatus}; + use chrono::Utc; + + use super::*; + + fn workspace(id: &str, parent: Option<&str>) -> Workspace { + Workspace { + id: id.into(), + instance_id: id.into(), + host_id: "local".into(), + project_id: "p".into(), + name: id.into(), + branch: None, + path: format!("/{id}"), + created_at: Utc::now(), + updated_at: Utc::now(), + kind: WorkspaceKind::Linked, + status: WorkspaceStatus::Active, + source_branch: None, + reuses_existing_branch: false, + is_pinned: false, + is_archived: false, + tag_ids: vec![], + tag_names: vec![], + section_id: None, + parent_workspace_id: parent.map(str::to_string), + child_count: 0, + } + } + + #[test] + fn a_tree_is_the_root_and_every_descendant_once() { + let workspaces = [ + workspace("root", Some("grandchild")), + workspace("child", Some("root")), + workspace("other", None), + workspace("grandchild", Some("child")), + workspace("sibling", Some("root")), + ]; + assert_eq!( + tree_ids(&workspaces, "root"), + ["root", "child", "sibling", "grandchild"] + ); + assert_eq!(tree_ids(&workspaces, "other"), ["other"]); + assert_eq!(tree_ids(&workspaces, "missing"), ["missing"]); + } +} diff --git a/rust/alera-cli/src/workspace_wake.rs b/rust/alera-cli/src/workspace_wake.rs new file mode 100644 index 000000000..40abcc296 --- /dev/null +++ b/rust/alera-cli/src/workspace_wake.rs @@ -0,0 +1,44 @@ +//! `alera workspace wake`: start again the terminals a sleep stopped, as +//! opening the workspace in the app does. The host attaches each slept tab +//! from its record (`workspace.wake`), so agent tabs resume their sessions. + +use anyhow::Result; +use serde_json::{json, Value}; + +use crate::cli::{IdArgs, RuntimeDirArgs}; +use crate::runtime_host_client::RuntimeHostRpcClient; +use crate::terminal_host::protocol::RUNTIME_HOST_WORKSPACE_WAKE_CAPABILITY; + +/// Starting a terminal can wait on the login shell and on agent hooks. +const WAKE_DEADLINE_MS: u64 = 45_000; + +pub async fn run(runtime: RuntimeDirArgs, args: IdArgs, json_output: bool) -> i32 { + match wake(&runtime, args.id.trim()).await { + Ok(value) => { + let count = value["woken"].as_array().map_or(0, Vec::len); + let message = if value["wasAsleep"] == true { + format!("workspace woke: {count} terminals started") + } else { + "workspace was not asleep".to_string() + }; + crate::print_value(&value, json_output, &message); + 0 + } + Err(error) => crate::print_error(error), + } +} + +async fn wake(runtime: &RuntimeDirArgs, id: &str) -> Result { + let mut client = RuntimeHostRpcClient::connect_or_start_with_required_capability( + &crate::runtime_dir(runtime), + RUNTIME_HOST_WORKSPACE_WAKE_CAPABILITY, + ) + .await?; + client + .request_value_with_deadline( + "workspace.wake", + &json!({ "workspaceId": id }), + WAKE_DEADLINE_MS, + ) + .await +} diff --git a/rust/alera-cli/tests/skill_version_matches_binary.rs b/rust/alera-cli/tests/skill_version_matches_binary.rs index 0e701a1e5..bcf1d86f1 100644 --- a/rust/alera-cli/tests/skill_version_matches_binary.rs +++ b/rust/alera-cli/tests/skill_version_matches_binary.rs @@ -8,45 +8,129 @@ use std::path::PathBuf; /// guide tells agents to consult it. All of that is built on the guide's own /// number being true. Bump one side and forget the other and the command /// reports a compatibility that does not hold, confidently. +/// Each skill folder and the constant in `protocol.rs` holding its version. +const SKILLS: &[(&str, &str)] = &[ + ("alera-cli", "CLI_SKILL_VERSION"), + ("alera-orchestration", "ORCHESTRATION_SKILL_VERSION"), + ("alera-automations", "AUTOMATIONS_SKILL_VERSION"), + ("alera-agent-profiles", "AGENT_PROFILES_SKILL_VERSION"), +]; + +/// `alera skill status` calls an installed skill current when its version +/// equals the runtime's, so a content change must come with a version bump. +/// Update a digest here only together with that bump. +const DIGESTS: &[(&str, i64, &str)] = &[ + ( + "alera-cli", + 1, + "39b86b4ca69b899f5134037d528898d19ee431493dac011c131eb6c875668ca9", + ), + ( + "alera-orchestration", + 3, + "20e5f07928eb4875e44778eba2a35ea50d76e5b1b6a7aaedeb15c1c67c3efe0f", + ), + ( + "alera-automations", + 1, + "757e9bfbfd48efa751bcb458f4d528b9781e4db03d2bd1f979e31a639614fff7", + ), + ( + "alera-agent-profiles", + 1, + "84ed0aebcdf7b00cba2132ee714ef0d3f405d94f9d3f63d6b6b4144c82472a23", + ), +]; + +#[test] +fn every_skill_guide_declares_the_version_the_binary_ships() { + for (folder, constant) in SKILLS { + let guide = repository_path(&["skills", folder, "SKILL.md"]); + let contents = std::fs::read_to_string(&guide) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", guide.display())); + + let declared = frontmatter_version(&contents).unwrap_or_else(|| { + panic!( + "{} has no `version:` in its frontmatter, so nothing pins it to the binary", + guide.display() + ) + }); + + assert_eq!( + declared, + binary_skill_version(constant), + "{} declares version {declared} but the binary ships {constant} {}. Bump both \ + together: `alera version --json` and `alera skill status` report the binary's \ + number, so a one-sided bump makes them lie.", + guide.display(), + binary_skill_version(constant), + ); + } +} + #[test] -fn the_orchestration_skill_guide_declares_the_version_the_binary_ships() { - let guide = repository_path(&["skills", "alera-orchestration", "SKILL.md"]); - let contents = std::fs::read_to_string(&guide) - .unwrap_or_else(|error| panic!("cannot read {}: {error}", guide.display())); +fn a_skill_whose_content_changed_bumps_its_version() { + for (folder, version, digest) in DIGESTS { + let declared = frontmatter_version( + &std::fs::read_to_string(repository_path(&["skills", folder, "SKILL.md"])).unwrap(), + ); + let actual = folder_digest(&repository_path(&["skills", folder])); + assert!( + declared == Some(*version) && actual == *digest, + "skills/{folder} changed. Bump its metadata.version and the matching constant in \ + protocol.rs, then record version {} and digest {actual} in DIGESTS.", + declared.unwrap_or_default(), + ); + } +} - let declared = frontmatter_version(&contents).unwrap_or_else(|| { - panic!( - "{} has no `version:` in its frontmatter, so nothing pins it to the binary", - guide.display() - ) - }); +/// SHA-256 over every file's relative path and content, with CRLF read as LF. +fn folder_digest(folder: &std::path::Path) -> String { + use sha2::{Digest, Sha256}; + let mut files = Vec::new(); + collect_files(folder, folder, &mut files); + files.sort(); + let mut hasher = Sha256::new(); + for relative in files { + let bytes = std::fs::read(folder.join(&relative)).unwrap(); + let text = String::from_utf8(bytes).unwrap().replace("\r\n", "\n"); + hasher.update(relative.as_bytes()); + hasher.update([0]); + hasher.update(text.as_bytes()); + hasher.update([0]); + } + hex::encode(hasher.finalize()) +} - assert_eq!( - declared, - alera_cli_orchestration_skill_version(), - "{} declares version {declared} but the binary ships \ - ORCHESTRATION_SKILL_VERSION {}. Bump both together: `alera version --json` \ - reports the binary's number, so a one-sided bump makes it lie.", - guide.display(), - alera_cli_orchestration_skill_version(), - ); +fn collect_files(root: &std::path::Path, folder: &std::path::Path, files: &mut Vec) { + for entry in std::fs::read_dir(folder).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + collect_files(root, &path, files); + } else { + let relative = path.strip_prefix(root).unwrap(); + let parts = relative + .iter() + .map(|part| part.to_string_lossy()) + .collect::>(); + files.push(parts.join("/")); + } + } } /// Read the constant from its source rather than linking the binary crate, /// which an integration test cannot import. -fn alera_cli_orchestration_skill_version() -> i64 { +fn binary_skill_version(constant: &str) -> i64 { let protocol = repository_path(&["rust", "alera-cli", "src", "terminal_host", "protocol.rs"]); let contents = std::fs::read_to_string(&protocol) .unwrap_or_else(|error| panic!("cannot read {}: {error}", protocol.display())); + let prefix = format!("pub const {constant}: i64 = "); let declaration = contents .lines() - .find_map(|line| { - line.trim() - .strip_prefix("pub const ORCHESTRATION_SKILL_VERSION: i64 = ") - }) + .find_map(|line| line.trim().strip_prefix(prefix.as_str())) .unwrap_or_else(|| { panic!( - "ORCHESTRATION_SKILL_VERSION is no longer declared as expected in {}", + "{constant} is no longer declared as expected in {}", protocol.display() ) }); @@ -54,7 +138,7 @@ fn alera_cli_orchestration_skill_version() -> i64 { .trim_end_matches(';') .trim() .parse() - .unwrap_or_else(|error| panic!("unreadable ORCHESTRATION_SKILL_VERSION: {error}")) + .unwrap_or_else(|error| panic!("unreadable {constant}: {error}")) } /// The first `version:` inside the leading `---` frontmatter block. diff --git a/rust/alera-core/src/runtime/inbox_listing_tests.rs b/rust/alera-core/src/runtime/inbox_listing_tests.rs index 804e9d438..3646887e0 100644 --- a/rust/alera-core/src/runtime/inbox_listing_tests.rs +++ b/rust/alera-core/src/runtime/inbox_listing_tests.rs @@ -239,3 +239,71 @@ async fn threads_page_by_latest_activity_and_filter_before_the_limit() { assert_eq!(rest.items.len(), 1); assert_eq!(rest.next_before, None); } + +#[tokio::test] +async fn listing_filters_by_the_origin_client() { + let (_dir, store) = store().await; + let mut chatgpt = question("ext:mcp", "agent"); + chatgpt.external_meta = serde_json::json!({ + "version": 1, + "origin": {"surface": "mcp", "transport": "remote", "clientId": "chatgpt", "clientName": "ChatGPT"}, + }); + let own = store.insert_inbox_question(chatgpt).await.unwrap(); + store + .insert_inbox_question(question("ext:mcp", "agent")) + .await + .unwrap(); + let filtered = store + .inbox_threads(InboxThreadFilter { + inbox: Some("ext:mcp".to_string()), + origin_client_id: Some("chatgpt".to_string()), + limit: 50, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(filtered.items.len(), 1); + assert_eq!(filtered.items[0].thread_id, own.id); + assert_eq!( + filtered.items[0].origin.as_ref().unwrap()["clientName"], + "ChatGPT" + ); + let other = store + .inbox_threads(InboxThreadFilter { + origin_client_id: Some("claude".to_string()), + limit: 50, + ..Default::default() + }) + .await + .unwrap(); + assert!(other.items.is_empty()); +} + +#[tokio::test] +async fn request_keys_find_the_same_clients_question() { + let (_dir, store) = store().await; + let mut keyed = question("ext:mcp", "agent"); + keyed.external_meta = serde_json::json!({ + "version": 1, + "requestKey": "request-0001", + "origin": {"surface": "mcp", "transport": "local", "clientId": "codex"}, + }); + let asked = store.insert_inbox_question(keyed).await.unwrap(); + let found = store + .inbox_question_by_request_key("ext:mcp", "request-0001", Some("codex")) + .await + .unwrap(); + assert_eq!(found.unwrap().id, asked.id); + let lookups = [ + ("ext:mcp", Some("other-client")), + ("ext:mcp", None), + ("ext:ci", Some("codex")), + ]; + for (inbox, client) in lookups { + let missing = store + .inbox_question_by_request_key(inbox, "request-0001", client) + .await + .unwrap(); + assert!(missing.is_none(), "{inbox} {client:?}"); + } +} diff --git a/rust/alera-core/src/runtime/inbox_models.rs b/rust/alera-core/src/runtime/inbox_models.rs index 0a77eb0c5..14bc23d9c 100644 --- a/rust/alera-core/src/runtime/inbox_models.rs +++ b/rust/alera-core/src/runtime/inbox_models.rs @@ -133,6 +133,9 @@ pub struct InboxThreadFilter { pub inbox: Option, pub workspace_id: Option, pub status: Option, + /// Only threads whose recorded `origin.clientId` equals this value, such + /// as the questions one MCP client asked in a shared inbox. + pub origin_client_id: Option, pub before_sequence: Option, pub limit: i64, } diff --git a/rust/alera-core/src/runtime/inbox_queries.rs b/rust/alera-core/src/runtime/inbox_queries.rs index a75f49291..cae857c7e 100644 --- a/rust/alera-core/src/runtime/inbox_queries.rs +++ b/rust/alera-core/src/runtime/inbox_queries.rs @@ -45,6 +45,9 @@ impl RuntimeStore { if filter.workspace_id.is_some() { sql.push_str(" AND r.workspace_id = ?"); } + if filter.origin_client_id.is_some() { + sql.push_str(" AND json_extract(r.external_meta, '$.origin.clientId') = ?"); + } sql.push_str(") WHERE last_sequence < ? ORDER BY last_sequence DESC LIMIT ?"); let mut items = Vec::new(); let mut before = filter.before_sequence.unwrap_or(i64::MAX); @@ -56,6 +59,9 @@ impl RuntimeStore { if let Some(workspace_id) = &filter.workspace_id { query = query.bind(workspace_id); } + if let Some(client_id) = &filter.origin_client_id { + query = query.bind(client_id); + } let rows = query .bind(before) .bind(INBOX_THREAD_SCAN_BATCH) @@ -90,6 +96,29 @@ impl RuntimeStore { } } + /// The question an inbox already asked with this retry key on behalf of + /// the same origin client, so a retried ask returns it instead of asking + /// twice. Keys live in `external_meta.requestKey`. + pub async fn inbox_question_by_request_key( + &self, + inbox: &str, + request_key: &str, + origin_client_id: Option<&str>, + ) -> Result> { + let row = sqlx::query(sqlx::AssertSqlSafe(format!( + "SELECT {MESSAGE_COLUMNS} FROM orchestrationMessages \ + WHERE from_handle = ? AND json_extract(external_meta, '$.requestKey') = ? \ + AND json_extract(external_meta, '$.origin.clientId') IS ? \ + ORDER BY sequence DESC LIMIT 1" + ))) + .bind(inbox) + .bind(request_key) + .bind(origin_client_id) + .fetch_optional(self.pool()) + .await?; + row.map(message_from_row).transpose() + } + /// Messages addressed to `inbox` after `after_sequence`, oldest first. pub async fn inbox_messages_after( &self, diff --git a/rust/alera-core/src/runtime/mod.rs b/rust/alera-core/src/runtime/mod.rs index c6f8be0c6..093a51b88 100644 --- a/rust/alera-core/src/runtime/mod.rs +++ b/rust/alera-core/src/runtime/mod.rs @@ -114,6 +114,7 @@ mod orchestration_task_recovery_store; mod orchestration_task_store; mod project_clone_job_store; mod project_clone_models; +mod prompt_workspace_operation_store; mod pull_request_watch_store; #[cfg(test)] mod pull_request_watch_store_tests; @@ -122,6 +123,7 @@ mod relocation_setup_descendant_store; mod relocation_setup_process_store; mod relocation_setup_recovery_store; mod relocation_setup_store; +mod runtime_event_store; mod terminal_lifecycle_store; mod workspace_record_write; mod workspace_retirement_store; @@ -325,7 +327,11 @@ pub use orchestration_run_snapshot::*; pub use orchestration_task_inspection::*; pub use orchestration_task_store::NewOrchestrationTask; pub use project_clone_models::*; +pub use prompt_workspace_operation_store::PromptWorkspaceOperationRecord; pub use pull_request_watch_store::{PullRequestWatch, PullRequestWatchDispatchMark}; +pub use runtime_event_store::{ + RuntimeEvent, RuntimeEventFilter, RuntimeEventPage, RUNTIME_EVENT_RETENTION_DAYS, +}; pub use runtime_file_security::*; pub use settings_models::*; pub use ssh_target_store::SshTargetBootstrapStateUpdate; diff --git a/rust/alera-core/src/runtime/prompt_workspace_operation_store.rs b/rust/alera-core/src/runtime/prompt_workspace_operation_store.rs new file mode 100644 index 000000000..1602568c0 --- /dev/null +++ b/rust/alera-core/src/runtime/prompt_workspace_operation_store.rs @@ -0,0 +1,246 @@ +//! New Workspace from Prompt operations started through the runtime. +//! +//! The runtime host owns the record's shape; the store keeps it as JSON next +//! to the columns it filters and deduplicates on. `requestId` makes a retried +//! start return the first operation instead of creating a second workspace. + +use anyhow::Result; +use chrono::Utc; +use serde_json::Value; +use sqlx::Row; + +use super::RuntimeStore; + +pub(super) const PROMPT_WORKSPACE_OPERATION_SCHEMA: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS promptWorkspaceOperations ( + id TEXT PRIMARY KEY, + requestId TEXT UNIQUE, + status TEXT NOT NULL, + dataJson TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + );", + "CREATE INDEX IF NOT EXISTS promptWorkspaceOperationsUpdatedAtIdx ON promptWorkspaceOperations(updatedAt DESC);", +]; + +/// Operations kept after they finish, newest first. +const RETAINED_FINISHED_OPERATIONS: i64 = 200; + +#[derive(Debug, Clone, PartialEq)] +pub struct PromptWorkspaceOperationRecord { + pub id: String, + pub request_id: Option, + pub status: String, + pub data: Value, + pub created_at: String, + pub updated_at: String, +} + +impl RuntimeStore { + /// Inserts the operation, or returns the one already started with the + /// same `request_id`. + pub async fn insert_prompt_workspace_operation( + &self, + id: &str, + request_id: Option<&str>, + status: &str, + data: &Value, + ) -> Result { + if let Some(request_id) = request_id { + if let Some(existing) = self + .find_prompt_workspace_operation_by_request(request_id) + .await? + { + return Ok(existing); + } + } + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO promptWorkspaceOperations (id, requestId, status, dataJson, createdAt, \ + updatedAt) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(requestId) DO NOTHING", + ) + .bind(id) + .bind(request_id) + .bind(status) + .bind(serde_json::to_string(data)?) + .bind(&now) + .bind(&now) + .execute(self.pool()) + .await?; + match request_id { + Some(request_id) => self + .find_prompt_workspace_operation_by_request(request_id) + .await? + .ok_or_else(|| anyhow::anyhow!("prompt workspace operation disappeared")), + None => self + .find_prompt_workspace_operation(id) + .await? + .ok_or_else(|| anyhow::anyhow!("prompt workspace operation disappeared")), + } + } + + pub async fn update_prompt_workspace_operation( + &self, + id: &str, + status: &str, + data: &Value, + ) -> Result> { + sqlx::query( + "UPDATE promptWorkspaceOperations SET status = ?, dataJson = ?, updatedAt = ? WHERE id = ?", + ) + .bind(status) + .bind(serde_json::to_string(data)?) + .bind(Utc::now().to_rfc3339()) + .bind(id) + .execute(self.pool()) + .await?; + self.find_prompt_workspace_operation(id).await + } + + pub async fn find_prompt_workspace_operation( + &self, + id: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT id, requestId, status, dataJson, createdAt, updatedAt \ + FROM promptWorkspaceOperations WHERE id = ?", + ) + .bind(id) + .fetch_optional(self.pool()) + .await?; + row.map(operation_from_row).transpose() + } + + pub async fn find_prompt_workspace_operation_by_request( + &self, + request_id: &str, + ) -> Result> { + let row = sqlx::query( + "SELECT id, requestId, status, dataJson, createdAt, updatedAt \ + FROM promptWorkspaceOperations WHERE requestId = ?", + ) + .bind(request_id) + .fetch_optional(self.pool()) + .await?; + row.map(operation_from_row).transpose() + } + + pub async fn list_prompt_workspace_operations( + &self, + limit: i64, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, requestId, status, dataJson, createdAt, updatedAt \ + FROM promptWorkspaceOperations ORDER BY updatedAt DESC LIMIT ?", + ) + .bind(limit.clamp(1, RETAINED_FINISHED_OPERATIONS)) + .fetch_all(self.pool()) + .await?; + rows.into_iter().map(operation_from_row).collect() + } + + /// Operations a runtime restart interrupted. + pub async fn list_running_prompt_workspace_operations( + &self, + ) -> Result> { + let rows = sqlx::query( + "SELECT id, requestId, status, dataJson, createdAt, updatedAt \ + FROM promptWorkspaceOperations WHERE status = 'running'", + ) + .fetch_all(self.pool()) + .await?; + rows.into_iter().map(operation_from_row).collect() + } + + /// Drops the oldest finished operations beyond the retained count. + pub async fn prune_prompt_workspace_operations(&self) -> Result { + let result = sqlx::query( + "DELETE FROM promptWorkspaceOperations WHERE status != 'running' AND id NOT IN \ + (SELECT id FROM promptWorkspaceOperations ORDER BY updatedAt DESC LIMIT ?)", + ) + .bind(RETAINED_FINISHED_OPERATIONS) + .execute(self.pool()) + .await?; + Ok(result.rows_affected()) + } +} + +fn operation_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let data: String = row.try_get("dataJson")?; + Ok(PromptWorkspaceOperationRecord { + id: row.try_get("id")?, + request_id: row.try_get("requestId")?, + status: row.try_get("status")?, + data: serde_json::from_str(&data)?, + created_at: row.try_get("createdAt")?, + updated_at: row.try_get("updatedAt")?, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::runtime::RuntimeStore; + + async fn store() -> (tempfile::TempDir, RuntimeStore) { + let directory = tempfile::tempdir().unwrap(); + let store = RuntimeStore::open(directory.path()).await.unwrap(); + (directory, store) + } + + #[tokio::test] + async fn a_repeated_request_id_returns_the_first_operation() { + let (_directory, store) = store().await; + let first = store + .insert_prompt_workspace_operation("op-1", Some("req-1"), "running", &json!({ "a": 1 })) + .await + .unwrap(); + let again = store + .insert_prompt_workspace_operation("op-2", Some("req-1"), "running", &json!({ "a": 2 })) + .await + .unwrap(); + assert_eq!(again.id, first.id); + assert_eq!(again.data, json!({ "a": 1 })); + assert!(store + .find_prompt_workspace_operation("op-2") + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn updates_list_and_running_filters() { + let (_directory, store) = store().await; + store + .insert_prompt_workspace_operation("op-1", None, "running", &json!({})) + .await + .unwrap(); + store + .insert_prompt_workspace_operation("op-2", None, "running", &json!({})) + .await + .unwrap(); + let updated = store + .update_prompt_workspace_operation("op-1", "completed", &json!({ "done": true })) + .await + .unwrap() + .unwrap(); + assert_eq!(updated.status, "completed"); + assert_eq!(updated.data["done"], true); + let running = store + .list_running_prompt_workspace_operations() + .await + .unwrap(); + assert_eq!(running.len(), 1); + assert_eq!(running[0].id, "op-2"); + assert_eq!( + store + .list_prompt_workspace_operations(10) + .await + .unwrap() + .len(), + 2 + ); + assert_eq!(store.prune_prompt_workspace_operations().await.unwrap(), 0); + } +} diff --git a/rust/alera-core/src/runtime/runtime_event_store.rs b/rust/alera-core/src/runtime/runtime_event_store.rs new file mode 100644 index 000000000..6ea33eaa1 --- /dev/null +++ b/rust/alera-core/src/runtime/runtime_event_store.rs @@ -0,0 +1,424 @@ +//! The runtime event journal: one row per domain event, in order. +//! +//! Clients read it with a cursor (`seq`), so a reader that reconnects or loses +//! a response reads the same events again instead of missing them. Events +//! carry identifiers and states only; details stay behind the tools that read +//! them. Rows older than [`RUNTIME_EVENT_RETENTION_DAYS`] are pruned. + +use anyhow::Result; +use chrono::{Duration, Utc}; +use serde::Serialize; +use serde_json::Value; +use sqlx::Row; + +use super::RuntimeStore; + +pub(super) const RUNTIME_EVENT_SCHEMA: &[&str] = &[ + "CREATE TABLE IF NOT EXISTS runtimeEvents ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + workspaceId TEXT, + projectId TEXT, + dataJson TEXT NOT NULL, + occurredAt TEXT NOT NULL, + forwardedAt TEXT + );", + "CREATE INDEX IF NOT EXISTS runtimeEventsKindIdx ON runtimeEvents(kind, seq);", + "CREATE INDEX IF NOT EXISTS runtimeEventsWorkspaceIdx ON runtimeEvents(workspaceId, seq);", +]; + +/// A version-4 UUID in SQL, for events the triggers below record. +const SQL_EVENT_ID: &str = "lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || \ + substr(hex(randomblob(2)), 2) || '-' || substr('89ab', 1 + (abs(random()) % 4), 1) || \ + substr(hex(randomblob(2)), 2) || '-' || hex(randomblob(6)))"; +const SQL_NOW: &str = "strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now')"; + +/// Task and external question states change in many places; triggers record +/// every transition without each writer having to remember it. They run +/// after every orchestration migration, which may rebuild those tables. +fn trigger_statements() -> Vec { + let task_event = format!( + "INSERT INTO runtimeEvents (id, kind, workspaceId, dataJson, occurredAt) VALUES \ + ({SQL_EVENT_ID}, 'orchestration.task.state', NEW.workspace_id, \ + json_object('taskId', NEW.id, 'runId', NEW.run_id, 'state', NEW.status), {SQL_NOW});" + ); + // `inbox_queries::question_status` precedence: cancelled, answered, + // expired, delivered, received, pending. + let question_status = "CASE WHEN NEW.state = 'obsolete' THEN 'cancelled' \ + WHEN EXISTS (SELECT 1 FROM orchestrationMessages r WHERE r.reply_to_id = NEW.id \ + AND r.from_handle = NEW.to_handle) THEN 'answered' \ + WHEN NEW.state = 'expired' AND NEW.delivered_at IS NULL THEN 'expired' \ + WHEN NEW.delivered_at IS NOT NULL THEN 'delivered' \ + WHEN NEW.read = 1 THEN 'received' ELSE 'pending' END"; + vec![ + format!( + "CREATE TRIGGER IF NOT EXISTS runtimeEventsTaskCreated AFTER INSERT ON orchestrationTasks \ + BEGIN {task_event} END;" + ), + format!( + "CREATE TRIGGER IF NOT EXISTS runtimeEventsTaskState AFTER UPDATE OF status ON orchestrationTasks \ + WHEN OLD.status IS NOT NEW.status BEGIN {task_event} END;" + ), + format!( + "CREATE TRIGGER IF NOT EXISTS runtimeEventsQuestionState AFTER UPDATE OF state, delivered_at, read \ + ON orchestrationMessages WHEN NEW.from_handle LIKE 'ext:%' \ + AND (OLD.state IS NOT NEW.state OR OLD.delivered_at IS NOT NEW.delivered_at OR OLD.read IS NOT NEW.read) \ + BEGIN INSERT INTO runtimeEvents (id, kind, workspaceId, dataJson, occurredAt) VALUES \ + ({SQL_EVENT_ID}, 'inbox.question.status', NEW.workspace_id, json_object('inbox', NEW.from_handle, \ + 'threadId', COALESCE(NEW.thread_id, NEW.id), 'questionId', NEW.id, 'status', {question_status}), \ + {SQL_NOW}); END;" + ), + // A reply answers its question without updating it, so the answer is + // recorded when the correlated reply is inserted. + format!( + "CREATE TRIGGER IF NOT EXISTS runtimeEventsQuestionAnswered AFTER INSERT ON orchestrationMessages \ + WHEN NEW.reply_to_id IS NOT NULL AND NEW.to_handle LIKE 'ext:%' BEGIN \ + INSERT INTO runtimeEvents (id, kind, workspaceId, dataJson, occurredAt) \ + SELECT {SQL_EVENT_ID}, 'inbox.question.status', q.workspace_id, json_object('inbox', q.from_handle, \ + 'threadId', COALESCE(q.thread_id, q.id), 'questionId', q.id, 'status', 'answered'), {SQL_NOW} \ + FROM orchestrationMessages q WHERE q.id = NEW.reply_to_id AND q.from_handle = NEW.to_handle \ + AND q.to_handle = NEW.from_handle AND q.state <> 'obsolete' \ + AND NOT EXISTS (SELECT 1 FROM orchestrationMessages r WHERE r.reply_to_id = q.id \ + AND r.from_handle = q.to_handle AND r.id <> NEW.id); END;" + ), + ] +} + +pub const RUNTIME_EVENT_RETENTION_DAYS: i64 = 7; +const MAX_PAGE: i64 = 500; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEvent { + pub seq: i64, + pub event_id: String, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + pub data: Value, + pub occurred_at: String, +} + +#[derive(Debug, Clone, Default)] +pub struct RuntimeEventFilter { + pub after: i64, + pub kinds: Vec, + pub workspace_id: Option, + pub limit: i64, +} + +/// A page of events and where the next read starts. `truncated` means the +/// cursor points before the oldest retained event, so some were pruned. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEventPage { + pub events: Vec, + pub cursor: i64, + pub truncated: bool, +} + +impl RuntimeStore { + pub(super) async fn install_runtime_event_triggers(&self) -> Result<()> { + // The statements are built only from constants above, never from input. + for statement in trigger_statements() { + sqlx::query(sqlx::AssertSqlSafe(statement)) + .execute(self.pool()) + .await?; + } + Ok(()) + } + + pub async fn append_runtime_event( + &self, + kind: &str, + workspace_id: Option<&str>, + project_id: Option<&str>, + data: &Value, + ) -> Result { + let result = sqlx::query( + "INSERT INTO runtimeEvents (id, kind, workspaceId, projectId, dataJson, occurredAt) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(kind) + .bind(workspace_id) + .bind(project_id) + .bind(serde_json::to_string(data)?) + .bind(Utc::now().to_rfc3339()) + .execute(self.pool()) + .await?; + Ok(result.last_insert_rowid()) + } + + pub async fn list_runtime_events( + &self, + filter: &RuntimeEventFilter, + ) -> Result { + let limit = if filter.limit <= 0 { + 100 + } else { + filter.limit.min(MAX_PAGE) + }; + let rows = sqlx::query( + "SELECT seq, id, kind, workspaceId, projectId, dataJson, occurredAt FROM runtimeEvents \ + WHERE seq > ? ORDER BY seq ASC LIMIT ?", + ) + .bind(filter.after) + // Filters run after the read so the cursor still advances past + // events a reader does not want. + .bind(MAX_PAGE) + .fetch_all(self.pool()) + .await?; + let mut cursor = filter.after; + let mut events = Vec::new(); + for row in rows { + let event = event_from_row(row)?; + cursor = event.seq; + let kind_matches = filter.kinds.is_empty() || filter.kinds.contains(&event.kind); + let workspace_matches = filter + .workspace_id + .as_deref() + .is_none_or(|workspace| event.workspace_id.as_deref() == Some(workspace)); + if kind_matches && workspace_matches { + events.push(event); + if events.len() as i64 >= limit { + break; + } + } + } + let oldest: Option = sqlx::query_scalar("SELECT MIN(seq) FROM runtimeEvents") + .fetch_one(self.pool()) + .await?; + let truncated = filter.after > 0 && oldest.is_some_and(|oldest| oldest > filter.after + 1); + Ok(RuntimeEventPage { + events, + cursor, + truncated, + }) + } + + /// Events not yet sent to the cloud, oldest first. + pub async fn list_unforwarded_runtime_events(&self, limit: i64) -> Result> { + let rows = sqlx::query( + "SELECT seq, id, kind, workspaceId, projectId, dataJson, occurredAt FROM runtimeEvents \ + WHERE forwardedAt IS NULL ORDER BY seq ASC LIMIT ?", + ) + .bind(limit.clamp(1, MAX_PAGE)) + .fetch_all(self.pool()) + .await?; + rows.into_iter().map(event_from_row).collect() + } + + pub async fn mark_runtime_events_forwarded(&self, up_to_seq: i64) -> Result<()> { + sqlx::query( + "UPDATE runtimeEvents SET forwardedAt = ? WHERE forwardedAt IS NULL AND seq <= ?", + ) + .bind(Utc::now().to_rfc3339()) + .bind(up_to_seq) + .execute(self.pool()) + .await?; + Ok(()) + } + + pub async fn prune_runtime_events(&self) -> Result { + let cutoff = Utc::now() - Duration::days(RUNTIME_EVENT_RETENTION_DAYS); + let result = sqlx::query("DELETE FROM runtimeEvents WHERE occurredAt < ?") + .bind(cutoff.to_rfc3339()) + .execute(self.pool()) + .await?; + Ok(result.rows_affected()) + } +} + +fn event_from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let data: String = row.try_get("dataJson")?; + Ok(RuntimeEvent { + seq: row.try_get("seq")?, + event_id: row.try_get("id")?, + kind: row.try_get("kind")?, + workspace_id: row.try_get("workspaceId")?, + project_id: row.try_get("projectId")?, + data: serde_json::from_str(&data)?, + occurred_at: row.try_get("occurredAt")?, + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::RuntimeEventFilter; + use crate::runtime::RuntimeStore; + + #[tokio::test] + async fn readers_page_by_cursor_and_filter_without_losing_their_place() { + let directory = tempfile::tempdir().unwrap(); + let store = RuntimeStore::open(directory.path()).await.unwrap(); + let first = store + .append_runtime_event( + "inbox.reply", + Some("w-1"), + None, + &json!({ "questionId": "q" }), + ) + .await + .unwrap(); + store + .append_runtime_event( + "agent.status", + Some("w-2"), + None, + &json!({ "state": "done" }), + ) + .await + .unwrap(); + let third = store + .append_runtime_event( + "inbox.reply", + Some("w-2"), + None, + &json!({ "questionId": "r" }), + ) + .await + .unwrap(); + let replies = store + .list_runtime_events(&RuntimeEventFilter { + kinds: vec!["inbox.reply".to_owned()], + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(replies.events.len(), 2); + assert_eq!(replies.cursor, third); + assert!(!replies.truncated); + let after_first = store + .list_runtime_events(&RuntimeEventFilter { + after: first, + workspace_id: Some("w-2".to_owned()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(after_first.events.len(), 2); + assert!(after_first + .events + .iter() + .all(|event| event.workspace_id.as_deref() == Some("w-2"))); + let nothing_new = store + .list_runtime_events(&RuntimeEventFilter { + after: third, + ..Default::default() + }) + .await + .unwrap(); + assert!(nothing_new.events.is_empty()); + assert_eq!(nothing_new.cursor, third); + } + + #[tokio::test] + async fn task_and_external_question_transitions_are_journaled_by_triggers() { + let directory = tempfile::tempdir().unwrap(); + let store = RuntimeStore::open(directory.path()).await.unwrap(); + sqlx::query( + "INSERT INTO orchestrationTasks (id, spec, workspace_id, coordinator_handle, run_id) \ + VALUES ('t-1', 'spec', 'w-1', 'c-1', 'r-1')", + ) + .execute(store.pool()) + .await + .unwrap(); + sqlx::query("UPDATE orchestrationTasks SET status = 'ready' WHERE id = 't-1'") + .execute(store.pool()) + .await + .unwrap(); + sqlx::query("UPDATE orchestrationTasks SET status = 'ready' WHERE id = 't-1'") + .execute(store.pool()) + .await + .unwrap(); + sqlx::query( + "INSERT INTO orchestrationMessages (id, from_handle, to_handle, subject) \ + VALUES ('q-1', 'ext:mcp', 'agent-1', 'Question')", + ) + .execute(store.pool()) + .await + .unwrap(); + sqlx::query( + "UPDATE orchestrationMessages SET state = 'delivered', delivered_at = datetime('now') \ + WHERE id = 'q-1'", + ) + .execute(store.pool()) + .await + .unwrap(); + let page = store + .list_runtime_events(&RuntimeEventFilter::default()) + .await + .unwrap(); + let states = page + .events + .iter() + .filter(|event| event.kind == "orchestration.task.state") + .map(|event| event.data["state"].as_str().unwrap().to_owned()) + .collect::>(); + assert_eq!(states, ["pending", "ready"]); + let question = page + .events + .iter() + .find(|event| event.kind == "inbox.question.status") + .unwrap(); + assert_eq!(question.data["status"], "delivered"); + assert_eq!(question.data["questionId"], "q-1"); + assert_eq!(question.event_id.len(), 36); + } + + #[tokio::test] + async fn the_first_answer_and_later_updates_journal_answered() { + let directory = tempfile::tempdir().unwrap(); + let store = RuntimeStore::open(directory.path()).await.unwrap(); + for statement in [ + "INSERT INTO orchestrationMessages (id, from_handle, to_handle, subject, workspace_id) \ + VALUES ('q-1', 'ext:mcp', 'agent-1', 'Question', 'w-1')", + "INSERT INTO orchestrationMessages (id, from_handle, to_handle, subject, reply_to_id, thread_id, workspace_id) \ + VALUES ('a-1', 'agent-1', 'ext:mcp', 'Re: Question', 'q-1', 'q-1', 'w-1')", + "INSERT INTO orchestrationMessages (id, from_handle, to_handle, subject, reply_to_id, thread_id) \ + VALUES ('a-2', 'agent-1', 'ext:mcp', 'Re: Question', 'q-1', 'q-1')", + "UPDATE orchestrationMessages SET read = 1 WHERE id = 'q-1'", + ] { + sqlx::query(statement).execute(store.pool()).await.unwrap(); + } + let page = store + .list_runtime_events(&RuntimeEventFilter::default()) + .await + .unwrap(); + let statuses = page + .events + .iter() + .filter(|event| event.kind == "inbox.question.status") + .map(|event| { + assert_eq!(event.workspace_id.as_deref(), Some("w-1")); + event.data["status"].as_str().unwrap().to_owned() + }) + .collect::>(); + assert_eq!(statuses, ["answered", "answered"]); + } + + #[tokio::test] + async fn forwarding_marks_events_up_to_a_sequence() { + let directory = tempfile::tempdir().unwrap(); + let store = RuntimeStore::open(directory.path()).await.unwrap(); + let first = store + .append_runtime_event("agent.status", None, None, &json!({})) + .await + .unwrap(); + store + .append_runtime_event("agent.status", None, None, &json!({})) + .await + .unwrap(); + store.mark_runtime_events_forwarded(first).await.unwrap(); + let pending = store.list_unforwarded_runtime_events(10).await.unwrap(); + assert_eq!(pending.len(), 1); + assert!(pending[0].seq > first); + assert_eq!(store.prune_runtime_events().await.unwrap(), 0); + } +} diff --git a/rust/alera-core/src/runtime/store.rs b/rust/alera-core/src/runtime/store.rs index 8e403dd3d..0f0d039a3 100644 --- a/rust/alera-core/src/runtime/store.rs +++ b/rust/alera-core/src/runtime/store.rs @@ -82,6 +82,7 @@ impl RuntimeStore { store.migrate_orchestration_board().await?; store.migrate_inbox().await?; store.migrate_conversations().await?; + store.install_runtime_event_triggers().await?; harden_sqlite_files(&path)?; Ok(store) } @@ -100,6 +101,13 @@ impl RuntimeStore { for statement in super::project_clone_job_store::PROJECT_CLONE_JOB_SCHEMA { sqlx::query(*statement).execute(&self.pool).await?; } + for statement in super::prompt_workspace_operation_store::PROMPT_WORKSPACE_OPERATION_SCHEMA + { + sqlx::query(*statement).execute(&self.pool).await?; + } + for statement in super::runtime_event_store::RUNTIME_EVENT_SCHEMA { + sqlx::query(*statement).execute(&self.pool).await?; + } self.migrate_legacy_orchestration_schema().await?; for statement in super::orchestration_message_store::ORCHESTRATION_SCHEMA { sqlx::query(*statement).execute(&self.pool).await?; diff --git a/skills/alera-agent-profiles/SKILL.md b/skills/alera-agent-profiles/SKILL.md index f5a9c827f..516507673 100644 --- a/skills/alera-agent-profiles/SKILL.md +++ b/skills/alera-agent-profiles/SKILL.md @@ -1,6 +1,8 @@ --- name: alera-agent-profiles description: Maintain Alera Agent Profiles or research and validate a launch catalog. +metadata: + version: 1 --- # Alera Agent Profiles diff --git a/skills/alera-cli/SKILL.md b/skills/alera-cli/SKILL.md index 102479c54..cb60ed72f 100644 --- a/skills/alera-cli/SKILL.md +++ b/skills/alera-cli/SKILL.md @@ -1,6 +1,8 @@ --- name: alera-cli description: Operate Alera workspaces and runtime resources through the alera CLI. +metadata: + version: 1 --- # Alera CLI @@ -17,6 +19,7 @@ Use the managed `alera` CLI for Alera resources. Inside Alera terminals, its shi - Asking a running agent a question from outside its terminal, or reading an inbox: read [inbox](references/inbox.md). - Agent dispatch, worker tasks, or coordinator lifecycle: use the `alera-orchestration` skill. - Signing the runtime in to an Alera account, naming it, or letting MCP clients drive it (MCP Control, `alera mcp serve`): read [mcp](references/mcp.md). +- Checking or updating the Alera skills installed for coding agents on this machine: `alera skill status` compares each one with this runtime, and `alera skill install [--skill ]` installs them at this runtime's own commit. Load only the workflow needed for the current request. diff --git a/skills/alera-cli/references/mcp.md b/skills/alera-cli/references/mcp.md index 674bf5e38..1151840eb 100644 --- a/skills/alera-cli/references/mcp.md +++ b/skills/alera-cli/references/mcp.md @@ -14,9 +14,18 @@ Use these commands when the user wants an MCP client (Claude, ChatGPT, Cursor, C ## MCP Control -- `alera mcp enable` lets MCP clients connected to the user's Alera account reach this runtime through the Alera cloud with full control. `alera mcp enable --read-only` allows only tools that read state. `alera mcp disable` turns it off. All three need a signed-in account for remote access. +- `alera mcp enable` lets MCP clients connected to the user's Alera account reach this runtime through the Alera cloud. `--access read|full|admin` picks the level (default `full`; `--read-only` is the same as `--access read`). `alera mcp disable` turns it off. All need a signed-in account for remote access. +- Levels: `read` lists and inspects; `full` also runs tools that change state, including deleting workspaces and projects, merging pull requests, and automations; `admin` also allows agent profile changes, runtime settings, webhooks, and internal maintenance. A remote client needs both the runtime level and its own grant: `mcp:admin` is never granted by default, so the user reconnects the client and ticks administrative tools on the consent page. - `alera mcp status` shows the level, the name, the cloud link state, and the endpoint to add to the MCP client (`https://api.alera.build/v1/mcp`). - `alera mcp apps` lists connected MCP clients for the whole account; `alera mcp revoke ` disconnects one immediately. -- `alera mcp tools` prints the tool catalog. `alera mcp serve [--read-only]` serves the same tools over stdio to a local MCP client without the cloud, for example `claude mcp add alera -- alera mcp serve`. +- `alera mcp tools` prints the tool catalog. `alera mcp serve [--access read|full|admin]` serves the same tools over stdio to a local MCP client without the cloud, for example `claude mcp add alera -- alera mcp serve`. MCP Control applies to remote clients only; a local server defaults to `full`, and administrative tools need `--access admin`. +- AI Assist pull request details can take minutes, longer than a client waits for one call. `generate_pull_request_details` (and `alera pr generate-details --operation-id --wait-seconds `) answers `status: running` with an `operationId` when the wait ends first; the runtime keeps generating, and calling again with that id as `clientRequestId` (or `--operation-id`), the same workspace and the same base branch waits again or returns the finished result, which is kept for 15 minutes. A failure is reported once; the next call with that id generates again. +- Remote clients also get Alera skills written for MCP (`list_skills` and `read_skill`). The Alera cloud serves them, so they need no runtime, and every tool description tells the model to read them first. A local `alera mcp serve` does not serve them. +- A local server also offers resources a client can subscribe to: `alera://events`, `alera://workspace-starts`, and `alera://inbox`. It sends `notifications/resources/updated` when they change. -Tool arguments and results pass through the Alera cloud in transit when a remote client calls a tool; they are not stored. Prefer `--read-only` when the user only needs status and listings. +Tool arguments and results pass through the Alera cloud in transit when a remote client calls a tool; they are not stored. Prefer `--access read` when the user only needs status and listings. + +## Events And Webhooks + +- `alera events list [--after ] [--kind ,...] [--workspace-id ]` reads the runtime event journal: inbox replies, question states, agent states, terminal exits, task and run changes, decision gates, automation runs, workspace starts and lifecycle, and pull request watch actions. Events carry ids and states only. Pass the returned `cursor` as `--after` to read only what is new; `alera events wait` blocks until a matching event arrives or the timeout ends. +- `alera webhook add --url https://... [--kind ,...]` sends this runtime's events to an HTTPS endpoint as signed POST requests (Standard Webhooks) and prints the signing secret once. `alera webhook list`, `alera webhook test --id `, and `alera webhook remove --id ` manage them. Webhooks need a signed-in account; the runtime forwards events to the cloud only while some webhook or MCP Events subscription wants them. diff --git a/skills/alera-cli/references/workspaces.md b/skills/alera-cli/references/workspaces.md index 05dee0626..b67aa6dc0 100644 --- a/skills/alera-cli/references/workspaces.md +++ b/skills/alera-cli/references/workspaces.md @@ -124,6 +124,15 @@ alera workspace --json start --profile "Codex Sol" --prompt "Add dark mode" alera workspace --json start --profile "Codex Sol" --prompt "Add dark mode" --project-id --source-branch main --branch feat/dark-mode --name "Dark Mode" --no-parent ``` +Start a workspace exactly like the app's New Workspace from Prompt form, as one runtime operation. Without `--project-id`, AI Assist recognizes the project from the prompt; an unclear prompt ends with status `needsInput` and a list of candidates instead of guessing. AI Assist also names the workspace and branch and picks a section, or none (Others). `--mode auto` (default) uses a new worktree for Git projects; the profile defaults to the runtime's default. `--request-id` makes a retry return the first operation instead of creating a second workspace: + +```bash +alera workspace --json prompt-start run --prompt "Fix the login screen" --wait 60 +alera workspace --json prompt-start run --prompt-stdin --project-id --mode project-checkout --section none --request-id +alera workspace --json prompt-start wait --id --timeout-seconds 60 +alera workspace --json prompt-start retry-launch --id +``` + Move the main worktree's current work into a new child workspace (hand off). From an Alera terminal this defaults to `ALERA_WORKSPACE_ID`: This command is the same from Bash, PowerShell, and CMD: diff --git a/test/fixtures/forges/azure_completed_pull_request.json b/test/fixtures/forges/azure_completed_pull_request.json new file mode 100644 index 000000000..cecf0882c --- /dev/null +++ b/test/fixtures/forges/azure_completed_pull_request.json @@ -0,0 +1,23 @@ +{ + "description": "az repos pr show for a completed pull request with conflicts reported", + "identity": { + "host": "dev.azure.com", + "owner": "myorg", + "repo": "myrepo", + "project": "myproject" + }, + "stdout": "{\"pullRequestId\": 9, \"title\": \"fix: done\", \"status\": \"completed\", \"creationDate\": \"2026-07-12T00:00:00Z\", \"isDraft\": false, \"sourceRefName\": \"refs/heads/fix\", \"targetRefName\": \"refs/heads/main\", \"mergeStatus\": \"conflicts\", \"createdBy\": {\"displayName\": \"Ana\"}, \"lastMergeSourceCommit\": {\"commitId\": \"f00\"}}", + "expected": { + "review": { + "number": 9, + "title": "fix: done", + "state": "merged", + "author": "Ana", + "baseBranch": "main", + "headBranch": "fix", + "headSha": "f00", + "mergeable": "conflicting", + "url": "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/9" + } + } +} diff --git a/test/fixtures/forges/azure_policies.json b/test/fixtures/forges/azure_policies.json new file mode 100644 index 000000000..86859cfa8 --- /dev/null +++ b/test/fixtures/forges/azure_policies.json @@ -0,0 +1,38 @@ +{ + "description": "az repos pr policy list --output json", + "identity": { + "host": "dev.azure.com", + "owner": "myorg", + "repo": "myrepo", + "project": "myproject" + }, + "stdout": "[{\"status\": \"approved\", \"configuration\": {\"type\": {\"displayName\": \"Build\"}}, \"context\": {\"buildId\": 991}}, {\"status\": \"rejected\", \"configuration\": {\"type\": {\"displayName\": \"Required reviewers\"}}}, {\"status\": \"running\", \"configuration\": {\"type\": {\"displayName\": \"Status\"}}}, {\"status\": \"notApplicable\", \"configuration\": {}}]", + "expected": { + "checks": [ + { + "name": "Build", + "conclusion": "success", + "bucket": "pass", + "url": "https://dev.azure.com/myorg/myproject/_build/results?buildId=991" + }, + { + "name": "Required reviewers", + "conclusion": "failure", + "bucket": "fail", + "url": null + }, + { + "name": "Status", + "conclusion": "pending", + "bucket": "pending", + "url": null + }, + { + "name": "Policy", + "conclusion": "skipped", + "bucket": "skipping", + "url": null + } + ] + } +} diff --git a/test/fixtures/forges/azure_pull_requests.json b/test/fixtures/forges/azure_pull_requests.json new file mode 100644 index 000000000..c25883d60 --- /dev/null +++ b/test/fixtures/forges/azure_pull_requests.json @@ -0,0 +1,23 @@ +{ + "description": "az repos pr list --status active --output json; the newest pull request wins", + "identity": { + "host": "dev.azure.com", + "owner": "myorg", + "repo": "myrepo", + "project": "myproject" + }, + "stdout": "[{\"pullRequestId\": 42, \"title\": \"feat: older\", \"status\": \"active\", \"creationDate\": \"2026-07-10T00:00:00Z\", \"isDraft\": false, \"sourceRefName\": \"refs/heads/feature\", \"targetRefName\": \"refs/heads/main\", \"mergeStatus\": \"succeeded\", \"createdBy\": {\"displayName\": \"Ley\"}, \"lastMergeSourceCommit\": {\"commitId\": \"abc\"}}, {\"pullRequestId\": 43, \"title\": \"feat: newest\", \"status\": \"active\", \"creationDate\": \"2026-07-11T00:00:00Z\", \"isDraft\": false, \"sourceRefName\": \"refs/heads/feature\", \"targetRefName\": \"refs/heads/main\", \"mergeStatus\": \"succeeded\", \"createdBy\": {\"displayName\": \"Ley\"}, \"lastMergeSourceCommit\": {\"commitId\": \"def\"}, \"lastMergeTargetCommit\": {\"commitId\": \"base-def\"}}]", + "expected": { + "review": { + "number": 43, + "title": "feat: newest", + "state": "open", + "author": "Ley", + "baseBranch": "main", + "headBranch": "feature", + "headSha": "def", + "mergeable": "mergeable", + "url": "https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/43" + } + } +} diff --git a/test/fixtures/forges/azure_threads.json b/test/fixtures/forges/azure_threads.json new file mode 100644 index 000000000..ac573febb --- /dev/null +++ b/test/fixtures/forges/azure_threads.json @@ -0,0 +1,44 @@ +{ + "description": "az devops invoke --resource pullRequestThreads --http-method GET", + "identity": { + "host": "dev.azure.com", + "owner": "myorg", + "repo": "myrepo", + "project": "myproject" + }, + "stdout": "{\"value\": [{\"id\": 11, \"status\": \"active\", \"threadContext\": {\"filePath\": \"/lib/a.dart\", \"rightFileStart\": {\"line\": 17}}, \"comments\": [{\"id\": 1, \"content\": \"Change this\", \"publishedDate\": \"2026-07-20T11:00:00Z\", \"author\": {\"displayName\": \"Bob\", \"uniqueName\": \"bob@example.com\"}, \"commentType\": \"text\"}, {\"id\": 2, \"content\": \"Done\", \"publishedDate\": \"2026-07-20T11:30:00Z\", \"author\": {\"uniqueName\": \"ley@example.com\"}, \"commentType\": 1}]}, {\"id\": 12, \"status\": \"fixed\", \"comments\": [{\"id\": 1, \"content\": \"General note\", \"publishedDate\": \"2026-07-20T10:00:00Z\", \"author\": {\"displayName\": \"Carol\"}, \"commentType\": 1}]}, {\"id\": 13, \"comments\": [{\"id\": 1, \"content\": \"Ley updated the pull request\", \"publishedDate\": \"2026-07-20T09:00:00Z\", \"commentType\": \"system\"}]}, {\"id\": 14, \"isDeleted\": true, \"comments\": [{\"id\": 1, \"content\": \"gone\", \"publishedDate\": \"2026-07-20T08:00:00Z\"}]}]}", + "expected": { + "comments": [ + { + "id": 1, + "author": "Carol", + "body": "General note", + "kind": "conversation", + "path": null, + "line": null, + "resolved": true, + "threadId": "12" + }, + { + "id": 1, + "author": "Bob", + "body": "Change this", + "kind": "review", + "path": "/lib/a.dart", + "line": 17, + "resolved": false, + "threadId": "11" + }, + { + "id": 2, + "author": "ley@example.com", + "body": "Done", + "kind": "review", + "path": "/lib/a.dart", + "line": 17, + "resolved": false, + "threadId": "11" + } + ] + } +} diff --git a/test/fixtures/forges/gitlab_discussions.json b/test/fixtures/forges/gitlab_discussions.json new file mode 100644 index 000000000..e3b95f1bd --- /dev/null +++ b/test/fixtures/forges/gitlab_discussions.json @@ -0,0 +1,38 @@ +{ + "description": "glab api merge_requests/:iid/discussions --paginate --output ndjson", + "stdout": "{\"id\": \"d1\", \"notes\": [{\"id\": 1, \"system\": false, \"resolvable\": true, \"resolved\": true, \"body\": \"Inline\", \"created_at\": \"2026-07-20T11:00:00Z\", \"author\": {\"username\": \"bob\"}, \"position\": {\"new_path\": \"lib/a.dart\", \"new_line\": 17}}]}\n{\"id\": \"d2\", \"notes\": [{\"id\": 2, \"system\": false, \"body\": \"General\", \"created_at\": \"2026-07-20T12:00:00Z\", \"author\": {\"name\": \"Carol\"}}]}\n{\"id\": \"general\", \"notes\": [{\"id\": 3, \"body\": \"Fix this\", \"resolvable\": true, \"resolved\": false, \"created_at\": \"2026-07-20T13:00:00Z\", \"author\": {\"username\": \"dave\"}}, {\"id\": 4, \"system\": true, \"body\": \"changed the description\", \"created_at\": \"2026-07-20T14:00:00Z\"}]}\n", + "expected": { + "comments": [ + { + "id": 1, + "author": "bob", + "body": "Inline", + "kind": "review", + "path": "lib/a.dart", + "line": 17, + "resolved": true, + "threadId": "d1" + }, + { + "id": 2, + "author": "Carol", + "body": "General", + "kind": "conversation", + "path": null, + "line": null, + "resolved": false, + "threadId": null + }, + { + "id": 3, + "author": "dave", + "body": "Fix this", + "kind": "conversation", + "path": null, + "line": null, + "resolved": false, + "threadId": "general" + } + ] + } +} diff --git a/test/fixtures/forges/gitlab_draft_conflicting_merge_request.json b/test/fixtures/forges/gitlab_draft_conflicting_merge_request.json new file mode 100644 index 000000000..9dc78b137 --- /dev/null +++ b/test/fixtures/forges/gitlab_draft_conflicting_merge_request.json @@ -0,0 +1,25 @@ +{ + "description": "glab api merge request that is a draft with conflicts and a manual pipeline", + "stdout": "{\"iid\": 7, \"title\": \"Draft: wip\", \"state\": \"opened\", \"work_in_progress\": true, \"web_url\": \"https://gitlab.com/g/p/-/merge_requests/7\", \"created_at\": \"2026-07-21T08:00:00Z\", \"author\": {\"name\": \"Carol\"}, \"target_branch\": \"develop\", \"source_branch\": \"wip\", \"sha\": \"def\", \"detailed_merge_status\": \"broken_status\", \"has_conflicts\": true, \"head_pipeline\": {\"id\": 5, \"status\": \"manual\"}}", + "expected": { + "review": { + "number": 7, + "title": "Draft: wip", + "state": "draft", + "author": "Carol", + "baseBranch": "develop", + "headBranch": "wip", + "headSha": "def", + "mergeable": "conflicting", + "url": "https://gitlab.com/g/p/-/merge_requests/7" + }, + "checks": [ + { + "name": "Pipeline #5", + "conclusion": "actionRequired", + "bucket": "fail", + "url": null + } + ] + } +} diff --git a/test/fixtures/forges/gitlab_merge_request.json b/test/fixtures/forges/gitlab_merge_request.json new file mode 100644 index 000000000..eac193459 --- /dev/null +++ b/test/fixtures/forges/gitlab_merge_request.json @@ -0,0 +1,25 @@ +{ + "description": "glab api projects/:id/merge_requests/:iid for an open, mergeable MR", + "stdout": "{\"iid\": 42, \"title\": \"feat: gitlab\", \"state\": \"opened\", \"draft\": false, \"web_url\": \"https://gitlab.acme.test:8443/platform/mobile/alera/-/merge_requests/42\", \"created_at\": \"2026-07-20T12:00:00Z\", \"author\": {\"username\": \"alice\"}, \"target_branch\": \"main\", \"source_branch\": \"feature\", \"sha\": \"abc\", \"diff_refs\": {\"base_sha\": \"base-abc\"}, \"detailed_merge_status\": \"mergeable\", \"has_conflicts\": false, \"head_pipeline\": {\"id\": 99, \"status\": \"failed\", \"ref\": \"refs/merge-requests/42/head\", \"sha\": \"abc\", \"web_url\": \"https://gitlab.acme.test:8443/pipelines/99\"}}", + "expected": { + "review": { + "number": 42, + "title": "feat: gitlab", + "state": "open", + "author": "alice", + "baseBranch": "main", + "headBranch": "feature", + "headSha": "abc", + "mergeable": "mergeable", + "url": "https://gitlab.acme.test:8443/platform/mobile/alera/-/merge_requests/42" + }, + "checks": [ + { + "name": "Pipeline #99", + "conclusion": "failure", + "bucket": "fail", + "url": "https://gitlab.acme.test:8443/pipelines/99" + } + ] + } +} diff --git a/test/unit/editor_buffer_guards_test.dart b/test/unit/editor_buffer_guards_test.dart index 921a0412a..a3ced9f02 100644 --- a/test/unit/editor_buffer_guards_test.dart +++ b/test/unit/editor_buffer_guards_test.dart @@ -111,6 +111,70 @@ void main() { registry.releaseBufferGuard('relocate'); registry.dispose(); }); + + test( + 'a save resolution saves dirty editors in scope before locking', + () async { + final registry = EditorSessionRegistry(); + final target = document(registry, 'target', '/repo') + ..updateCurrentText('edited'); + final outside = document(registry, 'outside', '/other') + ..updateCurrentText('unrelated'); + final handler = EditorBufferGuardRuntimeHandler( + registry, + files: () => SavingFileService(), + ); + final blockers = await handler.resolveAndLock( + guardId: 'remove', + tabIds: const {}, + workspacePaths: const {'/repo'}, + discard: false, + ); + expect(blockers, isEmpty); + expect(target.isDirty, isFalse); + expect(target.loadedText, 'edited'); + expect(registry.isBufferGuarded('target'), isTrue); + expect(outside.isDirty, isTrue); + registry.releaseBufferGuard('remove'); + registry.dispose(); + }, + ); + + test('a discard resolution drops edits and a failed save blocks', () async { + final registry = EditorSessionRegistry(); + final discarded = document(registry, 'discarded', '/repo') + ..updateCurrentText('throw away'); + final handler = EditorBufferGuardRuntimeHandler(registry); + expect( + await handler.resolveAndLock( + guardId: 'discard', + tabIds: const {'discarded'}, + workspacePaths: const {}, + discard: true, + ), + isEmpty, + ); + expect(discarded.currentText, 'saved'); + registry.releaseBufferGuard('discard'); + + discarded.updateCurrentText('cannot save'); + final failing = EditorBufferGuardRuntimeHandler( + registry, + files: () => FailingFileService(), + ); + final blockers = await failing.resolveAndLock( + guardId: 'save', + tabIds: const {'discarded'}, + workspacePaths: const {}, + discard: false, + ); + expect(blockers, hasLength(1)); + expect(blockers.single['tabId'], 'discarded'); + expect(blockers.single['reason'], contains('Could not save')); + expect(discarded.isDirty, isTrue); + registry.releaseBufferGuard('save'); + registry.dispose(); + }); } EditorDocumentSession document( @@ -145,3 +209,31 @@ class DelayedFileService extends WorkspaceFileService { required int tabSize, }) => completion.future; } + +class SavingFileService extends WorkspaceFileService { + @override + Future writeEditorTextFile({ + required String workspacePath, + required String relativePath, + required String currentDisplayContent, + required String? originalRawContent, + required String? originalDisplayContent, + required String? expectedContentToken, + required bool overwriteIfChanged, + required int tabSize, + }) async => file(currentDisplayContent); +} + +class FailingFileService extends WorkspaceFileService { + @override + Future writeEditorTextFile({ + required String workspacePath, + required String relativePath, + required String currentDisplayContent, + required String? originalRawContent, + required String? originalDisplayContent, + required String? expectedContentToken, + required bool overwriteIfChanged, + required int tabSize, + }) async => throw StateError('disk full'); +} diff --git a/test/unit/forge_shared_fixtures_test.dart b/test/unit/forge_shared_fixtures_test.dart new file mode 100644 index 000000000..ac3adfe2c --- /dev/null +++ b/test/unit/forge_shared_fixtures_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:alera/src/features/pull_requests/domain/hosted_review.dart'; +import 'package:alera/src/features/pull_requests/domain/review_check.dart'; +import 'package:alera/src/features/pull_requests/domain/review_comment.dart'; +import 'package:alera/src/features/pull_requests/infra/azure_devops_forge_provider.dart'; +import 'package:alera/src/features/pull_requests/infra/gitlab_forge_provider.dart'; +import 'package:alera/src/shared/git_hosting/domain/git_remote_identity.dart'; +import 'package:alera/src/shared/infra/process/process_runner.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_recording_process_runner.dart'; + +/// The recorded `glab` and `az` output in `test/fixtures/forges/`, which the +/// runtime's Rust providers read too (`pull_request_forges/fixture_tests.rs`). +/// Both ports must map it to the same neutral expectation. +Map _fixture(String name) => + jsonDecode(File('test/fixtures/forges/$name').readAsStringSync()) + as Map; + +FakeRecordingProcessRunner _runner(Map fixture) => + FakeRecordingProcessRunner([ + ProcessRunOutput( + exitCode: 0, + stdout: fixture['stdout']! as String, + stderr: '', + ), + ]); + +const _gitlab = GitRemoteIdentity( + provider: .gitlab, + host: 'gitlab.acme.test:8443', + owner: 'platform/mobile', + repo: 'alera', +); + +GitRemoteIdentity _azure(Map fixture) { + final identity = fixture['identity']! as Map; + return GitRemoteIdentity( + provider: .azureDevops, + host: identity['host']! as String, + owner: identity['owner']! as String, + repo: identity['repo']! as String, + project: identity['project'] as String?, + ); +} + +Map _expected(Map fixture) => + fixture['expected']! as Map; + +void _expectReview(HostedReview? review, Object? expected) { + final values = expected! as Map; + expect(review, isNotNull); + expect(review!.number, values['number']); + expect(review.title, values['title']); + expect(review.state.name, values['state']); + expect(review.author, values['author']); + expect(review.baseBranch, values['baseBranch']); + expect(review.headBranch, values['headBranch']); + expect(review.headSha, values['headSha']); + expect(review.mergeable.name, values['mergeable']); + expect(review.url, values['url']); +} + +void _expectChecks( + List checks, + Object? expected, { + bool compareUrl = true, +}) { + final values = (expected! as List).cast>(); + expect(checks, hasLength(values.length)); + for (final (index, check) in checks.indexed) { + expect(check.name, values[index]['name']); + expect(check.conclusion.name, values[index]['conclusion']); + if (compareUrl) { + expect(check.url, values[index]['url']); + } + } +} + +void _expectComments(List comments, Object? expected) { + final values = (expected! as List).cast>(); + expect(comments, hasLength(values.length)); + for (final (index, comment) in comments.indexed) { + final value = values[index]; + expect(comment.author, value['author']); + expect(comment.body, value['body']); + expect(comment.kind.name, value['kind']); + expect(comment.path, value['path']); + expect(comment.line, value['line']); + expect(comment.resolved, value['resolved']); + expect(comment.threadId, value['threadId']); + } +} + +void main() { + group('shared GitLab fixtures', () { + for (final name in [ + 'gitlab_merge_request.json', + 'gitlab_draft_conflicting_merge_request.json', + ]) { + test('$name maps the merge request and its pipeline', () async { + final fixture = _fixture(name); + final expected = _expected(fixture); + final number = + (expected['review']! as Map)['number']! as int; + _expectReview( + await GitLabForgeProvider(_runner(fixture)).getReviewByNumber( + identity: _gitlab, + repoPath: '/repo', + number: number, + ), + expected['review'], + ); + _expectChecks( + await GitLabForgeProvider(_runner(fixture)) + .getChecks(identity: _gitlab, repoPath: '/repo', number: number), + expected['checks'], + ); + }); + } + + test('gitlab_discussions.json maps notes and threads', () async { + final fixture = _fixture('gitlab_discussions.json'); + _expectComments( + await GitLabForgeProvider( + _runner(fixture), + ).getReviewComments(identity: _gitlab, repoPath: '/repo', number: 42), + _expected(fixture)['comments'], + ); + }); + }); + + group('shared Azure DevOps fixtures', () { + test('azure_pull_requests.json picks and maps the newest', () async { + final fixture = _fixture('azure_pull_requests.json'); + _expectReview( + await AzureDevOpsForgeProvider(_runner(fixture)).getReviewForBranch( + identity: _azure(fixture), + repoPath: '/repo', + branch: 'feature', + ), + _expected(fixture)['review'], + ); + }); + + test('azure_completed_pull_request.json maps a merged review', () async { + final fixture = _fixture('azure_completed_pull_request.json'); + _expectReview( + await AzureDevOpsForgeProvider(_runner(fixture)).getReviewByNumber( + identity: _azure(fixture), + repoPath: '/repo', + number: 9, + ), + _expected(fixture)['review'], + ); + }); + + test('azure_policies.json maps policy evaluations', () async { + final fixture = _fixture('azure_policies.json'); + // The desktop keeps the build link in the check details, not the check. + _expectChecks( + await AzureDevOpsForgeProvider( + _runner(fixture), + ).getChecks(identity: _azure(fixture), repoPath: '/repo', number: 42), + _expected(fixture)['checks'], + compareUrl: false, + ); + }); + + test('azure_threads.json maps live thread comments', () async { + final fixture = _fixture('azure_threads.json'); + _expectComments( + await AzureDevOpsForgeProvider(_runner(fixture)).getReviewComments( + identity: _azure(fixture), + repoPath: '/repo', + number: 42, + ), + _expected(fixture)['comments'], + ); + }); + }); +} diff --git a/test/unit/inbox_domain_test.dart b/test/unit/inbox_domain_test.dart index 0e5f73f1e..95fa95544 100644 --- a/test/unit/inbox_domain_test.dart +++ b/test/unit/inbox_domain_test.dart @@ -18,6 +18,15 @@ void main() { expect(const InboxOrigin(surface: 'desktop').label, 'Alera desktop'); expect(const InboxOrigin(surface: 'mobile').label, 'Alera mobile'); expect(const InboxOrigin(surface: 'cli').label, 'CLI'); + expect( + InboxOrigin.fromJson(const { + 'surface': 'mcp', + 'clientId': 'chatgpt', + 'clientName': 'ChatGPT', + }).label, + 'ChatGPT (MCP)', + ); + expect(const InboxOrigin(surface: 'mcp').label, 'MCP client'); expect(InboxQuestionStatus.delivered.open, isTrue); expect(InboxQuestionStatus.answered.open, isFalse); }); diff --git a/test/unit/prompt_workspace_service_test.dart b/test/unit/prompt_workspace_service_test.dart new file mode 100644 index 000000000..6808c6720 --- /dev/null +++ b/test/unit/prompt_workspace_service_test.dart @@ -0,0 +1,445 @@ +import 'dart:async'; + +import 'package:alera/src/features/projects/domain/project.dart'; +import 'package:alera/src/features/workbench/application/prompt_workspace_service_run.dart'; +import 'package:alera/src/features/workbench/domain/background_setup_job.dart'; +import 'package:alera/src/features/workbench/domain/workspace.dart'; +import 'package:alera/src/features/workbench/domain/workspace_creation_result.dart'; +import 'package:alera/src/features/workbench/infra/prompt_workspace_service_client.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_client_models.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeRuntime implements RuntimeHostClient, RuntimeHostCapabilityClient { + _FakeRuntime({this.capabilities = const {}}); + + final Set capabilities; + final List<(String, Map)> requests = + <(String, Map)>[]; + final List> reads = >[]; + Map started = _operation('running', 'resolvingProject'); + final StreamController events = + StreamController.broadcast(); + + @override + Stream get runtimeEvents => events.stream; + + @override + Future supportsRuntimeCapability(String capability) async => + capabilities.contains(capability); + + @override + Future runtimeRequest( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async { + requests.add((type, payload)); + return switch (type) { + 'workspace.promptStart.start' || + 'workspace.promptStart.retryLaunch' => started, + 'workspace.promptStart.get' => reads.removeAt(0), + _ => throw StateError('Unknown terminal host request: $type'), + }; + } + + void changed() => events.add( + const RuntimeHostEvent(aleraPromptWorkspaceOperationsChangedEvent, { + 'id': 'op-1', + }), + ); + + List get types => [ + for (final request in requests) request.$1, + ]; +} + +class _PlainRuntime implements RuntimeHostClient { + @override + Stream get runtimeEvents => const Stream.empty(); + + @override + Future runtimeRequest( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async => throw StateError('unexpected $type'); +} + +Map _workspace() => { + 'id': 'ws-1', + 'projectId': 'project-1', + 'name': 'Prompt Workspace', + 'branch': 'feat/prompt-workspace', + 'path': '/repo/alera-ws', + 'createdAt': '2026-10-10T00:00:00Z', + 'updatedAt': '2026-10-10T00:00:00Z', + 'kind': 'linked', + 'status': 'active', +}; + +Map _operation( + String status, + String phase, { + bool workspace = false, + Map? agent, + Map? error, + List warnings = const [], +}) => { + 'id': 'op-1', + 'status': status, + 'phase': phase, + 'workspace': ?(workspace ? _workspace() : null), + 'agent': ?agent, + 'setup': ?(workspace ? const {'tabId': 'setup-tab'} : null), + 'warnings': warnings, + 'error': ?error, +}; + +void main() { + final now = DateTime.utc(2026, 10, 10); + final project = Project( + id: 'project-1', + name: 'Alera', + repoPath: '/repo/alera', + createdAt: now, + updatedAt: now, + ); + + PromptWorkspaceCreateRequest request({ + bool useProjectCheckout = false, + bool autoAssignSection = true, + String prompt = ' Build the feature ', + }) => PromptWorkspaceCreateRequest( + useProjectCheckout: useProjectCheckout, + project: project, + prompt: prompt, + profileId: 'profile-1', + sourceBranch: useProjectCheckout ? '' : 'main', + parentWorkspaceId: 'parent-1', + issueUrl: 'https://github.com/o/r/issues/1', + autoAssignSection: autoAssignSection, + ); + + PromptWorkspaceServiceClient service( + RuntimeHostClient runtime, { + Duration pollInterval = const Duration(hours: 1), + }) => PromptWorkspaceServiceClient( + runtime, + pollInterval: pollInterval, + eventSafetyInterval: const Duration(hours: 1), + ); + + test('the service is used only when the runtime advertises it', () async { + expect( + runtimeHostEventNames, + contains(aleraPromptWorkspaceOperationsChangedEvent), + ); + expect(await service(_FakeRuntime()).isSupported(), isFalse); + expect(await service(_PlainRuntime()).isSupported(), isFalse); + expect( + await service( + _FakeRuntime( + capabilities: {aleraRuntimeHostPromptWorkspaceServiceCapability}, + ), + ).isSupported(), + isTrue, + ); + final created = WorkspaceCreationResult( + workspace: Workspace.fromJson(_workspace()), + setupReport: WorktreeSetupReport.empty, + ); + expect(canRunPromptWorkspaceOnService(request()), isTrue); + expect( + canRunPromptWorkspaceOnService(request().withCreated(created)), + isFalse, + reason: 'a client-created workspace keeps its client-side retry', + ); + expect( + canRunPromptWorkspaceOnService( + request().withCreated(created, serviceOperationId: 'op-1'), + ), + isTrue, + ); + }); + + test('the start payload states the form choices explicitly', () { + final worktree = promptWorkspaceStartPayload(request(), requestId: 'req-1'); + expect(worktree, { + 'prompt': 'Build the feature', + 'projectId': 'project-1', + 'profile': 'profile-1', + 'mode': 'worktree', + 'sourceBranch': 'main', + 'parentWorkspaceId': 'parent-1', + 'issueUrl': 'https://github.com/o/r/issues/1', + 'section': 'auto', + 'requestId': 'req-1', + }); + final checkout = promptWorkspaceStartPayload( + request(useProjectCheckout: true, autoAssignSection: false), + requestId: 'req-2', + ); + expect(checkout['mode'], 'projectCheckout'); + expect(checkout.containsKey('sourceBranch'), isFalse); + expect(checkout['section'], 'none'); + }); + + test('request ids repeat for one submission and change per attempt', () { + String id({int attempt = 0, String prompt = 'Build the feature'}) => + promptWorkspaceServiceRequestId( + jobId: 'job-1', + attempt: attempt, + request: request(prompt: prompt), + ); + expect(id(), id()); + expect(id(attempt: 1), isNot(id())); + expect(id(prompt: 'Something else'), isNot(id())); + }); + + test('follows the operation through its change events', () async { + final runtime = _FakeRuntime() + ..reads.addAll(>[ + _operation('running', 'creatingWorkspace', workspace: true), + _operation( + 'completed', + 'done', + workspace: true, + agent: const {'tabId': 'agent-tab'}, + ), + ]); + final phases = []; + final outcome = runPromptWorkspaceService( + service: service(runtime), + request: request(), + requestId: 'req-1', + onPhase: phases.add, + ); + await pumpEventQueue(); + expect(runtime.types, ['workspace.promptStart.start']); + runtime.changed(); + await pumpEventQueue(); + runtime.changed(); + final result = await outcome; + + expect(runtime.requests.first.$2['requestId'], 'req-1'); + expect(runtime.types, [ + 'workspace.promptStart.start', + 'workspace.promptStart.get', + 'workspace.promptStart.get', + ]); + expect(phases, [ + 'Generating workspace identity', + 'Creating workspace', + ]); + expect(result.creation.workspace.id, 'ws-1'); + expect(result.agentTabId, 'agent-tab'); + expect(runtime.events.hasListener, isFalse); + }); + + test('polls when no change event arrives', () async { + final runtime = _FakeRuntime() + ..reads.addAll(>[ + _operation('running', 'generatingIdentity'), + _operation( + 'completed', + 'done', + workspace: true, + agent: const {'tabId': 'agent-tab'}, + ), + ]); + final result = await runPromptWorkspaceService( + service: service(runtime, pollInterval: Duration.zero), + request: request(), + requestId: 'req-1', + ); + expect(result.agentTabId, 'agent-tab'); + expect( + runtime.types.where((type) => type == 'workspace.promptStart.get'), + hasLength(2), + ); + }); + + test('a failed launch keeps the workspace and retries on the host', () async { + final runtime = _FakeRuntime() + ..started = _operation( + 'failed', + 'launchingAgent', + workspace: true, + error: const { + 'code': 'failed', + 'message': 'Agent profile not found: profile-1', + 'retryable': true, + }, + ); + final failure = + await runPromptWorkspaceService( + service: service(runtime), + request: request(), + requestId: 'req-1', + ).then( + (_) => null, + onError: (Object e) { + return e as PromptWorkspaceServiceFailure; + }, + ); + expect(failure, isNotNull); + expect(failure!.toString(), 'Agent profile not found: profile-1'); + expect(failure.operation.retryable, isTrue); + expect(failure.operation.setupTabId, 'setup-tab'); + final creation = failure.creation!; + expect(creation.workspace.id, 'ws-1'); + expect(creation.deferredSetupCommand, isNull); + + runtime.started = _operation( + 'completed', + 'done', + workspace: true, + agent: const {'tabId': 'agent-tab'}, + ); + final retried = await runPromptWorkspaceService( + service: service(runtime), + request: request().withCreated(creation, serviceOperationId: 'op-1'), + requestId: 'req-1', + ); + expect(runtime.requests.last.$1, 'workspace.promptStart.retryLaunch'); + expect(runtime.requests.last.$2, {'id': 'op-1'}); + expect(retried.agentTabId, 'agent-tab'); + }); + + test('needs input and cancellation fail with their message', () async { + final runtime = _FakeRuntime() + ..started = _operation( + 'needsInput', + 'resolvingProject', + error: const { + 'code': 'needs_input', + 'message': 'The prompt does not clearly name a project.', + 'retryable': false, + }, + ); + await expectLater( + runPromptWorkspaceService( + service: service(runtime), + request: request(), + requestId: 'req-1', + ), + throwsA( + isA() + .having((f) => f.creation, 'creation', isNull) + .having( + (f) => f.toString(), + 'message', + 'The prompt does not clearly name a project.', + ), + ), + ); + runtime.started = _operation('cancelled', 'creatingWorkspace'); + await expectLater( + runPromptWorkspaceService( + service: service(runtime), + request: request(), + requestId: 'req-2', + ), + throwsA( + isA().having( + (f) => f.toString(), + 'message', + 'Workspace creation was cancelled.', + ), + ), + ); + }); + + test('a job falls back to the client pipeline without the service', () async { + final shown = []; + final absent = _FakeRuntime(); + expect( + await runPromptWorkspaceJobOnService( + service: service(absent), + request: request(), + requestId: 'req-1', + showWorkspace: (_, tabId) async => shown.add(tabId), + ), + isNull, + ); + final created = WorkspaceCreationResult( + workspace: Workspace.fromJson(_workspace()), + setupReport: WorktreeSetupReport.empty, + ); + final present = _FakeRuntime( + capabilities: {aleraRuntimeHostPromptWorkspaceServiceCapability}, + ); + expect( + await runPromptWorkspaceJobOnService( + service: service(present), + request: request().withCreated(created), + requestId: 'req-1', + showWorkspace: (_, tabId) async => shown.add(tabId), + ), + isNull, + ); + expect(absent.requests, isEmpty); + expect(present.requests, isEmpty); + expect(shown, isEmpty); + }); + + test('a job shows the workspace on the agent tab', () async { + final runtime = + _FakeRuntime( + capabilities: {aleraRuntimeHostPromptWorkspaceServiceCapability}, + ) + ..started = _operation( + 'completed', + 'done', + workspace: true, + agent: const {'tabId': 'agent-tab'}, + ); + final shown = <(String, String?)>[]; + final phases = []; + final outcome = await runPromptWorkspaceJobOnService( + service: service(runtime), + request: request(), + requestId: 'req-1', + onPhase: phases.add, + showWorkspace: (creation, tabId) async => + shown.add((creation.workspace.id, tabId)), + ); + expect(outcome, isNotNull); + expect(shown, <(String, String?)>[('ws-1', 'agent-tab')]); + expect(phases, ['Starting agent']); + }); + + test('a failed job keeps a snapshot that retries on the host', () async { + final runtime = + _FakeRuntime( + capabilities: {aleraRuntimeHostPromptWorkspaceServiceCapability}, + ) + ..started = _operation( + 'failed', + 'launchingAgent', + workspace: true, + error: const { + 'code': 'runtime_unavailable', + 'message': 'The agent did not start.', + 'retryable': true, + }, + ); + final shown = []; + final kept = []; + await expectLater( + runPromptWorkspaceJobOnService( + service: service(runtime), + request: request(), + requestId: 'req-1', + showWorkspace: (_, tabId) async => shown.add(tabId), + onWorkspaceKept: kept.add, + ), + throwsA(isA()), + ); + expect(shown, ['setup-tab']); + expect(kept.single.created?.workspace.id, 'ws-1'); + expect(kept.single.serviceOperationId, 'op-1'); + expect(canRunPromptWorkspaceOnService(kept.single), isTrue); + }); +} diff --git a/test/unit/pull_request_runtime_watch_sync_test.dart b/test/unit/pull_request_runtime_watch_sync_test.dart index e9d98cc65..8acb672ee 100644 --- a/test/unit/pull_request_runtime_watch_sync_test.dart +++ b/test/unit/pull_request_runtime_watch_sync_test.dart @@ -68,10 +68,15 @@ class _Runtime implements RuntimeHostClient, RuntimeHostCapabilityClient { final events = StreamController.broadcast(); Map? watch; int starts = 0; + + /// A runtime with `pullRequestWatchExecutionV2` also owns GitLab and Azure + /// DevOps watches; with only V1 the desktop keeps evaluating those. + bool executionV2 = true; @override Stream get runtimeEvents => events.stream; @override - Future supportsRuntimeCapability(String capability) async => true; + Future supportsRuntimeCapability(String capability) async => + executionV2 || capability != 'pullRequestWatchExecutionV2'; void changed() => events.add(const RuntimeHostEvent('pullRequestWatchChanged', {})); @override @@ -172,41 +177,51 @@ void main() { container.read(workbenchControllerProvider).activeWorkspaceId, 'other', ); - for (final provider in GitHostingProvider.values) { - container - .read(watchProvider.notifier) - .onPanelState( - 'w', - WorkspacePullRequestState( - identity: GitRemoteIdentity( - provider: provider, - host: 'forge.example', - owner: 'owner', - repo: 'repo', - ), - ), - ); - await settle(); - if (provider == GitHostingProvider.github) { - expect(container.read(watchProvider)['w'], isNotNull); - } else { - expect( - container.read(watchProvider), - isEmpty, - reason: '${provider.name} must stop when its review disappears', - ); - expect(runtime.watch, isNull); - await container + Future panelForEveryForge() async { + for (final provider in GitHostingProvider.values) { + container .read(watchProvider.notifier) - .start( - scope: _scope, - reviewNumber: 42, - mode: .fixAndMerge, - binding: const AgentTaskDispatchBinding(tabId: 'desktop-agent'), + .onPanelState( + 'w', + WorkspacePullRequestState( + identity: GitRemoteIdentity( + provider: provider, + host: 'forge.example', + owner: 'owner', + repo: 'repo', + ), + ), ); await settle(); + if (provider == GitHostingProvider.github || runtime.executionV2) { + expect( + container.read(watchProvider)['w'], + isNotNull, + reason: '${provider.name} is evaluated by the runtime', + ); + } else { + expect( + container.read(watchProvider), + isEmpty, + reason: '${provider.name} must stop when its review disappears', + ); + expect(runtime.watch, isNull); + await container + .read(watchProvider.notifier) + .start( + scope: _scope, + reviewNumber: 42, + mode: .fixAndMerge, + binding: const AgentTaskDispatchBinding(tabId: 'desktop-agent'), + ); + await settle(); + } } } + + await panelForEveryForge(); + runtime.executionV2 = false; + await panelForEveryForge(); runtime.watch = null; runtime.changed(); await settle(); diff --git a/test/unit/runtime_mcp_access_repository_test.dart b/test/unit/runtime_mcp_access_repository_test.dart index e45fd86ed..ff6460030 100644 --- a/test/unit/runtime_mcp_access_repository_test.dart +++ b/test/unit/runtime_mcp_access_repository_test.dart @@ -48,6 +48,35 @@ void main() { ); }); + test('reads every access level and orders admin last', () { + for (final (wire, level) in <(String, McpAccessLevel)>[ + ('off', .off), + ('read', .read), + ('full', .full), + ('admin', .admin), + ]) { + expect(McpAccessLevel.fromWire(wire), level); + expect(level.wireName, wire); + } + expect(McpAccessLevel.values, [.off, .read, .full, .admin]); + expect(McpAccessLevel.fromWire('ADMIN'), McpAccessLevel.off); + expect( + McpAccessSettings.fromJson(_settings(access: 'admin')).access, + McpAccessLevel.admin, + ); + }); + + test('update sends the admin access level', () async { + final client = _FakeRuntimeHostClient() + ..responses['mcp.settings.update'] = _settings(access: 'admin'); + final repository = RuntimeMcpAccessRepository(client); + + final updated = await repository.updateSettings(access: .admin); + + expect(updated.access, McpAccessLevel.admin); + expect(client.calls.single.payload, {'access': 'admin'}); + }); + test('update sends only the fields that are set', () async { final client = _FakeRuntimeHostClient() ..responses['mcp.settings.update'] = _settings(access: 'full'); @@ -91,6 +120,7 @@ void main() { final grant = grants.single; expect(grant.clientName, 'Claude'); expect(grant.canExecute, isTrue); + expect(grant.canAdmin, isFalse); expect(grant.createdAt, DateTime.utc(2026, 10, 1, 10)); expect(grant.lastUsedAt, isNull); expect(mcpGrantDetail(grant), 'claude.ai · 2 runtimes · Never used'); @@ -98,6 +128,16 @@ void main() { expect(client.calls.last.payload, {'grantId': 'grant-1'}); }); + test('reads the admin scope of a grant', () { + final grant = McpGrant.fromJson({ + 'id': 'grant-3', + 'clientId': 'admin-client', + 'scopes': ['mcp:read', 'mcp:execute', 'mcp:admin'], + }); + expect(grant.canExecute, isTrue); + expect(grant.canAdmin, isTrue); + }); + test('describes a grant that reaches every runtime', () { final grant = McpGrant.fromJson({ 'id': 'grant-2', @@ -107,6 +147,7 @@ void main() { }); expect(grant.clientName, 'chatgpt'); expect(grant.canExecute, isFalse); + expect(grant.canAdmin, isFalse); expect(mcpGrantDetail(grant), 'All runtimes · Never used'); }); diff --git a/test/unit/runtime_pull_request_watch_repository_test.dart b/test/unit/runtime_pull_request_watch_repository_test.dart new file mode 100644 index 000000000..b97eec0dd --- /dev/null +++ b/test/unit/runtime_pull_request_watch_repository_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:alera/src/features/pull_requests/infra/runtime_pull_request_watch_repository.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:alera/src/shared/git_hosting/domain/git_hosting_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Runtime implements RuntimeHostClient, RuntimeHostCapabilityClient { + _Runtime(this.capabilities); + + final Set capabilities; + final requests = <(String, Map)>[]; + + @override + Stream get runtimeEvents => const Stream.empty(); + + @override + Future supportsRuntimeCapability(String capability) async => + capabilities.contains(capability); + + @override + Future runtimeRequest( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async { + requests.add((type, payload)); + return {'prompt': 'Runtime prompt for ${payload['kind']}'}; + } +} + +void main() { + test('V2 runtimes own every forge, V1 runtimes only GitHub', () async { + final v1 = RuntimePullRequestWatchRepository( + _Runtime({'pullRequestWatchExecutionV1'}), + ); + final v2 = RuntimePullRequestWatchRepository( + _Runtime({'pullRequestWatchExecutionV1', 'pullRequestWatchExecutionV2'}), + ); + final legacy = RuntimePullRequestWatchRepository(_Runtime({})); + for (final provider in GitHostingProvider.values) { + expect(await v2.ownsExecutionFor(provider), isTrue); + expect( + await v1.ownsExecutionFor(provider), + provider == GitHostingProvider.github, + ); + expect(await legacy.ownsExecutionFor(provider), isFalse); + } + expect(await v2.ownsExecutionFor(null), isFalse); + }); + + test( + 'dispatch prompts come from the runtime only when it owns them', + () async { + final runtime = _Runtime({'pullRequestAgentDispatchV1'}); + final prompt = await RuntimePullRequestWatchRepository(runtime) + .agentDispatchPrompt( + workspaceId: 'w', + kind: 'fixFailedChecks', + reviewNumber: 7, + ); + expect(prompt, 'Runtime prompt for fixFailedChecks'); + expect(runtime.requests.single.$1, 'pullRequest.agentDispatch'); + expect(runtime.requests.single.$2, { + 'workspaceId': 'w', + 'kind': 'fixFailedChecks', + 'number': 7, + }); + final older = _Runtime({}); + expect( + await RuntimePullRequestWatchRepository(older) + .agentDispatchPrompt(workspaceId: 'w', kind: 'restack'), + isNull, + ); + expect(older.requests, isEmpty); + }, + ); +} diff --git a/test/unit/runtime_webhook_repository_test.dart b/test/unit/runtime_webhook_repository_test.dart new file mode 100644 index 000000000..7b334ebed --- /dev/null +++ b/test/unit/runtime_webhook_repository_test.dart @@ -0,0 +1,238 @@ +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:alera/src/features/webhooks/domain/webhook_repository.dart'; +import 'package:alera/src/features/webhooks/infra/runtime_webhook_repository.dart'; +import 'package:alera/src/features/webhooks/presentation/webhook_list_row.dart'; +import 'package:alera/src/features/workbench/infra/terminal_host/terminal_host_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map _webhookJson({ + String id = 'wh_1', + List? kinds, + Object? lastDeliveryAt = '2026-10-09T12:00:00Z', + String? lastError, +}) { + return { + 'id': id, + 'url': 'https://example.com/hooks', + 'kinds': + kinds ?? + [for (final kind in RuntimeEventKind.values) kind.wireName], + 'runtimeIds': [], + 'allRuntimes': true, + 'status': 'active', + 'createdAt': '2026-10-01T08:00:00Z', + 'lastDeliveryAt': lastDeliveryAt, + 'lastError': lastError, + }; +} + +void main() { + test('supportsWebhooks reads runtimeEventsV1 from status.get', () async { + final client = _FakeRuntimeHostClient() + ..responses['status.get'] = { + 'runtimeCapabilities': ['runSurfacesV1', 'runtimeEventsV1'], + }; + final repository = RuntimeWebhookRepository(client); + + expect(await repository.supportsWebhooks(), isTrue); + expect(client.calls.single.type, 'status.get'); + + client.responses['status.get'] = { + 'runtimeCapabilities': ['runSurfacesV1'], + }; + expect(await repository.supportsWebhooks(), isFalse); + + client.responses['status.get'] = const {}; + expect(await repository.supportsWebhooks(), isFalse); + }); + + test('lists webhooks and skips malformed entries', () async { + final client = _FakeRuntimeHostClient() + ..responses['webhook.list'] = { + 'webhooks': [ + _webhookJson(lastError: 'HTTP 500'), + 'garbage', + _webhookJson( + id: 'wh_2', + kinds: ['agent.status', 'terminal.exit'], + lastDeliveryAt: null, + ), + ], + }; + final repository = RuntimeWebhookRepository(client); + + final webhooks = await repository.listWebhooks(); + + expect(client.calls.single.type, 'webhook.list'); + expect(client.calls.single.payload, isEmpty); + expect(webhooks, hasLength(2)); + final first = webhooks.first; + expect(first.id, 'wh_1'); + expect(first.url, 'https://example.com/hooks'); + expect(first.status, 'active'); + expect(first.allRuntimes, isTrue); + expect(first.receivesAllKinds, isTrue); + expect(first.createdAt, DateTime.utc(2026, 10, 1, 8)); + expect(first.lastDeliveryAt, DateTime.utc(2026, 10, 9, 12)); + expect(first.lastError, 'HTTP 500'); + expect(webhookKindsSummary(first), 'All Events'); + + final second = webhooks.last; + expect(second.receivesAllKinds, isFalse); + expect(second.lastDeliveryAt, isNull); + expect(second.lastError, isNull); + expect(webhookKindsSummary(second), 'Agent Status, Terminal Exit'); + expect(webhookDeliveryDetail(second), startsWith('No deliveries yet')); + }); + + test('reads epoch seconds and milliseconds as UTC dates', () { + final seconds = RuntimeWebhook.fromJson( + _webhookJson(lastDeliveryAt: 1791201600), + ); + final millis = RuntimeWebhook.fromJson( + _webhookJson(lastDeliveryAt: 1791201600000), + ); + + expect(seconds.lastDeliveryAt, DateTime.utc(2026, 10, 5, 12)); + expect(millis.lastDeliveryAt, DateTime.utc(2026, 10, 5, 12)); + }); + + test('rejects a webhook without an id or url', () { + expect( + () => RuntimeWebhook.fromJson(const {'id': 'wh'}), + throwsFormatException, + ); + expect( + () => RuntimeWebhook.fromJson(const { + 'url': 'https://example.com', + }), + throwsFormatException, + ); + }); + + test('create sends the url and omits kinds when all are wanted', () async { + final client = _FakeRuntimeHostClient() + ..responses['webhook.create'] = { + 'webhook': _webhookJson(), + 'secret': 'whsec_abc', + }; + final repository = RuntimeWebhookRepository(client); + + final created = await repository.createWebhook( + url: ' https://example.com/hooks ', + ); + + expect(client.calls.single.type, 'webhook.create'); + expect(client.calls.single.payload, { + 'url': 'https://example.com/hooks', + }); + expect(created.secret, 'whsec_abc'); + expect(created.webhook.id, 'wh_1'); + + await repository.createWebhook( + url: 'https://example.com/hooks', + kinds: ['inbox.reply'], + ); + expect(client.calls.last.payload, { + 'url': 'https://example.com/hooks', + 'kinds': ['inbox.reply'], + }); + }); + + test('create fails when the secret is missing', () async { + final client = _FakeRuntimeHostClient() + ..responses['webhook.create'] = { + 'webhook': _webhookJson(), + }; + + await expectLater( + RuntimeWebhookRepository(client) + .createWebhook(url: 'https://example.com/hooks'), + throwsFormatException, + ); + }); + + test('delete and test send the webhook id', () async { + final client = _FakeRuntimeHostClient() + ..responses['webhook.delete'] = { + 'deleted': true, + 'id': 'wh_1', + } + ..responses['webhook.test'] = {'deliveryId': 'dl_9'}; + final repository = RuntimeWebhookRepository(client); + + await repository.deleteWebhook('wh_1'); + final deliveryId = await repository.testWebhook('wh_1'); + + expect(client.calls.map((call) => call.type), [ + 'webhook.delete', + 'webhook.test', + ]); + for (final call in client.calls) { + expect(call.payload, {'id': 'wh_1'}); + } + expect(deliveryId, 'dl_9'); + }); + + test('runtime errors surface as readable messages', () async { + final client = _FakeRuntimeHostClient() + ..error = StateError('Sign in to an Alera account first.'); + + await expectLater( + RuntimeWebhookRepository(client).listWebhooks(), + throwsStateError, + ); + expect( + webhookErrorMessage(StateError('Sign in to an Alera account first.')), + 'Sign in to an Alera account first.', + ); + expect( + webhookErrorMessage( + StateError('Unknown terminal host request: webhook.list'), + ), + 'Update the Alera runtime to use webhooks.', + ); + }); + + test('validates webhook URLs', () { + expect(webhookUrlError('https://example.com/hooks'), isNull); + expect(webhookUrlError(''), isNotNull); + expect(webhookUrlError('example.com'), isNotNull); + expect( + webhookUrlError('http://example.com/hooks'), + 'Webhook URLs must use https.', + ); + }); + + test('event kinds round-trip their wire names', () { + expect(RuntimeEventKind.values, hasLength(11)); + for (final kind in RuntimeEventKind.values) { + expect(RuntimeEventKind.fromWire(kind.wireName), kind); + } + expect(RuntimeEventKind.fromWire('made.up'), isNull); + }); +} + +final class _Call(final String type, final Map payload); + +final class _FakeRuntimeHostClient implements RuntimeHostClient { + final Map responses = {}; + final List<_Call> calls = <_Call>[]; + Object? error; + + @override + Stream get runtimeEvents => const Stream.empty(); + + @override + Future runtimeRequest( + String type, [ + Map payload = const {}, + Duration? timeout, + ]) async { + calls.add(_Call(type, Map.from(payload))); + if (error case final Object failure) { + throw failure; + } + return responses[type]; + } +} diff --git a/test/unit/terminal_host_client_buffer_guard_cases.dart b/test/unit/terminal_host_client_buffer_guard_cases.dart index 2a033ecb0..effc52a2f 100644 --- a/test/unit/terminal_host_client_buffer_guard_cases.dart +++ b/test/unit/terminal_host_client_buffer_guard_cases.dart @@ -44,6 +44,7 @@ void _registerTerminalHostBufferGuardTests() { } await acknowledged.future.timeout(const Duration(seconds: 5)); expect(server.payloadFor('hello')['checkoutBufferGuardsV1'], isTrue); + expect(server.payloadFor('hello')['checkoutBufferSaveV1'], isFalse); expect(handler.tabIds, {'editor'}); expect(handler.workspacePaths, {'/repo'}); expect(server.payloadFor('workspace.bufferGuard.ack'), { @@ -61,6 +62,81 @@ void _registerTerminalHostBufferGuardTests() { }, ); } + + for (final resolution in ['save', 'discard']) { + test('settles dirty editors before acknowledging a $resolution', () async { + final directory = await Directory.systemTemp.createTemp( + 'alera-buffer-resolution-', + ); + addTearDown(() => directory.delete(recursive: true)); + final acknowledged = Completer(); + final server = await _TerminalHostTestServer.start( + beforeResponse: (type) async { + if (type == 'workspace.bufferGuard.ack' && + !acknowledged.isCompleted) { + acknowledged.complete(); + } + }, + ); + addTearDown(server.dispose); + final handler = _ResolvingBufferGuardHandler(); + final client = SocketTerminalHostClient( + launcher: _FakeTerminalHostLauncher(server: server), + applicationSupportDirectory: () async => directory, + bufferGuardHandler: handler, + ); + addTearDown(client.dispose); + await client.ensureStarted(config: TerminalHostConfig.defaults); + const scope = { + 'tabIds': ['editor'], + 'workspacePaths': ['/repo'], + }; + server.send( + resolution == 'save' + ? { + 'event': 'checkoutBuffersSaveRequested', + 'payload': {'guardId': 'guard', 'scope': scope}, + } + : { + 'event': 'checkoutBuffersLock', + 'payload': { + 'guardId': 'guard', + 'scope': scope, + 'resolution': 'discard', + }, + }, + ); + await acknowledged.future.timeout(const Duration(seconds: 5)); + expect(server.payloadFor('hello')['checkoutBufferSaveV1'], isTrue); + expect(handler.discard, resolution == 'discard'); + expect(handler.tabIds, {'editor'}); + expect(server.payloadFor('workspace.bufferGuard.ack'), { + 'guardId': 'guard', + 'blockers': [ + {'tabId': 'editor', 'path': 'notes.md', 'reason': 'busy'}, + ], + }); + }); + } +} + +class _ResolvingBufferGuardHandler extends _RecordingBufferGuardHandler + implements RuntimeBufferGuardResolver { + bool? discard; + + @override + Future>> resolveAndLock({ + required String guardId, + required Set tabIds, + required Set workspacePaths, + required bool discard, + }) async { + this.discard = discard; + lock(guardId: guardId, tabIds: tabIds, workspacePaths: workspacePaths); + return [ + {'tabId': 'editor', 'path': 'notes.md', 'reason': 'busy'}, + ]; + } } class _RecordingBufferGuardHandler implements RuntimeBufferGuardHandler { diff --git a/test/widget/mcp_access_settings_pane_test.dart b/test/widget/mcp_access_settings_pane_test.dart index d5f2e8e12..206431ede 100644 --- a/test/widget/mcp_access_settings_pane_test.dart +++ b/test/widget/mcp_access_settings_pane_test.dart @@ -85,7 +85,8 @@ void main() { for (final (level, description) in <(McpAccessLevel, String)>[ (.off, 'MCP clients cannot reach this runtime.'), (.read, 'MCP clients can run read-only tools.'), - (.full, 'MCP clients can run every Alera tool'), + (.full, 'MCP clients can also run tools that start agents'), + (.admin, 'Connected apps can also change agent profiles'), ]) { testWidgets('shows the ${level.name} access level', (tester) async { await pumpPane( @@ -101,6 +102,7 @@ void main() { expect(find.text('Off'), findsWidgets); expect(find.text('Read Only'), findsOneWidget); expect(find.text('Full Control'), findsOneWidget); + expect(find.text('Admin'), findsOneWidget); expect(find.textContaining('not stored'), findsOneWidget); expect(find.text('https://api.alera.build/v1/mcp'), findsOneWidget); expect(find.text('Connected'), findsOneWidget); @@ -123,6 +125,22 @@ void main() { expect(button.selected, {McpAccessLevel.full}); }); + testWidgets('selecting Admin sends the admin access level', (tester) async { + final repository = await pumpPane( + tester, + _FakeMcpAccessRepository(_settings(access: .full)), + ); + + await tester.tap(find.text('Admin')); + await tester.pumpAndSettle(); + + expect(repository.updates, <(McpAccessLevel?, String?)>[(.admin, null)]); + expect( + find.textContaining('allowed administrative tools when you connect it'), + findsOneWidget, + ); + }); + testWidgets('requires an Alera account before turning access on', ( tester, ) async { @@ -139,6 +157,7 @@ void main() { expect(segmentEnabled(tester, 'Off'), isTrue); expect(segmentEnabled(tester, 'Read Only'), isFalse); expect(segmentEnabled(tester, 'Full Control'), isFalse); + expect(segmentEnabled(tester, 'Admin'), isFalse); expect( find.textContaining('Sign in to an Alera account in Settings > Account'), findsOneWidget, @@ -207,6 +226,27 @@ void main() { expect(repository.grantReads, 1); }); + testWidgets('marks an app allowed administrative tools', (tester) async { + final repository = _FakeMcpAccessRepository(_settings()) + ..grants = [ + const McpGrant( + id: 'grant-admin', + clientId: 'admin-client', + clientName: 'Admin Client', + scopes: ['mcp:read', 'mcp:execute', 'mcp:admin'], + allRuntimes: true, + runtimeIds: [], + ), + _chatGptGrant, + ]; + await pumpPane(tester, repository); + + expect(find.text('Admin'), findsNWidgets(2)); + expect(find.byTooltip('mcp:admin'), findsOneWidget); + expect(find.text('mcp:admin'), findsNothing); + expect(find.text('mcp:execute'), findsOneWidget); + }); + testWidgets('shows an empty state without connected apps', (tester) async { await pumpPane(tester, _FakeMcpAccessRepository(_settings())); diff --git a/test/widget/webhooks_settings_test.dart b/test/widget/webhooks_settings_test.dart new file mode 100644 index 000000000..547d1574c --- /dev/null +++ b/test/widget/webhooks_settings_test.dart @@ -0,0 +1,408 @@ +import 'dart:async'; + +import 'package:alera/src/app/theme/alera_dark_theme.dart'; +import 'package:alera/src/features/mcp_access/application/mcp_access_providers.dart'; +import 'package:alera/src/features/mcp_access/domain/mcp_access_repository.dart'; +import 'package:alera/src/features/mcp_access/domain/mcp_access_settings.dart'; +import 'package:alera/src/features/mcp_access/domain/mcp_grant.dart'; +import 'package:alera/src/features/settings/presentation/mcp_access_settings_section.dart'; +import 'package:alera/src/features/webhooks/application/webhook_providers.dart'; +import 'package:alera/src/features/webhooks/domain/runtime_webhook.dart'; +import 'package:alera/src/features/webhooks/domain/webhook_repository.dart'; +import 'package:alera/src/features/webhooks/presentation/add_webhook_dialog.dart'; +import 'package:alera/src/features/webhooks/presentation/webhooks_settings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final RuntimeWebhook _allEvents = RuntimeWebhook( + id: 'wh_all', + url: 'https://example.com/alera', + kinds: [for (final kind in RuntimeEventKind.values) kind.wireName], + status: 'active', + lastDeliveryAt: DateTime.utc(2026, 10, 9, 12), +); + +const RuntimeWebhook _failing = RuntimeWebhook( + id: 'wh_failing', + url: 'https://hooks.example.org/ci', + kinds: ['agent.status', 'terminal.exit', 'pullRequest.watch'], + status: 'failing', + lastError: 'HTTP 502 from receiver', +); + +final Finder _dialogAddButton = find.descendant( + of: find.byType(AddWebhookDialog), + matching: find.widgetWithText(FilledButton, 'Add Webhook'), +); + +void main() { + Future<_FakeWebhookRepository> pump( + WidgetTester tester, + _FakeWebhookRepository repository, { + bool accountConnected = true, + }) async { + tester.view.physicalSize = const Size(1400, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + webhookRepositoryProvider.overrideWithValue(repository), + mcpAccessRepositoryProvider.overrideWithValue( + _FakeMcpAccessRepository(accountConnected: accountConnected), + ), + ], + child: MaterialApp( + theme: aleraDarkTheme, + home: const Scaffold( + body: SingleChildScrollView(child: WebhooksSettings()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return repository; + } + + FilledButton addButton(WidgetTester tester) { + return tester.widget( + find.ancestor( + of: find.text('Add Webhook'), + matching: find.byWidgetPredicate((widget) => widget is FilledButton), + ), + ); + } + + testWidgets('lists webhooks with events, status and deliveries', ( + tester, + ) async { + await pump( + tester, + _FakeWebhookRepository(webhooks: [_allEvents, _failing]), + ); + + expect(find.text('Webhooks'), findsOneWidget); + expect(find.text('https://example.com/alera'), findsOneWidget); + expect(find.text('https://hooks.example.org/ci'), findsOneWidget); + expect(find.text('All Events'), findsOneWidget); + expect(find.text('3 Events'), findsOneWidget); + expect(find.text('Active'), findsOneWidget); + expect(find.text('Failing'), findsOneWidget); + expect(find.textContaining('Last delivery'), findsOneWidget); + expect(find.textContaining('No deliveries yet'), findsOneWidget); + expect(find.text('Last error: HTTP 502 from receiver'), findsOneWidget); + expect(addButton(tester).onPressed, isNotNull); + }); + + testWidgets('shows an empty state when the account has no webhooks', ( + tester, + ) async { + await pump(tester, _FakeWebhookRepository()); + + expect(find.text('No webhooks'), findsOneWidget); + }); + + testWidgets('explains and disables webhooks on an older runtime', ( + tester, + ) async { + final repository = await pump( + tester, + _FakeWebhookRepository(supported: false), + ); + + expect( + find.textContaining('Update the Alera runtime to use webhooks.'), + findsOneWidget, + ); + expect(addButton(tester).onPressed, isNull); + expect(repository.listCalls, 0); + }); + + testWidgets('asks to sign in before listing webhooks', (tester) async { + final repository = await pump( + tester, + _FakeWebhookRepository(webhooks: [_allEvents]), + accountConnected: false, + ); + + expect(find.textContaining('Sign in to an Alera account'), findsOneWidget); + expect(find.text('https://example.com/alera'), findsNothing); + expect(addButton(tester).onPressed, isNull); + expect(repository.listCalls, 0); + }); + + testWidgets('shows a runtime error from the list', (tester) async { + await pump( + tester, + _FakeWebhookRepository( + listError: StateError('Sign in to an Alera account first.'), + ), + ); + + expect(find.text('Webhooks unavailable'), findsOneWidget); + expect(find.text('Sign in to an Alera account first.'), findsOneWidget); + }); + + testWidgets('adds a webhook and shows its secret only once', (tester) async { + final repository = await pump(tester, _FakeWebhookRepository()); + expect(repository.listCalls, 1); + + await tester.tap(find.text('Add Webhook')); + await tester.pumpAndSettle(); + expect(find.text('Webhook URL'), findsOneWidget); + + final urlField = find.byType(TextField); + await tester.enterText(urlField, 'http://example.com/hooks'); + await tester.tap(_dialogAddButton); + await tester.pumpAndSettle(); + expect(find.text('Webhook URLs must use https.'), findsOneWidget); + expect(repository.created, isEmpty); + + await tester.enterText(urlField, 'https://example.com/hooks'); + await tester.tap(find.text('Terminal Exit')); + await tester.pumpAndSettle(); + await tester.tap(_dialogAddButton); + await tester.pumpAndSettle(); + + final (url, kinds) = repository.created.single; + expect(url, 'https://example.com/hooks'); + expect(kinds, hasLength(RuntimeEventKind.values.length - 1)); + expect(kinds, isNot(contains('terminal.exit'))); + + expect(find.text('Webhook Added'), findsOneWidget); + expect(find.text('whsec_once'), findsOneWidget); + expect(find.textContaining("won't see it again"), findsOneWidget); + expect(find.byTooltip('Copy Secret'), findsOneWidget); + + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + expect(find.text('whsec_once'), findsNothing); + expect(find.text('https://example.com/hooks'), findsOneWidget); + expect(repository.listCalls, 2); + }); + + testWidgets('keeps the dialog open until a slow creation answers', ( + tester, + ) async { + final repository = _FakeWebhookRepository()..createGate = Completer(); + await pump(tester, repository); + await tester.tap(find.text('Add Webhook')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'https://example.com/hooks'); + await tester.tap(_dialogAddButton); + await tester.pump(); + + await tester.tap(find.byTooltip('Close')); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.tapAt(const Offset(4, 4)); + await tester.pump(); + expect(find.text('Webhook URL'), findsOneWidget); + + repository.createGate!.complete(); + await tester.pumpAndSettle(); + expect(find.text('whsec_once'), findsOneWidget); + }); + + testWidgets('sends every kind as the default when all are selected', ( + tester, + ) async { + final repository = await pump(tester, _FakeWebhookRepository()); + + await tester.tap(find.text('Add Webhook')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'https://example.com/all'); + await tester.tap(_dialogAddButton); + await tester.pumpAndSettle(); + + expect(repository.created.single.$2, isNull); + }); + + testWidgets('keeps the add dialog open with the runtime error', ( + tester, + ) async { + final repository = await pump( + tester, + _FakeWebhookRepository( + createError: StateError('Sign in to an Alera account first.'), + ), + ); + + await tester.tap(find.text('Add Webhook')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'https://example.com/x'); + await tester.tap(_dialogAddButton); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to an Alera account first.'), findsOneWidget); + expect(find.text('Webhook URL'), findsOneWidget); + expect(find.text('Webhook Added'), findsNothing); + expect(repository.created, hasLength(1)); + }); + + testWidgets('deletes a webhook only after confirmation', (tester) async { + final repository = await pump( + tester, + _FakeWebhookRepository(webhooks: [_allEvents]), + ); + + await tester.tap(find.byTooltip('Delete Webhook')); + await tester.pumpAndSettle(); + expect(find.textContaining('stops receiving runtime events'), findsOne); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(repository.deleted, isEmpty); + + await tester.tap(find.byTooltip('Delete Webhook')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(repository.deleted, ['wh_all']); + expect(find.text('https://example.com/alera'), findsNothing); + expect(find.text('No webhooks'), findsOneWidget); + }); + + testWidgets('sends a test event and reports it', (tester) async { + final repository = await pump( + tester, + _FakeWebhookRepository(webhooks: [_allEvents]), + ); + + await tester.tap(find.byTooltip('Send Test Event')); + await tester.pumpAndSettle(); + + expect(repository.tested, ['wh_all']); + expect(find.textContaining('Test event queued'), findsOneWidget); + }); + + testWidgets('shows a failed test inline', (tester) async { + await pump( + tester, + _FakeWebhookRepository( + webhooks: [_allEvents], + testError: StateError('Webhook delivery is paused.'), + ), + ); + + await tester.tap(find.byTooltip('Send Test Event')); + await tester.pumpAndSettle(); + + expect(find.text('Webhook delivery is paused.'), findsOneWidget); + }); + + test('registers a searchable Webhooks group in MCP Access', () { + final section = mcpAccessSettingsSection( + paneKeys: (_, groups) => { + for (final group in groups) group.id: GlobalKey(), + }, + ); + + expect(section.groups.map((group) => group.id), contains('webhooks')); + expect(section.firstMatchingGroupId('webhook'), 'webhooks'); + expect(section.firstMatchingGroupId('signing secret'), 'webhooks'); + }); +} + +final class _FakeWebhookRepository implements WebhookRepository { + _FakeWebhookRepository({ + this.supported = true, + List webhooks = const [], + this.listError, + this.createError, + this.testError, + }) : _webhooks = List.of(webhooks); + + final bool supported; + final List _webhooks; + final Object? listError; + final Object? createError; + final Object? testError; + int listCalls = 0; + + /// When set, creation waits for it, like a slow cloud answer. + Completer? createGate; + final List<(String, List?)> created = <(String, List?)>[]; + final List deleted = []; + final List tested = []; + + @override + Future supportsWebhooks() async => supported; + + @override + Future> listWebhooks() async { + listCalls += 1; + if (listError case final Object error) { + throw error; + } + return List.of(_webhooks); + } + + @override + Future createWebhook({ + required String url, + List? kinds, + }) async { + created.add((url, kinds)); + await createGate?.future; + if (createError case final Object error) { + throw error; + } + final webhook = RuntimeWebhook( + id: 'wh_${created.length}', + url: url, + kinds: + kinds ?? + [for (final kind in RuntimeEventKind.values) kind.wireName], + status: 'active', + ); + _webhooks.add(webhook); + return RuntimeWebhookCreation(webhook: webhook, secret: 'whsec_once'); + } + + @override + Future deleteWebhook(String id) async { + deleted.add(id); + _webhooks.removeWhere((webhook) => webhook.id == id); + } + + @override + Future testWebhook(String id) async { + tested.add(id); + if (testError case final Object error) { + throw error; + } + return 'dl_1'; + } +} + +final class _FakeMcpAccessRepository implements McpAccessRepository { + _FakeMcpAccessRepository({required this.accountConnected}); + + final bool accountConnected; + + McpAccessSettings get _settings => McpAccessSettings( + access: McpAccessLevel.off, + effectiveRuntimeName: 'studio-box', + accountConnected: accountConnected, + ); + + @override + Stream watchSettings() { + return Stream.value(_settings); + } + + @override + Future updateSettings({ + McpAccessLevel? access, + String? runtimeName, + }) async => _settings; + + @override + Future> listGrants() async => const []; + + @override + Future revokeGrant(String grantId) async {} +} diff --git a/tool/ci/mcp_parity_acceptance.py b/tool/ci/mcp_parity_acceptance.py new file mode 100644 index 000000000..d7277e928 --- /dev/null +++ b/tool/ci/mcp_parity_acceptance.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""End-to-end acceptance for the MCP parity work, against an isolated runtime. + +Starts a runtime in a temporary directory, registers throwaway Git projects, +replaces AI Assist with a deterministic custom command and the agent with a +script that only sleeps, then drives `alera mcp serve` over stdio the way an +MCP client does. Nothing touches the user's own runtime or repositories. + +Usage: python3 tool/ci/mcp_parity_acceptance.py --alera path/to/alera [--keep] +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +FAKE_AI = r"""#!/bin/sh +prompt=$(cat) +task=$(printf '%s\n' "$prompt" | sed -n '/^User task:/,$p') +case "$prompt" in + *"Choose the project"*) + case "$task" in + *alpha*|*Alpha*) + echo '{"project":"Alpha","workspaceName":"Fix Alpha Login","branchName":"fix/alpha-login","section":"Alpha Work"}' ;; + *) + echo '{"project":"Unknown","workspaceName":"Unclear Task","branchName":"chore/unclear"}' ;; + esac ;; + *"previous generated workspace identity was unavailable"*) + echo '{"workspaceName":"Retry Name","branchName":"fix/taken","section":"Others"}' ;; + *"taken branch"*) + echo '{"workspaceName":"Taken Name","branchName":"fix/taken","section":"Others"}' ;; + *) + echo '{"workspaceName":"Beta Docs","branchName":"docs/beta-guide","section":"Others"}' ;; +esac +""" + +FAKE_AGENT = """#!/bin/sh +exec sleep 3600 +""" + + +class Failure(Exception): + pass + + +def check(condition: bool, message: str) -> None: + if not condition: + raise Failure(message) + + +class Runtime: + def __init__(self, alera: str, root: Path) -> None: + self.alera = alera + self.root = root + self.runtime_dir = root / "runtime" + self.env = {key: value for key, value in os.environ.items() if not key.startswith("ALERA_")} + self.env.pop("HERDR_SOCKET_PATH", None) + + def cli(self, *args: str, stdin: str | None = None, check_exit: bool = True) -> dict: + command = [self.alera, args[0], f"--runtime-dir={self.runtime_dir}", "--json", *args[1:]] + result = subprocess.run(command, input=stdin, capture_output=True, text=True, env=self.env, timeout=180) + if check_exit and result.returncode != 0: + raise Failure(f"{' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}") + return json.loads(result.stdout) if result.stdout.strip() else {} + + def set_ai_assist(self, script: Path) -> None: + database = self.runtime_dir / "runtime.sqlite" + settings = {"enabled": True, "agent": "custom", "customCommand": f"/bin/sh {script}"} + with sqlite3.connect(database) as connection: + table = next( + name + for (name,) in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + if name.lower() == "runtimemetadata" + ) + columns = {row[1] for row in connection.execute(f"PRAGMA table_info({table})")} + values = {"key": "settings.aiTextGeneration", "value": json.dumps(settings)} + if "updatedAt" in columns: + values["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + names = ", ".join(values) + marks = ", ".join("?" for _ in values) + connection.execute(f"INSERT OR REPLACE INTO {table} ({names}) VALUES ({marks})", tuple(values.values())) + + +class McpClient: + def __init__(self, runtime: Runtime, access: str) -> None: + self.process = subprocess.Popen( + [runtime.alera, "mcp", f"--runtime-dir={runtime.runtime_dir}", "serve", f"--access={access}"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + env=runtime.env, + ) + self.next_id = 0 + self.notifications: list[dict] = [] + self.initialize = self.request("initialize", { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "parity-acceptance", "title": "Parity Acceptance", "version": "1"}, + }) + + def request(self, method: str, params: dict) -> dict: + self.next_id += 1 + message = {"jsonrpc": "2.0", "id": self.next_id, "method": method, "params": params} + assert self.process.stdin and self.process.stdout + self.process.stdin.write(json.dumps(message) + "\n") + self.process.stdin.flush() + while True: + line = self.process.stdout.readline() + if not line: + raise Failure(f"mcp serve closed while answering {method}") + response = json.loads(line) + if "id" not in response: + self.notifications.append(response) + continue + if response.get("id") == self.next_id: + if "error" in response: + raise Failure(f"{method}: {response['error']}") + return response["result"] + + def tool(self, name: str, arguments: dict) -> dict: + result = self.request("tools/call", {"name": name, "arguments": arguments}) + structured = result.get("structuredContent") + if result.get("isError"): + raise Failure(f"{name} failed: {result['content'][0]['text']}") + check(isinstance(structured, dict), f"{name} returned no structuredContent") + return structured + + def tool_names(self) -> set[str]: + return {tool["name"] for tool in self.request("tools/list", {})["tools"]} + + def close(self) -> None: + self.process.terminate() + self.process.wait(timeout=10) + + +def git_repo(path: Path) -> None: + path.mkdir(parents=True) + for args in (["init", "-q", "-b", "main"], ["config", "user.email", "t@example.com"], ["config", "user.name", "T"]): + subprocess.run(["git", *args], cwd=path, check=True) + (path / "README.md").write_text("fixture\n") + subprocess.run(["git", "add", "."], cwd=path, check=True) + subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=path, check=True) + + +def wait_for(client: McpClient, operation_id: str) -> dict: + deadline = time.time() + 180 + while True: + operation = client.tool("wait_for_workspace_start", {"operationId": operation_id, "timeoutSeconds": 20}) + if operation["status"] != "running" or time.time() > deadline: + return operation + + +def prompt_workspace_scenarios(runtime: Runtime, client: McpClient, alpha: dict, beta: dict) -> list[str]: + passed = [] + tools = client.tool_names() + for name in ("start_workspace_from_prompt", "get_workspace_start", "wait_for_workspace_start", + "list_workspace_starts", "cancel_workspace_start", "retry_workspace_start_launch"): + check(name in tools, f"{name} is not listed") + passed.append("prompt workspace tools are listed") + + inferred = client.tool("start_workspace_from_prompt", {"prompt": "Fix the alpha login screen", "clientRequestId": "accept-0001"}) + inferred = wait_for(client, inferred["id"]) + check(inferred["status"] == "completed", f"inferred start ended {inferred['status']}: {inferred.get('error')}") + check(inferred["projectId"] == alpha["id"], "the project was not inferred from the prompt") + check(inferred["workspace"]["branch"] == "fix/alpha-login", "unexpected branch") + check(inferred["workspace"]["kind"] != "main", "auto mode did not use a worktree") + check(inferred.get("sectionId") == alpha["sectionId"], "the AI-picked section was not assigned") + check(inferred.get("agent", {}).get("tabId"), "the agent did not launch") + check("prompt" not in inferred, "the prompt leaked into the public record") + passed.append("project, name, branch, and section are inferred and the agent launches") + + again = client.tool("start_workspace_from_prompt", {"prompt": "Fix the alpha login screen", "clientRequestId": "accept-0001"}) + check(again["id"] == inferred["id"], "a repeated clientRequestId started a second operation") + passed.append("a repeated clientRequestId returns the first operation") + + unclear = client.tool("start_workspace_from_prompt", {"prompt": "Tidy things up somewhere"}) + unclear = wait_for(client, unclear["id"]) + check(unclear["status"] == "needsInput", f"unclear prompt ended {unclear['status']}") + names = {candidate["name"] for candidate in unclear.get("candidates", [])} + check({"Alpha", "Beta"} <= names, "candidates are missing") + passed.append("an unclear project returns candidates instead of guessing") + + others = client.tool("start_workspace_from_prompt", {"prompt": "Write the beta guide", "projectId": beta["id"]}) + others = wait_for(client, others["id"]) + check(others["status"] == "completed", f"explicit project ended {others['status']}: {others.get('error')}") + check(others.get("sectionId") is None, "an Others answer assigned a section") + passed.append("an Others section answer leaves the workspace without a section") + + checkout = client.tool("start_workspace_from_prompt", { + "prompt": "Write the beta guide", "projectId": beta["id"], "mode": "projectCheckout", "section": "none"}) + checkout = wait_for(client, checkout["id"]) + check(checkout["status"] == "completed", f"project checkout start ended {checkout['status']}: {checkout.get('error')}") + check(checkout["workspace"]["path"] == beta["path"], "projectCheckout did not use the project folder") + passed.append("mode projectCheckout uses the project folder") + + subprocess.run(["git", "branch", "fix/taken"], cwd=alpha["path"], check=True) + taken = client.tool("start_workspace_from_prompt", {"prompt": "taken branch task", "projectId": alpha["id"]}) + taken = wait_for(client, taken["id"]) + check(taken["status"] == "completed", f"collision start ended {taken['status']}: {taken.get('error')}") + check(taken["workspace"]["branch"].startswith("fix/taken-"), "a taken branch was not renumbered") + passed.append("a taken branch is retried and then numbered") + return passed + + +def event_scenarios(client: McpClient, beta: dict) -> list[str]: + passed = [] + check(client.initialize["capabilities"].get("resources", {}).get("subscribe") is True, + "mcp serve does not offer resource subscriptions") + uris = {resource["uri"] for resource in client.request("resources/list", {})["resources"]} + check({"alera://events", "alera://workspace-starts", "alera://inbox"} <= uris, "resources are missing") + passed.append("resources are listed and subscribable") + + start = client.tool("list_events", {"kinds": ["workspace.start.state"]}) + cursor = start["cursor"] + client.request("resources/subscribe", {"uri": "alera://workspace-starts"}) + client.notifications.clear() + operation = client.tool("start_workspace_from_prompt", {"prompt": "Write the beta guide", "projectId": beta["id"], "section": "none"}) + wait_for(client, operation["id"]) + page = client.tool("wait_for_events", {"after": cursor, "kinds": ["workspace.start.state"], "timeoutSeconds": 10}) + events = page["events"] + check(events and all(event["kind"] == "workspace.start.state" for event in events), "no workspace start events") + check(any(event["data"].get("status") == "completed" and event["data"].get("operationId") == operation["id"] + for event in client.tool("list_events", {"after": cursor, "kinds": ["workspace.start.state"]})["events"]), + "the completed transition was not journaled") + check(page["cursor"] > cursor, "the cursor did not advance") + passed.append("wait_for_events returns journaled workspace start transitions by cursor") + + client.request("ping", {}) + updated = [note for note in client.notifications + if note.get("method") == "notifications/resources/updated" + and note["params"]["uri"] == "alera://workspace-starts"] + check(updated, "no resources/updated notification for a subscribed resource") + contents = client.request("resources/read", {"uri": "alera://workspace-starts"})["contents"][0] + check(operation["id"] in contents["text"], "reading the resource does not show the operation") + client.request("resources/unsubscribe", {"uri": "alera://workspace-starts"}) + passed.append("a subscribed resource sends resources/updated and reads back its content") + return passed + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--alera", required=True) + parser.add_argument("--keep", action="store_true") + args = parser.parse_args() + root = Path(tempfile.mkdtemp(prefix="alera-mcp-parity-")) + runtime = Runtime(str(Path(args.alera).resolve()), root) + client = None + try: + ai = root / "fake_ai.sh" + ai.write_text(FAKE_AI) + agent = root / "fake_agent.sh" + agent.write_text(FAKE_AGENT) + agent.chmod(0o755) + git_repo(root / "alpha") + git_repo(root / "beta") + runtime.cli("runtime", "start") + alpha = runtime.cli("project", "add", f"--repo-path={root / 'alpha'}", "--name=Alpha") + beta = runtime.cli("project", "add", f"--repo-path={root / 'beta'}", "--name=Beta") + alpha = {"id": alpha.get("id") or alpha["project"]["id"], "path": str(root / "alpha")} + beta = {"id": beta.get("id") or beta["project"]["id"], "path": str(root / "beta")} + main_workspace = runtime.cli("workspace", "add", f"--project-id={alpha['id']}") + workspace_id = main_workspace.get("workspace", main_workspace).get("id") + section = runtime.cli("workspace", "section", "create", "--name=Alpha Work", f"--workspace-id={workspace_id}") + alpha["sectionId"] = section.get("section", section).get("id") or section.get("sectionId") + runtime.cli("agent-profile", "create", "--name=Fake Agent", "--agent-type=codex", + "--launch-mode=command", f"--command={agent}") + runtime.set_ai_assist(ai) + client = McpClient(runtime, "full") + passed = prompt_workspace_scenarios(runtime, client, alpha, beta) + passed += event_scenarios(client, beta) + for line in passed: + print(f"PASS {line}") + print(f"{len(passed)} scenario(s) passed") + return 0 + except Failure as failure: + print(f"FAIL {failure}", file=sys.stderr) + return 1 + finally: + if client: + client.close() + runtime.cli("runtime", "stop", "--force", check_exit=False) + if args.keep: + print(f"kept {root}") + else: + shutil.rmtree(root, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main())