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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
13 changes: 10 additions & 3 deletions apps/decodex-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ actor DecodexServerBridge {
process.executableURL = try decodexExecutableURL()
process.arguments = [
"serve",
"--api-only",
"--dev",
"--listen-address", defaultListenAddress,
]
process.standardOutput = nullDevice
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ struct OperatorSnapshotResponse: Decodable, Sendable {
}

var shouldDisplayInPanel: Bool {
hasVisibleSignal && (activeRunCount > 0 || isAPIOnlySnapshot == false)
hasVisibleSignal && (activeRunCount > 0 || isDevSnapshot == false)
}

var warningSummary: String? {
Expand Down Expand Up @@ -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] {
Expand Down
30 changes: 15 additions & 15 deletions apps/decodex/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
}
Expand Down Expand Up @@ -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"));
}

Expand Down
2 changes: 1 addition & 1 deletion apps/decodex/src/orchestrator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
32 changes: 16 additions & 16 deletions apps/decodex/src/orchestrator/entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
);
}

Expand All @@ -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()?;
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -485,7 +485,7 @@ fn run_control_plane_tick(
}))
}

fn run_control_plane_api_only_tick(state_store: &StateStore) -> Result<OperatorStatusSnapshot> {
fn run_control_plane_dev_tick(state_store: &StateStore) -> Result<OperatorStatusSnapshot> {
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();
Expand All @@ -497,7 +497,7 @@ fn run_control_plane_api_only_tick(state_store: &StateStore) -> Result<OperatorS
add_operator_snapshot_warning(&mut snapshot, "automation_disabled");

for registration in &registered_projects {
let mut project_status = operator_project_status_from_api_only_registration(registration);
let mut project_status = operator_project_status_from_dev_registration(registration);

if registration.enabled() {
match ServiceConfig::from_path(registration.config_path()).and_then(|project| {
Expand Down Expand Up @@ -534,7 +534,7 @@ fn run_control_plane_api_only_tick(state_store: &StateStore) -> Result<OperatorS

tracing::warn!(
project_id = registration.service_id(),
"API-only operator snapshot local run hydration failed; sensitive runtime details were withheld."
"Dev operator snapshot local run hydration failed; sensitive runtime details were withheld."
);
},
}
Expand Down Expand Up @@ -955,7 +955,7 @@ fn operator_project_status_from_registration(
}
}

fn operator_project_status_from_api_only_registration(
fn operator_project_status_from_dev_registration(
project: &ProjectRegistration,
) -> OperatorProjectStatus {
OperatorProjectStatus {
Expand All @@ -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")
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -48,14 +48,14 @@ fn control_plane_api_only_snapshot_does_not_tick_enabled_projects() {

state_store.upsert_project(&registration).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);
Expand All @@ -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"));
Expand All @@ -82,8 +82,8 @@ fn control_plane_api_only_snapshot_marks_unloadable_project_config() {

state_store.upsert_project(&registration).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);
Expand All @@ -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"));
Expand All @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion apps/decodex/src/orchestrator/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub(crate) struct ServeRequest<'a> {
pub(crate) config_path: Option<&'a Path>,
pub(crate) poll_interval: Option<Duration>,
pub(crate) listen_address: &'a str,
pub(crate) api_only: bool,
pub(crate) dev: bool,
}

/// Agent-readable runtime diagnosis request.
Expand Down
22 changes: 17 additions & 5 deletions docs/reference/operator-control-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading