From b3669efe31fcf8ba7f4c3a4df1ce68449bf86a1d Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Tue, 18 Aug 2026 16:01:28 +0200 Subject: [PATCH 1/3] auth: Renew stored credentials through login Expose auth in top-level help and remove the redundant renew subcommand. Make login reuse stored-credential validation and renewal, with device-flow fallback and login-labeled results. Co-authored-by: SCE --- cli/src/cli_schema.rs | 22 +-- cli/src/services/auth_command/mod.rs | 161 +++++----------------- cli/src/services/parse/command_runtime.rs | 31 ++++- context/architecture.md | 2 +- context/cli/cli-command-surface.md | 14 +- context/context-map.md | 2 +- context/glossary.md | 7 +- context/overview.md | 4 +- 8 files changed, 86 insertions(+), 157 deletions(-) diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 695a9cc71..660505b54 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -12,8 +12,8 @@ pub struct TopLevelCommandMetadata { } pub const AUTH_CLAP_ABOUT: &str = "Authenticate with `WorkOS` device authorization flow"; -pub const AUTH_TOP_LEVEL_PURPOSE: &str = "Authenticate with WorkOS and inspect local auth state"; -pub const AUTH_SHOW_IN_TOP_LEVEL_HELP: bool = false; +pub const AUTH_TOP_LEVEL_PURPOSE: &str = "Authenticate with WorkOS"; +pub const AUTH_SHOW_IN_TOP_LEVEL_HELP: bool = true; pub const CONFIG_CLAP_ABOUT: &str = "Inspect or validate runtime config and observability resolution"; @@ -143,11 +143,10 @@ pub fn auth_help_text() -> String { let base = render_help_for_path(&["auth"]).expect("auth help should be renderable"); format!( - "{}\n{}:\n {}\n {}\n {}\n {}\n", + "{}\n{}:\n {}\n {}\n {}\n", base, heading("Examples"), command_name("sce auth login"), - command_name("sce auth renew"), command_name("sce auth status"), command_name("sce auth logout") ) @@ -241,28 +240,19 @@ pub enum Commands { #[derive(Subcommand, Debug, Clone, PartialEq, Eq)] pub enum AuthSubcommand { - #[command(about = "Start login flow and store credentials")] + #[command(about = "Start the login flow")] Login { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, }, - #[command(about = "Renew stored credentials when they are expired or near expiry")] - Renew { - #[arg(long, value_enum, default_value_t = OutputFormat::Text)] - format: OutputFormat, - - #[arg(long)] - force: bool, - }, - - #[command(about = "Remove stored credentials from the local machine")] + #[command(about = "Log out the currently authenticated user")] Logout { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, }, - #[command(about = "Show current authentication status from stored credentials")] + #[command(about = "Show information about the currently authenticated user")] Status { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, diff --git a/cli/src/services/auth_command/mod.rs b/cli/src/services/auth_command/mod.rs index 70a544899..83ac26b53 100644 --- a/cli/src/services/auth_command/mod.rs +++ b/cli/src/services/auth_command/mod.rs @@ -22,7 +22,6 @@ static AUTH_RUNTIME: OnceLock = OnceLock::new(); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AuthSubcommand { Login { format: AuthFormat }, - Renew { format: AuthFormat, force: bool }, Logout { format: AuthFormat }, Status { format: AuthFormat }, } @@ -46,25 +45,22 @@ struct AuthStatusReport { } pub fn run_auth_subcommand(request: AuthRequest) -> Result { - run_auth_subcommand_with(request, run_login, run_renew, run_logout, run_status) + run_auth_subcommand_with(request, run_login, run_logout, run_status) } -fn run_auth_subcommand_with( +fn run_auth_subcommand_with( request: AuthRequest, login: L, - renew: R, logout: O, status: S, ) -> Result where L: FnOnce(AuthFormat) -> Result, - R: FnOnce(AuthFormat, bool) -> Result, O: FnOnce(AuthFormat) -> Result, S: FnOnce(AuthFormat) -> Result, { match request.subcommand { AuthSubcommand::Login { format } => login(format), - AuthSubcommand::Renew { format, force } => renew(format, force), AuthSubcommand::Logout { format } => logout(format), AuthSubcommand::Status { format } => status(format), } @@ -76,14 +72,15 @@ pub fn run_login(format: AuthFormat) -> Result { let client_id = resolve_login_client_id()?; - if let Some(stored_tokens) = maybe_renew_expired_credentials(runtime, &client, &client_id)? { - return render_login_refresh_result(&stored_tokens, format); - } - - match format { - AuthFormat::Text => run_text_login_with_runtime(runtime, &client, &client_id), - AuthFormat::Json => run_login_json(runtime, &client, &client_id, format), - } + run_login_with_stored_credentials( + format, + token_storage::load_tokens()?, + |stored_tokens| maybe_renew_stored_credentials(runtime, &client, &client_id, stored_tokens), + |format| match format { + AuthFormat::Text => run_text_login_with_runtime(runtime, &client, &client_id), + AuthFormat::Json => run_login_json(runtime, &client, &client_id, format), + }, + ) } pub fn run_logout(format: AuthFormat) -> Result { @@ -96,43 +93,6 @@ pub fn run_logout(format: AuthFormat) -> Result { render_logout_result(deleted, format) } -pub fn run_renew(format: AuthFormat, force: bool) -> Result { - let client = reqwest::Client::new(); - let runtime = shared_runtime()?; - let client_id = resolve_login_client_id()?; - - let Some(stored_tokens) = token_storage::load_tokens()? else { - return Err(anyhow!(AuthError::Unauthorized( - "No stored WorkOS credentials were found. Try: run 'sce auth login' before running 'sce auth renew'.".to_string(), - ))); - }; - - let was_expired = auth::is_stored_token_expired(&stored_tokens)?; - let updated: StoredTokens = if force { - let token = runtime - .block_on(auth::renew_stored_token_from_refresh_token( - &client, - auth::WORKOS_DEFAULT_BASE_URL, - &client_id, - &stored_tokens.refresh_token, - )) - .map_err(|e| map_login_error(&e))?; - token_storage::save_tokens(&token)? - } else { - let token = runtime - .block_on(auth::ensure_valid_token_returning_token( - &client, - auth::WORKOS_DEFAULT_BASE_URL, - &client_id, - &stored_tokens, - )) - .map_err(|e| map_login_error(&e))?; - token_storage::save_tokens(&token)? - }; - - render_renew_result(&updated, force || was_expired, format) -} - pub fn run_status(format: AuthFormat) -> Result { let stored_credentials_path = token_storage::token_file_path()?.display().to_string(); let report = match token_storage::load_tokens()? { @@ -170,30 +130,42 @@ fn shared_runtime() -> Result<&'static tokio::runtime::Runtime> { Ok(AUTH_RUNTIME.get_or_init(|| runtime)) } -fn maybe_renew_expired_credentials( +fn maybe_renew_stored_credentials( runtime: &tokio::runtime::Runtime, client: &reqwest::Client, client_id: &str, + stored_tokens: &StoredTokens, ) -> Result> { - let Some(stored_tokens) = token_storage::load_tokens()? else { - return Ok(None); - }; - - if !auth::is_stored_token_expired(&stored_tokens)? { - return Ok(None); - } - match runtime.block_on(auth::ensure_valid_token_returning_token( client, auth::WORKOS_DEFAULT_BASE_URL, client_id, - &stored_tokens, + stored_tokens, )) { Ok(token) => Ok(Some(token_storage::save_tokens(&token)?)), Err(_) => Ok(None), } } +fn run_login_with_stored_credentials( + format: AuthFormat, + stored_tokens: Option, + renew: R, + device_login: D, +) -> Result +where + R: FnOnce(&StoredTokens) -> Result>, + D: FnOnce(AuthFormat) -> Result, +{ + if let Some(stored_tokens) = stored_tokens { + if let Some(renewed_tokens) = renew(&stored_tokens)? { + return render_login_refresh_result(&renewed_tokens, format); + } + } + + device_login(format) +} + fn maybe_refresh_tokens_for_status(stored_tokens: &StoredTokens) -> Result> { if !auth::is_stored_token_expired(stored_tokens)? { return Ok(None); @@ -357,25 +329,9 @@ fn render_login_result(result: &DeviceAuthFlowResult, format: AuthFormat) -> Res .stored_tokens .stored_at_unix_seconds .saturating_add(result.stored_tokens.expires_in); - let browser_url = result - .authorization - .verification_uri_complete - .as_deref() - .unwrap_or(&result.authorization.verification_uri); match format { - AuthFormat::Text => Ok(format!( - "{}\n{} {}\n{} {}\n{} {}\n{} {}", - success("Authentication succeeded."), - prompt_label("Open in browser:"), - prompt_value(browser_url), - prompt_label("Code:"), - prompt_value(&result.authorization.user_code), - label("Token type:"), - value(&result.stored_tokens.token_type), - label("Expires at (unix):"), - value(&expires_at_unix_seconds.to_string()), - )), + AuthFormat::Text => Ok(success("✓ Authentication succeeded.")), AuthFormat::Json => serde_json::to_string_pretty(&json!({ "status": "ok", "command": NAME, @@ -400,14 +356,7 @@ fn render_login_refresh_result(tokens: &StoredTokens, format: AuthFormat) -> Res .saturating_add(tokens.expires_in); match format { - AuthFormat::Text => Ok(format!( - "{}\n{} {}\n{} {}", - success("Authentication renewed."), - label("Token type:"), - value(&tokens.token_type), - label("Expires at (unix):"), - value(&expires_at_unix_seconds.to_string()), - )), + AuthFormat::Text => Ok(success("✓ Authentication succeeded.")), AuthFormat::Json => serde_json::to_string_pretty(&json!({ "status": "ok", "command": NAME, @@ -424,48 +373,12 @@ fn render_login_refresh_result(tokens: &StoredTokens, format: AuthFormat) -> Res } } -fn render_renew_result(tokens: &StoredTokens, renewed: bool, format: AuthFormat) -> Result { - let expires_at_unix_seconds = tokens - .stored_at_unix_seconds - .saturating_add(tokens.expires_in); - - let status_text = if renewed { - "renewed" - } else { - "is already valid" - }; - - match format { - AuthFormat::Text => Ok(format!( - "{}\n{} {}\n{} {}", - success(&format!("Authentication {status_text}.")), - label("Token type:"), - value(&tokens.token_type), - label("Expires at (unix):"), - value(&expires_at_unix_seconds.to_string()), - )), - AuthFormat::Json => serde_json::to_string_pretty(&json!({ - "status": "ok", - "command": NAME, - "subcommand": "renew", - "authenticated": true, - "renewed": renewed, - "token_type": tokens.token_type, - "scope": tokens.scope, - "stored_at_unix_seconds": tokens.stored_at_unix_seconds, - "expires_in_seconds": tokens.expires_in, - "expires_at_unix_seconds": expires_at_unix_seconds, - })) - .context("failed to serialize auth renew report to JSON. Try: rerun 'sce auth renew --format json'."), - } -} - fn render_logout_result(deleted: bool, format: AuthFormat) -> Result { match format { AuthFormat::Text => Ok(if deleted { - success("Removed stored WorkOS credentials.") + success("Logged out") } else { - value("No stored WorkOS credentials were found.") + value("No user logged in") }), AuthFormat::Json => serde_json::to_string_pretty(&json!({ "status": "ok", diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index e041c1dc7..6359abd28 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -319,9 +319,6 @@ fn convert_auth_subcommand( cli_schema::AuthSubcommand::Login { format } => { services::auth_command::AuthSubcommand::Login { format } } - cli_schema::AuthSubcommand::Renew { format, force } => { - services::auth_command::AuthSubcommand::Renew { format, force } - } cli_schema::AuthSubcommand::Logout { format } => { services::auth_command::AuthSubcommand::Logout { format } } @@ -521,4 +518,32 @@ mod tests { Err(error) => assert!(error.to_string().contains("Unknown command 'trace'")), } } + + #[test] + fn auth_help_lists_only_supported_subcommands() { + let command = parse(&["sce", "auth", "--help"]); + + let RuntimeCommand::HelpText(command) = command else { + panic!("expected help text command"); + }; + + assert!(command.text.contains("login")); + assert!(command.text.contains("status")); + assert!(command.text.contains("logout")); + assert!(!command.text.contains("renew")); + } + + #[test] + fn auth_renew_is_rejected_as_parse_error() { + let Err(error) = parse_runtime_command( + ["sce", "auth", "renew"].into_iter().map(String::from), + &CommandRegistry::default(), + None, + ) else { + panic!("removed auth renew command should not parse") + }; + + assert_eq!(error.class(), FailureClass::Parse); + assert!(error.message().contains("Unknown command 'renew'")); + } } diff --git a/context/architecture.md b/context/architecture.md index 400e27595..2e9cd2ca2 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -118,7 +118,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/capabilities.rs` defines the current broad CLI capability traits consumed by the borrowed, compile-time-typed `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing service internals do not consume these traits directly yet; command execution uses narrow accessors and repo-root-scoped context derivation. - `cli/src/services/lifecycle.rs` defines the current compile-safe lifecycle seam. `ServiceLifecycle` has default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types or the full `AppContext` shape. The same module owns the static `LifecycleProvider` enum and shared `lifecycle_providers(include_hooks)` catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested); enum dispatch calls each concrete provider through generic context methods without boxed lifecycle-provider allocation or repo-root trait-object context erasure. Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for setup-time repository-scoped Agent Trace storage initialization when a repo root is available and repository Agent Trace DB path health/fix from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path; the former fallback was removed by the `retire-legacy-agent-trace-db` plan). Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. - Agent Trace lifecycle setup resolves `agent_trace.repository_id` / `agent_trace.repository_remote`, creates/reuses checkout identity for diagnostics, and initializes the repository-scoped DB through `agent_trace_storage::resolve_agent_trace_storage(...)`; hook runtime uses the same storage resolver and `RepositoryAgentTraceDb` lazy fast-path-then-migrate open with bounded retry plus narrow migration-metadata repair for concurrent first-open races. -- `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|renew|logout|status`, including device-flow login, stored-token renewal (`--force` supported for renew), logout, and status rendering in text/JSON formats; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. +- `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|logout|status`, including device-flow login, stored-credential validation/renewal through login with device-flow fallback, logout, and status rendering in text/JSON formats; renewal reports retain the `login` operation label. `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. There is no public renewal subcommand. - `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, ordered embedded migrations, and config-file lookup key (`db_config_key()`), while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization (with `experimental_multiprocess_wal(true)` for safe concurrent access), Turso connection setup, tokio current-thread runtime bridging, retry-backed blocking `execute`/`query`/`query_values`/`query_map` wrappers, and generic migration execution with per-database `__sce_migrations` metadata. `TursoDb::new()` and `EncryptedTursoDb::new()` wrap only their local open/connect block in `run_with_retry_sync` using a config-driven connection-open policy resolved from the `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults, while operation methods use a config-driven operation policy from the same source. `query_values()` returns fully fetched column names plus raw `turso::Value` rows for deterministic operator-facing rendering; `query_map()` retries the initial query and row-fetch loop, then applies caller row mapping after retry completion. Migration execution is not retried and uses batch execution so one migration file may contain multiple SQL statements while still recording one migration ID. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key through `encryption_key::get_or_create_encryption_key()`, enables Turso local encryption with strict `aegis256` cipher selection, and exposes retry-backed synchronous wrappers plus migration execution. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index a8bf2c7fb..6964930a5 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -16,7 +16,7 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou ## Onboarding documentation -- `sce --help` includes a slim top-level command list and quick-start examples for `setup`, `doctor`, and `version`; `auth` and `hooks` remain implemented in code but are hidden from `sce`, `sce help`, and `sce --help` for this phase. +- `sce --help` includes a slim top-level command list and quick-start examples for `setup`, `doctor`, and `version`; `auth` is visible while `hooks` remains implemented in code but hidden from `sce`, `sce help`, and `sce --help` for this phase. - `cli/src/cli_schema.rs` owns the real top-level command catalog metadata for clap-backed commands (purpose text plus `show_in_top_level_help`), while `command_surface::help_text()` consumes that catalog and adds the synthetic `help` row plus the ASCII banner. - `cli/src/app.rs` now owns an explicit startup lifecycle (`perform_dependency_check` -> `build_startup_context` -> `initialize_runtime` -> `run_command_lifecycle` -> `render_run_outcome`) so dependency bootstrap, config-backed runtime initialization, command parsing/execution, and final stream rendering are no longer coordinated inside one monolithic startup function. - `cli/src/app.rs` also routes clap output through an internal static `RuntimeCommand` enum defined in `cli/src/services/command_registry.rs`, so parse-time conversion and run-time command execution stay separated while avoiding boxed command trait objects. @@ -46,10 +46,10 @@ Operator onboarding currently comes from `sce --help`, command-local `--help` ou - the banner uses a per-column right-to-left color gradient (cyan on the right, magenta on the left) when stdout color is enabled, and renders as plain ASCII when color is disabled (non-TTY or `NO_COLOR`) - the banner is rendered by `command_surface::help_text()` calling `style::banner_with_gradient(SCE_BANNER_LINES)` before the heading - the visible real-command rows are sourced from `cli_schema::TOP_LEVEL_COMMANDS`, so top-level purpose text and help visibility are defined once for both help rendering and known-command classification -- the visible command list is `help`, `config`, `setup`, `doctor`, `sync`, `version`, and `completion` +- the visible command list is `help`, `auth`, `config`, `setup`, `doctor`, `sync`, `version`, and `completion` - top-level help omits implemented/placeholder labels - top-level examples cover setup plus doctor/version machine-readable or repair-intent flows (`doctor --format json`, `doctor --fix`, `version --format json`) and use the shared example-command styling when stdout color is enabled -- `auth` and `hooks` stay parser-valid and directly invocable, but are hidden from those top-level help surfaces +- `auth` and `hooks` stay parser-valid and directly invocable; `auth` is visible in those top-level help surfaces while `hooks` remains hidden Deferred or gated command surfaces currently avoid claiming unimplemented behavior. `hooks` routes through implemented subcommand parsing/dispatch for `pre-commit`, `commit-msg`, `post-commit`, `post-rewrite`, `diff-trace`, and `conversation-trace`; `session-model` is no longer a supported hooks route. Current behavior remains attribution-only and enabled by default for commit attribution unless explicitly opted out (via `SCE_ATTRIBUTION_HOOKS_DISABLED`, `SCE_DISABLED`, or `policies.attribution_hooks.enabled = false`), gated by the staged-diff AI-overlap preflight so the trailer is appended only when AI/editor evidence is found. `post-commit` requires validated `--remote-url`, threads that value through Agent Trace flow, prints it to stderr, and remains the active intersection + Agent Trace DB path. `diff-trace` is active STDIN intake with required non-empty `sessionID`/`diff`/`tool_name`, optional `model_id`, required nullable/non-empty `tool_version`, required `u64` `time` validation, direct nullable `model_id` / `tool_version` attribution (no session fallback), non-lossy AgentTraceDb `time_ms` conversion, and AgentTraceDb insertion including tool-prefixed stored `session_id` values (`oc_` for OpenCode, `cc_` for Claude, `pi_` for Pi) plus nullable `model_id` and `tool_version`; Claude structured `PostToolUse` payloads may derive `model_id` from top-level or nested `model` metadata with `claude/` prefix normalization. @@ -59,7 +59,7 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `setup` defaults to an `inquire` interactive target selection (OpenCode, Claude, Pi, All) and accepts mutually-exclusive non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`); the former `--both` flag was removed in favor of `--all` (opencode+claude+pi); the interactive prompt title and target labels reuse shared prompt styling helpers when stdout color is enabled. `setup` also accepts `--bootstrap-context` as a standalone context-only mode that ensures the durable-context baseline without prompts or integration installs; every normal successful setup path also ensures that baseline after the Git gate. `setup` accepts a repeatable `--workflow ` flag selecting which optional workflows to install (currently only `brownfield`). Passing it makes the listed slugs the exact selection for that run; omitting it reuses the persisted `integrations.optional_workflows`, so a repeat run preserves an earlier opt-in. Unknown slugs fail request resolution with a validation error naming the embedded catalog's available slugs and write no files, and `--workflow` is rejected alongside `--bootstrap-context` or on a hooks-only run because neither installs target assets. The resolved selection filters the installed assets and is persisted; see [config precedence contract](config-precedence-contract.md) and [setup local bootstrap](../sce/setup-repo-local-config-bootstrap.md). An interactive `setup` run instead resolves the selection through an `inquire` multi-select shown after the target prompt, titled `Select optional workflows` with one `{title} — {description}` row per optional workflow using the shared prompt styling. Rows are unchecked when nothing is persisted and pre-checked from `integrations.optional_workflows` otherwise (a supplied `--workflow` list seeds them instead); the answered prompt is the run's exact selection. Cancelling either prompt yields the existing `Setup cancelled. No files were changed.` outcome, a non-TTY run keeps the existing actionable guidance, and the prompt is skipped when the catalog has no optional workflow. -`auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `status` plus copy-ready next steps. +`auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `status` plus copy-ready next steps. `sce auth login` uses the existing token-validation path whenever stored credentials are present: valid credentials are preserved, expired credentials are refreshed, and a failed renewal falls back to device authorization. First login without stored credentials still starts device authorization, and renewal reports remain labeled as `login` in text and JSON output. `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. `setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. `setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. @@ -76,7 +76,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - User-facing diagnostics are rendered on `stderr` as `Error [SCE-ERR-]: ...` with class-default `Try:` remediation appended only when missing; when stderr color is enabled the heading, error code, and diagnostic body all render through shared stderr styling helpers. - Unknown commands/options and extra positional arguments return deterministic, actionable guidance to run `sce --help`. - `sce setup --help` returns setup-specific usage output with target-flag contract details and deterministic examples, including one-run non-interactive setup+hooks and composable follow-up validation/repair-intent flows (`sce doctor --format json`, `sce doctor --fix`). -- `sce auth` and `sce auth --help` return auth-specific usage output with available subcommands and deterministic examples, while `sce auth --help` stays scoped to the selected auth subcommand. +- `sce auth` and `sce auth --help` return auth-specific usage output with available subcommands and deterministic examples, while `sce auth --help` stays scoped to the selected auth subcommand. The removed `sce auth renew` route is rejected as an invalid command. - `sce doctor --help` and `sce hooks --help` return command-local usage output and deterministic copy-ready examples. - Interactive `sce setup` prompt cancellation/interrupt exits cleanly with: `Setup cancelled. No files were changed.` - Command handlers return deterministic status messaging: @@ -98,7 +98,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. - `cli/src/services/agent_trace.rs` defines the canonical Rust SCE web base URL and helpers for Agent Trace conversation URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created repo-local config schema URLs. - `cli/src/services/token_storage.rs` defines WorkOS token persistence (`save_tokens`, `load_tokens`, `delete_tokens`) via the encrypted `AuthDb` `auth_credentials` table using a `OnceLock` lazy singleton with constant integer row ID `1`. `token_file_path()` returns the auth DB path. `TokenStorageError` exposes `PathResolution` and `Database` variants. No JSON file I/O remains. -- `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `renew`, `logout`, and `status`, including shared text/JSON rendering, token refresh/forced renewal handling for `sce auth renew`, token-storage-backed logout deletion with path-aware remediation guidance, expiry-aware status reporting, canonical credentials-file path reporting sourced from the shared default-path seam, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and a lazily initialized current-thread Tokio runtime with both I/O and time enabled so the auth flows can drive the WorkOS device/refresh paths without the prior I/O-disabled panic; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. +- `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `logout`, and `status`, including shared text/JSON rendering, login's stored-token validation and refresh path for any stored credential, device-flow fallback after absent or unsuccessfully renewed credentials, token-storage-backed logout deletion with path-aware remediation guidance, expiry-aware status reporting, canonical credentials-file path reporting sourced from the shared default-path seam, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and a lazily initialized current-thread Tokio runtime with both I/O and time enabled so the auth flows can drive the WorkOS device/refresh paths without the prior I/O-disabled panic; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/app.rs` parses `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` into service-owned runtime command handlers so runtime messages are sourced from domain modules instead of inline strings. ## Local and Agent Trace Turso adapter behavior @@ -133,7 +133,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/setup/mod.rs` and `cli/src/services/hooks/mod.rs` include contract-focused tests for setup flag parsing/validation, interactive selection/cancellation dispatch, setup run messaging, and hook runtime argument/IO/finalization behavior. - `cli/src/services/token_storage.rs` tests cover token save/load round-trips, missing-file handling, token deletion outcomes, invalid JSON corruption handling, and Unix `0600` file-permission enforcement. - `cli/src/services/auth.rs` tests cover WorkOS device/token payload shape parsing, RFC 8628 device and refresh grant constant wiring, terminal OAuth error mapping with `Try:` guidance, polling decision handling for `authorization_pending`/`slow_down`/terminal outcomes, token-expiry evaluation, and refresh-token re-login guidance for terminal refresh errors. -- `cli/src/services/auth_command/mod.rs` tests cover auth subcommand dispatch, login/logout/status text-or-JSON report shapes (including canonical credentials-file path reporting), `Try:` guidance preservation, and runtime-I/O readiness for the login flow. +- `cli/src/services/auth_command/mod.rs` tests cover auth subcommand dispatch, login/logout/status text-or-JSON report shapes (including canonical credentials-file path reporting), stored valid/expired/absent-credential login routing, failed-renewal fallback, login-labeled renewal reports, `Try:` guidance preservation, and runtime-I/O readiness for the login flow. - `cli/src/services/setup/mod.rs` tests also verify embedded-manifest completeness against runtime `config/` trees, deterministic sorted path normalization, and target-scoped iterator behavior (`OpenCode`, `Claude`, `Both`); sandbox-sensitive filesystem install coverage has been removed from the unit-test slice for later integration-test coverage. - `cli/src/services/doctor/` unit coverage is intentionally limited to flake-safe output-shape assertions; filesystem, git, and real repair-flow coverage is deferred to future integration tests so `nix flake check` stays sandbox-safe. diff --git a/context/context-map.md b/context/context-map.md index 3f40802bf..8e6a96b8a 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -9,7 +9,7 @@ Primary context files: Feature/domain context: -- `context/cli/cli-command-surface.md` (CLI command surface including top-level help with ASCII art banner and gradient rendering, setup install flow with the repeatable `sce setup --workflow ` optional-workflow selection and its interactive post-target multi-select, WorkOS device authorization flow + token storage behavior, attribution-only hook routing with validated post-commit `--remote-url` plumbing plus DB-backed `diff-trace` dual persistence and post-commit Agent Trace payload persistence including range `content_hash`, setup-owned local DB + repository-scoped Agent Trace DB bootstrap plus doctor DB health coverage with credential-safe repository identity diagnostics, centralized Rust SCE web URL helpers in `services::agent_trace`, nested flake release package/app installability, Cargo local install + crates.io readiness policy, hidden `sce policy bash` command adapter for bash-policy hook callers, and top-level `sce sync` command wiring for current-repository Agent Trace synchronization; static `RuntimeCommand` enum dispatch lives in `services/command_registry.rs`, command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy/sync are owned by their respective `services/{name}/command.rs` files, and clap-to-runtime conversion lives in `services/parse/command_runtime.rs`) +- `context/cli/cli-command-surface.md` (CLI command surface including top-level help with ASCII art banner and gradient rendering, setup install flow with the repeatable `sce setup --workflow ` optional-workflow selection and its interactive post-target multi-select, WorkOS device authorization flow + token storage behavior including stored-credential renewal through `sce auth login`, attribution-only hook routing with validated post-commit `--remote-url` plumbing plus DB-backed `diff-trace` dual persistence and post-commit Agent Trace payload persistence including range `content_hash`, setup-owned local DB + repository-scoped Agent Trace DB bootstrap plus doctor DB health coverage with credential-safe repository identity diagnostics, centralized Rust SCE web URL helpers in `services::agent_trace`, nested flake release package/app installability, Cargo local install + crates.io readiness policy, hidden `sce policy bash` command adapter for bash-policy hook callers, and top-level `sce sync` command wiring for current-repository Agent Trace synchronization; static `RuntimeCommand` enum dispatch lives in `services/command_registry.rs`, command payload structs for help/version/completion/auth/config/setup/doctor/hooks/policy/sync are owned by their respective `services/{name}/command.rs` files, and clap-to-runtime conversion lives in `services/parse/command_runtime.rs`) - `context/cli/default-path-catalog.md` (canonical production CLI path-ownership contract centered on `cli/src/services/default_paths.rs`, including persisted auth/config files, named DB paths for auth/local/repository-scoped Agent Trace databases, the default observability log-dir accessor consumed by config resolution with Linux `${XDG_STATE_HOME:-~/.local/state}/sce/logs` fallback semantics, repo-relative, embedded-asset, install, hook, and context-path families plus the regression guard that keeps production path ownership centralized) - `context/cli/repository-identity.md` (repository identity module in `cli/src/services/repository_identity/`: pure scheme-neutral `host[:port]/path` canonicalization for SCP/`ssh://`/HTTPS/`git://` remote URLs with credential stripping, hostname lowercasing, default-port removal, and query/fragment/trailing-`.git` cleanup, trim-only explicit-identity handling, `sha256("sce-repository-id-v1\0" + canonical_identity)` repository IDs, credential-safe fieldless errors, plus the `resolve` runtime submodule applying explicit-config-then-configured-remote precedence with `git config --get remote..url` lookup, `RepositoryIdentitySource` provenance, and `.sce/config.json`-guidance resolution errors that never echo URLs; consumed by the T04 `agent_trace_storage` resolver) - `context/cli/agent-trace-storage.md` (repository-scoped Agent Trace storage resolver in `cli/src/services/agent_trace_storage/`: `AgentTraceStorageContext` inputs mirroring the `agent_trace.*` config keys, `ResolvedAgentTraceStorage` carrying repository identity + checkout ID + `/sce/repos//agent-trace.db` path + open `RepositoryAgentTraceDb` + typed `RepositoryMetadata`, `resolve_agent_trace_storage{,_at_state_root}` setup/lifecycle entrypoints with idempotent concurrent-safe first open via bounded fast-path-then-migrate retry plus narrow one-file schema migration-metadata repair and repository metadata validation, a separate no-migration `resolve_agent_trace_storage_for_hook_runtime{,_at_state_root}` pair that high-frequency hook callers use exclusively and that never runs migration `002` or any migration, path-unsafe repository ID rejection in `default_paths::agent_trace_db_path_for_repository{,_at}`, strict never-touch boundary for any pre-migration checkout-scoped/global DB files, and active hook/runtime plus Agent Trace lifecycle setup call-site consumption after T08) diff --git a/context/glossary.md b/context/glossary.md index b932e9e7a..7115ff6a1 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -65,11 +65,12 @@ - `cli cargo install contract`: Supported Cargo install surface for the `shared-context-engineering` crate, which installs the `sce` binary: crates.io (`cargo install shared-context-engineering --locked`) and local checkout (`./scripts/run-cli-cargo.sh install --path cli --locked`). Direct `cargo install --git` is unsupported because it has no repository pre-Cargo generation boundary. - `cli crates.io publication posture`: Current Cargo package posture in `cli/Cargo.toml` where crates.io-facing metadata is publication-ready for the `shared-context-engineering` crate, with crate-facing install guidance owned by `cli/README.md`. - `Nix performance recommendations`: Repo-local operator guidance in `AGENTS.md` covering optional user-level `~/.config/nix/nix.conf` tuning (`max-jobs = auto`, `cores = 0`) and the explicit root/admin-only boundary for `/etc/nix/nix.conf` `auto-optimise-store = true`. -- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|renew|logout|status`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). +- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|status`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). +- `auth login stored-credential renewal`: The `sce auth login` behavior that first validates every stored credential through the existing non-forced token path, preserves valid credentials, refreshes expired credentials, and falls back to device authorization when credentials are absent or renewal fails. Renewal reports retain `login` labels in text and JSON; credential renewal is not exposed as a public subcommand. - `command surface contract`: The current top-level command/help catalog split where `cli/src/cli_schema.rs` owns the real clap-backed command metadata (top-level purpose text plus help visibility for `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion`) and `cli/src/command_surface.rs` consumes that catalog for the custom banner/help surface plus known-command classification, while still adding the synthetic `help` row. -- `top-level help visibility metadata`: Per-command `show_in_top_level_help` metadata in `cli/src/cli_schema.rs` that controls whether a known command appears in `sce`, `sce help`, and `sce --help` without affecting direct invocation; the current hidden top-level commands are `auth`, `hooks`, and `policy`, and `cli/src/command_surface.rs` renders the curated top-level help list from that shared metadata. +- `top-level help visibility metadata`: Per-command `show_in_top_level_help` metadata in `cli/src/cli_schema.rs` that controls whether a known command appears in `sce`, `sce help`, and `sce --help` without affecting direct invocation; the current hidden top-level commands are `hooks` and `policy`, while `auth` is visible, and `cli/src/command_surface.rs` renders the curated top-level help list from that shared metadata. - `command loop`: The `clap` derive-based parser + dispatcher in `cli/src/cli_schema.rs`, `cli/src/services/parse/command_runtime.rs`, and `cli/src/app.rs` that routes `help`, `config`, `setup`, `doctor`, `auth`, `hooks`, `policy`, `sync`, `version`, and `completion`, executes implemented command flows, emits command-local help payloads for supported subcommand trees, and returns deterministic actionable errors for invalid invocation. -- `RuntimeCommand seam`: Internal static command-execution abstraction in `cli/src/services/command_registry.rs` where clap-parsed commands are represented as a `RuntimeCommand` enum with variants for `Help`, `HelpText`, `Version`, `Completion`, `Auth`, `Config`, `Setup`, `Doctor`, `Hooks`, and `Policy`. The enum owns `name()` and `execute(...)` dispatch, delegating behavior to service-owned command payload structs while avoiding boxed `dyn RuntimeCommand` handles. Parsed request construction lives in `cli/src/services/parse/command_runtime.rs` when user-provided options or subcommands are required. +- `RuntimeCommand seam`: Internal static command-execution abstraction in `cli/src/services/command_registry.rs` where clap-parsed commands are represented as a `RuntimeCommand` enum with variants for `Help`, `HelpText`, `Version`, `Completion`, `Auth`, `Config`, `Setup`, `Doctor`, `Hooks`, `Policy`, and `Sync`. The enum owns `name()` and `execute(...)` dispatch, delegating behavior to service-owned command payload structs while avoiding boxed `dyn RuntimeCommand` handles. Parsed request construction lives in `cli/src/services/parse/command_runtime.rs` when user-provided options or subcommands are required. - `sce dependency baseline`: Current crate dependency set declared in `cli/Cargo.toml` (`anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, `uuid`, plus target-specific keyring backends). No CLI dev-dependencies are currently declared, and the baseline is validated through normal compile/test coverage. - `progress reporter contract`: Library-independent, consumer-typed sync seam in `cli/src/services/sync/progress.rs` where `ProgressReporter` carries an arbitrary consumer event type, supports explicit successful finalization and closure-based collectors, and provides `NoopProgressReporter` for callers without human progress output. The sync-owned `SyncProgressEvent` in `cli/src/services/sync/sync.rs` carries lifecycle, accepted-batch, and stream-completion payloads for the four concurrent Agent Trace streams; `services::sync::progress` also owns the fixed `indicatif` terminal adapter and focused contract tests, while `sync/command.rs` selects text versus JSON behavior. No top-level `services::progress` module exists. - `local Turso adapter`: Module in `cli/src/services/local_db/mod.rs` that defines `LocalDbSpec` and exposes `LocalDb` as a `TursoDb` alias. It resolves the canonical local DB path with `local_db_path()`, currently declares zero migrations, and inherits retry-backed `new()`, `execute()`, `query()`, and `query_map()` behavior from the shared generic adapter. diff --git a/context/overview.md b/context/overview.md index a14350372..d5c2887cd 100644 --- a/context/overview.md +++ b/context/overview.md @@ -17,7 +17,7 @@ The generated `/next-task` workflow persists task-level context-synchronization - **Install channels:** repo-flake Nix, Cargo, npm, and source-built Flatpak (`dev.crocoder.sce`); Homebrew deferred (see `context/sce/cli-first-install-channels-contract.md`). The CLI crate currently depends on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, with target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. No CLI dev-dependencies are currently declared. -Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help now displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan→magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels, and hides `auth` and `hooks` from `sce`, `sce help`, and `sce --help`, while those commands remain directly invocable. The real top-level command catalog/help-visibility contract is now centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|status`) plus auth-local guidance for bare `sce auth` / `sce auth --help`, implemented config inspection/validation (`config show`/`config validate`) with bare `sce config` routing to the same help payload as `sce config --help`, real setup orchestration, implemented `doctor` diagnosis-vs-fix CLI surface and stable output-shape scaffolding (`sce doctor`, `sce doctor --fix`, `--format text|json`) plus current installed-CLI/global-state diagnostics for state-root resolution, global config validation, local DB and Agent Trace DB path + health, writable DB-parent-path checks, git availability/repository targeting, bare-repo refusal, effective hook-path source detection, an intentionally empty repo-scoped SCE database section for the active repository, required-hook presence/executable/content-drift checks against canonical embedded SCE-managed hook assets, repair-mode reuse of canonical setup hook installation for missing/stale/non-executable required hooks and missing hooks directories, and doctor-owned bootstrap repair for missing canonical DB parent directories, implemented attribution-only `hooks` subcommand routing/validation entrypoints with commit-msg-only behavior behind an enabled-by-default gate with explicit opt-out controls, implemented machine-readable runtime identification (`version`), implemented shell completion script generation via `clap_complete` (`completion --shell `), and placeholder dispatch for deferred commands (`sync`) through explicit service contracts. Parse-time command conversion plus run-time command handling now flow through an internal `RuntimeCommand` seam in `cli/src/app.rs`, so top-level app orchestration no longer owns one monolithic dispatch `match` for every command. +Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan to magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels; `auth` is visible while `hooks` and `policy` remain directly invocable but hidden. The real top-level command catalog/help-visibility contract is centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|status`), config inspection/validation, setup orchestration, doctor diagnosis/repair, attribution-only hooks, shell completion, and the top-level `sync` command. Parse-time command conversion plus run-time command handling flow through the internal `RuntimeCommand` seam in `cli/src/app.rs`. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. The same runtime also emits stable user-facing stderr error classes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) using deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, stderr primary emission so stdout command payloads remain pipe-safe, and `observability::traits` boundaries for logger and telemetry behavior. @@ -116,7 +116,7 @@ Lightweight post-task verification baseline (required after each completed task) - Use `context/patterns.md` for implementation and operational conventions. - Use `context/decisions/` for explicit architecture decisions. - Use `context/plans/` for active plan execution state and task handoff continuity. -- Use `context/cli/cli-command-surface.md` for current command-surface and command-local help coverage, including `auth login|renew|logout|status`, attribution-only `hooks`, and local Turso adapter behavior plus module-boundary details of the `sce` placeholder crate. +- Use `context/cli/cli-command-surface.md` for current command-surface and command-local help coverage, including `auth login|logout|status`, attribution-only `hooks`, and local Turso adapter behavior plus module-boundary details of the `sce` placeholder crate. - Use `context/cli/default-path-catalog.md` for the current canonical CLI path-ownership contract centered on `cli/src/services/default_paths.rs`. - Use `context/sce/shared-context-plan-workflow.md` for the canonical planning-session workflow (`/change-to-plan`) including clarification gating and `/next-task` handoff contract. - Use `context/sce/plan-code-overlap-map.md` for the current overlap/dedup inventory across Shared Context Plan/Code agents, related commands, and core skills. From acd93a621cb2d37c4ce648abc4b3085d4b7b7f12 Mon Sep 17 00:00:00 2001 From: stefanskoricdev Date: Tue, 18 Aug 2026 17:25:23 +0200 Subject: [PATCH 2/3] auth: Replace local status with Control Plane-backed whoami Expose sce auth whoami and retrieve the authenticated profile from the Control Plane /me endpoint. Render deterministic text and JSON profile output, including logged-out guidance and optional workspace, role, and name fields. Update command-surface documentation to remove the retired status route. Co-authored-by: SCE --- cli/src/cli_schema.rs | 4 +- .../agent_trace_sync/control_plane.rs | 47 +++- cli/src/services/auth_command/mod.rs | 211 +++++++----------- cli/src/services/command_registry.rs | 2 +- cli/src/services/parse/command_runtime.rs | 6 +- context/architecture.md | 4 +- context/cli/cli-command-surface.md | 8 +- context/glossary.md | 3 +- context/overview.md | 4 +- 9 files changed, 143 insertions(+), 146 deletions(-) diff --git a/cli/src/cli_schema.rs b/cli/src/cli_schema.rs index 660505b54..67c0d0502 100644 --- a/cli/src/cli_schema.rs +++ b/cli/src/cli_schema.rs @@ -147,7 +147,7 @@ pub fn auth_help_text() -> String { base, heading("Examples"), command_name("sce auth login"), - command_name("sce auth status"), + command_name("sce auth whoami"), command_name("sce auth logout") ) } @@ -253,7 +253,7 @@ pub enum AuthSubcommand { }, #[command(about = "Show information about the currently authenticated user")] - Status { + Whoami { #[arg(long, value_enum, default_value_t = OutputFormat::Text)] format: OutputFormat, }, diff --git a/cli/src/services/agent_trace_sync/control_plane.rs b/cli/src/services/agent_trace_sync/control_plane.rs index ed64e282a..b15c69633 100644 --- a/cli/src/services/agent_trace_sync/control_plane.rs +++ b/cli/src/services/agent_trace_sync/control_plane.rs @@ -1,7 +1,8 @@ //! Wire-contract DTOs for the control-plane Agent Trace ingestion API. //! //! These types define the request/response shapes for -//! `POST /agent-trace/ingestion/state` and `POST /agent-trace/ingestion/batch`. +//! `GET /me`, `POST /agent-trace/ingestion/state`, and +//! `POST /agent-trace/ingestion/batch`. //! They perform no HTTP I/O and hold no cursor state themselves. use std::fmt; @@ -105,6 +106,7 @@ pub struct AgentTraceIngestionBatchResponse { const STATE_PATH: &str = "agent-trace/ingestion/state"; const BATCH_PATH: &str = "agent-trace/ingestion/batch"; +const ME_PATH: &str = "me"; const STATE_RETRY_MAX_ATTEMPTS: u32 = 1; const STATE_RETRY_TIMEOUT_MS: u64 = 60_000; @@ -234,6 +236,39 @@ pub struct AuthenticatedControlPlaneClient { refresh_lock: Arc>, } +/// Response body for `GET /me`. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MeResponse { + pub user: MeUser, + pub authorization: MeAuthorization, + pub workspace: Option, +} + +/// User profile returned by the Control Plane's `/me` endpoint. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MeUser { + pub email: String, + pub first_name: Option, + pub last_name: Option, +} + +/// Authorization information returned by the Control Plane's `/me` endpoint. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MeAuthorization { + pub permissions: Vec, + pub role: Option, +} + +/// Current workspace returned by the Control Plane's `/me` endpoint. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MeWorkspace { + pub name: String, +} + impl AuthenticatedControlPlaneClient { pub fn new( http: reqwest::Client, @@ -306,6 +341,16 @@ impl AuthenticatedControlPlaneClient { } } + /// Calls `GET /me` to retrieve the current authenticated user's profile, + /// authorization, and optional workspace from the Control Plane. + pub async fn me(&self) -> Result { + let url = self.endpoint(ME_PATH); + let response = self + .execute_authenticated(|token| self.http.get(&url).bearer_auth(token)) + .await?; + classify_response(response).await + } + pub async fn ingest_messages( &self, request: &AgentTraceIngestionBatchRequest, diff --git a/cli/src/services/auth_command/mod.rs b/cli/src/services/auth_command/mod.rs index 83ac26b53..3e3c67638 100644 --- a/cli/src/services/auth_command/mod.rs +++ b/cli/src/services/auth_command/mod.rs @@ -2,11 +2,13 @@ pub mod command; use std::io::Write; use std::sync::OnceLock; -use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{anyhow, Context, Result}; use serde_json::json; +use crate::services::agent_trace_sync::control_plane::{ + AuthenticatedControlPlaneClient, ControlPlaneError, MeResponse, +}; use crate::services::auth::{self, AuthError, DeviceAuthFlowResult}; use crate::services::config; use crate::services::output_format::OutputFormat; @@ -23,7 +25,7 @@ static AUTH_RUNTIME: OnceLock = OnceLock::new(); pub enum AuthSubcommand { Login { format: AuthFormat }, Logout { format: AuthFormat }, - Status { format: AuthFormat }, + Whoami { format: AuthFormat }, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -31,28 +33,15 @@ pub struct AuthRequest { pub subcommand: AuthSubcommand, } -#[derive(Clone, Debug, Eq, PartialEq)] -struct AuthStatusReport { - authentication_state: &'static str, - stored_credentials_path: String, - has_stored_credentials: bool, - token_expired: Option, - token_type: Option, - scope: Option, - stored_at_unix_seconds: Option, - expires_at_unix_seconds: Option, - seconds_until_expiry: Option, -} - pub fn run_auth_subcommand(request: AuthRequest) -> Result { - run_auth_subcommand_with(request, run_login, run_logout, run_status) + run_auth_subcommand_with(request, run_login, run_logout, run_whoami) } fn run_auth_subcommand_with( request: AuthRequest, login: L, logout: O, - status: S, + whoami: S, ) -> Result where L: FnOnce(AuthFormat) -> Result, @@ -62,7 +51,7 @@ where match request.subcommand { AuthSubcommand::Login { format } => login(format), AuthSubcommand::Logout { format } => logout(format), - AuthSubcommand::Status { format } => status(format), + AuthSubcommand::Whoami { format } => whoami(format), } } @@ -93,27 +82,25 @@ pub fn run_logout(format: AuthFormat) -> Result { render_logout_result(deleted, format) } -pub fn run_status(format: AuthFormat) -> Result { - let stored_credentials_path = token_storage::token_file_path()?.display().to_string(); - let report = match token_storage::load_tokens()? { - Some(tokens) => { - let tokens = maybe_refresh_tokens_for_status(&tokens)?.unwrap_or(tokens); - build_authenticated_status_report(&tokens, stored_credentials_path)? - } - None => AuthStatusReport { - authentication_state: "unauthenticated", - stored_credentials_path, - has_stored_credentials: false, - token_expired: None, - token_type: None, - scope: None, - stored_at_unix_seconds: None, - expires_at_unix_seconds: None, - seconds_until_expiry: None, - }, - }; +pub fn run_whoami(format: AuthFormat) -> Result { + if token_storage::load_tokens()?.is_none() { + return render_unauthenticated_whoami(format); + } - render_status_result(&report, format) + let cwd = std::env::current_dir() + .context("failed to determine current directory for auth config resolution")?; + let auth_config = config::resolve_auth_runtime_config(&cwd)?; + let client = AuthenticatedControlPlaneClient::new( + reqwest::Client::new(), + auth_config.control_plane_base_url.value.unwrap_or_default(), + auth::WORKOS_DEFAULT_BASE_URL, + auth_config.workos_client_id.value.unwrap_or_default(), + ); + let profile = shared_runtime()? + .block_on(client.me()) + .map_err(|error| map_whoami_control_plane_error(&error))?; + + render_whoami_result(&profile, format) } fn shared_runtime() -> Result<&'static tokio::runtime::Runtime> { @@ -166,26 +153,6 @@ where device_login(format) } -fn maybe_refresh_tokens_for_status(stored_tokens: &StoredTokens) -> Result> { - if !auth::is_stored_token_expired(stored_tokens)? { - return Ok(None); - } - - let client_id = resolve_login_client_id()?; - let runtime = shared_runtime()?; - let client = reqwest::Client::new(); - - match runtime.block_on(auth::ensure_valid_token_returning_token( - &client, - auth::WORKOS_DEFAULT_BASE_URL, - &client_id, - stored_tokens, - )) { - Ok(token) => Ok(Some(token_storage::save_tokens(&token)?)), - Err(_) => Ok(None), - } -} - fn run_text_login_with_runtime( runtime: &tokio::runtime::Runtime, client: &reqwest::Client, @@ -300,30 +267,6 @@ fn map_login_error(error: &AuthError) -> anyhow::Error { )) } -fn build_authenticated_status_report( - tokens: &StoredTokens, - stored_credentials_path: String, -) -> Result { - let now_unix_seconds = current_unix_timestamp_seconds()?; - let expires_at_unix_seconds = tokens - .stored_at_unix_seconds - .saturating_add(tokens.expires_in); - let seconds_until_expiry = i64::try_from(expires_at_unix_seconds).unwrap_or(i64::MAX) - - i64::try_from(now_unix_seconds).unwrap_or(0); - - Ok(AuthStatusReport { - authentication_state: "authenticated", - stored_credentials_path, - has_stored_credentials: true, - token_expired: Some(seconds_until_expiry <= 0), - token_type: Some(tokens.token_type.clone()), - scope: tokens.scope.clone(), - stored_at_unix_seconds: Some(tokens.stored_at_unix_seconds), - expires_at_unix_seconds: Some(expires_at_unix_seconds), - seconds_until_expiry: Some(seconds_until_expiry), - }) -} - fn render_login_result(result: &DeviceAuthFlowResult, format: AuthFormat) -> Result { let expires_at_unix_seconds = result .stored_tokens @@ -391,66 +334,76 @@ fn render_logout_result(deleted: bool, format: AuthFormat) -> Result { } } -fn render_status_result(report: &AuthStatusReport, format: AuthFormat) -> Result { +fn render_unauthenticated_whoami(format: AuthFormat) -> Result { + match format { + AuthFormat::Text => Ok(format!( + "You are not logged in. Please log in using the {} command.", + success("sce auth login") + )), + AuthFormat::Json => serde_json::to_string_pretty(&json!({ + "status": "ok", + "command": NAME, + "subcommand": "whoami", + "authentication_state": "unauthenticated", + "has_stored_credentials": false, + })) + .context("failed to serialize auth whoami report to JSON. Try: rerun 'sce auth whoami --format json'."), + } +} + +fn render_whoami_result(profile: &MeResponse, format: AuthFormat) -> Result { match format { AuthFormat::Text => { - if !report.has_stored_credentials { - return Ok(format!( - "{} {}", - label("Authentication status:"), - value("unauthenticated") - ) + &format!( - "\n{} {}\n{} {}", - label("Stored credentials:"), - value("none"), - label("Credentials file:"), - value(&report.stored_credentials_path), - )); - } + let permissions = if profile.authorization.permissions.is_empty() { + String::from("none") + } else { + profile.authorization.permissions.join(", ") + }; Ok(format!( - "{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}", - label("Authentication status:"), - value(report.authentication_state), - label("Stored credentials:"), - value("present"), - label("Credentials file:"), - value(&report.stored_credentials_path), - label("Token expired:"), - value(&report.token_expired.unwrap_or(false).to_string()), - label("Seconds until expiry:"), - value(&report.seconds_until_expiry.unwrap_or_default().to_string()), - label("Expires at (unix):"), - value(&report.expires_at_unix_seconds.unwrap_or_default().to_string()), - label("Token type:"), - value(report.token_type.as_deref().unwrap_or("(unknown)")), - label("Scope:"), - value(report.scope.as_deref().unwrap_or("(none)")), + "{} {}\n{} {}\n{} {}\n{} {}\n{} {}\n{} {}", + label("Email:"), + value(&profile.user.email), + label("First Name:"), + value(profile.user.first_name.as_deref().unwrap_or("")), + label("Last Name:"), + value(profile.user.last_name.as_deref().unwrap_or("")), + label("Role:"), + value(profile.authorization.role.as_deref().unwrap_or("none")), + label("Permissions:"), + value(&permissions), + label("Organization Name:"), + value( + profile + .workspace + .as_ref() + .map_or("none", |workspace| workspace.name.as_str()), + ), )) } AuthFormat::Json => serde_json::to_string_pretty(&json!({ "status": "ok", "command": NAME, - "subcommand": "status", - "authentication_state": report.authentication_state, - "stored_credentials_path": report.stored_credentials_path, - "has_stored_credentials": report.has_stored_credentials, - "token_expired": report.token_expired, - "token_type": report.token_type, - "scope": report.scope, - "stored_at_unix_seconds": report.stored_at_unix_seconds, - "expires_at_unix_seconds": report.expires_at_unix_seconds, - "seconds_until_expiry": report.seconds_until_expiry, + "subcommand": "whoami", + "user": { + "email": profile.user.email, + "first_name": profile.user.first_name, + "last_name": profile.user.last_name, + }, + "authorization": { + "role": profile.authorization.role, + "permissions": profile.authorization.permissions, + }, + "workspace": profile.workspace.as_ref().map(|workspace| json!({ + "name": workspace.name, + })), })) - .context("failed to serialize auth status report to JSON. Try: rerun 'sce auth status --format json'."), + .context("failed to serialize auth whoami report to JSON. Try: rerun 'sce auth whoami --format json'."), } } -fn current_unix_timestamp_seconds() -> Result { - Ok(SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| anyhow!("system clock is invalid for auth status checks: {error}. Try: verify local system time and rerun 'sce auth status'."))? - .as_secs()) +fn map_whoami_control_plane_error(error: &ControlPlaneError) -> anyhow::Error { + anyhow!("failed to fetch authenticated user information from the Control Plane: {error}") } fn with_try_guidance(message: String, guidance: &str) -> String { diff --git a/cli/src/services/command_registry.rs b/cli/src/services/command_registry.rs index bff826015..b58a4ad58 100644 --- a/cli/src/services/command_registry.rs +++ b/cli/src/services/command_registry.rs @@ -128,7 +128,7 @@ pub fn default_runtime_command(name: &str) -> Option { services::auth_command::NAME => Some(RuntimeCommand::Auth( services::auth_command::command::AuthCommand { request: services::auth_command::AuthRequest { - subcommand: services::auth_command::AuthSubcommand::Status { + subcommand: services::auth_command::AuthSubcommand::Whoami { format: services::auth_command::AuthFormat::Text, }, }, diff --git a/cli/src/services/parse/command_runtime.rs b/cli/src/services/parse/command_runtime.rs index 6359abd28..d58dbbd63 100644 --- a/cli/src/services/parse/command_runtime.rs +++ b/cli/src/services/parse/command_runtime.rs @@ -322,8 +322,8 @@ fn convert_auth_subcommand( cli_schema::AuthSubcommand::Logout { format } => { services::auth_command::AuthSubcommand::Logout { format } } - cli_schema::AuthSubcommand::Status { format } => { - services::auth_command::AuthSubcommand::Status { format } + cli_schema::AuthSubcommand::Whoami { format } => { + services::auth_command::AuthSubcommand::Whoami { format } } }; @@ -528,7 +528,7 @@ mod tests { }; assert!(command.text.contains("login")); - assert!(command.text.contains("status")); + assert!(command.text.contains("whoami")); assert!(command.text.contains("logout")); assert!(!command.text.contains("renew")); } diff --git a/context/architecture.md b/context/architecture.md index 2e9cd2ca2..870ce9194 100644 --- a/context/architecture.md +++ b/context/architecture.md @@ -101,7 +101,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - The npm distribution implementation lives under `npm/`: `package.json` defines the `sce` package surface, `bin/sce.js` launches the package-local native binary, `lib/install.js` resolves the current package version against the release manifest, verifies `sce-v-release-manifest.json.sig` with the bundled public key before trusting manifest contents, and then installs the checksum-verified native archive for supported macOS/Linux targets, while `test/platform.test.js` and `test/install.test.js` cover platform selection plus signed-manifest installer behavior. - `cli/src/main.rs` is the executable entrypoint (`sce`) and delegates to `app::run`. -- `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands, including the top-level `sync` command, and renders command-local help text for the `auth` command tree (`auth`, `auth login`, `auth logout`, `auth status`). +- `cli/src/cli_schema.rs` defines the clap-based CLI schema using derive macros for all top-level commands and subcommands, including the top-level `sync` command, and renders command-local help text for the `auth` command tree (`auth`, `auth login`, `auth logout`, `auth whoami`). - `cli/src/app.rs` provides the clap-based argument dispatch loop with deterministic help/setup execution, bare-command help routing for `sce auth` and `sce config`, centralized stream routing (`stdout` success payloads, `stderr` redacted diagnostics), stable class-based exit-code mapping (`2` parse, `3` validation, `4` runtime, `5` dependency), and stable class-based stderr diagnostic codes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) with default `Try:` remediation injection when missing. - The app runtime now moves through explicit startup phases in `cli/src/app.rs`: dependency bootstrapping (`perform_dependency_check`), startup context construction (`build_startup_context`), runtime initialization (`initialize_runtime`), command parse/execute inside telemetry subscriber context (`run_command_lifecycle`, `parse_command_phase` plus `services::app_support::execute_command_phase`), and final output rendering through `services::app_support::render_run_outcome`. `AppRuntime` owns the concrete production logger, no-op telemetry runtime, filesystem ops, git ops, static `CommandRegistry`, and startup-diagnostic state across those phases; `RunOutcome` carries final render data with an optional generic logger implementing `services::observability::traits::Logger`, so render support can log classified errors without production-logger type coupling. If a telemetry implementation attempts to invoke the command action more than once, dispatch returns a runtime-classified error instead of panicking or reusing consumed arguments. - `AppContext` is the CLI's borrowed dependency view in `cli/src/app.rs`: it is generic over logger, telemetry, filesystem, and git capability implementations and stores references plus an optional `repo_root: Option` instead of owning `Arc` trait objects. Because it borrows from `AppRuntime`, `AppContext` is a lightweight, short-lived view and must not be stored long-term (e.g., in structs or across await points). Startup creates a context view over `AppRuntime`'s concrete production dependencies with `repo_root` set to `None`; command paths can derive repo-root-scoped context views through the `ContextWithRepoRoot` accessor trait / `AppContext::with_repo_root(...)`, which reuses the same borrowed dependencies while attaching the resolved root. Narrow accessor traits expose associated concrete capability types for logger, telemetry, fs, and git (`&Self::...`) plus repo-root access, so call sites can express capability requirements without erasing the borrowed dependencies back to trait objects; lifecycle providers consume the repo-root accessor rather than the full context type. @@ -118,7 +118,7 @@ The repository includes a new placeholder Rust binary crate at `cli/`. - `cli/src/services/capabilities.rs` defines the current broad CLI capability traits consumed by the borrowed, compile-time-typed `AppContext`: `FsOps` with `StdFsOps` for filesystem operations and `GitOps` with `ProcessGitOps` for git command execution plus repository-root/hooks-directory resolution. Existing service internals do not consume these traits directly yet; command execution uses narrow accessors and repo-root-scoped context derivation. - `cli/src/services/lifecycle.rs` defines the current compile-safe lifecycle seam. `ServiceLifecycle` has default no-op generic `diagnose`, `fix`, and `setup` methods over `C: HasRepoRoot`, with lifecycle-owned health, fix, and setup result types so the trait contract is not publicly anchored to doctor/setup module types or the full `AppContext` shape. The same module owns the static `LifecycleProvider` enum and shared `lifecycle_providers(include_hooks)` catalog/factory, returning providers in deterministic order (config → local_db → auth_db → agent_trace_db → hooks when requested); enum dispatch calls each concrete provider through generic context methods without boxed lifecycle-provider allocation or repo-root trait-object context erasure. Hooks exposes a `HooksLifecycle` provider in `cli/src/services/hooks/lifecycle.rs` for hook rollout diagnosis/fix/setup using lifecycle-owned health records plus the canonical required-hook installer. Config exposes a `ConfigLifecycle` provider in `cli/src/services/config/lifecycle.rs` for global/repo-local config validation and repo-local `.sce/config.json` bootstrap. local_db exposes a `LocalDbLifecycle` provider in `cli/src/services/local_db/lifecycle.rs` for canonical local DB path health, parent-directory readiness/bootstrap, and `LocalDb::new()` setup. auth_db exposes an `AuthDbLifecycle` provider in `cli/src/services/auth_db/lifecycle.rs` for canonical auth DB path health, parent-directory readiness/bootstrap, and `AuthDb::new()` setup. agent_trace_db exposes an `AgentTraceDbLifecycle` provider in `cli/src/services/agent_trace_db/lifecycle.rs` for setup-time repository-scoped Agent Trace storage initialization when a repo root is available and repository Agent Trace DB path health/fix from resolved repository identity, returning an actionable "requires a Git repository" diagnostic outside repository context (no global/checkout fallback path; the former fallback was removed by the `retire-legacy-agent-trace-db` plan). Doctor runtime aggregates the full provider catalog for `diagnose` and `fix` and adapts lifecycle records into doctor report/fix records at the orchestration boundary; setup command aggregates the shared catalog for `setup` with hooks included only when requested and adapts hook setup outcomes before rendering setup-owned messages. - Agent Trace lifecycle setup resolves `agent_trace.repository_id` / `agent_trace.repository_remote`, creates/reuses checkout identity for diagnostics, and initializes the repository-scoped DB through `agent_trace_storage::resolve_agent_trace_storage(...)`; hook runtime uses the same storage resolver and `RepositoryAgentTraceDb` lazy fast-path-then-migrate open with bounded retry plus narrow migration-metadata repair for concurrent first-open races. -- `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|logout|status`, including device-flow login, stored-credential validation/renewal through login with device-flow fallback, logout, and status rendering in text/JSON formats; renewal reports retain the `login` operation label. `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. There is no public renewal subcommand. +- `cli/src/services/auth_command/mod.rs` defines the implemented auth command surface for `sce auth login|logout|whoami`, including device-flow login, stored-credential validation/renewal through login with device-flow fallback, logout, and Control Plane `/me`-backed whoami rendering in text/JSON formats; text mode uses flat `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name` labels, with optional names and missing role/permissions/workspace values handled deterministically. Logged-out text returns exact login guidance and renewal reports retain the `login` operation label. `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. There is no public renewal or status subcommand. - `cli/src/services/db/mod.rs` provides the shared generic Turso infrastructure seam: `DbSpec` supplies a service-specific name, path, ordered embedded migrations, and config-file lookup key (`db_config_key()`), while `TursoDb` owns parent-directory creation, `Builder::new_local(...)` initialization (with `experimental_multiprocess_wal(true)` for safe concurrent access), Turso connection setup, tokio current-thread runtime bridging, retry-backed blocking `execute`/`query`/`query_values`/`query_map` wrappers, and generic migration execution with per-database `__sce_migrations` metadata. `TursoDb::new()` and `EncryptedTursoDb::new()` wrap only their local open/connect block in `run_with_retry_sync` using a config-driven connection-open policy resolved from the `DATABASE_RETRY_CONFIG` `OnceLock` with fallback to hardcoded defaults, while operation methods use a config-driven operation policy from the same source. `query_values()` returns fully fetched column names plus raw `turso::Value` rows for deterministic operator-facing rendering; `query_map()` retries the initial query and row-fetch loop, then applies caller row mapping after retry completion. Migration execution is not retried and uses batch execution so one migration file may contain multiple SQL statements while still recording one migration ID. The same module also provides `EncryptedTursoDb`, a structurally parallel encrypted adapter that resolves the encryption key through `encryption_key::get_or_create_encryption_key()`, enables Turso local encryption with strict `aegis256` cipher selection, and exposes retry-backed synchronous wrappers plus migration execution. `cli/src/services/db/encryption_key.rs` first derives a Turso-compatible 64-character hex key from non-empty `SCE_AUTH_DB_ENCRYPTION_KEY` env-secret text when present, otherwise falls back to keyring-backed credential-store get-or-create behavior; no plaintext auth DB fallback exists. - `cli/src/services/local_db/mod.rs` provides the concrete local DB spec and `LocalDb` type alias over the shared generic `TursoDb` adapter. `LocalDbSpec` resolves the deterministic persistent runtime DB target through the shared default-path seam and declares no local migrations; `TursoDb` supplies retry-backed blocking `execute`/`query`, parent-directory creation, Turso connection setup, tokio current-thread runtime bridging, and generic migration execution. - `cli/src/services/auth_db/mod.rs` provides the encrypted auth DB spec and `AuthDb` type alias over `EncryptedTursoDb`. `AuthDbSpec` resolves `/sce/auth.db` through the shared default-path seam and embeds ordered auth migrations. Auth DB lifecycle setup/doctor integration is wired through `AuthDbLifecycle`; auth command/token-storage reads/writes are directed through `token_storage.rs`. diff --git a/context/cli/cli-command-surface.md b/context/cli/cli-command-surface.md index 6964930a5..9443425d1 100644 --- a/context/cli/cli-command-surface.md +++ b/context/cli/cli-command-surface.md @@ -59,7 +59,7 @@ Deferred or gated command surfaces currently avoid claiming unimplemented behavi `setup` defaults to an `inquire` interactive target selection (OpenCode, Claude, Pi, All) and accepts mutually-exclusive non-interactive target flags (`--opencode`, `--claude`, `--pi`, `--all`); the former `--both` flag was removed in favor of `--all` (opencode+claude+pi); the interactive prompt title and target labels reuse shared prompt styling helpers when stdout color is enabled. `setup` also accepts `--bootstrap-context` as a standalone context-only mode that ensures the durable-context baseline without prompts or integration installs; every normal successful setup path also ensures that baseline after the Git gate. `setup` accepts a repeatable `--workflow ` flag selecting which optional workflows to install (currently only `brownfield`). Passing it makes the listed slugs the exact selection for that run; omitting it reuses the persisted `integrations.optional_workflows`, so a repeat run preserves an earlier opt-in. Unknown slugs fail request resolution with a validation error naming the embedded catalog's available slugs and write no files, and `--workflow` is rejected alongside `--bootstrap-context` or on a hooks-only run because neither installs target assets. The resolved selection filters the installed assets and is persisted; see [config precedence contract](config-precedence-contract.md) and [setup local bootstrap](../sce/setup-repo-local-config-bootstrap.md). An interactive `setup` run instead resolves the selection through an `inquire` multi-select shown after the target prompt, titled `Select optional workflows` with one `{title} — {description}` row per optional workflow using the shared prompt styling. Rows are unchecked when nothing is persisted and pre-checked from `integrations.optional_workflows` otherwise (a supplied `--workflow` list seeds them instead); the answered prompt is the run's exact selection. Cancelling either prompt yields the existing `Setup cancelled. No files were changed.` outcome, a non-TTY run keeps the existing actionable guidance, and the prompt is skipped when the catalog has no optional workflow. -`auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `status` plus copy-ready next steps. `sce auth login` uses the existing token-validation path whenever stored credentials are present: valid credentials are preserved, expired credentials are refreshed, and a failed renewal falls back to device authorization. First login without stored credentials still starts device authorization, and renewal reports remain labeled as `login` in text and JSON output. +`auth` now emits auth-local guidance for bare `sce auth` and `sce auth --help`, listing `login`, `logout`, and `whoami` plus copy-ready next steps. `sce auth login` uses the existing token-validation path whenever stored credentials are present: valid credentials are preserved, expired credentials are refreshed, and a failed renewal falls back to device authorization. First login without stored credentials still starts device authorization, and renewal reports remain labeled as `login` in text and JSON output. Authenticated `sce auth whoami` retrieves the authoritative profile from the Control Plane `GET /me` endpoint and renders flat text labels for `Email`, `First Name`, `Last Name`, `Role`, `Permissions`, and `Organization Name`; missing first/last names render empty and missing role, permissions, or workspace values render `none`. `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` all support command-local `--help`/`-h` usage output via top-level parser routing in `cli/src/app.rs`. `setup` now also exposes compile-time embedded config assets for OpenCode/Claude/Pi targets, sourced from the generated `config/.opencode/**`, `config/.claude/**`, and `config/.pi/**` trees via `cli/build.rs` with normalized forward-slash relative paths and target-scoped iteration APIs; the embedded asset set includes the OpenCode bash-policy plugin wrapper plus Claude settings `PreToolUse` Bash policy hook, both delegating to the Rust `sce policy bash` path. `setup` additionally includes a repository-root install engine (`install_embedded_setup_assets`) that installs each embedded asset individually into `.opencode/`/`.claude/`/`.pi/` — stage next to the final destination, remove only that destination file if present, swap into place, with deterministic recovery guidance naming the failing asset's path on swap failure — never removing an integration target directory as a whole, while treating bash-policy enforcement files as first-class SCE-managed assets. See [setup non-destructive per-asset install policy](../sce/setup-no-backup-policy-seam.md) for the full contract, including the pending pruning gap for deselected/stale assets. @@ -76,7 +76,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - User-facing diagnostics are rendered on `stderr` as `Error [SCE-ERR-]: ...` with class-default `Try:` remediation appended only when missing; when stderr color is enabled the heading, error code, and diagnostic body all render through shared stderr styling helpers. - Unknown commands/options and extra positional arguments return deterministic, actionable guidance to run `sce --help`. - `sce setup --help` returns setup-specific usage output with target-flag contract details and deterministic examples, including one-run non-interactive setup+hooks and composable follow-up validation/repair-intent flows (`sce doctor --format json`, `sce doctor --fix`). -- `sce auth` and `sce auth --help` return auth-specific usage output with available subcommands and deterministic examples, while `sce auth --help` stays scoped to the selected auth subcommand. The removed `sce auth renew` route is rejected as an invalid command. +- `sce auth` and `sce auth --help` return auth-specific usage output with available subcommands and deterministic examples, while `sce auth --help` stays scoped to the selected auth subcommand. The removed `sce auth renew` and `sce auth status` routes are rejected as invalid commands. - `sce doctor --help` and `sce hooks --help` return command-local usage output and deterministic copy-ready examples. - Interactive `sce setup` prompt cancellation/interrupt exits cleanly with: `Setup cancelled. No files were changed.` - Command handlers return deterministic status messaging: @@ -98,7 +98,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/default_paths.rs` defines the canonical per-user persisted-location seam for config/state/cache roots plus named default file paths for current persisted artifacts (`global config`, `auth tokens`, `local DB`, `agent trace DB`) used by config discovery, token storage, database adapters, and doctor diagnostics; its internal `roots` seam now owns the platform-aware root-directory resolution so non-test production modules consume shared path accessors instead of resolving owned roots directly. - `cli/src/services/agent_trace.rs` defines the canonical Rust SCE web base URL and helpers for Agent Trace conversation URLs, persisted Agent Trace trace URLs, Agent Trace session URLs, and setup-created repo-local config schema URLs. - `cli/src/services/token_storage.rs` defines WorkOS token persistence (`save_tokens`, `load_tokens`, `delete_tokens`) via the encrypted `AuthDb` `auth_credentials` table using a `OnceLock` lazy singleton with constant integer row ID `1`. `token_file_path()` returns the auth DB path. `TokenStorageError` exposes `PathResolution` and `Database` variants. No JSON file I/O remains. -- `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `logout`, and `status`, including shared text/JSON rendering, login's stored-token validation and refresh path for any stored credential, device-flow fallback after absent or unsuccessfully renewed credentials, token-storage-backed logout deletion with path-aware remediation guidance, expiry-aware status reporting, canonical credentials-file path reporting sourced from the shared default-path seam, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and a lazily initialized current-thread Tokio runtime with both I/O and time enabled so the auth flows can drive the WorkOS device/refresh paths without the prior I/O-disabled panic; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. +- `cli/src/services/auth_command/mod.rs` defines the auth command orchestration surface (`AuthRequest`, `AuthSubcommand`, `run_auth_subcommand`) for `login`, `logout`, and `whoami`, including shared text/JSON rendering, login's stored-token validation and refresh path for any stored credential, device-flow fallback after absent or unsuccessfully renewed credentials, token-storage-backed logout deletion with path-aware remediation guidance, Control Plane `/me` profile retrieval for authenticated whoami, flat safe-field rendering with optional-name/null-value handling, exact logged-out text guidance, precedence-aware client-ID guidance sourced from the shared auth-runtime resolver instead of env-only assumptions, and a lazily initialized current-thread Tokio runtime with both I/O and time enabled so the auth flows can drive the WorkOS device/refresh paths without the prior I/O-disabled panic; `cli/src/services/auth_command/command.rs` owns the `AuthCommand` payload used by the static `RuntimeCommand` enum. - `cli/src/app.rs` parses `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion` into service-owned runtime command handlers so runtime messages are sourced from domain modules instead of inline strings. ## Local and Agent Trace Turso adapter behavior @@ -133,7 +133,7 @@ An interactive `setup` run instead resolves the selection through an `inquire` m - `cli/src/services/setup/mod.rs` and `cli/src/services/hooks/mod.rs` include contract-focused tests for setup flag parsing/validation, interactive selection/cancellation dispatch, setup run messaging, and hook runtime argument/IO/finalization behavior. - `cli/src/services/token_storage.rs` tests cover token save/load round-trips, missing-file handling, token deletion outcomes, invalid JSON corruption handling, and Unix `0600` file-permission enforcement. - `cli/src/services/auth.rs` tests cover WorkOS device/token payload shape parsing, RFC 8628 device and refresh grant constant wiring, terminal OAuth error mapping with `Try:` guidance, polling decision handling for `authorization_pending`/`slow_down`/terminal outcomes, token-expiry evaluation, and refresh-token re-login guidance for terminal refresh errors. -- `cli/src/services/auth_command/mod.rs` tests cover auth subcommand dispatch, login/logout/status text-or-JSON report shapes (including canonical credentials-file path reporting), stored valid/expired/absent-credential login routing, failed-renewal fallback, login-labeled renewal reports, `Try:` guidance preservation, and runtime-I/O readiness for the login flow. +- `cli/src/services/auth_command/mod.rs` tests cover auth subcommand dispatch, unauthenticated whoami guidance, safe authenticated whoami JSON fields, stored valid/expired/absent-credential login routing, failed-renewal fallback, login-labeled renewal reports, `Try:` guidance preservation, and runtime-I/O readiness for the login flow. Flat authenticated text rendering is implemented but currently has no dedicated regression test. - `cli/src/services/setup/mod.rs` tests also verify embedded-manifest completeness against runtime `config/` trees, deterministic sorted path normalization, and target-scoped iterator behavior (`OpenCode`, `Claude`, `Both`); sandbox-sensitive filesystem install coverage has been removed from the unit-test slice for later integration-test coverage. - `cli/src/services/doctor/` unit coverage is intentionally limited to flake-safe output-shape assertions; filesystem, git, and real repair-flow coverage is deferred to future integration tests so `nix flake check` stays sandbox-safe. diff --git a/context/glossary.md b/context/glossary.md index 7115ff6a1..8ae87b64a 100644 --- a/context/glossary.md +++ b/context/glossary.md @@ -65,7 +65,7 @@ - `cli cargo install contract`: Supported Cargo install surface for the `shared-context-engineering` crate, which installs the `sce` binary: crates.io (`cargo install shared-context-engineering --locked`) and local checkout (`./scripts/run-cli-cargo.sh install --path cli --locked`). Direct `cargo install --git` is unsupported because it has no repository pre-Cargo generation boundary. - `cli crates.io publication posture`: Current Cargo package posture in `cli/Cargo.toml` where crates.io-facing metadata is publication-ready for the `shared-context-engineering` crate, with crate-facing install guidance owned by `cli/README.md`. - `Nix performance recommendations`: Repo-local operator guidance in `AGENTS.md` covering optional user-level `~/.config/nix/nix.conf` tuning (`max-jobs = auto`, `cores = 0`) and the explicit root/admin-only boundary for `/etc/nix/nix.conf` `auto-optimise-store = true`. -- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|status`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). +- `sce` (CLI foundation): Rust binary crate at `cli/` with implemented auth command flows (`auth login|logout|whoami`) plus auth-local bare-command guidance (`sce auth`, `sce auth --help`), Control Plane `/me`-backed whoami profile output using flat email/name/role/permissions/organization labels, exact logged-out login guidance, implemented setup installation flow including lifecycle-aggregated local DB and Agent Trace DB bootstrap, implemented attribution-only `hooks` subcommand routing/validation entrypoints, and a fully implemented top-level `sce sync` command that synchronizes the current repository's Agent Trace DB with the control plane and renders the documented text/JSON output (see `context/cli/sync-command.md`). - `auth login stored-credential renewal`: The `sce auth login` behavior that first validates every stored credential through the existing non-forced token path, preserves valid credentials, refreshes expired credentials, and falls back to device authorization when credentials are absent or renewal fails. Renewal reports retain `login` labels in text and JSON; credential renewal is not exposed as a public subcommand. - `command surface contract`: The current top-level command/help catalog split where `cli/src/cli_schema.rs` owns the real clap-backed command metadata (top-level purpose text plus help visibility for `auth`, `config`, `setup`, `doctor`, `hooks`, `policy`, `sync`, `version`, and `completion`) and `cli/src/command_surface.rs` consumes that catalog for the custom banner/help surface plus known-command classification, while still adding the synthetic `help` row. - `top-level help visibility metadata`: Per-command `show_in_top_level_help` metadata in `cli/src/cli_schema.rs` that controls whether a known command appears in `sce`, `sce help`, and `sce --help` without affecting direct invocation; the current hidden top-level commands are `hooks` and `policy`, while `auth` is visible, and `cli/src/command_surface.rs` renders the curated top-level help list from that shared metadata. @@ -245,7 +245,6 @@ - `agent-trace plugin secondary diff persistence ownership`: Current runtime contract where `buildTrace` no longer writes diff-trace artifacts or database rows directly; extracted diff payloads are forwarded to CLI `diff-trace` intake and the Rust hook runtime owns AgentTraceDb insertion without any `context/tmp` artifact fallback. - `messages table (Agent Trace DB)`: Agent Trace DB table created by migration `008_create_messages.sql`; stores session-scoped parent messages with columns `session_id`, `message_id`, `role` (`user`/`assistant` via CHECK constraint), `generated_at_unix_ms`, `created_at`, and `updated_at`. Message body text belongs to `parts.text`, not the parent `messages` row. Has a unique index on `(session_id, message_id)` for duplicate-ignore parent message inserts and a compound index on `(session_id, generated_at_unix_ms, id)` for chronological session message retrieval. No foreign keys to any other table. - `musl static Linux release`: The Linux binary release targets (`x86_64-unknown-linux-musl` and `aarch64-unknown-linux-musl`) compile against musl libc and link fully statically. The resulting binary has no runtime libc dependency and zero `/nix/store/` references in ELF metadata, strings, or dynamic-linker fields, satisfying the native portability audit. The musl targets replace the previous glibc-linked `*-unknown-linux-gnu` targets; macOS (`aarch64-apple-darwin`) is unchanged. Introduced in the `musl-static-linux-release` plan. - - `parts table (Agent Trace DB)`: Agent Trace DB table created by migration `009_create_parts.sql`; stores append-only message parts with columns `type` (typed by Rust as `text`/`reasoning`/`patch`/`question` and stored as unconstrained `TEXT NOT NULL`), `text`, `message_id`, `session_id`, `generated_at_unix_ms`, `created_at`, `updated_at`. Uses only the internal `id` for row identity (no upsert/dedup). Multiple parts can exist for the same `(session_id, message_id)`. A compound index on `(session_id, message_id, generated_at_unix_ms, id)` enables ordered joins. No foreign keys to `messages` or any other table, so parts may be inserted before their parent message exists. - `AgentTraceExportReader`: Read-only incremental export reader in `cli/src/services/agent_trace_export/mod.rs` over one `RepositoryAgentTraceDb`, exposing `read_messages_after`/`read_parts_after`/`read_diff_traces_after`/`read_agent_traces_after`, each `(cursor: i64, limit: usize) -> Result>` over `WHERE id > cursor ORDER BY id ASC LIMIT {limit}`. Holds no local cursor, performs no mutation, makes no network calls, and returns owned camelCase `serde::Serialize` export-row DTOs matching the shipped control-plane ingestion contract. See `context/sce/agent-trace-export-readers.md`. - `context synchronization lifecycle`: Durable task-level state for synchronization after successful `/next-task` execution. The task record is `pending`, `synced`, or `blocked`; blocked records carry a blocker, required action, and retry condition. Missing lifecycle state on a completed task is unresolved debt, not evidence of synchronization. `/validate` does not persist a plan-level synchronization lifecycle. See `context/sce/shared-context-code-workflow.md`. diff --git a/context/overview.md b/context/overview.md index d5c2887cd..5d5f812e5 100644 --- a/context/overview.md +++ b/context/overview.md @@ -17,7 +17,7 @@ The generated `/next-task` workflow persists task-level context-synchronization - **Install channels:** repo-flake Nix, Cargo, npm, and source-built Flatpak (`dev.crocoder.sce`); Homebrew deferred (see `context/sce/cli-first-install-channels-contract.md`). The CLI crate currently depends on `anyhow`, `chrono`, `clap`, `clap_complete`, `dirs`, `hmac`, `indicatif`, `inquire`, `jsonschema`, `keyring-core`, `murmur3`, `owo-colors`, `rand`, `reqwest`, `serde`, `serde_json`, `sha2`, `tokio`, `tracing`, `turso`, and `uuid`, with target-specific keyring backend dependencies for Linux/FreeBSD, macOS, and Windows. No CLI dev-dependencies are currently declared. -Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan to magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels; `auth` is visible while `hooks` and `policy` remain directly invocable but hidden. The real top-level command catalog/help-visibility contract is centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|status`), config inspection/validation, setup orchestration, doctor diagnosis/repair, attribution-only hooks, shell completion, and the top-level `sync` command. Parse-time command conversion plus run-time command handling flow through the internal `RuntimeCommand` seam in `cli/src/app.rs`. +Its command loop is implemented with `clap` derive-based argument parsing and `anyhow` error handling. Top-level help displays an ASCII art "SCE" banner with a per-column right-to-left color gradient (cyan to magenta when color is enabled, plain ASCII when disabled) above a slim command list without implemented/placeholder labels; `auth` is visible while `hooks` and `policy` remain directly invocable but hidden. The real top-level command catalog/help-visibility contract is centralized in `cli/src/cli_schema.rs` and consumed by `cli/src/command_surface.rs` for custom banner/help rendering plus known-command classification. The runtime includes implemented auth flows (`auth login|logout|whoami`), with authenticated whoami reading the Control Plane `GET /me` profile and rendering flat email/name/role/permissions/organization labels, optional names and missing values handled deterministically, and exact login guidance returned when logged out, alongside config inspection/validation, setup orchestration, doctor diagnosis/repair, attribution-only hooks, shell completion, and the top-level `sync` command. Parse-time command conversion plus run-time command handling flow through the internal `RuntimeCommand` seam in `cli/src/app.rs`. The command loop now enforces a stable exit-code contract in `cli/src/app.rs`: `2` parse failures, `3` invocation validation failures, `4` runtime failures, and `5` dependency startup failures. The same runtime also emits stable user-facing stderr error classes (`SCE-ERR-PARSE`, `SCE-ERR-VALIDATION`, `SCE-ERR-RUNTIME`, `SCE-ERR-DEPENDENCY`) using deterministic `Error []: ...` diagnostics with class-default `Try:` remediation appended when missing. The app runtime now also includes a structured observability baseline in `cli/src/services/observability.rs`: deterministic env-controlled log threshold/format (`SCE_LOG_LEVEL` defaults to `error`; `SCE_LOG_FORMAT` defaults to `text`), default-backed log-directory routing (`SCE_LOG_DIR` / config-file `log_dir` / `/sce/logs`) with per-operation machine-local dated file selection, optional session filename partitioning, and creation-triggered retention of direct regular `*.log` files to 10 entries, stable lifecycle event IDs, stderr primary emission so stdout command payloads remain pipe-safe, and `observability::traits` boundaries for logger and telemetry behavior. @@ -116,7 +116,7 @@ Lightweight post-task verification baseline (required after each completed task) - Use `context/patterns.md` for implementation and operational conventions. - Use `context/decisions/` for explicit architecture decisions. - Use `context/plans/` for active plan execution state and task handoff continuity. -- Use `context/cli/cli-command-surface.md` for current command-surface and command-local help coverage, including `auth login|logout|status`, attribution-only `hooks`, and local Turso adapter behavior plus module-boundary details of the `sce` placeholder crate. +- Use `context/cli/cli-command-surface.md` for current command-surface and command-local help coverage, including `auth login|logout|whoami`, attribution-only `hooks`, and local Turso adapter behavior plus module-boundary details of the `sce` placeholder crate. - Use `context/cli/default-path-catalog.md` for the current canonical CLI path-ownership contract centered on `cli/src/services/default_paths.rs`. - Use `context/sce/shared-context-plan-workflow.md` for the canonical planning-session workflow (`/change-to-plan`) including clarification gating and `/next-task` handoff contract. - Use `context/sce/plan-code-overlap-map.md` for the current overlap/dedup inventory across Shared Context Plan/Code agents, related commands, and core skills. From 0fcf18796ed7dc4b1e008946bf5ed37118a79742 Mon Sep 17 00:00:00 2001 From: David Abram Date: Tue, 18 Aug 2026 20:06:25 +0200 Subject: [PATCH 3/3] runtime: Stop reconciling terminal stream failures Treat terminal control-plane errors as a distinct batch outcome so stream synchronization returns immediately without a `/state` refresh. Preserve reconciliation for conflicts, ambiguous responses, and transport failures, and verify terminal failures do not invoke refresh. Co-authored-by: SCE --- cli/src/services/agent_trace_sync/mod.rs | 45 ++++++++++++++++++++++-- cli/src/services/sync/sync.rs | 19 +++------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/cli/src/services/agent_trace_sync/mod.rs b/cli/src/services/agent_trace_sync/mod.rs index 18cc3861c..c27f55c92 100644 --- a/cli/src/services/agent_trace_sync/mod.rs +++ b/cli/src/services/agent_trace_sync/mod.rs @@ -55,8 +55,10 @@ impl AgentTraceExportRow for AgentTraceAgentTraceExportRow { /// Result of one batch-ingest attempt, as classified by the caller-supplied /// ingest closure. `Conflict` and `Ambiguous` carry no data: reconciliation /// always re-derives truth from a fresh `/state` call rather than trusting -/// anything about the failed attempt itself. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// anything about the failed attempt itself. `Terminal` is different: the +/// attempt is known to have failed in a way that cannot be resolved by +/// `/state`, so the stream stops without invoking its refresh closure. +#[derive(Debug, PartialEq, Eq)] pub enum BatchAttemptOutcome { /// The batch was accepted. `accepted` and `cursor` are the server /// response's own fields, validated by the engine before the stream @@ -67,6 +69,9 @@ pub enum BatchAttemptOutcome { /// The batch outcome could not be determined (`5xx`, a transport /// failure, or an invalid response). Ambiguous, + /// The batch failed with a terminal control-plane error. The string is + /// already safe to surface as a stream error and is never reconciled. + Terminal(String), } /// Terminal failure of [`sync_stream`]. @@ -80,6 +85,9 @@ pub enum StreamSyncError { /// that were sent (`accepted != rows.len()` or /// `cursor != rows.last().source_row_id()`). InvalidResponse(String), + /// The batch failed with a terminal control-plane error. Unlike + /// [`Self::Refresh`], this does not represent a failed `/state` call. + Terminal(String), /// The reconciliation loop exceeded [`RECONCILIATION_MAX_ATTEMPTS`] /// without converging. DidNotConverge, @@ -93,6 +101,7 @@ impl fmt::Display for StreamSyncError { Self::InvalidResponse(reason) => { write!(f, "control-plane batch response did not match the sent rows: {reason}") } + Self::Terminal(reason) => write!(f, "terminal control-plane failure: {reason}"), Self::DidNotConverge => write!( f, "stream did not converge after {RECONCILIATION_MAX_ATTEMPTS} reconciliation attempts" @@ -122,7 +131,8 @@ pub struct StreamSyncOutcome { /// On `Conflict` or `Ambiguous`, calls `refresh_cursor` and resumes from the /// refreshed value: if it advanced, the next read naturally skips the /// already-accepted rows; if unchanged, the same rows are re-read and -/// resent. Both cases share one bounded reconciliation counter. +/// resent. Both cases share one bounded reconciliation counter. A `Terminal` +/// outcome stops immediately without calling `refresh_cursor`. pub type SyncFuture<'a, Output> = Pin + 'a>>; pub async fn sync_stream<'a, T, ReadFn, IngestFn, RefreshFn>( @@ -171,6 +181,9 @@ where batches += 1; reconciliation_attempts = 0; } + BatchAttemptOutcome::Terminal(reason) => { + return Err(StreamSyncError::Terminal(reason)); + } BatchAttemptOutcome::Conflict | BatchAttemptOutcome::Ambiguous => { reconciliation_attempts += 1; if reconciliation_attempts > RECONCILIATION_MAX_ATTEMPTS { @@ -414,6 +427,32 @@ mod tests { assert_eq!(outcome.final_cursor, 3); } + #[test] + fn terminal_failure_does_not_call_refresh() { + let local = FakeLocalRows::new(3); + let refresh_calls = RefCell::new(0usize); + let result = block_on(sync_stream( + 0, + 500, + |cursor, limit| ready(Ok(local.after(cursor, limit))), + |_cursor, _rows: &[AgentTraceMessageExportRow]| { + ready(BatchAttemptOutcome::Terminal( + "batch route is not supported".to_string(), + )) + }, + || { + *refresh_calls.borrow_mut() += 1; + ready(Ok(0)) + }, + )); + + assert!(matches!( + result, + Err(StreamSyncError::Terminal(reason)) if reason == "batch route is not supported" + )); + assert_eq!(*refresh_calls.borrow(), 0); + } + #[test] fn ambiguous_failure_with_unchanged_refresh_resends_once() { let local = FakeLocalRows::new(3); diff --git a/cli/src/services/sync/sync.rs b/cli/src/services/sync/sync.rs index 0cf23ab94..f95d1e261 100644 --- a/cli/src/services/sync/sync.rs +++ b/cli/src/services/sync/sync.rs @@ -434,10 +434,10 @@ where } /// Synchronizes one stream via the T04 engine. Genuine `409`/`5xx`/transport -/// ambiguity reconciles through a real `/state` refetch; a terminal -/// control-plane failure (missing/invalid auth, `400`, `403`) short-circuits -/// the reconciliation closure with that failure instead of issuing another -/// network call, so a `403` never mutates local state or retries. +/// ambiguity, including an undecodable successful batch body, reconciles +/// through a real `/state` refetch. A terminal control-plane failure +/// (missing/invalid auth, `400`, `403`, or a protocol mismatch such as `404`) +/// stops the stream immediately without issuing another `/state` request. #[allow(clippy::too_many_arguments)] async fn sync_one_stream<'a, T, ReadFn, IngestFn, S>( client: &'a AuthenticatedControlPlaneClient, @@ -460,7 +460,6 @@ where -> SyncFuture<'a, Result> + 'a, { - let terminal: Rc>> = Rc::new(RefCell::new(None)); let uploaded = Rc::new(RefCell::new(0usize)); let outcome = sync_stream( @@ -472,7 +471,6 @@ where Box::pin(std::future::ready(result)) }, |cursor, rows: &[T]| { - let terminal = Rc::clone(&terminal); let uploaded = Rc::clone(&uploaded); let row_count = rows.len(); let last_row_id = rows @@ -509,20 +507,13 @@ where } Err(ControlPlaneError::Conflict(_)) => BatchAttemptOutcome::Conflict, Err(error) if is_stream_terminal(&error) => { - *terminal.borrow_mut() = Some(error); - BatchAttemptOutcome::Ambiguous + BatchAttemptOutcome::Terminal(error.to_string()) } Err(_) => BatchAttemptOutcome::Ambiguous, } }) }, || { - if let Some(error) = terminal.borrow_mut().take() { - return Box::pin(std::future::ready(Err(StreamSyncError::Refresh( - error.to_string(), - )))); - } - let state_request = AgentTraceIngestionStateRequest { repository_id: repository_id.to_string(), source_instance_id: source_instance_id.to_string(),