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 # Local brainstorming companion artifacts
.superpowers/ .superpowers/
+1 -2
View File
@@ -3,8 +3,7 @@
A personal focus operating system: treat each work session as an explicit A personal focus operating system: treat each work session as an explicit
commitment (next action, success condition, timebox), and make drift visible. commitment (next action, success condition, timebox), and make drift visible.
This is the Go reimagining. The original Rust implementation is preserved under See `docs/superpowers/specs/` for the design.
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
## Run ## Run
+5 -1
View File
@@ -97,7 +97,11 @@ func main() {
if statusPath, err := statusfile.DefaultPath(); err != nil { if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err) log.Printf("status file disabled: %v", err)
} else { } 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) 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 // Package domain holds the core commitment types and validation. These are
// the original Rust implementation. These are pure data types with no I/O. // pure data types with no I/O.
package domain package domain
import ( import (
+4 -1
View File
@@ -19,7 +19,10 @@ func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
return false return false
} }
for _, allowed := range ctx.WindowClasses { 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 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) { func TestWindowTitleMatchesSubstring(t *testing.T) {
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}} ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
if !windowTitleAllowed(ctx, "Commitment OS - 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)) Watch(ctx context.Context, onChange func(WindowSnapshot))
} }
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket // titleNoise matches the clock/ratio/percent runs that pollute bucket
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl. // keys (e.g. "1:23", "50.5%", "-3.0").
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`) var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
// extraSpace collapses the whitespace runs left behind by the scrubs below. // 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.driftGen++
c.nudgeEpoch++ c.nudgeEpoch++
c.lastJudgedAt = time.Time{} c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{} c.judgedWindows = map[string]ai.Verdict{}
c.recentTitles = nil c.recentTitles = nil
c.nudgeMessage = "" c.nudgeMessage = ""
c.lastNudgedAt = time.Time{} c.lastNudgedAt = time.Time{}
} }
// OnTask appends the current window class to the session allowed-context, clears // 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 // drift, drops the cached verdict for the current window, and persists. The
// matches locally and is never re-judged. // class now matches locally and is never re-judged.
func (c *Controller) OnTask() error { func (c *Controller) OnTask() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -100,7 +100,7 @@ func (c *Controller) OnTask() error {
if !evidence.MatchesAllowed(ac, class, "") { if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(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.driftStatus = driftOnTask
c.driftReason = "" c.driftReason = ""
@@ -108,7 +108,7 @@ func (c *Controller) OnTask() error {
} }
// Refocus clears the current drift verdict without changing allowed-context, and // 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 { func (c *Controller) Refocus() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -116,7 +116,7 @@ func (c *Controller) Refocus() error {
return ErrNotActive return ErrNotActive
} }
if c.stats != nil { 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.driftStatus = driftIdle
c.driftReason = "" c.driftReason = ""
@@ -193,8 +193,12 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.nudgeMessage = "" c.nudgeMessage = ""
c.nudgeEpoch++ c.nudgeEpoch++
// 2. Per-class cache. // 2. Per-window cache, keyed by class+title. A verdict for one window of an
if v, ok := c.judgedClasses[class]; ok { // 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) c.applyVerdictLocked(v)
return nil return nil
} }
@@ -241,9 +245,9 @@ func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnap
c.notify() c.notify()
return return
} }
c.judgedClasses[class] = v c.judgedWindows[key] = v
var enforceAct func() 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) c.applyVerdictLocked(v)
enforceAct = c.enforceActionLocked() 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. // applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) { func (c *Controller) applyVerdictLocked(v ai.Verdict) {
if v.OnTask { if v.OnTask {
+19 -7
View File
@@ -43,7 +43,7 @@ type Controller struct {
auditPath string auditPath string
sessionsDir string sessionsDir string
clock func() time.Time clock func() time.Time
onChange func() onChanges []func()
latestWindow evidence.WindowSnapshot latestWindow evidence.WindowSnapshot
stats *EvidenceStats stats *EvidenceStats
outcomePending string outcomePending string
@@ -80,7 +80,7 @@ type Controller struct {
driftGen int driftGen int
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
lastJudgedAt time.Time lastJudgedAt time.Time
judgedClasses map[string]ai.Verdict judgedWindows map[string]ai.Verdict
nudge ai.Nudger nudge ai.Nudger
recentTitles []string // in-memory ring of recent distinct titles this session 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.allowedClasses = s.AllowedWindowClasses
c.enforcementLevel = s.EnforcementLevel c.enforcementLevel = s.EnforcementLevel
// Drift state is not persisted: recompute fresh after restart to avoid // 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. // writes to it) so a restored Active session never panics on a nil map.
c.resetDriftLocked() c.resetDriftLocked()
c.replayStats(s.SessionID) c.replayStats(s.SessionID)
@@ -135,22 +135,34 @@ func (c *Controller) SetClock(f func() time.Time) {
c.mu.Unlock() c.mu.Unlock()
} }
// SetOnChange registers a callback fired after an evidence-driven state change // SetOnChange registers the sole change listener, replacing any already set. A
// (focus updates). It is invoked with the mutex released. // 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()) { func (c *Controller) SetOnChange(f func()) {
c.mu.Lock() 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() c.mu.Unlock()
} }
func (c *Controller) notify() { func (c *Controller) notify() {
c.mu.Lock() c.mu.Lock()
f := c.onChange fs := append([]func(){}, c.onChanges...)
c.mu.Unlock() c.mu.Unlock()
for _, f := range fs {
if f != nil { if f != nil {
f() f()
} }
} }
}
// State returns the current broadcastable state. Safe for concurrent use. // State returns the current broadcastable state. Safe for concurrent use.
func (c *Controller) State() State { func (c *Controller) State() State {
+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) { func TestOnTaskAppendsCurrentClass(t *testing.T) {
c, _ := newTestController(t) c, _ := newTestController(t)
_ = c.EnterPlanning() _ = 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) c, _ := newTestController(t)
now := time.Now() now := time.Now()
c.SetClock(func() time.Time { return now }) c.SetClock(func() time.Time { return now })
@@ -488,12 +558,13 @@ func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
c.RecordWindow(obs("firefox", "YouTube")) c.RecordWindow(obs("firefox", "YouTube"))
waitDriftStatus(t, c, "drifting") waitDriftStatus(t, c, "drifting")
// Advance past the debounce window so a second judge call would be allowed: // 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) 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) time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fj.calls); got != 1 { 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) t.Fatalf("reload: %v", err)
} }
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) 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") waitDriftStatus(t, second, "drifting")
} }
@@ -714,8 +785,8 @@ func TestNudgeDoesNotRunOnDriftPath(t *testing.T) {
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
startActive(t, c, []string{"code"}) startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube")) c.RecordWindow(obs("firefox", "YouTube"))
c.RecordWindow(obs("firefox", "Reddit"))
waitDriftStatus(t, c, "drifting") waitDriftStatus(t, c, "drifting")
c.RecordWindow(obs("firefox", "Reddit")) // second off-task title builds nudge history
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fn.calls); got != 0 { if got := atomic.LoadInt32(&fn.calls); got != 0 {
t.Fatalf("nudge must not run on the drift path, calls=%d", got) 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 waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment
clk.advance(8 * time.Minute) 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) clk.advance(4 * time.Minute)
if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task
t.Fatalf("complete: %v", err) t.Fatalf("complete: %v", err)
+8
View File
@@ -125,6 +125,14 @@ func (c *Controller) stateLocked() State {
} }
} }
if c.runtimeState == domain.RuntimePlanning { 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 status := c.coachStatus
if status == "" { if status == "" {
status = coachIdle status = coachIdle
+3 -3
View File
@@ -1,5 +1,5 @@
// Package statemachine holds the pure runtime and commitment transition // 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 package statemachine
import ( import (
@@ -9,8 +9,8 @@ import (
) )
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance // RuntimeAction enumerates runtime transitions. Activate's policy acceptance
// and admin-override exit targets from the Rust enum are flattened into // and admin-override exit targets are flattened into distinct actions so the
// distinct actions so the action type stays a simple value. // action type stays a simple value.
type RuntimeAction string type RuntimeAction string
const ( const (
+21 -4
View File
@@ -76,6 +76,7 @@ type Writer struct {
path string path string
state func() session.State state func() session.State
now func() time.Time now func() time.Time
wake chan struct{}
last string last string
wrote bool wrote bool
@@ -83,12 +84,26 @@ type Writer struct {
// NewWriter builds a Writer for path, reading state via the given accessor. // NewWriter builds a Writer for path, reading state via the given accessor.
func NewWriter(path string, state func() session.State) *Writer { 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 // Wake asks the writer to re-render now rather than at the next tick. It is the
// line has changed. It removes the file on ctx cancellation so a stale status // hook the controller's change notifications fire through, so drift transitions
// does not linger after shutdown. // 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) { func (w *Writer) Run(ctx context.Context) {
t := time.NewTicker(interval) t := time.NewTicker(interval)
defer t.Stop() defer t.Stop()
@@ -98,6 +113,8 @@ func (w *Writer) Run(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
_ = os.Remove(w.path) _ = os.Remove(w.path)
return return
case <-w.wake:
w.write()
case <-t.C: case <-t.C:
w.write() w.write()
} }
+33
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time" "time"
@@ -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 { func readFile(t *testing.T, path string) string {
t.Helper() t.Helper()
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
+6
View File
@@ -73,6 +73,12 @@ input {
border-radius: 8px; color: var(--ink); font: inherit; border-radius: 8px; color: var(--ink); font: inherit;
} }
input:focus { outline: 0; border-color: var(--accent); } 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 { .btn {
margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px; 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>`; <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) { function updatePlanningCoach(coach) {
const statusEl = document.getElementById('coachStatus'); const statusEl = document.getElementById('coachStatus');
if (!statusEl) return; if (!statusEl) return;
@@ -213,6 +231,7 @@ function render(state) {
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge); updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection); updatePlanningReflection(state.reflection);
updatePlanningWindowHint(state);
return; return;
} }
if (rs === 'active' && renderedState === 'active') { 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>Minutes</label><input id="mins" type="number" min="1" value="25">
<label>Allowed apps (comma-separated window classes)</label> <label>Allowed apps (comma-separated window classes)</label>
<input id="apps" placeholder="e.g. code, firefox"> <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> <label class="enforce-toggle"><input id="enforce" type="checkbox"> Enforce focus</label>
<p class="hint">Minimize off-task windows when you drift.</p> <p class="hint">Minimize off-task windows when you drift.</p>
<button id="start" class="btn btn-primary" disabled>Start commitment</button> <button id="start" class="btn btn-primary" disabled>Start commitment</button>
@@ -272,6 +292,7 @@ function render(state) {
updatePlanningTasks(state.tasks); updatePlanningTasks(state.tasks);
updatePlanningKnowledge(state.knowledge); updatePlanningKnowledge(state.knowledge);
updatePlanningReflection(state.reflection); updatePlanningReflection(state.reflection);
updatePlanningWindowHint(state);
} else if (rs === 'active') { } else if (rs === 'active') {
const c = state.commitment || {}; 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 }
}
}