Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 571a97e0e2 | |||
| b51ad45c10 | |||
| c61c9d46b1 | |||
| 38a62e4c68 | |||
| 463ac4f23c | |||
| 25a33f027a | |||
| 3148fdfa92 | |||
| dea8e6ba78 | |||
| 5545a4e1cd | |||
| ae5a0f123c | |||
| b2104f6442 | |||
| 332e17d742 | |||
| cd5d69dec5 | |||
| 1d281ad528 | |||
| ed5085ab6b | |||
| f286609ec0 | |||
| b5c3f883b7 | |||
| 63418d106f | |||
| df384afb7c | |||
| 1646321d0b | |||
| 034cc16540 | |||
| 5ee34a350d | |||
| d780643015 | |||
| a31ce702fe | |||
| edca3e280b | |||
| 28305da530 | |||
| e68b7437fa | |||
| fe2fb4e250 | |||
| 06c1b719b3 | |||
| d08cc9f891 | |||
| 937d714be2 | |||
| 0318aeb08d | |||
| f243f3004e | |||
| a8216095de | |||
| 18108c3da3 | |||
| 63247e402a | |||
| de40fe73c7 | |||
| 0a934bf061 | |||
| e66fddd29f | |||
| 3da269c3b7 | |||
| 0eb30ca93d | |||
| bb97a374da | |||
| 010e8c07a2 | |||
| 1706e8afd8 | |||
| 25aeb58ae8 | |||
| 379804caae | |||
| 6fd1a61b04 | |||
| f2eab5492c | |||
| 1fa94f3ca0 | |||
| 65d6b992a0 | |||
| 41da2c11a2 | |||
| 3b6ba569ce | |||
| 3ff868c4e8 | |||
| fd166d1ebb | |||
| ed00c7c24f | |||
| b61db909c9 | |||
| 932c9ac807 | |||
| 7303eeacaf | |||
| e07bd8dcda | |||
| dd6314027c | |||
| 6ce6bb97ab | |||
| 65b2a756c5 | |||
| 7b1bc32c3f | |||
| 92cc023b4d | |||
| 1b8b6cabf0 | |||
| a71a7ce84f | |||
| dd9fc3de3a | |||
| 2e58e3b269 | |||
| 999d99749a | |||
| 3cf6ab42b8 | |||
| 3cece19e73 | |||
| aebebade87 | |||
| c02002825d | |||
| e7cc388742 | |||
| 814f28c5ae | |||
| 03eb1f82b3 | |||
| 52d5c65290 | |||
| a7f9ef9e42 | |||
| 0c0d5292a8 | |||
| 1d4372c6c3 | |||
| 3fe7131f13 | |||
| b89351727e | |||
| 27fabab2b9 | |||
| 54e9433835 | |||
| 8e282ce9f0 | |||
| b70fd20785 | |||
| acb37e990e | |||
| 39b8c32f49 | |||
| 5b1005164f |
+7
-4
@@ -2,12 +2,15 @@
|
|||||||
debug/
|
debug/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
|
||||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
|
||||||
Cargo.lock
|
|
||||||
|
|
||||||
# These are backup files generated by rustfmt
|
# These are backup files generated by rustfmt
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
|
# Local brainstorming companion artifacts
|
||||||
|
.superpowers/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
/antidriftd
|
||||||
|
*.test
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
# AntiDrift
|
# AntiDrift
|
||||||
|
|
||||||
Just my personal productivity tool. It asks me about my intention for the next
|
A personal focus operating system: treat each work session as an explicit
|
||||||
work session. Until I have provided an intention, duration, and start a
|
commitment (next action, success condition, timebox), and make drift visible.
|
||||||
session, it forcefully minimizes all windows. It then records all active
|
|
||||||
windows during the session. At the end, it allows me to rate how relevant each
|
|
||||||
window was and calculates a session score from that.
|
|
||||||
|
|
||||||
To use AntiDrift, run `cargo run --release` directly, or `cargo build --release`
|
This is the Go reimagining. The original Rust implementation is preserved under
|
||||||
and copy the binary into your `PATH`.
|
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
|
||||||
|
|
||||||
Under Linux, we use `xdotool` to get window titles and minimize windows. Under
|
## Run
|
||||||
Windows, we use the package `winapi` for the same functionality.
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/antidriftd
|
||||||
|
```
|
||||||
|
|
||||||
|
The daemon serves a local web UI at http://localhost:7777 and opens your
|
||||||
|
browser. State is persisted to `~/.antidrift/state.json`.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
|
||||||
|
into a structured commitment (next action, success condition, timebox) by an
|
||||||
|
LLM CLI backend (claude or codex, selectable via `ANTIDRIFT_AI_BACKEND`). The
|
||||||
|
coach runs asynchronously and degrades gracefully — manual planning always
|
||||||
|
works.
|
||||||
|
|
||||||
|
M1 (evidence & audit): active-window tracking, two-tier evidence store
|
||||||
|
(disposable per-session raw log + permanent hash-chained session summaries),
|
||||||
|
and live SSE updates. Live drift judgment and the ambient nudge arrive in later
|
||||||
|
milestones (see the roadmap in
|
||||||
|
`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`).
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI
|
||||||
|
// and owns the commitment state machine.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/ai"
|
||||||
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/session"
|
||||||
|
"antidrift/internal/store"
|
||||||
|
"antidrift/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addr = "localhost:7777"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
path, err := store.DefaultPath()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("resolve snapshot path: %v", err)
|
||||||
|
}
|
||||||
|
ctrl, err := session.New(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load session: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := web.NewServer(ctrl)
|
||||||
|
srv.Init() // re-arm or expire a restored Active session
|
||||||
|
|
||||||
|
// Wire the AI planning coach. Backend selectable via ANTIDRIFT_AI_BACKEND
|
||||||
|
// (claude default, or codex). Misconfiguration degrades to no coach rather
|
||||||
|
// than failing startup — manual planning still works.
|
||||||
|
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
|
||||||
|
log.Printf("ai coach disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
ctrl.SetCoach(ai.NewService(backend))
|
||||||
|
log.Printf("ai coach: %s backend", backend.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
src := evidence.NewSource()
|
||||||
|
go src.Watch(context.Background(), ctrl.RecordWindow)
|
||||||
|
|
||||||
|
go openBrowser("http://" + addr)
|
||||||
|
|
||||||
|
log.Printf("antidriftd listening on http://%s", addr)
|
||||||
|
if err := srv.Router().Run(addr); err != nil {
|
||||||
|
log.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openBrowser best-effort launches the default browser after a short delay so
|
||||||
|
// the server is listening first. Failures are logged, not fatal.
|
||||||
|
func openBrowser(url string) {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
var cmd string
|
||||||
|
var args []string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
cmd, args = "xdg-open", []string{url}
|
||||||
|
case "darwin":
|
||||||
|
cmd, args = "open", []string{url}
|
||||||
|
case "windows":
|
||||||
|
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
||||||
|
default:
|
||||||
|
log.Printf("open browser manually: %s", url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := exec.Command(cmd, args...).Start(); err != nil {
|
||||||
|
log.Printf("could not open browser (%v); visit %s", err, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,674 @@
|
|||||||
|
# Commitment OS Design
|
||||||
|
|
||||||
|
Date: 2026-05-25
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
AntiDrift should evolve from a session timer and window-rating tool into a commitment operating system: a personal agency layer that makes computer use explicit, constrained, observable, and resistant to casual tampering.
|
||||||
|
|
||||||
|
The system addresses distraction as unconscious context switching, not only as access to bad websites. The guiding rule is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
No unchosen transitions.
|
||||||
|
```
|
||||||
|
|
||||||
|
The computer should not silently enter unconstrained use. It should be in one of these explicit modes:
|
||||||
|
|
||||||
|
- active commitment;
|
||||||
|
- deliberate transition;
|
||||||
|
- constrained planning;
|
||||||
|
- review;
|
||||||
|
- locked state;
|
||||||
|
- delayed administrative override.
|
||||||
|
|
||||||
|
Tamper resistance is a friction gradient against the user's impulsive self. It is not a perfect security boundary. The goal is to defeat impulsive and casual bypasses, delay deliberate bypasses, and make drift visible.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system has five layers that share a common commitment model.
|
||||||
|
|
||||||
|
1. Orientation: projects, responsibilities, values, tasks, next actions, energy constraints.
|
||||||
|
2. Commitment: a concrete next action, success condition, allowed context, timebox, and transition policy.
|
||||||
|
3. Live work mode: desktop, browser, terminal, files, ActivityWatch, window tracking, and future evidence sources.
|
||||||
|
4. Containment: a privileged guardian that enforces the selected commitment across the live environment.
|
||||||
|
5. Reflection: session outcomes, drift events, transition reasons, rule adjustments, and planner updates.
|
||||||
|
|
||||||
|
The commitment model sits between planning and enforcement. Planner integration can later create commitments from tasks. Enforcement operates on policy snapshots derived from commitments without understanding planner internals.
|
||||||
|
|
||||||
|
## Threat Model And Scope
|
||||||
|
|
||||||
|
The adversary is the same person in a less reflective state. The system should be honest about what local software can and cannot enforce.
|
||||||
|
|
||||||
|
| Tier | Bypass effort | Examples | Realistic goal |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0: impulse | less than 1 second | Alt-tab, open browser, type URL, leave desk without choosing transition | Defeat with blocking, prompts, overlays, and explicit transitions |
|
||||||
|
| 1: casual | seconds | Kill process, stop user service, close app, quit ActivityWatch | Defeat with watchdogs, privilege separation, and restart behavior |
|
||||||
|
| 2: determined | minutes | Use sudo, edit config, disable systemd units, reboot, manipulate clock | Delay and audit through delayed admin path and break-glass flow |
|
||||||
|
| 3: premeditated | unconstrained | Boot live USB, GRUB recovery, second device, phone, hardware removal | Out of scope for local software |
|
||||||
|
|
||||||
|
In-scope bypasses:
|
||||||
|
|
||||||
|
- opening disallowed domains or apps;
|
||||||
|
- quitting AntiDrift or ActivityWatch;
|
||||||
|
- stopping user-level services;
|
||||||
|
- entering unconstrained planning;
|
||||||
|
- changing rules without delayed admin access;
|
||||||
|
- crash-looping the user agent;
|
||||||
|
- malformed policy snapshots;
|
||||||
|
- clock manipulation attempts visible to the running system;
|
||||||
|
- unmonitored desk absence after future presence sensing exists.
|
||||||
|
|
||||||
|
Out-of-scope or explicitly limited:
|
||||||
|
|
||||||
|
- second phones, tablets, or other computers;
|
||||||
|
- booting from external media or recovery shell;
|
||||||
|
- physical tampering;
|
||||||
|
- fully premeditated bypasses with unlimited time;
|
||||||
|
- coercive monitoring of another person.
|
||||||
|
|
||||||
|
Known edge cases to address or scope per implementation stage:
|
||||||
|
|
||||||
|
- switching TTYs or desktop sessions;
|
||||||
|
- Wayland compositor limitations;
|
||||||
|
- VMs and nested display servers;
|
||||||
|
- SSH into the same machine;
|
||||||
|
- browser profiles and extensions;
|
||||||
|
- Flatpak/Snap application identity;
|
||||||
|
- multi-user machines;
|
||||||
|
- multiple monitors, virtual desktops, and tiling window managers;
|
||||||
|
- direct modification of ActivityWatch data.
|
||||||
|
|
||||||
|
Stage 1 does not need to solve every edge case, but it must not pretend they are solved.
|
||||||
|
|
||||||
|
## Linux Target Environment
|
||||||
|
|
||||||
|
The first target is the user's Linux workstation.
|
||||||
|
|
||||||
|
Stage 1 should support the current X11-style active-window model already used by AntiDrift through `xdotool`, plus ActivityWatch evidence when available. If the session is Wayland and active window metadata cannot be collected reliably, the system must surface that as a degraded evidence mode rather than silently reporting false confidence.
|
||||||
|
|
||||||
|
Stage 2 should choose concrete Linux enforcement mechanisms per target environment:
|
||||||
|
|
||||||
|
- systemd system service for the privileged guardian;
|
||||||
|
- systemd user service or normal user process for the agent;
|
||||||
|
- nftables or DNS-level controls for domain blocking;
|
||||||
|
- window-class interruption where active window details are available;
|
||||||
|
- ActivityWatch watchdog where ActivityWatch is installed and expected.
|
||||||
|
|
||||||
|
Wayland support should be compositor-specific. GNOME Wayland, KDE Wayland, Sway, and Hyprland may need separate adapters. Unknown or unsupported environments should fail into a conservative mode: planning/review may work, but enforcement claims are limited.
|
||||||
|
|
||||||
|
## Domain Model
|
||||||
|
|
||||||
|
The first-pass `Commitment` concept should be split into three related records.
|
||||||
|
|
||||||
|
### Commitment
|
||||||
|
|
||||||
|
User-facing intent and planning artifact.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Commitment
|
||||||
|
id
|
||||||
|
created_at
|
||||||
|
source: manual | planner | recurring | recovery | template
|
||||||
|
project_id: optional initially, planner-backed later
|
||||||
|
template_id: optional
|
||||||
|
next_action: concrete executable action
|
||||||
|
success_condition: what counts as done or meaningfully advanced
|
||||||
|
timebox
|
||||||
|
transition_policy_id
|
||||||
|
state: draft | active | paused | completed | abandoned | violated
|
||||||
|
```
|
||||||
|
|
||||||
|
Only one commitment may be active at a time. Task switches go through `Transition` and either resume the current commitment, abandon it, or create/select a new one.
|
||||||
|
|
||||||
|
### PolicySnapshot
|
||||||
|
|
||||||
|
Versioned enforcement contract consumed by the guardian.
|
||||||
|
|
||||||
|
```text
|
||||||
|
PolicySnapshot
|
||||||
|
id
|
||||||
|
commitment_id
|
||||||
|
schema_version
|
||||||
|
created_at
|
||||||
|
runtime_state: locked | planning | active | transition | review | admin_override
|
||||||
|
enforcement_level: observe | warn | block | locked
|
||||||
|
allowed_context
|
||||||
|
required_monitors
|
||||||
|
violation_actions
|
||||||
|
expires_at
|
||||||
|
generated_by_agent_version
|
||||||
|
```
|
||||||
|
|
||||||
|
The guardian consumes `PolicySnapshot`, not planner internals.
|
||||||
|
|
||||||
|
### SessionRecord And EventLog
|
||||||
|
|
||||||
|
Append-only history of what happened.
|
||||||
|
|
||||||
|
```text
|
||||||
|
SessionRecord
|
||||||
|
id
|
||||||
|
commitment_id
|
||||||
|
started_at
|
||||||
|
ended_at
|
||||||
|
outcome: completed | partial | abandoned | violated
|
||||||
|
review_rating
|
||||||
|
review_notes
|
||||||
|
|
||||||
|
EventLog
|
||||||
|
sequence
|
||||||
|
timestamp
|
||||||
|
event_type
|
||||||
|
commitment_id
|
||||||
|
runtime_state
|
||||||
|
payload
|
||||||
|
previous_hash
|
||||||
|
hash
|
||||||
|
```
|
||||||
|
|
||||||
|
The event log is the source for review, audit, and later planner writeback.
|
||||||
|
|
||||||
|
## Allowed Context Semantics
|
||||||
|
|
||||||
|
Allowed context should be explicit and conservative. Stage 1 may only observe or warn, but the matching rules should be defined early so Stage 2 can enforce the same contract.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
allowed_context:
|
||||||
|
window_classes:
|
||||||
|
match: exact_lowercase
|
||||||
|
values:
|
||||||
|
- code
|
||||||
|
- alacritty
|
||||||
|
window_titles:
|
||||||
|
match: substring
|
||||||
|
values:
|
||||||
|
- antidrift
|
||||||
|
domains:
|
||||||
|
match: exact_or_subdomain
|
||||||
|
values:
|
||||||
|
- github.com
|
||||||
|
- docs.python.org
|
||||||
|
repos:
|
||||||
|
match: canonical_path_prefix
|
||||||
|
values:
|
||||||
|
- ~/dev/antidrift
|
||||||
|
commands:
|
||||||
|
match: executable_basename
|
||||||
|
values:
|
||||||
|
- cargo
|
||||||
|
- git
|
||||||
|
- rg
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Domains include subdomains unless explicitly marked exact-only.
|
||||||
|
- Full URLs should not be stored by default; store domain and coarse path only when needed.
|
||||||
|
- Window classes are preferred over titles because titles can contain private data and change frequently.
|
||||||
|
- Unknown windows are handled by `enforcement_level`: record in `observe`, prompt in `warn`, interrupt in `block`, and force `Locked` on repeated or severe violations in `locked`.
|
||||||
|
- Terminals are hard to classify. Stage 1 may treat terminal windows as allowed based on window class and record command evidence later only when a shell integration exists.
|
||||||
|
- Child process handling is deferred until command/process enforcement is designed. Stage 2 should not claim process-level policy without cgroups, namespaces, AppArmor, seccomp, or equivalent mechanisms.
|
||||||
|
- Commitments may use per-resource actions. Example: block social domains, warn on unfamiliar apps, observe unknown terminal commands.
|
||||||
|
|
||||||
|
Commitment templates and project defaults should reduce manual allowlist construction:
|
||||||
|
|
||||||
|
- `deep coding`;
|
||||||
|
- `writing`;
|
||||||
|
- `admin`;
|
||||||
|
- `research`;
|
||||||
|
- `rest`;
|
||||||
|
- project-specific default repos, apps, and documentation domains.
|
||||||
|
|
||||||
|
Time-limited context expansion should be allowed when work legitimately discovers a new need. Expansion must be explicit, audited, and bounded, such as "allow `docs.rs` for 20 minutes for this commitment."
|
||||||
|
|
||||||
|
## Runtime State Machine
|
||||||
|
|
||||||
|
Runtime state controls what the machine is allowed to do now.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Locked
|
||||||
|
No valid active commitment. Distracting/default computer use is blocked.
|
||||||
|
|
||||||
|
Planning
|
||||||
|
Constrained UI for inspecting tasks/projects and creating or choosing a commitment.
|
||||||
|
|
||||||
|
Active
|
||||||
|
A commitment is running. Allowed context is available; disallowed context is blocked or interrupted.
|
||||||
|
|
||||||
|
Transition
|
||||||
|
Intentional state change: bodily break, reset, task switch, or session end.
|
||||||
|
Requires reason, expected duration, and return target.
|
||||||
|
|
||||||
|
Review
|
||||||
|
Session ended. The user rates relevance, marks outcome, and records drift/avoidance patterns.
|
||||||
|
|
||||||
|
Admin Override
|
||||||
|
Privileged change path. Requires delayed admin access and leaves audit evidence.
|
||||||
|
```
|
||||||
|
|
||||||
|
Legal transitions:
|
||||||
|
|
||||||
|
| From | To | Guard | Side effects |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Locked | Planning | Planning UI requested | Apply planning policy, start planning timer |
|
||||||
|
| Planning | Active | Valid commitment and policy snapshot accepted by guardian | Create session record, apply active policy |
|
||||||
|
| Planning | Locked | Cancel, timeout, invalid policy, or idle | Apply locked policy |
|
||||||
|
| Active | Transition | Reason, expected duration, and return target provided | Record transition start, apply transition policy |
|
||||||
|
| Active | Review | Commitment completed, abandoned, or timebox expired | Stop active policy, collect evidence summary |
|
||||||
|
| Active | Locked | Severe violation, policy failure, or monitor failure | Record violation, apply locked policy |
|
||||||
|
| Transition | Active | Return before timeout to same commitment | Record return, reapply active policy |
|
||||||
|
| Transition | Planning | Task switch requested | Mark current commitment paused/abandoned as chosen, apply planning policy |
|
||||||
|
| Transition | Review | End session requested | Collect review data |
|
||||||
|
| Transition | Locked | Timeout exceeded or transition policy failure | Record violation, apply locked policy |
|
||||||
|
| Review | Planning | Continue working | Store review, apply planning policy |
|
||||||
|
| Review | Locked | End work period | Store review, apply locked policy |
|
||||||
|
| Any | Admin Override | Delayed admin path completed | Record override, apply requested privileged change |
|
||||||
|
| Admin Override | Previous/new state | Override ends | Record result, apply selected policy |
|
||||||
|
|
||||||
|
Planning must never become unconstrained browsing. It has its own policy:
|
||||||
|
|
||||||
|
- only the planning UI, local task data, and approved references are available;
|
||||||
|
- planning is time-limited;
|
||||||
|
- evidence collection remains active;
|
||||||
|
- opening arbitrary browser tabs from Planning is a violation unless explicitly allowed.
|
||||||
|
|
||||||
|
Suspend/hibernate and reboot should resume into `Locked` unless a valid policy can be restored and verified. A violated commitment may be resumed only through `Review` or `Planning`; resumption should create a visible event.
|
||||||
|
|
||||||
|
## Commitment Lifecycle
|
||||||
|
|
||||||
|
Commitment state is distinct from runtime state.
|
||||||
|
|
||||||
|
| From | To | Guard | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| draft | active | Runtime enters `Active` with accepted policy | Only one active commitment at a time |
|
||||||
|
| active | paused | Runtime enters `Transition` with return target | Paused does not imply unconstrained use |
|
||||||
|
| paused | active | User returns before transition timeout | Same commitment resumes |
|
||||||
|
| active | completed | Success condition met or user completes in review | Requires review |
|
||||||
|
| active | abandoned | User chooses task switch/end without completion | Requires reason |
|
||||||
|
| active | violated | Severe or unresolved violation | May force runtime `Locked` |
|
||||||
|
| paused | abandoned | Transition becomes task switch/end | Requires reason |
|
||||||
|
| violated | active | Explicit recovery flow | Audited; not automatic |
|
||||||
|
| violated | abandoned | Review confirms abandonment | Stored in history |
|
||||||
|
|
||||||
|
## Guardian IPC Contract
|
||||||
|
|
||||||
|
The user agent and guardian communicate through a narrow local API.
|
||||||
|
|
||||||
|
Recommended transport: root-owned Unix domain socket with a small JSON-lines protocol. D-Bus can be reconsidered later if desktop integration requires it, but the first design should avoid unnecessary framework surface.
|
||||||
|
|
||||||
|
Authentication and integrity:
|
||||||
|
|
||||||
|
- The socket path is owned by root and writable only by the expected user/group.
|
||||||
|
- The guardian checks peer credentials with `SO_PEERCRED`.
|
||||||
|
- Messages include `schema_version`, monotonic sequence, and request id.
|
||||||
|
- The guardian rejects malformed, unknown-version, stale, or unauthenticated messages.
|
||||||
|
- Policy changes are logged before and after application.
|
||||||
|
|
||||||
|
Minimal API:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ApplyPolicy(PolicySnapshot) -> Result
|
||||||
|
ReportEvent(Event) -> Ack
|
||||||
|
GetStatus() -> SystemState
|
||||||
|
RequestOverride(OverrideRequest) -> OverrideResult
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure behavior:
|
||||||
|
|
||||||
|
- If the agent disconnects while `Active`, the guardian keeps the last valid policy for a short grace period, then moves to `Locked`.
|
||||||
|
- If the agent sends malformed policy, the guardian rejects it and keeps the previous valid policy; repeated malformed policy escalates to `Locked`.
|
||||||
|
- If IPC is unavailable, the agent should show degraded state and the system should not claim enforcement.
|
||||||
|
- If guardian and agent schema versions are incompatible, the system should enter `Locked` with an actionable error and break-glass instructions.
|
||||||
|
|
||||||
|
## Enforcement Mechanism Matrix
|
||||||
|
|
||||||
|
Stage 2 enforcement should be explicit about feasibility.
|
||||||
|
|
||||||
|
| Resource | Stage 1 | Stage 2 candidate | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Domain | observe from ActivityWatch/browser data if available | nftables or DNS-level blocking | Prefer domains over full URLs for privacy |
|
||||||
|
| App/window | active-window tracking, minimize or overlay | window-class interruption, minimize, SIGSTOP only where safe | Depends on X11/Wayland adapter |
|
||||||
|
| Agent health | local process check | systemd restart and guardian watchdog | Casual process killing should fail |
|
||||||
|
| ActivityWatch health | observe availability | guardian restart and escalation after repeated failure | Direct DB tampering only audited if detectable |
|
||||||
|
| Config changes | local file checks | root-owned config, delayed admin required | Config writes must be audited |
|
||||||
|
| Commands/processes | mostly out of scope | future shell integration, cgroups, AppArmor, seccomp, namespaces | Do not overclaim early |
|
||||||
|
| Files/repos | local path metadata | future filesystem policy only if needed | Path allowlists are mostly planning/evidence initially |
|
||||||
|
| Presence | out of scope | future opt-in sensor events | Requires separate ethics/design review |
|
||||||
|
|
||||||
|
Stage 1 should include Tier 0 friction even without privileged blocking:
|
||||||
|
|
||||||
|
- full-screen or high-priority overlay on violation where feasible;
|
||||||
|
- dismissal requires entering a reason;
|
||||||
|
- explicit transition prompts;
|
||||||
|
- local event logging tied to the active commitment.
|
||||||
|
|
||||||
|
## Delayed Admin And Break-Glass Recovery
|
||||||
|
|
||||||
|
The delayed admin mechanism is load-bearing and must be concrete before Stage 2 hardening.
|
||||||
|
|
||||||
|
Initial assumption:
|
||||||
|
|
||||||
|
- daily work happens in a non-admin user account;
|
||||||
|
- admin access exists in a separate admin account or equivalent path;
|
||||||
|
- admin access already has a delay, currently about 10 minutes, and can be lengthened;
|
||||||
|
- privileged enforcement changes require that delayed path.
|
||||||
|
|
||||||
|
Actions requiring delayed admin:
|
||||||
|
|
||||||
|
- disabling guardian service;
|
||||||
|
- changing locked policy files;
|
||||||
|
- changing blocklists or allowlists outside explicit time-limited context expansion;
|
||||||
|
- uninstalling enforcement;
|
||||||
|
- disabling watchdog behavior;
|
||||||
|
- emergency break-glass.
|
||||||
|
|
||||||
|
Override requests should be logged at request time and completion time:
|
||||||
|
|
||||||
|
```text
|
||||||
|
OverrideRequest
|
||||||
|
id
|
||||||
|
requested_at
|
||||||
|
requester_user
|
||||||
|
reason
|
||||||
|
requested_action
|
||||||
|
earliest_allowed_at
|
||||||
|
completed_at
|
||||||
|
result
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-scheduled instant overrides weaken the system. The guardian should reject override tokens or sentinel files created before the current request window unless they are part of the audited delayed-admin protocol.
|
||||||
|
|
||||||
|
Break-glass recovery must be independent of the normal agent UI. Example: a privileged command or root-owned sentinel file disables enforcement for 10 minutes after delayed admin access. It must:
|
||||||
|
|
||||||
|
- be simple enough to trust under failure;
|
||||||
|
- be audited;
|
||||||
|
- have automatic expiry;
|
||||||
|
- restore normal policy after expiry unless explicitly uninstalled;
|
||||||
|
- not depend on the user agent being healthy.
|
||||||
|
|
||||||
|
## Event Log, Tamper Evidence, And Retention
|
||||||
|
|
||||||
|
Events should be append-only and bounded.
|
||||||
|
|
||||||
|
Recommended initial format: JSON lines with hash chaining.
|
||||||
|
|
||||||
|
```text
|
||||||
|
event_hash = hash(schema_version, sequence, timestamp, event_type, payload, previous_hash)
|
||||||
|
```
|
||||||
|
|
||||||
|
The log should include:
|
||||||
|
|
||||||
|
- policy applications;
|
||||||
|
- state transitions;
|
||||||
|
- violations;
|
||||||
|
- process/monitor failures;
|
||||||
|
- override requests and completions;
|
||||||
|
- review outcomes.
|
||||||
|
|
||||||
|
Retention and storage:
|
||||||
|
|
||||||
|
- rotate logs by size and time;
|
||||||
|
- enforce max disk usage;
|
||||||
|
- define behavior when disk is full;
|
||||||
|
- support export and deletion through delayed admin if enforcement logs are involved;
|
||||||
|
- include schema version and migration path.
|
||||||
|
|
||||||
|
If logging fails:
|
||||||
|
|
||||||
|
- non-critical review notes may be skipped with visible warning;
|
||||||
|
- enforcement/audit events should fail conservative, usually `Locked`, unless doing so would create an unsafe machine lockout;
|
||||||
|
- break-glass must still work even when normal logging is impaired, with best-effort emergency log.
|
||||||
|
|
||||||
|
## Privacy, Ethics, And Data Retention
|
||||||
|
|
||||||
|
This is voluntary self-use software. It must not become bossware, parental control software, or coercive monitoring.
|
||||||
|
|
||||||
|
Principles:
|
||||||
|
|
||||||
|
- local-first storage by default;
|
||||||
|
- no network transmission unless explicitly configured;
|
||||||
|
- minimum necessary observation;
|
||||||
|
- prefer domain over full URL;
|
||||||
|
- prefer window class over title where possible;
|
||||||
|
- treat window titles as sensitive because they can contain private messages, documents, or secrets;
|
||||||
|
- encrypt logs/history at rest if feasible, especially once planner and presence data exist;
|
||||||
|
- provide retention, deletion, and export tools;
|
||||||
|
- make data collection visible to the user;
|
||||||
|
- include first-class `rest` and `exploration` commitments so legitimate openness and recovery are not treated as failure.
|
||||||
|
|
||||||
|
The system should increase agency, not create surveillance anxiety. Presence sensing, camera use, posture detection, and phone pickup detection require a separate opt-in design and ethics review after the core system is useful.
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
Failures should have explicit fail-open or fail-closed behavior.
|
||||||
|
|
||||||
|
| Failure | Default behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| User agent crash | Guardian restarts agent; if repeated, enter `Locked` |
|
||||||
|
| User agent crash loop | Enter `Locked`, show recovery instructions |
|
||||||
|
| Guardian crash | systemd restarts guardian; until restored, user agent shows enforcement degraded |
|
||||||
|
| Guardian crash loop | Fail conservative where possible, but keep break-glass available |
|
||||||
|
| ActivityWatch stopped | Guardian restarts it; repeated failure escalates to `Locked` if ActivityWatch is required |
|
||||||
|
| ActivityWatch unavailable | Run degraded if policy does not require it; otherwise refuse `Active` |
|
||||||
|
| ActivityWatch data corrupted | Mark evidence degraded, preserve raw error, continue only if policy allows |
|
||||||
|
| Policy apply failure | Refuse `Active`; stay `Planning` or move `Locked` |
|
||||||
|
| Malformed policy | Reject, keep previous valid policy, escalate after repeated failures |
|
||||||
|
| Disk full/log write failure | Warn; fail conservative for enforcement events; keep break-glass available |
|
||||||
|
| System reboot | Start in `Locked` until state is restored and verified |
|
||||||
|
| Suspend/hibernate | On resume, revalidate timebox and policy; otherwise enter `Locked` |
|
||||||
|
| Clock manipulation | Prefer monotonic timers for timeboxes; record wall-clock anomalies |
|
||||||
|
| OS updates | Require delayed admin or maintenance commitment |
|
||||||
|
| Emergency interruption | Use transition or break-glass depending on severity |
|
||||||
|
| Schema migration failure | Enter `Locked` with recovery instructions |
|
||||||
|
|
||||||
|
The primary invariant is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Without an active valid commitment, the machine cannot silently enter unconstrained use.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Locked, Planning, Transition, And Review UX
|
||||||
|
|
||||||
|
Locked state should show a small, reliable UI:
|
||||||
|
|
||||||
|
- current status;
|
||||||
|
- reason for lock;
|
||||||
|
- option to enter constrained Planning;
|
||||||
|
- option to review last session if pending;
|
||||||
|
- break-glass instructions that require delayed admin.
|
||||||
|
|
||||||
|
Planning should be useful but constrained:
|
||||||
|
|
||||||
|
- local project/task list;
|
||||||
|
- commitment templates;
|
||||||
|
- recent commitments;
|
||||||
|
- allowed local notes or references;
|
||||||
|
- time limit;
|
||||||
|
- no arbitrary browsing unless a planning policy explicitly allows it.
|
||||||
|
|
||||||
|
Transition should force consciousness into state changes:
|
||||||
|
|
||||||
|
- reason;
|
||||||
|
- expected duration;
|
||||||
|
- return target;
|
||||||
|
- optional note;
|
||||||
|
- timeout behavior.
|
||||||
|
|
||||||
|
Review should be difficult to skip:
|
||||||
|
|
||||||
|
- mark outcome;
|
||||||
|
- rate relevance;
|
||||||
|
- record drift or transition failures;
|
||||||
|
- update commitment/task state;
|
||||||
|
- generate next suggested commitment where useful.
|
||||||
|
|
||||||
|
## Staged Implementation
|
||||||
|
|
||||||
|
### Stage 1a: Commitment Kernel
|
||||||
|
|
||||||
|
- Commitment schema.
|
||||||
|
- PolicySnapshot schema without privileged enforcement.
|
||||||
|
- Runtime and commitment state machines.
|
||||||
|
- Append-only local event log.
|
||||||
|
- Unit tests for legal and illegal transitions.
|
||||||
|
|
||||||
|
### Stage 1b: Session UI And Evidence
|
||||||
|
|
||||||
|
- Session UI using commitment fields.
|
||||||
|
- Review flow.
|
||||||
|
- Activity/window evidence linked to `commitment_id`.
|
||||||
|
- Degraded evidence reporting when ActivityWatch or window metadata is unavailable.
|
||||||
|
|
||||||
|
### Stage 1c: Tier 0 Friction
|
||||||
|
|
||||||
|
- Full-screen or high-priority overlay on violation where feasible.
|
||||||
|
- Dismissal requires deliberate reason entry.
|
||||||
|
- Explicit transition prompts.
|
||||||
|
- Unknown-window/domain behavior based on enforcement level.
|
||||||
|
|
||||||
|
### Stage 2a: Guardian Skeleton
|
||||||
|
|
||||||
|
- systemd system service for guardian.
|
||||||
|
- systemd user service or launcher for agent.
|
||||||
|
- Watchdog restart behavior for user agent and ActivityWatch.
|
||||||
|
- Minimal status reporting.
|
||||||
|
|
||||||
|
### Stage 2b: IPC And Policy Delivery
|
||||||
|
|
||||||
|
- Root-owned Unix domain socket.
|
||||||
|
- JSON-lines protocol.
|
||||||
|
- `ApplyPolicy`, `ReportEvent`, `GetStatus`, `RequestOverride`.
|
||||||
|
- Schema versioning and malformed-message handling.
|
||||||
|
|
||||||
|
### Stage 2c: Domain Blocking
|
||||||
|
|
||||||
|
- nftables or DNS-level policy application.
|
||||||
|
- Domain matching semantics.
|
||||||
|
- Policy rollback on failure.
|
||||||
|
|
||||||
|
### Stage 2d: App/Window Interruption
|
||||||
|
|
||||||
|
- X11 adapter first if current environment is X11.
|
||||||
|
- Compositor-specific Wayland adapters only after verification.
|
||||||
|
- Minimize, overlay, or interrupt based on policy.
|
||||||
|
|
||||||
|
### Stage 2e: Tamper-Evident Logs And Retention
|
||||||
|
|
||||||
|
- Hash-chained events.
|
||||||
|
- Rotation and max disk usage.
|
||||||
|
- Export/delete path.
|
||||||
|
- Log failure handling.
|
||||||
|
|
||||||
|
### Stage 2f: Delayed Admin And Config Lockdown
|
||||||
|
|
||||||
|
- Root-owned policy/config.
|
||||||
|
- Delayed override protocol.
|
||||||
|
- Break-glass command.
|
||||||
|
- Uninstall/rollback tooling.
|
||||||
|
|
||||||
|
### Stage 3a: Project And Task Model
|
||||||
|
|
||||||
|
- Projects.
|
||||||
|
- Tasks.
|
||||||
|
- Next actions.
|
||||||
|
- Recurring responsibilities.
|
||||||
|
|
||||||
|
### Stage 3b: Commitment Templates From Tasks
|
||||||
|
|
||||||
|
- Project defaults.
|
||||||
|
- Work-mode templates.
|
||||||
|
- Task selection during Planning state.
|
||||||
|
- Time-limited context expansion.
|
||||||
|
|
||||||
|
### Stage 3c: Outcome Writeback
|
||||||
|
|
||||||
|
- Session outcomes written back to tasks.
|
||||||
|
- Review suggestions based on drift history.
|
||||||
|
- Next commitment suggestions.
|
||||||
|
|
||||||
|
## Future Direction: Embodied And Presence Sensing
|
||||||
|
|
||||||
|
Presence sensing is not part of the core roadmap. It may be considered later only if:
|
||||||
|
|
||||||
|
- the core system has proven useful;
|
||||||
|
- the user explicitly opts in;
|
||||||
|
- data minimization is defined;
|
||||||
|
- psychological safety is reviewed;
|
||||||
|
- retention and deletion behavior are explicit.
|
||||||
|
|
||||||
|
Potential future events:
|
||||||
|
|
||||||
|
- `desk_absence_started`;
|
||||||
|
- `desk_absence_resolved`;
|
||||||
|
- `posture_warning`;
|
||||||
|
- `phone_pickup_detected`.
|
||||||
|
|
||||||
|
These should be evidence events, not core dependencies.
|
||||||
|
|
||||||
|
## Uninstall And Rollback
|
||||||
|
|
||||||
|
The system must have a clean removal path.
|
||||||
|
|
||||||
|
Uninstall should:
|
||||||
|
|
||||||
|
- disable guardian service;
|
||||||
|
- disable user agent service;
|
||||||
|
- remove nftables or DNS rules;
|
||||||
|
- remove root-owned policy/config files after delayed admin confirmation;
|
||||||
|
- export or delete logs;
|
||||||
|
- restore normal admin behavior if it was modified for AntiDrift;
|
||||||
|
- verify that no blocking policy remains active.
|
||||||
|
|
||||||
|
Rollback should support returning from a failed upgrade to the previous known-good policy and guardian version.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Testing must include bypass resistance, not only happy paths.
|
||||||
|
|
||||||
|
Unit tests:
|
||||||
|
|
||||||
|
- commitment validation;
|
||||||
|
- legal and illegal runtime transitions;
|
||||||
|
- legal and illegal commitment transitions;
|
||||||
|
- PolicySnapshot generation;
|
||||||
|
- allowed context matching;
|
||||||
|
- event hash chaining.
|
||||||
|
|
||||||
|
Property tests:
|
||||||
|
|
||||||
|
- no unconstrained use without valid active commitment;
|
||||||
|
- illegal transitions do not mutate state;
|
||||||
|
- expired policy snapshots are rejected.
|
||||||
|
|
||||||
|
Integration tests:
|
||||||
|
|
||||||
|
- user agent writes state;
|
||||||
|
- guardian reads policy;
|
||||||
|
- simulated window/ActivityWatch events produce expected violations;
|
||||||
|
- malformed policy is rejected;
|
||||||
|
- agent disconnect triggers grace period then `Locked`;
|
||||||
|
- timebox expiry moves to `Review` or `Locked` as configured.
|
||||||
|
|
||||||
|
Adversarial VM/manual tests:
|
||||||
|
|
||||||
|
- kill agent;
|
||||||
|
- stop ActivityWatch;
|
||||||
|
- attempt blocked domain;
|
||||||
|
- malformed policy;
|
||||||
|
- guardian restart;
|
||||||
|
- guardian crash loop;
|
||||||
|
- timebox expiry;
|
||||||
|
- config change attempt;
|
||||||
|
- reboot during active commitment;
|
||||||
|
- suspend/resume during active commitment;
|
||||||
|
- disk full or log write failure.
|
||||||
|
|
||||||
|
Soak and upgrade tests:
|
||||||
|
|
||||||
|
- 24-48 hour run with normal use;
|
||||||
|
- log rotation;
|
||||||
|
- schema migration;
|
||||||
|
- downgrade/rollback;
|
||||||
|
- IPC fuzz tests.
|
||||||
|
|
||||||
|
The first implementation plan should target Stage 1a through Stage 1c while preserving the process boundary and data contracts needed for Stage 2 and planner integration.
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
# AntiDrift Go Reimagining — Design
|
||||||
|
|
||||||
|
Date: 2026-05-31
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
AntiDrift is being reimagined from its current Rust implementation (~7,500
|
||||||
|
lines) into Go, to become the user's focus operating system on the computer:
|
||||||
|
fast, good-looking, and deeply integrated with AI from the start.
|
||||||
|
|
||||||
|
This is **not a faithful 1:1 port**. The existing domain model and the
|
||||||
|
`commitment-os-design.md` spec remain the north star. The Rust code is a
|
||||||
|
reference, not a thing to replicate line-for-line. The move to Go is an
|
||||||
|
opportunity to shed incidental complexity — most notably the token-heavy
|
||||||
|
event-log replay/revalidation design — while preserving what is genuinely
|
||||||
|
valuable.
|
||||||
|
|
||||||
|
### Why Go, why now
|
||||||
|
|
||||||
|
- **Token efficiency for AI-assisted development.** The current pain is not
|
||||||
|
runtime cost; it is that any AI edit to the core must load
|
||||||
|
`session.rs` (3,475 lines, ~80% replay-validation logic and its tests).
|
||||||
|
Go's smaller idioms plus a redesigned, smaller core directly reduce the
|
||||||
|
context any change requires.
|
||||||
|
- **Predictable LLM codegen.** Go's rigid syntax produces functional code on
|
||||||
|
the first pass with fewer correction loops.
|
||||||
|
- **Runtime fit.** Concurrency and local HTTP serving are first-class, which
|
||||||
|
suits a long-running focus daemon that also talks to AI.
|
||||||
|
|
||||||
|
## What Carries Over vs. What Changes
|
||||||
|
|
||||||
|
**Preserved (high value, ports cleanly):**
|
||||||
|
|
||||||
|
- The domain model: `Commitment`, `PolicySnapshot`, `RuntimeState`,
|
||||||
|
`CommitmentState`, `AllowedContext`, `EnforcementLevel`.
|
||||||
|
- The pure runtime/commitment state machines (`state_machine.rs`) — a near 1:1
|
||||||
|
port.
|
||||||
|
- The `commitment-os-design.md` spec as the conceptual foundation, including
|
||||||
|
"no unchosen transitions" and the staged threat model.
|
||||||
|
- Hash-chained tamper evidence — but relocated to the audit log only.
|
||||||
|
|
||||||
|
**Reimagined:**
|
||||||
|
|
||||||
|
- **Persistence.** Replace replay-everything-and-revalidate-on-startup with an
|
||||||
|
in-memory state-of-truth, a persisted **snapshot**, and an append-only audit
|
||||||
|
log. This removes roughly 3,000 lines (the bulk of `session.rs`).
|
||||||
|
- **UI.** Replace the ratatui TUI with a **local web app** (Gin backend +
|
||||||
|
browser). This is the surface that must "look good."
|
||||||
|
- **AI.** AI is a first-class participant from the start, not a later add-on.
|
||||||
|
|
||||||
|
**Deferred for v1:**
|
||||||
|
|
||||||
|
- The AI **reviewer** role (session-end reflection). The three live roles ship
|
||||||
|
first; the reviewer returns as **M7 — Reflection**, which closes the loop.
|
||||||
|
- Privileged enforcement (guardian, IPC, nftables, delayed admin) — same Stage 2
|
||||||
|
boundary as the original spec. This is the path toward the **entry gate**
|
||||||
|
(see "Destination" in the Roadmap) and is deliberately the last milestone.
|
||||||
|
|
||||||
|
## Process Model
|
||||||
|
|
||||||
|
A single Go binary, `antidriftd`, runs as a **local daemon** and owns all
|
||||||
|
state. The **browser** is its face.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ antidriftd (one Go process) │
|
||||||
|
│ │
|
||||||
|
│ web (Gin) ──HTTP + SSE──▶ browser UI │
|
||||||
|
│ │ │
|
||||||
|
│ session ── statemachine ── domain │
|
||||||
|
│ │ │
|
||||||
|
│ store (snapshot + audit log) │
|
||||||
|
│ evidence (xdotool/X11) │
|
||||||
|
│ ai (CLI backend, async workers) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- The daemon holds live state **in memory** as the single source of truth.
|
||||||
|
- It persists a **snapshot** on every state change (crash/restart recovery).
|
||||||
|
- It appends every significant event to an **append-only audit log** (the
|
||||||
|
tamper-evident, hash-chained trail — for audit and later review, not for
|
||||||
|
state reconstruction).
|
||||||
|
- The browser is stateless: it renders what the daemon pushes over Server-Sent
|
||||||
|
Events (SSE) and POSTs user actions back. No business logic in the browser.
|
||||||
|
|
||||||
|
### Why snapshot instead of replay
|
||||||
|
|
||||||
|
The original Rust design reconstructs all state by replaying the entire event
|
||||||
|
log on startup and re-validating every transition, with a dedicated test per
|
||||||
|
illegal sequence. That is correct and tamper-aware, but it is the single
|
||||||
|
largest source of code and token weight. A snapshot of current state plus an
|
||||||
|
append-only audit trail gives the same recoverability and keeps tamper evidence
|
||||||
|
on the log, at a fraction of the code. State-machine *correctness* is still
|
||||||
|
enforced — by the pure transition functions at the point of transition, tested
|
||||||
|
directly — just not re-litigated on every startup.
|
||||||
|
|
||||||
|
## Architecture: Ports Around a Decision Core
|
||||||
|
|
||||||
|
AntiDrift is a **focus brain**: a decision core surrounded by pluggable
|
||||||
|
interfaces (ports) to the outside world. This is a ports-and-adapters
|
||||||
|
(hexagonal) architecture, and it is the organizing principle the whole system
|
||||||
|
grows along. New capability is almost always "a new port + adapter," not a
|
||||||
|
change to the core.
|
||||||
|
|
||||||
|
The core is layered, and the layering is load-bearing:
|
||||||
|
|
||||||
|
- **Skeleton — deterministic, no I/O** (`domain` + `statemachine`). The rails.
|
||||||
|
Owns what moves are *legal*. The original spec's safety property, "no
|
||||||
|
unchosen transitions," lives here: the system can only ever be in a legal
|
||||||
|
state, reached by a legal move.
|
||||||
|
- **Nervous system — the orchestrator** (`session.Controller`). The single hub.
|
||||||
|
Holds the in-memory state-of-truth, routes signals between ports and the
|
||||||
|
skeleton, persists snapshots, appends to the audit log, and broadcasts.
|
||||||
|
Everything connects through here.
|
||||||
|
- **Cortex — the advisor** (the LLM, via the `ai` port). Powerful *judgment* at
|
||||||
|
the decision points the state machine exposes — sharpen this commitment, is
|
||||||
|
this window drift, nudge me. It informs and proposes; **it can never force an
|
||||||
|
illegal transition.** The LLM is the most powerful adapter, not the kernel.
|
||||||
|
|
||||||
|
"The brain" is all three together. Critically, the state machine — not the LLM
|
||||||
|
— owns transitions; the LLM acts only within the rails the skeleton enforces.
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
Each port is a small Go interface with one real adapter (and a fake for tests).
|
||||||
|
|
||||||
|
| Port | Interface | "Answers" | Adapter(s) | Milestone |
|
||||||
|
| ---- | --------- | --------- | ---------- | --------- |
|
||||||
|
| Activity | `evidence.Source` | What am I doing right now? | X11 / xgbutil (was xdotool) | M1 |
|
||||||
|
| Advisor | `ai.Assistant` (`Coach`/`JudgeDrift`/`Nudge`) | What's the smart call here? | `claude`/`codex` CLI | M2–M3 |
|
||||||
|
| Tasks | `tasks.Provider` | What *should* I be doing? | Amazing Marvin (existing `ampy` + marvin MCP) | deferred (M5) |
|
||||||
|
| Knowledge | `knowledge.Source` | Who am I; what are my priorities? | PKM / files | deferred (M6) |
|
||||||
|
| Enforcement / Gate | `enforce.Guard` | Make drift cost something — ultimately, gate the machine on a declared intention | window-minimize (legacy `minimize_other`) → nftables/guardian → entry gate | deferred (M8) |
|
||||||
|
| UI | `web` | Show me; take my input | Gin + browser over SSE | M0, ongoing |
|
||||||
|
|
||||||
|
Persistence (`store`) is infrastructure shared by the orchestrator, not a port.
|
||||||
|
|
||||||
|
The `tasks`, `knowledge`, and `enforce` ports are **named now but built later**
|
||||||
|
— defining them keeps the architecture coherent without expanding near-term
|
||||||
|
scope. We resist designing their interfaces in detail until the milestone that
|
||||||
|
builds them, to avoid speculative abstraction (YAGNI). M1 ships the first real
|
||||||
|
port end-to-end (`evidence.Source` + X11 adapter + fake), establishing the
|
||||||
|
pattern every later port copies.
|
||||||
|
|
||||||
|
## Package Layout
|
||||||
|
|
||||||
|
| Package | Ports from | Size | Purpose |
|
||||||
|
| -------------- | ------------------------ | ------ | ------- |
|
||||||
|
| `domain` | `domain.rs` | small | Commitment, PolicySnapshot, runtime/commitment states, AllowedContext, EnforcementLevel, validation |
|
||||||
|
| `statemachine` | `state_machine.rs` | small | Pure transition functions (1:1 port) |
|
||||||
|
| `session` | reimagined `session.rs` | medium | In-memory controller; drives transitions, snapshots, audit appends; no replay validation |
|
||||||
|
| `store` | `event_log.rs` | small | Snapshot file (current state) + append-only hash-chained audit JSONL |
|
||||||
|
| `evidence` | `window/*` + `context.rs`| small | Active-window snapshot (xdotool/X11), evidence health, allowed-context matching |
|
||||||
|
| `ai` | new | small | `Coach` / `JudgeDrift` / `Nudge` behind one interface; CLI backend |
|
||||||
|
| `web` | new (replaces TUI) | medium | Gin routes, SSE stream, static browser UI |
|
||||||
|
| `tasks` | new (deferred, M5) | small | `Provider` port over current to-do items; Amazing Marvin adapter |
|
||||||
|
| `knowledge` | new (deferred, M6) | small | `Source` port over personal priorities / about-me context |
|
||||||
|
| `enforce` | `window/*` (minimize) | small | `Guard` port; make drift cost something (window-minimize → nftables/guardian) |
|
||||||
|
|
||||||
|
Design constraint: every package stays small and single-purpose so an AI edit
|
||||||
|
loads one focused file, not a monolith. This is the concrete mechanism for the
|
||||||
|
token-efficiency goal.
|
||||||
|
|
||||||
|
## AI Integration
|
||||||
|
|
||||||
|
AI is reached through one narrow interface with a single CLI backend to start:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Assistant interface {
|
||||||
|
// Planning: turn a vague intent into a concrete commitment.
|
||||||
|
Coach(ctx context.Context, intent string) (domain.Commitment, error)
|
||||||
|
// Live: is the current window on-task for this commitment?
|
||||||
|
JudgeDrift(ctx context.Context, c domain.Commitment, w evidence.WindowSnapshot) (Verdict, error)
|
||||||
|
// Ambient: periodic check-in based on recent activity.
|
||||||
|
Nudge(ctx context.Context, c domain.Commitment, recent []evidence.WindowSnapshot) (string, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Backend (v1):** shell out to `claude`/`codex` with a strict prompt that
|
||||||
|
demands JSON output. Reuses existing CLI auth; no API key plumbing.
|
||||||
|
- **Latency containment** (the CLI is slow, ~seconds, and AI is in the live hot
|
||||||
|
path): all AI calls run in **background goroutines**; the UI never blocks.
|
||||||
|
Drift judgments are **debounced** (no faster than ~10s) and **cached per
|
||||||
|
(commitment, window-class)** so the same window is not re-judged. The UI
|
||||||
|
shows a pending state and updates via SSE when a verdict lands.
|
||||||
|
- **Swap path:** the interface boundary lets an Anthropic API backend (faster,
|
||||||
|
structured, prompt-cached) drop in later without touching callers. Not built
|
||||||
|
in v1.
|
||||||
|
|
||||||
|
The three live roles ship first: planning **coach**, live **drift
|
||||||
|
interceptor**, ambient **nudge**. The reviewer is deferred. The advisor sits at
|
||||||
|
the **cortex** layer of the decision core (see "Architecture: Ports Around a
|
||||||
|
Decision Core"): it proposes and judges at the decision points the state
|
||||||
|
machine exposes, but never owns a transition.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Each milestone is independently shippable and gets its own spec → plan → build
|
||||||
|
cycle. M0–M4 build the core plus the first two ports (activity, advisor) and the
|
||||||
|
UI; M5–M8 add the remaining ports and close the loop, each a small interface +
|
||||||
|
adapter following the pattern M1 establishes.
|
||||||
|
|
||||||
|
**Destination — the gate-first loop.** The end state is an OS-level focus loop:
|
||||||
|
the **Guard** checks for a declared intention *before* the machine is usable,
|
||||||
|
the user commits, works under monitoring, and every cycle ends in
|
||||||
|
**reflection** before returning to the gate (gate → plan → work → reflect →
|
||||||
|
gate). The earlier milestones build "track and advise"; the later ones turn
|
||||||
|
that into "you don't drift in the first place." We get there incrementally — the
|
||||||
|
Guard's privileged entry-gate behavior is deliberately the last and heaviest
|
||||||
|
step (M8), and everything before it is valuable standalone. The runtime state
|
||||||
|
machine already mirrors this loop: `locked` is the gate, `planning`/`active` are
|
||||||
|
declare-and-work, `review` is reflection.
|
||||||
|
|
||||||
|
- **M0 — Walking skeleton.** Daemon + Gin + minimal browser UI; port `domain` +
|
||||||
|
`statemachine`; snapshot persistence; manual commitment → timebox → end.
|
||||||
|
Proves the full stack end-to-end. No AI, no window tracking.
|
||||||
|
- **M1 — Evidence & audit.** X11 (xgbutil) active-window tracking via the
|
||||||
|
`evidence.Source` port, evidence health, per-window time stats, append-only
|
||||||
|
hash-chained audit log, live SSE updates. Establishes the port pattern.
|
||||||
|
- **M2 — AI planning coach.** `ai` port + CLI backend; "sharpen this
|
||||||
|
commitment" in the Planning view.
|
||||||
|
- **M3 — Drift interceptor + ambient nudge.** Allowed-context matching + live
|
||||||
|
AI drift judgment (debounced/cached) + violation friction UI.
|
||||||
|
- **M4 — Look good.** A real design pass on the web UI.
|
||||||
|
- **M5 — Tasks port.** `tasks.Provider` over current to-do items; Amazing Marvin
|
||||||
|
adapter. Pull the day's commitments from real tasks.
|
||||||
|
- **M6 — Knowledge port.** `knowledge.Source` over personal priorities and
|
||||||
|
about-me context, feeding the advisor richer grounding.
|
||||||
|
- **M7 — Reflection.** The deferred AI **reviewer** role, promoted into the main
|
||||||
|
loop: a session-end reflection that reads the audit trail and per-window stats
|
||||||
|
(built in M1) to summarize what happened and feed the next planning cycle.
|
||||||
|
Closes the "Focus OS Reflection" step and makes the loop self-reinforcing.
|
||||||
|
- **M8 — Enforcement & gate.** `enforce.Guard`: make drift cost something,
|
||||||
|
starting with window-minimize (porting legacy `minimize_other`) and the
|
||||||
|
nftables/DNS path, building toward the **entry gate** — the Guard checking for
|
||||||
|
a declared intention before the machine is usable. The privileged guardian
|
||||||
|
process, root-owned IPC, and break-glass keep the original Stage 2 threat
|
||||||
|
boundary and are the final, heaviest step.
|
||||||
|
|
||||||
|
The first sub-project to brainstorm and spec in detail is **M0**.
|
||||||
|
|
||||||
|
## Repo Strategy
|
||||||
|
|
||||||
|
- New Go module at the repository root.
|
||||||
|
- Move the existing Rust into a `legacy/` directory (or a `rust` branch) so it
|
||||||
|
remains available as reference while the Go code becomes the front door.
|
||||||
|
|
||||||
|
## Out of Scope (v1)
|
||||||
|
|
||||||
|
"v1" here means the first shippable arc, **M0–M4** (core + activity/advisor
|
||||||
|
ports + UI). The items below are deferred past it; some are now named ports with
|
||||||
|
their own later milestones (see Roadmap), others remain fully out of scope.
|
||||||
|
|
||||||
|
- **Tasks, knowledge, and enforcement ports** — named in the architecture and
|
||||||
|
slotted as M5–M8, but not built in v1. The `enforce.Guard` port starts with
|
||||||
|
window-minimize and builds toward the entry gate; its **privileged** adapters
|
||||||
|
(guardian process, root-owned Unix socket IPC, nftables/DNS domain blocking,
|
||||||
|
delayed admin, break-glass) remain out of scope until M8 and keep the original
|
||||||
|
Stage 2 threat boundary.
|
||||||
|
- AI reviewer / session-end reflection — now scheduled as **M7 — Reflection**.
|
||||||
|
- Wayland compositor adapters beyond the existing degraded reporting.
|
||||||
|
- Planner/project model and outcome writeback (beyond the M5 tasks port).
|
||||||
|
- Presence sensing.
|
||||||
|
|
||||||
|
These remain governed by `commitment-os-design.md` and may return as later
|
||||||
|
milestones.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# M0 — Walking Skeleton Design
|
||||||
|
|
||||||
|
Date: 2026-05-31
|
||||||
|
|
||||||
|
Parent design: `2026-05-31-go-focus-os-design.md`
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
M0 proves the entire Go stack end-to-end with the smallest possible surface: a
|
||||||
|
single Go daemon (`antidriftd`) that serves a local web UI, drives one manual
|
||||||
|
commitment through the ported state machine, and persists a snapshot that
|
||||||
|
survives restart.
|
||||||
|
|
||||||
|
M0 explicitly excludes AI, active-window tracking, and the hash-chained audit
|
||||||
|
log (those arrive in M1+). What M0 establishes is the real architecture — the
|
||||||
|
daemon process model, the ported domain/state-machine, snapshot persistence,
|
||||||
|
and the SSE sync channel — so later milestones add features to a working spine
|
||||||
|
rather than scaffolding.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- Port `domain` (Commitment, runtime/commitment states, EnforcementLevel,
|
||||||
|
minimal PolicySnapshot, validation).
|
||||||
|
- Port `statemachine` (pure runtime/commitment transition functions).
|
||||||
|
- `session.Controller`: in-memory state of truth behind a mutex; snapshot on
|
||||||
|
every change.
|
||||||
|
- `store`: snapshot load/save as JSON.
|
||||||
|
- `web`: Gin server with the M0 HTTP surface and an SSE stream.
|
||||||
|
- A single-page vanilla-JS browser UI covering the M0 state flow.
|
||||||
|
- Unit tests for domain, statemachine, session; httptest tests for web.
|
||||||
|
|
||||||
|
Out of scope (deferred):
|
||||||
|
|
||||||
|
- AI of any kind (M2+).
|
||||||
|
- Active-window tracking and evidence health (M1).
|
||||||
|
- Hash-chained append-only audit log (M1).
|
||||||
|
- Allowed-context matching and violation friction (M3).
|
||||||
|
- Transition (break) flow, admin override, planner — full machine edges beyond
|
||||||
|
the M0 subset.
|
||||||
|
- Visual design polish (M4).
|
||||||
|
|
||||||
|
## State Flow
|
||||||
|
|
||||||
|
M0 exercises a subset of the full runtime state machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
Locked ──Plan──▶ Planning ──Start──▶ Active ──Complete / timebox expiry──▶ Review ──End──▶ Locked
|
||||||
|
```
|
||||||
|
|
||||||
|
All transitions go through the ported pure transition functions, so behavior is
|
||||||
|
correct from day one even though fewer edges are exercised. Mapping to the
|
||||||
|
ported actions:
|
||||||
|
|
||||||
|
- Locked → Planning: `EnterPlanning`
|
||||||
|
- Planning → Active: `Activate { policy_accepted: true }` (commitment also
|
||||||
|
`Activate`s draft → active)
|
||||||
|
- Active → Review: `CompleteForReview` (triggered by user "Complete" or by
|
||||||
|
server-side timebox expiry)
|
||||||
|
- Review → Locked: `EndWorkPeriod`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `domain`
|
||||||
|
|
||||||
|
Direct port of the valuable Rust types:
|
||||||
|
|
||||||
|
- `Commitment` (id, createdAt, source, next action, success condition, timebox
|
||||||
|
seconds, state) with `NewManual(...)` validation: non-empty next action,
|
||||||
|
non-empty success condition, non-zero timebox.
|
||||||
|
- `RuntimeState` (Locked, Planning, Active, Transition, Review, AdminOverride)
|
||||||
|
and `CommitmentState` (Draft, Active, Paused, Completed, Abandoned, Violated)
|
||||||
|
— full enums, even though M0 uses a subset, so later milestones need no
|
||||||
|
changes here.
|
||||||
|
- `EnforcementLevel` (Observe, Warn, Block, Locked).
|
||||||
|
- `PolicySnapshot`: minimal — enough to represent the accepted policy that gates
|
||||||
|
`Activate`. Full enforcement fields can stay zero/empty in M0.
|
||||||
|
- IDs use UUIDv7 (or equivalent monotonic unique id); JSON tags use snake_case
|
||||||
|
to match the existing on-disk vocabulary.
|
||||||
|
|
||||||
|
### `statemachine`
|
||||||
|
|
||||||
|
Port of `state_machine.rs`:
|
||||||
|
|
||||||
|
- `TransitionRuntime(current RuntimeState, action RuntimeAction) (RuntimeState, error)`
|
||||||
|
- `TransitionCommitment(current CommitmentState, action CommitmentAction) (CommitmentState, error)`
|
||||||
|
- Illegal transitions return a typed error identifying current state + action.
|
||||||
|
Pure functions, no I/O.
|
||||||
|
|
||||||
|
### `session.Controller`
|
||||||
|
|
||||||
|
- Holds `runtimeState` and `activeCommitment` in memory, guarded by a
|
||||||
|
`sync.Mutex`, as the single source of truth.
|
||||||
|
- Methods: `EnterPlanning()`, `StartManualCommitment(nextAction, successCond
|
||||||
|
string, timebox time.Duration)`, `Complete()`, `End()`.
|
||||||
|
- Each method applies the relevant transition(s), updates in-memory state, and
|
||||||
|
persists a snapshot via `store`. On any transition error, state is left
|
||||||
|
unchanged and the error is returned.
|
||||||
|
- Exposes a read method returning a snapshot-shaped view for broadcasting.
|
||||||
|
|
||||||
|
### `store`
|
||||||
|
|
||||||
|
- Snapshot is the current state: runtime state + active commitment (+ deadline).
|
||||||
|
- `Load(path) (Snapshot, error)` — missing file yields a default `Locked`
|
||||||
|
snapshot, not an error.
|
||||||
|
- `Save(path, Snapshot) error` — atomic write (temp file + rename) to
|
||||||
|
`~/.antidrift/state.json`, creating the directory if needed.
|
||||||
|
- No hash chaining in M0; that belongs to the M1 audit log.
|
||||||
|
|
||||||
|
### `web`
|
||||||
|
|
||||||
|
- Gin server bound to `localhost:7777`.
|
||||||
|
- Holds the `session.Controller` and an SSE broadcaster (set of subscriber
|
||||||
|
channels).
|
||||||
|
- Each mutating handler: acquire controller mutex → apply transition → persist
|
||||||
|
snapshot → broadcast new state to all SSE subscribers → return.
|
||||||
|
|
||||||
|
## Daemon Behavior
|
||||||
|
|
||||||
|
On start, `antidriftd`:
|
||||||
|
|
||||||
|
1. Loads the snapshot (or starts `Locked`).
|
||||||
|
2. If the loaded state is `Active` with a future deadline, re-arms the expiry
|
||||||
|
timer; if the deadline has already passed, transitions to `Review`.
|
||||||
|
3. Starts Gin on `localhost:7777`.
|
||||||
|
4. Attempts to open the default browser at that URL (best-effort; logs and
|
||||||
|
continues if it fails).
|
||||||
|
|
||||||
|
Timebox expiry is **server-authoritative**: on entering `Active`, the daemon
|
||||||
|
arms a `time.AfterFunc` at the deadline that fires `Active → Review` and
|
||||||
|
broadcasts. The browser countdown is cosmetic and derived from the deadline
|
||||||
|
timestamp in the state payload.
|
||||||
|
|
||||||
|
## HTTP Surface
|
||||||
|
|
||||||
|
| Method | Route | Body | Effect |
|
||||||
|
| ------ | ------------- | ------------------------------------------------- | ------ |
|
||||||
|
| GET | `/` | — | Serves the single-page UI |
|
||||||
|
| GET | `/events` | — | SSE stream; emits the current state immediately, then on every change |
|
||||||
|
| POST | `/planning` | — | Locked → Planning |
|
||||||
|
| POST | `/commitment` | `{next_action, success_condition, timebox_secs}` | Planning → Active (validates; 400 on invalid) |
|
||||||
|
| POST | `/complete` | — | Active → Review |
|
||||||
|
| POST | `/end` | — | Review → Locked |
|
||||||
|
|
||||||
|
State payload (SSE `data:` and POST responses) is JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"runtime_state": "active",
|
||||||
|
"commitment": {
|
||||||
|
"next_action": "Port the domain package",
|
||||||
|
"success_condition": "domain tests pass",
|
||||||
|
"timebox_secs": 1500,
|
||||||
|
"deadline_unix_secs": 1748725200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`commitment` is null when there is no active commitment. Invalid transitions
|
||||||
|
requested via HTTP return 409 (illegal transition) or 400 (invalid input); state
|
||||||
|
is unchanged.
|
||||||
|
|
||||||
|
## Browser UI
|
||||||
|
|
||||||
|
Single HTML page served from `/`, using vanilla JavaScript with no build step
|
||||||
|
(no bundler, no framework) to keep it dependency-free and token-light. It:
|
||||||
|
|
||||||
|
- Subscribes to `/events` via `EventSource`.
|
||||||
|
- Renders exactly one view based on `runtime_state`:
|
||||||
|
- **Locked** — status + a "Plan" button (POST `/planning`).
|
||||||
|
- **Planning** — a form with next action, success condition, and minutes;
|
||||||
|
"Start" is disabled until all three are valid (POST `/commitment`).
|
||||||
|
- **Active** — next action, success condition, a live countdown derived from
|
||||||
|
`deadline_unix_secs`, and a "Complete" button (POST `/complete`).
|
||||||
|
- **Review** — a short summary of the just-ended commitment and an "End"
|
||||||
|
button (POST `/end`).
|
||||||
|
- Is a pure renderer of pushed state; it holds no authoritative state of its
|
||||||
|
own. Styling is clean and legible but not the focus — the real visual pass is
|
||||||
|
M4.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `domain`: validation rejects empty next action / success condition / zero
|
||||||
|
timebox; JSON round-trips with snake_case tags.
|
||||||
|
- `statemachine`: legal transitions for the M0 subset succeed; representative
|
||||||
|
illegal transitions (e.g. Locked → Active) return typed errors. Port the
|
||||||
|
intent of the existing Rust tests.
|
||||||
|
- `session`: planning → start → complete → end happy path drives the expected
|
||||||
|
states; snapshot save/load round-trips and restores the controller.
|
||||||
|
- `web`: `httptest` checks that POST `/planning` then POST `/commitment` moves
|
||||||
|
the controller to Active, that `/commitment` with invalid input returns 400,
|
||||||
|
and that `/events` emits a state payload.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
`go run ./cmd/antidriftd` opens a browser; the user creates a commitment,
|
||||||
|
watches the timebox count down, completes it (or lets it expire into Review),
|
||||||
|
and ends the session back to Locked. Killing and restarting the daemon restores
|
||||||
|
the persisted state from `~/.antidrift/state.json`. `go test ./...` passes.
|
||||||
|
|
||||||
|
## Repo Setup (first task of M0)
|
||||||
|
|
||||||
|
- Initialize the Go module at the repository root.
|
||||||
|
- Move the existing Rust sources into `legacy/` so they remain available as
|
||||||
|
reference (Cargo.toml, src/, etc.), keeping the shared `docs/` at the root.
|
||||||
|
- Code layout: `cmd/antidriftd/` (main), `internal/domain`,
|
||||||
|
`internal/statemachine`, `internal/session`, `internal/store`,
|
||||||
|
`internal/web` (with the static UI under `internal/web/static`).
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
# M1 — Evidence & Audit Design
|
||||||
|
|
||||||
|
Date: 2026-05-31
|
||||||
|
|
||||||
|
Parent design: `2026-05-31-go-focus-os-design.md`
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
M1 gives the daemon eyes and a memory. It adds a continuous active-window
|
||||||
|
sensor, a two-tier evidence store, and live updates of what you are doing — then
|
||||||
|
seals each finished session into a tamper-evident, hash-chained audit trail.
|
||||||
|
|
||||||
|
M1 is **observe & record only**. It makes no judgment about whether the current
|
||||||
|
window is on-task; that is the advisor's job in M3. M1 is honest
|
||||||
|
instrumentation: the trustworthy foundation that later milestones read from.
|
||||||
|
|
||||||
|
In the architecture of the parent design, M1 builds the first real **port** end
|
||||||
|
to end — the activity port (`evidence.Source`) with its X11 adapter and a fake
|
||||||
|
for tests — establishing the interface + adapter + fake pattern every later port
|
||||||
|
copies.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `evidence` package: an X11 (xgbutil) active-window sensor behind a `Source`
|
||||||
|
interface, pushing a `WindowSnapshot` on every focus change; evidence health;
|
||||||
|
the legacy title-scrubbing regex as a tested `ScrubTitle` function.
|
||||||
|
- `store` extensions: a per-session raw focus-event log (`sessions/<id>.jsonl`,
|
||||||
|
appended live, pruned after a retention window) and a permanent hash-chained
|
||||||
|
audit log (`audit.jsonl`, one linked `SessionSummary` per completed session).
|
||||||
|
- `session.Controller` extensions: in-memory per-session evidence stats, an
|
||||||
|
injectable clock, focus accumulation while `Active`, crash-recovery replay,
|
||||||
|
and writing the hashed summary at session end.
|
||||||
|
- `web` extensions: the SSE payload carries an `evidence` object; the Active and
|
||||||
|
Review views surface current window, per-bucket time, context-switch count,
|
||||||
|
and an evidence-health indicator. A broadcast callback registered on the
|
||||||
|
controller so every state change (focus, expiry, user action) fans out over
|
||||||
|
one path.
|
||||||
|
- Unit tests for `evidence` (scrub + interface), `store` (audit chain +
|
||||||
|
evidence log), `session` (accumulation via fake source + injectable clock),
|
||||||
|
and `web` (evidence payload over SSE).
|
||||||
|
|
||||||
|
Out of scope (deferred):
|
||||||
|
|
||||||
|
- Any AI / drift judgment / on-task vs off-task classification (M2–M3).
|
||||||
|
- Allowed-context matching and violation friction (M3).
|
||||||
|
- Enforcement of any kind, including window-minimize (M7).
|
||||||
|
- Wayland active-window support beyond degraded `Unavailable` reporting.
|
||||||
|
- Visual design polish (M4) — M1 surfaces data legibly, nothing more.
|
||||||
|
|
||||||
|
## Architecture Fit
|
||||||
|
|
||||||
|
Per the parent design's "ports around a decision core":
|
||||||
|
|
||||||
|
- **Skeleton** (`domain`, `statemachine`) is unchanged by M1.
|
||||||
|
- **Nervous system** (`session.Controller`) gains evidence ownership: it
|
||||||
|
receives sensor events, decides relevance by current runtime state,
|
||||||
|
accumulates stats, persists raw events, and writes the audit summary. It
|
||||||
|
remains the single hub.
|
||||||
|
- **Activity port** (`evidence.Source`) is new — a dumb sensor that makes no
|
||||||
|
decisions. `session` decides what each event means.
|
||||||
|
|
||||||
|
The sensor never judges; the orchestrator never talks to X11 directly. This is
|
||||||
|
the port boundary M1 exists to establish.
|
||||||
|
|
||||||
|
## The Two-Tier Evidence Model
|
||||||
|
|
||||||
|
Window focus can change dozens of times a minute. Keeping every focus event in
|
||||||
|
the permanent hash chain forever would bloat it. Discarding detail loses the
|
||||||
|
ability to compute things like context-switch frequency. M1 resolves this with
|
||||||
|
two tiers:
|
||||||
|
|
||||||
|
1. **Raw, per-session, disposable.** Every focus change is appended live to
|
||||||
|
`sessions/<session-id>.jsonl` as it happens (crash-durable). This is the
|
||||||
|
firehose: full titles, millisecond timestamps. It is the recovery source for
|
||||||
|
in-memory stats and the basis for end-of-session analytics. It is **pruned
|
||||||
|
after 30 days**.
|
||||||
|
2. **Summarized, permanent, tamper-evident.** At session end the raw stream is
|
||||||
|
rolled up into one `SessionSummary` (per-bucket totals, switch count,
|
||||||
|
duration, outcome) which is hash-chained into `audit.jsonl` and **kept
|
||||||
|
forever**. The chain is coarse — one linked entry per session — so tamper
|
||||||
|
evidence is cheap and the log stays small.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `evidence` (new package)
|
||||||
|
|
||||||
|
A dumb X11 sensor. One long-lived xgbutil connection is opened at daemon start
|
||||||
|
and kept open for the whole process. It subscribes to `PropertyNotify` on the
|
||||||
|
root window's `_NET_ACTIVE_WINDOW` and emits a `WindowSnapshot` on every active
|
||||||
|
window change (and once immediately with the current window). It makes no
|
||||||
|
relevance decisions.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EvidenceHealth struct {
|
||||||
|
Available bool
|
||||||
|
Reason string // empty when Available; populated when not
|
||||||
|
}
|
||||||
|
|
||||||
|
type WindowSnapshot struct {
|
||||||
|
Title string // full _NET_WM_NAME (raw; used in live view + raw log)
|
||||||
|
Class string // WM_CLASS
|
||||||
|
Health EvidenceHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
type Source interface {
|
||||||
|
// Watch runs until ctx is cancelled, invoking onChange on every active
|
||||||
|
// window change, and once immediately with the current window.
|
||||||
|
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Real implementation `x11Source` uses xgbutil:
|
||||||
|
`ewmh.ActiveWindowGet` → `ewmh.WmNameGet` (title) + `icccm.WmClassGet` (class).
|
||||||
|
- On X failure, no active window, or unset `DISPLAY`:
|
||||||
|
`Health{Available: false, Reason: "..."}` with empty title/class. This mirrors
|
||||||
|
the legacy degraded reporting and covers Wayland implicitly.
|
||||||
|
- `ScrubTitle(string) string` is the legacy digit/percent regex
|
||||||
|
(`-?\d+([:.]\d+)+%?`) ported as its own tested function. Used to compute
|
||||||
|
bucket keys; the raw log keeps the unscrubbed title.
|
||||||
|
|
||||||
|
### `store` (extended)
|
||||||
|
|
||||||
|
Two new files beside `snapshot.go`. `store` is infrastructure, not a port.
|
||||||
|
|
||||||
|
`store/audit.go` — the permanent hash-chained log at `~/.antidrift/audit.jsonl`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type BucketTotal struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"` // scrubbed
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionSummary struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
PrevHash string `json:"prev_hash"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCond string `json:"success_condition"`
|
||||||
|
Outcome string `json:"outcome"` // "completed" | "expired"
|
||||||
|
StartedUnix int64 `json:"started_unix"`
|
||||||
|
EndedUnix int64 `json:"ended_unix"`
|
||||||
|
SwitchCount int `json:"switch_count"`
|
||||||
|
Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppendSession(path string, s SessionSummary) error
|
||||||
|
func VerifyChain(path string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
- `AppendSession` reads the last line's `Hash` as this entry's `PrevHash`
|
||||||
|
(genesis = 64 hex zeros), assigns `Seq = lastSeq + 1`, computes
|
||||||
|
`Hash = SHA-256(prevHash || canonicalJSON(fields-except-Hash))`, and appends
|
||||||
|
one JSON line atomically (append + fsync). Canonical serialization uses the
|
||||||
|
struct with `Hash` zeroed and stable field order (encoding/json is stable for
|
||||||
|
structs).
|
||||||
|
- `VerifyChain` re-walks every line: recompute each hash, confirm each
|
||||||
|
`PrevHash` equals the prior `Hash`, confirm `Seq` is contiguous. Returns an
|
||||||
|
error naming the first broken line; nil if intact or empty.
|
||||||
|
|
||||||
|
`store/evidence_log.go` — the per-session raw stream at
|
||||||
|
`~/.antidrift/sessions/<session-id>.jsonl`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type FocusEvent struct {
|
||||||
|
AtUnixMillis int64 `json:"at_unix_millis"`
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"` // full, unscrubbed
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func AppendFocus(dir, sessionID string, e FocusEvent) error
|
||||||
|
func ReplaySession(dir, sessionID string) ([]FocusEvent, error)
|
||||||
|
func PruneOlderThan(dir string, age time.Duration, now time.Time) error
|
||||||
|
```
|
||||||
|
|
||||||
|
- `AppendFocus` opens the session file `O_APPEND|O_CREATE`, writes one JSON
|
||||||
|
line, closes. Creates `sessions/` if needed.
|
||||||
|
- `ReplaySession` reads all events in order (used to rebuild stats after a
|
||||||
|
crash).
|
||||||
|
- `PruneOlderThan` deletes session files whose modification time is older than
|
||||||
|
`now - age`. `now` is injected for testability.
|
||||||
|
|
||||||
|
### `session.Controller` (extended)
|
||||||
|
|
||||||
|
Gains:
|
||||||
|
|
||||||
|
- `clock func() time.Time` — injectable; defaults to `time.Now`. Used for all
|
||||||
|
accumulation and timestamps so tests are deterministic.
|
||||||
|
- `stats *EvidenceStats` — in-memory, current session only (nil when not in a
|
||||||
|
live session).
|
||||||
|
- `onChange func()` — a notification callback the web layer registers; invoked
|
||||||
|
after any state change so the browser is pushed fresh state.
|
||||||
|
- A reference to the store paths (audit file, sessions dir).
|
||||||
|
|
||||||
|
```go
|
||||||
|
type bucketKey struct{ Class, Title string } // Title is scrubbed
|
||||||
|
|
||||||
|
type EvidenceStats struct {
|
||||||
|
SessionID string
|
||||||
|
StartedUnix int64
|
||||||
|
Buckets map[bucketKey]time.Duration
|
||||||
|
SwitchCount int
|
||||||
|
Current evidence.WindowSnapshot
|
||||||
|
lastFocusAt time.Time
|
||||||
|
lastKey bucketKey
|
||||||
|
hasLast bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RecordWindow(snap evidence.WindowSnapshot)`:
|
||||||
|
|
||||||
|
1. Lock. Always update `c.latestWindow = snap` (for the live indicator outside
|
||||||
|
Active).
|
||||||
|
2. If `runtimeState != Active` or `stats == nil`: fire `onChange`; return.
|
||||||
|
(Tracked for display, not accounted.)
|
||||||
|
3. `now := c.clock()`. If `hasLast`: add `now - lastFocusAt` to
|
||||||
|
`Buckets[lastKey]`. (If the prior snapshot was unavailable, `lastKey` is the
|
||||||
|
reserved `(evidence unavailable)` key.)
|
||||||
|
4. Append a `FocusEvent` to the session log.
|
||||||
|
5. Compute the new key: unavailable snapshot → reserved key
|
||||||
|
`{Class: "", Title: "(evidence unavailable)"}`; else
|
||||||
|
`{snap.Class, ScrubTitle(snap.Title)}`. If `hasLast && newKey != lastKey`,
|
||||||
|
increment `SwitchCount`.
|
||||||
|
6. Set `lastKey = newKey`, `lastFocusAt = now`, `hasLast = true`,
|
||||||
|
`Current = snap`. Fire `onChange`.
|
||||||
|
|
||||||
|
Lifecycle hooks (all under the existing mutex):
|
||||||
|
|
||||||
|
- **`StartManualCommitment`** (→ Active): mint `session_id` (UUIDv7), create
|
||||||
|
`stats` with `StartedUnix = clock()`, seed `Current`/`lastKey`/`lastFocusAt`
|
||||||
|
from `latestWindow` (so the first segment counts from start), `hasLast = true`.
|
||||||
|
Persist snapshot (now carrying `session_id`).
|
||||||
|
- **`Complete`** / **timebox expiry** (→ Review): flush final segment
|
||||||
|
(`clock() - lastFocusAt` into `lastKey`), freeze `stats` (stop accounting).
|
||||||
|
Consistent with M0's "freeze active time during review."
|
||||||
|
- **`End`** (→ Locked): build `SessionSummary` from frozen `stats`
|
||||||
|
(`Outcome` = "completed" if user-completed, "expired" if timebox fired —
|
||||||
|
tracked via a field set at the Active→Review transition), `AppendSession` to
|
||||||
|
the audit chain, then clear `stats`.
|
||||||
|
|
||||||
|
Read view: `State()` is extended to include an evidence projection (current
|
||||||
|
window, buckets sorted desc, switch count, health) for the SSE payload.
|
||||||
|
|
||||||
|
### Crash Recovery
|
||||||
|
|
||||||
|
The snapshot (`state.json`) gains `session_id` and `outcome_pending` fields.
|
||||||
|
|
||||||
|
On startup, after loading the snapshot:
|
||||||
|
|
||||||
|
- Run `store.PruneOlderThan(sessionsDir, 30*24*time.Hour, time.Now())`.
|
||||||
|
- If runtime state is `Active` with a `session_id`: `ReplaySession` the raw log
|
||||||
|
and rebuild `EvidenceStats` exactly (sum segments between consecutive events;
|
||||||
|
the final open segment resumes from the last event's timestamp). The raw log
|
||||||
|
is the recovery source of truth for stats; the snapshot only points at it.
|
||||||
|
- The M0 deadline re-arm / expire-to-Review behavior is unchanged and runs
|
||||||
|
after stats are rebuilt.
|
||||||
|
|
||||||
|
### `web` (extended)
|
||||||
|
|
||||||
|
- At startup the `Server` registers `ctrl.SetOnChange(func(){ broadcast() })`.
|
||||||
|
Every state change — focus update, timebox expiry, user action — flows out
|
||||||
|
through this single path. This also moves M0's expiry broadcast onto the same
|
||||||
|
callback rather than an inline broadcast.
|
||||||
|
- The SSE/POST payload gains an `evidence` object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"runtime_state": "active",
|
||||||
|
"commitment": { "next_action": "...", "success_condition": "...",
|
||||||
|
"timebox_secs": 1500, "deadline_unix_secs": 1748725200 },
|
||||||
|
"evidence": {
|
||||||
|
"available": true,
|
||||||
|
"reason": "",
|
||||||
|
"current": { "class": "code", "title": "m1 design - antidrift" },
|
||||||
|
"switch_count": 7,
|
||||||
|
"buckets": [
|
||||||
|
{ "class": "code", "title": "antidrift", "seconds": 540 },
|
||||||
|
{ "class": "firefox", "title": "docs", "seconds": 120 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`evidence` is null when there is no live session (Locked/Planning). In Review it
|
||||||
|
carries the frozen stats.
|
||||||
|
|
||||||
|
### Browser UI (data, not polish)
|
||||||
|
|
||||||
|
The page stays a pure renderer of pushed state. Additions:
|
||||||
|
|
||||||
|
- **Active view:** current window line (`class · title`); a per-bucket time
|
||||||
|
breakdown list (sorted desc, `mm:ss`); the context-switch count; an
|
||||||
|
evidence-health pill — green "tracking" when available, amber
|
||||||
|
"evidence unavailable: <reason>" when not.
|
||||||
|
- **Review view:** the session summary about to be hashed — total active time,
|
||||||
|
switch count, top buckets.
|
||||||
|
- Visual treatment is minimal and legible; the real design pass is M4.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
X server ──PropertyNotify──▶ evidence.x11Source.Watch (goroutine)
|
||||||
|
│ onChange(WindowSnapshot)
|
||||||
|
▼
|
||||||
|
session.Controller.RecordWindow
|
||||||
|
│ (accumulate if Active) │ append raw event
|
||||||
|
▼ ▼
|
||||||
|
EvidenceStats (memory) sessions/<id>.jsonl
|
||||||
|
│ onChange()
|
||||||
|
▼
|
||||||
|
web broadcast ──SSE──▶ browser
|
||||||
|
...
|
||||||
|
End: SessionSummary ──hash-chain──▶ audit.jsonl (permanent)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- **Evidence unavailable** (X error, no active window, Wayland): snapshot has
|
||||||
|
`Health.Available = false`; time during the gap accrues to the reserved
|
||||||
|
`(evidence unavailable)` bucket so totals always equal wall-clock duration.
|
||||||
|
The watch goroutine logs and continues; it never crashes the daemon.
|
||||||
|
- **Session log append failure:** logged; accounting continues in memory (the
|
||||||
|
raw log is best-effort detail, not the state of truth). Crash recovery for
|
||||||
|
that session would be incomplete, which is acceptable for disposable detail.
|
||||||
|
- **Audit append failure at End:** logged and surfaced; the transition to Locked
|
||||||
|
still completes (state integrity over audit completeness). A retry-on-next-
|
||||||
|
start is out of scope.
|
||||||
|
- **Corrupt audit chain:** `VerifyChain` reports the first broken line; M1 only
|
||||||
|
exposes verification, it does not auto-repair.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `evidence`: `ScrubTitle` table tests porting the legacy regex cases
|
||||||
|
(percentages, ratios, leading sign, plain titles untouched). The `x11Source`
|
||||||
|
sits behind `Source`; a `//go:build` integration smoke test queries the live
|
||||||
|
server and is skipped when `DISPLAY` is unset.
|
||||||
|
- `store/audit`: append N summaries then `VerifyChain` passes; genesis
|
||||||
|
`PrevHash` is 64 zeros; `Seq` is contiguous; a tamper test mutates a middle
|
||||||
|
line's field and asserts `VerifyChain` names that line.
|
||||||
|
- `store/evidence_log`: `AppendFocus` then `ReplaySession` round-trips event
|
||||||
|
order/values; `PruneOlderThan` deletes only files older than the cutoff
|
||||||
|
(modification time controlled).
|
||||||
|
- `session`: a `fakeSource` plus injectable `clock` drive scripted focus
|
||||||
|
sequences with controlled timestamps. Assert: bucket totals and switch count;
|
||||||
|
events outside Active are not accounted; unavailable snapshots accrue to the
|
||||||
|
reserved bucket; crash-replay rebuilds identical stats; `End` writes a
|
||||||
|
summary whose buckets/switch-count match and which extends the chain.
|
||||||
|
- `web`: httptest confirms `/events` emits an evidence-bearing payload and that
|
||||||
|
a simulated focus change pushes an updated payload.
|
||||||
|
|
||||||
|
## Done When
|
||||||
|
|
||||||
|
`go run ./cmd/antidriftd`: start a commitment, switch between a few windows, and
|
||||||
|
watch the Active view update live with the current window, per-app time, and
|
||||||
|
switch count. Let it complete (or expire) into Review and see the session
|
||||||
|
summary. End it; `audit.jsonl` gains one hash-chained line and `VerifyChain`
|
||||||
|
passes. Kill the daemon mid-session and restart: the per-window stats are
|
||||||
|
rebuilt from the session log and tracking resumes. Session files older than 30
|
||||||
|
days are gone on startup. `go test ./...` passes.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Create: `internal/evidence/evidence.go` (types, `Source`, `ScrubTitle`)
|
||||||
|
- Create: `internal/evidence/x11.go` (`x11Source`, xgbutil; build-tagged)
|
||||||
|
- Create: `internal/evidence/evidence_test.go`
|
||||||
|
- Create: `internal/store/audit.go`, `internal/store/evidence_log.go`
|
||||||
|
- Create: `internal/store/audit_test.go`, `internal/store/evidence_log_test.go`
|
||||||
|
- Modify: `internal/store/store.go` (snapshot gains `session_id`,
|
||||||
|
`outcome_pending`)
|
||||||
|
- Modify: `internal/session/session.go` (clock, stats, RecordWindow, lifecycle
|
||||||
|
hooks, onChange, recovery), `internal/session/session_test.go`
|
||||||
|
- Modify: `internal/web/web.go` (SetOnChange wiring, evidence payload),
|
||||||
|
`internal/web/web_test.go`, `internal/web/static/index.html`
|
||||||
|
- Modify: `cmd/antidriftd/main.go` (construct `evidence.Source`, start `Watch`,
|
||||||
|
wire to controller)
|
||||||
|
- Modify: `go.mod` (add `github.com/jezek/xgb`, `github.com/jezek/xgbutil`)
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
# M2 — AI Planning Coach — Design
|
||||||
|
|
||||||
|
Date: 2026-05-31
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
M2 adds the first AI capability to AntiDrift: a **planning coach**. In the
|
||||||
|
Planning view, the user types one rough intent ("work on the quarterly report"),
|
||||||
|
presses **Sharpen**, and an AI coach proposes a structured commitment —
|
||||||
|
`next_action`, `success_condition`, and a `timebox` — that pre-fills the existing
|
||||||
|
three Planning inputs for the user to edit and accept.
|
||||||
|
|
||||||
|
This establishes the `ai` port (the **cortex** layer of the decision core) and
|
||||||
|
the CLI backend, the pattern every later AI role (drift interceptor, nudge,
|
||||||
|
reflection) will reuse. The coach **proposes**; the user still drives the
|
||||||
|
existing `/commitment` transition. The LLM never owns a state transition.
|
||||||
|
|
||||||
|
AI is **strictly additive**: if the coach is unavailable, slow, or returns
|
||||||
|
garbage, the three manual Planning inputs remain fully usable. This mirrors the
|
||||||
|
evidence-health degradation pattern established in M1.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope (M2):**
|
||||||
|
|
||||||
|
- A new `ai` package with a pluggable CLI **backend** abstraction and **two real
|
||||||
|
adapters from day one: `claude` and `codex`**.
|
||||||
|
- A backend-agnostic **`Coach`** capability that turns a free-text intent into a
|
||||||
|
validated `Proposal`.
|
||||||
|
- Async, SSE-driven delivery: the coach runs in a background goroutine; the UI
|
||||||
|
shows a pending state and updates when the proposal lands.
|
||||||
|
- Graceful degradation on every failure path (missing CLI, timeout, malformed
|
||||||
|
output, no backend wired).
|
||||||
|
- Planning-view UI: an intent box + Sharpen button that pre-fills the existing
|
||||||
|
inputs from the proposal.
|
||||||
|
|
||||||
|
**Out of scope (deferred):**
|
||||||
|
|
||||||
|
- The `JudgeDrift` and `Nudge` roles — they join the `ai` interface in **M3**.
|
||||||
|
M2 builds only `Coach` (YAGNI).
|
||||||
|
- An Anthropic API backend — the interface boundary allows it later without
|
||||||
|
touching callers; not built now.
|
||||||
|
- Any change to the commitment/runtime state machine. The coach produces a
|
||||||
|
draft; activation still goes through the existing `StartManualCommitment`
|
||||||
|
path.
|
||||||
|
- Persisting the proposal. It is ephemeral pre-commitment advice (see
|
||||||
|
"Ephemeral state").
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
M2 follows the established ports-and-adapters shape. The `ai` package is the new
|
||||||
|
**Advisor** port; `claude` and `codex` are its adapters; `session.Controller`
|
||||||
|
(the nervous system) orchestrates the async call and broadcasts; the browser
|
||||||
|
renders. The coach sits at the **cortex** layer: it proposes at a decision point
|
||||||
|
the state machine exposes (planning), but never forces a transition.
|
||||||
|
|
||||||
|
### The `ai` package — two layers
|
||||||
|
|
||||||
|
The pluggability requirement is met by separating *what we ask* from *how we
|
||||||
|
reach a CLI*.
|
||||||
|
|
||||||
|
**Layer 1 — `Backend` (the pluggable adapter).**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
||||||
|
// and arguments they run.
|
||||||
|
type Backend interface {
|
||||||
|
// Run sends prompt to the CLI and returns its raw stdout.
|
||||||
|
Run(ctx context.Context, prompt string) (string, error)
|
||||||
|
// Name identifies the backend (e.g. "claude", "codex").
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two real adapters. The exact invocations below were verified empirically on
|
||||||
|
this machine (claude 2.1.154, codex-cli 0.135.0); both authenticate via the
|
||||||
|
existing CLI login — **no API keys**.
|
||||||
|
|
||||||
|
- **`claudeBackend`** runs:
|
||||||
|
```
|
||||||
|
claude --print --tools "" --no-session-persistence --output-format text
|
||||||
|
```
|
||||||
|
The prompt is delivered on **stdin** (avoids argv limits and shell-escaping;
|
||||||
|
also dodges a quirk where an empty `--tools ""` positional can be mistaken for
|
||||||
|
the prompt). The model's answer is exactly **stdout** (trailing newline
|
||||||
|
trimmed). `--tools ""` disables all tools so it just answers;
|
||||||
|
`--no-session-persistence` avoids writing resumable session files. Do **not**
|
||||||
|
use `--bare` (it forces `ANTHROPIC_API_KEY` and ignores the machine's login).
|
||||||
|
|
||||||
|
- **`codexBackend`** runs:
|
||||||
|
```
|
||||||
|
codex exec --skip-git-repo-check --ignore-user-config --ignore-rules \
|
||||||
|
-s read-only -a never --ephemeral -o <tmpfile> -
|
||||||
|
```
|
||||||
|
The prompt is delivered on **stdin** (the trailing `-` tells codex to read it
|
||||||
|
from stdin). codex's **stdout is not clean** (it includes session preamble),
|
||||||
|
so the adapter writes the final answer to a per-call **temp file** via `-o`,
|
||||||
|
then reads and returns that file's contents. The adapter creates the temp file
|
||||||
|
(`os.CreateTemp`) and removes it on return. The flags matter:
|
||||||
|
`--ignore-user-config --ignore-rules -s read-only` stop codex from executing
|
||||||
|
shell commands driven by local config (observed: it otherwise runs tool calls
|
||||||
|
even for a trivial prompt, adding latency); `-a never` disables approval
|
||||||
|
prompts for headless use; `--ephemeral` skips persisting session files;
|
||||||
|
`--skip-git-repo-check` lets it run anywhere.
|
||||||
|
|
||||||
|
Both use `os/exec` with the `ctx` passed to `exec.CommandContext` so a timeout
|
||||||
|
cancels the child process. Each adapter stores its command name and base args in
|
||||||
|
struct fields so argument construction is unit-testable without spawning a
|
||||||
|
process. The codex adapter's temp-file handling lives inside its `Run` so the
|
||||||
|
`Backend` interface stays uniform (`Run(ctx, prompt) (string, error)`).
|
||||||
|
|
||||||
|
A selector constructs the configured backend:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// NewBackend returns the named backend, or an error for an unknown name.
|
||||||
|
// name "" defaults to "claude".
|
||||||
|
func NewBackend(name string) (Backend, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Layer 2 — `Coach` (backend-agnostic capability).**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Proposal is the coach's structured suggestion for a commitment. It is NOT a
|
||||||
|
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
|
||||||
|
type Proposal struct {
|
||||||
|
NextAction string
|
||||||
|
SuccessCondition string
|
||||||
|
TimeboxSecs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coach turns a free-text intent into a validated Proposal.
|
||||||
|
type Coach interface {
|
||||||
|
Coach(ctx context.Context, intent string) (Proposal, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Service` implements `Coach` over any `Backend`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Service struct {
|
||||||
|
backend Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(b Backend) *Service
|
||||||
|
```
|
||||||
|
|
||||||
|
`Coach` builds a strict prompt, calls `backend.Run`, extracts and parses the
|
||||||
|
JSON, and validates it. The `ai` package imports nothing from the rest of the
|
||||||
|
app (it returns its own `Proposal`, not `domain.Commitment`), so it stays a leaf
|
||||||
|
package with no import cycles.
|
||||||
|
|
||||||
|
### Prompt and JSON contract
|
||||||
|
|
||||||
|
The prompt instructs the model to act as a focus coach and to **return only
|
||||||
|
JSON** of the form:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"next_action": "Draft the executive summary section",
|
||||||
|
"success_condition": "Summary section has 3 paragraphs covering revenue, risks, outlook",
|
||||||
|
"timebox_minutes": 25
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsing is tolerant of a chatty CLI:
|
||||||
|
|
||||||
|
- `extractJSON(s string) (string, error)` scans for the first balanced `{...}`
|
||||||
|
object in the output and returns it. This survives leading/trailing prose or
|
||||||
|
code fences.
|
||||||
|
- `parseProposal(jsonStr string) (Proposal, error)` unmarshals into an internal
|
||||||
|
struct with `next_action`, `success_condition`, `timebox_minutes`, then:
|
||||||
|
- trims whitespace; errors if `next_action` or `success_condition` is empty;
|
||||||
|
- errors if `timebox_minutes <= 0`;
|
||||||
|
- converts minutes to `TimeboxSecs` (`minutes * 60`).
|
||||||
|
|
||||||
|
All parse/validation failures return a non-nil error; the caller degrades
|
||||||
|
gracefully (see below). Sentinel errors: `ErrEmptyResponse`, `ErrNoJSON`,
|
||||||
|
`ErrInvalidProposal`.
|
||||||
|
|
||||||
|
Both CLIs also offer native structured-output flags (claude
|
||||||
|
`--output-format json --json-schema`; codex `--output-schema <file>`) that would
|
||||||
|
guarantee shape. We deliberately do **not** use them in M2: they diverge the two
|
||||||
|
adapters (different flags, envelope vs file) and would push schema concerns into
|
||||||
|
the `Backend` layer. Prompt-instructed JSON + tolerant `extractJSON` keeps the
|
||||||
|
`Backend` interface uniform and the parsing in one place. Native schemas remain a
|
||||||
|
clean future robustness upgrade behind the same `Coach` boundary.
|
||||||
|
|
||||||
|
### `session.Controller` — async coach orchestration
|
||||||
|
|
||||||
|
A new method drives the coach using the **exact concurrency pattern** already in
|
||||||
|
`RecordWindow`: mutate state under the mutex, then call `notify()` with the
|
||||||
|
mutex released (`session.go:139-146`).
|
||||||
|
|
||||||
|
```go
|
||||||
|
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
||||||
|
// RequestCoach degrade gracefully.
|
||||||
|
func (c *Controller) SetCoach(coach ai.Coach)
|
||||||
|
|
||||||
|
// RequestCoach starts an async coach call for the given intent. It is a no-op
|
||||||
|
// error path (not a hard failure) unless the runtime state is wrong.
|
||||||
|
func (c *Controller) RequestCoach(intent string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior of `RequestCoach`:
|
||||||
|
|
||||||
|
1. Lock. If `runtimeState != RuntimePlanning`, unlock and return
|
||||||
|
`ErrNotPlanning` (a real client error — coaching only makes sense in
|
||||||
|
planning).
|
||||||
|
2. If `coach == nil`: set coach state to `status=error`,
|
||||||
|
`err="coach unavailable"`, unlock, `notify()`, return `nil` (graceful — not
|
||||||
|
an HTTP error).
|
||||||
|
3. Otherwise: increment `coachGen`, capture `gen := coachGen`, set
|
||||||
|
`status=pending`, clear prior proposal/error, capture the `coach` reference,
|
||||||
|
unlock, `notify()` (broadcasts the pending state).
|
||||||
|
4. Launch a goroutine:
|
||||||
|
- `ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)`
|
||||||
|
(`coachTimeout = 60 * time.Second` — codex in particular runs tens of
|
||||||
|
seconds even for trivial prompts; 60s gives a real coaching prompt
|
||||||
|
headroom); `defer cancel()`.
|
||||||
|
- Call `coach.Coach(ctx, intent)`.
|
||||||
|
- Lock. **If `gen != c.coachGen` or `runtimeState != RuntimePlanning`,
|
||||||
|
unlock and return** (stale result — a newer request superseded this one, or
|
||||||
|
the user left planning). Discard silently.
|
||||||
|
- On error: `status=error`, `err=<sanitized message>`, `proposal=nil`.
|
||||||
|
- On success: `status=ready`, `proposal=<the Proposal>`, `err=""`.
|
||||||
|
- Unlock, `notify()`.
|
||||||
|
|
||||||
|
The intent string is **not** stored on the controller; it is captured by the
|
||||||
|
goroutine closure only.
|
||||||
|
|
||||||
|
#### Ephemeral state
|
||||||
|
|
||||||
|
The coach state lives on the controller as plain fields and is **never written
|
||||||
|
to the snapshot**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// on Controller:
|
||||||
|
coach ai.Coach
|
||||||
|
coachStatus string // "idle" | "pending" | "ready" | "error"
|
||||||
|
coachProposal *ai.Proposal
|
||||||
|
coachErr string
|
||||||
|
coachGen int
|
||||||
|
```
|
||||||
|
|
||||||
|
`persistLocked()` is **not** modified — `store.Snapshot` gains no coach fields.
|
||||||
|
Rationale: a proposal is pre-commitment advice; if the daemon restarts during
|
||||||
|
planning, there is nothing to recover, and the user simply re-sharpens.
|
||||||
|
|
||||||
|
Coach state is reset to `idle` (proposal nil, err "") in two places:
|
||||||
|
|
||||||
|
- `EnterPlanning` — entering planning starts with a clean coach.
|
||||||
|
- `StartManualCommitment` and the `enterReview`/`End` paths implicitly leave
|
||||||
|
planning; coach state is reset to `idle` there so a stale `ready` proposal is
|
||||||
|
not projected outside planning. (Concretely: reset in `EnterPlanning` and on
|
||||||
|
any successful leave-planning transition.)
|
||||||
|
|
||||||
|
#### State projection
|
||||||
|
|
||||||
|
`State` gains a coach projection, populated **only while in planning**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ProposalView struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoachView struct {
|
||||||
|
Status string `json:"status"` // idle | pending | ready | error
|
||||||
|
Proposal *ProposalView `json:"proposal,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// added to State:
|
||||||
|
// Coach *CoachView `json:"coach,omitempty"`
|
||||||
|
```
|
||||||
|
|
||||||
|
In `stateLocked()`: if `runtimeState == RuntimePlanning`, attach a `CoachView`
|
||||||
|
with the current status (default `idle`), the proposal if `ready`, and the error
|
||||||
|
if `error`. Outside planning, `Coach` is `nil` and omitted.
|
||||||
|
|
||||||
|
### `web` layer
|
||||||
|
|
||||||
|
One new route:
|
||||||
|
|
||||||
|
```go
|
||||||
|
r.POST("/coach", s.handleCoach)
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
type coachRequest struct {
|
||||||
|
Intent string `json:"intent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCoach(c *gin.Context) {
|
||||||
|
var req coachRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.respond(c, s.ctrl.RequestCoach(req.Intent))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`respond` already broadcasts on success and maps errors. `ErrNotPlanning` is a
|
||||||
|
plain (non-`IllegalTransitionError`) error, so it maps to
|
||||||
|
`http.StatusBadRequest` — acceptable, since the UI only shows Sharpen during
|
||||||
|
planning. The pending → ready/error progression reaches the browser entirely
|
||||||
|
over the existing SSE stream; the POST response itself is not relied upon for
|
||||||
|
the proposal.
|
||||||
|
|
||||||
|
### UI (`internal/web/static/index.html`)
|
||||||
|
|
||||||
|
The Planning view gains an intent box and a Sharpen button **above** the three
|
||||||
|
existing inputs:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Rough intent .......................... ] [ Sharpen ]
|
||||||
|
(coach status line: thinking… / error note)
|
||||||
|
Next action [ ........................ ]
|
||||||
|
Success condition[ ........................ ]
|
||||||
|
Minutes [ 25 ]
|
||||||
|
[ Start commitment ]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Partial-update requirement.** Today `render()` replaces the planning view's
|
||||||
|
`innerHTML` on every SSE message. With a coach, SSE messages now arrive *while
|
||||||
|
the user is typing*, so a full rebuild would wipe their input and focus. The
|
||||||
|
fix:
|
||||||
|
|
||||||
|
- Track the currently rendered runtime state in a module variable
|
||||||
|
(e.g. `renderedState`).
|
||||||
|
- When an SSE message arrives and `rs === 'planning'` **and** the planning view
|
||||||
|
is already mounted, do **not** rebuild. Instead call an
|
||||||
|
`updatePlanningCoach(state.coach)` that only:
|
||||||
|
- updates the coach status line (pending → "thinking…", error → the message,
|
||||||
|
idle/absent → empty);
|
||||||
|
- when status is `ready` and the proposal has not yet been applied for this
|
||||||
|
generation, writes `proposal.next_action`, `proposal.success_condition`, and
|
||||||
|
`Math.round(proposal.timebox_secs / 60)` into the three inputs, then runs the
|
||||||
|
existing `check()` to enable Start. Pre-fill happens once per ready proposal
|
||||||
|
(guard with a flag) so it does not clobber subsequent manual edits on every
|
||||||
|
SSE tick.
|
||||||
|
- Only rebuild the planning structure when transitioning *into* planning from a
|
||||||
|
different state.
|
||||||
|
|
||||||
|
The Sharpen button POSTs `{ intent }` to `/coach` and shows the pending state
|
||||||
|
optimistically; the disabled/enabled logic for Start is unchanged. Other runtime
|
||||||
|
states (`locked`/`active`/`review`) keep their current full-rebuild render.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Backend selection is config-driven from day one:
|
||||||
|
|
||||||
|
- Env var `ANTIDRIFT_AI_BACKEND` selects the adapter: `claude` (default) or
|
||||||
|
`codex`. Unknown values are a startup error.
|
||||||
|
- `cmd/antidriftd/main.go` reads the env var, calls `ai.NewBackend(name)`, wraps
|
||||||
|
it in `ai.NewService(backend)`, and calls `ctrl.SetCoach(service)`. If
|
||||||
|
`NewBackend` errors, the daemon logs a warning and runs **without** a coach
|
||||||
|
(manual planning still works) rather than failing to start — graceful
|
||||||
|
degradation extends to misconfiguration.
|
||||||
|
|
||||||
|
## Error Handling and Degradation
|
||||||
|
|
||||||
|
Every failure surfaces as a non-blocking `status=error` in the coach view, never
|
||||||
|
as a broken Planning view:
|
||||||
|
|
||||||
|
| Failure | Result |
|
||||||
|
| ------- | ------ |
|
||||||
|
| No backend wired (`SetCoach` never called / nil) | `RequestCoach` sets `status=error`, "coach unavailable"; returns nil |
|
||||||
|
| CLI binary missing | `backend.Run` errors → goroutine sets `status=error` |
|
||||||
|
| CLI timeout (>60s) | `context` cancels child → error → `status=error` |
|
||||||
|
| Empty / non-JSON output | `extractJSON`/`parseProposal` error → `status=error` |
|
||||||
|
| Missing/empty fields, non-positive timebox | `parseProposal` error → `status=error` |
|
||||||
|
| Request issued outside planning | `RequestCoach` returns `ErrNotPlanning` → HTTP 400 |
|
||||||
|
|
||||||
|
Error messages shown to the UI are sanitized to a short human string; raw CLI
|
||||||
|
stderr is logged server-side, not surfaced to the browser.
|
||||||
|
|
||||||
|
## Package Layout Changes
|
||||||
|
|
||||||
|
| Package | Change |
|
||||||
|
| ------- | ------ |
|
||||||
|
| `ai` (new) | `Backend` interface; `claudeBackend`, `codexBackend`; `NewBackend`; `Coach` interface; `Proposal`; `Service`; prompt builder; `extractJSON`; `parseProposal`; sentinel errors; `fakeBackend` (test) |
|
||||||
|
| `session` | `coach` fields; `SetCoach`; `RequestCoach`; coach reset in `EnterPlanning` and leave-planning paths; `CoachView`/`ProposalView`; `Coach` field on `State`; `stateLocked` projection |
|
||||||
|
| `web` | `POST /coach` route + `handleCoach` + `coachRequest` |
|
||||||
|
| `web/static/index.html` | intent box + Sharpen button; `updatePlanningCoach`; partial-update guard in `render()` |
|
||||||
|
| `cmd/antidriftd` | read `ANTIDRIFT_AI_BACKEND`; build backend + service; `ctrl.SetCoach`; graceful fallback |
|
||||||
|
|
||||||
|
`ai` stays small and single-purpose, consistent with the token-efficiency design
|
||||||
|
constraint.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
**`ai` package:**
|
||||||
|
|
||||||
|
- `extractJSON`: bare object, object wrapped in prose, fenced code block, no JSON
|
||||||
|
(error), multiple objects (returns first balanced one).
|
||||||
|
- `parseProposal`: valid; missing `next_action`; empty `success_condition`;
|
||||||
|
`timebox_minutes` of 0 and negative; minutes→secs conversion.
|
||||||
|
- `Service.Coach` against a `fakeBackend` returning canned strings: success,
|
||||||
|
chatty-wrapped success, malformed → error.
|
||||||
|
- `claudeBackend`/`codexBackend`: argument construction is correct and the prompt
|
||||||
|
is routed to stdin (assert on the built `*exec.Cmd` `Args`/`Stdin` fields; do
|
||||||
|
not spawn the real CLI). For codex, assert the `-o <tmpfile>` flag is present
|
||||||
|
and that `Run` would read that path (factor the temp-file path out so it is
|
||||||
|
injectable/observable in the test).
|
||||||
|
- `NewBackend`: returns claude by default, codex by name, error on unknown.
|
||||||
|
|
||||||
|
**`session` package** (with a fake `ai.Coach`):
|
||||||
|
|
||||||
|
- `RequestCoach` in planning, fake returns a proposal: status goes
|
||||||
|
`pending` then `ready`; `State().Coach.Proposal` matches; `onChange` fires
|
||||||
|
twice.
|
||||||
|
- Fake returns an error: status goes `pending` then `error`.
|
||||||
|
- Nil coach: status `error` "coach unavailable"; `RequestCoach` returns nil.
|
||||||
|
- Wrong state (locked/active): `RequestCoach` returns `ErrNotPlanning`; no
|
||||||
|
goroutine, no state change.
|
||||||
|
- Stale generation: two `RequestCoach` calls; the first (slow) fake result is
|
||||||
|
discarded, only the second is projected. (Drive via a fake whose return is
|
||||||
|
gated on a channel so ordering is deterministic.)
|
||||||
|
- Leaving planning discards a pending/ready proposal: `Coach` is nil in `State`
|
||||||
|
once active.
|
||||||
|
- Snapshot has no coach fields (round-trip a snapshot, assert unaffected).
|
||||||
|
|
||||||
|
**`web` package** (with a fake `ai.Coach` wired into a real controller):
|
||||||
|
|
||||||
|
- `POST /coach` in planning returns 200 and the broadcast state shows
|
||||||
|
`status=pending` (or `ready` if the fake is synchronous).
|
||||||
|
- `POST /coach` outside planning returns 400.
|
||||||
|
- `POST /coach` with invalid JSON returns 400.
|
||||||
|
- Coach-unavailable controller: `POST /coach` returns 200, state shows
|
||||||
|
`status=error`.
|
||||||
|
|
||||||
|
All tests use fakes; **no test invokes the real `claude`/`codex` CLI**. Tests
|
||||||
|
must remain race-clean (`go test -race ./...`), consistent with M1.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- `ai` package with both adapters, `Coach`/`Service`, parsing, and tests.
|
||||||
|
- `RequestCoach` async flow with generation-guard and graceful degradation.
|
||||||
|
- `/coach` route and Planning-view Sharpen flow that pre-fills without clobbering
|
||||||
|
user input.
|
||||||
|
- `ANTIDRIFT_AI_BACKEND` wiring in the daemon with graceful fallback.
|
||||||
|
- `go test -race ./...` passes; manual smoke: type an intent, Sharpen, see the
|
||||||
|
three fields populate, edit, Start.
|
||||||
|
- README/roadmap note that M2 is complete (consistent with prior milestones).
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
module antidrift
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jezek/xgb v1.3.1
|
||||||
|
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298/go.mod h1:D+QujdIlUNfa0igpNMk6UIvlb6C252URs4yupRUV4lQ=
|
||||||
|
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966/go.mod h1:Mid70uvE93zn9wgF92A/r5ixgnvX8Lh68fxp9KQBaI0=
|
||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jezek/xgb v1.3.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
||||||
|
github.com/jezek/xgb v1.3.1 h1:NQCAEfQyzN+3RjWUSHBuVIxQcy2YfG3/mNvKfs/0rEg=
|
||||||
|
github.com/jezek/xgb v1.3.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
|
||||||
|
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a h1:byHoGvaqttgBKjxlRiJrgXpEGSEGaGSehCDAEOhWdmo=
|
||||||
|
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a/go.mod h1:J+gHyFrSWnDEeTohhG6DSh048byYWPx4z0ndUu6OhYw=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// claudeBackend shells out to `claude --print` with the prompt on stdin and the
|
||||||
|
// answer on stdout.
|
||||||
|
type claudeBackend struct {
|
||||||
|
cmd string
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClaudeBackend() claudeBackend {
|
||||||
|
return claudeBackend{
|
||||||
|
cmd: "claude",
|
||||||
|
args: []string{"--print", "--tools", "", "--no-session-persistence", "--output-format", "text"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b claudeBackend) Name() string { return "claude" }
|
||||||
|
|
||||||
|
func (b claudeBackend) Run(ctx context.Context, prompt string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, b.cmd, b.args...)
|
||||||
|
cmd.Stdin = strings.NewReader(prompt)
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &errb
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", cmdError("claude", err, errb.String())
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdError wraps a CLI failure, appending stderr only when it is non-empty so
|
||||||
|
// the message does not end in a dangling ": ".
|
||||||
|
func cmdError(name string, err error, stderr string) error {
|
||||||
|
if s := strings.TrimSpace(stderr); s != "" {
|
||||||
|
return fmt.Errorf("%s: %w: %s", name, err, s)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// codexBackend shells out to `codex exec` headlessly. codex's stdout includes
|
||||||
|
// session preamble, so the final answer is written to a temp file via -o and
|
||||||
|
// read back. The --ignore-* and read-only flags stop it from running shell
|
||||||
|
// commands driven by local config.
|
||||||
|
type codexBackend struct {
|
||||||
|
cmd string
|
||||||
|
baseArgs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCodexBackend() codexBackend {
|
||||||
|
return codexBackend{
|
||||||
|
cmd: "codex",
|
||||||
|
baseArgs: []string{
|
||||||
|
"exec", "--skip-git-repo-check", "--ignore-user-config",
|
||||||
|
"--ignore-rules", "-s", "read-only", "-a", "never", "--ephemeral",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b codexBackend) Name() string { return "codex" }
|
||||||
|
|
||||||
|
// args builds the full argument list, writing the answer to outfile and reading
|
||||||
|
// the prompt from stdin (trailing "-").
|
||||||
|
func (b codexBackend) args(outfile string) []string {
|
||||||
|
out := append([]string{}, b.baseArgs...)
|
||||||
|
return append(out, "-o", outfile, "-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
|
||||||
|
f, err := os.CreateTemp("", "antidrift-codex-*.out")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("codex: temp file: %w", err)
|
||||||
|
}
|
||||||
|
path := f.Name()
|
||||||
|
_ = f.Close()
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, b.cmd, b.args(path)...)
|
||||||
|
cmd.Stdin = strings.NewReader(prompt)
|
||||||
|
var errb bytes.Buffer
|
||||||
|
cmd.Stderr = &errb // stdout is noisy and intentionally discarded
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", cmdError("codex", err, errb.String())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("codex: read output: %w", err)
|
||||||
|
}
|
||||||
|
// A zero-exit run that wrote nothing means the -o file was never populated
|
||||||
|
// (e.g. flag/version skew); surface that as a codex error instead of letting
|
||||||
|
// it masquerade downstream as an empty model answer.
|
||||||
|
if len(bytes.TrimSpace(data)) == 0 {
|
||||||
|
return "", fmt.Errorf("codex: empty output file: %s", strings.TrimSpace(errb.String()))
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBackend returns the named backend. "" defaults to "claude".
|
||||||
|
func NewBackend(name string) (Backend, error) {
|
||||||
|
switch name {
|
||||||
|
case "", "claude":
|
||||||
|
return newClaudeBackend(), nil
|
||||||
|
case "codex":
|
||||||
|
return newCodexBackend(), nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("ai: unknown backend %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewBackendSelector(t *testing.T) {
|
||||||
|
for _, name := range []string{"", "claude"} {
|
||||||
|
b, err := NewBackend(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewBackend(%q): %v", name, err)
|
||||||
|
}
|
||||||
|
if b.Name() != "claude" {
|
||||||
|
t.Fatalf("NewBackend(%q) name = %q", name, b.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := NewBackend("codex")
|
||||||
|
if err != nil || b.Name() != "codex" {
|
||||||
|
t.Fatalf("codex backend: %v / %q", err, b.Name())
|
||||||
|
}
|
||||||
|
if _, err := NewBackend("bogus"); err == nil {
|
||||||
|
t.Fatal("unknown backend should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeArgs(t *testing.T) {
|
||||||
|
want := []string{"--print", "--tools", "", "--no-session-persistence", "--output-format", "text"}
|
||||||
|
if got := newClaudeBackend().args; !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("claude args = %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexArgsCarryOutfileAndStdinMarker(t *testing.T) {
|
||||||
|
got := newCodexBackend().args("/tmp/out.txt")
|
||||||
|
if got[len(got)-1] != "-" {
|
||||||
|
t.Fatalf("codex args must end with stdin marker '-', got %v", got)
|
||||||
|
}
|
||||||
|
if !slices.Contains(got, "-o") || !slices.Contains(got, "/tmp/out.txt") {
|
||||||
|
t.Fatalf("codex args must include -o /tmp/out.txt, got %v", got)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"exec", "--ignore-user-config", "--ignore-rules", "read-only", "never"} {
|
||||||
|
if !slices.Contains(got, want) {
|
||||||
|
t.Fatalf("codex args missing %q, got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Coach turns a free-text intent into a validated Proposal.
|
||||||
|
type Coach interface {
|
||||||
|
Coach(ctx context.Context, intent string) (Proposal, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
|
||||||
|
// and arguments they run; each returns the model's text answer.
|
||||||
|
type Backend interface {
|
||||||
|
Run(ctx context.Context, prompt string) (string, error)
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service implements Coach over any Backend.
|
||||||
|
type Service struct {
|
||||||
|
backend Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(b Backend) *Service { return &Service{backend: b} }
|
||||||
|
|
||||||
|
func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) {
|
||||||
|
out, err := s.backend.Run(ctx, buildPrompt(intent))
|
||||||
|
if err != nil {
|
||||||
|
return Proposal{}, err
|
||||||
|
}
|
||||||
|
return parseProposal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPrompt(intent string) string {
|
||||||
|
return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
|
||||||
|
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- next_action: a single concrete imperative action, doable now.
|
||||||
|
- success_condition: observable and verifiable; how you'd know it is done.
|
||||||
|
- timebox_minutes: a realistic integer number of minutes for the action.
|
||||||
|
|
||||||
|
User intent: ` + intent
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeBackend struct {
|
||||||
|
out string
|
||||||
|
err error
|
||||||
|
gotPrompt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeBackend) Run(ctx context.Context, prompt string) (string, error) {
|
||||||
|
f.gotPrompt = prompt
|
||||||
|
return f.out, f.err
|
||||||
|
}
|
||||||
|
func (f *fakeBackend) Name() string { return "fake" }
|
||||||
|
|
||||||
|
func TestServiceCoachSuccess(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`}
|
||||||
|
svc := NewService(fb)
|
||||||
|
p, err := svc.Coach(context.Background(), "write the tests")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("coach: %v", err)
|
||||||
|
}
|
||||||
|
if p.NextAction != "Write tests" || p.TimeboxSecs != 1800 {
|
||||||
|
t.Fatalf("bad proposal: %+v", p)
|
||||||
|
}
|
||||||
|
if !strings.Contains(fb.gotPrompt, "write the tests") {
|
||||||
|
t.Fatalf("prompt should embed the intent, got: %s", fb.gotPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceCoachBackendError(t *testing.T) {
|
||||||
|
fb := &fakeBackend{err: errors.New("boom")}
|
||||||
|
if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil {
|
||||||
|
t.Fatal("want backend error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceCoachUnparseable(t *testing.T) {
|
||||||
|
fb := &fakeBackend{out: "I cannot help with that."}
|
||||||
|
if _, err := NewService(fb).Coach(context.Background(), "x"); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Package ai is the Advisor port: it turns a user's free-text intent into a
|
||||||
|
// structured commitment Proposal by shelling out to an LLM CLI. It imports
|
||||||
|
// nothing from the rest of the app, so it stays a leaf package.
|
||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proposal is the coach's structured suggestion for a commitment. It is not a
|
||||||
|
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
|
||||||
|
type Proposal struct {
|
||||||
|
NextAction string
|
||||||
|
SuccessCondition string
|
||||||
|
TimeboxSecs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrEmptyResponse = errors.New("ai: empty response")
|
||||||
|
ErrNoJSON = errors.New("ai: no JSON object in response")
|
||||||
|
ErrInvalidProposal = errors.New("ai: invalid proposal")
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractJSON returns the first balanced top-level JSON object in s, tolerating
|
||||||
|
// leading/trailing prose or code fences around it.
|
||||||
|
func extractJSON(s string) (string, error) {
|
||||||
|
start := strings.IndexByte(s, '{')
|
||||||
|
if start < 0 {
|
||||||
|
return "", ErrNoJSON
|
||||||
|
}
|
||||||
|
depth, inStr, esc := 0, false, false
|
||||||
|
for i := start; i < len(s); i++ {
|
||||||
|
ch := s[i]
|
||||||
|
if inStr {
|
||||||
|
switch {
|
||||||
|
case esc:
|
||||||
|
esc = false
|
||||||
|
case ch == '\\':
|
||||||
|
esc = true
|
||||||
|
case ch == '"':
|
||||||
|
inStr = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ch {
|
||||||
|
case '"':
|
||||||
|
inStr = true
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return s[start : i+1], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", ErrNoJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawProposal struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxMinutes int64 `json:"timebox_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProposal extracts and validates a Proposal from raw CLI output.
|
||||||
|
func parseProposal(s string) (Proposal, error) {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return Proposal{}, ErrEmptyResponse
|
||||||
|
}
|
||||||
|
// If there is no opening brace at all, report ErrNoJSON.
|
||||||
|
// If there is a brace but it is unbalanced or otherwise malformed,
|
||||||
|
// report ErrInvalidProposal so callers can distinguish "no attempt"
|
||||||
|
// from "bad attempt".
|
||||||
|
if strings.IndexByte(s, '{') < 0 {
|
||||||
|
return Proposal{}, ErrNoJSON
|
||||||
|
}
|
||||||
|
jsonStr, err := extractJSON(s)
|
||||||
|
if err != nil {
|
||||||
|
return Proposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
|
||||||
|
}
|
||||||
|
var raw rawProposal
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
|
||||||
|
return Proposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
|
||||||
|
}
|
||||||
|
na := strings.TrimSpace(raw.NextAction)
|
||||||
|
sc := strings.TrimSpace(raw.SuccessCondition)
|
||||||
|
if na == "" || sc == "" || raw.TimeboxMinutes <= 0 {
|
||||||
|
return Proposal{}, ErrInvalidProposal
|
||||||
|
}
|
||||||
|
return Proposal{NextAction: na, SuccessCondition: sc, TimeboxSecs: raw.TimeboxMinutes * 60}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractJSON(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, in, want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"bare", `{"a":1}`, `{"a":1}`, false},
|
||||||
|
{"prose around", "sure, here:\n{\"a\":1}\nhope that helps", `{"a":1}`, false},
|
||||||
|
{"code fence", "```json\n{\"a\":1}\n```", `{"a":1}`, false},
|
||||||
|
{"nested", `{"a":{"b":2},"c":3}`, `{"a":{"b":2},"c":3}`, false},
|
||||||
|
{"brace in string", `{"a":"}{"}`, `{"a":"}{"}`, false},
|
||||||
|
{"first of two", `{"a":1} then {"b":2}`, `{"a":1}`, false},
|
||||||
|
{"none", `no json here`, "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, err := extractJSON(tc.in)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("want error, got %q", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("got %q want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseProposal(t *testing.T) {
|
||||||
|
good := `{"next_action":"Draft summary","success_condition":"3 paragraphs written","timebox_minutes":25}`
|
||||||
|
p, err := parseProposal(good)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("good parse: %v", err)
|
||||||
|
}
|
||||||
|
if p.NextAction != "Draft summary" || p.SuccessCondition != "3 paragraphs written" {
|
||||||
|
t.Fatalf("fields wrong: %+v", p)
|
||||||
|
}
|
||||||
|
if p.TimeboxSecs != 25*60 {
|
||||||
|
t.Fatalf("timebox secs got %d want %d", p.TimeboxSecs, 25*60)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := parseProposal(""); !errors.Is(err, ErrEmptyResponse) {
|
||||||
|
t.Fatalf("empty: want ErrEmptyResponse, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := parseProposal("no json"); !errors.Is(err, ErrNoJSON) {
|
||||||
|
t.Fatalf("no json: want ErrNoJSON, got %v", err)
|
||||||
|
}
|
||||||
|
bad := []string{
|
||||||
|
`{"next_action":"","success_condition":"x","timebox_minutes":25}`,
|
||||||
|
`{"next_action":"x","success_condition":" ","timebox_minutes":25}`,
|
||||||
|
`{"next_action":"x","success_condition":"y","timebox_minutes":0}`,
|
||||||
|
`{"next_action":"x","success_condition":"y","timebox_minutes":-5}`,
|
||||||
|
`{"next_action":"x"`,
|
||||||
|
}
|
||||||
|
for _, in := range bad {
|
||||||
|
if _, err := parseProposal(in); !errors.Is(err, ErrInvalidProposal) {
|
||||||
|
t.Fatalf("bad %q: want ErrInvalidProposal, got %v", in, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Package domain holds the core commitment types and validation, ported from
|
||||||
|
// the original Rust implementation. These are pure data types with no I/O.
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const PolicySchemaVersion = 1
|
||||||
|
|
||||||
|
type CommitmentSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceManual CommitmentSource = "manual"
|
||||||
|
SourcePlanner CommitmentSource = "planner"
|
||||||
|
SourceRecurring CommitmentSource = "recurring"
|
||||||
|
SourceRecovery CommitmentSource = "recovery"
|
||||||
|
SourceTemplate CommitmentSource = "template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommitmentState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CommitmentDraft CommitmentState = "draft"
|
||||||
|
CommitmentActive CommitmentState = "active"
|
||||||
|
CommitmentPaused CommitmentState = "paused"
|
||||||
|
CommitmentCompleted CommitmentState = "completed"
|
||||||
|
CommitmentAbandoned CommitmentState = "abandoned"
|
||||||
|
CommitmentViolated CommitmentState = "violated"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RuntimeState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RuntimeLocked RuntimeState = "locked"
|
||||||
|
RuntimePlanning RuntimeState = "planning"
|
||||||
|
RuntimeActive RuntimeState = "active"
|
||||||
|
RuntimeTransition RuntimeState = "transition"
|
||||||
|
RuntimeReview RuntimeState = "review"
|
||||||
|
RuntimeAdminOverride RuntimeState = "admin_override"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnforcementLevel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EnforcementObserve EnforcementLevel = "observe"
|
||||||
|
EnforcementWarn EnforcementLevel = "warn"
|
||||||
|
EnforcementBlock EnforcementLevel = "block"
|
||||||
|
EnforcementLocked EnforcementLevel = "locked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AllowedContext is carried by PolicySnapshot. Matching logic arrives in a
|
||||||
|
// later milestone; M0 only stores it.
|
||||||
|
type AllowedContext struct {
|
||||||
|
WindowClasses []string `json:"window_classes"`
|
||||||
|
WindowTitleSubstrings []string `json:"window_title_substrings"`
|
||||||
|
Domains []string `json:"domains"`
|
||||||
|
Repos []string `json:"repos"`
|
||||||
|
Commands []string `json:"commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Commitment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CreatedAtUnixSecs int64 `json:"created_at_unix_secs"`
|
||||||
|
Source CommitmentSource `json:"source"`
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
State CommitmentState `json:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PolicySnapshot struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CommitmentID string `json:"commitment_id"`
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
CreatedAtUnixSecs int64 `json:"created_at_unix_secs"`
|
||||||
|
RuntimeState RuntimeState `json:"runtime_state"`
|
||||||
|
EnforcementLevel EnforcementLevel `json:"enforcement_level"`
|
||||||
|
AllowedContext AllowedContext `json:"allowed_context"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMissingNextAction = errors.New("next action is required")
|
||||||
|
ErrMissingSuccessCondition = errors.New("success condition is required")
|
||||||
|
ErrMissingTimebox = errors.New("timebox must be nonzero")
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewManual builds a validated draft commitment from user input.
|
||||||
|
func NewManual(nextAction, successCondition string, timebox time.Duration) (Commitment, error) {
|
||||||
|
nextAction = strings.TrimSpace(nextAction)
|
||||||
|
successCondition = strings.TrimSpace(successCondition)
|
||||||
|
if nextAction == "" {
|
||||||
|
return Commitment{}, ErrMissingNextAction
|
||||||
|
}
|
||||||
|
if successCondition == "" {
|
||||||
|
return Commitment{}, ErrMissingSuccessCondition
|
||||||
|
}
|
||||||
|
if timebox < time.Second {
|
||||||
|
return Commitment{}, ErrMissingTimebox
|
||||||
|
}
|
||||||
|
return Commitment{
|
||||||
|
ID: "commitment-" + uuid.Must(uuid.NewV7()).String(),
|
||||||
|
CreatedAtUnixSecs: time.Now().Unix(),
|
||||||
|
Source: SourceManual,
|
||||||
|
NextAction: nextAction,
|
||||||
|
SuccessCondition: successCondition,
|
||||||
|
TimeboxSecs: int64(timebox.Seconds()),
|
||||||
|
State: CommitmentDraft,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForRuntime builds a minimal accepted policy snapshot for a commitment.
|
||||||
|
func ForRuntime(commitmentID string, runtime RuntimeState, level EnforcementLevel) PolicySnapshot {
|
||||||
|
return PolicySnapshot{
|
||||||
|
ID: "policy-" + uuid.Must(uuid.NewV7()).String(),
|
||||||
|
CommitmentID: commitmentID,
|
||||||
|
SchemaVersion: PolicySchemaVersion,
|
||||||
|
CreatedAtUnixSecs: time.Now().Unix(),
|
||||||
|
RuntimeState: runtime,
|
||||||
|
EnforcementLevel: level,
|
||||||
|
AllowedContext: AllowedContext{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewManualRejectsEmptyNextAction(t *testing.T) {
|
||||||
|
_, err := NewManual("", "tests pass", 25*time.Minute)
|
||||||
|
if err != ErrMissingNextAction {
|
||||||
|
t.Fatalf("want ErrMissingNextAction, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewManualRejectsEmptySuccessCondition(t *testing.T) {
|
||||||
|
_, err := NewManual("Refactor state", " ", 25*time.Minute)
|
||||||
|
if err != ErrMissingSuccessCondition {
|
||||||
|
t.Fatalf("want ErrMissingSuccessCondition, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewManualRejectsZeroTimebox(t *testing.T) {
|
||||||
|
_, err := NewManual("Refactor state", "tests pass", 0)
|
||||||
|
if err != ErrMissingTimebox {
|
||||||
|
t.Fatalf("want ErrMissingTimebox, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewManualPopulatesDraftCommitment(t *testing.T) {
|
||||||
|
c, err := NewManual(" Port domain ", "domain tests pass", 25*time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if c.NextAction != "Port domain" {
|
||||||
|
t.Errorf("next action not trimmed: %q", c.NextAction)
|
||||||
|
}
|
||||||
|
if c.SuccessCondition != "domain tests pass" {
|
||||||
|
t.Errorf("success condition wrong: %q", c.SuccessCondition)
|
||||||
|
}
|
||||||
|
if c.TimeboxSecs != 1500 {
|
||||||
|
t.Errorf("timebox secs wrong: %d", c.TimeboxSecs)
|
||||||
|
}
|
||||||
|
if c.State != CommitmentDraft {
|
||||||
|
t.Errorf("state should be draft, got %s", c.State)
|
||||||
|
}
|
||||||
|
if c.Source != SourceManual {
|
||||||
|
t.Errorf("source should be manual, got %s", c.Source)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(c.ID, "commitment-") {
|
||||||
|
t.Errorf("id should be prefixed, got %s", c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewManualRejectsSubSecondTimebox(t *testing.T) {
|
||||||
|
_, err := NewManual("Refactor state", "tests pass", 500*time.Millisecond)
|
||||||
|
if err != ErrMissingTimebox {
|
||||||
|
t.Fatalf("want ErrMissingTimebox, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForRuntimePopulatesPolicySnapshot(t *testing.T) {
|
||||||
|
p := ForRuntime("commitment-123", RuntimeActive, EnforcementObserve)
|
||||||
|
if !strings.HasPrefix(p.ID, "policy-") {
|
||||||
|
t.Errorf("id should be prefixed, got %s", p.ID)
|
||||||
|
}
|
||||||
|
if p.CommitmentID != "commitment-123" {
|
||||||
|
t.Errorf("commitment id wrong: %s", p.CommitmentID)
|
||||||
|
}
|
||||||
|
if p.SchemaVersion != PolicySchemaVersion {
|
||||||
|
t.Errorf("schema version wrong: %d", p.SchemaVersion)
|
||||||
|
}
|
||||||
|
if p.RuntimeState != RuntimeActive {
|
||||||
|
t.Errorf("runtime state wrong: %s", p.RuntimeState)
|
||||||
|
}
|
||||||
|
if p.EnforcementLevel != EnforcementObserve {
|
||||||
|
t.Errorf("enforcement level wrong: %s", p.EnforcementLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitmentSerializesSnakeCaseEnums(t *testing.T) {
|
||||||
|
c, _ := NewManual("Port domain", "domain tests pass", 25*time.Minute)
|
||||||
|
data, err := json.Marshal(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
s := string(data)
|
||||||
|
if !strings.Contains(s, `"source":"manual"`) {
|
||||||
|
t.Errorf("missing snake_case source in %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"state":"draft"`) {
|
||||||
|
t.Errorf("missing snake_case state in %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Package evidence is the activity port: a dumb active-window sensor behind a
|
||||||
|
// Source interface, plus the value types it emits. It makes no judgment about
|
||||||
|
// whether a window is on-task — that is the advisor's job in a later milestone.
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EvidenceHealth records whether the sensor could observe the active window.
|
||||||
|
type EvidenceHealth struct {
|
||||||
|
Available bool // true when a window was observed
|
||||||
|
Reason string // empty when Available; populated when not
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowSnapshot is one observation of the active window.
|
||||||
|
type WindowSnapshot struct {
|
||||||
|
Title string // full _NET_WM_NAME (raw; used in live view + raw log)
|
||||||
|
Class string // WM_CLASS
|
||||||
|
Health EvidenceHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source is the activity port. Watch runs until ctx is cancelled, invoking
|
||||||
|
// onChange on every active-window change, and once immediately with the
|
||||||
|
// current window.
|
||||||
|
type Source interface {
|
||||||
|
Watch(ctx context.Context, onChange func(WindowSnapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket
|
||||||
|
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl.
|
||||||
|
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
|
||||||
|
|
||||||
|
// ScrubTitle removes volatile numeric runs so window titles bucket stably.
|
||||||
|
// The raw event log keeps the unscrubbed title; only bucket keys are scrubbed.
|
||||||
|
func ScrubTitle(title string) string {
|
||||||
|
return titleNoise.ReplaceAllString(title, "")
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScrubTitle(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"Plain title", "Plain title"}, // no digits-with-separator: untouched
|
||||||
|
{"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory)
|
||||||
|
{"12.5%", ""}, // percent decimal: stripped whole
|
||||||
|
{"1:23:45 remaining", " remaining"}, // clock ratio: stripped
|
||||||
|
{"-3.0 delta", " delta"}, // leading sign + decimal: stripped
|
||||||
|
{"Download 50.5% complete", "Download complete"}, // embedded percent decimal
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ScrubTitle(c.in); got != c.want {
|
||||||
|
t.Errorf("ScrubTitle(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSource satisfies Source for downstream tests and proves the interface shape.
|
||||||
|
type fakeSource struct{ snaps []WindowSnapshot }
|
||||||
|
|
||||||
|
func (f fakeSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||||
|
for _, s := range f.snaps {
|
||||||
|
onChange(s)
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeSourceEmits(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
got := make(chan WindowSnapshot, 1)
|
||||||
|
src := fakeSource{snaps: []WindowSnapshot{
|
||||||
|
{Title: "a", Class: "code", Health: EvidenceHealth{Available: true}},
|
||||||
|
}}
|
||||||
|
// The channel synchronizes the producing goroutine with this one, so the
|
||||||
|
// read below is race-free and we can actually assert the emission.
|
||||||
|
go src.Watch(ctx, func(s WindowSnapshot) {
|
||||||
|
select {
|
||||||
|
case got <- s:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
select {
|
||||||
|
case s := <-got:
|
||||||
|
if s.Class != "code" {
|
||||||
|
t.Errorf("got class %q, want code", s.Class)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("fakeSource emitted no snapshot")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !race
|
||||||
|
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
// raceDetectorEnabled is true when the binary is built with -race.
|
||||||
|
const raceDetectorEnabled = false
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build race
|
||||||
|
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
// raceDetectorEnabled is true when the binary is built with -race.
|
||||||
|
const raceDetectorEnabled = true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// NewSource returns a sensor that reports evidence permanently unavailable on
|
||||||
|
// platforms without the X11 adapter.
|
||||||
|
func NewSource() Source { return noopSource{} }
|
||||||
|
|
||||||
|
type noopSource struct{}
|
||||||
|
|
||||||
|
func (noopSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||||
|
onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no active-window sensor on this platform"}})
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/jezek/xgb/xproto"
|
||||||
|
"github.com/jezek/xgbutil"
|
||||||
|
"github.com/jezek/xgbutil/ewmh"
|
||||||
|
"github.com/jezek/xgbutil/icccm"
|
||||||
|
"github.com/jezek/xgbutil/xevent"
|
||||||
|
"github.com/jezek/xgbutil/xprop"
|
||||||
|
"github.com/jezek/xgbutil/xwindow"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSource returns the real X11 active-window sensor.
|
||||||
|
func NewSource() Source { return &x11Source{} }
|
||||||
|
|
||||||
|
type x11Source struct{}
|
||||||
|
|
||||||
|
// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW
|
||||||
|
// changes on the root window, and emits a snapshot immediately plus on every
|
||||||
|
// change. Any failure degrades to an Unavailable snapshot; it never panics the
|
||||||
|
// daemon.
|
||||||
|
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
|
||||||
|
X, err := xgbutil.NewConn()
|
||||||
|
if err != nil {
|
||||||
|
onChange(unavailable("cannot connect to X server: " + err.Error()))
|
||||||
|
<-ctx.Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer X.Conn().Close()
|
||||||
|
|
||||||
|
root := X.RootWin()
|
||||||
|
activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW")
|
||||||
|
if err != nil {
|
||||||
|
onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error()))
|
||||||
|
<-ctx.Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for property changes on the root window.
|
||||||
|
if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil {
|
||||||
|
onChange(unavailable("cannot listen on root window: " + err.Error()))
|
||||||
|
<-ctx.Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit := func() { onChange(snapshot(X)) }
|
||||||
|
emit() // immediate current window
|
||||||
|
|
||||||
|
xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) {
|
||||||
|
if ev.Atom == activeAtom {
|
||||||
|
emit()
|
||||||
|
}
|
||||||
|
}).Connect(X, root)
|
||||||
|
|
||||||
|
// Run the event loop until ctx is cancelled.
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
xevent.Quit(X)
|
||||||
|
}()
|
||||||
|
xevent.Main(X)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(X *xgbutil.XUtil) WindowSnapshot {
|
||||||
|
active, err := ewmh.ActiveWindowGet(X)
|
||||||
|
if err != nil || active == 0 {
|
||||||
|
return unavailable("no active window")
|
||||||
|
}
|
||||||
|
title, err := ewmh.WmNameGet(X, active)
|
||||||
|
if err != nil || title == "" {
|
||||||
|
// Fall back to ICCCM WM_NAME.
|
||||||
|
if t, e := icccm.WmNameGet(X, active); e == nil {
|
||||||
|
title = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var class string
|
||||||
|
if wmClass, err := icccm.WmClassGet(X, active); err == nil && wmClass != nil {
|
||||||
|
class = wmClass.Class
|
||||||
|
}
|
||||||
|
return WindowSnapshot{
|
||||||
|
Title: title,
|
||||||
|
Class: class,
|
||||||
|
Health: EvidenceHealth{Available: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unavailable(reason string) WindowSnapshot {
|
||||||
|
log.Printf("evidence: %s", reason)
|
||||||
|
return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package evidence
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestX11SourceEmitsWhenDisplaySet(t *testing.T) {
|
||||||
|
if os.Getenv("DISPLAY") == "" {
|
||||||
|
t.Skip("no DISPLAY; skipping live X11 smoke test")
|
||||||
|
}
|
||||||
|
if raceDetectorEnabled {
|
||||||
|
// xgbutil's event loop has an internal Quit/Quitting data race on its
|
||||||
|
// shutdown flag (xevent.Quit vs xevent.Quitting). It is in the library,
|
||||||
|
// not our code, and is never reached in production: the daemon's Watch
|
||||||
|
// runs under context.Background(), which is never cancelled. Skip under
|
||||||
|
// -race so the rest of the suite stays clean.
|
||||||
|
t.Skip("xgbutil event loop races on shutdown under -race; not exercised in production")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
got := make(chan WindowSnapshot, 1)
|
||||||
|
go NewSource().Watch(ctx, func(s WindowSnapshot) {
|
||||||
|
select {
|
||||||
|
case got <- s:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case snap := <-got:
|
||||||
|
// Either a real window or a clean Unavailable — both are valid; we only
|
||||||
|
// assert the sensor produced an observation without panicking.
|
||||||
|
t.Logf("first snapshot: %+v", snap)
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("x11 source emitted no snapshot within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
// Package session owns the daemon's in-memory state of truth and persists a
|
||||||
|
// snapshot on every change. Transitions go through the pure statemachine. It
|
||||||
|
// also owns per-session evidence stats: it accumulates active-window time while
|
||||||
|
// Active, logs raw focus events, and seals each session into the audit chain.
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/ai"
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/statemachine"
|
||||||
|
"antidrift/internal/store"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
unavailableTitle = "(evidence unavailable)"
|
||||||
|
sessionRetention = 30 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
const coachTimeout = 60 * time.Second
|
||||||
|
|
||||||
|
const (
|
||||||
|
coachIdle = "idle"
|
||||||
|
coachPending = "pending"
|
||||||
|
coachReady = "ready"
|
||||||
|
coachError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
|
||||||
|
|
||||||
|
// bucketKey identifies a time bucket; Title is the scrubbed title.
|
||||||
|
type bucketKey struct{ Class, Title string }
|
||||||
|
|
||||||
|
// EvidenceStats is the in-memory accounting for the current session only.
|
||||||
|
type EvidenceStats struct {
|
||||||
|
SessionID string
|
||||||
|
StartedUnix int64
|
||||||
|
Buckets map[bucketKey]time.Duration
|
||||||
|
SwitchCount int
|
||||||
|
Current evidence.WindowSnapshot
|
||||||
|
lastFocusAt time.Time
|
||||||
|
lastKey bucketKey
|
||||||
|
hasLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller holds runtime state and the active commitment behind a mutex.
|
||||||
|
type Controller struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
runtimeState domain.RuntimeState
|
||||||
|
commitment *domain.Commitment
|
||||||
|
deadline time.Time
|
||||||
|
snapshotPath string
|
||||||
|
auditPath string
|
||||||
|
sessionsDir string
|
||||||
|
clock func() time.Time
|
||||||
|
onChange func()
|
||||||
|
latestWindow evidence.WindowSnapshot
|
||||||
|
stats *EvidenceStats
|
||||||
|
outcomePending string
|
||||||
|
coach ai.Coach
|
||||||
|
coachStatus string
|
||||||
|
coachProposal *ai.Proposal
|
||||||
|
coachErr string
|
||||||
|
coachGen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitmentView is the UI projection of the active commitment.
|
||||||
|
type CommitmentView struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProposalView / CoachView project the ephemeral planning-coach state.
|
||||||
|
type ProposalView struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CoachView struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Proposal *ProposalView `json:"proposal,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WindowView / BucketView / EvidenceView are the evidence projection.
|
||||||
|
type WindowView struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BucketView struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EvidenceView struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Current WindowView `json:"current"`
|
||||||
|
SwitchCount int `json:"switch_count"`
|
||||||
|
Buckets []BucketView `json:"buckets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the broadcastable view of the controller.
|
||||||
|
type State struct {
|
||||||
|
RuntimeState domain.RuntimeState `json:"runtime_state"`
|
||||||
|
Commitment *CommitmentView `json:"commitment"`
|
||||||
|
Evidence *EvidenceView `json:"evidence"`
|
||||||
|
Coach *CoachView `json:"coach,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
|
||||||
|
// in-memory stats from the raw log if a live session was interrupted.
|
||||||
|
func New(snapshotPath string) (*Controller, error) {
|
||||||
|
s, err := store.Load(snapshotPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(snapshotPath)
|
||||||
|
c := &Controller{
|
||||||
|
runtimeState: s.RuntimeState,
|
||||||
|
commitment: s.Commitment,
|
||||||
|
snapshotPath: snapshotPath,
|
||||||
|
auditPath: filepath.Join(dir, "audit.jsonl"),
|
||||||
|
sessionsDir: filepath.Join(dir, "sessions"),
|
||||||
|
clock: time.Now,
|
||||||
|
outcomePending: s.OutcomePending,
|
||||||
|
}
|
||||||
|
if s.DeadlineUnixSecs > 0 {
|
||||||
|
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
|
||||||
|
}
|
||||||
|
if c.runtimeState == "" {
|
||||||
|
c.runtimeState = domain.RuntimeLocked
|
||||||
|
}
|
||||||
|
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
|
||||||
|
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
|
||||||
|
c.replayStats(s.SessionID)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetClock overrides the time source (tests only). Call before starting a
|
||||||
|
// session.
|
||||||
|
func (c *Controller) SetClock(f func() time.Time) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.clock = f
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnChange registers a callback fired after an evidence-driven state change
|
||||||
|
// (focus updates). It is invoked with the mutex released.
|
||||||
|
func (c *Controller) SetOnChange(f func()) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.onChange = f
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) notify() {
|
||||||
|
c.mu.Lock()
|
||||||
|
f := c.onChange
|
||||||
|
c.mu.Unlock()
|
||||||
|
if f != nil {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State returns the current broadcastable state. Safe for concurrent use.
|
||||||
|
func (c *Controller) State() State {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.stateLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deadline returns the active commitment deadline, or the zero time.
|
||||||
|
func (c *Controller) Deadline() time.Time {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.deadline
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) stateLocked() State {
|
||||||
|
st := State{RuntimeState: c.runtimeState}
|
||||||
|
if c.commitment != nil {
|
||||||
|
view := &CommitmentView{
|
||||||
|
NextAction: c.commitment.NextAction,
|
||||||
|
SuccessCondition: c.commitment.SuccessCondition,
|
||||||
|
TimeboxSecs: c.commitment.TimeboxSecs,
|
||||||
|
}
|
||||||
|
if !c.deadline.IsZero() {
|
||||||
|
view.DeadlineUnixSecs = c.deadline.Unix()
|
||||||
|
}
|
||||||
|
st.Commitment = view
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
st.Evidence = &EvidenceView{
|
||||||
|
Available: c.stats.Current.Health.Available,
|
||||||
|
Reason: c.stats.Current.Health.Reason,
|
||||||
|
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
|
||||||
|
SwitchCount: c.stats.SwitchCount,
|
||||||
|
Buckets: bucketViews(c.stats.Buckets),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.runtimeState == domain.RuntimePlanning {
|
||||||
|
status := c.coachStatus
|
||||||
|
if status == "" {
|
||||||
|
status = coachIdle
|
||||||
|
}
|
||||||
|
cv := &CoachView{Status: status, Error: c.coachErr}
|
||||||
|
if c.coachProposal != nil {
|
||||||
|
cv.Proposal = &ProposalView{
|
||||||
|
NextAction: c.coachProposal.NextAction,
|
||||||
|
SuccessCondition: c.coachProposal.SuccessCondition,
|
||||||
|
TimeboxSecs: c.coachProposal.TimeboxSecs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Coach = cv
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
|
||||||
|
out := make([]BucketView, 0, len(buckets))
|
||||||
|
for k, d := range buckets {
|
||||||
|
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) persistLocked() error {
|
||||||
|
snap := store.Snapshot{
|
||||||
|
RuntimeState: c.runtimeState,
|
||||||
|
Commitment: c.commitment,
|
||||||
|
OutcomePending: c.outcomePending,
|
||||||
|
}
|
||||||
|
if !c.deadline.IsZero() {
|
||||||
|
snap.DeadlineUnixSecs = c.deadline.Unix()
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
snap.SessionID = c.stats.SessionID
|
||||||
|
}
|
||||||
|
return store.Save(c.snapshotPath, snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnterPlanning moves Locked -> Planning.
|
||||||
|
func (c *Controller) EnterPlanning() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.runtimeState = next
|
||||||
|
c.resetCoachLocked()
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
|
||||||
|
// RequestCoach degrade gracefully.
|
||||||
|
func (c *Controller) SetCoach(coach ai.Coach) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.coach = coach
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetCoachLocked returns coach state to idle and invalidates any in-flight
|
||||||
|
// request. Caller holds mu.
|
||||||
|
func (c *Controller) resetCoachLocked() {
|
||||||
|
c.coachStatus = coachIdle
|
||||||
|
c.coachProposal = nil
|
||||||
|
c.coachErr = ""
|
||||||
|
c.coachGen++
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
|
||||||
|
// not in planning; otherwise never a hard error (failures surface as coach
|
||||||
|
// state). The proposal is ephemeral and never persisted.
|
||||||
|
func (c *Controller) RequestCoach(intent string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.runtimeState != domain.RuntimePlanning {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return ErrNotPlanning
|
||||||
|
}
|
||||||
|
if c.coach == nil {
|
||||||
|
c.coachStatus = coachError
|
||||||
|
c.coachErr = "coach unavailable"
|
||||||
|
c.coachProposal = nil
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.coachGen++
|
||||||
|
gen := c.coachGen
|
||||||
|
c.coachStatus = coachPending
|
||||||
|
c.coachErr = ""
|
||||||
|
c.coachProposal = nil
|
||||||
|
coach := c.coach
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
|
||||||
|
defer cancel()
|
||||||
|
prop, err := coach.Coach(ctx, intent)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return // stale or left planning: discard
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.coachStatus = coachError
|
||||||
|
c.coachErr = coachErrorMessage(err)
|
||||||
|
c.coachProposal = nil
|
||||||
|
} else {
|
||||||
|
c.coachStatus = coachReady
|
||||||
|
c.coachProposal = &prop
|
||||||
|
c.coachErr = ""
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func coachErrorMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
|
||||||
|
return "coach returned an unusable response"
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return "coach timed out"
|
||||||
|
default:
|
||||||
|
return "coach unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartManualCommitment validates input, activates a new commitment, mints a
|
||||||
|
// session, seeds evidence stats from the latest window, and moves Planning ->
|
||||||
|
// Active.
|
||||||
|
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := c.clock()
|
||||||
|
c.runtimeState = next
|
||||||
|
c.commitment = &commitment
|
||||||
|
c.deadline = now.Add(timebox)
|
||||||
|
c.outcomePending = ""
|
||||||
|
c.resetCoachLocked()
|
||||||
|
|
||||||
|
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
|
||||||
|
c.stats = &EvidenceStats{
|
||||||
|
SessionID: sessionID,
|
||||||
|
StartedUnix: now.Unix(),
|
||||||
|
Buckets: map[bucketKey]time.Duration{},
|
||||||
|
}
|
||||||
|
seed := c.latestWindow
|
||||||
|
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
|
||||||
|
c.applyEvent(now, seed)
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete moves Active -> Review with a "completed" outcome.
|
||||||
|
func (c *Controller) Complete() error { return c.enterReview("completed") }
|
||||||
|
|
||||||
|
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
|
||||||
|
func (c *Controller) Expire() error { return c.enterReview("expired") }
|
||||||
|
|
||||||
|
func (c *Controller) enterReview(outcome string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.commitment != nil {
|
||||||
|
completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.commitment.State = completed
|
||||||
|
}
|
||||||
|
// Flush the final open segment, then freeze accounting.
|
||||||
|
if c.stats != nil && c.stats.hasLast {
|
||||||
|
c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt)
|
||||||
|
c.stats.hasLast = false
|
||||||
|
}
|
||||||
|
c.runtimeState = next
|
||||||
|
c.outcomePending = outcome
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// End moves Review -> Locked, writes the session summary to the audit chain,
|
||||||
|
// and clears the commitment and stats.
|
||||||
|
func (c *Controller) End() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.stats != nil {
|
||||||
|
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
|
||||||
|
// State integrity over audit completeness: the transition still
|
||||||
|
// completes. Surfaced for the operator; no auto-retry in M1.
|
||||||
|
log.Printf("session: audit append failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.runtimeState = next
|
||||||
|
c.commitment = nil
|
||||||
|
c.deadline = time.Time{}
|
||||||
|
c.stats = nil
|
||||||
|
c.outcomePending = ""
|
||||||
|
return c.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) buildSummaryLocked() store.SessionSummary {
|
||||||
|
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
|
||||||
|
for k, d := range c.stats.Buckets {
|
||||||
|
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
|
||||||
|
}
|
||||||
|
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds })
|
||||||
|
outcome := c.outcomePending
|
||||||
|
if outcome == "" {
|
||||||
|
outcome = "completed"
|
||||||
|
}
|
||||||
|
var na, sc string
|
||||||
|
if c.commitment != nil {
|
||||||
|
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
|
||||||
|
}
|
||||||
|
return store.SessionSummary{
|
||||||
|
SessionID: c.stats.SessionID,
|
||||||
|
NextAction: na,
|
||||||
|
SuccessCond: sc,
|
||||||
|
Outcome: outcome,
|
||||||
|
StartedUnix: c.stats.StartedUnix,
|
||||||
|
EndedUnix: c.clock().Unix(),
|
||||||
|
SwitchCount: c.stats.SwitchCount,
|
||||||
|
Buckets: buckets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordWindow ingests one sensor observation. It accumulates time only while
|
||||||
|
// Active, always tracks the latest window for display, and fires onChange after
|
||||||
|
// releasing the mutex.
|
||||||
|
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.latestWindow = snap
|
||||||
|
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := c.clock()
|
||||||
|
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
|
||||||
|
c.applyEvent(now, snap)
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.notify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEvent advances stats by one observation: it credits the prior segment to
|
||||||
|
// the prior bucket, counts a context switch on key change, and records the new
|
||||||
|
// current window. Used by both live tracking and crash replay. Caller holds mu.
|
||||||
|
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
|
||||||
|
if c.stats.hasLast {
|
||||||
|
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
|
||||||
|
}
|
||||||
|
newKey := keyFor(snap)
|
||||||
|
if c.stats.hasLast && newKey != c.stats.lastKey {
|
||||||
|
c.stats.SwitchCount++
|
||||||
|
}
|
||||||
|
c.stats.lastKey = newKey
|
||||||
|
c.stats.lastFocusAt = now
|
||||||
|
c.stats.hasLast = true
|
||||||
|
c.stats.Current = snap
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayStats rebuilds in-memory stats from the raw session log after a crash.
|
||||||
|
func (c *Controller) replayStats(sessionID string) {
|
||||||
|
events, err := store.ReplaySession(c.sessionsDir, sessionID)
|
||||||
|
if err != nil || len(events) == 0 {
|
||||||
|
c.stats = &EvidenceStats{
|
||||||
|
SessionID: sessionID,
|
||||||
|
StartedUnix: c.clock().Unix(),
|
||||||
|
Buckets: map[bucketKey]time.Duration{},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.stats = &EvidenceStats{
|
||||||
|
SessionID: sessionID,
|
||||||
|
StartedUnix: events[0].AtUnixMillis / 1000,
|
||||||
|
Buckets: map[bucketKey]time.Duration{},
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyFor(snap evidence.WindowSnapshot) bucketKey {
|
||||||
|
if !snap.Health.Available {
|
||||||
|
return bucketKey{Class: "", Title: unavailableTitle}
|
||||||
|
}
|
||||||
|
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
|
||||||
|
return store.FocusEvent{
|
||||||
|
AtUnixMillis: now.UnixMilli(),
|
||||||
|
Class: snap.Class,
|
||||||
|
Title: snap.Title,
|
||||||
|
Available: snap.Health.Available,
|
||||||
|
Reason: snap.Health.Reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
|
||||||
|
return evidence.WindowSnapshot{
|
||||||
|
Title: e.Title,
|
||||||
|
Class: e.Class,
|
||||||
|
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/ai"
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestController(t *testing.T) (*Controller, string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
c, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new controller: %v", err)
|
||||||
|
}
|
||||||
|
return c, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHappyPathDrivesStates(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
if c.State().RuntimeState != domain.RuntimeLocked {
|
||||||
|
t.Fatalf("should start Locked")
|
||||||
|
}
|
||||||
|
if err := c.EnterPlanning(); err != nil {
|
||||||
|
t.Fatalf("enter planning: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil {
|
||||||
|
t.Fatalf("start commitment: %v", err)
|
||||||
|
}
|
||||||
|
st := c.State()
|
||||||
|
if st.RuntimeState != domain.RuntimeActive {
|
||||||
|
t.Fatalf("should be Active, got %s", st.RuntimeState)
|
||||||
|
}
|
||||||
|
if st.Commitment == nil || st.Commitment.NextAction != "Port session" {
|
||||||
|
t.Fatalf("active commitment missing: %+v", st.Commitment)
|
||||||
|
}
|
||||||
|
if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() {
|
||||||
|
t.Fatalf("deadline should be in the future")
|
||||||
|
}
|
||||||
|
if err := c.Complete(); err != nil {
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if c.State().RuntimeState != domain.RuntimeReview {
|
||||||
|
t.Fatalf("should be Review after complete")
|
||||||
|
}
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
if c.State().RuntimeState != domain.RuntimeLocked {
|
||||||
|
t.Fatalf("should be Locked after end")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil {
|
||||||
|
t.Fatalf("empty next action should error")
|
||||||
|
}
|
||||||
|
if c.State().RuntimeState != domain.RuntimePlanning {
|
||||||
|
t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateRestoresFromSnapshot(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
_ = first.EnterPlanning()
|
||||||
|
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute)
|
||||||
|
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
st := second.State()
|
||||||
|
if st.RuntimeState != domain.RuntimeActive {
|
||||||
|
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
|
||||||
|
}
|
||||||
|
if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" {
|
||||||
|
t.Fatalf("restored commitment missing: %+v", st.Commitment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new: %v", err)
|
||||||
|
}
|
||||||
|
_ = first.EnterPlanning()
|
||||||
|
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute)
|
||||||
|
want := first.State().Commitment.DeadlineUnixSecs
|
||||||
|
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
got := second.State().Commitment
|
||||||
|
if got == nil || got.DeadlineUnixSecs != want {
|
||||||
|
t.Fatalf("restored deadline: got %+v want %d", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeClock returns successive instants on demand.
|
||||||
|
type fakeClock struct{ now time.Time }
|
||||||
|
|
||||||
|
func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) }
|
||||||
|
func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } }
|
||||||
|
|
||||||
|
func snap(class, title string) evidence.WindowSnapshot {
|
||||||
|
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketSeconds(t *testing.T, st State, class, title string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
if st.Evidence == nil {
|
||||||
|
t.Fatalf("expected evidence projection")
|
||||||
|
}
|
||||||
|
for _, b := range st.Evidence.Buckets {
|
||||||
|
if b.Class == class && b.Title == title {
|
||||||
|
return b.Seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccumulatesBucketsAndSwitches(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
|
c.SetClock(clk.fn())
|
||||||
|
c.RecordWindow(snap("code", "antidrift")) // latest window before start
|
||||||
|
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift
|
||||||
|
|
||||||
|
clk.advance(10 * time.Second)
|
||||||
|
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
|
||||||
|
clk.advance(20 * time.Second)
|
||||||
|
c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back
|
||||||
|
|
||||||
|
st := c.State()
|
||||||
|
if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 {
|
||||||
|
t.Errorf("code bucket = %d, want 10", got)
|
||||||
|
}
|
||||||
|
if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 {
|
||||||
|
t.Errorf("firefox bucket = %d, want 20", got)
|
||||||
|
}
|
||||||
|
if st.Evidence.SwitchCount != 2 {
|
||||||
|
t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoAccountingOutsideActive(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
|
c.SetClock(clk.fn())
|
||||||
|
// Locked: RecordWindow must not create stats.
|
||||||
|
c.RecordWindow(snap("code", "x"))
|
||||||
|
if c.State().Evidence != nil {
|
||||||
|
t.Fatalf("no session: evidence should be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnavailableAccruesToReservedBucket(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
|
c.SetClock(clk.fn())
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
_ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow
|
||||||
|
// latestWindow was zero-value (unavailable) at seed time.
|
||||||
|
clk.advance(5 * time.Second)
|
||||||
|
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
|
||||||
|
st := c.State()
|
||||||
|
if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 {
|
||||||
|
t.Errorf("unavailable bucket = %d, want 5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrashReplayRebuildsStats(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
first, _ := New(path)
|
||||||
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
|
first.SetClock(clk.fn())
|
||||||
|
first.RecordWindow(snap("code", "antidrift"))
|
||||||
|
_ = first.EnterPlanning()
|
||||||
|
_ = first.StartManualCommitment("work", "done", 25*time.Minute)
|
||||||
|
clk.advance(15 * time.Second)
|
||||||
|
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
|
||||||
|
|
||||||
|
// Simulate crash + restart: New replays the raw log.
|
||||||
|
second, err := New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopen: %v", err)
|
||||||
|
}
|
||||||
|
st := second.State()
|
||||||
|
if st.RuntimeState != domain.RuntimeActive {
|
||||||
|
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
|
||||||
|
}
|
||||||
|
if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 {
|
||||||
|
t.Errorf("replayed code bucket = %d, want 15", got)
|
||||||
|
}
|
||||||
|
if st.Evidence.SwitchCount != 1 {
|
||||||
|
t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEndWritesAuditSummary(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "state.json")
|
||||||
|
c, _ := New(path)
|
||||||
|
clk := &fakeClock{now: time.Unix(1000, 0)}
|
||||||
|
c.SetClock(clk.fn())
|
||||||
|
c.RecordWindow(snap("code", "antidrift"))
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute)
|
||||||
|
clk.advance(30 * time.Second)
|
||||||
|
c.RecordWindow(snap("firefox", "docs"))
|
||||||
|
clk.advance(10 * time.Second)
|
||||||
|
if err := c.Complete(); err != nil { // flush: +10s to firefox/docs
|
||||||
|
t.Fatalf("complete: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.End(); err != nil {
|
||||||
|
t.Fatalf("end: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditPath := filepath.Join(dir, "audit.jsonl")
|
||||||
|
if err := store.VerifyChain(auditPath); err != nil {
|
||||||
|
t.Fatalf("chain should verify: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeCoach struct {
|
||||||
|
prop ai.Proposal
|
||||||
|
err error
|
||||||
|
gate chan struct{} // if non-nil, Coach blocks until it receives
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
||||||
|
if f.gate != nil {
|
||||||
|
<-f.gate
|
||||||
|
}
|
||||||
|
return f.prop, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
|
||||||
|
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st := c.State()
|
||||||
|
if st.Coach != nil && st.Coach.Status == want {
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach)
|
||||||
|
return State{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCoachReady(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}})
|
||||||
|
if err := c.RequestCoach("do x"); err != nil {
|
||||||
|
t.Fatalf("request coach: %v", err)
|
||||||
|
}
|
||||||
|
st := waitCoachStatus(t, c, "ready")
|
||||||
|
if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 {
|
||||||
|
t.Fatalf("ready proposal wrong: %+v", st.Coach)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCoachError(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
c.SetCoach(&fakeCoach{err: errors.New("backend down")})
|
||||||
|
if err := c.RequestCoach("x"); err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
st := waitCoachStatus(t, c, "error")
|
||||||
|
if st.Coach.Error == "" {
|
||||||
|
t.Fatal("error status should carry a message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCoachUnavailable(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
if err := c.RequestCoach("x"); err != nil {
|
||||||
|
t.Fatalf("nil coach must degrade, not error: %v", err)
|
||||||
|
}
|
||||||
|
st := c.State()
|
||||||
|
if st.Coach == nil || st.Coach.Status != "error" {
|
||||||
|
t.Fatalf("want error status for nil coach, got %+v", st.Coach)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCoachWrongState(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
||||||
|
if err := c.RequestCoach("x"); err == nil {
|
||||||
|
t.Fatal("coaching from Locked should error")
|
||||||
|
}
|
||||||
|
if c.State().Coach != nil {
|
||||||
|
t.Fatal("no coach view outside planning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestCoachStaleResultDiscarded(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
|
||||||
|
slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})}
|
||||||
|
c.SetCoach(slow)
|
||||||
|
_ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate
|
||||||
|
|
||||||
|
fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}}
|
||||||
|
c.SetCoach(fast)
|
||||||
|
_ = c.RequestCoach("second") // gen2 -> ready NEW
|
||||||
|
st := waitCoachStatus(t, c, "ready")
|
||||||
|
if st.Coach.Proposal.NextAction != "NEW" {
|
||||||
|
t.Fatalf("expected NEW, got %+v", st.Coach.Proposal)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(slow.gate) // release gen1; it must be discarded
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := c.State().Coach.Proposal.NextAction; got != "NEW" {
|
||||||
|
t.Fatalf("stale gen1 overwrote state: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLeavingPlanningClearsCoach(t *testing.T) {
|
||||||
|
c, _ := newTestController(t)
|
||||||
|
_ = c.EnterPlanning()
|
||||||
|
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
|
||||||
|
_ = c.RequestCoach("x")
|
||||||
|
waitCoachStatus(t, c, "ready")
|
||||||
|
if err := c.StartManualCommitment("a", "b", 25*time.Minute); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
if c.State().Coach != nil {
|
||||||
|
t.Fatal("coach view must be gone once Active")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package statemachine holds the pure runtime and commitment transition
|
||||||
|
// functions ported from the Rust implementation. No I/O, no shared state.
|
||||||
|
package statemachine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
|
||||||
|
// and admin-override exit targets from the Rust enum are flattened into
|
||||||
|
// distinct actions so the action type stays a simple value.
|
||||||
|
type RuntimeAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EnterPlanning RuntimeAction = "enter_planning"
|
||||||
|
CancelOrTimeout RuntimeAction = "cancel_or_timeout"
|
||||||
|
ActivateAccepted RuntimeAction = "activate_accepted"
|
||||||
|
ActivateRejected RuntimeAction = "activate_rejected"
|
||||||
|
StartTransition RuntimeAction = "start_transition"
|
||||||
|
CompleteForReview RuntimeAction = "complete_for_review"
|
||||||
|
SevereViolation RuntimeAction = "severe_violation"
|
||||||
|
ReturnToActive RuntimeAction = "return_to_active"
|
||||||
|
SwitchTask RuntimeAction = "switch_task"
|
||||||
|
EndTransitionForReview RuntimeAction = "end_transition_for_review"
|
||||||
|
ContinuePlanning RuntimeAction = "continue_planning"
|
||||||
|
EndWorkPeriod RuntimeAction = "end_work_period"
|
||||||
|
EnterAdminOverride RuntimeAction = "enter_admin_override"
|
||||||
|
ExitAdminOverrideToLocked RuntimeAction = "exit_admin_override_to_locked"
|
||||||
|
ExitAdminOverrideToPlanning RuntimeAction = "exit_admin_override_to_planning"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommitmentAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CommitmentActivate CommitmentAction = "activate"
|
||||||
|
PauseForTransition CommitmentAction = "pause_for_transition"
|
||||||
|
ReturnFromPause CommitmentAction = "return_from_pause"
|
||||||
|
Complete CommitmentAction = "complete"
|
||||||
|
Abandon CommitmentAction = "abandon"
|
||||||
|
Violate CommitmentAction = "violate"
|
||||||
|
RecoverExplicitly CommitmentAction = "recover_explicitly"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IllegalTransitionError reports a rejected transition.
|
||||||
|
type IllegalTransitionError struct {
|
||||||
|
From string
|
||||||
|
Action string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e IllegalTransitionError) Error() string {
|
||||||
|
return fmt.Sprintf("illegal transition from %s via %s", e.From, e.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyRejectedError reports an activation attempted with an unaccepted policy.
|
||||||
|
type PolicyRejectedError struct{}
|
||||||
|
|
||||||
|
func (PolicyRejectedError) Error() string { return "policy was rejected" }
|
||||||
|
|
||||||
|
func TransitionRuntime(current domain.RuntimeState, action RuntimeAction) (domain.RuntimeState, error) {
|
||||||
|
type key struct {
|
||||||
|
s domain.RuntimeState
|
||||||
|
a RuntimeAction
|
||||||
|
}
|
||||||
|
table := map[key]domain.RuntimeState{
|
||||||
|
{domain.RuntimeLocked, EnterPlanning}: domain.RuntimePlanning,
|
||||||
|
{domain.RuntimePlanning, ActivateAccepted}: domain.RuntimeActive,
|
||||||
|
{domain.RuntimePlanning, CancelOrTimeout}: domain.RuntimeLocked,
|
||||||
|
{domain.RuntimeActive, StartTransition}: domain.RuntimeTransition,
|
||||||
|
{domain.RuntimeActive, CompleteForReview}: domain.RuntimeReview,
|
||||||
|
{domain.RuntimeActive, SevereViolation}: domain.RuntimeLocked,
|
||||||
|
{domain.RuntimeTransition, ReturnToActive}: domain.RuntimeActive,
|
||||||
|
{domain.RuntimeTransition, SwitchTask}: domain.RuntimePlanning,
|
||||||
|
{domain.RuntimeTransition, EndTransitionForReview}: domain.RuntimeReview,
|
||||||
|
{domain.RuntimeTransition, CancelOrTimeout}: domain.RuntimeLocked,
|
||||||
|
{domain.RuntimeReview, ContinuePlanning}: domain.RuntimePlanning,
|
||||||
|
{domain.RuntimeReview, EndWorkPeriod}: domain.RuntimeLocked,
|
||||||
|
{domain.RuntimeAdminOverride, ExitAdminOverrideToLocked}: domain.RuntimeLocked,
|
||||||
|
{domain.RuntimeAdminOverride, ExitAdminOverrideToPlanning}: domain.RuntimePlanning,
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == ActivateRejected && current == domain.RuntimePlanning {
|
||||||
|
return current, PolicyRejectedError{}
|
||||||
|
}
|
||||||
|
if action == EnterAdminOverride {
|
||||||
|
return domain.RuntimeAdminOverride, nil
|
||||||
|
}
|
||||||
|
if next, ok := table[key{current, action}]; ok {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
return current, IllegalTransitionError{From: string(current), Action: string(action)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TransitionCommitment(current domain.CommitmentState, action CommitmentAction) (domain.CommitmentState, error) {
|
||||||
|
type key struct {
|
||||||
|
s domain.CommitmentState
|
||||||
|
a CommitmentAction
|
||||||
|
}
|
||||||
|
table := map[key]domain.CommitmentState{
|
||||||
|
{domain.CommitmentDraft, CommitmentActivate}: domain.CommitmentActive,
|
||||||
|
{domain.CommitmentActive, PauseForTransition}: domain.CommitmentPaused,
|
||||||
|
{domain.CommitmentPaused, ReturnFromPause}: domain.CommitmentActive,
|
||||||
|
{domain.CommitmentActive, Complete}: domain.CommitmentCompleted,
|
||||||
|
{domain.CommitmentActive, Abandon}: domain.CommitmentAbandoned,
|
||||||
|
{domain.CommitmentActive, Violate}: domain.CommitmentViolated,
|
||||||
|
{domain.CommitmentPaused, Abandon}: domain.CommitmentAbandoned,
|
||||||
|
{domain.CommitmentViolated, RecoverExplicitly}: domain.CommitmentActive,
|
||||||
|
{domain.CommitmentViolated, Abandon}: domain.CommitmentAbandoned,
|
||||||
|
}
|
||||||
|
if next, ok := table[key{current, action}]; ok {
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
return current, IllegalTransitionError{From: string(current), Action: string(action)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package statemachine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
|
||||||
|
got, err := TransitionRuntime(domain.RuntimePlanning, ActivateAccepted)
|
||||||
|
if err != nil || got != domain.RuntimeActive {
|
||||||
|
t.Fatalf("accepted: got %s err %v", got, err)
|
||||||
|
}
|
||||||
|
_, err = TransitionRuntime(domain.RuntimePlanning, ActivateRejected)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("rejected policy should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveCanCompleteForReview(t *testing.T) {
|
||||||
|
got, err := TransitionRuntime(domain.RuntimeActive, CompleteForReview)
|
||||||
|
if err != nil || got != domain.RuntimeReview {
|
||||||
|
t.Fatalf("got %s err %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewEndsToLocked(t *testing.T) {
|
||||||
|
got, err := TransitionRuntime(domain.RuntimeReview, EndWorkPeriod)
|
||||||
|
if err != nil || got != domain.RuntimeLocked {
|
||||||
|
t.Fatalf("got %s err %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockedCannotBecomeActiveDirectly(t *testing.T) {
|
||||||
|
_, err := TransitionRuntime(domain.RuntimeLocked, ActivateAccepted)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("locked->active must be illegal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitmentDraftActivatesAndViolatedRecovers(t *testing.T) {
|
||||||
|
got, err := TransitionCommitment(domain.CommitmentDraft, CommitmentActivate)
|
||||||
|
if err != nil || got != domain.CommitmentActive {
|
||||||
|
t.Fatalf("draft->active: got %s err %v", got, err)
|
||||||
|
}
|
||||||
|
got, err = TransitionCommitment(domain.CommitmentViolated, RecoverExplicitly)
|
||||||
|
if err != nil || got != domain.CommitmentActive {
|
||||||
|
t.Fatalf("violated->active: got %s err %v", got, err)
|
||||||
|
}
|
||||||
|
_, err = TransitionCommitment(domain.CommitmentViolated, ReturnFromPause)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("violated->returnFromPause must be illegal")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const genesisHash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
|
||||||
|
// BucketTotal is per-(class, scrubbed-title) accumulated time in a session.
|
||||||
|
type BucketTotal struct {
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"` // scrubbed
|
||||||
|
Seconds int64 `json:"seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionSummary is one permanent, hash-chained record of a finished session.
|
||||||
|
type SessionSummary struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
PrevHash string `json:"prev_hash"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCond string `json:"success_condition"`
|
||||||
|
Outcome string `json:"outcome"` // "completed" | "expired"
|
||||||
|
StartedUnix int64 `json:"started_unix"`
|
||||||
|
EndedUnix int64 `json:"ended_unix"`
|
||||||
|
SwitchCount int `json:"switch_count"`
|
||||||
|
Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeHash returns SHA-256(prevHash || canonicalJSON(s with Hash zeroed)).
|
||||||
|
// encoding/json emits struct fields in declaration order, so this is stable.
|
||||||
|
func computeHash(s SessionSummary) (string, error) {
|
||||||
|
s.Hash = ""
|
||||||
|
canonical, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(s.PrevHash))
|
||||||
|
h.Write(canonical)
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSummaries(path string) ([]SessionSummary, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []SessionSummary
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var s SessionSummary
|
||||||
|
if err := json.Unmarshal([]byte(line), &s); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
|
||||||
|
// the next contiguous Seq, computes its Hash, and appends one JSON line with an
|
||||||
|
// fsync.
|
||||||
|
func AppendSession(path string, s SessionSummary) error {
|
||||||
|
existing, err := readSummaries(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(existing) == 0 {
|
||||||
|
s.Seq = 1
|
||||||
|
s.PrevHash = genesisHash
|
||||||
|
} else {
|
||||||
|
last := existing[len(existing)-1]
|
||||||
|
s.Seq = last.Seq + 1
|
||||||
|
s.PrevHash = last.Hash
|
||||||
|
}
|
||||||
|
hash, err := computeHash(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Hash = hash
|
||||||
|
|
||||||
|
line, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyChain re-walks every line, recomputing hashes and confirming each
|
||||||
|
// prev_hash links to the prior hash and Seq is contiguous from 1. It returns an
|
||||||
|
// error naming the first broken line (1-based); nil if intact or empty.
|
||||||
|
func VerifyChain(path string) error {
|
||||||
|
summaries, err := readSummaries(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prev := genesisHash
|
||||||
|
for i, s := range summaries {
|
||||||
|
lineNo := i + 1
|
||||||
|
if s.Seq != lineNo {
|
||||||
|
return fmt.Errorf("audit chain broken at line %d: seq %d, want %d", lineNo, s.Seq, lineNo)
|
||||||
|
}
|
||||||
|
if s.PrevHash != prev {
|
||||||
|
return fmt.Errorf("audit chain broken at line %d: prev_hash mismatch", lineNo)
|
||||||
|
}
|
||||||
|
want, err := computeHash(s)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if want != s.Hash {
|
||||||
|
return fmt.Errorf("audit chain broken at line %d: hash mismatch", lineNo)
|
||||||
|
}
|
||||||
|
prev = s.Hash
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func auditFixture(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return filepath.Join(t.TempDir(), "audit.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleSummary(id string) SessionSummary {
|
||||||
|
return SessionSummary{
|
||||||
|
SessionID: id,
|
||||||
|
NextAction: "write plan",
|
||||||
|
SuccessCond: "plan committed",
|
||||||
|
Outcome: "completed",
|
||||||
|
StartedUnix: 1000,
|
||||||
|
EndedUnix: 2000,
|
||||||
|
SwitchCount: 3,
|
||||||
|
Buckets: []BucketTotal{
|
||||||
|
{Class: "code", Title: "antidrift", Seconds: 540},
|
||||||
|
{Class: "firefox", Title: "docs", Seconds: 120},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendSessionChainsAndVerifies(t *testing.T) {
|
||||||
|
path := auditFixture(t)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := AppendSession(path, sampleSummary("session-"+string(rune('a'+i)))); err != nil {
|
||||||
|
t.Fatalf("append %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := VerifyChain(path); err != nil {
|
||||||
|
t.Fatalf("chain should verify: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenesisPrevHashIsZeros(t *testing.T) {
|
||||||
|
path := auditFixture(t)
|
||||||
|
if err := AppendSession(path, sampleSummary("session-a")); err != nil {
|
||||||
|
t.Fatalf("append: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
if !strings.Contains(string(data), strings.Repeat("0", 64)) {
|
||||||
|
t.Fatalf("genesis prev_hash should be 64 zeros, got: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyEmptyChain(t *testing.T) {
|
||||||
|
if err := VerifyChain(auditFixture(t)); err != nil {
|
||||||
|
t.Fatalf("empty/missing chain should verify, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTamperDetected(t *testing.T) {
|
||||||
|
path := auditFixture(t)
|
||||||
|
_ = AppendSession(path, sampleSummary("session-a"))
|
||||||
|
_ = AppendSession(path, sampleSummary("session-b"))
|
||||||
|
_ = AppendSession(path, sampleSummary("session-c"))
|
||||||
|
|
||||||
|
// Corrupt the middle line's switch_count.
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||||
|
lines[1] = strings.Replace(lines[1], `"switch_count":3`, `"switch_count":99`, 1)
|
||||||
|
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("rewrite: %v", err)
|
||||||
|
}
|
||||||
|
err := VerifyChain(path)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "line 2") {
|
||||||
|
t.Fatalf("tamper on line 2 should be reported, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FocusEvent is one raw, disposable observation in a session's firehose log.
|
||||||
|
type FocusEvent struct {
|
||||||
|
AtUnixMillis int64 `json:"at_unix_millis"`
|
||||||
|
Class string `json:"class"`
|
||||||
|
Title string `json:"title"` // full, unscrubbed
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionFile(dir, sessionID string) string {
|
||||||
|
return filepath.Join(dir, sessionID+".jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendFocus appends one event to dir/<sessionID>.jsonl, creating dir if
|
||||||
|
// needed. It is best-effort detail, not the state of truth.
|
||||||
|
func AppendFocus(dir, sessionID string, e FocusEvent) error {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
line, err := json.Marshal(e)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(sessionFile(dir, sessionID), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.Write(append(line, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaySession reads all events for a session in append order. A missing file
|
||||||
|
// yields an empty slice and no error.
|
||||||
|
func ReplaySession(dir, sessionID string) ([]FocusEvent, error) {
|
||||||
|
f, err := os.Open(sessionFile(dir, sessionID))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var out []FocusEvent
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var e FocusEvent
|
||||||
|
if err := json.Unmarshal([]byte(line), &e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneOlderThan deletes session files whose modification time precedes
|
||||||
|
// now-age. A missing dir is a no-op. now is injected for testability.
|
||||||
|
func PruneOlderThan(dir string, age time.Duration, now time.Time) error {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cutoff := now.Add(-age)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info.ModTime().Before(cutoff) {
|
||||||
|
_ = os.Remove(filepath.Join(dir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppendFocusReplayRoundTrips(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "sessions")
|
||||||
|
id := "session-x"
|
||||||
|
events := []FocusEvent{
|
||||||
|
{AtUnixMillis: 1000, Class: "code", Title: "a", Available: true},
|
||||||
|
{AtUnixMillis: 2000, Class: "firefox", Title: "b", Available: true},
|
||||||
|
{AtUnixMillis: 3000, Class: "", Title: "", Available: false, Reason: "no active window"},
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
if err := AppendFocus(dir, id, e); err != nil {
|
||||||
|
t.Fatalf("append: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := ReplaySession(dir, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("replay: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != len(events) {
|
||||||
|
t.Fatalf("got %d events, want %d", len(got), len(events))
|
||||||
|
}
|
||||||
|
for i := range events {
|
||||||
|
if got[i] != events[i] {
|
||||||
|
t.Errorf("event %d: got %+v want %+v", i, got[i], events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplayMissingSessionIsEmpty(t *testing.T) {
|
||||||
|
got, err := ReplaySession(filepath.Join(t.TempDir(), "sessions"), "nope")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("missing session should not error: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("missing session should be empty, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneOlderThan(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "sessions")
|
||||||
|
_ = AppendFocus(dir, "old", FocusEvent{AtUnixMillis: 1})
|
||||||
|
_ = AppendFocus(dir, "new", FocusEvent{AtUnixMillis: 1})
|
||||||
|
|
||||||
|
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||||
|
oldTime := now.Add(-40 * 24 * time.Hour)
|
||||||
|
if err := os.Chtimes(filepath.Join(dir, "old.jsonl"), oldTime, oldTime); err != nil {
|
||||||
|
t.Fatalf("chtimes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := PruneOlderThan(dir, 30*24*time.Hour, now); err != nil {
|
||||||
|
t.Fatalf("prune: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "old.jsonl")); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("old session should be pruned")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "new.jsonl")); err != nil {
|
||||||
|
t.Errorf("new session should survive: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneMissingDirIsNoop(t *testing.T) {
|
||||||
|
if err := PruneOlderThan(filepath.Join(t.TempDir(), "nope"), time.Hour, time.Now()); err != nil {
|
||||||
|
t.Fatalf("missing dir should be a no-op, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Package store persists and restores the daemon's current state as a single
|
||||||
|
// JSON snapshot. It is the M0 source of durability; the hash-chained audit log
|
||||||
|
// arrives in a later milestone.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshot is the persisted current state of the daemon.
|
||||||
|
type Snapshot struct {
|
||||||
|
RuntimeState domain.RuntimeState `json:"runtime_state"`
|
||||||
|
Commitment *domain.Commitment `json:"commitment,omitempty"`
|
||||||
|
DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
OutcomePending string `json:"outcome_pending,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPath returns ~/.antidrift/state.json.
|
||||||
|
func DefaultPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".antidrift", "state.json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads a snapshot. A missing file yields a default Locked snapshot.
|
||||||
|
func Load(path string) (Snapshot, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Snapshot{RuntimeState: domain.RuntimeLocked}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
var s Snapshot
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return Snapshot{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes a snapshot atomically (temp file + rename), creating the
|
||||||
|
// directory if needed.
|
||||||
|
func Save(path string, s Snapshot) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(s, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadMissingFileReturnsLocked(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
s, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if s.RuntimeState != domain.RuntimeLocked {
|
||||||
|
t.Errorf("missing file should be Locked, got %s", s.RuntimeState)
|
||||||
|
}
|
||||||
|
if s.Commitment != nil {
|
||||||
|
t.Errorf("missing file should have nil commitment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveThenLoadRoundTrips(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "nested", "state.json")
|
||||||
|
c, _ := domain.NewManual("Port store", "store tests pass", 25*time.Minute)
|
||||||
|
c.State = domain.CommitmentActive
|
||||||
|
want := Snapshot{
|
||||||
|
RuntimeState: domain.RuntimeActive,
|
||||||
|
Commitment: &c,
|
||||||
|
DeadlineUnixSecs: 1748725200,
|
||||||
|
}
|
||||||
|
if err := Save(path, want); err != nil {
|
||||||
|
t.Fatalf("save failed: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load failed: %v", err)
|
||||||
|
}
|
||||||
|
if got.RuntimeState != want.RuntimeState {
|
||||||
|
t.Errorf("runtime state: got %s want %s", got.RuntimeState, want.RuntimeState)
|
||||||
|
}
|
||||||
|
if got.Commitment == nil || got.Commitment.NextAction != "Port store" {
|
||||||
|
t.Errorf("commitment did not round-trip: %+v", got.Commitment)
|
||||||
|
}
|
||||||
|
if got.DeadlineUnixSecs != want.DeadlineUnixSecs {
|
||||||
|
t.Errorf("deadline: got %d want %d", got.DeadlineUnixSecs, want.DeadlineUnixSecs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCarriesSessionFields(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
want := Snapshot{
|
||||||
|
RuntimeState: domain.RuntimeActive,
|
||||||
|
SessionID: "session-abc",
|
||||||
|
OutcomePending: "completed",
|
||||||
|
}
|
||||||
|
if err := Save(path, want); err != nil {
|
||||||
|
t.Fatalf("save: %v", err)
|
||||||
|
}
|
||||||
|
got, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got.SessionID != "session-abc" || got.OutcomePending != "completed" {
|
||||||
|
t.Fatalf("session fields did not round-trip: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Broadcaster fans a string message out to all SSE subscribers. Slow
|
||||||
|
// subscribers drop messages rather than blocking publishers.
|
||||||
|
type Broadcaster struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
subs map[chan string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBroadcaster() *Broadcaster {
|
||||||
|
return &Broadcaster{subs: make(map[chan string]struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcaster) Subscribe() chan string {
|
||||||
|
ch := make(chan string, 8)
|
||||||
|
b.mu.Lock()
|
||||||
|
b.subs[ch] = struct{}{}
|
||||||
|
b.mu.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcaster) Unsubscribe(ch chan string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if _, ok := b.subs[ch]; ok {
|
||||||
|
delete(b.subs, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcaster) Publish(msg string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for ch := range b.subs {
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
default: // drop for slow subscriber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBroadcasterDeliversToSubscribers(t *testing.T) {
|
||||||
|
b := NewBroadcaster()
|
||||||
|
ch := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(ch)
|
||||||
|
|
||||||
|
b.Publish("hello")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-ch:
|
||||||
|
if msg != "hello" {
|
||||||
|
t.Fatalf("got %q want hello", msg)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("subscriber did not receive message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBroadcasterDropsWhenSubscriberFull(t *testing.T) {
|
||||||
|
b := NewBroadcaster()
|
||||||
|
ch := b.Subscribe()
|
||||||
|
defer b.Unsubscribe(ch)
|
||||||
|
// Publishing many messages without reading must not block (slow client).
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
b.Publish("x")
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Publish blocked on a full subscriber")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>AntiDrift</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: #11131a; color: #e6e8ee; }
|
||||||
|
main { max-width: 640px; margin: 8vh auto; padding: 0 24px; }
|
||||||
|
h1 { font-size: 14px; letter-spacing: .2em; text-transform: uppercase; color: #7a8095; }
|
||||||
|
.card { background: #1a1d27; border: 1px solid #272b38; border-radius: 14px; padding: 28px; }
|
||||||
|
label { display: block; font-size: 13px; color: #9aa0b4; margin: 16px 0 6px; }
|
||||||
|
input { width: 100%; box-sizing: border-box; padding: 10px 12px; background: #11131a;
|
||||||
|
border: 1px solid #2d3242; border-radius: 8px; color: #e6e8ee; font: inherit; }
|
||||||
|
button { margin-top: 22px; padding: 11px 18px; border: 0; border-radius: 8px;
|
||||||
|
background: #4c6ef5; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
|
||||||
|
button:disabled { background: #2d3242; color: #6a7186; cursor: not-allowed; }
|
||||||
|
.timer { font-size: 56px; font-weight: 700; font-variant-numeric: tabular-nums; margin: 8px 0 4px; }
|
||||||
|
.action { font-size: 22px; font-weight: 600; }
|
||||||
|
.meta { color: #9aa0b4; }
|
||||||
|
.pill { display: inline-block; font-size: 12px; letter-spacing: .15em; text-transform: uppercase;
|
||||||
|
color: #7a8095; border: 1px solid #272b38; border-radius: 999px; padding: 3px 10px; }
|
||||||
|
.ev { margin-top: 18px; border-top: 1px solid #272b38; padding-top: 14px; }
|
||||||
|
.ev .now { font-size: 14px; color: #cdd2e0; }
|
||||||
|
.ev .health-ok { color: #5fd08a; }
|
||||||
|
.ev .health-bad { color: #e0b15f; }
|
||||||
|
.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: #9aa0b4; }
|
||||||
|
.buckets li { display: flex; justify-content: space-between; padding: 2px 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.switches { font-size: 13px; color: #7a8095; margin-top: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>AntiDrift</h1>
|
||||||
|
<div class="card" id="view">connecting…</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const view = document.getElementById('view');
|
||||||
|
let countdownTimer = null;
|
||||||
|
let renderedState = null; // which runtime_state the DOM currently shows
|
||||||
|
|
||||||
|
function post(path, body) {
|
||||||
|
return fetch(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(secs) {
|
||||||
|
secs = Math.max(0, Math.floor(secs));
|
||||||
|
const m = String(Math.floor(secs / 60)).padStart(2, '0');
|
||||||
|
const s = String(secs % 60).padStart(2, '0');
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceBlock(ev) {
|
||||||
|
if (!ev) return '';
|
||||||
|
const health = ev.available
|
||||||
|
? `<span class="health-ok">tracking</span>`
|
||||||
|
: `<span class="health-bad">evidence unavailable: ${ev.reason || 'unknown'}</span>`;
|
||||||
|
const now = ev.current && (ev.current.class || ev.current.title)
|
||||||
|
? `${ev.current.class || '?'} · ${ev.current.title || ''}` : '—';
|
||||||
|
const rows = (ev.buckets || []).map(b =>
|
||||||
|
`<li><span>${(b.class || '?')} · ${b.title || ''}</span><span>${fmt(b.seconds)}</span></li>`).join('');
|
||||||
|
return `<div class="ev">
|
||||||
|
<div class="now">Now: ${now} ${health}</div>
|
||||||
|
<ul class="buckets">${rows}</ul>
|
||||||
|
<div class="switches">Context switches: ${ev.switch_count || 0}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlanningCoach(coach) {
|
||||||
|
const statusEl = document.getElementById('coachStatus');
|
||||||
|
if (!statusEl) return;
|
||||||
|
if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; }
|
||||||
|
if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; }
|
||||||
|
if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; }
|
||||||
|
if (coach.status === 'ready' && coach.proposal) {
|
||||||
|
statusEl.textContent = 'AI suggestion applied — edit freely.';
|
||||||
|
// Pre-fill once per ready proposal so later manual edits are not clobbered
|
||||||
|
// by subsequent SSE ticks (idempotent via the dataset stamp below).
|
||||||
|
const stamp = JSON.stringify(coach.proposal);
|
||||||
|
if (statusEl.dataset.applied === stamp) return;
|
||||||
|
statusEl.dataset.applied = stamp;
|
||||||
|
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||||
|
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||||
|
if (na && !na.value.trim()) na.value = coach.proposal.next_action || '';
|
||||||
|
if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || '';
|
||||||
|
if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60);
|
||||||
|
if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(state) {
|
||||||
|
const rs = state.runtime_state;
|
||||||
|
if (rs === 'planning' && renderedState === 'planning') {
|
||||||
|
updatePlanningCoach(state.coach); // partial update only
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||||
|
renderedState = rs;
|
||||||
|
if (rs === 'locked') {
|
||||||
|
view.innerHTML = `<span class="pill">Locked</span>
|
||||||
|
<p class="meta">No active commitment.</p>
|
||||||
|
<button id="plan">Start planning</button>`;
|
||||||
|
document.getElementById('plan').onclick = () => post('/planning');
|
||||||
|
} else if (rs === 'planning') {
|
||||||
|
view.innerHTML = `<span class="pill">Planning</span>
|
||||||
|
<label>Rough intent</label>
|
||||||
|
<input id="intent" placeholder="e.g. work on the quarterly report">
|
||||||
|
<button id="sharpen" type="button">Sharpen</button>
|
||||||
|
<div id="coachStatus" class="meta"></div>
|
||||||
|
<label>Next action</label><input id="na" placeholder="What exactly will you do?">
|
||||||
|
<label>Success condition</label><input id="sc" placeholder="How do you know it's done?">
|
||||||
|
<label>Minutes</label><input id="mins" type="number" min="1" value="25">
|
||||||
|
<button id="start" disabled>Start commitment</button>`;
|
||||||
|
const na = document.getElementById('na'), sc = document.getElementById('sc'),
|
||||||
|
mins = document.getElementById('mins'), start = document.getElementById('start');
|
||||||
|
const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); };
|
||||||
|
[na, sc, mins].forEach(el => el.oninput = check);
|
||||||
|
start.onclick = () => post('/commitment', {
|
||||||
|
next_action: na.value.trim(),
|
||||||
|
success_condition: sc.value.trim(),
|
||||||
|
timebox_secs: Math.round(+mins.value * 60),
|
||||||
|
});
|
||||||
|
document.getElementById('sharpen').onclick = () => {
|
||||||
|
const intent = document.getElementById('intent').value.trim();
|
||||||
|
if (intent) post('/coach', { intent });
|
||||||
|
};
|
||||||
|
updatePlanningCoach(state.coach);
|
||||||
|
} else if (rs === 'active') {
|
||||||
|
const c = state.commitment || {};
|
||||||
|
view.innerHTML = `<span class="pill">Active</span>
|
||||||
|
<div class="timer" id="t">--:--</div>
|
||||||
|
<div class="action">${c.next_action || ''}</div>
|
||||||
|
<p class="meta">Done when: ${c.success_condition || ''}</p>
|
||||||
|
${evidenceBlock(state.evidence)}
|
||||||
|
<button id="done">Complete</button>`;
|
||||||
|
document.getElementById('done').onclick = () => post('/complete');
|
||||||
|
const t = document.getElementById('t');
|
||||||
|
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||||
|
tick();
|
||||||
|
countdownTimer = setInterval(tick, 250);
|
||||||
|
} else if (rs === 'review') {
|
||||||
|
const c = state.commitment || {};
|
||||||
|
view.innerHTML = `<span class="pill">Review</span>
|
||||||
|
<p class="action">Session ended</p>
|
||||||
|
<p class="meta">${c.next_action || ''}</p>
|
||||||
|
${evidenceBlock(state.evidence)}
|
||||||
|
<button id="end">End</button>`;
|
||||||
|
document.getElementById('end').onclick = () => post('/end');
|
||||||
|
} else {
|
||||||
|
view.textContent = rs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource('/events');
|
||||||
|
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||||
|
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Package web serves the local UI and an SSE state stream, and owns the
|
||||||
|
// server-authoritative timebox expiry timer.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/session"
|
||||||
|
"antidrift/internal/statemachine"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
// Server wires the session controller to HTTP and SSE.
|
||||||
|
type Server struct {
|
||||||
|
ctrl *session.Controller
|
||||||
|
bcast *Broadcaster
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
timer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(ctrl *session.Controller) *Server {
|
||||||
|
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()}
|
||||||
|
ctrl.SetOnChange(s.broadcast)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Router() *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
|
sub, _ := fs.Sub(staticFS, "static")
|
||||||
|
r.GET("/", func(c *gin.Context) {
|
||||||
|
c.FileFromFS("/", http.FS(sub))
|
||||||
|
})
|
||||||
|
r.GET("/events", s.handleEvents)
|
||||||
|
r.POST("/planning", s.handlePlanning)
|
||||||
|
r.POST("/coach", s.handleCoach)
|
||||||
|
r.POST("/commitment", s.handleCommitment)
|
||||||
|
r.POST("/complete", s.handleComplete)
|
||||||
|
r.POST("/end", s.handleEnd)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) stateJSON() string {
|
||||||
|
data, _ := json.Marshal(s.ctrl.State())
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcast() {
|
||||||
|
s.bcast.Publish(s.stateJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
// respond maps a controller error to an HTTP status, otherwise broadcasts and
|
||||||
|
// returns the new state.
|
||||||
|
func (s *Server) respond(c *gin.Context, err error) {
|
||||||
|
if err != nil {
|
||||||
|
var illegal statemachine.IllegalTransitionError
|
||||||
|
if errors.As(err, &illegal) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.broadcast()
|
||||||
|
c.Data(http.StatusOK, "application/json", []byte(s.stateJSON()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handlePlanning(c *gin.Context) {
|
||||||
|
s.cancelExpiry()
|
||||||
|
s.respond(c, s.ctrl.EnterPlanning())
|
||||||
|
}
|
||||||
|
|
||||||
|
type coachRequest struct {
|
||||||
|
Intent string `json:"intent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCoach(c *gin.Context) {
|
||||||
|
var req coachRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.respond(c, s.ctrl.RequestCoach(req.Intent))
|
||||||
|
}
|
||||||
|
|
||||||
|
type commitmentRequest struct {
|
||||||
|
NextAction string `json:"next_action"`
|
||||||
|
SuccessCondition string `json:"success_condition"`
|
||||||
|
TimeboxSecs int64 `json:"timebox_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCommitment(c *gin.Context) {
|
||||||
|
var req commitmentRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second)
|
||||||
|
if err == nil {
|
||||||
|
s.armExpiry()
|
||||||
|
}
|
||||||
|
s.respond(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleComplete(c *gin.Context) {
|
||||||
|
s.cancelExpiry()
|
||||||
|
s.respond(c, s.ctrl.Complete())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEnd(c *gin.Context) {
|
||||||
|
s.respond(c, s.ctrl.End())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEvents(c *gin.Context) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch := s.bcast.Subscribe()
|
||||||
|
defer s.bcast.Unsubscribe(ch)
|
||||||
|
|
||||||
|
fmt.Fprintf(c.Writer, "data: %s\n\n", s.stateJSON())
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
case msg, open := <-ch:
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.Writer, "data: %s\n\n", msg)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// armExpiry schedules an Active -> Review transition at the commitment deadline.
|
||||||
|
func (s *Server) armExpiry() {
|
||||||
|
deadline := s.ctrl.Deadline()
|
||||||
|
if deadline.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.timer != nil {
|
||||||
|
s.timer.Stop()
|
||||||
|
}
|
||||||
|
d := time.Until(deadline)
|
||||||
|
if d < 0 {
|
||||||
|
d = 0
|
||||||
|
}
|
||||||
|
s.timer = time.AfterFunc(d, func() {
|
||||||
|
if err := s.ctrl.Expire(); err == nil {
|
||||||
|
s.broadcast()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) cancelExpiry() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.timer != nil {
|
||||||
|
s.timer.Stop()
|
||||||
|
s.timer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init re-arms or expires a restored Active session at startup.
|
||||||
|
func (s *Server) Init() {
|
||||||
|
st := s.ctrl.State()
|
||||||
|
if st.RuntimeState != "active" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.ctrl.Deadline().After(time.Now()) {
|
||||||
|
s.armExpiry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.ctrl.Expire(); err == nil {
|
||||||
|
s.broadcast()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/ai"
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/evidence"
|
||||||
|
"antidrift/internal/session"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
path := filepath.Join(t.TempDir(), "state.json")
|
||||||
|
ctrl, err := session.New(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("controller: %v", err)
|
||||||
|
}
|
||||||
|
return NewServer(ctrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := s.Router()
|
||||||
|
|
||||||
|
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/planning code %d", w.Code)
|
||||||
|
}
|
||||||
|
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
|
||||||
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if s.ctrl.State().RuntimeState != domain.RuntimeActive {
|
||||||
|
t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitmentRejectsInvalidInput(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := s.Router()
|
||||||
|
_ = post(t, r, "/planning", "")
|
||||||
|
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
|
||||||
|
w := post(t, r, "/commitment", body)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIllegalTransitionReturns409(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := s.Router()
|
||||||
|
// /complete from Locked is illegal.
|
||||||
|
w := post(t, r, "/complete", "")
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("expected 409, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := s.Router()
|
||||||
|
_ = post(t, r, "/planning", "")
|
||||||
|
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}`
|
||||||
|
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// A focus update should appear in the serialized state.
|
||||||
|
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}})
|
||||||
|
|
||||||
|
js := s.stateJSON()
|
||||||
|
if !strings.Contains(js, `"evidence"`) {
|
||||||
|
t.Fatalf("payload missing evidence object: %s", js)
|
||||||
|
}
|
||||||
|
if !strings.Contains(js, `"class":"code"`) {
|
||||||
|
t.Fatalf("payload missing current window: %s", js)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLockedStateHasNullEvidence(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
js := s.stateJSON()
|
||||||
|
if !strings.Contains(js, `"evidence":null`) {
|
||||||
|
t.Fatalf("locked payload should have null evidence: %s", js)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubCoach struct {
|
||||||
|
prop ai.Proposal
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s stubCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
|
||||||
|
return s.prop, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoachRouteHappyPath(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
|
||||||
|
r := s.Router()
|
||||||
|
_ = post(t, r, "/planning", "")
|
||||||
|
if w := post(t, r, "/coach", `{"intent":"do something"}`); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("/coach code %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cv := s.ctrl.State().Coach; cv != nil && cv.Status == "ready" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoachRouteOutsidePlanning(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
s.ctrl.SetCoach(stubCoach{})
|
||||||
|
r := s.Router()
|
||||||
|
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoachRouteInvalidJSON(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
r := s.Router()
|
||||||
|
_ = post(t, r, "/planning", "")
|
||||||
|
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoachRouteUnavailableDegrades(t *testing.T) {
|
||||||
|
s := newTestServer(t) // no SetCoach
|
||||||
|
r := s.Router()
|
||||||
|
_ = post(t, r, "/planning", "")
|
||||||
|
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unavailable coach should still 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if cv := s.ctrl.State().Coach; cv == nil || cv.Status != "error" {
|
||||||
|
t.Fatalf("want error status, got %+v", cv)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1102
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,15 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.86"
|
anyhow = "1.0.86"
|
||||||
|
fs2 = "0.4"
|
||||||
|
hex = "0.4.3"
|
||||||
ratatui = "0.27.0"
|
ratatui = "0.27.0"
|
||||||
regex = "1.10.5"
|
regex = "1.10.5"
|
||||||
shellexpand = "3.1.0"
|
shellexpand = "3.1.0"
|
||||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
uuid = { version = "1", features = ["v7"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["winuser", "processthreadsapi"] }
|
winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "windef"] }
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
pub const APP_TITLE: &str = "AntiDrift";
|
pub const APP_TITLE: &str = "AntiDrift";
|
||||||
pub const DEFAULT_DURATION: &str = "25";
|
pub const DEFAULT_DURATION: &str = "25";
|
||||||
pub const DURATION_TITLE: &str = "Duration";
|
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 INTENTION_TITLE: &str = "Intention";
|
||||||
pub const PAUSED: &str = "paused";
|
pub const PAUSED: &str = "paused";
|
||||||
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
|
pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions";
|
||||||
@@ -15,6 +19,18 @@ pub const STATUS_FILE: &str = "~/.antidrift_status";
|
|||||||
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
|
pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl";
|
||||||
pub const STATUS_TITLE: &str = "Status";
|
pub const STATUS_TITLE: &str = "Status";
|
||||||
pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session";
|
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_PAUSED: &str = "⏸ antidrift paused";
|
||||||
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
|
pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!";
|
||||||
pub const STATUS_QUIT: &str = "antidrift shutting down";
|
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! ";
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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
@@ -0,0 +1,327 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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,16 +1,41 @@
|
|||||||
|
use super::WindowSnapshot;
|
||||||
|
use crate::domain::EvidenceHealth;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
use std::{ffi::OsString, os::windows::ffi::OsStringExt};
|
||||||
use winapi::shared::windef::HWND;
|
use winapi::shared::windef::HWND;
|
||||||
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
|
use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE};
|
||||||
|
|
||||||
pub fn get_title_clean() -> String {
|
pub fn get_title_clean() -> String {
|
||||||
let title = get_window_info().title;
|
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 re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
|
||||||
re.replace_all(&title, "").to_string()
|
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) {
|
pub fn minimize_other(title: &str) {
|
||||||
let window_info = get_window_info();
|
let window_info = get_window_info();
|
||||||
|
if window_info.hwnd.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if window_info.title != title {
|
if window_info.title != title {
|
||||||
unsafe {
|
unsafe {
|
||||||
ShowWindow(window_info.hwnd, SW_MINIMIZE);
|
ShowWindow(window_info.hwnd, SW_MINIMIZE);
|
||||||
@@ -26,6 +51,13 @@ struct WindowInfo {
|
|||||||
fn get_window_info() -> WindowInfo {
|
fn get_window_info() -> WindowInfo {
|
||||||
unsafe {
|
unsafe {
|
||||||
let hwnd = GetForegroundWindow();
|
let hwnd = GetForegroundWindow();
|
||||||
|
if hwnd.is_null() {
|
||||||
|
return WindowInfo {
|
||||||
|
title: String::new(),
|
||||||
|
hwnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let mut text: [u16; 512] = [0; 512];
|
let mut text: [u16; 512] = [0; 512];
|
||||||
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
|
let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize;
|
||||||
let title = OsString::from_wide(&text[..len])
|
let title = OsString::from_wide(&text[..len])
|
||||||
-560
@@ -1,560 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde::{Serialize, Serializer};
|
|
||||||
use shellexpand;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::io::{stdout, Write};
|
|
||||||
use std::rc::Rc;
|
|
||||||
use std::time::{Duration, Instant}; // <--- Add this
|
|
||||||
mod constants;
|
|
||||||
mod window;
|
|
||||||
|
|
||||||
use ratatui::{
|
|
||||||
crossterm::{
|
|
||||||
event::{self, Event, KeyCode},
|
|
||||||
execute,
|
|
||||||
terminal::{
|
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle,
|
|
||||||
},
|
|
||||||
ExecutableCommand,
|
|
||||||
},
|
|
||||||
prelude::*,
|
|
||||||
style::Color,
|
|
||||||
widgets::*,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
|
||||||
enum State {
|
|
||||||
InputIntention,
|
|
||||||
InputDuration,
|
|
||||||
InProgress,
|
|
||||||
Paused,
|
|
||||||
End,
|
|
||||||
ShouldQuit,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_duration<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_u64(duration.as_secs())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct SessionResult {
|
|
||||||
intention: String,
|
|
||||||
#[serde(serialize_with = "serialize_duration")]
|
|
||||||
duration: Duration,
|
|
||||||
session_ratings: Vec<SessionRating>,
|
|
||||||
rating: u8,
|
|
||||||
rating_f64: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionResult {
|
|
||||||
fn rate(&mut self) {
|
|
||||||
let mut rating = 0_f64;
|
|
||||||
let total_duration = self
|
|
||||||
.session_ratings
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.duration)
|
|
||||||
.fold(Duration::ZERO, |acc, dur| acc.saturating_add(dur));
|
|
||||||
|
|
||||||
for session_rating in &self.session_ratings {
|
|
||||||
let ratio: f64 = session_rating.duration.as_secs_f64() / total_duration.as_secs_f64();
|
|
||||||
rating += (session_rating.rating as f64) * ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.rating_f64 = rating;
|
|
||||||
|
|
||||||
if rating > 2.5 {
|
|
||||||
self.rating = 3;
|
|
||||||
} else if rating > 2.0 {
|
|
||||||
self.rating = 2;
|
|
||||||
} else if rating > 1.0 {
|
|
||||||
self.rating = 1;
|
|
||||||
} else {
|
|
||||||
self.rating = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
|
||||||
struct SessionRating {
|
|
||||||
window_title: Rc<String>,
|
|
||||||
duration: Duration,
|
|
||||||
rating: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
|
||||||
state: State,
|
|
||||||
user_intention: String,
|
|
||||||
user_duration_str: String,
|
|
||||||
user_duration: Duration,
|
|
||||||
current_window_title: Rc<String>,
|
|
||||||
current_window_time: Instant,
|
|
||||||
session_start: Instant,
|
|
||||||
session_stats: HashMap<Rc<String>, Duration>,
|
|
||||||
session_remaining: Duration,
|
|
||||||
session_ratings: Vec<SessionRating>,
|
|
||||||
session_ratings_index: usize,
|
|
||||||
session_results: Vec<SessionResult>,
|
|
||||||
last_tick_50ms: Instant,
|
|
||||||
last_tick_1s: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_session(session: &SessionResult) {
|
|
||||||
let path = shellexpand::tilde(constants::HISTORY_FILE).to_string();
|
|
||||||
|
|
||||||
// 1. Open file in append mode, create if missing
|
|
||||||
let file = std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.append(true)
|
|
||||||
.open(path);
|
|
||||||
|
|
||||||
if let Ok(mut f) = file {
|
|
||||||
// 2. Serialize to JSON
|
|
||||||
if let Ok(json) = serde_json::to_string(session) {
|
|
||||||
// 3. Write newline appended
|
|
||||||
let _ = writeln!(f, "{}", json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl App {
|
|
||||||
fn new() -> Self {
|
|
||||||
let window_title = window::get_title_clean();
|
|
||||||
|
|
||||||
App {
|
|
||||||
state: State::InputIntention,
|
|
||||||
user_intention: String::new(),
|
|
||||||
user_duration_str: constants::DEFAULT_DURATION.to_string(),
|
|
||||||
user_duration: Duration::ZERO,
|
|
||||||
current_window_title: window_title.into(),
|
|
||||||
current_window_time: Instant::now(),
|
|
||||||
session_start: Instant::now(),
|
|
||||||
session_stats: HashMap::new(),
|
|
||||||
session_remaining: Duration::ZERO,
|
|
||||||
session_ratings: Vec::new(),
|
|
||||||
session_ratings_index: 0,
|
|
||||||
session_results: Vec::new(),
|
|
||||||
last_tick_50ms: Instant::now(),
|
|
||||||
last_tick_1s: Instant::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_ticks(&mut self) {
|
|
||||||
if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) {
|
|
||||||
self.tick_50ms();
|
|
||||||
}
|
|
||||||
if self.last_tick_1s.elapsed() >= Duration::from_secs(1) {
|
|
||||||
self.tick_1s();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_in_progress(&mut self) {
|
|
||||||
if self.user_intention.len() == 0 || self.user_duration == Duration::ZERO {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.state = State::InProgress;
|
|
||||||
self.current_window_time = Instant::now();
|
|
||||||
self.session_start = self.current_window_time;
|
|
||||||
self.session_stats = HashMap::new();
|
|
||||||
self.session_ratings_index = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_end(&mut self) {
|
|
||||||
self.state = State::End;
|
|
||||||
self.session_ratings = session_stats_as_vec(&self.session_stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup(&self) {
|
|
||||||
let path = shellexpand::tilde(constants::STATUS_FILE).to_string();
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_status(&self) {
|
|
||||||
let status = match self.state {
|
|
||||||
State::InputIntention | State::InputDuration => constants::STATUS_CONFIGURE.to_string(),
|
|
||||||
State::InProgress => format!(
|
|
||||||
"🎯 {} - {}",
|
|
||||||
self.user_intention,
|
|
||||||
duration_as_str(&self.session_remaining)
|
|
||||||
),
|
|
||||||
State::Paused => format!(
|
|
||||||
"{} - {}",
|
|
||||||
constants::STATUS_PAUSED,
|
|
||||||
duration_as_str(&self.session_remaining)
|
|
||||||
),
|
|
||||||
State::End => constants::STATUS_RATE_SESSION.to_string(),
|
|
||||||
State::ShouldQuit => constants::STATUS_QUIT.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let path = shellexpand::tilde(constants::STATUS_FILE).to_string();
|
|
||||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(true)
|
|
||||||
.open(&path)
|
|
||||||
{
|
|
||||||
let _ = file.write_all(status.as_bytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tick_50ms(&mut self) {
|
|
||||||
match self.state {
|
|
||||||
State::InputIntention | State::InputDuration => {
|
|
||||||
if let Ok(user_duration_mins) = self.user_duration_str.parse::<u64>() {
|
|
||||||
self.user_duration = Duration::from_secs(user_duration_mins * 60);
|
|
||||||
} else {
|
|
||||||
self.user_duration = Duration::ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
window::minimize_other(&constants::APP_TITLE);
|
|
||||||
}
|
|
||||||
State::InProgress => {
|
|
||||||
let elapsed = self.session_start.elapsed();
|
|
||||||
self.session_remaining = self.user_duration.saturating_sub(elapsed);
|
|
||||||
update_session_stats(self);
|
|
||||||
|
|
||||||
if self.session_remaining.is_zero() {
|
|
||||||
self.to_end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
State::ShouldQuit => {}
|
|
||||||
State::Paused => {
|
|
||||||
window::minimize_other(&constants::APP_TITLE);
|
|
||||||
update_session_stats(self);
|
|
||||||
}
|
|
||||||
State::End => {
|
|
||||||
window::minimize_other(&constants::APP_TITLE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.last_tick_50ms = Instant::now();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tick_1s(&mut self) {
|
|
||||||
self.last_tick_1s = Instant::now();
|
|
||||||
self.write_status();
|
|
||||||
|
|
||||||
if self.state == State::Paused {
|
|
||||||
self.user_duration = self.user_duration.saturating_add(Duration::from_secs(1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn timeout(&self) -> Duration {
|
|
||||||
Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_session_results(&self) -> Vec<Line<'_>> {
|
|
||||||
self.session_results
|
|
||||||
.iter()
|
|
||||||
.map(|r| {
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!("{} {}", duration_as_str(&r.duration), r.intention,),
|
|
||||||
match r.rating {
|
|
||||||
2 => Style::new().fg(Color::LightYellow),
|
|
||||||
3 => Style::new().fg(Color::LightGreen),
|
|
||||||
_ => Style::new().fg(Color::LightRed),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_session_stats(&self) -> Vec<Line<'_>> {
|
|
||||||
let mut zero_encountered = if self.state != State::End {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
self.session_ratings
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!("{}: {}", duration_as_str(&s.duration), s.window_title),
|
|
||||||
match s.rating {
|
|
||||||
0 if !zero_encountered => {
|
|
||||||
zero_encountered = true;
|
|
||||||
Style::new().fg(Color::LightBlue)
|
|
||||||
}
|
|
||||||
0 if zero_encountered => Style::new(),
|
|
||||||
1 => Style::new().fg(Color::LightRed),
|
|
||||||
2 => Style::new().fg(Color::LightYellow),
|
|
||||||
3 => Style::new().fg(Color::LightGreen),
|
|
||||||
_ => Style::new().fg(Color::LightBlue),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn duration_as_str(duration: &Duration) -> String {
|
|
||||||
format!(
|
|
||||||
"{:3}:{:02}",
|
|
||||||
duration.as_secs() / 60,
|
|
||||||
duration.as_secs() % 60
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<SessionRating> {
|
|
||||||
let mut stats: Vec<_> = session_stats
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, duration)| duration.as_secs() > 30)
|
|
||||||
.map(|(title, duration)| SessionRating {
|
|
||||||
window_title: title.clone(),
|
|
||||||
duration: duration.clone(),
|
|
||||||
rating: 0,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
stats.sort_by(|a, b| b.duration.cmp(&a.duration));
|
|
||||||
stats
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
enable_raw_mode()?;
|
|
||||||
stdout().execute(EnterAlternateScreen)?;
|
|
||||||
execute!(stdout(), SetTitle(constants::APP_TITLE))?;
|
|
||||||
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
|
||||||
let mut app = App::new();
|
|
||||||
|
|
||||||
while app.state != State::ShouldQuit {
|
|
||||||
terminal.draw(|frame| ui(frame, &app))?;
|
|
||||||
handle_events(&mut app)?;
|
|
||||||
app.handle_ticks();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.cleanup();
|
|
||||||
disable_raw_mode()?;
|
|
||||||
stdout().execute(LeaveAlternateScreen)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_events(app: &mut App) -> Result<()> {
|
|
||||||
if !event::poll(app.timeout())? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Event::Key(key) = event::read()? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if key.kind != event::KeyEventKind::Press {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if key.code == KeyCode::Esc {
|
|
||||||
app.state = State::ShouldQuit;
|
|
||||||
}
|
|
||||||
|
|
||||||
match app.state {
|
|
||||||
State::InputIntention => match key.code {
|
|
||||||
KeyCode::Enter => app.state = State::InputDuration,
|
|
||||||
KeyCode::Tab => app.state = State::InputDuration,
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
let _ = app.user_intention.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) => {
|
|
||||||
app.user_intention.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
State::InputDuration => match key.code {
|
|
||||||
KeyCode::Enter => app.to_in_progress(),
|
|
||||||
KeyCode::Tab => app.state = State::InputIntention,
|
|
||||||
KeyCode::Backspace => {
|
|
||||||
let _ = app.user_duration_str.pop();
|
|
||||||
}
|
|
||||||
KeyCode::Char(c) if c.is_ascii_digit() => {
|
|
||||||
app.user_duration_str.push(c);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
State::InProgress => match key.code {
|
|
||||||
KeyCode::Char('q') => {
|
|
||||||
app.to_end();
|
|
||||||
}
|
|
||||||
KeyCode::Char('p') => {
|
|
||||||
app.state = State::Paused;
|
|
||||||
}
|
|
||||||
KeyCode::Char('a') => {
|
|
||||||
app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60));
|
|
||||||
}
|
|
||||||
KeyCode::Char('x') => {
|
|
||||||
app.user_duration = app.user_duration.saturating_sub(Duration::from_secs(60));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
State::Paused => {
|
|
||||||
if key.code == KeyCode::Char('p') {
|
|
||||||
app.state = State::InProgress;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
State::ShouldQuit => (),
|
|
||||||
State::End => {
|
|
||||||
let code = match key.code {
|
|
||||||
KeyCode::Char('1') => 1,
|
|
||||||
KeyCode::Char('2') => 2,
|
|
||||||
KeyCode::Char('3') => 3,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if app.session_ratings_index < app.session_ratings.len() && code != 0 {
|
|
||||||
app.session_ratings[app.session_ratings_index].rating = code;
|
|
||||||
app.session_ratings_index += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.session_ratings_index >= app.session_ratings.len() {
|
|
||||||
app.state = State::InputIntention;
|
|
||||||
let mut session_result = SessionResult {
|
|
||||||
intention: app.user_intention.clone(),
|
|
||||||
duration: app.session_start.elapsed(),
|
|
||||||
session_ratings: std::mem::take(&mut app.session_ratings),
|
|
||||||
rating: 0,
|
|
||||||
rating_f64: 0.0,
|
|
||||||
};
|
|
||||||
session_result.rate();
|
|
||||||
save_session(&session_result);
|
|
||||||
app.session_results.push(session_result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_session_stats(app: &mut App) {
|
|
||||||
let window_title = if app.state == State::Paused {
|
|
||||||
constants::PAUSED.to_string().into()
|
|
||||||
} else {
|
|
||||||
window::get_title_clean().into()
|
|
||||||
};
|
|
||||||
|
|
||||||
let delta = app.current_window_time.elapsed();
|
|
||||||
if app.current_window_title != window_title || (delta > Duration::from_secs(1)) {
|
|
||||||
let entry = app
|
|
||||||
.session_stats
|
|
||||||
.entry(app.current_window_title.clone())
|
|
||||||
.or_insert_with(|| Duration::default());
|
|
||||||
*entry += app.current_window_time.elapsed();
|
|
||||||
app.current_window_time = Instant::now();
|
|
||||||
app.current_window_title = window_title;
|
|
||||||
app.session_ratings = session_stats_as_vec(&app.session_stats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ui(frame: &mut Frame, app: &App) {
|
|
||||||
let layout = Layout::vertical([
|
|
||||||
Constraint::Min(3),
|
|
||||||
Constraint::Min(3),
|
|
||||||
Constraint::Percentage(100),
|
|
||||||
Constraint::Min(3),
|
|
||||||
]);
|
|
||||||
let [layout_intention, layout_duration, layout_titles, layout_status] =
|
|
||||||
layout.areas(frame.size());
|
|
||||||
|
|
||||||
let border_type_intention = if app.state == State::InputIntention {
|
|
||||||
BorderType::Thick
|
|
||||||
} else {
|
|
||||||
BorderType::Plain
|
|
||||||
};
|
|
||||||
let input_intention = Line::from(Span::raw(&app.user_intention));
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(input_intention).block(
|
|
||||||
Block::bordered()
|
|
||||||
.border_type(border_type_intention)
|
|
||||||
.title(constants::INTENTION_TITLE),
|
|
||||||
),
|
|
||||||
layout_intention,
|
|
||||||
);
|
|
||||||
|
|
||||||
let input_duration: Vec<Line> = match app.state {
|
|
||||||
State::InProgress | State::Paused => {
|
|
||||||
let s = duration_as_str(&app.session_remaining);
|
|
||||||
vec![Line::from(Span::raw(s))]
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
vec![Line::from(Span::raw(&app.user_duration_str))]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let border_type_duration = if app.state == State::InputDuration {
|
|
||||||
BorderType::Thick
|
|
||||||
} else {
|
|
||||||
BorderType::Plain
|
|
||||||
};
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(input_duration).block(
|
|
||||||
Block::bordered()
|
|
||||||
.border_type(border_type_duration)
|
|
||||||
.title(constants::DURATION_TITLE),
|
|
||||||
),
|
|
||||||
layout_duration,
|
|
||||||
);
|
|
||||||
|
|
||||||
if app.state == State::InputIntention || app.state == State::InputDuration {
|
|
||||||
let results = app.get_session_results();
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(results)
|
|
||||||
.block(Block::bordered().title(constants::PREVIOUS_SESSIONS_TITLE)),
|
|
||||||
layout_titles,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let stats = app.get_session_stats();
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(stats).block(Block::bordered().title(constants::SESSION_STATS_TITLE)),
|
|
||||||
layout_titles,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut spans: Vec<Span> = Vec::new();
|
|
||||||
if app.user_intention.len() == 0 {
|
|
||||||
let span = Span::styled(
|
|
||||||
constants::PROVIDE_INTENTION,
|
|
||||||
Style::new().fg(Color::LightRed),
|
|
||||||
);
|
|
||||||
spans.push(span);
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.user_duration.is_zero() {
|
|
||||||
let span = Span::styled(
|
|
||||||
constants::PROVIDE_VALID_DURATION,
|
|
||||||
Style::new().fg(Color::LightRed),
|
|
||||||
);
|
|
||||||
spans.push(span);
|
|
||||||
}
|
|
||||||
|
|
||||||
match app.state {
|
|
||||||
State::InputIntention | State::InputDuration => {
|
|
||||||
if spans.len() == 0 {
|
|
||||||
let span = Span::styled(
|
|
||||||
constants::READY_TO_START,
|
|
||||||
Style::new().fg(Color::LightGreen),
|
|
||||||
);
|
|
||||||
spans.push(span);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
State::InProgress => {
|
|
||||||
let span = Span::styled(
|
|
||||||
constants::SESSION_IN_PROGRESS,
|
|
||||||
Style::new().fg(Color::LightGreen),
|
|
||||||
);
|
|
||||||
spans.push(span);
|
|
||||||
}
|
|
||||||
State::Paused => {
|
|
||||||
let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue));
|
|
||||||
spans.push(span);
|
|
||||||
}
|
|
||||||
State::ShouldQuit => {}
|
|
||||||
State::End => {
|
|
||||||
let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue));
|
|
||||||
spans.push(span);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let input_status: Vec<Line> = vec![Line::from(spans)];
|
|
||||||
frame.render_widget(
|
|
||||||
Paragraph::new(input_status).block(Block::bordered().title(constants::STATUS_TITLE)),
|
|
||||||
layout_status,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
use regex::Regex;
|
|
||||||
use std::{process::Command, process::Output, str};
|
|
||||||
|
|
||||||
pub fn get_title_clean() -> String {
|
|
||||||
let title = get_window_info().title;
|
|
||||||
let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap();
|
|
||||||
re.replace_all(&title, "").to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn minimize_other(title: &str) {
|
|
||||||
let window_info = get_window_info();
|
|
||||||
if &window_info.title != title {
|
|
||||||
run(&format!("xdotool windowminimize {}", window_info.wid));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WindowInfo {
|
|
||||||
title: String,
|
|
||||||
_class: String,
|
|
||||||
wid: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(cmd: &str) -> Option<String> {
|
|
||||||
let output = Command::new("sh").arg("-c").arg(cmd).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;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(class) = run(&format!("xdotool getwindowclassname {wid}")) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(title) = run(&format!("xdotool getwindowname {wid}")) else {
|
|
||||||
return none;
|
|
||||||
};
|
|
||||||
|
|
||||||
WindowInfo {
|
|
||||||
title,
|
|
||||||
_class: class,
|
|
||||||
wid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#[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::*;
|
|
||||||
Reference in New Issue
Block a user