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
|
# Local brainstorming companion artifacts
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 || {};
|
||||||
|
|||||||
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