Compare commits
2 Commits
91a3fb29c2
...
13633ffabf
| Author | SHA1 | Date | |
|---|---|---|---|
| 13633ffabf | |||
| 9012d5ddc6 |
-10
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
Generated
-1102
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=AntiDrift
|
||||
Exec=antidrift
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=false
|
||||
@@ -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! ";
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user