Skip to content

Commit e1780fd

Browse files
LutzLangeclaude
andcommitted
feat: report per-program configuration status to the controller
Each apply reports every managed program's outcome with a detail: applied, unchanged, removed, conflict, inactive (the program uses the gateway but the local proxy is not running), blocked (not applied because another program conflicted) or failed. The daemon logs one line per program that was not applied and sends the list with its ConfigStatus (additive proto fields `programs`, `programs_reported`). The controller stores the latest list per device (migration 0003, `device_program_status`), returns it in `GET /api/v1/devices/{id}`, and the device page shows a "Managed programs" card. Upgrade the controller first (migration 0003 is one-way). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6bcbcb6 commit e1780fd

30 files changed

Lines changed: 2205 additions & 66 deletions

File tree

‎README.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,43 @@ stage.
587587

588588
![Agentdesktop controller device inventory](images/controller-ui.png)
589589

590+
### Configuration status
591+
592+
Each apply reports the outcome for every managed program, next to the
593+
device-wide state. The device page in the controller lists them under
594+
"Managed programs" (`GET /api/v1/devices/{id}` returns them as `programs`):
595+
596+
| State | Meaning |
597+
| --- | --- |
598+
| `applied` | The program is configured and its files were changed. |
599+
| `unchanged` | The program is configured and nothing needed to change. |
600+
| `removed` | The program is no longer configured and its managed content was removed. |
601+
| `conflict` | A managed file holds configuration the daemon will not overwrite; the detail names the file. |
602+
| `inactive` | The program uses the LLM gateway but the local LLM proxy is not running (see `llmProxy.error` in daemon-info), so its files point nowhere and were removed. |
603+
| `blocked` | The program had changes, but none were written because another program conflicted or failed; the detail names that program. |
604+
| `failed` | Planning or writing this program failed; the detail carries the error. |
605+
606+
When several apply, the first in the list `failed`, `conflict`, `blocked`,
607+
`inactive`, `applied`/`removed`, `unchanged` is shown. An apply is all or
608+
nothing across programs: one conflict or failure means no file is written for
609+
any program, which is what `blocked` makes visible. Programs that are not
610+
configured and have nothing to clean up, and discovery-only tools, are not
611+
listed. After each apply the daemon logs one `program configuration outcome`
612+
line (`program`, `state`, `detail`) per program that is not `applied` or
613+
`unchanged`. A daemon restart reports again, because the controller re-sends
614+
the configuration on every connection; the files are already written by the
615+
startup apply of the saved configuration at that point, so the report after a
616+
restart usually says `unchanged` (the startup apply's outcome lines for programs
617+
that are not `applied` or `unchanged` are in the daemon log). If the controller
618+
rejects a report (an agent outside the reporting limits), it keeps the device
619+
status and the last accepted program rows; rows from an earlier revision show
620+
that revision on the device page. Agents and controllers can be upgraded
621+
in either order: an older agent shows as "Per-program status is not reported
622+
by this agent version", and an older controller ignores the new fields. The
623+
controller's database migration is one-way: once this controller has run, an
624+
older controller refuses the same database, so roll back from a database
625+
backup taken before the upgrade.
626+
590627
## Core capabilities
591628

592629
- **AI tool discovery:** detect supported developer tools and their versions

‎crates/agent/src/daemon.rs‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,9 @@ where
430430
.context("preview daemon configuration")?;
431431
} else {
432432
validate_one_shot(&config)?;
433-
reconciler
434-
.apply(&config)
435-
.context("apply daemon configuration")?;
433+
let (report, applied) = reconciler.apply_with_report(&config);
434+
report.log();
435+
applied.context("apply daemon configuration")?;
436436
println!("Reconciliation complete.");
437437
}
438438
return Ok(());
@@ -480,9 +480,10 @@ where
480480
Some(local_config)
481481
};
482482
if let Some(initial_config) = initial_config {
483-
reconciler
484-
.apply(&initial_config)
485-
.context("apply initial daemon configuration")?;
483+
// The outcomes are logged before a failure stops the daemon.
484+
let (report, applied) = reconciler.apply_with_report(&initial_config);
485+
report.log();
486+
applied.context("apply initial daemon configuration")?;
486487
// With a controller, the cached controller configuration is what is in effect
487488
// from here, and start_gateway_authentication above only saw the local file.
488489
// Without one the two are the same configuration and it already ran.
@@ -607,7 +608,7 @@ fn attach_llm_proxy_pairing(
607608
/// failure is logged and reported as `bound: false` rather than propagated:
608609
/// the proxy is optional, the rest of the daemon is not.
609610
///
610-
/// Invariant for callers: this runs before the initial `reconciler.apply`, so a
611+
/// Invariant for callers: this runs before the initial apply, so a
611612
/// reconciler that writes the proxy address into a client file must take the
612613
/// address from this result and must not write it when `bound` is false. The
613614
/// accept loop starts later, after discovery; connections in between queue in

‎crates/agent/src/llm_proxy.rs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3411,13 +3411,16 @@ mod tests {
34113411
let key = format!("vscode-copilot\u{0}http://{address}/\u{0}agentgateway");
34123412
// Inserted directly (bypassing `insert`'s expiry-margin floor,
34133413
// which would otherwise refuse to cache anything this close to
3414-
// expiry) so the tunnel has time to open before the deadline.
3414+
// expiry). The cache reuses the entry for a minute, so the
3415+
// request always gets it however slowly it arrives; the
3416+
// credential itself expires in 300 ms, and that is the deadline
3417+
// the tunnel must close at.
34153418
cache.entries.lock().unwrap().insert(
34163419
key,
34173420
CachedCredential {
34183421
credential: "short-lived".to_owned(),
34193422
device_id: "device-test".to_owned(),
3420-
valid_until: std::time::Instant::now() + Duration::from_millis(300),
3423+
valid_until: std::time::Instant::now() + Duration::from_secs(60),
34213424
valid_until_unix: now_unix() + 60,
34223425
expires: std::time::Instant::now() + Duration::from_millis(300),
34233426
expires_unix: now_unix() + 60,

‎crates/agent/src/provider/claude_code/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ impl ClaudeCode {
7070

7171
#[async_trait::async_trait]
7272
impl Provider for ClaudeCode {
73+
fn id(&self) -> &'static str {
74+
Self::ID
75+
}
76+
7377
async fn discover(&self) -> Discovery {
7478
Discovery {
7579
agents: discovery::discover(&self.settings_path)

‎crates/agent/src/provider/claude_desktop/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ impl ClaudeDesktop {
3131

3232
#[async_trait::async_trait]
3333
impl Provider for ClaudeDesktop {
34+
fn id(&self) -> &'static str {
35+
Self::ID
36+
}
37+
3438
async fn discover(&self) -> Discovery {
3539
Discovery {
3640
agents: discovery::discover(&self.managed_settings_path)

‎crates/agent/src/provider/codex/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ impl Codex {
2929

3030
#[async_trait::async_trait]
3131
impl Provider for Codex {
32+
fn id(&self) -> &'static str {
33+
Self::ID
34+
}
35+
3236
async fn discover(&self) -> Discovery {
3337
Discovery {
3438
agents: discovery::discover().into_iter().collect(),

‎crates/agent/src/provider/copilot/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ pub fn default_copilot_providers_path() -> anyhow::Result<PathBuf> {
4040

4141
#[async_trait::async_trait]
4242
impl Provider for Copilot {
43+
fn id(&self) -> &'static str {
44+
Self::ID
45+
}
46+
4347
async fn discover(&self) -> Discovery {
4448
Discovery {
4549
agents: discovery::discover().into_iter().collect(),

‎crates/agent/src/provider/copilot/reconcile.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ pub(super) fn plan(
112112
path = %path.display(),
113113
"programs.copilot is configured but the local LLM proxy is not available, so the Copilot CLI is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed Copilot CLI providers"
114114
);
115+
plan.inactive(crate::reconcile::PROXY_ABSENT_REASON);
115116
remove(path, &state_path, None, plan)?;
116117
return Ok(());
117118
};

‎crates/agent/src/provider/cursor/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ impl Cursor {
1313

1414
#[async_trait::async_trait]
1515
impl Provider for Cursor {
16+
fn id(&self) -> &'static str {
17+
Self::ID
18+
}
19+
1620
async fn discover(&self) -> Discovery {
1721
Discovery {
1822
agents: discovery::discover().into_iter().collect(),

‎crates/agent/src/provider/grok/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ impl Grok {
2929

3030
#[async_trait::async_trait]
3131
impl Provider for Grok {
32+
fn id(&self) -> &'static str {
33+
Self::ID
34+
}
35+
3236
async fn discover(&self) -> Discovery {
3337
Discovery {
3438
agents: discovery::discover().into_iter().collect(),

0 commit comments

Comments
 (0)