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
11 changes: 6 additions & 5 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13788,8 +13788,9 @@ impl App {
Ok(result) => {
self.refresh_services().await;
self.set_status(format!(
"{} saved; restart daemon to apply",
result.service.name
"{} {}",
result.service.name,
result.applied.summary()
));
}
Err(error) => self.set_status(format!("service update failed: {error}")),
Expand Down Expand Up @@ -41139,11 +41140,11 @@ mod tests {
.collect::<Vec<_>>();
let note_row = view_rows
.iter()
.position(|row| row.contains("Changes apply after the daemon restarts."))
.expect("service restart note is visible");
.position(|row| row.contains("Saved edits apply live"))
.expect("service propagation note is visible");
assert!(
note_row > 0 && view_rows[note_row - 1].trim().is_empty(),
"restart note has a spacer above it"
"propagation note has a spacer above it"
);
let footer_row = view_rows
.iter()
Expand Down
8 changes: 4 additions & 4 deletions crates/cli/src/app/service_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ impl App {
mode: ServiceDialogMode::Edit,
service,
selected_field: 1,
note: Some("Changes apply after the daemon restarts.".to_string()),
note: Some("Saved edits apply live — see each field for when.".to_string()),
confirm_delete: false,
picker: None,
picker_selected: 0,
Expand Down Expand Up @@ -328,7 +328,7 @@ impl App {
attached_to: Some(dialog.service.name.clone()),
},
selected_field: 0,
note: Some("HTTP channels listen on loopback after the daemon restarts.".to_string()),
note: Some("HTTP channels bind on loopback as soon as they are saved.".to_string()),
new_secret: None,
confirm_delete: false,
});
Expand All @@ -352,7 +352,7 @@ impl App {
service_name: dialog.service.name.clone(),
channel,
selected_field: 2,
note: Some("Channel changes apply after the daemon restarts.".to_string()),
note: Some("Channel changes bind or unbind the listener immediately.".to_string()),
new_secret: None,
confirm_delete: false,
});
Expand Down Expand Up @@ -795,7 +795,7 @@ impl App {
self.sync_active_window_selection();
}
self.set_status(format!(
"{name} deleted; restart daemon to withdraw the endpoint"
"{name} deleted; its endpoint is withdrawn"
));
}
Err(error) => {
Expand Down
18 changes: 9 additions & 9 deletions crates/cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,22 +549,22 @@ fn service_dialog_field_help(
1 => (
"Instruction",
"Prepended to every incoming message. Define the service's role, boundaries, and response behavior here.",
"Type to edit · applies after restart.",
"Type to edit · applies to new sessions.",
),
2 => (
"Harness",
"Agent harness used for new service sessions, such as smith, claude, or codex.",
"Enter opens the picker · applies after restart.",
"Enter opens the picker · applies to new sessions.",
),
3 => (
"Model",
"Optional model override passed to the harness. Leave it blank to use that harness's default model.",
"Enter opens the picker · applies after restart.",
"Enter opens the picker · applies to new sessions.",
),
4 => (
"Working directory",
"Directory where service sessions start and from which relative paths are resolved.",
"Type to edit · applies after restart.",
"Type to edit · applies to new sessions.",
),
5 => {
let explanation = match dialog.service.routing.as_str() {
Expand All @@ -581,7 +581,7 @@ fn service_dialog_field_help(
(
"Routing",
explanation,
"←/→ or Space cycles · applies after restart.",
"←/→ or Space cycles · applies to the next request.",
)
}
6 => (
Expand All @@ -591,8 +591,8 @@ fn service_dialog_field_help(
),
7 => (
"State",
"Serving starts the listener. Paused keeps the definition but does not start its listener after restart.",
"Space or ←/→ toggles · applies after restart.",
"Serving starts the listener. Pausing stops it and releases its port; callers get 503 until it resumes.",
"Space or ←/→ toggles · applies immediately.",
),
_ => ("Service field", "", ""),
}
Expand Down Expand Up @@ -8365,8 +8365,8 @@ fn render_service_channel_editor(
let (title, body, hint) = match editor.selected_field {
0 => ("Channel ID", "Stable name for this channel. It becomes part of the service's local configuration and is locked after creation.", "Type to edit when creating.") ,
1 => ("Kind", "The transport implementation. HTTP is the only built-in channel in v1; future channel plugins can add other kinds.", "Fixed to HTTP in v1."),
2 => ("HTTP port", "Loopback TCP port for this channel. No two channels on one service may share a port.", "Type a port · applies after restart."),
3 => ("State", "Disabled channels remain configured but do not accept requests after the daemon restarts.", "Space or ←/→ toggles."),
2 => ("HTTP port", "Loopback TCP port for this channel. No two channels on one service may share a port.", "Type a port · rebinds immediately."),
3 => ("State", "Disabled channels remain configured but release their port and stop accepting requests.", "Space or ←/→ toggles · applies immediately."),
_ => ("Channel", "", ""),
};
let mut help = vec![Line::from(Span::styled(title, Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD))), Line::from(""), Line::from(Span::styled(body, Style::default().fg(app.theme.text))), Line::from(""), Line::from(Span::styled(hint, Style::default().fg(app.theme.dim)))];
Expand Down
14 changes: 7 additions & 7 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4762,13 +4762,13 @@ <h2 id="serviceViewTitle"></h2>
const route = service?.routing || "session-key";
const descriptions = {
name: ["Service name", "Stable identifier used in the service URL and TOML filename.", "Locked after creation."],
instruction: ["Instruction", "Prepended to every incoming message. Define the service role, boundaries, and response behavior here.", "Applies after daemon restart."],
harness: ["Harness", "Agent harness used for new service sessions, such as smith, claude, or codex.", "Applies after daemon restart."],
model: ["Model", "Optional model override passed to the harness. Blank uses the harness default.", "Applies after daemon restart."],
cwd: ["Working directory", "Directory where service sessions start and relative paths resolve.", "Applies after daemon restart."],
routing: ["Routing", route === "per-event" ? "Creates a fresh session for every accepted request." : route === "single" ? "Routes every request into one shared session." : "Reuses one session for each caller-supplied session_key.", "Applies after daemon restart."],
instruction: ["Instruction", "Prepended to every incoming message. Define the service role, boundaries, and response behavior here.", "Applies to new sessions."],
harness: ["Harness", "Agent harness used for new service sessions, such as smith, claude, or codex.", "Applies to new sessions."],
model: ["Model", "Optional model override passed to the harness. Blank uses the harness default.", "Applies to new sessions."],
cwd: ["Working directory", "Directory where service sessions start and relative paths resolve.", "Applies to new sessions."],
routing: ["Routing", route === "per-event" ? "Creates a fresh session for every accepted request." : route === "single" ? "Routes every request into one shared session." : "Reuses one session for each caller-supplied session_key.", "Applies to the next request."],
channels: ["Channels", "Transport endpoints attached to this service. Each HTTP channel has its own loopback port, enabled state, and credential.", "Add, edit, disable, rotate, or delete channels below."],
paused: ["State", "Paused services refuse new requests while keeping their definition and history.", "Applies after daemon restart."],
paused: ["State", "Paused services stop their listeners and refuse requests, keeping the definition and history.", "Applies immediately."],
};
return descriptions[field] || descriptions.instruction;
}
Expand Down Expand Up @@ -4907,7 +4907,7 @@ <h2 id="serviceViewTitle"></h2>
await refreshSessions();
state.serviceChannelDraft = null;
renderServiceView();
serviceViewFooterEl.textContent = `Channel '${id}' ${attach ? "attached" : "detached"}; restart the daemon to apply.`;
serviceViewFooterEl.textContent = `Channel '${id}' ${attach ? "attached" : "detached"}.`;
} catch (e) {
serviceViewFooterEl.textContent = `Channel ${attach ? "attach" : "detach"} failed: ${e.message}`;
renderServiceView();
Expand Down
20 changes: 19 additions & 1 deletion crates/daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod remote_supervisor;
mod router;
mod server;
mod service;
mod service_supervisor;
mod session;
mod storage;
mod tunnel;
Expand Down Expand Up @@ -239,7 +240,24 @@ pub async fn run(socket_override: Option<PathBuf>) -> Result<()> {
// surface. They do not reuse the remote-control listener: a service's
// bearer credential is scoped to that service, while remote control
// remains protected by its owner-login boundary.
service::spawn_all(manager.clone(), service_definitions, paths.data_dir.clone());
//
// The supervisor owns every listener and is the only thing that binds one,
// so an edit — over IPC or by hand in the config dir — reaches the running
// system without a restart. It is handed the paths resolved at startup
// rather than rediscovering them, so it and the IPC handlers can never
// disagree about which directory holds the definitions.
{
let (service_handle, service_rx) = service_supervisor::channel();
manager.set_service_supervisor(service_handle.clone());
tokio::spawn(service_supervisor::run(
manager.clone(),
paths.clone(),
service_handle.clone(),
service_rx,
));
service_handle.reload_detached(service_supervisor::ReloadReason::Boot);
service_supervisor::spawn_watcher(paths.clone(), service_handle);
}
// Loop scheduler: wakes every second, fires due loops by
// calling `SessionManager::send_input`. Persisted per-session
// in `sessions/<id>/loops.json`; daemon restart picks them
Expand Down
78 changes: 71 additions & 7 deletions crates/daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,40 @@ fn parse_params<T: serde::de::DeserializeOwned>(
serde_json::from_value(v).map_err(|e| ErrorObject::invalid_params(e.to_string()))
}

/// Apply a just-persisted service edit to the running daemon and describe what
/// happened.
///
/// The write already succeeded by the time this runs, so a reload failure is
/// reported rather than raised: the operator's edit is saved either way, and
/// what they need to know is which part of it is not yet live.
async fn apply_service_edit(
manager: &Arc<SessionManager>,
method: &'static str,
) -> construct_protocol::ServiceApplyResult {
match manager
.reload_services(crate::service_supervisor::ReloadReason::Ipc(method))
.await
{
Ok(report) => construct_protocol::ServiceApplyResult {
reloaded: true,
started: report.started.len(),
stopped: report.stopped.len(),
rebound: report.rebound.len(),
failures: report
.failures
.into_iter()
.map(|((service, channel), port, error)| {
format!("{service}/{channel} on port {port}: {error}")
})
.collect(),
},
Err(error) => {
tracing::debug!(%error, "service edit persisted without a live reload");
construct_protocol::ServiceApplyResult::default()
}
}
}

async fn dispatch(
manager: &Arc<SessionManager>,
sub_cmd_tx: &mpsc::Sender<SubCmd>,
Expand Down Expand Up @@ -1240,7 +1274,11 @@ async fn dispatch(
&construct_protocol::paths::Paths::discover().services_dir(),
p,
) {
Ok(result) => ok!(req, &result),
Ok(mut result) => {
result.applied =
apply_service_edit(manager, ipc_method::SERVICE_PUT).await;
ok!(req, &result)
}
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
Expand All @@ -1250,7 +1288,12 @@ async fn dispatch(
&construct_protocol::paths::Paths::discover().services_dir(),
&p.name,
) {
Ok(()) => Response::ok(req.id.clone(), serde_json::Value::Null),
Ok(()) => {
// Deletion must still withdraw the endpoint, but the response
// stays null: clients deserialize it as unit.
let _ = apply_service_edit(manager, ipc_method::SERVICE_DELETE).await;
Response::ok(req.id.clone(), serde_json::Value::Null)
}
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
Expand Down Expand Up @@ -1278,7 +1321,11 @@ async fn dispatch(
&construct_protocol::paths::Paths::discover().services_dir(),
p,
) {
Ok(result) => ok!(req, &result),
Ok(mut result) => {
result.applied =
apply_service_edit(manager, ipc_method::SERVICE_CHANNEL_PUT).await;
ok!(req, &result)
}
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
Expand All @@ -1288,7 +1335,12 @@ async fn dispatch(
&construct_protocol::paths::Paths::discover().services_dir(),
p,
) {
Ok(()) => Response::ok(req.id.clone(), serde_json::Value::Null),
Ok(()) => {
// Deletion must still withdraw the endpoint, but the response
// stays null: clients deserialize it as unit.
let _ = apply_service_edit(manager, ipc_method::SERVICE_CHANNEL_DELETE).await;
Response::ok(req.id.clone(), serde_json::Value::Null)
}
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
Expand All @@ -1298,7 +1350,11 @@ async fn dispatch(
&construct_protocol::paths::Paths::discover().services_dir(),
p,
) {
Ok(result) => ok!(req, &result),
Ok(mut result) => {
result.applied =
apply_service_edit(manager, ipc_method::SERVICE_CHANNEL_ATTACH).await;
ok!(req, &result)
}
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
Expand All @@ -1308,7 +1364,11 @@ async fn dispatch(
&construct_protocol::paths::Paths::discover().services_dir(),
p,
) {
Ok(result) => ok!(req, &result),
Ok(mut result) => {
result.applied =
apply_service_edit(manager, ipc_method::SERVICE_CHANNEL_DETACH).await;
ok!(req, &result)
}
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
Expand All @@ -1318,7 +1378,11 @@ async fn dispatch(
&construct_protocol::paths::Paths::discover().services_dir(),
p,
) {
Ok(result) => ok!(req, &result),
Ok(mut result) => {
result.applied =
apply_service_edit(manager, ipc_method::SERVICE_CHANNEL_ROTATE_SECRET).await;
ok!(req, &result)
}
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
Expand Down
Loading
Loading