diff --git a/README.md b/README.md index b2ce33f6..79121a3d 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,13 @@ node dev/operator-dashboard-mock.mjs --listen-address 127.0.0.1:57399 node dev/operator-dashboard-mock.mjs --listen-address 127.0.0.1:57399 --use-codex-auth ``` +Use hidden `decodex serve --dev --listen-address 127.0.0.1:8912` only when +developing Decodex App's bundled server path or the local account/app snapshot APIs +against real runtime state. Dev mode deliberately does not register projects, poll +Linear, dispatch work, or accept `--config` or `--interval`. For real automation, +use ordinary `decodex serve --interval ...`; for dashboard-only UI work, prefer the +mock server above. + The dashboard semantics and local-vs-external state boundary live in `docs/reference/operator-control-plane.md`. diff --git a/apps/decodex-app/README.md b/apps/decodex-app/README.md index 008f73bc..769cac55 100644 --- a/apps/decodex-app/README.md +++ b/apps/decodex-app/README.md @@ -13,9 +13,10 @@ The first Decodex App release manages the shared Codex account pool through the bundled Rust app helper so account UI stays on the same CLI-owned files even when a long-running local `decodex serve` is older than the app bundle. On launch the app also connects to an existing `decodex serve` on the default local endpoint when one is -available; otherwise it starts the bundled Decodex binary in its hidden API-only -operator endpoint mode for operator snapshot and WebUI routes. App-started servers do -not poll registered projects or dispatch Linear work. The helper owns account +available; otherwise it starts the bundled Decodex binary in its hidden dev endpoint +mode as `decodex serve --dev --listen-address 127.0.0.1:8912` for operator snapshot +and WebUI routes. App-started servers do not poll registered projects or dispatch +Linear work. The helper owns account operations and interactive login flows that need streamed command output: - list accounts without printing token material @@ -71,6 +72,12 @@ DECODEX_APP_HELPER="$(pwd)/target/debug/decodex-app-helper" \ swift run --package-path apps/decodex-app DecodexApp ``` +The app-started server path is the main reason to use `decodex serve --dev` +manually: it lets you test the same local account APIs, app snapshot API, and +dashboard routes without starting the scheduler. Do not use `--dev` to validate +project registration, Linear polling, queue intake, or retained-lane execution; use +ordinary `decodex serve --interval ...` for those paths. + The staging script follows the local Rsnap-style signing path: it writes `target/decodex-app/Decodex App.app`, signs the bundle with an Apple Development identity, enables hardened runtime, and verifies the signature before launch. Override diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift index d052b86a..00c9d110 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexServerBridge.swift @@ -223,7 +223,7 @@ actor DecodexServerBridge { process.executableURL = try decodexExecutableURL() process.arguments = [ "serve", - "--api-only", + "--dev", "--listen-address", defaultListenAddress, ] process.standardOutput = nullDevice diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index cc1b055f..23e51ccc 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -54,7 +54,7 @@ struct OperatorSnapshotResponse: Decodable, Sendable { } var shouldDisplayInPanel: Bool { - hasVisibleSignal && (activeRunCount > 0 || isAPIOnlySnapshot == false) + hasVisibleSignal && (activeRunCount > 0 || isDevSnapshot == false) } var warningSummary: String? { @@ -117,9 +117,9 @@ struct OperatorSnapshotResponse: Decodable, Sendable { self.postReviewLanes = postReviewLanes } - private var isAPIOnlySnapshot: Bool { + private var isDevSnapshot: Bool { warnings.contains("automation_disabled") - && projects.allSatisfy { $0.connectorState == "api_only" } + && projects.allSatisfy { $0.connectorState == "api_only" || $0.connectorState == "dev" } } private var snapshotBuildFailureProjectIDs: [String] { diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 1a126dc3..d47f72e2 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -320,27 +320,27 @@ struct ServeCommand { /// Operator UI listen address. #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:8912")] listen_address: String, - /// Serve only local operator HTTP/API endpoints without polling or dispatching projects. + /// Start the Decodex App/dev endpoint without polling or dispatching projects. #[arg(long, hide = true)] - api_only: bool, + dev: bool, } impl ServeCommand { fn run(&self) -> crate::prelude::Result<()> { - if self.api_only && self.interval.is_some() { + if self.dev && self.interval.is_some() { eyre::bail!( - "serve --api-only does not accept --interval because API-only mode does not poll projects." + "serve --dev does not accept --interval because dev mode does not poll projects." ); } orchestrator::run_control_plane(ServeRequest { config_path: self.project_config.as_path(), - poll_interval: if self.api_only { + poll_interval: if self.dev { None } else { Some(self.interval.unwrap_or_else(|| Duration::from_secs(60))) }, listen_address: &self.listen_address, - api_only: self.api_only, + dev: self.dev, }) } } @@ -1016,35 +1016,35 @@ mod tests { project_config: ProjectConfigArgs { config: Some(config) }, interval, listen_address, - api_only, + dev, }) if interval == Some(Duration::from_secs(30)) && listen_address == "127.0.0.1:9000" - && !api_only + && !dev && config == Path::new("./project.toml") )); } #[test] - fn parses_serve_api_only() { - let cli = Cli::parse_from(["decodex", "serve", "--api-only"]); + fn parses_serve_dev() { + let cli = Cli::parse_from(["decodex", "serve", "--dev"]); assert!(matches!( cli.command, - Command::Serve(ServeCommand { interval: None, api_only: true, .. }) + Command::Serve(ServeCommand { interval: None, dev: true, .. }) )); } #[test] - fn rejects_serve_api_only_with_interval() { - let cli = Cli::parse_from(["decodex", "serve", "--api-only", "--interval", "30s"]); + fn rejects_serve_dev_with_interval() { + let cli = Cli::parse_from(["decodex", "serve", "--dev", "--interval", "30s"]); let Command::Serve(command) = cli.command else { panic!("expected serve command"); }; - let error = command.run().expect_err("api-only serve must reject interval configuration"); + let error = command.run().expect_err("dev serve must reject interval configuration"); let message = error.to_string(); - assert!(message.contains("--api-only")); + assert!(message.contains("--dev")); assert!(message.contains("--interval")); } diff --git a/apps/decodex/src/orchestrator.rs b/apps/decodex/src/orchestrator.rs index 6623c0b5..9642990a 100644 --- a/apps/decodex/src/orchestrator.rs +++ b/apps/decodex/src/orchestrator.rs @@ -86,7 +86,7 @@ const OPERATOR_DASHBOARD_WS_CLIENT_MESSAGE_MAX_BYTES: usize = 64 * 1_024; const OPERATOR_STATE_HEADER_TERMINATOR: &[u8] = b"\r\n\r\n"; const OPERATOR_DASHBOARD_WS_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(20); const OPERATOR_RUN_ACTIVITY_STREAM_INTERVAL: Duration = Duration::from_secs(1); -const OPERATOR_API_ONLY_SNAPSHOT_STREAM_INTERVAL: Duration = Duration::from_secs(1); +const OPERATOR_DEV_SNAPSHOT_STREAM_INTERVAL: Duration = Duration::from_secs(1); const PULL_REQUEST_REVIEW_STATE_QUERY: &str = r#" query($owner: String!, $name: String!, $number: Int!, $reviewThreadsAfter: String) { repository(owner: $owner, name: $name) { diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 73b5010a..a35c0331 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -102,14 +102,14 @@ pub(crate) fn run_once(request: RunOnceRequest<'_>) -> Result<()> { } pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { - if request.api_only && request.config_path.is_some() { + if request.dev && request.config_path.is_some() { eyre::bail!( - "serve --api-only does not accept --config because it must not register or poll projects." + "serve --dev does not accept --config because it must not register or poll projects." ); } - if request.api_only && request.poll_interval.is_some() { + if request.dev && request.poll_interval.is_some() { eyre::bail!( - "serve --api-only does not accept --interval because API-only mode does not poll projects." + "serve --dev does not accept --interval because dev mode does not poll projects." ); } @@ -119,7 +119,7 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { run_control_plane_maintenance("startup"); - if request.api_only { + if request.dev { let operator_state_endpoint = OperatorStateEndpoint::start(request.listen_address, Arc::clone(&state_store))?; let runtime_db_path = runtime::runtime_db_path()?; @@ -130,20 +130,20 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { listen_address = %operator_state_endpoint.listen_address(), path = OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, ws_path = OPERATOR_DASHBOARD_WS_ENDPOINT_PATH, - api_only = true, - stream_interval_s = OPERATOR_API_ONLY_SNAPSHOT_STREAM_INTERVAL.as_secs(), + dev = true, + stream_interval_s = OPERATOR_DEV_SNAPSHOT_STREAM_INTERVAL.as_secs(), runtime_db_path = %runtime_db_path.display(), global_config_path = %global_config_path.display(), project_config_dir = %project_config_dir.display(), - "Starting Decodex API-only operator endpoint." + "Starting Decodex dev operator endpoint." ); loop { let tick_started_at = Instant::now(); - let snapshot = run_control_plane_api_only_tick(&state_store)?; + let snapshot = run_control_plane_dev_tick(&state_store)?; publish_operator_snapshot(&operator_state_endpoint, &snapshot); - sleep_until_next_tick(OPERATOR_API_ONLY_SNAPSHOT_STREAM_INTERVAL, tick_started_at); + sleep_until_next_tick(OPERATOR_DEV_SNAPSHOT_STREAM_INTERVAL, tick_started_at); } } @@ -178,7 +178,7 @@ pub(crate) fn run_control_plane(request: ServeRequest<'_>) -> Result<()> { listen_address = %operator_state_endpoint.listen_address(), path = OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, ws_path = OPERATOR_DASHBOARD_WS_ENDPOINT_PATH, - api_only = false, + dev = false, runtime_db_path = %runtime_db_path.display(), global_config_path = %global_config_path.display(), project_config_dir = %project_config_dir.display(), @@ -485,7 +485,7 @@ fn run_control_plane_tick( })) } -fn run_control_plane_api_only_tick(state_store: &StateStore) -> Result { +fn run_control_plane_dev_tick(state_store: &StateStore) -> Result { let registered_projects = state_store.list_projects()?; let mut snapshot = empty_control_plane_snapshot(DEFAULT_OPERATOR_DASHBOARD_RUN_LIMIT); let mut project_statuses = Vec::new(); @@ -497,7 +497,7 @@ fn run_control_plane_api_only_tick(state_store: &StateStore) -> Result Result OperatorProjectStatus { OperatorProjectStatus { @@ -972,7 +972,7 @@ fn operator_project_status_from_api_only_registration( cleanup_blocked_count: 0, cleanup_pending_count: 0, connector_state: if project.enabled() { - String::from("api_only") + String::from("dev") } else { String::from("disabled") }, diff --git a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs index f5bdd022..bbab888f 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/control_plane.rs @@ -33,7 +33,7 @@ fn control_plane_snapshot_lists_disabled_registered_projects() { } #[test] -fn control_plane_api_only_snapshot_does_not_tick_enabled_projects() { +fn control_plane_dev_snapshot_does_not_tick_enabled_projects() { let (temp_dir, config, _workflow) = temp_project_layout(); let _home_guard = TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8")); @@ -48,14 +48,14 @@ fn control_plane_api_only_snapshot_does_not_tick_enabled_projects() { state_store.upsert_project(®istration).expect("project should register"); - let snapshot = orchestrator::run_control_plane_api_only_tick(&state_store) - .expect("api-only snapshot should build"); + let snapshot = + orchestrator::run_control_plane_dev_tick(&state_store).expect("dev snapshot should build"); let project = snapshot.projects.first().expect("enabled project should be listed"); assert_eq!(snapshot.projects.len(), 1); assert_eq!(project.project_id, "pubfi"); assert!(project.enabled); - assert_eq!(project.connector_state, "api_only"); + assert_eq!(project.connector_state, "dev"); assert_eq!(project.active_run_count, 0); assert_eq!(project.queued_candidate_count, 0); assert_eq!(project.warning_count, 1); @@ -66,7 +66,7 @@ fn control_plane_api_only_snapshot_does_not_tick_enabled_projects() { } #[test] -fn control_plane_api_only_snapshot_marks_unloadable_project_config() { +fn control_plane_dev_snapshot_marks_unloadable_project_config() { let (temp_dir, config, _workflow) = temp_project_layout(); let _home_guard = TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8")); @@ -82,8 +82,8 @@ fn control_plane_api_only_snapshot_marks_unloadable_project_config() { state_store.upsert_project(®istration).expect("project should register"); - let snapshot = orchestrator::run_control_plane_api_only_tick(&state_store) - .expect("api-only snapshot should still build"); + let snapshot = orchestrator::run_control_plane_dev_tick(&state_store) + .expect("dev snapshot should still build"); let project = snapshot.projects.first().expect("enabled project should be listed"); assert_eq!(snapshot.projects.len(), 1); @@ -97,7 +97,7 @@ fn control_plane_api_only_snapshot_marks_unloadable_project_config() { } #[test] -fn control_plane_api_only_snapshot_includes_local_active_runs() { +fn control_plane_dev_snapshot_includes_local_active_runs() { let (temp_dir, config, _workflow) = temp_project_layout(); let _home_guard = TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("home should be utf-8")); @@ -119,13 +119,13 @@ fn control_plane_api_only_snapshot_includes_local_active_runs() { .upsert_lease(config.service_id(), &issue.id, "run-active", "In Progress") .expect("active lease should record"); - let snapshot = orchestrator::run_control_plane_api_only_tick(&state_store) - .expect("api-only snapshot should build"); + let snapshot = + orchestrator::run_control_plane_dev_tick(&state_store).expect("dev snapshot should build"); let project = snapshot.projects.first().expect("enabled project should be listed"); assert_eq!(snapshot.projects.len(), 1); assert_eq!(project.project_id, "pubfi"); - assert_eq!(project.connector_state, "api_only"); + assert_eq!(project.connector_state, "dev"); assert_eq!(project.active_run_count, 1); assert_eq!(snapshot.active_runs.len(), 1); assert_eq!(snapshot.active_runs[0].run_id, "run-active"); diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 40b90fda..754ca50a 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -32,7 +32,7 @@ pub(crate) struct ServeRequest<'a> { pub(crate) config_path: Option<&'a Path>, pub(crate) poll_interval: Option, pub(crate) listen_address: &'a str, - pub(crate) api_only: bool, + pub(crate) dev: bool, } /// Agent-readable runtime diagnosis request. diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 80787b91..8645057b 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -36,10 +36,22 @@ Decodex currently runs as a local, single-machine control plane: Decodex App is a native shell over the same local runtime and account-pool state. On launch it connects to an existing default local listener when one is reachable; if not, it starts the bundled `decodex` binary as -`decodex serve --api-only --listen-address 127.0.0.1:8912`. API-only mode serves the -dashboard, account APIs, and `GET /api/operator-snapshot` for the app, but it does -not register projects, poll Linear, dispatch work, or accept `--config` or -`--interval`. Use ordinary `decodex serve --interval ...` for the automation loop. +`decodex serve --dev --listen-address 127.0.0.1:8912`. Dev mode serves the dashboard, +account APIs, and `GET /api/operator-snapshot` for the app, but it does not register +projects, poll Linear, dispatch work, or accept `--config` or `--interval`. Use +ordinary `decodex serve --interval ...` for the automation loop. + +Use `--dev` only for local development and app-owned startup: + +- Decodex App may start the bundled server with `--dev` when no compatible default + listener is already running. +- Developers may use `--dev` to exercise real account APIs, `GET /api/operator-snapshot`, + and dashboard routes against local runtime state without starting automation. +- Do not use `--dev` for operator automation, queue intake, retained-lane recovery, + project registration refresh, or service scheduling. It is hidden from CLI help and + intentionally rejects `--config` and `--interval`. +- For browser-only dashboard UI work, use `dev/operator-dashboard-mock.mjs` instead + of `--dev`. Project registration is not service intake. The `Projects` dashboard section may show multiple enabled projects with visible work at once, and its filter can reveal the full @@ -120,7 +132,7 @@ The browser dashboard reads the complete published state from the local published snapshots, active-lane activity updates, and local dashboard control acknowledgements. `GET /api/operator-snapshot` is the Decodex App read API over the same runtime database, not a browser-dashboard polling authority and not a sign that -an API-only listener owns scheduling. The current browser UI keeps live updates +the dev listener owns scheduling. The current browser UI keeps live updates unscoped and exposes explicit stop controls for active lanes with a known live child process plus account-pool selection controls; project watch, project pause/resume, and manual retry controls are intentionally not shown. diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index 9bb950f3..0cec1a5a 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -426,6 +426,10 @@ wants to observe the self-bootstrap loop without reading source code. local runtime database. Passing `--config` refreshes that project registration before the scheduler starts. + Do not use `decodex serve --dev` for this step. Dev mode is only for Decodex App + and local account/app snapshot API development; it does not register projects, + poll Linear, dispatch work, or accept `--config` or `--interval`. + Pass `decodex serve --config ` when you want `serve` to refresh one project registration before it starts. Omit it when the registry already contains the enabled projects you want the control plane to monitor. @@ -617,6 +621,10 @@ Decodex is intentionally Unix-only, and the control plane relies on Unix file-de decodex serve --interval 60s --listen-address 127.0.0.1:8912 ``` +Use hidden `decodex serve --dev` only for Decodex App or local account/app snapshot API +development. It is not a scheduler and must not be used for this runbook's automation, +queue intake, project registration, or retained-lane recovery steps. + The listener serves the operator console from the canonical `GET /` and `GET /dashboard` routes, the same JSON operator snapshot used by `cargo run -p decodex --bin decodex -- status --json` through the `/dashboard/control` WebSocket, and the minimal `GET /livez` liveness probe on the same listener. The single console keeps `Projects`, `Running Lanes`, `Intake Queue`, `Review & Landing`, `Recovery Worktrees`, and `Run Ledger` visible together. Intake candidates that are already claimed by a running lane are shown as active queue echoes, capacity-bound candidates are shown as waiting rather than blocked, running lane worktrees stay with their owning lane, and retained/recovery worktrees remain folded until diagnostics are needed: - `GET /` or `GET /dashboard`: the same single-page operator console diff --git a/plugins/decodex/skills/automation/SKILL.md b/plugins/decodex/skills/automation/SKILL.md index 4f8ac975..81981446 100644 --- a/plugins/decodex/skills/automation/SKILL.md +++ b/plugins/decodex/skills/automation/SKILL.md @@ -52,9 +52,11 @@ wants to register that project and start the scheduler in one command. Use `decodex run ` or `cargo run -p decodex --bin decodex -- run ` only for a deliberate one-issue automation pass; it still uses the same retained-lane eligibility and lifecycle rules. -Do not use hidden `serve --api-only` for automation. That mode belongs to Decodex App: -it serves local dashboard/account/app snapshot APIs, but it does not register -projects, poll Linear, or dispatch lanes. +Do not use hidden `serve --dev` for automation. That mode belongs to Decodex App and +local development: it serves local dashboard/account/app snapshot APIs, but it does +not register projects, poll Linear, or dispatch lanes, and it rejects `--config` and +`--interval`. Use it only when testing the app-owned endpoint without starting the +scheduler. ## Intake and Ownership diff --git a/plugins/decodex/skills/manual-cli/SKILL.md b/plugins/decodex/skills/manual-cli/SKILL.md index c6543b5f..da485a71 100644 --- a/plugins/decodex/skills/manual-cli/SKILL.md +++ b/plugins/decodex/skills/manual-cli/SKILL.md @@ -80,9 +80,10 @@ Manual commit and landing are separate narrow workflows: - Use `run --dry-run` before live automation to validate project loading, issue discovery, eligibility, and worktree planning without tracker mutation. - Use `probe stdio://` before relying on the Codex app-server boundary. -- Treat hidden `serve --api-only` as Decodex App infrastructure only. It serves - dashboard, account, and app snapshot APIs, but it does not register projects, poll - Linear, or dispatch work. +- Treat hidden `serve --dev` as Decodex App and local-development infrastructure + only. It serves dashboard, account, and app snapshot APIs, but it does not register + projects, poll Linear, dispatch work, or accept `--config` or `--interval`. Use it + only when testing the app-owned endpoint without starting the scheduler. - For `skills/list` app-server preflight output, enabled skills plus scan diagnostics are local evidence, not a lane blocker. Missing cwd coverage or zero enabled skills are blockers; inspect `first_error_path` and `first_error` before changing plugin or