Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items * Fix activity owner monitoring and Morph list interactions * Keep activity rows stable during updates and tighten their layout * Use subtle theme tint for activity cards * Tighten activity title and progress spacing * Use theme accent for active progress rings * Fit compact activities to measured titles and badge * Expose Live Activities display controls in Settings * Open activity terminals and streamline the Morph header * Give Morph continuous icons and prioritize workspace navigation * Show activity progress inside its ring * Inset activity progress rings and strengthen their stroke * Fix activity lifecycle, command output handling and list navigation * Reduce activity update work and harden publisher lifetimes * Set up Live Activities for the detected user shell during installation * Document Activities setup, daily use and troubleshooting * Preserve Morph idle width across empty activity updates * Fix dimming flicker, hide inactive music tab and consolidate Activities docs * Fix panel clearance across styles and match previews to theme settings * Prevent panel blur from repainting neighboring islands --------- Co-authored-by: Samuel Onoja <[email protected]>
This commit is contained in:
1 parent
7a7df0106c
commit
6b0d903ad0
107 files changed
+9340
-291
No files matched your search
@@ -0,0 +1,771 @@
|
||||
//! Session broker: verified process identities, one-shot policy deadlines and
|
||||
//! bounded long-poll subscribers. Process inspection runs on IPC workers.
|
||||
mod publication;
|
||||
use ferese_activities::{Data, Id, Owner, Source, State, Store, Template, Value};
|
||||
use ferese_ipc::{
|
||||
Response,
|
||||
activities::{Publish, Snapshot},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Rules {
|
||||
pub enabled: bool,
|
||||
pub matcher: ferese_config::activities::Matcher,
|
||||
}
|
||||
pub(crate) type SharedRules = Arc<RwLock<Arc<Rules>>>;
|
||||
|
||||
pub(crate) struct Engine {
|
||||
pub store: Store,
|
||||
pub rules: SharedRules,
|
||||
pub monitor: Option<crate::ipc::activity_owner::Monitor>,
|
||||
process_enabled: bool,
|
||||
process_sender: Option<smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>>,
|
||||
process_watcher: Option<crate::activities_process::Watcher>,
|
||||
pub timer: Option<crate::ipc::DeadlineTimer<crate::Ferese>>,
|
||||
revision: u64,
|
||||
cached_snapshot: Option<Arc<Snapshot>>,
|
||||
publisher: Option<publication::Publisher>,
|
||||
watchers: BTreeMap<u64, (u64, SyncSender<Response>)>,
|
||||
rates: BTreeMap<Owner, (u64, u16)>,
|
||||
windows: BTreeMap<Id, u64>,
|
||||
results: BTreeMap<Id, ferese_config::activities::ResultText>,
|
||||
}
|
||||
impl Default for Engine {
|
||||
fn default() -> Self {
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
Self {
|
||||
store: Store::new(ferese_ipc::activities::now_ms() ^ u64::from(std::process::id()) << 32),
|
||||
rules: Arc::new(RwLock::new(Arc::new(Rules {
|
||||
enabled: false,
|
||||
matcher: ferese_config::activities::Matcher::new(&settings).expect("built-in rules"),
|
||||
}))),
|
||||
monitor: None,
|
||||
process_enabled: false,
|
||||
process_sender: None,
|
||||
process_watcher: None,
|
||||
timer: None,
|
||||
revision: 1,
|
||||
cached_snapshot: None,
|
||||
publisher: publication::Publisher::new()
|
||||
.map_err(|error| tracing::warn!(%error, "activity snapshot worker unavailable"))
|
||||
.ok(),
|
||||
watchers: BTreeMap::new(),
|
||||
rates: BTreeMap::new(),
|
||||
results: BTreeMap::new(),
|
||||
windows: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Engine {
|
||||
pub fn detect_process(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
matched: ferese_config::activities::Matched,
|
||||
output: Option<String>,
|
||||
fd: &std::os::fd::OwnedFd,
|
||||
) {
|
||||
if crate::ipc::activity_owner::exited(fd) {
|
||||
return;
|
||||
}
|
||||
// A dispatched detection is not an admission acknowledgement. Retry
|
||||
// starts after rejection, and refresh existing records without restarting
|
||||
// their eligibility delay or recency boost.
|
||||
if self.resolve(owner, "process", None).is_ok() {
|
||||
let _ = self.publish(
|
||||
owner,
|
||||
Publish::Update {
|
||||
local: "process".into(),
|
||||
handle: None,
|
||||
value: None,
|
||||
lease_ms: Some(5_000),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
);
|
||||
} else {
|
||||
let _ = self.publish(
|
||||
owner,
|
||||
Publish::Hook {
|
||||
local: "process".into(),
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
Some(matched),
|
||||
output,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(
|
||||
&mut self,
|
||||
settings: &ferese_config::activities::Settings,
|
||||
matcher: ferese_config::activities::Matcher,
|
||||
) {
|
||||
self.store.limits = settings.limits();
|
||||
self.process_enabled = settings.sources.process_watch;
|
||||
*self.rules.write().expect("activity rules") = Arc::new(Rules {
|
||||
enabled: settings.sources.shell_hook,
|
||||
matcher,
|
||||
});
|
||||
self.refresh_process_source();
|
||||
self.changed();
|
||||
}
|
||||
pub fn attach_process_source(
|
||||
&mut self,
|
||||
sender: smithay::reexports::calloop::channel::SyncSender<crate::ipc::IpcEvent>,
|
||||
) {
|
||||
self.process_sender = Some(sender);
|
||||
self.refresh_process_source();
|
||||
}
|
||||
fn refresh_process_source(&mut self) {
|
||||
if !self.process_enabled {
|
||||
self.process_watcher = None;
|
||||
} else if self.process_watcher.is_none()
|
||||
&& let Some(sender) = &self.process_sender
|
||||
{
|
||||
match crate::activities_process::Watcher::start(self.rules.clone(), sender.clone()) {
|
||||
Ok(watcher) => self.process_watcher = Some(watcher),
|
||||
Err(error) => tracing::warn!(%error, "could not start optional activity process source"),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn snapshot(&mut self) -> Arc<Snapshot> {
|
||||
if let Some(snapshot) = &self.cached_snapshot {
|
||||
return snapshot.clone();
|
||||
}
|
||||
let snapshot = Arc::new(Snapshot {
|
||||
version: ferese_ipc::activities::VERSION,
|
||||
revision: self.revision,
|
||||
now_ms: ferese_ipc::activities::now_ms(),
|
||||
limits: self.store.limits,
|
||||
activities: self.store.entries().cloned().collect(),
|
||||
windows: self
|
||||
.windows
|
||||
.iter()
|
||||
.map(|(activity, window)| ferese_ipc::activities::ActivityWindow {
|
||||
activity: activity.clone(),
|
||||
window: *window,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
self.cached_snapshot = Some(snapshot.clone());
|
||||
snapshot
|
||||
}
|
||||
pub fn remove_connection(&mut self, owner: u64) {
|
||||
self.watchers.remove(&owner);
|
||||
}
|
||||
fn deliver(&mut self, recipients: Vec<(u64, SyncSender<Response>)>) {
|
||||
if recipients.is_empty() {
|
||||
return;
|
||||
}
|
||||
let snapshot = self.snapshot();
|
||||
if let Some(publisher) = &self.publisher {
|
||||
publisher.send(snapshot, recipients);
|
||||
} else {
|
||||
publication::unavailable(recipients);
|
||||
}
|
||||
}
|
||||
pub fn get(&mut self, request: u64, sender: SyncSender<Response>) {
|
||||
self.deliver(vec![(request, sender)]);
|
||||
}
|
||||
pub fn watch(&mut self, connection: u64, request: u64, since: u64, sender: SyncSender<Response>) {
|
||||
if since != self.revision {
|
||||
self.get(request, sender);
|
||||
} else if self.watchers.len() < 32 {
|
||||
self.watchers.insert(connection, (request, sender));
|
||||
} else {
|
||||
let _ = sender.try_send(Response::error(
|
||||
request,
|
||||
"subscriber_limit",
|
||||
"Activity subscriber limit",
|
||||
));
|
||||
}
|
||||
}
|
||||
pub fn owner_exit(&mut self, owner: Owner) {
|
||||
self.store.owner_exit(owner);
|
||||
self.rates.remove(&owner);
|
||||
self.changed();
|
||||
}
|
||||
pub fn changed(&mut self) {
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.cached_snapshot = None;
|
||||
self.results
|
||||
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
|
||||
self.windows
|
||||
.retain(|id, _| self.store.entries().any(|entry| &entry.id == id));
|
||||
let recipients = std::mem::take(&mut self.watchers).into_values().collect();
|
||||
self.deliver(recipients);
|
||||
self.schedule();
|
||||
}
|
||||
pub fn tick(&mut self) {
|
||||
self.store.advance(ferese_ipc::activities::now_ms());
|
||||
self.changed();
|
||||
}
|
||||
pub fn schedule(&self) {
|
||||
if let Some(monitor) = &self.monitor {
|
||||
monitor.retain(
|
||||
&self
|
||||
.store
|
||||
.entries()
|
||||
.filter(|entry| entry.state == State::Running)
|
||||
.map(|entry| entry.id.owner)
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(timer) = &self.timer {
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
timer.update(
|
||||
self.store
|
||||
.next_deadline(now)
|
||||
.map(|deadline| Instant::now() + Duration::from_millis(deadline.saturating_sub(now))),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn publish_verified(
|
||||
&mut self,
|
||||
verified: crate::ipc::activity_owner::VerifiedOwner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
window: Option<u64>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// Admission and publication run in one compositor dispatch. A queued
|
||||
// request owns its pidfd, but is not yet subject to monitor pruning.
|
||||
let owner = self
|
||||
.monitor
|
||||
.as_ref()
|
||||
.ok_or("Activity owner monitor unavailable")?
|
||||
.register(verified)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let result = self
|
||||
.publish_from(owner, publish, matched, output, window)
|
||||
.map_err(str::to_owned);
|
||||
self.schedule();
|
||||
result
|
||||
}
|
||||
|
||||
pub fn publish(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
self.publish_from(owner, publish, matched, output, None)
|
||||
}
|
||||
|
||||
fn publish_from(
|
||||
&mut self,
|
||||
owner: Owner,
|
||||
publish: Publish,
|
||||
matched: Option<ferese_config::activities::Matched>,
|
||||
output: Option<String>,
|
||||
window: Option<u64>,
|
||||
) -> Result<serde_json::Value, &'static str> {
|
||||
let started = match &publish {
|
||||
Publish::Start { local, .. } | Publish::Run { local, .. } | Publish::Hook { local, .. } => {
|
||||
Some(local.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let now = ferese_ipc::activities::now_ms();
|
||||
if matches!(publish, Publish::Hook { .. }) && matched.is_none() {
|
||||
return Ok(json!({"matched":false}));
|
||||
}
|
||||
self.rates.retain(|_, (at, _)| now.saturating_sub(*at) < 60_000);
|
||||
if self.rates.len() >= 128 && !self.rates.contains_key(&owner) {
|
||||
return Err("owner publication quota exceeded");
|
||||
}
|
||||
let rate = self.rates.entry(owner).or_insert((now, 0));
|
||||
if now.saturating_sub(rate.0) >= 1_000 {
|
||||
*rate = (now, 0);
|
||||
}
|
||||
// Each connection waits for acknowledgement; excess replaceable updates
|
||||
// are explicitly rejected so terminal events retain capacity.
|
||||
if (matches!(publish, Publish::Update { .. }) && rate.1 >= 30)
|
||||
|| (matches!(
|
||||
publish,
|
||||
Publish::Start { .. } | Publish::Hook { .. } | Publish::Run { .. }
|
||||
) && rate.1 >= 10)
|
||||
{
|
||||
return Err("publication rate exceeded");
|
||||
}
|
||||
rate.1 = rate.1.saturating_add(1);
|
||||
// Expiration is a change even when the following publication is rejected.
|
||||
// Notify subscribers before a store mutation can silently sweep leases.
|
||||
if self.store.advance(now) {
|
||||
self.changed();
|
||||
}
|
||||
let result = match publish {
|
||||
Publish::Start {
|
||||
local,
|
||||
mut data,
|
||||
lease_ms,
|
||||
} => {
|
||||
if data.output.is_none() {
|
||||
data.output = output;
|
||||
}
|
||||
let id = self
|
||||
.store
|
||||
.start(owner, local, data, now, 0, lease_ms, true, true, None)?;
|
||||
json!({"handle":id})
|
||||
}
|
||||
Publish::Run { local, title, .. } => {
|
||||
if let Some(matched) = matched {
|
||||
let data = Data {
|
||||
source: if local == "process" {
|
||||
Source::Process
|
||||
} else {
|
||||
Source::Command
|
||||
},
|
||||
output,
|
||||
priority: matched.rule.priority,
|
||||
icon: matched.rule.icon.clone(),
|
||||
title: title.unwrap_or(matched.title),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let id = self.store.start(
|
||||
owner,
|
||||
local,
|
||||
data,
|
||||
now,
|
||||
matched.rule.min_duration_ms,
|
||||
None,
|
||||
true,
|
||||
matched.rule.flash_on_fail,
|
||||
matched.group,
|
||||
)?;
|
||||
self.results.insert(id.clone(), matched.rule.result);
|
||||
json!({"handle":id,"progress":matched.rule.progress})
|
||||
} else {
|
||||
let data = Data {
|
||||
source: Source::Command,
|
||||
output,
|
||||
priority: 100,
|
||||
icon: "ferese".into(),
|
||||
title: title.unwrap_or_else(|| "Command".into()),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let id = self.store.start(owner, local, data, now, 0, None, true, true, None)?;
|
||||
json!({"handle":id,"progress":"indeterminate"})
|
||||
}
|
||||
}
|
||||
Publish::Hook { local, .. } => {
|
||||
let Some(matched) = matched else {
|
||||
return Ok(json!({"matched":false}));
|
||||
};
|
||||
let data = Data {
|
||||
source: if local == "process" {
|
||||
Source::Process
|
||||
} else {
|
||||
Source::Command
|
||||
},
|
||||
output,
|
||||
priority: matched.rule.priority,
|
||||
icon: matched.rule.icon.clone(),
|
||||
title: matched.title,
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
};
|
||||
let lease = (local == "process").then_some(5_000);
|
||||
let id = self.store.start(
|
||||
owner,
|
||||
local,
|
||||
data,
|
||||
now,
|
||||
matched.rule.min_duration_ms,
|
||||
lease,
|
||||
true,
|
||||
matched.rule.flash_on_fail,
|
||||
matched.group,
|
||||
)?;
|
||||
self.results.insert(id.clone(), matched.rule.result);
|
||||
json!({"matched":true,"handle":id,"progress":matched.rule.progress})
|
||||
}
|
||||
Publish::Update {
|
||||
local,
|
||||
handle,
|
||||
value,
|
||||
lease_ms,
|
||||
} => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
self.store.update(owner, &id, value, lease_ms, now)?;
|
||||
json!({})
|
||||
}
|
||||
Publish::End {
|
||||
local,
|
||||
handle,
|
||||
result,
|
||||
message,
|
||||
} => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
let message = message.or_else(|| {
|
||||
self.results.get(&id).map(|text| {
|
||||
if result == State::Done {
|
||||
text.ok.clone()
|
||||
} else {
|
||||
text.fail.clone()
|
||||
}
|
||||
})
|
||||
});
|
||||
self.store.finish(owner, &id, result, message, now)?;
|
||||
json!({})
|
||||
}
|
||||
Publish::Cancel { local, handle } => {
|
||||
let id = self.resolve(owner, &local, handle)?;
|
||||
self.store.finish(owner, &id, State::Cancelled, None, now)?;
|
||||
json!({})
|
||||
}
|
||||
};
|
||||
if let Some(local) = started
|
||||
&& let Some(id) = self.store.resolve(owner, &local)
|
||||
&& let Some(window) = window
|
||||
{
|
||||
self.windows.insert(id.clone(), window);
|
||||
}
|
||||
self.changed();
|
||||
Ok(result)
|
||||
}
|
||||
fn resolve(&self, owner: Owner, local: &str, handle: Option<Id>) -> Result<Id, &'static str> {
|
||||
match handle {
|
||||
Some(id) if id.local == local => Ok(id),
|
||||
Some(_) => Err("handle does not match local ID"),
|
||||
None => self
|
||||
.store
|
||||
.resolve(owner, local)
|
||||
.cloned()
|
||||
.ok_or("unknown owner-scoped activity"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Ferese {
|
||||
pub(crate) fn activity_window(&self, owner: &crate::ipc::activity_owner::VerifiedOwner) -> Option<u64> {
|
||||
use smithay::reexports::wayland_server::Resource;
|
||||
let mut ancestors = owner.ancestor_pids().peekable();
|
||||
ancestors.peek()?;
|
||||
let windows: Vec<_> = self
|
||||
.windows
|
||||
.records()
|
||||
.filter_map(|(id, record)| {
|
||||
let client = record.surface.as_ref()?.client()?;
|
||||
let pid = u32::try_from(client.get_credentials(&self.display_handle).ok()?.pid).ok()?;
|
||||
Some((pid, id.0))
|
||||
})
|
||||
.collect();
|
||||
origin_window(ancestors, &windows)
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_window(ancestors: impl Iterator<Item = u32>, windows: &[(u32, u64)]) -> Option<u64> {
|
||||
for pid in ancestors {
|
||||
let mut matches = windows.iter().filter(|(client, _)| *client == pid);
|
||||
if let Some((_, window)) = matches.next() {
|
||||
// A terminal server may own several windows. Never guess from focus.
|
||||
return matches.next().is_none().then_some(*window);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn owner() -> Owner {
|
||||
Owner { pid: 12, birth: 3 }
|
||||
}
|
||||
fn data() -> Data {
|
||||
Data {
|
||||
source: Source::Publisher,
|
||||
output: None,
|
||||
priority: 255,
|
||||
icon: "hammer".into(),
|
||||
title: "Build".into(),
|
||||
value: Value::Indeterminate,
|
||||
template: Template::Progress,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejected_updates_do_not_leave_a_snapshot_from_before_lease_expiry() {
|
||||
let mut engine = Engine::default();
|
||||
let id = engine
|
||||
.store
|
||||
.start(
|
||||
owner(),
|
||||
"expired".into(),
|
||||
data(),
|
||||
ferese_ipc::activities::now_ms().saturating_sub(5_000),
|
||||
0,
|
||||
Some(1_000),
|
||||
true,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let before = engine.snapshot();
|
||||
assert_eq!(before.activities.len(), 1);
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 11, before.revision, sender);
|
||||
assert!(
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "expired".into(),
|
||||
handle: Some(id),
|
||||
value: None,
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(engine.snapshot().activities.is_empty());
|
||||
let response = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
let snapshot: Snapshot = serde_json::from_value(response.result.unwrap()).unwrap();
|
||||
assert!(snapshot.activities.is_empty());
|
||||
assert!(snapshot.revision > before.revision);
|
||||
assert_eq!(before.activities.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_are_lazy_shared_and_immutable_across_publications() {
|
||||
let mut engine = Engine::default();
|
||||
engine.changed();
|
||||
assert!(engine.cached_snapshot.is_none(), "no subscriber needs a snapshot");
|
||||
let before = engine.snapshot();
|
||||
assert!(Arc::ptr_eq(&before, &engine.snapshot()));
|
||||
let (first, first_reply) = std::sync::mpsc::sync_channel(1);
|
||||
let (second, second_reply) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(1, 11, before.revision, first);
|
||||
engine.watch(2, 22, before.revision, second);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Start {
|
||||
local: "build".into(),
|
||||
data: data(),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let after = engine.snapshot();
|
||||
assert!(!Arc::ptr_eq(&before, &after));
|
||||
assert!(before.activities.is_empty());
|
||||
assert_eq!(after.activities.len(), 1);
|
||||
for (id, reply) in [(11, first_reply), (22, second_reply)] {
|
||||
let reply = reply.recv_timeout(Duration::from_secs(2)).unwrap();
|
||||
assert_eq!(reply.id, id);
|
||||
let snapshot: Snapshot = serde_json::from_value(reply.result.unwrap()).unwrap();
|
||||
assert_eq!(snapshot.revision, after.revision);
|
||||
assert_eq!(snapshot.activities[0].id, after.activities[0].id);
|
||||
}
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
engine.watch(3, 33, before.revision, sender);
|
||||
assert_eq!(receiver.recv_timeout(Duration::from_secs(2)).unwrap().id, 33);
|
||||
assert!(Arc::ptr_eq(&after, &engine.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_that_exits_before_detection_dispatch_is_not_published() {
|
||||
let mut child = std::process::Command::new("sleep").arg("30").spawn().unwrap();
|
||||
let (owner, fd, _) = crate::ipc::activity_owner::inspect(child.id()).unwrap();
|
||||
let matched = ferese_config::activities::Matcher::new(&Default::default())
|
||||
.unwrap()
|
||||
.matches("cargo test", "/tmp", &[])
|
||||
.unwrap();
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine.detect_process(owner, matched, None, &fd);
|
||||
assert_eq!(engine.store.entries().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_process_detection_is_retried_without_restarting_admitted_records() {
|
||||
let mut engine = Engine::default();
|
||||
let (owner, fd, _) = crate::ipc::activity_owner::inspect(std::process::id()).unwrap();
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
let matched = ferese_config::activities::Matcher::new(&settings)
|
||||
.unwrap()
|
||||
.matches("cargo test", "/tmp/test", &[])
|
||||
.unwrap();
|
||||
engine.store.limits.max_total = 0;
|
||||
engine.detect_process(owner, matched.clone(), None, &fd);
|
||||
assert_eq!(engine.store.entries().count(), 0);
|
||||
engine.store.limits.max_total = 128;
|
||||
engine.detect_process(owner, matched.clone(), None, &fd);
|
||||
let admitted = engine.store.entries().next().unwrap().clone();
|
||||
engine.detect_process(owner, matched, None, &fd);
|
||||
let refreshed = engine.store.entries().next().unwrap();
|
||||
assert_eq!(refreshed.id, admitted.id);
|
||||
assert_eq!(refreshed.eligible_at, admitted.eligible_at);
|
||||
assert_eq!(engine.store.entries().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_matching_uses_nearest_unique_ancestor_and_never_guesses() {
|
||||
let ancestry = [400, 300, 200, 100];
|
||||
assert_eq!(origin_window(ancestry.into_iter(), &[(200, 7), (100, 8)]), Some(7));
|
||||
assert_eq!(
|
||||
origin_window(ancestry.into_iter(), &[(200, 7), (200, 9), (100, 8)]),
|
||||
None
|
||||
);
|
||||
assert_eq!(origin_window(ancestry.into_iter(), &[(900, 7)]), None);
|
||||
assert_eq!(origin_window([].into_iter(), &[(200, 7)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_window_survives_updates_and_results_then_expires() {
|
||||
let mut engine = Engine::default();
|
||||
engine.store.limits.start_delay_ms = 0;
|
||||
engine
|
||||
.publish_from(
|
||||
owner(),
|
||||
Publish::Run {
|
||||
local: "build".into(),
|
||||
title: Some("Build".into()),
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
Some(42),
|
||||
)
|
||||
.unwrap();
|
||||
let id = engine.store.entries().next().unwrap().id.clone();
|
||||
assert_eq!(engine.snapshot().windows[0].window, 42);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
value: Some(Value::Progress(0.5)),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(engine.snapshot().windows[0].window, 42);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::End {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
result: State::Done,
|
||||
message: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
engine.owner_exit(owner());
|
||||
assert_eq!(engine.snapshot().windows[0].activity, id);
|
||||
engine.store.advance(ferese_ipc::activities::now_ms() + 2_000);
|
||||
engine.changed();
|
||||
assert!(engine.snapshot().windows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_shot_disconnect_does_not_own_lifetime_and_rate_limit_preserves_completion() {
|
||||
let mut engine = Engine::default();
|
||||
let result = engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Start {
|
||||
local: "build".into(),
|
||||
data: data(),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
Some("first".into()),
|
||||
)
|
||||
.unwrap();
|
||||
let id: Id = serde_json::from_value(result["handle"].clone()).unwrap();
|
||||
engine.remove_connection(1);
|
||||
assert_eq!(engine.store.entries().count(), 1);
|
||||
for _ in 0..40 {
|
||||
let _ = engine.publish(
|
||||
owner(),
|
||||
Publish::Update {
|
||||
local: "build".into(),
|
||||
handle: Some(id.clone()),
|
||||
value: Some(Value::Progress(0.5)),
|
||||
lease_ms: None,
|
||||
},
|
||||
None,
|
||||
Some("other".into()),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
engine.store.entries().next().unwrap().data.output.as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::End {
|
||||
local: "build".into(),
|
||||
handle: Some(id),
|
||||
result: State::Done,
|
||||
message: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
engine.store.entries().count(),
|
||||
0,
|
||||
"completion before delay never flashes"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn rule_is_pinned_across_atomic_reload_and_no_raw_metadata_is_published() {
|
||||
let settings = ferese_config::activities::Settings::default();
|
||||
let matched = ferese_config::activities::Matcher::new(&settings)
|
||||
.unwrap()
|
||||
.matches("cargo test --secret", "/secret/cwd", &[])
|
||||
.unwrap();
|
||||
let mut engine = Engine::default();
|
||||
engine
|
||||
.publish(
|
||||
owner(),
|
||||
Publish::Run {
|
||||
local: "build".into(),
|
||||
title: None,
|
||||
command: String::new(),
|
||||
cwd: String::new(),
|
||||
},
|
||||
Some(matched),
|
||||
Some("first".into()),
|
||||
)
|
||||
.unwrap();
|
||||
let before = engine.store.entries().next().unwrap().clone();
|
||||
let mut replacement = settings.clone();
|
||||
replacement.start_delay_ms = 5_000;
|
||||
replacement.rules[0].title = "Replacement".into();
|
||||
engine.configure(
|
||||
&replacement,
|
||||
ferese_config::activities::Matcher::new(&replacement).unwrap(),
|
||||
);
|
||||
let after = engine.store.entries().next().unwrap();
|
||||
assert_eq!(after.data.title, "cargo test");
|
||||
assert_eq!(after.eligible_at, before.eligible_at);
|
||||
let serialized = serde_json::to_string(engine.snapshot().as_ref()).unwrap();
|
||||
assert!(!serialized.contains("secret"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user