Mirror runtime status to ~/.antidrift_status
A window-manager status bar can now read a single-line status file: a glyph plus minutes-remaining (● 24m / ⚠ DRIFT 24m / ● 24m ·? / ◔ planning / ✓ review), empty when idle. Rewritten on a one-minute tick, only when the line changes, and removed on shutdown. Degrades to no file if $HOME is unresolvable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import (
|
|||||||
"antidrift/internal/ai"
|
"antidrift/internal/ai"
|
||||||
"antidrift/internal/evidence"
|
"antidrift/internal/evidence"
|
||||||
"antidrift/internal/session"
|
"antidrift/internal/session"
|
||||||
|
"antidrift/internal/statusfile"
|
||||||
"antidrift/internal/store"
|
"antidrift/internal/store"
|
||||||
"antidrift/internal/web"
|
"antidrift/internal/web"
|
||||||
)
|
)
|
||||||
@@ -45,6 +46,15 @@ func main() {
|
|||||||
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
log.Printf("ai: %s backend (coach + drift judge + nudge)", backend.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirror runtime status to ~/.antidrift_status for a window-manager bar.
|
||||||
|
// A misconfigured home dir degrades to no status file, not a failed start.
|
||||||
|
if statusPath, err := statusfile.DefaultPath(); err != nil {
|
||||||
|
log.Printf("status file disabled: %v", err)
|
||||||
|
} else {
|
||||||
|
go statusfile.NewWriter(statusPath, ctrl.State).Run(context.Background())
|
||||||
|
log.Printf("status: writing %s", statusPath)
|
||||||
|
}
|
||||||
|
|
||||||
src := evidence.NewSource()
|
src := evidence.NewSource()
|
||||||
go src.Watch(context.Background(), ctrl.RecordWindow)
|
go src.Watch(context.Background(), ctrl.RecordWindow)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Package statusfile mirrors the controller's runtime status into a single-line
|
||||||
|
// file (~/.antidrift_status) so an external consumer — a window-manager status
|
||||||
|
// bar — can display it with a plain file read.
|
||||||
|
package statusfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statusFileName is the file written under the user's home directory.
|
||||||
|
const statusFileName = ".antidrift_status"
|
||||||
|
|
||||||
|
// interval is how often the writer re-renders. Minute granularity is enough for
|
||||||
|
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
|
||||||
|
const interval = time.Minute
|
||||||
|
|
||||||
|
// DefaultPath resolves ~/.antidrift_status.
|
||||||
|
func DefaultPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, statusFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render produces the single status line for the given state. It is empty for
|
||||||
|
// Locked/idle so a bar module can hide itself. Drift status strings ("drifting")
|
||||||
|
// match the JSON contract the web UI already consumes.
|
||||||
|
func Render(st session.State, now time.Time) string {
|
||||||
|
switch st.RuntimeState {
|
||||||
|
case domain.RuntimeActive:
|
||||||
|
timer := remaining(st, now)
|
||||||
|
switch {
|
||||||
|
case st.Drift != nil && st.Drift.Status == "drifting":
|
||||||
|
return "⚠ DRIFT " + timer
|
||||||
|
case st.Drift != nil && st.Drift.Nudge != "":
|
||||||
|
return "● " + timer + " ·?"
|
||||||
|
default:
|
||||||
|
return "● " + timer
|
||||||
|
}
|
||||||
|
case domain.RuntimePlanning:
|
||||||
|
return "◔ planning"
|
||||||
|
case domain.RuntimeReview:
|
||||||
|
return "✓ review"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remaining formats the minutes left until the commitment deadline, rounded up
|
||||||
|
// so a partial minute still reads as a minute and the count hits 0m only at the
|
||||||
|
// end. It returns 0m when no deadline is known.
|
||||||
|
func remaining(st session.State, now time.Time) string {
|
||||||
|
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
|
||||||
|
return "0m"
|
||||||
|
}
|
||||||
|
left := time.Unix(st.Commitment.DeadlineUnixSecs, 0).Sub(now)
|
||||||
|
if left < 0 {
|
||||||
|
left = 0
|
||||||
|
}
|
||||||
|
mins := int((left + time.Minute - 1) / time.Minute) // ceil
|
||||||
|
return fmt.Sprintf("%dm", mins)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer periodically renders the controller state and writes it to a file,
|
||||||
|
// rewriting only when the line changes.
|
||||||
|
type Writer struct {
|
||||||
|
path string
|
||||||
|
state func() session.State
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
last string
|
||||||
|
wrote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWriter builds a Writer for path, reading state via the given accessor.
|
||||||
|
func NewWriter(path string, state func() session.State) *Writer {
|
||||||
|
return &Writer{path: path, state: state, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run writes the status file immediately, then on every tick when the rendered
|
||||||
|
// line has changed. It removes the file on ctx cancellation so a stale status
|
||||||
|
// does not linger after shutdown.
|
||||||
|
func (w *Writer) Run(ctx context.Context) {
|
||||||
|
t := time.NewTicker(interval)
|
||||||
|
defer t.Stop()
|
||||||
|
w.write()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = os.Remove(w.path)
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.write()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Writer) write() {
|
||||||
|
line := Render(w.state(), w.now())
|
||||||
|
if w.wrote && line == w.last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(w.path, []byte(line), 0o644); err != nil {
|
||||||
|
log.Printf("statusfile: write %s: %v", w.path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.last = line
|
||||||
|
w.wrote = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package statusfile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"antidrift/internal/domain"
|
||||||
|
"antidrift/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func activeState(deadline int64, driftStatus, nudge string) session.State {
|
||||||
|
st := session.State{
|
||||||
|
RuntimeState: domain.RuntimeActive,
|
||||||
|
Commitment: &session.CommitmentView{DeadlineUnixSecs: deadline},
|
||||||
|
}
|
||||||
|
if driftStatus != "" || nudge != "" {
|
||||||
|
st.Drift = &session.DriftView{Status: driftStatus, Nudge: nudge}
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender(t *testing.T) {
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
in24m := now.Add(24 * time.Minute).Unix()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
st session.State
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"locked", session.State{RuntimeState: domain.RuntimeLocked}, ""},
|
||||||
|
{"empty runtime", session.State{}, ""},
|
||||||
|
{"planning", session.State{RuntimeState: domain.RuntimePlanning}, "◔ planning"},
|
||||||
|
{"review", session.State{RuntimeState: domain.RuntimeReview}, "✓ review"},
|
||||||
|
{"active ontask", activeState(in24m, "ontask", ""), "● 24m"},
|
||||||
|
{"active no drift view", activeState(in24m, "", ""), "● 24m"},
|
||||||
|
{"active drifting", activeState(in24m, "drifting", ""), "⚠ DRIFT 24m"},
|
||||||
|
{"active nudge", activeState(in24m, "ontask", "wandered off"), "● 24m ·?"},
|
||||||
|
{"drift outranks nudge", activeState(in24m, "drifting", "wandered off"), "⚠ DRIFT 24m"},
|
||||||
|
{"active no deadline", session.State{RuntimeState: domain.RuntimeActive}, "● 0m"},
|
||||||
|
{"active past deadline", activeState(now.Add(-5 * time.Minute).Unix(), "ontask", ""), "● 0m"},
|
||||||
|
{"active partial minute rounds up", activeState(now.Add(10*time.Second).Unix(), "ontask", ""), "● 1m"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Render(tc.st, now); got != tc.want {
|
||||||
|
t.Errorf("Render() = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterWritesAndDedups(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
now := time.Unix(1_000_000, 0)
|
||||||
|
state := session.State{RuntimeState: domain.RuntimePlanning}
|
||||||
|
w := NewWriter(path, func() session.State { return state })
|
||||||
|
w.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "◔ planning" {
|
||||||
|
t.Fatalf("first write = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same state: file untouched. Detect by clearing it and confirming the
|
||||||
|
// deduped write does not recreate content.
|
||||||
|
if err := os.WriteFile(path, []byte("sentinel"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "sentinel" {
|
||||||
|
t.Errorf("deduped write should not rewrite, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed state: rewrites.
|
||||||
|
state = session.State{RuntimeState: domain.RuntimeLocked}
|
||||||
|
w.write()
|
||||||
|
if got := readFile(t, path); got != "" {
|
||||||
|
t.Errorf("changed write = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterRemovesFileOnCancel(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status")
|
||||||
|
w := NewWriter(path, func() session.State {
|
||||||
|
return session.State{RuntimeState: domain.RuntimePlanning}
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { w.Run(ctx); close(done) }()
|
||||||
|
|
||||||
|
// Wait for the immediate write.
|
||||||
|
waitFor(t, func() bool { _, err := os.Stat(path); return err == nil })
|
||||||
|
cancel()
|
||||||
|
<-done
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("file should be removed on cancel, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitFor(t *testing.T, cond func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if cond() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatal("condition not met within timeout")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user