Skip to content

Commit 220b4ad

Browse files
committed
fix(resync): close review lifecycle gaps
1 parent 0a88ab2 commit 220b4ad

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>,
@@ -71,6 +73,11 @@ pub fn roster_from_discovered(
7173
desired_state: s.desired_state.as_str().to_owned(),
7274
desired_state_reason: s.desired_state.reason().map(str::to_owned),
7375
resources: s.resources.clone(),
76+
resource_resync: s
77+
.resources
78+
.iter()
79+
.map(|resource| crate::resync::resource_coverage(agent_dir, resource))
80+
.collect(),
7481
last_activity_ms: newest_activity_ms(agent_dir),
7582
inbox: inbox_count(agent_dir),
7683
observed: observed_state(s, agent_dir, &pty_root, this_host),
@@ -137,6 +144,30 @@ impl<'a> ObservedJson<'a> {
137144
}
138145
}
139146

147+
#[derive(Serialize)]
148+
struct ResourceJson<'a> {
149+
name: &'a str,
150+
uri: &'a str,
151+
reason: &'a str,
152+
#[serde(skip_serializing_if = "Option::is_none")]
153+
inactive_reason: Option<&'a str>,
154+
resync: &'static str,
155+
}
156+
157+
fn resource_json(row: &AgentRow) -> Vec<ResourceJson<'_>> {
158+
row.resources
159+
.iter()
160+
.zip(&row.resource_resync)
161+
.map(|(resource, coverage)| ResourceJson {
162+
name: resource.name(),
163+
uri: resource.uri(),
164+
reason: resource.reason(),
165+
inactive_reason: resource.inactive_reason(),
166+
resync: coverage.as_str(),
167+
})
168+
.collect()
169+
}
170+
140171
/// `st2 agents --json` row. Field order and names are the stable wire contract.
141172
#[derive(Serialize)]
142173
struct SummaryJson<'a> {
@@ -145,7 +176,7 @@ struct SummaryJson<'a> {
145176
name: Option<&'a str>,
146177
description: Option<&'a str>,
147178
retired: bool,
148-
resources: &'a [Resource],
179+
resources: Vec<ResourceJson<'a>>,
149180
#[serde(rename = "desiredState")]
150181
desired_state: &'a str,
151182
#[serde(rename = "desiredStateReason")]
@@ -162,7 +193,7 @@ struct EnrichedJson<'a> {
162193
name: Option<&'a str>,
163194
description: Option<&'a str>,
164195
retired: bool,
165-
resources: &'a [Resource],
196+
resources: Vec<ResourceJson<'a>>,
166197
#[serde(rename = "lastActivity")]
167198
last_activity: Option<f64>,
168199
inbox: usize,
@@ -185,7 +216,7 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String {
185216
name: r.name.as_deref(),
186217
description: r.description.as_deref(),
187218
retired: r.retired,
188-
resources: &r.resources,
219+
resources: resource_json(r),
189220
desired_state: &r.desired_state,
190221
desired_state_reason: r.desired_state_reason.as_deref(),
191222
last_activity: r.last_activity_ms,
@@ -203,7 +234,7 @@ pub fn to_json(rows: &[AgentRow], enrich: bool) -> String {
203234
name: r.name.as_deref(),
204235
description: r.description.as_deref(),
205236
retired: r.retired,
206-
resources: &r.resources,
237+
resources: resource_json(r),
207238
desired_state: &r.desired_state,
208239
desired_state_reason: r.desired_state_reason.as_deref(),
209240
observed_state: ObservedJson::from_row(r.observed.as_ref()),
@@ -269,6 +300,7 @@ mod tests {
269300
desired_state: if retired { "retired" } else { "running" }.to_owned(),
270301
desired_state_reason: None,
271302
resources: Vec::new(),
303+
resource_resync: Vec::new(),
272304
last_activity_ms: last,
273305
inbox,
274306
observed: None,
@@ -313,10 +345,13 @@ mod tests {
313345
)
314346
.unwrap(),
315347
);
348+
resource_row
349+
.resource_resync
350+
.push(crate::resync::ResyncCoverage::Unsupported);
316351

317352
assert_eq!(
318353
to_json(&[resource_row], false),
319-
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}]"#
354+
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}]"#
320355
);
321356
}
322357

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)