diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index ac1ba011..0ef66081 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -5131,7 +5131,11 @@ async fn run_with_socket_initial_selection( let sessions = client.list().await.unwrap_or_default(); let groups = client.list_projects().await.unwrap_or_default(); let mut services = client.list_services().await.unwrap_or_default(); - services.sort_by(|a, b| a.name.cmp(&b.name)); + services.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then_with(|| a.name.cmp(&b.name)) + }); let mut service_channel_catalog = client .list_service_channel_catalog() .await @@ -8120,11 +8124,16 @@ impl App { let mut out: Vec = Vec::new(); // Services are ordinary top-level list rows rather than a separate - // sidebar section. Keep their order stable even if a notification - // arrives with an unsorted service vector. Their routed sessions are - // inserted below each row after the session-tree indexes exist. + // sidebar section. Their persisted positions order the section; name + // keeps legacy equal-position definitions deterministic until moved. + // Routed sessions are inserted below each service row after the + // session-tree indexes exist. let mut services = self.services.clone(); - services.sort_by(|a, b| a.name.cmp(&b.name)); + services.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then_with(|| a.name.cmp(&b.name)) + }); let orch_id = self.orchestrator_id.as_deref(); let mut subagents_by_parent: HashMap<&str, Vec<&SessionSummary>> = HashMap::new(); @@ -8975,7 +8984,10 @@ impl App { self.set_status(format!("move failed: {e}")); } } - Selection::Service(_) => {} + Selection::Service(name) => match self.client.move_service(&name, dir).await { + Ok(()) => self.refresh_services().await, + Err(e) => self.set_status(format!("move failed: {e}")), + }, Selection::None => self.set_status("nothing selected".into()), // The "N archived" disclosure row isn't reorderable. Selection::ArchivedRow(_) => {} @@ -41590,6 +41602,7 @@ mod tests { fn service_summary_for_test(name: &str) -> construct_protocol::ServiceSummary { construct_protocol::ServiceSummary { name: name.to_string(), + position: 0, instruction: "Answer briefly.".to_string(), harness: "smith".to_string(), model: Some("test-model".to_string()), @@ -41643,6 +41656,27 @@ mod tests { server.abort(); } + #[tokio::test] + async fn services_render_in_persisted_position_order() { + let (mut app, _dir, server) = test_app_with_lineage().await; + let mut later = service_summary_for_test("alpha"); + later.position = 2; + let mut earlier = service_summary_for_test("zulu"); + earlier.position = 1; + app.services = vec![later, earlier]; + + let items = app.list_items(); + assert!(matches!( + &items[0], + ListItem::Service { summary, .. } if summary.name == "zulu" + )); + assert!(matches!( + &items[1], + ListItem::Service { summary, .. } if summary.name == "alpha" + )); + server.abort(); + } + #[tokio::test] async fn service_view_title_bar_includes_the_service_glyph() { let (mut app, _dir, server) = captured_app().await; diff --git a/crates/cli/src/app/service_dialog.rs b/crates/cli/src/app/service_dialog.rs index 604614d5..369086ad 100644 --- a/crates/cli/src/app/service_dialog.rs +++ b/crates/cli/src/app/service_dialog.rs @@ -473,6 +473,13 @@ fn default_service(app: &App, suggested: String) -> ServiceSummary { let selected = app.selected_session(); ServiceSummary { name: suggested, + position: app + .services + .iter() + .map(|service| service.position) + .max() + .map(|position| position.saturating_add(1)) + .unwrap_or_default(), instruction: String::new(), harness: selected .map(|session| session.harness.clone()) @@ -502,7 +509,11 @@ impl App { pub async fn refresh_services(&mut self) { match self.client.list_services().await { Ok(mut services) => { - services.sort_by(|a, b| a.name.cmp(&b.name)); + services.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then_with(|| a.name.cmp(&b.name)) + }); self.services = services; } Err(error) => self.set_status(format!("services refresh failed: {error}")), diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 1fc5fb58..40ade952 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -491,6 +491,16 @@ impl Client { ) .await } + pub async fn move_service(&self, name: &str, direction: MoveDirection) -> Result<()> { + self.request( + ipc_method::SERVICE_MOVE, + &construct_protocol::ServiceMoveParams { + name: name.to_string(), + direction, + }, + ) + .await + } pub async fn list_service_channels( &self, service_name: &str, diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index a0518158..32984d81 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -7099,7 +7099,10 @@

const service = { ...state.serviceDraft, model: state.serviceDraft.model || null }; try { const result = await rpc("service.put", { service }); - state.services = state.services.filter((item) => item.name !== service.name).concat(result.service).sort((a, b) => a.name.localeCompare(b.name)); + state.services = state.services + .filter((item) => item.name !== service.name) + .concat(result.service) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name)); if (previousName && previousName !== result.service.name) { renameServiceInLayout(previousName, result.service.name); } @@ -7317,7 +7320,7 @@

