Compare commits

...

2 Commits

Author SHA1 Message Date
felixm 13633ffabf Fix drift sync: per-window verdicts, forgiving class match, event-driven status bar
Three independent defects made the focus state feel flaky and let the OS
status bar disagree with the web UI:

- Status file lagged the web by up to a minute: it rendered only on a 60s
  ticker with no hook into state changes. notify() now fans out to multiple
  listeners (AddOnChange) and the writer has a coalesced Wake() so drift
  reaches the bar as promptly as the browser.

- A brief off-task visit could latch drift for the whole session. Sibling
  windows of one app (a browser's reading tab vs its chat tab) share a window
  class, but the judge verdict was cached by class alone, so one tab's verdict
  poisoned the rest and never re-judged. Cache is now keyed by class + scrubbed
  title (judgedWindows) so siblings are judged independently.

- Allowed-class matching was exact equality, so a short token ("brave") never
  matched the real WM_CLASS ("Brave-browser") and every window was routed to
  the LLM. Matching is now substring-based, and planning surfaces the live
  window class with a click-to-add chip so users pick a token that matches.

Also fix the planning checkbox alignment: the global full-width input rule was
stretching the "Enforce focus" checkbox; scope it out for checkboxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:25:30 -04:00
felixm 9012d5ddc6 Purge leftover Rust implementation
Remove the legacy/ Rust tree (Cargo manifests, .rs sources, desktop
entry), drop Rust-specific .gitignore blocks and README note, and strip
now-dangling "ported from Rust" comments from the Go sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:06:14 -04:00
30 changed files with 244 additions and 8624 deletions
-10
View File
@@ -1,13 +1,3 @@
# Generated by Cargo will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Local brainstorming companion artifacts
.superpowers/
+1 -2
View File
@@ -3,8 +3,7 @@
A personal focus operating system: treat each work session as an explicit
commitment (next action, success condition, timebox), and make drift visible.
This is the Go reimagining. The original Rust implementation is preserved under
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
See `docs/superpowers/specs/` for the design.
## Run
+5 -1
View File
@@ -97,7 +97,11 @@ func main() {
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background())
writer := statusfile.NewWriter(statusPath, ctrl.State)
// Re-render the bar on every state change, not just the coarse tick, so a
// drift transition reaches the status bar as fast as it reaches the web UI.
ctrl.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
+2 -2
View File
@@ -1,5 +1,5 @@
// Package domain holds the core commitment types and validation, ported from
// the original Rust implementation. These are pure data types with no I/O.
// Package domain holds the core commitment types and validation. These are
// pure data types with no I/O.
package domain
import (
+4 -1
View File
@@ -19,7 +19,10 @@ func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
return false
}
for _, allowed := range ctx.WindowClasses {
if normalizeCasefolded(allowed) == candidate {
// Substring, not equality: a short token a user types ("brave") matches the
// longer real WM_CLASS ("Brave-browser"). Equality silently disabled the
// fast path whenever the two differed, routing every window to the judge.
if a := normalizeCasefolded(allowed); a != "" && strings.Contains(candidate, a) {
return true
}
}
+14
View File
@@ -19,6 +19,20 @@ func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
}
}
func TestWindowClassMatchesSubstring(t *testing.T) {
// Users type a short app name ("brave") but the real WM_CLASS is longer
// ("Brave-browser"). The allowed token matching as a substring of the actual
// class keeps the local on-task fast path working instead of silently routing
// every window to the LLM judge.
ctx := domain.AllowedContext{WindowClasses: []string{"brave"}}
if !windowClassAllowed(ctx, "Brave-browser") {
t.Fatal("short allowed token should match the longer real class")
}
if windowClassAllowed(ctx, "firefox") {
t.Fatal("unrelated class must not match")
}
}
func TestWindowTitleMatchesSubstring(t *testing.T) {
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
+2 -2
View File
@@ -30,8 +30,8 @@ type Source interface {
Watch(ctx context.Context, onChange func(WindowSnapshot))
}
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl.
// titleNoise matches the clock/ratio/percent runs that pollute bucket
// keys (e.g. "1:23", "50.5%", "-3.0").
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
// extraSpace collapses the whitespace runs left behind by the scrubs below.
+23 -10
View File
@@ -76,15 +76,15 @@ func (c *Controller) resetDriftLocked() {
c.driftGen++
c.nudgeEpoch++
c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{}
c.judgedWindows = map[string]ai.Verdict{}
c.recentTitles = nil
c.nudgeMessage = ""
c.lastNudgedAt = time.Time{}
}
// OnTask appends the current window class to the session allowed-context, clears
// drift, drops any cached verdict for that class, and persists. The class now
// matches locally and is never re-judged.
// drift, drops the cached verdict for the current window, and persists. The
// class now matches locally and is never re-judged.
func (c *Controller) OnTask() error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -100,7 +100,7 @@ func (c *Controller) OnTask() error {
if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
}
delete(c.judgedClasses, class)
delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title))
}
c.driftStatus = driftOnTask
c.driftReason = ""
@@ -108,7 +108,7 @@ func (c *Controller) OnTask() error {
}
// Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current class so it may be judged again later.
// drops the cached verdict for the current window so it may be judged again later.
func (c *Controller) Refocus() error {
c.mu.Lock()
defer c.mu.Unlock()
@@ -116,7 +116,7 @@ func (c *Controller) Refocus() error {
return ErrNotActive
}
if c.stats != nil {
delete(c.judgedClasses, c.stats.Current.Class)
delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title))
}
c.driftStatus = driftIdle
c.driftReason = ""
@@ -193,8 +193,12 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.nudgeMessage = ""
c.nudgeEpoch++
// 2. Per-class cache.
if v, ok := c.judgedClasses[class]; ok {
// 2. Per-window cache, keyed by class+title. A verdict for one window of an
// app must not leak to a sibling window of the same class — a browser hosts
// both an on-task reading page and an off-task chat under one window class, so
// keying by class alone latched one tab's verdict onto every other tab.
key := verdictKey(class, title)
if v, ok := c.judgedWindows[key]; ok {
c.applyVerdictLocked(v)
return nil
}
@@ -241,9 +245,9 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.notify()
return
}
c.judgedClasses[class] = v
c.judgedWindows[key] = v
var enforceAct func()
if c.stats != nil && c.stats.Current.Class == class {
if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key {
c.applyVerdictLocked(v)
enforceAct = c.enforceActionLocked()
}
@@ -314,6 +318,15 @@ func (c *Controller) recordTitleLocked(title string) {
}
}
// verdictKey identifies the judged window for the per-window verdict cache. It
// combines the window class with the scrubbed, casefolded title so sibling
// windows of one app (a browser's reading tab vs its chat tab) are judged
// independently, while transient title noise (clocks, counts) does not split a
// window into ever-changing keys that defeat the cache.
func verdictKey(class, title string) string {
return strings.ToLower(strings.TrimSpace(class)) + "\x00" + strings.ToLower(evidence.ScrubTitle(title))
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
if v.OnTask {
+21 -9
View File
@@ -43,7 +43,7 @@ type Controller struct {
auditPath string
sessionsDir string
clock func() time.Time
onChange func()
onChanges []func()
latestWindow evidence.WindowSnapshot
stats *EvidenceStats
outcomePending string
@@ -80,7 +80,7 @@ type Controller struct {
driftGen int
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
lastJudgedAt time.Time
judgedClasses map[string]ai.Verdict
judgedWindows map[string]ai.Verdict
nudge ai.Nudger
recentTitles []string // in-memory ring of recent distinct titles this session
@@ -119,7 +119,7 @@ func New(snapshotPath string) (*Controller, error) {
c.allowedClasses = s.AllowedWindowClasses
c.enforcementLevel = s.EnforcementLevel
// Drift state is not persisted: recompute fresh after restart to avoid
// stale interrupts. This also initializes judgedClasses (the pipeline
// stale interrupts. This also initializes judgedWindows (the pipeline
// writes to it) so a restored Active session never panics on a nil map.
c.resetDriftLocked()
c.replayStats(s.SessionID)
@@ -135,20 +135,32 @@ func (c *Controller) SetClock(f func() time.Time) {
c.mu.Unlock()
}
// SetOnChange registers a callback fired after an evidence-driven state change
// (focus updates). It is invoked with the mutex released.
// SetOnChange registers the sole change listener, replacing any already set. A
// listener is fired after a state change (focus updates, role results) with the
// mutex released. Use AddOnChange to register additional listeners.
func (c *Controller) SetOnChange(f func()) {
c.mu.Lock()
c.onChange = f
c.onChanges = []func(){f}
c.mu.Unlock()
}
// AddOnChange registers an additional change listener alongside any already set,
// so several consumers (the web broadcaster, the status-file writer) all react
// to the same notification rather than only the last one wired.
func (c *Controller) AddOnChange(f func()) {
c.mu.Lock()
c.onChanges = append(c.onChanges, f)
c.mu.Unlock()
}
func (c *Controller) notify() {
c.mu.Lock()
f := c.onChange
fs := append([]func(){}, c.onChanges...)
c.mu.Unlock()
if f != nil {
f()
for _, f := range fs {
if f != nil {
f()
}
}
}
+79 -7
View File
@@ -386,6 +386,29 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
}
}
func TestPlanningStateExposesCurrentWindowClass(t *testing.T) {
c, _ := newTestController(t)
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
st := c.State()
if st.Evidence == nil || st.Evidence.Current.Class != "Brave-browser" {
t.Fatalf("planning should expose the live window class so the user can pick a matching token, got %+v", st.Evidence)
}
}
func TestAddOnChangeFansOutToAllListeners(t *testing.T) {
c, _ := newTestController(t)
var a, b int
c.SetOnChange(func() { a++ })
c.AddOnChange(func() { b++ })
c.notify()
if a != 1 || b != 1 {
t.Fatalf("every registered listener should fire: a=%d b=%d", a, b)
}
}
func TestOnTaskAppendsCurrentClass(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
@@ -478,7 +501,54 @@ func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
}
}
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
// titleJudge returns a verdict chosen by which substring the title contains,
// so a test can model an app (e.g. a browser) hosting both on- and off-task
// windows under one window class.
type titleJudge struct {
byTitle map[string]ai.Verdict
calls int32
}
func (j *titleJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
atomic.AddInt32(&j.calls, 1)
for sub, v := range j.byTitle {
if strings.Contains(title, sub) {
return v, nil
}
}
return ai.Verdict{OnTask: true}, nil
}
// A brief off-task window must not poison every later same-class window. Teams
// and a Consume reading page are both class "Brave-browser"; caching the judge
// verdict by class alone made the on-task page inherit Teams's drift verdict
// (and never re-judge), latching drift for the rest of the session.
func TestVerdictCacheKeysOnTitleNotClassAlone(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fj := &titleJudge{byTitle: map[string]ai.Verdict{
"Teams": {OnTask: false, Reason: "Teams is off-task"},
"Consume": {OnTask: true},
}}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"}) // Brave-browser never matches locally
c.RecordWindow(obs("Brave-browser", "Microsoft Teams - Brave"))
waitDriftStatus(t, c, "drifting")
now = now.Add(2 * driftDebounce) // clear debounce so a re-judge is allowed
c.RecordWindow(obs("Brave-browser", "Consume - Brave"))
st := waitDriftStatus(t, c, "ontask")
if st.Drift.Reason != "" {
t.Fatalf("on-task window must not carry the Teams reason: %+v", st.Drift)
}
if got := atomic.LoadInt32(&fj.calls); got != 2 {
t.Fatalf("different title under same class should re-judge, calls=%d", got)
}
}
func TestPerWindowCacheAvoidsSecondJudge(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
@@ -488,12 +558,13 @@ func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
c.RecordWindow(obs("firefox", "YouTube"))
waitDriftStatus(t, c, "drifting")
// Advance past the debounce window so a second judge call would be allowed:
// this isolates the per-class cache as the only reason the judge is skipped.
// this isolates the per-window cache as the only reason the judge is skipped.
now = now.Add(2 * driftDebounce)
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
c.RecordWindow(obs("Code", "main.go")) // switch away (local on-task match)
c.RecordWindow(obs("firefox", "YouTube")) // same window, should hit cache
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fj.calls); got != 1 {
t.Fatalf("cached class should not re-judge, calls=%d", got)
t.Fatalf("cached window should not re-judge, calls=%d", got)
}
}
@@ -554,7 +625,7 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
t.Fatalf("reload: %v", err)
}
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedWindows map
waitDriftStatus(t, second, "drifting")
}
@@ -714,8 +785,8 @@ func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
c.RecordWindow(obs("firefox", "Reddit"))
waitDriftStatus(t, c, "drifting")
c.RecordWindow(obs("firefox", "Reddit")) // second off-task title builds nudge history
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fn.calls); got != 0 {
t.Fatalf("nudge must not run on the drift path, calls=%d", got)
@@ -1484,7 +1555,8 @@ func TestRecordWindowCreditsSplitFaithfully(t *testing.T) {
waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
clk.advance(8 * time.Minute)
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting
c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task
waitDriftStatus(t, c, "drifting") // Reddit is a distinct window: re-judged off-task before its segment is credited
clk.advance(4 * time.Minute)
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
t.Fatalf("complete: %v", err)
+8
View File
@@ -125,6 +125,14 @@ func (c *Controller) stateLocked() State {
}
}
if c.runtimeState == domain.RuntimePlanning {
// Surface the live window so planning can show the real class to copy into
// the allowed-apps field — exact class names are otherwise invisible, and a
// non-matching token silently disables the local on-task fast path.
st.Evidence = &EvidenceView{
Available: c.latestWindow.Health.Available,
Reason: c.latestWindow.Health.Reason,
Current: WindowView{Class: c.latestWindow.Class, Title: c.latestWindow.Title},
}
status := c.coachStatus
if status == "" {
status = coachIdle
+3 -3
View File
@@ -1,5 +1,5 @@
// Package statemachine holds the pure runtime and commitment transition
// functions ported from the Rust implementation. No I/O, no shared state.
// functions. No I/O, no shared state.
package statemachine
import (
@@ -9,8 +9,8 @@ import (
)
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
// and admin-override exit targets from the Rust enum are flattened into
// distinct actions so the action type stays a simple value.
// and admin-override exit targets are flattened into distinct actions so the
// action type stays a simple value.
type RuntimeAction string
const (
+21 -4
View File
@@ -76,6 +76,7 @@ type Writer struct {
path string
state func() session.State
now func() time.Time
wake chan struct{}
last string
wrote bool
@@ -83,12 +84,26 @@ type Writer struct {
// NewWriter builds a Writer for path, reading state via the given accessor.
func NewWriter(path string, state func() session.State) *Writer {
return &Writer{path: path, state: state, now: time.Now}
return &Writer{path: path, state: state, now: time.Now, wake: make(chan struct{}, 1)}
}
// Run writes the status file immediately, then on every tick when the rendered
// line has changed. It removes the file on ctx cancellation so a stale status
// does not linger after shutdown.
// Wake asks the writer to re-render now rather than at the next tick. It is the
// hook the controller's change notifications fire through, so drift transitions
// reach the status bar promptly instead of lagging the web UI by up to a tick.
// The signal is coalesced (buffered, size 1): a burst of changes collapses into
// a single re-render, and the actual write still happens on the Run goroutine,
// so concurrent callers never race on the file.
func (w *Writer) Wake() {
select {
case w.wake <- struct{}{}:
default: // a re-render is already pending
}
}
// Run writes the status file immediately, then re-renders on each wake or tick
// when the rendered line has changed. The tick still advances the minute
// countdown when nothing else changes. It removes the file on ctx cancellation
// so a stale status does not linger after shutdown.
func (w *Writer) Run(ctx context.Context) {
t := time.NewTicker(interval)
defer t.Stop()
@@ -98,6 +113,8 @@ func (w *Writer) Run(ctx context.Context) {
case <-ctx.Done():
_ = os.Remove(w.path)
return
case <-w.wake:
w.write()
case <-t.C:
w.write()
}
+34 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
@@ -41,7 +42,7 @@ func TestRender(t *testing.T) {
{"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"},
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"},
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"},
{"active past deadline", activeState(now.Add(-5 * time.Minute).Unix(), "ontask", ""), "● 0m"},
{"active past deadline", activeState(now.Add(-5*time.Minute).Unix(), "ontask", ""), "● 0m"},
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"},
}
for _, tc := range cases {
@@ -101,6 +102,38 @@ func TestWriterRemovesFileOnCancel(t *testing.T) {
}
}
// Wake makes the writer re-render on a state change instead of waiting for the
// coarse minute tick, so the status bar tracks drift transitions promptly
// instead of lagging the web UI by up to a minute.
func TestWriterWakeRendersPromptly(t *testing.T) {
path := filepath.Join(t.TempDir(), "status")
now := time.Unix(1_000_000, 0)
in10m := now.Add(10 * time.Minute).Unix()
var mu sync.Mutex
st := activeState(in10m, "ontask", "")
w := NewWriter(path, func() session.State { mu.Lock(); defer mu.Unlock(); return st })
w.now = func() time.Time { return now }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
waitFor(t, func() bool { return fileEquals(path, "● 10m") })
mu.Lock()
st = activeState(in10m, "drifting", "")
mu.Unlock()
w.Wake()
waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") })
}
func fileEquals(path, want string) bool {
b, err := os.ReadFile(path)
return err == nil && string(b) == want
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
+6
View File
@@ -73,6 +73,12 @@ input {
border-radius: 8px; color: var(--ink); font: inherit;
}
input:focus { outline: 0; border-color: var(--accent); }
/* Checkboxes must opt out of the full-width text-input styling above, which
otherwise stretches the box across the row and shoves its label adrift. */
input[type="checkbox"] {
width: auto; flex: none; margin: 0; padding: 0; accent-color: var(--accent);
}
.enforce-toggle { justify-content: flex-start; }
.btn {
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px;
+21
View File
@@ -120,6 +120,24 @@ function updateActiveDrift(state) {
<span class="status-meta">on task · ${switches} switches</span>`;
}
// updatePlanningWindowHint shows the live foreground window class so the user
// can copy the exact token into the allowed-apps field — class names are
// otherwise invisible, and a non-matching token silently disables on-task
// matching. Clicking the chip appends the class if not already listed.
function updatePlanningWindowHint(state) {
const el = document.getElementById('appHint');
if (!el) return;
const cls = state.evidence && state.evidence.current && state.evidence.current.class;
if (!cls) { el.innerHTML = ''; return; }
el.innerHTML = `current window: <button type="button" class="link" id="addClass">${esc(cls)}</button>`;
document.getElementById('addClass').onclick = () => {
const apps = document.getElementById('apps');
if (!apps) return;
const have = apps.value.split(',').map(s => s.trim()).filter(Boolean);
if (!have.includes(cls)) { have.push(cls); apps.value = have.join(', '); }
};
}
function updatePlanningCoach(coach) {
const statusEl = document.getElementById('coachStatus');
if (!statusEl) return;
@@ -213,6 +231,7 @@ function render(state) {
updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection);
updatePlanningWindowHint(state);
return;
}
if (rs === 'active' && renderedState === 'active') {
@@ -248,6 +267,7 @@ function render(state) {
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
<label>Allowed apps (comma-separated window classes)</label>
<input id="apps" placeholder="e.g. code, firefox">
<div id="appHint" class="hint"></div>
<label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
<p class="hint">Minimize off-task windows when you drift.</p>
<button id="start" class="btn btn-primary" disabled>Start commitment</button>
@@ -272,6 +292,7 @@ function render(state) {
updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection);
updatePlanningWindowHint(state);
} else if (rs === 'active') {
const c = state.commitment || {};
-1102
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "antidrift"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0.86"
fs2 = "0.4"
hex = "0.4.3"
ratatui = "0.27.0"
regex = "1.10.5"
shellexpand = "3.1.0"
serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0"
sha2 = "0.10.8"
uuid = { version = "1", features = ["v7"] }
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "windef"] }
-6
View File
@@ -1,6 +0,0 @@
[Desktop Entry]
Name=AntiDrift
Exec=antidrift
Terminal=false
Type=Application
StartupNotify=false
-36
View File
@@ -1,36 +0,0 @@
pub const APP_TITLE: &str = "AntiDrift";
pub const DEFAULT_DURATION: &str = "25";
pub const DURATION_TITLE: &str = "Duration";
#[allow(dead_code)]
pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence";
pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! ";
pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl";
pub const INTENTION_TITLE: &str = "Intention";
pub const PAUSED: &str = "paused";
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
pub const PROVIDE_INTENTION: &str = "Provide intention! ";
pub const PROVIDE_VALID_DURATION: &str = "Provide valid duration in minutes! ";
pub const RATE_TITLES: &str = "Press 1, 2, 3 to rate titles!";
pub const READY_TO_START: &str = "Ready to start next session.";
pub const SESSION_IN_PROGRESS: &str = "Session In-Progress";
pub const SESSION_PAUSED: &str = "Session is paused. Unpause with 'p'.";
pub const SESSION_STATS_TITLE: &str = "Session Stats";
pub const STATUS_FILE: &str = "~/.antidrift_status";
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
pub const STATUS_TITLE: &str = "Status";
pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session";
pub const STATUS_LOCKED: &str = "🔒 antidrift locked";
pub const STATUS_PLANNING: &str = "🧭 antidrift planning";
pub const STATUS_PAUSED: &str = "⏸ antidrift paused";
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
pub const STATUS_QUIT: &str = "antidrift shutting down";
pub const STATUS_TRANSITION: &str = "↔ antidrift transition";
pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition";
pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes";
pub const TRANSITION_REASON_TITLE: &str = "Transition Reason";
pub const TRANSITION_RETURN_TITLE: &str = "Return Target";
pub const VIOLATION_TITLE: &str = "Violation";
pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason";
pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue.";
pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! ";
pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! ";
-156
View File
@@ -1,156 +0,0 @@
use crate::domain::AllowedContext;
pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_domain(candidate);
if candidate.is_empty() {
return false;
}
context.domains.iter().any(|allowed| {
let allowed = normalize_domain(allowed);
!allowed.is_empty()
&& (candidate == allowed
|| candidate
.strip_suffix(&allowed)
.is_some_and(|prefix| prefix.ends_with('.')))
})
}
pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_casefolded(candidate);
if candidate.is_empty() {
return false;
}
context
.window_classes
.iter()
.any(|allowed| normalize_casefolded(allowed) == candidate)
}
pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = normalize_casefolded(candidate);
if candidate.is_empty() {
return false;
}
context.window_title_substrings.iter().any(|allowed| {
let allowed = normalize_casefolded(allowed);
!allowed.is_empty() && candidate.contains(&allowed)
})
}
pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool {
let candidate = executable_basename(candidate);
if candidate.is_empty() {
return false;
}
context
.commands
.iter()
.any(|allowed| executable_basename(allowed) == candidate)
}
fn normalize_domain(value: &str) -> String {
value.trim().trim_end_matches('.').to_lowercase()
}
fn normalize_casefolded(value: &str) -> String {
value.trim().to_lowercase()
}
fn executable_basename(command: &str) -> String {
let executable = command.split_whitespace().next().unwrap_or_default();
executable
.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::AllowedContext;
#[test]
fn domains_match_exact_or_subdomain() {
let context = AllowedContext {
domains: vec!["github.com".to_string()],
..AllowedContext::default()
};
assert!(domain_allowed(&context, "github.com"));
assert!(domain_allowed(&context, "docs.github.com"));
assert!(!domain_allowed(&context, "evilgithub.com"));
}
#[test]
fn window_classes_match_case_insensitively_after_normalization() {
let context = AllowedContext {
window_classes: vec!["code".to_string()],
..AllowedContext::default()
};
assert!(window_class_allowed(&context, "Code"));
assert!(!window_class_allowed(&context, "firefox"));
}
#[test]
fn titles_match_by_substring() {
let context = AllowedContext {
window_title_substrings: vec!["antidrift".to_string()],
..AllowedContext::default()
};
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
assert!(!window_title_allowed(&context, "random video"));
}
#[test]
fn domains_ignore_whitespace_case_and_trailing_dots() {
let context = AllowedContext {
domains: vec![" GitHub.COM. ".to_string()],
..AllowedContext::default()
};
assert!(domain_allowed(&context, " DOCS.GITHUB.COM. "));
}
#[test]
fn window_class_matching_trims_allowed_and_candidate_values() {
let context = AllowedContext {
window_classes: vec![" code ".to_string()],
..AllowedContext::default()
};
assert!(window_class_allowed(&context, " Code "));
}
#[test]
fn title_matching_trims_allowed_substrings() {
let context = AllowedContext {
window_title_substrings: vec![" antidrift ".to_string()],
..AllowedContext::default()
};
assert!(window_title_allowed(&context, "Commitment OS - AntiDrift"));
}
#[test]
fn commands_match_executable_basename() {
let context = AllowedContext {
commands: vec!["cargo".to_string()],
..AllowedContext::default()
};
assert!(command_allowed(
&context,
"/home/felixm/.cargo/bin/cargo test"
));
assert!(command_allowed(&context, "cargo"));
assert!(!command_allowed(&context, "/usr/bin/git status"));
}
}
-515
View File
@@ -1,515 +0,0 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
pub const POLICY_SCHEMA_VERSION: u16 = 1;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitmentSource {
Manual,
Planner,
Recurring,
Recovery,
Template,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommitmentState {
Draft,
Active,
Paused,
Completed,
Abandoned,
Violated,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeState {
Locked,
Planning,
Active,
Transition,
Review,
AdminOverride,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnforcementLevel {
Observe,
Warn,
Block,
Locked,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceHealth {
Available,
Degraded(String),
Unavailable(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AllowedContext {
pub window_classes: Vec<String>,
pub window_title_substrings: Vec<String>,
pub domains: Vec<String>,
pub repos: Vec<String>,
pub commands: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Commitment {
pub id: String,
pub created_at_unix_secs: u64,
pub source: CommitmentSource,
pub project_id: Option<String>,
pub template_id: Option<String>,
pub next_action: String,
pub success_condition: String,
pub timebox_secs: u64,
pub transition_policy_id: Option<String>,
pub state: CommitmentState,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicySnapshot {
pub id: String,
pub commitment_id: String,
pub schema_version: u16,
pub created_at_unix_secs: u64,
pub runtime_state: RuntimeState,
pub enforcement_level: EnforcementLevel,
pub allowed_context: AllowedContext,
pub required_monitors: Vec<String>,
pub violation_actions: Vec<String>,
pub expires_at_unix_secs: Option<u64>,
pub generated_by_agent_version: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CommitmentValidationError {
MissingId,
MissingNextAction,
MissingSuccessCondition,
MissingTimebox,
}
impl fmt::Display for CommitmentValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CommitmentValidationError::MissingId => write!(f, "commitment id is required"),
CommitmentValidationError::MissingNextAction => write!(f, "next action is required"),
CommitmentValidationError::MissingSuccessCondition => {
write!(f, "success condition is required")
}
CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"),
}
}
}
impl std::error::Error for CommitmentValidationError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PolicySnapshotValidationError {
WrongSchemaVersion,
MissingId,
MissingCommitmentId,
MissingGeneratedByAgentVersion,
}
impl fmt::Display for PolicySnapshotValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PolicySnapshotValidationError::WrongSchemaVersion => {
write!(f, "policy schema version is unsupported")
}
PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"),
PolicySnapshotValidationError::MissingCommitmentId => {
write!(f, "commitment id is required")
}
PolicySnapshotValidationError::MissingGeneratedByAgentVersion => {
write!(f, "generated agent version is required")
}
}
}
}
impl std::error::Error for PolicySnapshotValidationError {}
impl Commitment {
pub fn new_manual(
next_action: impl Into<String>,
success_condition: impl Into<String>,
timebox: Duration,
) -> Result<Self, CommitmentValidationError> {
let next_action = next_action.into().trim().to_string();
let success_condition = success_condition.into().trim().to_string();
if timebox.as_secs() == 0 {
return Err(CommitmentValidationError::MissingTimebox);
}
let created_at_unix_secs = unix_secs_now();
let commitment = Self {
id: unique_id("commitment"),
created_at_unix_secs,
source: CommitmentSource::Manual,
project_id: None,
template_id: None,
next_action,
success_condition,
timebox_secs: timebox.as_secs(),
transition_policy_id: None,
state: CommitmentState::Draft,
};
commitment.validate()?;
Ok(commitment)
}
pub fn validate(&self) -> Result<(), CommitmentValidationError> {
if self.id.trim().is_empty() {
return Err(CommitmentValidationError::MissingId);
}
if self.next_action.trim().is_empty() {
return Err(CommitmentValidationError::MissingNextAction);
}
if self.success_condition.trim().is_empty() {
return Err(CommitmentValidationError::MissingSuccessCondition);
}
if self.timebox_secs == 0 {
return Err(CommitmentValidationError::MissingTimebox);
}
Ok(())
}
}
impl PolicySnapshot {
pub fn for_runtime(
commitment_id: String,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
) -> Self {
Self::try_for_runtime(
commitment_id,
runtime_state,
enforcement_level,
allowed_context,
)
.expect("runtime policy snapshot requires a non-empty commitment id")
}
pub fn try_for_runtime(
commitment_id: String,
runtime_state: RuntimeState,
enforcement_level: EnforcementLevel,
allowed_context: AllowedContext,
) -> Result<Self, PolicySnapshotValidationError> {
let commitment_id = commitment_id.trim().to_string();
if commitment_id.is_empty() {
return Err(PolicySnapshotValidationError::MissingCommitmentId);
}
let created_at_unix_secs = unix_secs_now();
let policy = Self {
id: unique_id("policy"),
commitment_id,
schema_version: POLICY_SCHEMA_VERSION,
created_at_unix_secs,
runtime_state,
enforcement_level,
allowed_context,
required_monitors: Vec::new(),
violation_actions: Vec::new(),
expires_at_unix_secs: None,
generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(),
};
policy.validate()?;
Ok(policy)
}
pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> {
if self.schema_version != POLICY_SCHEMA_VERSION {
return Err(PolicySnapshotValidationError::WrongSchemaVersion);
}
if self.id.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingId);
}
if self.commitment_id.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingCommitmentId);
}
if self.generated_by_agent_version.trim().is_empty() {
return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion);
}
Ok(())
}
}
pub fn unix_secs_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn unique_id(prefix: &str) -> String {
format!("{prefix}-{}", Uuid::now_v7())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn commitment_requires_concrete_action_and_success_condition() {
let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500))
.expect_err("empty next action must be rejected");
assert_eq!(err, CommitmentValidationError::MissingNextAction);
let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500))
.expect_err("empty success condition must be rejected");
assert_eq!(err, CommitmentValidationError::MissingSuccessCondition);
}
#[test]
fn commitment_requires_nonzero_timebox() {
let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO)
.expect_err("zero timebox must be rejected");
assert_eq!(err, CommitmentValidationError::MissingTimebox);
}
#[test]
fn policy_snapshot_carries_runtime_contract() {
let commitment = Commitment::new_manual(
"Implement domain types",
"domain tests pass",
Duration::from_secs(1500),
)
.unwrap();
let policy = PolicySnapshot::for_runtime(
commitment.id.clone(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
assert_eq!(policy.commitment_id, commitment.id);
assert_eq!(policy.runtime_state, RuntimeState::Active);
assert_eq!(policy.enforcement_level, EnforcementLevel::Warn);
assert_eq!(policy.schema_version, 1);
}
#[test]
fn commitment_ids_are_unique_for_rapid_consecutive_construction() {
let first = Commitment::new_manual(
"Implement first action",
"first tests pass",
Duration::from_secs(1500),
)
.unwrap();
let second = Commitment::new_manual(
"Implement second action",
"second tests pass",
Duration::from_secs(1500),
)
.unwrap();
assert_ne!(first.id, second.id);
}
#[test]
fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() {
let first = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
let second = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
assert_ne!(first.id, second.id);
}
#[test]
fn commitment_serializes_enum_contract_as_snake_case() {
let commitment = Commitment::new_manual(
"Implement domain contracts",
"domain tests pass",
Duration::from_secs(1500),
)
.unwrap();
let json = serde_json::to_string(&commitment).unwrap();
assert!(json.contains(r#""source":"manual""#));
assert!(json.contains(r#""state":"draft""#));
}
#[test]
fn policy_snapshot_serializes_enum_contract_as_snake_case() {
let policy = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
let json = serde_json::to_string(&policy).unwrap();
assert!(json.contains(r#""runtime_state":"active""#));
assert!(json.contains(r#""enforcement_level":"warn""#));
}
#[test]
fn commitment_validate_rejects_invalid_public_field_values() {
let mut commitment =
Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500))
.unwrap();
commitment.id = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingId)
);
commitment.id = "commitment-1".to_string();
commitment.next_action = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingNextAction)
);
commitment.next_action = "Refactor state".to_string();
commitment.success_condition = " ".to_string();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingSuccessCondition)
);
commitment.success_condition = "tests pass".to_string();
commitment.timebox_secs = 0;
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingTimebox)
);
}
#[test]
fn commitment_validate_rejects_invalid_deserialized_values() {
let json = r#"{
"id": "",
"created_at_unix_secs": 1,
"source": "manual",
"project_id": null,
"template_id": null,
"next_action": "Refactor state",
"success_condition": "tests pass",
"timebox_secs": 1500,
"transition_policy_id": null,
"state": "draft"
}"#;
let commitment: Commitment = serde_json::from_str(json).unwrap();
assert_eq!(
commitment.validate(),
Err(CommitmentValidationError::MissingId)
);
}
#[test]
fn validation_errors_implement_std_error() {
fn assert_std_error<E: std::error::Error>() {}
assert_std_error::<CommitmentValidationError>();
assert_std_error::<PolicySnapshotValidationError>();
}
#[test]
fn policy_snapshot_try_for_runtime_rejects_empty_commitment_id() {
let err = PolicySnapshot::try_for_runtime(
" ".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
)
.expect_err("empty commitment id must be rejected");
assert_eq!(err, PolicySnapshotValidationError::MissingCommitmentId);
}
#[test]
fn policy_snapshot_validate_rejects_invalid_public_field_values() {
let mut policy = PolicySnapshot::for_runtime(
"commitment-1".to_string(),
RuntimeState::Active,
EnforcementLevel::Warn,
AllowedContext::default(),
);
policy.schema_version = POLICY_SCHEMA_VERSION + 1;
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::WrongSchemaVersion)
);
policy.schema_version = POLICY_SCHEMA_VERSION;
policy.id = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingId)
);
policy.id = "policy-1".to_string();
policy.commitment_id = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingCommitmentId)
);
policy.commitment_id = "commitment-1".to_string();
policy.generated_by_agent_version = " ".to_string();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion)
);
}
#[test]
fn policy_snapshot_validate_rejects_invalid_deserialized_values() {
let json = r#"{
"id": "",
"commitment_id": "commitment-1",
"schema_version": 1,
"created_at_unix_secs": 1,
"runtime_state": "active",
"enforcement_level": "warn",
"allowed_context": {
"window_classes": [],
"window_title_substrings": [],
"domains": [],
"repos": [],
"commands": []
},
"required_monitors": [],
"violation_actions": [],
"expires_at_unix_secs": null,
"generated_by_agent_version": "0.1.0"
}"#;
let policy: PolicySnapshot = serde_json::from_str(json).unwrap();
assert_eq!(
policy.validate(),
Err(PolicySnapshotValidationError::MissingId)
);
}
}
-583
View File
@@ -1,583 +0,0 @@
use crate::domain::{unix_secs_now, RuntimeState, POLICY_SCHEMA_VERSION};
use anyhow::{anyhow, Context, Result};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventType {
CommitmentCreated,
RuntimeTransition,
CommitmentTransition,
PolicyApplied,
EvidenceObserved,
ViolationObserved,
TransitionStarted,
ReviewCompleted,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EventRecord {
pub schema_version: u16,
pub sequence: u64,
pub timestamp_unix_secs: u64,
pub event_type: EventType,
pub commitment_id: Option<String>,
pub runtime_state: RuntimeState,
pub payload_json: Value,
pub previous_hash: Option<String>,
pub hash: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PendingEventRecord {
pub event_type: EventType,
pub runtime_state: RuntimeState,
pub commitment_id: Option<String>,
pub payload_json: Value,
}
#[derive(Debug)]
pub struct EventLog {
path: PathBuf,
next_sequence: u64,
previous_hash: Option<String>,
needs_parent_sync_after_append: bool,
}
impl EventLog {
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf();
create_parent_dirs(&path)?;
let (mut file, created) = open_log_file(&path)?;
file.lock_shared()
.with_context(|| format!("lock event log for read {}", path.display()))?;
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &path)?;
Ok(Self {
path,
next_sequence,
previous_hash,
needs_parent_sync_after_append: created,
})
}
pub fn append(
&mut self,
event_type: EventType,
runtime_state: RuntimeState,
commitment_id: Option<String>,
payload_json: Value,
) -> Result<EventRecord> {
let mut records = self.append_batch([PendingEventRecord {
event_type,
runtime_state,
commitment_id,
payload_json,
}])?;
records
.pop()
.ok_or_else(|| anyhow!("single event append produced no record"))
}
pub fn append_batch(
&mut self,
events: impl IntoIterator<Item = PendingEventRecord>,
) -> Result<Vec<EventRecord>> {
let events: Vec<PendingEventRecord> = events.into_iter().collect();
if events.is_empty() {
return Ok(Vec::new());
}
create_parent_dirs(&self.path)?;
let existed = self
.path
.try_exists()
.with_context(|| format!("check whether event log exists {}", self.path.display()))?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(&self.path)
.with_context(|| format!("open event log for append {}", self.path.display()))?;
file.lock_exclusive()
.with_context(|| format!("lock event log {}", self.path.display()))?;
let (next_sequence, previous_hash) = load_tail_locked(&mut file, &self.path)?;
let mut records = Vec::with_capacity(events.len());
let mut sequence = next_sequence;
let mut record_previous_hash = previous_hash;
for event in events {
let mut record = EventRecord {
schema_version: POLICY_SCHEMA_VERSION,
sequence,
timestamp_unix_secs: unix_secs_now(),
event_type: event.event_type,
commitment_id: event.commitment_id,
runtime_state: event.runtime_state,
payload_json: event.payload_json,
previous_hash: record_previous_hash,
hash: String::new(),
};
record.hash = record_hash(&record)?;
record_previous_hash = Some(record.hash.clone());
sequence += 1;
records.push(record);
}
let mut bytes = Vec::new();
for record in &records {
serde_json::to_writer(&mut bytes, record).with_context(|| {
format!("serialize event log record to {}", self.path.display())
})?;
bytes.push(b'\n');
}
file.write_all(&bytes).with_context(|| {
format!(
"write {} event log records to {}",
records.len(),
self.path.display()
)
})?;
file.flush()
.with_context(|| format!("flush event log {}", self.path.display()))?;
file.sync_data()
.with_context(|| format!("sync event log {}", self.path.display()))?;
if !existed || self.needs_parent_sync_after_append {
sync_parent_dir(&self.path)?;
self.needs_parent_sync_after_append = false;
}
self.next_sequence = sequence;
self.previous_hash = record_previous_hash;
Ok(records)
}
pub fn records(&self) -> Result<Vec<EventRecord>> {
let mut file = OpenOptions::new()
.read(true)
.open(&self.path)
.with_context(|| format!("open event log for read {}", self.path.display()))?;
file.lock_shared()
.with_context(|| format!("lock event log for read {}", self.path.display()))?;
load_records_locked(&mut file, &self.path)
}
}
fn create_parent_dirs(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create parent directories for event log {}", path.display())
})?;
}
Ok(())
}
fn open_log_file(path: &Path) -> Result<(File, bool)> {
let existed = path
.try_exists()
.with_context(|| format!("check whether event log exists {}", path.display()))?;
let file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(path)
.with_context(|| format!("open event log {}", path.display()))?;
Ok((file, !existed))
}
fn load_tail_locked(file: &mut File, path: &Path) -> Result<(u64, Option<String>)> {
let records = load_records_locked(file, path)?;
let next_sequence = records.last().map_or(1, |record| record.sequence + 1);
let previous_hash = records.last().map(|record| record.hash.clone());
Ok((next_sequence, previous_hash))
}
fn load_records_locked(file: &mut File, path: &Path) -> Result<Vec<EventRecord>> {
let mut next_sequence = 1;
let mut previous_hash = None;
let mut records = Vec::new();
file.seek(SeekFrom::Start(0))
.with_context(|| format!("seek event log {}", path.display()))?;
for (line_index, line) in BufReader::new(file).lines().enumerate() {
let line_number = line_index + 1;
let line = line.with_context(|| {
format!("read event log line {line_number} from {}", path.display())
})?;
if line.trim().is_empty() {
continue;
}
let record: EventRecord = serde_json::from_str(&line).with_context(|| {
format!("parse event log line {line_number} from {}", path.display())
})?;
validate_record(&record, next_sequence, previous_hash.as_deref())
.map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?;
next_sequence += 1;
previous_hash = Some(record.hash.clone());
records.push(record);
}
Ok(records)
}
#[cfg(unix)]
fn sync_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
let dir = File::open(parent)
.with_context(|| format!("open parent directory for event log {}", path.display()))?;
dir.sync_all().with_context(|| {
format!(
"sync parent directory {} for event log {}",
parent.display(),
path.display()
)
})?;
}
Ok(())
}
#[cfg(not(unix))]
fn sync_parent_dir(_path: &Path) -> Result<()> {
// Directory fsync is not exposed portably by std on all supported platforms.
// Linux and other Unix targets take the durable path above.
Ok(())
}
fn validate_record(
record: &EventRecord,
expected_sequence: u64,
expected_previous_hash: Option<&str>,
) -> Result<()> {
if record.schema_version != POLICY_SCHEMA_VERSION {
return Err(anyhow!(
"unsupported schema version at sequence {}: expected {}, found {}",
record.sequence,
POLICY_SCHEMA_VERSION,
record.schema_version
));
}
if record.sequence != expected_sequence {
return Err(anyhow!(
"sequence mismatch: expected {}, found {}",
expected_sequence,
record.sequence
));
}
if record.previous_hash.as_deref() != expected_previous_hash {
return Err(anyhow!(
"previous hash mismatch at sequence {}",
record.sequence
));
}
let expected_hash = record_hash(record)?;
if record.hash != expected_hash {
return Err(anyhow!("hash mismatch at sequence {}", record.sequence));
}
Ok(())
}
fn record_hash(record: &EventRecord) -> Result<String> {
let mut hashable = record.clone();
hashable.hash.clear();
let bytes = serde_json::to_vec(&hashable).context("serialize event log record for hash")?;
Ok(hex::encode(Sha256::digest(bytes)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::RuntimeState;
use serde_json::json;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_log_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"antidrift-event-log-{name}-{}-{nonce}.jsonl",
std::process::id()
))
}
#[test]
fn appends_hash_chained_jsonl_records() {
let path = temp_log_path("append");
let mut log = EventLog::open(&path).unwrap();
let first = log
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
Some("commitment-1".to_string()),
json!({"from": "active", "to": "transition"}),
)
.unwrap();
let second = log
.append(
EventType::PolicyApplied,
RuntimeState::Active,
Some("commitment-1".to_string()),
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(first.sequence, 1);
assert_eq!(second.sequence, 2);
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
assert_ne!(first.hash, second.hash);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
fs::remove_file(path).unwrap();
}
#[test]
fn batch_appends_hash_chained_adjacent_records() {
let path = temp_log_path("batch");
let mut log = EventLog::open(&path).unwrap();
let records = log
.append_batch([
PendingEventRecord {
event_type: EventType::CommitmentCreated,
runtime_state: RuntimeState::Active,
commitment_id: Some("commitment-1".to_string()),
payload_json: json!({"commitment_id": "commitment-1"}),
},
PendingEventRecord {
event_type: EventType::PolicyApplied,
runtime_state: RuntimeState::Active,
commitment_id: Some("commitment-1".to_string()),
payload_json: json!({"policy_id": "policy-1"}),
},
])
.unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0].sequence, 1);
assert_eq!(records[1].sequence, 2);
assert_eq!(
records[1].previous_hash.as_deref(),
Some(records[0].hash.as_str())
);
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn opening_existing_log_validates_chain_and_continues_sequence() {
let path = temp_log_path("reopen");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
}
let mut reopened = EventLog::open(&path).unwrap();
let next = reopened
.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(next.sequence, 2);
fs::remove_file(path).unwrap();
}
#[test]
fn opening_missing_log_creates_reopenable_file() {
let path = temp_log_path("open-missing");
EventLog::open(&path).unwrap();
assert!(path.exists());
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn opening_malformed_json_log_fails() {
let path = temp_log_path("malformed");
fs::write(&path, "{not json}\n").unwrap();
let err = EventLog::open(&path).expect_err("malformed JSONL must fail");
assert!(err.to_string().contains("parse event log"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_tampered_log_chain_fails() {
let path = temp_log_path("tampered");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
log.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
fs::write(&path, contents.replace("\"sequence\":2", "\"sequence\":3")).unwrap();
let err = EventLog::open(&path).expect_err("tampered chain must fail");
assert!(err.to_string().contains("sequence mismatch"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_log_with_unknown_record_field_fails() {
let path = temp_log_path("unknown-field");
{
let mut log = EventLog::open(&path).unwrap();
log.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
}
let contents = fs::read_to_string(&path).unwrap();
let mut record_json: Value =
serde_json::from_str(contents.lines().next().unwrap()).unwrap();
record_json["unexpected_field"] = json!("ignored-before-hashing");
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record_json).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unknown record fields must fail");
assert!(err.to_string().contains("parse event log"));
fs::remove_file(path).unwrap();
}
#[test]
fn opening_log_with_unsupported_schema_version_fails() {
let path = temp_log_path("unsupported-schema");
let mut record = EventRecord {
schema_version: 999,
sequence: 1,
timestamp_unix_secs: unix_secs_now(),
event_type: EventType::RuntimeTransition,
commitment_id: None,
runtime_state: RuntimeState::Transition,
payload_json: json!({"from": "active"}),
previous_hash: None,
hash: String::new(),
};
record.hash = record_hash(&record).unwrap();
fs::write(
&path,
format!("{}\n", serde_json::to_string(&record).unwrap()),
)
.unwrap();
let err = EventLog::open(&path).expect_err("unsupported schema version must fail");
assert!(err.to_string().contains("unsupported schema version"));
fs::remove_file(path).unwrap();
}
#[test]
fn stale_handle_reloads_tail_before_append() {
let path = temp_log_path("stale-handle");
let mut log1 = EventLog::open(&path).unwrap();
let mut log2 = EventLog::open(&path).unwrap();
let first = log1
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
let second = log2
.append(
EventType::PolicyApplied,
RuntimeState::Active,
None,
json!({"policy_id": "policy-1"}),
)
.unwrap();
assert_eq!(second.sequence, 2);
assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str()));
EventLog::open(&path).unwrap();
fs::remove_file(path).unwrap();
}
#[test]
fn append_creates_parent_dirs_and_writes_reopenable_file() {
let path = temp_log_path("append-parents")
.with_file_name("nested")
.join("events.jsonl");
let mut log = EventLog {
path: path.clone(),
next_sequence: 1,
previous_hash: None,
needs_parent_sync_after_append: false,
};
let record = log
.append(
EventType::RuntimeTransition,
RuntimeState::Transition,
None,
json!({"from": "active"}),
)
.unwrap();
assert_eq!(record.sequence, 1);
EventLog::open(&path).unwrap();
fs::remove_file(&path).unwrap();
fs::remove_dir(path.parent().unwrap()).unwrap();
}
}
-6
View File
@@ -1,6 +0,0 @@
pub mod context;
pub mod domain;
pub mod event_log;
pub mod session;
pub mod state_machine;
pub mod window;
-2121
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-327
View File
@@ -1,327 +0,0 @@
use crate::domain::{CommitmentState, RuntimeState};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RuntimeAction {
EnterPlanning,
CancelOrTimeout,
Activate { policy_accepted: bool },
StartTransition,
CompleteForReview,
SevereViolation,
ReturnToActive,
SwitchTask,
EndTransitionForReview,
ContinuePlanning,
EndWorkPeriod,
EnterAdminOverride,
ExitAdminOverride(RuntimeState),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CommitmentAction {
Activate,
PauseForTransition,
ReturnFromPause,
Complete,
Abandon,
Violate,
RecoverExplicitly,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TransitionError {
PolicyRejected,
IllegalRuntimeTransition {
current: RuntimeState,
action: RuntimeAction,
},
IllegalCommitmentTransition {
current: CommitmentState,
action: CommitmentAction,
},
InvalidAdminOverrideExitTarget {
target: RuntimeState,
},
}
impl std::fmt::Display for TransitionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransitionError::PolicyRejected => write!(f, "policy was rejected"),
TransitionError::IllegalRuntimeTransition { current, action } => {
write!(
f,
"illegal runtime transition from {:?} via {:?}",
current, action
)
}
TransitionError::IllegalCommitmentTransition { current, action } => {
write!(
f,
"illegal commitment transition from {:?} via {:?}",
current, action
)
}
TransitionError::InvalidAdminOverrideExitTarget { target } => {
write!(f, "invalid admin override exit target {:?}", target)
}
}
}
}
impl std::error::Error for TransitionError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompositeTransition {
pub runtime_state: RuntimeState,
pub commitment_state: CommitmentState,
}
pub fn transition_runtime(
current: RuntimeState,
action: RuntimeAction,
) -> Result<RuntimeState, TransitionError> {
use RuntimeAction::*;
use RuntimeState::*;
match (current, action) {
(Locked, EnterPlanning) => Ok(Planning),
(
Planning,
Activate {
policy_accepted: true,
},
) => Ok(Active),
(
Planning,
Activate {
policy_accepted: false,
},
) => Err(TransitionError::PolicyRejected),
(Planning, CancelOrTimeout) => Ok(Locked),
(Active, StartTransition) => Ok(Transition),
(Active, CompleteForReview) => Ok(Review),
(Active, SevereViolation) => Ok(Locked),
(Transition, ReturnToActive) => Ok(Active),
(Transition, SwitchTask) => Ok(Planning),
(Transition, EndTransitionForReview) => Ok(Review),
(Transition, CancelOrTimeout) => Ok(Locked),
(Review, ContinuePlanning) => Ok(Planning),
(Review, EndWorkPeriod) => Ok(Locked),
(_, EnterAdminOverride) => Ok(AdminOverride),
(AdminOverride, ExitAdminOverride(Locked)) => Ok(Locked),
(AdminOverride, ExitAdminOverride(Planning)) => Ok(Planning),
(AdminOverride, ExitAdminOverride(target)) => {
Err(TransitionError::InvalidAdminOverrideExitTarget { target })
}
_ => Err(TransitionError::IllegalRuntimeTransition { current, action }),
}
}
pub fn transition_commitment(
current: CommitmentState,
action: CommitmentAction,
) -> Result<CommitmentState, TransitionError> {
use CommitmentAction::*;
use CommitmentState::*;
match (current.clone(), action) {
(Draft, Activate) => Ok(Active),
(Active, PauseForTransition) => Ok(Paused),
(Paused, ReturnFromPause) => Ok(Active),
(Active, Complete) => Ok(Completed),
(Active, Abandon) => Ok(Abandoned),
(Active, Violate) => Ok(Violated),
(Paused, Abandon) => Ok(Abandoned),
(Violated, RecoverExplicitly) => Ok(Active),
(Violated, Abandon) => Ok(Abandoned),
_ => Err(TransitionError::IllegalCommitmentTransition { current, action }),
}
}
pub fn start_transition(
runtime_state: RuntimeState,
commitment_state: CommitmentState,
) -> Result<CompositeTransition, TransitionError> {
let next_runtime = transition_runtime(runtime_state, RuntimeAction::StartTransition)?;
let next_commitment =
transition_commitment(commitment_state, CommitmentAction::PauseForTransition)?;
Ok(CompositeTransition {
runtime_state: next_runtime,
commitment_state: next_commitment,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planning_can_only_activate_with_valid_policy() {
assert_eq!(
transition_runtime(
RuntimeState::Planning,
RuntimeAction::Activate {
policy_accepted: true,
}
),
Ok(RuntimeState::Active)
);
assert_eq!(
transition_runtime(
RuntimeState::Planning,
RuntimeAction::Activate {
policy_accepted: false,
}
),
Err(TransitionError::PolicyRejected)
);
}
#[test]
fn active_can_enter_transition_or_review_or_locked() {
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::StartTransition),
Ok(RuntimeState::Transition)
);
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::CompleteForReview),
Ok(RuntimeState::Review)
);
assert_eq!(
transition_runtime(RuntimeState::Active, RuntimeAction::SevereViolation),
Ok(RuntimeState::Locked)
);
}
#[test]
fn locked_cannot_directly_become_active() {
let err = transition_runtime(
RuntimeState::Locked,
RuntimeAction::Activate {
policy_accepted: true,
},
)
.unwrap_err();
assert_eq!(
err,
TransitionError::IllegalRuntimeTransition {
current: RuntimeState::Locked,
action: RuntimeAction::Activate {
policy_accepted: true,
},
}
);
assert_eq!(
err.to_string(),
"illegal runtime transition from Locked via Activate { policy_accepted: true }"
);
}
#[test]
fn admin_override_cannot_exit_to_admin_override() {
let err = transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::AdminOverride),
)
.unwrap_err();
assert_eq!(
err,
TransitionError::InvalidAdminOverrideExitTarget {
target: RuntimeState::AdminOverride,
}
);
}
#[test]
fn admin_override_cannot_exit_to_active_or_transition() {
for target in [RuntimeState::Active, RuntimeState::Transition] {
let err = transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(target),
)
.unwrap_err();
assert_eq!(
err,
TransitionError::InvalidAdminOverrideExitTarget { target }
);
}
}
#[test]
fn admin_override_can_exit_to_locked_or_planning() {
assert_eq!(
transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::Locked),
),
Ok(RuntimeState::Locked)
);
assert_eq!(
transition_runtime(
RuntimeState::AdminOverride,
RuntimeAction::ExitAdminOverride(RuntimeState::Planning),
),
Ok(RuntimeState::Planning)
);
}
#[test]
fn commitment_violation_recovery_is_explicit() {
assert_eq!(
transition_commitment(
CommitmentState::Violated,
CommitmentAction::RecoverExplicitly
),
Ok(CommitmentState::Active)
);
let err =
transition_commitment(CommitmentState::Violated, CommitmentAction::ReturnFromPause)
.unwrap_err();
assert_eq!(
err,
TransitionError::IllegalCommitmentTransition {
current: CommitmentState::Violated,
action: CommitmentAction::ReturnFromPause,
}
);
assert_eq!(
err.to_string(),
"illegal commitment transition from Violated via ReturnFromPause"
);
}
#[test]
fn start_transition_moves_runtime_and_commitment_together() {
assert_eq!(
start_transition(RuntimeState::Active, CommitmentState::Active),
Ok(CompositeTransition {
runtime_state: RuntimeState::Transition,
commitment_state: CommitmentState::Paused,
})
);
}
#[test]
fn start_transition_fails_before_returning_partial_state() {
assert_eq!(
start_transition(RuntimeState::Active, CommitmentState::Draft),
Err(TransitionError::IllegalCommitmentTransition {
current: CommitmentState::Draft,
action: CommitmentAction::PauseForTransition,
})
);
assert_eq!(
start_transition(RuntimeState::Locked, CommitmentState::Active),
Err(TransitionError::IllegalRuntimeTransition {
current: RuntimeState::Locked,
action: RuntimeAction::StartTransition,
})
);
}
}
-113
View File
@@ -1,113 +0,0 @@
use super::WindowSnapshot;
use crate::domain::EvidenceHealth;
use regex::Regex;
use std::{process::Command, process::Output, str};
pub fn get_title_clean() -> String {
get_snapshot().title
}
pub fn get_snapshot() -> WindowSnapshot {
let window_info = get_window_info();
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
let title = re.replace_all(&window_info.title, "").to_string();
let class = (window_info.class != "none").then_some(window_info.class);
let health = if window_info.wid.is_empty() {
EvidenceHealth::Unavailable("xdotool active window unavailable".to_string())
} else {
EvidenceHealth::Available
};
WindowSnapshot {
title,
class,
health,
}
}
pub fn minimize_other(title: &str) {
let window_info = get_window_info();
if window_info.wid.is_empty() {
return;
}
if window_info.title != title {
run_xdotool(&["windowminimize", &window_info.wid]);
}
}
struct WindowInfo {
title: String,
class: String,
wid: String,
}
fn is_valid_window_id(wid: &str) -> bool {
!wid.is_empty() && wid.bytes().all(|byte| byte.is_ascii_digit())
}
fn run_xdotool(args: &[&str]) -> Option<String> {
let output = Command::new("xdotool").args(args).output();
let Ok(Output {
status,
stdout,
stderr: _,
}) = output
else {
return None;
};
if status.code() != Some(0) {
return None;
}
let Ok(output_str) = str::from_utf8(&stdout) else {
return None;
};
Some(output_str.trim().to_string())
}
fn get_window_info() -> WindowInfo {
let none = WindowInfo {
title: "none".to_string(),
class: "none".to_string(),
wid: "".to_string(),
};
let Some(wid) = run_xdotool(&["getactivewindow"]) else {
return none;
};
if !is_valid_window_id(&wid) {
return none;
};
let Some(class) = run_xdotool(&["getwindowclassname", &wid]) else {
return none;
};
let Some(title) = run_xdotool(&["getwindowname", &wid]) else {
return none;
};
WindowInfo { title, class, wid }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_window_id_accepts_ascii_digits_only() {
assert!(is_valid_window_id("12345"));
assert!(is_valid_window_id("0"));
assert!(!is_valid_window_id(""));
assert!(!is_valid_window_id("12abc"));
assert!(!is_valid_window_id(" 123"));
assert!(!is_valid_window_id("123 "));
assert!(!is_valid_window_id("123"));
}
}
-45
View File
@@ -1,45 +0,0 @@
use crate::domain::EvidenceHealth;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WindowSnapshot {
pub title: String,
pub class: Option<String>,
pub health: EvidenceHealth,
}
impl WindowSnapshot {
pub fn unavailable(reason: impl Into<String>) -> Self {
Self {
title: "none".to_string(),
class: None,
health: EvidenceHealth::Unavailable(reason.into()),
}
}
}
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "linux")]
pub use linux::*;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_snapshot_records_reason_without_evidence() {
assert_eq!(
WindowSnapshot::unavailable("no active window"),
WindowSnapshot {
title: "none".to_string(),
class: None,
health: EvidenceHealth::Unavailable("no active window".to_string()),
}
);
}
}
-68
View File
@@ -1,68 +0,0 @@
use super::WindowSnapshot;
use crate::domain::EvidenceHealth;
use regex::Regex;
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
use winapi::shared::windef::HWND;
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
pub fn get_title_clean() -> String {
get_snapshot().title
}
pub fn get_snapshot() -> WindowSnapshot {
let window_info = get_window_info();
if window_info.hwnd.is_null() {
return WindowSnapshot::unavailable("foreground window unavailable");
}
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
let title = re.replace_all(&window_info.title, "").to_string();
let health = if window_info.title.is_empty() {
EvidenceHealth::Degraded("foreground window title unavailable".to_string())
} else {
EvidenceHealth::Degraded("window class unavailable on current Windows adapter".to_string())
};
WindowSnapshot {
title,
class: None,
health,
}
}
pub fn minimize_other(title: &str) {
let window_info = get_window_info();
if window_info.hwnd.is_null() {
return;
}
if window_info.title != title {
unsafe {
ShowWindow(window_info.hwnd, SW_MINIMIZE);
}
}
}
struct WindowInfo {
title: String,
hwnd: HWND,
}
fn get_window_info() -> WindowInfo {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return WindowInfo {
title: String::new(),
hwnd,
};
}
let mut text: [u16; 512] = [0; 512];
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
let title = OsString::from_wide(&text[..len])
.to_string_lossy()
.into_owned();
WindowInfo { title, hwnd }
}
}