1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
//! `tm sessions sync-assets <id>|--all` — force a re-deploy of a live managed
//! session's agents/skills/output-styles against the current catalog (issue
//! #2444).
//!
//! Why: extracted into its own file (mirroring `delete.rs`/`prune.rs`) since
//! `commands::managed` sits at the 500-SLOC production cap. `#2002`'s asset
//! deployment is one-shot at launch, so a long-lived session's deployed
//! `.claude/{agents,skills}` never re-syncs when the bundled/catalog source
//! changes underneath it — `tm sessions ls`'s `[stale-assets]` marker (see
//! `commands::managed_render::format_state_column`) flags exactly this. These two
//! functions are the fix.
//! What: [`session_sync_assets`] POSTs the per-session daemon route
//! (resolving `id_or_name` to a managed id via
//! [`super::managed_route::resolve_managed_match`] first); [`session_sync_assets_all`]
//! POSTs the fleet-wide route. Both print a one-line-per-session summary of
//! what changed.
//! Test: `cli_parses_sessions_sync_assets`, `cli_parses_sessions_sync_assets_all`
//! in `tests_behavior_d_tests.rs`; the HTTP round-trip is covered by
//! `sync_assets_route_*` in `tests/session_manager_mvp.rs`.
use serde::Deserialize;
/// Wire shape for one session's sync-assets outcome — mirrors
/// `daemon::managed_routes::sync_assets::SyncAssetsResponse` field-for-field.
#[derive(Debug, Deserialize)]
struct SyncAssetsResult {
id: String,
agents_deployed: Vec<String>,
agents_skipped: Vec<String>,
skills_deployed: Vec<String>,
skills_skipped: Vec<String>,
output_style_synced: bool,
}
/// Wire shape for the fleet-wide sync-assets response — mirrors
/// `daemon::managed_routes::sync_assets::SyncAllAssetsResponse`.
#[derive(Debug, Deserialize)]
struct SyncAllAssetsResult {
synced: Vec<SyncAssetsResult>,
skipped: Vec<String>,
errors: Vec<String>,
}
/// Render one session's sync-assets result as a terminal-friendly line.
///
/// Why: shared by both the single-session and `--all` paths so their output
/// is byte-for-byte consistent per session.
/// What: `"<id>: N agent(s), M skill(s) refreshed" [+ ", output style synced"]`,
/// or `"<id>: already up to date"` when nothing changed.
fn render_result(r: &SyncAssetsResult) {
if r.agents_deployed.is_empty() && r.skills_deployed.is_empty() {
println!("{}: already up to date", r.id);
return;
}
print!(
"{}: {} agent(s), {} skill(s) refreshed",
r.id,
r.agents_deployed.len(),
r.skills_deployed.len()
);
if !r.agents_skipped.is_empty() || !r.skills_skipped.is_empty() {
print!(
" ({} agent(s), {} skill(s) skipped — user-modified)",
r.agents_skipped.len(),
r.skills_skipped.len()
);
}
if r.output_style_synced {
print!(", output style synced");
}
println!();
}
/// `tm sessions sync-assets <id_or_name>` — re-sync ONE session's assets.
///
/// Why: the concrete fix for a single session flagged `[stale-assets]` on
/// `tm sessions ls` — re-run the same deployers launch uses, without a full
/// relaunch. `id_or_name` is resolved to a managed session id first (friendly
/// names, not just raw UUIDs, are accepted everywhere else in this CLI).
/// What: resolves `id_or_name` via [`super::managed_route::resolve_managed_match`]
/// (a `bail!` if it does not resolve to any managed session), POSTs
/// `/api/v1/sessions/managed/{id}/sync-assets`, and renders the result.
/// Test: `cli_parses_sessions_sync_assets`.
pub(crate) async fn session_sync_assets(
client: &reqwest::Client,
url: &str,
id_or_name: String,
) -> anyhow::Result<()> {
let Some(id) = super::managed_route::resolve_managed_match(client, url, &id_or_name).await
else {
anyhow::bail!("managed session '{id_or_name}' not found");
};
let resp = client
.post(format!("{url}/api/v1/sessions/managed/{id}/sync-assets"))
.send()
.await?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
anyhow::bail!("managed session '{id}' not found");
}
let result: SyncAssetsResult = resp.error_for_status()?.json().await?;
render_result(&result);
Ok(())
}
/// `tm sessions sync-assets --all` — re-sync EVERY syncable session's assets.
///
/// Why: an operator refreshing a catalog/bundled skill or agent (a `tm
/// install`, or a fresh `tm` binary) wants to push it to every live session
/// in one call.
/// What: POSTs `/api/v1/sessions/managed/sync-assets`, prints one line per
/// synced session (via [`render_result`]), a `skipped` count (sessions with
/// no live workspace — `provisioning`/`decommissioned`), and any per-session
/// errors (a single session's failure never aborts the rest of the fleet
/// report).
/// Test: `cli_parses_sessions_sync_assets_all`.
pub(crate) async fn session_sync_assets_all(
client: &reqwest::Client,
url: &str,
) -> anyhow::Result<()> {
let resp = client
.post(format!("{url}/api/v1/sessions/managed/sync-assets"))
.send()
.await?;
let result: SyncAllAssetsResult = resp.error_for_status()?.json().await?;
if result.synced.is_empty() {
println!("no syncable sessions found");
}
for r in &result.synced {
render_result(r);
}
if !result.skipped.is_empty() {
println!(
"{} session(s) skipped (provisioning/decommissioned)",
result.skipped.len()
);
}
for err in &result.errors {
eprintln!("error: {err}");
}
if !result.errors.is_empty() {
anyhow::bail!(
"{} of {} session(s) failed to sync",
result.errors.len(),
result.synced.len() + result.errors.len()
);
}
Ok(())
}
// Unit tests live in sync_assets_tests.rs (test-file budget: 1500 SLOC),
// mirroring the managed.rs / managed_tests.rs split (#2457).
#[cfg(test)]
#[path = "sync_assets_tests.rs"]
mod tests;