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 {
|
||||
icon: "tune"
|
||||
title: Translation.tr("Device overrides")
|
||||
|
|
|
|||
|
|
@ -95,6 +95,131 @@ Singleton {
|
|||
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 {
|
||||
id: sock
|
||||
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
|
||||
|
|
|
|||
Loading…
Reference in a new issue