.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); const services = state.services .slice() - .sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))); + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || String(a.name || "").localeCompare(String(b.name || ""))); const items = []; if (operator) items.push({ kind: "session", session: operator, isOperator: true, depth: 0 }); for (const service of services) items.push({ kind: "service", service, depth: 0 }); @@ -7541,11 +7544,10 @@

// // Rows reorder by direct manipulation: grab with the mouse, or // press-and-hold on touch (so plain swipes keep scrolling the list), -// then drop on the target position. The daemon's reorder primitive is -// single-step `session.move` — which enforces region boundaries -// (pinned block, fork sibling runs, group membership) — so a drop -// replays one step per row crossed and stops early at a region edge, -// exactly like tapping the old ↑/↓ buttons repeatedly. +// then drop on the target position. The daemon's reorder primitives are +// single-step moves within each row kind, so a drop replays one step per row +// crossed and respects the same session, service, and project boundaries as +// the keyboard commands. const listDrag = { armed: false, // pointer is down on a draggable row active: false, // drag visuals engaged @@ -7556,6 +7558,7 @@

holdTimer: null, // touch press-and-hold timer sourceId: null, sourceGroupId: null, + sourceServiceName: null, targetEl: null, dropAfter: false, suppressClick: false, // swallow the click that follows a drag @@ -7638,6 +7641,7 @@

const steps = key === "ArrowDown" ? 1 : -1; if (sessionId) moveSessionSteps(sessionId, steps); else if (groupId) moveGroupSteps(groupId, steps); + else if (serviceName) moveServiceSteps(serviceName, steps); else return; ev.preventDefault(); return; @@ -7697,6 +7701,9 @@

if (listDrag.sourceGroupId) { return sessionListEl.querySelector(`.group-header[data-group-id="${CSS.escape(listDrag.sourceGroupId)}"]`); } + if (listDrag.sourceServiceName) { + return sessionListEl.querySelector(`.item[data-service-name="${CSS.escape(listDrag.sourceServiceName)}"]`); + } return null; } @@ -7726,9 +7733,16 @@

listDragClearIndicator(); const under = document.elementFromPoint(x, y); const isGroup = !!listDrag.sourceGroupId; - const row = under && under.closest ? under.closest(isGroup ? ".session-list .group-header[data-group-id]" : ".session-list .item[data-id]") : null; + const isService = !!listDrag.sourceServiceName; + const selector = isGroup + ? ".session-list .group-header[data-group-id]" + : isService + ? ".session-list .item[data-service-name]" + : ".session-list .item[data-id]"; + const row = under && under.closest ? under.closest(selector) : null; if (!row || !sessionListEl.contains(row)) return; if (isGroup && row.dataset.groupId === listDrag.sourceGroupId) return; + if (isService && row.dataset.serviceName === listDrag.sourceServiceName) return; if (!isGroup && (row.dataset.id === listDrag.sourceId || row.classList.contains("is-operator"))) return; const r = row.getBoundingClientRect(); listDrag.dropAfter = y > r.top + r.height / 2; @@ -7765,12 +7779,27 @@

} } +async function moveServiceSteps(serviceName, steps) { + const direction = steps > 0 ? "down" : "up"; + for (let i = 0; i < Math.abs(steps); i += 1) { + try { + await rpc("service.move", { name: serviceName, direction }); + } catch (e) { + appendError(`reorder failed: ${e.message}`); + break; + } + } + await refreshSessions(); + refocusListRow(serviceName, true); +} + function listDragFinish(commit) { clearTimeout(listDrag.holdTimer); listDrag.holdTimer = null; const wasActive = listDrag.active; const sourceId = listDrag.sourceId; const sourceGroupId = listDrag.sourceGroupId; + const sourceServiceName = listDrag.sourceServiceName; const targetEl = listDrag.targetEl; const dropAfter = listDrag.dropAfter; listDrag.armed = false; @@ -7778,24 +7807,33 @@

listDrag.pointerId = null; listDrag.sourceId = null; listDrag.sourceGroupId = null; + listDrag.sourceServiceName = null; sessionListEl.classList.remove("drag-active"); const src = sourceId ? sessionListEl.querySelector(`.item[data-id="${CSS.escape(sourceId)}"]`) : sourceGroupId ? sessionListEl.querySelector(`.group-header[data-group-id="${CSS.escape(sourceGroupId)}"]`) - : null; + : sourceServiceName + ? sessionListEl.querySelector(`.item[data-service-name="${CSS.escape(sourceServiceName)}"]`) + : null; if (src) src.classList.remove("drag-source"); listDragClearIndicator(); if (!wasActive) return; listDrag.suppressClick = true; - if (commit && (sourceId || sourceGroupId) && targetEl) { + if (commit && (sourceId || sourceGroupId || sourceServiceName) && targetEl) { const isGroup = !!sourceGroupId; + const isService = !!sourceServiceName; + const selector = isGroup + ? ".group-header[data-group-id]" + : isService + ? ".item[data-service-name]" + : ".item[data-id]:not(.is-operator)"; const ids = Array.from( - sessionListEl.querySelectorAll(isGroup ? ".group-header[data-group-id]" : ".item[data-id]:not(.is-operator)") - ).map((el) => isGroup ? el.dataset.groupId : el.dataset.id); - const idToMove = isGroup ? sourceGroupId : sourceId; + sessionListEl.querySelectorAll(selector) + ).map((el) => isGroup ? el.dataset.groupId : isService ? el.dataset.serviceName : el.dataset.id); + const idToMove = isGroup ? sourceGroupId : isService ? sourceServiceName : sourceId; const from = ids.indexOf(idToMove); - const targetId = isGroup ? targetEl.dataset.groupId : targetEl.dataset.id; + const targetId = isGroup ? targetEl.dataset.groupId : isService ? targetEl.dataset.serviceName : targetEl.dataset.id; const slot = ids.indexOf(targetId) + (dropAfter ? 1 : 0); if (from >= 0 && slot >= 0) { // Removing the row from its old index shifts every later slot @@ -7805,6 +7843,8 @@

if (steps !== 0) { if (isGroup) { moveGroupSteps(idToMove, steps); + } else if (isService) { + moveServiceSteps(idToMove, steps); } else { moveSessionSteps(idToMove, steps); } @@ -7822,7 +7862,7 @@

sessionListEl.__dragBound = true; sessionListEl.addEventListener("pointerdown", (ev) => { if (ev.button !== 0 || listDrag.armed) return; - const row = ev.target.closest(".item[data-id], .group-header[data-group-id]"); + const row = ev.target.closest(".item[data-id], .item[data-service-name], .group-header[data-group-id]"); if (!row || row.classList.contains("is-operator")) return; if (ev.target.closest(".disclosure.can-toggle")) return; listDrag.armed = true; @@ -7832,6 +7872,8 @@

listDrag.startY = ev.clientY; if (row.classList.contains("group-header")) { listDrag.sourceGroupId = row.dataset.groupId; + } else if (row.dataset.serviceName) { + listDrag.sourceServiceName = row.dataset.serviceName; } else { listDrag.sourceId = row.dataset.id; } diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 9c6cb815..2621710a 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -1460,6 +1460,17 @@ pub(crate) async fn dispatch( Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())), } }); + dispatch_entry!(ipc_method::SERVICE_MOVE, { + let p = params!(req, construct_protocol::ServiceMoveParams); + match crate::service::move_definition( + &construct_protocol::paths::Paths::discover().services_dir(), + &p.name, + p.direction, + ) { + Ok(()) => Response::ok(req.id.clone(), serde_json::Value::Null), + Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())), + } + }); dispatch_entry!(ipc_method::SERVICE_CHANNEL_LIST, { let p = params!(req, construct_protocol::ServiceNameParams); match crate::service::list_channel_summaries( diff --git a/crates/daemon/src/service.rs b/crates/daemon/src/service.rs index 61b1de9b..17b520b1 100644 --- a/crates/daemon/src/service.rs +++ b/crates/daemon/src/service.rs @@ -25,6 +25,10 @@ use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceConfig { + /// Stable position among service rows. This is display metadata only; + /// service runtime behavior does not depend on it. + #[serde(default)] + pub position: u64, #[serde(default)] pub instruction: String, #[serde(default = "default_service_harness")] @@ -298,10 +302,16 @@ pub fn load_definitions(dir: &std::path::Path) -> Result Result> { - Ok(load_definitions(dir)? + let mut services: Vec<_> = load_definitions(dir)? .into_iter() .map(|(name, config)| summary(name, &config)) - .collect()) + .collect(); + services.sort_by(|a, b| { + a.position + .cmp(&b.position) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(services) } pub fn put_definition( @@ -333,7 +343,17 @@ pub fn put_definition( }; let session_mode = parse_service_session_mode(¶ms.service.harness, ¶ms.service.session_mode)?; + let position = match existing.as_ref() { + Some(config) => config.position, + None => load_definitions(dir)? + .values() + .map(|config| config.position) + .max() + .map(|position| position.saturating_add(1)) + .unwrap_or_default(), + }; let config = ServiceConfig { + position, instruction: params.service.instruction, harness: params.service.harness, model: params.service.model, @@ -355,6 +375,46 @@ pub fn put_definition( }) } +/// Move one service past its adjacent service row. Legacy definitions may all +/// have position zero, so every successful move first materializes the full +/// deterministic `(position, name)` order as contiguous persisted positions. +pub fn move_definition( + dir: &std::path::Path, + name: &str, + direction: construct_protocol::MoveDirection, +) -> Result<()> { + let mut services = load_definitions(dir)?; + let mut ordered: Vec = services.keys().cloned().collect(); + ordered.sort_by(|a, b| { + services[a] + .position + .cmp(&services[b].position) + .then_with(|| a.cmp(b)) + }); + let index = ordered + .iter() + .position(|candidate| candidate == name) + .ok_or_else(|| anyhow!("service not found: {name}"))?; + let neighbor = match direction { + construct_protocol::MoveDirection::Up if index > 0 => index - 1, + construct_protocol::MoveDirection::Down if index + 1 < ordered.len() => index + 1, + _ => return Ok(()), + }; + ordered.swap(index, neighbor); + + for (position, service_name) in ordered.into_iter().enumerate() { + let config = services + .get_mut(&service_name) + .ok_or_else(|| anyhow!("service disappeared while reordering: {service_name}"))?; + let position = position as u64; + if config.position != position { + config.position = position; + write_definition(dir, &service_name, config)?; + } + } + Ok(()) +} + fn parse_service_session_mode(harness: &str, mode: &str) -> Result { match mode { "headless" => Ok(ServiceSessionMode::Headless), @@ -920,6 +980,7 @@ fn summary(name: String, config: &ServiceConfig) -> construct_protocol::ServiceS let service_name = name.clone(); construct_protocol::ServiceSummary { name, + position: config.position, instruction: config.instruction.clone(), harness: config.harness.clone(), model: config.model.clone(), @@ -1093,6 +1154,7 @@ mod tests { fn config_with_channel(token: &str, enabled: bool, paused: bool) -> ServiceConfig { ServiceConfig { + position: 0, instruction: String::new(), harness: "smith".into(), model: None, @@ -1389,6 +1451,7 @@ mod tests { fn sandbox_limits_survive_an_unrelated_edit() { let dir = tempfile::tempdir().unwrap(); let mut config = ServiceConfig { + position: 0, instruction: "hi".into(), harness: "smith".into(), model: None, @@ -1410,6 +1473,7 @@ mod tests { construct_protocol::ServicePutParams { service: construct_protocol::ServiceSummary { name: "svc".into(), + position: 0, instruction: "changed".into(), harness: "smith".into(), model: None, @@ -1580,6 +1644,48 @@ mod tests { assert_eq!(services["alerts"].channels["http"].port, Some(8787)); } + #[test] + fn service_reorder_materializes_and_preserves_positions() { + let dir = tempfile::tempdir().unwrap(); + for name in ["alpha", "bravo", "charlie"] { + std::fs::write( + dir.path().join(format!("{name}.toml")), + "harness = \"smith\"\n", + ) + .unwrap(); + } + + let names = || { + list_summaries(dir.path()) + .unwrap() + .into_iter() + .map(|service| service.name) + .collect::>() + }; + assert_eq!(names(), ["alpha", "bravo", "charlie"]); + + move_definition(dir.path(), "charlie", construct_protocol::MoveDirection::Up).unwrap(); + assert_eq!(names(), ["alpha", "charlie", "bravo"]); + assert_eq!( + list_summaries(dir.path()) + .unwrap() + .iter() + .map(|service| service.position) + .collect::>(), + [0, 1, 2] + ); + + let mut edited = list_summaries(dir.path()).unwrap()[1].clone(); + edited.instruction = "changed".into(); + edited.position = 99; + put_definition( + dir.path(), + construct_protocol::ServicePutParams { service: edited }, + ) + .unwrap(); + assert_eq!(names(), ["alpha", "charlie", "bravo"]); + } + #[test] fn a_slack_channel_chooses_its_progress_affordance() { let dir = tempfile::tempdir().unwrap(); @@ -1629,6 +1735,7 @@ mod tests { std::fs::create_dir_all(&services).unwrap(); let service = construct_protocol::ServiceSummary { name: "alerts".into(), + position: 0, instruction: "triage".into(), harness: "smith".into(), model: None, @@ -1717,6 +1824,7 @@ mod tests { construct_protocol::ServicePutParams { service: construct_protocol::ServiceSummary { name: "alerts".into(), + position: 0, instruction: String::new(), harness: "smith".into(), model: None, @@ -1788,6 +1896,7 @@ mod tests { construct_protocol::ServicePutParams { service: construct_protocol::ServiceSummary { name: "backup".into(), + position: 0, instruction: String::new(), harness: "smith".into(), model: None, @@ -1856,6 +1965,7 @@ mod tests { construct_protocol::ServicePutParams { service: construct_protocol::ServiceSummary { name: name.into(), + position: 0, instruction: String::new(), harness: "smith".into(), model: None, @@ -1944,6 +2054,7 @@ mod tests { construct_protocol::ServicePutParams { service: construct_protocol::ServiceSummary { name: "alerts".into(), + position: 0, instruction: String::new(), harness: "smith".into(), model: None, @@ -2203,6 +2314,7 @@ mod tests { construct_protocol::ServicePutParams { service: construct_protocol::ServiceSummary { name: "chat".into(), + position: 0, instruction: String::new(), harness: "smith".into(), model: None, diff --git a/crates/daemon/src/service/ingress.rs b/crates/daemon/src/service/ingress.rs index c820b925..1757eca4 100644 --- a/crates/daemon/src/service/ingress.rs +++ b/crates/daemon/src/service/ingress.rs @@ -1094,6 +1094,7 @@ pub(super) mod tests { ServiceIngressShared::load( "svc".to_string(), ServiceConfig { + position: 0, instruction: String::new(), harness: "codex".into(), model: None, @@ -1595,6 +1596,7 @@ pub(super) mod tests { #[test] fn interactive_service_mcp_profile_is_least_privilege_unless_granted() { let mut config = ServiceConfig { + position: 0, instruction: String::new(), harness: "codex".into(), model: None, diff --git a/crates/daemon/src/service_supervisor.rs b/crates/daemon/src/service_supervisor.rs index 8139827c..56921a47 100644 --- a/crates/daemon/src/service_supervisor.rs +++ b/crates/daemon/src/service_supervisor.rs @@ -740,6 +740,7 @@ mod tests { fn service(channels: &[(&str, u16, bool)], paused: bool) -> ServiceConfig { ServiceConfig { + position: 0, instruction: String::new(), harness: "smith".into(), model: None, @@ -797,6 +798,7 @@ mod tests { #[test] fn slack_channels_are_outbound_tasks_and_credential_edits_change_revision() { let slack_service = |app_token: &str, paused: bool| ServiceConfig { + position: 0, instruction: String::new(), harness: "smith".into(), model: None, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 53df01dd..b6fd4a53 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1265,6 +1265,7 @@ pub mod ipc_method { pub const SERVICE_LIST: &str = "service.list"; pub const SERVICE_PUT: &str = "service.put"; pub const SERVICE_DELETE: &str = "service.delete"; + pub const SERVICE_MOVE: &str = "service.move"; pub const SERVICE_REPLY: &str = "service.reply"; pub const SERVICE_CHANNEL_LIST: &str = "service.channel.list"; pub const SERVICE_CHANNEL_CATALOG_LIST: &str = "service.channel.catalog.list"; @@ -3443,6 +3444,11 @@ pub struct CreateSessionParams { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceSummary { pub name: String, + /// Stable position among service rows in the unified session list. + /// Legacy definitions default to zero and use their name as a tie-breaker + /// until the first reorder materializes explicit positions. + #[serde(default)] + pub position: u64, pub instruction: String, pub harness: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -3907,6 +3913,12 @@ pub struct ServiceNameParams { pub name: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceMoveParams { + pub name: String, + pub direction: MoveDirection, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceChannelPutParams { pub service_name: String, @@ -5435,6 +5447,7 @@ mod service_protocol_tests { .expect("legacy service summary"); assert_eq!(summary.session_mode, "headless"); + assert_eq!(summary.position, 0); } #[test] diff --git a/specs/0195-service-rows-have-persistent-order.md b/specs/0195-service-rows-have-persistent-order.md new file mode 100644 index 00000000..6006b70e --- /dev/null +++ b/specs/0195-service-rows-have-persistent-order.md @@ -0,0 +1,38 @@ +# 0195-service-rows-have-persistent-order + +Status: accepted +Date: 2026-08-05 +Area: ux +Scope: Ordering and reordering of service rows in the unified session list. + +## Decision + +Services remain a distinct top-level region above ordinary sessions and +projects, but service rows have a user-controlled persistent order within that +region. Every first-party session-list client must expose the same reorder +action it exposes for project rows. + +Definitions without an explicit service position use service name as a stable +tie-breaker. The first reorder materializes explicit positions so the chosen +order survives client and daemon restarts. + +## Reason + +Services are ordinary selectable rows in the unified list. Fixing them in +alphabetical order while adjacent session and project rows can be organized +makes the same reorder command silently fail based only on row type. + +## Consequences + +- Reordering a service never moves it into the session or project regions. +- Service edits preserve its existing position. +- New services append to the existing service region. +- Terminal and web clients must render the daemon's persisted service order. +- Legacy service definitions remain valid and deterministic before any + reorder occurs. + +## Non-Goals + +- Interleaving services with sessions or projects. +- Reordering sessions routed beneath an expanded service row through the + service reorder action.