Scheduled-result notifications: first lines, Open / Run again / Pause; a click opens
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

A run's own notification (run.notification, server dev) gives the body (the
result's first lines) and the actions with labels in the person's language;
they become the notification's action type. Every notification is
remembered by id (watch::Notes) so a click opens the chat or the task, and
Run again / Pause post /schedules/{id}/run|pause without the window. An
older server's run is said as before. Fixtures re-pinned at server dev
e0c2332, schedule.json added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 14:08:04 +00:00
1 parent 2b87fa4c29
commit 9b07527c3a
9 files changed
+420 -16

No files matched your search

+98 -9
View File
@@ -43,7 +43,7 @@ use tauri::{
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_notification::{Action, ActionPerformed, ActionType, NotificationExt};
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_updater::UpdaterExt;
@@ -1656,9 +1656,11 @@ fn watch_chats(handle: AppHandle) {
} else {
d.title.clone()
};
let id = app.watch.notes.add(watch::Note::Chat(d.id.clone()));
let _ = handle
.notification()
.builder()
.id(id)
.title(title)
.body(if d.agent { &words.agent } else { &words.reply })
.show();
@@ -1666,6 +1668,56 @@ fn watch_chats(handle: AppHandle) {
});
}
/// A click on one of the app's notifications, or on one of its actions: a chat's opens that chat;
/// a scheduled run's opens its task (`open`, or the notification itself), runs it again now
/// (`run_again`) or pauses it (`pause`) -- the last two without opening the window.
fn notification_action(handle: &AppHandle, done: &ActionPerformed) {
let Some(id) = done.notification().map(|n| n.id()) else {
return;
};
let Some(note) = handle.state::<App>().watch.notes.get(id) else {
return;
};
match (note, done.action_id()) {
(watch::Note::Chat(chat), "tap") => {
show(handle);
let _ = handle.emit("open-chat", chat);
}
(watch::Note::Run { schedule, run }, "tap" | "open") => {
show(handle);
let _ = handle.emit(
"open-schedule",
serde_json::json!({ "schedule": schedule, "run": run }),
);
}
(watch::Note::Run { schedule, .. }, which @ ("run_again" | "pause")) => {
let path = format!(
"/schedules/{}/{}",
terminal::encode(&schedule),
if which == "pause" { "pause" } else { "run" }
);
let h = handle.clone();
std::thread::spawn(move || {
let Some((app_url, token)) = h.state::<App>().door() else {
return;
};
match server::request(
&app_url,
&token,
"POST",
&path,
Some(&serde_json::json!({})),
) {
Err(e) if e.status == 401 => token_refused(&h),
Err(e) => log::write(&format!("a notification's action failed: {}", e.detail)),
Ok(_) => {}
}
});
}
_ => {}
}
}
/// How long to wait before following the event stream again: after it closed or broke off, and
/// (longer) on a server that has none.
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
@@ -1703,17 +1755,48 @@ fn watch_events(handle: AppHandle) {
} else {
r.title
};
let body = match r.told {
watch::Told::Result => &words.scheduled,
watch::Told::Failed => &words.scheduled_failed,
watch::Told::Pause => &words.scheduled_pause,
};
let _ = handle
let body = r.body.clone().unwrap_or_else(|| {
match r.told {
watch::Told::Result => &words.scheduled,
watch::Told::Failed => &words.scheduled_failed,
watch::Told::Pause => &words.scheduled_pause,
}
.clone()
});
let id = app.watch.notes.add(watch::Note::Run {
schedule: r.schedule.clone(),
run: r.run.clone(),
});
let mut note = handle
.notification()
.builder()
.id(id)
.title(title)
.body(body)
.show();
.body(body);
if !r.actions.is_empty() {
// One action type per set of actions; registered again, it takes the labels
// this run came with (the person's language may have changed).
let kind = format!(
"schedule-{}",
r.actions
.iter()
.map(|(a, _)| a.as_str())
.collect::<Vec<_>>()
.join("-")
);
let actions = r
.actions
.iter()
.map(|(a, label)| Action::builder(a, label).build())
.collect();
let _ = handle
.notification()
.register_action_types(vec![ActionType::builder(&kind)
.actions(actions)
.build()]);
note = note.action_type_id(kind);
}
let _ = note.show();
}
true
});
@@ -1900,6 +1983,12 @@ pub fn run() {
watch_updates(handle.clone());
watch_chats(handle.clone());
watch_events(handle.clone());
{
let h = handle.clone();
let _ = handle
.notification()
.on_action(move |done| notification_action(&h, done));
}
{
let h = handle.clone();
std::thread::spawn(move || {
+172 -4
View File
@@ -15,8 +15,15 @@
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
//! whether to pause it. Said by the schedule's title, never the result's words. A server without
//! the stream answers 404, and nothing is said.
//! whether to pause it. A server that made the run's notification (`run.notification`, server
//! 2.0.0-beta.9) gives its words: the task's name and the result's first lines -- shown here as on
//! an unlocked phone, the desktop being the person's own -- and its actions with their labels in
//! the person's language (`open`, `run_again`, and `pause` on a failure that asks). An older
//! server's run is said by the schedule's title and the page's words, without actions. A server
//! without the stream answers 404, and nothing is said.
//!
//! Every notification shown is remembered by its id (`Notes`), so a click on it or on one of its
//! actions knows which chat or which run it was about.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -90,6 +97,8 @@ pub struct Watch {
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
stop: Mutex<Arc<AtomicBool>>,
/// The notifications shown, so a click on one knows what it was about.
pub notes: Notes,
}
/// How a scheduled task's run is told.
@@ -108,6 +117,95 @@ pub enum Told {
pub struct Ran {
pub title: String,
pub told: Told,
pub schedule: String,
pub run: String,
/// The server's own words for it (`run.notification.body`): the result's first lines, or the
/// failure said. None from a server that does not make them.
pub body: Option<String>,
/// The actions it offers, `(id, label)`, in the server's order: only those the app can do.
pub actions: Vec<(String, String)>,
}
/// The actions of a scheduled run's notification the app knows how to do.
pub const RUN_ACTIONS: [&str; 3] = ["open", "run_again", "pause"];
/// The most of a result's first lines a notification carries: the server cuts them already.
const BODY_MAX: usize = 400;
/// The words and actions of a run's own notification (`run.notification`), as far as they are
/// usable: a body without control characters and not too long; actions the app knows, each with
/// its label (the id when the server gave none).
fn run_notification(run: &Value) -> (Option<String>, Vec<(String, String)>) {
let Some(n) = run.get("notification").filter(|n| n.is_object()) else {
return (None, Vec::new());
};
let body = n
.get("body")
.and_then(Value::as_str)
.map(|b| {
b.chars()
.map(|c| if c.is_control() && c != '\n' { ' ' } else { c })
.take(BODY_MAX)
.collect::<String>()
.trim()
.to_string()
})
.filter(|b| !b.is_empty());
let labels = n.get("labels");
let actions = n
.get("actions")
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(Value::as_str)
.filter(|a| RUN_ACTIONS.contains(a))
.map(|a| {
let label = labels
.and_then(|l| l.get(a))
.and_then(Value::as_str)
.filter(|l| !l.trim().is_empty())
.unwrap_or(a);
(a.to_string(), label.to_string())
})
.collect()
})
.unwrap_or_default();
(body, actions)
}
/// What a notification shown was about.
#[derive(Clone, Debug, PartialEq)]
pub enum Note {
/// A chat whose reply or work finished.
Chat(String),
/// A scheduled task's run.
Run { schedule: String, run: String },
}
/// The notifications shown, by id: the most recent `NOTES_KEPT`.
#[derive(Default)]
pub struct Notes {
next: Mutex<i32>,
by_id: Mutex<HashMap<i32, Note>>,
}
const NOTES_KEPT: i32 = 200;
impl Notes {
/// Remember a notification about to be shown; its id.
pub fn add(&self, note: Note) -> i32 {
let mut next = self.next.lock().unwrap();
*next = next.checked_add(1).unwrap_or(1);
let id = *next;
let mut by_id = self.by_id.lock().unwrap();
by_id.insert(id, note);
by_id.retain(|k, _| *k > id - NOTES_KEPT);
id
}
pub fn get(&self, id: i32) -> Option<Note> {
self.by_id.lock().unwrap().get(&id).cloned()
}
}
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
@@ -212,6 +310,7 @@ impl Watch {
.unwrap_or(0),
);
if let Some(told) = told {
let (body, actions) = run_notification(run);
out.push(Ran {
title: row
.get("title")
@@ -219,6 +318,10 @@ impl Watch {
.unwrap_or("")
.to_string(),
told,
schedule: id.to_string(),
run: run_id.to_string(),
body,
actions,
});
}
}
@@ -259,13 +362,75 @@ mod tests {
})).collect::<Vec<_>>()})
}
fn ran(id: &str, told: Told) -> Ran {
fn ran(id: &str, run: &str, told: Told) -> Ran {
Ran {
title: format!("Task {id}"),
told,
schedule: id.into(),
run: run.into(),
body: None,
actions: vec![],
}
}
#[test]
fn a_runs_own_notification_gives_its_first_lines_and_the_actions_the_app_can_do() {
let w = Watch::default();
let row = |run: Value| {
json!({"version": "v", "schedules": [{
"id": "s1", "title": "Waybread check", "notify": "matters", "failure_streak": 3,
"last_run": run,
}]})
};
w.look_schedules(&row(json!({"id": "r0", "status": "running"})));
let out = w.look_schedules(&row(json!({"id": "r0", "status": "failed", "notified": true,
"notification": {"title": "Waybread check", "body": "Failed 3 times\u{7} in a row. Pause it?",
"actions": ["open", "run_again", "pause", "unknown"],
"labels": {"open": "Otvoriť", "run_again": "Spustiť znova", "pause": ""}}})));
assert_eq!(out.len(), 1);
assert_eq!(out[0].told, Told::Pause);
assert_eq!(
(out[0].schedule.as_str(), out[0].run.as_str()),
("s1", "r0")
);
assert_eq!(
out[0].body.as_deref(),
Some("Failed 3 times in a row. Pause it?")
);
assert_eq!(
out[0].actions,
vec![
("open".to_string(), "Otvoriť".to_string()),
("run_again".to_string(), "Spustiť znova".to_string()),
("pause".to_string(), "pause".to_string()),
]
);
}
#[test]
fn a_notification_is_found_again_by_its_id() {
let notes = Notes::default();
let a = notes.add(Note::Chat("c1".into()));
let b = notes.add(Note::Run {
schedule: "s1".into(),
run: "r1".into(),
});
assert_ne!(a, b);
assert_eq!(notes.get(a), Some(Note::Chat("c1".into())));
assert_eq!(
notes.get(b),
Some(Note::Run {
schedule: "s1".into(),
run: "r1".into()
})
);
assert_eq!(notes.get(b + 1), None);
for _ in 0..NOTES_KEPT {
notes.add(Note::Chat("x".into()));
}
assert_eq!(notes.get(a), None);
}
#[test]
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
let w = Watch::default();
@@ -290,7 +455,10 @@ mod tests {
("c", "matters", 0, Some(("r4", "silent", false))),
("d", "never", 0, Some(("r5", "ok", false))),
]));
assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]);
assert_eq!(
out,
vec![ran("a", "r2", Told::Result), ran("b", "r3", Told::Result)]
);
// The same list again (the server told a device meanwhile): not said twice.
assert!(w
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))