diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index ad03daec..c307dd9f 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -356,6 +356,10 @@ impl ProjectCommand { let registration = runtime::register_project_config(&state_store, &args.config, true)?; + if !registration.enabled() { + state_store.set_project_enabled(registration.service_id(), true)?; + } + println!( "registered project {} at {}", registration.service_id(), @@ -715,7 +719,7 @@ enum AccountSubcommand { #[derive(Debug, Subcommand)] enum ProjectSubcommand { - /// Register or refresh one Decodex project config. + /// Register or refresh one Decodex project config and enable it. Add(ProjectAddCommand), /// List registered local projects. List, diff --git a/apps/decodex/src/runtime.rs b/apps/decodex/src/runtime.rs index 97e4cb6d..ce87a608 100644 --- a/apps/decodex/src/runtime.rs +++ b/apps/decodex/src/runtime.rs @@ -161,9 +161,7 @@ pub(crate) fn register_project_config( &config_fingerprint(&config_path, config.workflow_path())?, ); - state_store.upsert_project(®istration)?; - - Ok(registration) + state_store.upsert_project(®istration) } /// Resolve the registered project config that owns a local working directory. @@ -354,6 +352,40 @@ mod tests { ); } + #[test] + fn project_config_refresh_preserves_disabled_state() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let _home_guard = set_test_home(temp_dir.path()); + let state_store = StateStore::open(temp_dir.path().join("runtime.sqlite3")) + .expect("state store should open"); + let repo_root = temp_dir.path().join("target-repo"); + let config_dir = + runtime::project_config_dir().expect("project config dir should resolve").join("pubfi"); + let config_path = config_dir.join("project.toml"); + + fs::create_dir_all(&repo_root).expect("repo root should exist"); + fs::create_dir_all(&config_dir).expect("project config dir should exist"); + + write_workflow(&config_dir); + write_config_body(&config_path, &repo_root); + + runtime::register_project_config(&state_store, &config_dir, true) + .expect("project config should register"); + + state_store.set_project_enabled("pubfi", false).expect("project should disable"); + + let registration = runtime::register_project_config(&state_store, &config_dir, true) + .expect("project config should refresh"); + let projects = state_store.list_projects().expect("projects should list"); + + assert!( + !registration.enabled(), + "runtime refresh should report the preserved disabled state" + ); + assert_eq!(projects.len(), 1, "refresh should keep one project row"); + assert!(!projects[0].enabled(), "stored project should remain disabled"); + } + fn set_test_home(path: &Path) -> TestEnvVarGuard { TestEnvVarGuard::set("HOME", path.to_str().expect("test home should be UTF-8")) } diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index c01d9b6b..bf7ab855 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -67,15 +67,29 @@ impl StateStore { Ok(Self::default()) } - /// Create or replace a registered project row in the local control-plane registry. - pub(crate) fn upsert_project(&self, registration: &ProjectRegistration) -> Result<()> { + /// Create or refresh a registered project row in the local control-plane registry. + /// + /// Project refreshes preserve an existing enablement toggle. Use + /// [`StateStore::set_project_enabled`] for explicit operator enable/disable changes. + pub(crate) fn upsert_project( + &self, + registration: &ProjectRegistration, + ) -> Result { let mut state = self.lock()?; + let mut registration = registration.clone(); + + if let Some(enabled) = state.projects.get(registration.service_id()).map(ProjectRegistration::enabled) + && registration.enabled() != enabled + { + registration.set_enabled(enabled); + } state .projects .insert(registration.service_id().to_owned(), registration.clone()); + self.persist_runtime_state_locked(&state)?; - self.persist_runtime_state_locked(&state) + Ok(registration) } /// List all registered projects known to this local Decodex installation. diff --git a/apps/decodex/src/state/tests.rs b/apps/decodex/src/state/tests.rs index 1cba6a38..74e297d5 100644 --- a/apps/decodex/src/state/tests.rs +++ b/apps/decodex/src/state/tests.rs @@ -1986,7 +1986,7 @@ fn state_store_open_refreshes_pubfi_project_registry_across_instances() { "pubfi", "pubfi refresh should stay scoped to the same service id" ); - assert!(project.enabled(), "pubfi refresh should replace the previously disabled row"); + assert!(!project.enabled(), "pubfi refresh should preserve the existing disabled state"); assert_eq!( project.config_fingerprint(), "def456", diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 1e89e48d..9897530a 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -24,6 +24,9 @@ Decodex currently runs as a local, single-machine control plane: - Each project directory uses fixed filenames: `project.toml` for service paths and credentials, plus `WORKFLOW.md` for execution policy. - Projects are registered explicitly with `decodex project add `. +- Registry refresh paths preserve the existing enabled or disabled toggle; use + `decodex project add`, `decodex project enable`, or `decodex project disable` for + deliberate enablement changes. - `decodex serve` does not scan `.codex` history, repo-local config files, or open worktrees to infer projects. - Each project row is scoped by `project_id` and canonical `repo_root`. diff --git a/docs/runbook/self-dogfood-pilot.md b/docs/runbook/self-dogfood-pilot.md index 8f8bd831..a46f2c83 100644 --- a/docs/runbook/self-dogfood-pilot.md +++ b/docs/runbook/self-dogfood-pilot.md @@ -70,6 +70,11 @@ decodex project list `.codex` history, repo-local config files, or currently open worktrees to infer projects. +Commands that refresh a project config keep the current enabled or disabled registry +toggle. Use `decodex project add ` or `decodex project enable +` when the intended action is to enable a project for scheduling, and use +`decodex project disable ` before a protected pause. + If the project uses managed ChatGPT accounts, enable `[codex.accounts]` in `project.toml` and keep the JSONL pool at `~/.codex/decodex/accounts.jsonl`. Do not store the shared pool under a project directory or configure a project-local account diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 89425651..2acbe4e0 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -303,7 +303,7 @@ and idempotency fields are defined by The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, global operator config lives at `~/.codex/decodex/config.toml`, bounded local account usage estimates live at `~/.codex/decodex/account-usage-history.jsonl`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. Global operator config owns account-pool routing and shared account display-name offsets. Account usage history owns local seven-day display estimates only; it does not contain token material and does not decide scheduling. UI-only preferences such as theme, table sorting, and local privacy visibility are not runtime state. -Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. +Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. Project config refreshes preserve an existing enabled or disabled registry toggle; only explicit operator commands such as `decodex project add `, `decodex project enable `, and `decodex project disable ` may change that toggle. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. The runtime database stores at least: