Skip to content

Commit 0708d42

Browse files
committed
fix(resync): close review lifecycle gaps
1 parent 1fa47cf commit 0708d42

4 files changed

Lines changed: 215 additions & 15 deletions

File tree

src/agents.rs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ pub struct AgentRow {
3131
pub desired_state_reason: Option<String>,
3232
/// Typed Resource bindings declared directly by the agent.
3333
pub resources: Vec<Resource>,
34+
/// Resync coverage derived from the declaration directory and each Resource URI.
35+
pub resource_resync: Vec<crate::resync::ResyncCoverage>,
3436
/// Newest activity time across inbox, archive, and status. Version 1 status uses its embedded
3537
/// writer timestamp; message files and legacy status use local mtime. `--enrich` only.
3638
pub last_activity_ms: Option<f64>,
@@ -74,6 +76,11 @@ pub fn roster_from_discovered(
7476
desired_state: s.desired_state.as_str().to_owned(),
7577
desired_state_reason: s.desired_state.reason().map(str::to_owned),
7678
resources: s.resources.clone(),
79+
resource_resync: s
80+
.resources
81+
.iter()
82+
.map(|resource| crate::resync::resource_coverage(agent_dir, resource))
83+
.collect(),
7784
last_activity_ms: newest_activity_ms(agent_dir),
7885
inbox: inbox_count(agent_dir),
7986
observed: observed_state(s, agent_dir, &pty_root, this_host),
@@ -202,6 +209,30 @@ impl<'a> DriverDiagnosticJson<'a> {
202209
}
203210
}
204211

212+
#[derive(Serialize)]
213+
struct ResourceJson<'a> {
214+
name: &'a str,
215+
uri: &'a str,
216+
reason: &'a str,
217+
#[serde(skip_serializing_if = "Option::is_none")]
218+
inactive_reason: Option<&'a str>,
219+
resync: &'static str,
220+
}
221+
222+
fn resource_json(row: &AgentRow) -> Vec<ResourceJson<'_>> {
223+
row.resources
224+
.iter()
225+
.zip(&row.resource_resync)
226+
.map(|(resource, coverage)| ResourceJson {
227+
name: resource.name(),
228+
uri: resource.uri(),
229+
reason: resource.reason(),
230+
inactive_reason: resource.inactive_reason(),
231+
resync: coverage.as_str(),
232+
})
233+
.collect()
234+
}
235+
205236
/// `st2 agents --json` row. Field order and names are the stable wire contract.
206237
#[derive(Serialize)]
207238
struct SummaryJson<'a> {
@@ -210,7 +241,7 @@ struct SummaryJson<'a> {
210241
name: Option<&'a str>,
211242
description: Option<&'a str>,
212243
retired: bool,
213-
resources: &'a [Resource],
244+
resources: Vec<ResourceJson<'a>>,
214245
#[serde(rename = "desiredState")]
215246
desired_state: &'a str,
216247
#[serde(rename = "desiredStateReason")]
@@ -229,7 +260,7 @@ struct EnrichedJson<'a> {
229260
name: Option<&'a str>,
230261
description: Option<&'a str>,
231262
retired: bool,
232-
resources: &'a [Resource],
263+
resources: Vec<ResourceJson<'a>>,
233264
#[serde(rename = "lastActivity")]
234265
last_activity: Option<f64>,
235266
inbox: usize,
@@ -254,7 +285,7 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String {
254285
name: r.name.as_deref(),
255286
description: r.description.as_deref(),
256287
retired: r.retired,
257-
resources: &r.resources,
288+
resources: resource_json(r),
258289
desired_state: &r.desired_state,
259290
desired_state_reason: r.desired_state_reason.as_deref(),
260291
last_activity: r.last_activity_ms,
@@ -273,7 +304,7 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String {
273304
name: r.name.as_deref(),
274305
description: r.description.as_deref(),
275306
retired: r.retired,
276-
resources: &r.resources,
307+
resources: resource_json(r),
277308
desired_state: &r.desired_state,
278309
desired_state_reason: r.desired_state_reason.as_deref(),
279310
observed_state: ObservedJson::from_row(r.observed.as_ref()),
@@ -340,6 +371,7 @@ mod tests {
340371
desired_state: if retired { "retired" } else { "running" }.to_owned(),
341372
desired_state_reason: None,
342373
resources: Vec::new(),
374+
resource_resync: Vec::new(),
343375
last_activity_ms: last,
344376
inbox,
345377
observed: None,
@@ -385,10 +417,13 @@ mod tests {
385417
)
386418
.unwrap(),
387419
);
420+
resource_row
421+
.resource_resync
422+
.push(crate::resync::ResyncCoverage::Unsupported);
388423

389424
assert_eq!(
390425
to_json(&[resource_row], false),
391-
r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[{"name":"work","uri":"vendor+thing://authority/exact%20identity","reason":"Current implementation task."}],"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"}}]"#
426+
r#"[{"identity":"hetz.worker","status":"available","name":null,"description":null,"retired":false,"resources":[{"name":"work","uri":"vendor+thing://authority/exact%20identity","reason":"Current implementation task.","resync":"unsupported"}],"desiredState":"running","desiredStateReason":null,"observedState":null,"driverDiagnostic":{"status":"absent","driver":null,"stage":null,"reason":null,"source":null,"producerVersion":null,"support":"unknown","observedAt":null,"evidenceAgeMs":null,"recovery":"publishFailureOrClearOnStageRecovery"}}]"#
392427
);
393428
}
394429

src/resync.rs

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,36 @@ impl CarrierClass {
4343
}
4444
}
4545
}
46+
/// Catalog-observable resync coverage for one declared Resource binding.
47+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48+
pub enum ResyncCoverage {
49+
Immediate,
50+
Coalesced,
51+
Silent,
52+
Unsupported,
53+
Inactive,
54+
}
55+
56+
impl ResyncCoverage {
57+
pub const fn as_str(self) -> &'static str {
58+
match self {
59+
Self::Immediate => "immediate",
60+
Self::Coalesced => "coalesced",
61+
Self::Silent => "silent",
62+
Self::Unsupported => "unsupported",
63+
Self::Inactive => "inactive",
64+
}
65+
}
66+
67+
fn carrier_class(self) -> Option<CarrierClass> {
68+
match self {
69+
Self::Immediate => Some(CarrierClass::Immediate),
70+
Self::Coalesced => Some(CarrierClass::Coalesced),
71+
Self::Silent | Self::Unsupported | Self::Inactive => None,
72+
}
73+
}
74+
}
75+
4676

4777
/// One watchable local carrier: binding label, absolute path, notification class.
4878
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -73,15 +103,11 @@ pub fn watch_set_for(spec: &AgentSpec, this_host: &str) -> AgentWatchSet {
73103
class: CarrierClass::Immediate,
74104
}];
75105
for resource in &spec.resources {
76-
if resource.inactive_reason().is_some() {
77-
continue;
78-
}
79-
let Some(path) = resolve_local_path(agent_dir, resource.uri()) else {
80-
continue;
81-
};
82-
let Some(class) = classify(agent_dir, resource.name(), &path) else {
106+
let Some(class) = resource_coverage(agent_dir, resource).carrier_class() else {
83107
continue;
84108
};
109+
let path = resolve_local_path(agent_dir, resource.uri())
110+
.expect("watchable coverage must have a local path");
85111
carriers.push(WatchableCarrier {
86112
label: resource.name().to_owned(),
87113
path,
@@ -133,6 +159,21 @@ fn resolve_local_path(agent_dir: &Path, uri: &str) -> Option<PathBuf> {
133159
let path = PathBuf::from(decode_percent_path(uri).ok()?);
134160
Some(lexical_clean(&agent_dir.join(path)))
135161
}
162+
/// Resolve the externally visible resync coverage for one Resource binding.
163+
pub fn resource_coverage(agent_dir: &Path, resource: &agent_spec::spec::Resource) -> ResyncCoverage {
164+
if resource.inactive_reason().is_some() {
165+
return ResyncCoverage::Inactive;
166+
}
167+
let Some(path) = resolve_local_path(agent_dir, resource.uri()) else {
168+
return ResyncCoverage::Unsupported;
169+
};
170+
match classify(agent_dir, resource.name(), &path) {
171+
Some(CarrierClass::Immediate) => ResyncCoverage::Immediate,
172+
Some(CarrierClass::Coalesced) => ResyncCoverage::Coalesced,
173+
None => ResyncCoverage::Silent,
174+
}
175+
}
176+
136177

137178
/// Remove `.` and `..` components lexically. This deliberately does not inspect the filesystem:
138179
/// classification follows the authored path structure without resolving symlinks.
@@ -190,6 +231,7 @@ struct WatchRefresh {
190231
enum Msg {
191232
WatchSet(WatchRefresh),
192233
Install(AgentWatchSet, Sender<()>),
234+
Deactivate(String, Sender<()>),
193235
Mutations(Vec<PathBuf>),
194236
Rescan,
195237
/// Explicit stop: the worker's own watcher holds the last `Sender`, so `Disconnected`
@@ -267,6 +309,19 @@ impl ResyncSupervisor {
267309
let _ = ack_rx.recv();
268310
}
269311
}
312+
313+
/// Synchronously remove a canonical seat's active subscriptions before relaunch work begins.
314+
/// Sequence floors remain retained so a later successful install cannot reuse an occurrence.
315+
pub fn deactivate(&self, spec: &AgentSpec, this_host: &str) {
316+
let (ack_tx, ack_rx) = channel();
317+
if self
318+
.tx
319+
.as_ref()
320+
.is_some_and(|tx| tx.send(Msg::Deactivate(spec.bus_id(this_host), ack_tx)).is_ok())
321+
{
322+
let _ = ack_rx.recv();
323+
}
324+
}
270325
}
271326

272327
impl Drop for ResyncSupervisor {
@@ -422,6 +477,10 @@ fn worker_loop(root: PathBuf, this_host: String, rx: Receiver<Msg>, forward: Sen
422477
worker.install_watch_set(set);
423478
let _ = ack.send(());
424479
}
480+
Ok(Msg::Deactivate(bus_id, ack)) => {
481+
worker.deactivate_watch_set(&bus_id);
482+
let _ = ack.send(());
483+
}
425484
Ok(Msg::Mutations(paths)) => worker.mark_mutated(paths),
426485
Ok(Msg::Rescan) => worker.rescan_all(),
427486
Ok(Msg::Shutdown) => break,
@@ -620,6 +679,22 @@ impl Worker {
620679
self.finish_carrier_update(&previous_paths);
621680
}
622681

682+
fn deactivate_watch_set(&mut self, bus_id: &str) {
683+
let previous = std::mem::take(&mut self.carriers);
684+
let previous_paths = self.prepare_carrier_update(&previous);
685+
self.carriers = previous
686+
.into_iter()
687+
.filter_map(|(path, entries)| {
688+
let retained = entries
689+
.into_iter()
690+
.filter(|entry| entry.bus_id != bus_id)
691+
.collect::<Vec<_>>();
692+
(!retained.is_empty()).then_some((path, retained))
693+
})
694+
.collect();
695+
self.finish_carrier_update(&previous_paths);
696+
}
697+
623698
fn reconcile_dirty_deadlines(&mut self, now: Instant) {
624699
let dirty_classes = self
625700
.carriers
@@ -1033,18 +1108,30 @@ mod tests {
10331108
host "hetz"
10341109
command "true"
10351110
resource "goal" uri="resources/goal.md" reason="Mission."
1111+
resource "journal" uri="resources/context/journal.md" reason="Memory."
10361112
resource "issue" uri="github-issue://org/repo/41" reason="Task."
1113+
resource "old" uri="resources/old.md" reason="History." inactive-reason="No longer used."
10371114
}"#,
10381115
)
10391116
.unwrap();
1040-
let set = watch_set_for(&discover(tmp.path()), "hetz");
1117+
let spec = discover(tmp.path());
1118+
let set = watch_set_for(&spec, "hetz");
10411119
assert_eq!(set.bus_id, "hetz.worker");
10421120
let mut labels: Vec<&str> = set.carriers.iter().map(|c| c.label.as_str()).collect();
10431121
labels.sort();
10441122
assert_eq!(labels, vec!["declaration", "goal"]);
10451123
let goal = set.carriers.iter().find(|c| c.label == "goal").unwrap();
10461124
assert_eq!(goal.class, CarrierClass::Immediate);
10471125
assert_eq!(goal.path, dir.join("resources/goal.md"));
1126+
let coverage = spec
1127+
.resources
1128+
.iter()
1129+
.map(|resource| (resource.name(), resource_coverage(&dir, resource)))
1130+
.collect::<BTreeMap<_, _>>();
1131+
assert_eq!(coverage["goal"], ResyncCoverage::Immediate);
1132+
assert_eq!(coverage["journal"], ResyncCoverage::Silent);
1133+
assert_eq!(coverage["issue"], ResyncCoverage::Unsupported);
1134+
assert_eq!(coverage["old"], ResyncCoverage::Inactive);
10481135
}
10491136

10501137
#[test]

0 commit comments

Comments
 (0)