Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/decodex/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 35 additions & 3 deletions apps/decodex/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,7 @@ pub(crate) fn register_project_config(
&config_fingerprint(&config_path, config.workflow_path())?,
);

state_store.upsert_project(&registration)?;

Ok(registration)
state_store.upsert_project(&registration)
}

/// Resolve the registered project config that owns a local working directory.
Expand Down Expand Up @@ -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"))
}
Expand Down
20 changes: 17 additions & 3 deletions apps/decodex/src/state/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProjectRegistration> {
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.
Expand Down
2 changes: 1 addition & 1 deletion apps/decodex/src/state/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/operator-control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project-dir>`.
- 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`.
Expand Down
5 changes: 5 additions & 0 deletions docs/runbook/self-dogfood-pilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project-dir>` or `decodex project enable
<service-id>` when the intended action is to enable a project for scheduling, and use
`decodex project disable <service-id>` 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
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<service-id>/`; 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/<service-id>/`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `<service-id>.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/<service-id>/`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `<service-id>.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 <project-dir>`, `decodex project enable <service-id>`, and `decodex project disable <service-id>` 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:

Expand Down