settings: the device state machine gets a surface
TASK-08(f)/TASK-19: state, panel, evidence, confidence, per-source health, the sensors_degraded flag and the recent decision trail were legible only through forensic.jsonl. DeviceEvidence gains a read path (polled only while watched); the Device page renders it. Readout only — the confidence gates are still computed and never branched on, so controls over them would lie.
This commit is contained in:
parent
8a4eebbdd4
commit
1c90d7f76a
2 changed files with 335 additions and 0 deletions
|
|
@ -29,6 +29,216 @@ ContentPage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Proprioception ───────────────────────────────────────────────────
|
||||||
|
// TASK-08(f) / TASK-19: the state machine computes its state, its
|
||||||
|
// evidence, its confidence and its per-source health, and until now none
|
||||||
|
// of it reached a screen. `forensic.jsonl` knew; the device could not tell
|
||||||
|
// you. A body that cannot feel itself is the thing this OS is not
|
||||||
|
// supposed to be.
|
||||||
|
//
|
||||||
|
// READOUT ONLY, deliberately. TASK-19: the confidence gates are computed,
|
||||||
|
// logged and never branched on, so a control over them "would be lying" —
|
||||||
|
// showing a threshold slider nothing consults breaks this page's own rule
|
||||||
|
// against success-shaped switches. Observations can be shown honestly
|
||||||
|
// today; controls wait on TASK-08(g).
|
||||||
|
property bool _watchingEvidence: false
|
||||||
|
|
||||||
|
function _startWatching() {
|
||||||
|
if (_watchingEvidence) return;
|
||||||
|
_watchingEvidence = true;
|
||||||
|
DeviceEvidence.watch();
|
||||||
|
}
|
||||||
|
function _stopWatching() {
|
||||||
|
if (!_watchingEvidence) return;
|
||||||
|
_watchingEvidence = false;
|
||||||
|
DeviceEvidence.unwatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: _startWatching()
|
||||||
|
Component.onDestruction: _stopWatching()
|
||||||
|
|
||||||
|
ContentSection {
|
||||||
|
icon: "monitor_heart"
|
||||||
|
title: Translation.tr("Device state")
|
||||||
|
|
||||||
|
// The laptop has no sessiond. Say so, rather than rendering zeroes
|
||||||
|
// that look like a healthy reading (§10: "no evidence" and "evidence
|
||||||
|
// says nothing is happening" must not be the same state).
|
||||||
|
StyledText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: !DeviceEvidence.available
|
||||||
|
text: Translation.tr("sessiond is not answering on this device — no state to report.")
|
||||||
|
color: Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: DeviceEvidence.available ? [
|
||||||
|
{ k: Translation.tr("State"), v: DeviceEvidence.state.device_state ?? "—" },
|
||||||
|
{ k: Translation.tr("Lock phase"), v: DeviceEvidence.state.phase ?? "—" },
|
||||||
|
{ k: Translation.tr("Locked"), v: (DeviceEvidence.state.locked ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||||
|
{ k: Translation.tr("Panel"), v: (DeviceEvidence.state.panel_on ?? false) ? Translation.tr("on") : Translation.tr("off") },
|
||||||
|
{ k: Translation.tr("Dimmed"), v: (DeviceEvidence.state.dimmed ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||||
|
{ k: Translation.tr("Display active"), v: (DeviceEvidence.state.display_active ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||||
|
{ k: Translation.tr("Idle"), v: (DeviceEvidence.state.idle_secs ?? 0) + "s" },
|
||||||
|
{ k: Translation.tr("Observed"), v: (DeviceEvidence.state.observed ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||||
|
{ k: Translation.tr("Confidence"), v: Number(DeviceEvidence.state.observed_confidence ?? 0).toFixed(2) },
|
||||||
|
{ k: Translation.tr("Wake suppressed"), v: (DeviceEvidence.state.suppress_dpms_wake ?? false) ? Translation.tr("yes") : Translation.tr("no") },
|
||||||
|
{ k: Translation.tr("Shell alive"), v: (DeviceEvidence.state.shell_alive ?? false) ? Translation.tr("yes") : Translation.tr("no") }
|
||||||
|
] : []
|
||||||
|
|
||||||
|
delegate: RowLayout {
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: modelData.k
|
||||||
|
color: Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
}
|
||||||
|
StyledText {
|
||||||
|
text: String(modelData.v)
|
||||||
|
color: Appearance.colors.colOnLayer1
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentSection {
|
||||||
|
icon: "sensors"
|
||||||
|
title: Translation.tr("Evidence sources")
|
||||||
|
|
||||||
|
// The flag §10 was built for. It rides every forensic snapshot and had
|
||||||
|
// nowhere to appear: the SLPI outage on 2026-07-25 killed every sensor
|
||||||
|
// for four hours and exited status 0, so the crash reporter
|
||||||
|
// structurally could not help.
|
||||||
|
StyledText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: DeviceEvidence.available && (DeviceEvidence.state.sensors_degraded ?? false)
|
||||||
|
text: Translation.tr("A source reported and then went silent. Readings below are not trustworthy.")
|
||||||
|
color: Appearance.colors.colError
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: DeviceEvidence.available
|
||||||
|
text: Translation.tr("live = reporting · unknown = never heard from (no reporter wired) · down = spoke, then stopped")
|
||||||
|
color: Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: {
|
||||||
|
if (!DeviceEvidence.available) return [];
|
||||||
|
const health = DeviceEvidence.state.sensor_health ?? {};
|
||||||
|
const fresh = DeviceEvidence.state.evidence_fresh ?? {};
|
||||||
|
return Object.keys(health).map(name => ({
|
||||||
|
name: name,
|
||||||
|
health: health[name],
|
||||||
|
fresh: fresh[name] === true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate: RowLayout {
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
MaterialSymbol {
|
||||||
|
iconSize: Appearance.font.pixelSize.normal
|
||||||
|
text: modelData.health === "live" ? "sensors"
|
||||||
|
: modelData.health === "down" ? "sensors_off"
|
||||||
|
: "help"
|
||||||
|
color: modelData.health === "down" ? Appearance.colors.colError
|
||||||
|
: modelData.health === "live" ? Appearance.colors.colOnLayer1
|
||||||
|
: Appearance.colors.colSubtext
|
||||||
|
}
|
||||||
|
StyledText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: modelData.name
|
||||||
|
color: Appearance.colors.colOnLayer1
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
}
|
||||||
|
StyledText {
|
||||||
|
// "unknown" is not a failure — accel, light and touch have
|
||||||
|
// no reporter on this device and correctly sit there
|
||||||
|
// forever. Only a source that spoke and then stopped failed.
|
||||||
|
text: modelData.health + (modelData.fresh ? Translation.tr(" · fresh") : "")
|
||||||
|
color: modelData.health === "down" ? Appearance.colors.colError
|
||||||
|
: Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentSection {
|
||||||
|
icon: "history"
|
||||||
|
title: Translation.tr("Recent decisions")
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: Translation.tr("What the machine last decided, and what it decided it from. The same entries the forensic trail hash-chains.")
|
||||||
|
color: Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
// Newest first, by seq — independent of the order the buffer
|
||||||
|
// happens to return.
|
||||||
|
model: (DeviceEvidence.recentDecisions ?? []).slice()
|
||||||
|
.sort((a, b) => (b.seq ?? 0) - (a.seq ?? 0))
|
||||||
|
.slice(0, 12)
|
||||||
|
|
||||||
|
delegate: ColumnLayout {
|
||||||
|
required property var modelData
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 6
|
||||||
|
StyledText {
|
||||||
|
text: {
|
||||||
|
// `event` is a tagged union (decision,
|
||||||
|
// state-transition, sensor-input, error-*). Take
|
||||||
|
// whichever key it carries rather than assuming.
|
||||||
|
const ev = modelData.event ?? {};
|
||||||
|
const kind = Object.keys(ev)[0] ?? "event";
|
||||||
|
const body = ev[kind] ?? {};
|
||||||
|
return body.decision ?? body.to ?? kind;
|
||||||
|
}
|
||||||
|
color: Appearance.colors.colOnLayer1
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
StyledText {
|
||||||
|
text: "#" + (modelData.seq ?? "?")
|
||||||
|
color: Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StyledText {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: (modelData.reason ?? "").length > 0
|
||||||
|
text: modelData.reason ?? ""
|
||||||
|
color: Appearance.colors.colSubtext
|
||||||
|
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ContentSection {
|
ContentSection {
|
||||||
icon: "tune"
|
icon: "tune"
|
||||||
title: Translation.tr("Device overrides")
|
title: Translation.tr("Device overrides")
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,131 @@ Singleton {
|
||||||
sock.connected = true;
|
sock.connected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ingress: the machine's own account of itself ─────────────────────
|
||||||
|
//
|
||||||
|
// Everything above is egress — the shell telling sessiond that something
|
||||||
|
// happened. This half is the other direction, and until now it did not
|
||||||
|
// exist: the state machine computes its state, its evidence, its
|
||||||
|
// confidence and its per-source health, and **no surface could see any of
|
||||||
|
// it** (TASK-08(f), TASK-19). The trail knew and the glass did not.
|
||||||
|
//
|
||||||
|
// Strictly a projection. It reads `device_state`, holds nothing the
|
||||||
|
// protocol owns, and decides nothing — DEVICE-STATE-MACHINE §1's whole
|
||||||
|
// complaint is actors that saw one facet and acted on it, and a readout
|
||||||
|
// that started branching would be the eighth. Doctrine §4: read the
|
||||||
|
// authority, never mirror it into a second source of truth.
|
||||||
|
//
|
||||||
|
// Polled only while a surface is actually looking (watch/unwatch). A
|
||||||
|
// settings page open on the desk should not cost a request per second for
|
||||||
|
// the rest of the day.
|
||||||
|
|
||||||
|
/// True once sessiond has answered at least once. False on the laptop,
|
||||||
|
/// where there is no daemon — surfaces must render that as "unavailable",
|
||||||
|
/// never as healthy-looking zeroes.
|
||||||
|
property bool available: false
|
||||||
|
/// The last `device_state` reply, verbatim. Read-only to every consumer.
|
||||||
|
property var state: ({})
|
||||||
|
/// Recent forensic entries (the decision trail), newest last.
|
||||||
|
property var recentDecisions: []
|
||||||
|
/// ms epoch of the last successful read; 0 = never.
|
||||||
|
property double lastReadAt: 0
|
||||||
|
|
||||||
|
property int _watchers: 0
|
||||||
|
|
||||||
|
/** Begin polling. Pair every call with unwatch(). */
|
||||||
|
function watch() {
|
||||||
|
root._watchers += 1;
|
||||||
|
if (root._watchers === 1) {
|
||||||
|
readTimer.running = true;
|
||||||
|
root._query();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwatch() {
|
||||||
|
root._watchers = Math.max(0, root._watchers - 1);
|
||||||
|
if (root._watchers === 0) {
|
||||||
|
readTimer.running = false;
|
||||||
|
readSock.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot refresh, whether or not anything is watching. */
|
||||||
|
function refresh() {
|
||||||
|
root._query();
|
||||||
|
}
|
||||||
|
|
||||||
|
property bool _queryPending: false
|
||||||
|
|
||||||
|
function _query() {
|
||||||
|
if (readSock.connected) {
|
||||||
|
readSock.write(JSON.stringify({ op: "device_state" }) + "\n");
|
||||||
|
readSock.write(JSON.stringify({ op: "forensic_log", count: 20 }) + "\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root._queryPending = true;
|
||||||
|
readSock.connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: readTimer
|
||||||
|
interval: 2000
|
||||||
|
repeat: true
|
||||||
|
running: false
|
||||||
|
onTriggered: root._query()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A SECOND connection, deliberately. The egress socket above is
|
||||||
|
// fire-and-forget and throttled; interleaving request/response traffic on
|
||||||
|
// it would mean correlating replies to writes that may never come. This
|
||||||
|
// one only ever asks questions. It does NOT register shell authority —
|
||||||
|
// that is SessiondBridge's job, and a second registration is what
|
||||||
|
// deadlocks the lease.
|
||||||
|
Socket {
|
||||||
|
id: readSock
|
||||||
|
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
||||||
|
|
||||||
|
onConnectionStateChanged: {
|
||||||
|
if (connected && root._queryPending) {
|
||||||
|
root._queryPending = false;
|
||||||
|
readSock.write(JSON.stringify({ op: "device_state" }) + "\n");
|
||||||
|
readSock.write(JSON.stringify({ op: "forensic_log", count: 20 }) + "\n");
|
||||||
|
} else if (!connected) {
|
||||||
|
root._queryPending = false;
|
||||||
|
// No daemon is the laptop's normal state. Say unavailable and
|
||||||
|
// let the surface show that, rather than leaving stale values
|
||||||
|
// on screen that look current.
|
||||||
|
root.available = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parser: SplitParser {
|
||||||
|
splitMarker: "\n"
|
||||||
|
onRead: message => {
|
||||||
|
let reply;
|
||||||
|
try {
|
||||||
|
reply = JSON.parse(message);
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (reply.ok !== true) {
|
||||||
|
console.log("[device-evidence] read refused:",
|
||||||
|
reply.code ?? "?", reply.reason ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// device_state carries the state field; forensic_log carries
|
||||||
|
// entries. One parser, two shapes, told apart by content
|
||||||
|
// rather than by a correlation id the protocol does not have.
|
||||||
|
if (reply.device_state !== undefined) {
|
||||||
|
root.state = reply;
|
||||||
|
root.available = true;
|
||||||
|
root.lastReadAt = Date.now();
|
||||||
|
} else if (reply.entries !== undefined) {
|
||||||
|
root.recentDecisions = reply.entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Socket {
|
Socket {
|
||||||
id: sock
|
id: sock
|
||||||
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue