Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.
- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
ignored, stale antidriftd binary removed.
Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
"keel/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -81,7 +81,7 @@ type browseResponse struct {
|
||||
|
||||
// handleBrowse lists subdirectories plus .md files under dir, so the UI can build
|
||||
// a file picker that returns a real server-side path. Dotfiles are NOT hidden —
|
||||
// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail
|
||||
// the default profile lives under ~/.keel. Localhost-only daemon; no jail
|
||||
// beyond OS permissions (same trust boundary as the rest of the UI).
|
||||
func (s *Server) handleBrowse(c *gin.Context) {
|
||||
dir := strings.TrimSpace(c.Query("dir"))
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"antidrift/internal/settings"
|
||||
"keel/internal/settings"
|
||||
)
|
||||
|
||||
// fakeApplier records the last settings it was asked to apply and can be told to
|
||||
@@ -29,7 +29,7 @@ func (f *fakeApplier) apply(s settings.Settings) error {
|
||||
}
|
||||
|
||||
func TestGetSettingsReturnsCurrent(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
|
||||
@@ -51,7 +51,7 @@ func TestGetSettingsReturnsCurrent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
@@ -78,7 +78,7 @@ func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
path := filepath.Join(t.TempDir(), "settings.json")
|
||||
fa := &fakeApplier{reject: true}
|
||||
s.SetSettings(path, settings.Settings{}, fa.apply)
|
||||
@@ -97,7 +97,7 @@ func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
fa := &fakeApplier{}
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
|
||||
r := s.Router()
|
||||
@@ -123,7 +123,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||
r := s.Router()
|
||||
|
||||
@@ -179,7 +179,7 @@ func TestBrowseListsDirsAndMarkdown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBrowseBadDirIs400(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
|
||||
r := s.Router()
|
||||
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
|
||||
|
||||
@@ -88,6 +88,10 @@ input[type="checkbox"] {
|
||||
.btn-ghost { background: var(--line); color: var(--ink); }
|
||||
.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; }
|
||||
|
||||
/* Large tap targets for the launcher and off-screen primary actions. */
|
||||
.btn-big { display: block; width: 100%; margin-top: 12px; padding: 14px 16px; font-size: 16px; }
|
||||
.launcher .btn-big:first-of-type { margin-top: 4px; }
|
||||
|
||||
/* Drift/nudge actions live in the status band; lay them out below the text. */
|
||||
.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.band-actions .btn { margin-top: 0; padding: 7px 12px; }
|
||||
@@ -176,3 +180,13 @@ input[type="checkbox"] {
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.browse-list button:hover { background: var(--line); color: var(--accent); }
|
||||
|
||||
/* Off-screen mode: pending spinner. */
|
||||
.offscreen-pending { text-align: center; }
|
||||
.spinner {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 3px solid var(--line); border-top-color: var(--accent);
|
||||
animation: spin 0.8s linear infinite; margin: 4px auto 10px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
|
||||
|
||||
+79
-12
@@ -2,6 +2,7 @@ const app = document.getElementById('app');
|
||||
const view = document.getElementById('view');
|
||||
let countdownTimer = null;
|
||||
let renderedState = null; // which runtime_state the DOM currently shows
|
||||
let renderedMode = null; // the active_mode the DOM currently shows
|
||||
let dismissedNudge = null; // text of the nudge the user has dismissed
|
||||
|
||||
function post(path, body) {
|
||||
@@ -86,9 +87,9 @@ function updateActiveDrift(state) {
|
||||
<button id="ontask" type="button" class="btn btn-ghost">This is on task</button>
|
||||
<button id="enddrift" type="button" class="btn btn-ghost">End session</button>
|
||||
</div>`;
|
||||
document.getElementById('refocus').onclick = () => post('/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/complete');
|
||||
document.getElementById('refocus').onclick = () => post('/mode/command/refocus');
|
||||
document.getElementById('ontask').onclick = () => post('/mode/command/ontask');
|
||||
document.getElementById('enddrift').onclick = () => post('/mode/command/complete');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ function updatePlanningReflection(refl) {
|
||||
el.innerHTML = `<span class="reflectline meta">Last time: ${refl.carry_forward}</span>`;
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
function renderFocus(state) {
|
||||
setStateAttr(state);
|
||||
const rs = state.runtime_state;
|
||||
if (rs === 'planning' && renderedState === 'planning') {
|
||||
@@ -248,7 +249,7 @@ function render(state) {
|
||||
<p class="meta">No active commitment.</p>
|
||||
<button id="plan" class="btn btn-primary">Start planning</button>
|
||||
</div>`;
|
||||
document.getElementById('plan').onclick = () => post('/planning');
|
||||
document.getElementById('plan').onclick = () => post('/mode/focus/start');
|
||||
|
||||
} else if (rs === 'planning') {
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Planning</span></div>
|
||||
@@ -276,7 +277,7 @@ function render(state) {
|
||||
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', {
|
||||
start.onclick = () => post('/mode/command/commitment', {
|
||||
next_action: na.value.trim(),
|
||||
success_condition: sc.value.trim(),
|
||||
timebox_secs: Math.round(+mins.value * 60),
|
||||
@@ -286,7 +287,7 @@ function render(state) {
|
||||
});
|
||||
document.getElementById('sharpen').onclick = () => {
|
||||
const intent = document.getElementById('intent').value.trim();
|
||||
if (intent) post('/coach', { intent });
|
||||
if (intent) post('/mode/command/coach', { intent });
|
||||
};
|
||||
updatePlanningCoach(state.coach);
|
||||
updatePlanningTasks(state.tasks);
|
||||
@@ -304,7 +305,7 @@ function render(state) {
|
||||
</div>
|
||||
${evidenceBlock(state.evidence)}
|
||||
<div class="band"><button id="done" class="btn btn-primary">Complete</button></div>`;
|
||||
document.getElementById('done').onclick = () => post('/complete');
|
||||
document.getElementById('done').onclick = () => post('/mode/command/complete');
|
||||
const t = document.getElementById('t');
|
||||
const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); };
|
||||
tick();
|
||||
@@ -322,16 +323,82 @@ function render(state) {
|
||||
${reflectionBlock(state.reflection)}
|
||||
${reviewSummary(state.evidence)}
|
||||
<div class="band"><button id="end" class="btn btn-primary">End</button></div>`;
|
||||
document.getElementById('end').onclick = () => post('/end');
|
||||
document.getElementById('end').onclick = () => post('/mode/command/end');
|
||||
|
||||
} else {
|
||||
view.textContent = rs;
|
||||
}
|
||||
}
|
||||
|
||||
// renderLauncher shows the idle screen with mode-start buttons.
|
||||
function renderLauncher() {
|
||||
app.dataset.state = 'locked';
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Keel</span></div>
|
||||
<div class="band launcher">
|
||||
<p class="meta">No active mode.</p>
|
||||
<button id="startFocus" class="btn btn-primary btn-big">Start focus</button>
|
||||
<button id="startOffscreen" class="btn btn-ghost btn-big">Off-screen</button>
|
||||
</div>`;
|
||||
document.getElementById('startFocus').onclick = () => post('/mode/focus/start');
|
||||
document.getElementById('startOffscreen').onclick = () => post('/mode/offscreen/start');
|
||||
}
|
||||
|
||||
// renderOffscreen shows the off-screen mode card. Basic but functional; Task 22
|
||||
// polishes the phone-first styling.
|
||||
function renderOffscreen(m) {
|
||||
app.dataset.state = 'review';
|
||||
const status = m.status || 'pending';
|
||||
let body;
|
||||
if (status === 'proposed') {
|
||||
body = `<div class="band">
|
||||
<div class="action">${esc(m.next_action || '')}</div>
|
||||
<p class="meta">${esc(m.rationale || '')}</p>
|
||||
</div>
|
||||
<div class="band band-actions">
|
||||
<button id="osDo" class="btn btn-primary btn-big">Do it</button>
|
||||
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
|
||||
</div>`;
|
||||
} else if (status === 'error') {
|
||||
body = `<div class="band">
|
||||
<p class="meta">${esc(m.error || 'Could not propose an action.')}</p>
|
||||
</div>
|
||||
<div class="band band-actions">
|
||||
<button id="osRetry" class="btn btn-primary btn-big">Retry</button>
|
||||
<button id="osDismiss" class="btn btn-ghost btn-big">Dismiss</button>
|
||||
</div>`;
|
||||
} else { // pending / done
|
||||
body = `<div class="band offscreen-pending">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<p class="meta">Finding a worthwhile off-screen action…</p>
|
||||
</div>`;
|
||||
}
|
||||
view.innerHTML = `<div class="band statusband"><span class="pill">Off-screen</span></div>${body}`;
|
||||
const doBtn = document.getElementById('osDo');
|
||||
if (doBtn) doBtn.onclick = () => post('/mode/command/confirm');
|
||||
const disBtn = document.getElementById('osDismiss');
|
||||
if (disBtn) disBtn.onclick = () => post('/mode/command/dismiss');
|
||||
const retryBtn = document.getElementById('osRetry');
|
||||
if (retryBtn) retryBtn.onclick = () => post('/mode/offscreen/start');
|
||||
}
|
||||
|
||||
// route dispatches the state envelope to the active mode's renderer. On a mode
|
||||
// switch it resets the focus in-place trackers so stale DOM never leaks across.
|
||||
function route(env) {
|
||||
const am = (env && env.active_mode) || '';
|
||||
if (am !== renderedMode) {
|
||||
renderedMode = am;
|
||||
renderedState = null;
|
||||
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
|
||||
}
|
||||
if (am === '') return renderLauncher();
|
||||
if (am === 'focus') return renderFocus(env.mode || {});
|
||||
if (am === 'offscreen') return renderOffscreen(env.mode || {});
|
||||
view.textContent = am; // unknown mode: degrade visibly
|
||||
}
|
||||
|
||||
const es = new EventSource('/events');
|
||||
es.onmessage = (e) => render(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is antidriftd running?'; };
|
||||
es.onmessage = (e) => route(JSON.parse(e.data));
|
||||
es.onerror = () => { view.textContent = 'disconnected — is keeld running?'; };
|
||||
|
||||
// ---- Settings overlay ----
|
||||
function openSettings() {
|
||||
@@ -349,7 +416,7 @@ function openSettings() {
|
||||
<input id="setMarvin" placeholder="uv run am">
|
||||
<label>Knowledge profile path</label>
|
||||
<div class="path-row">
|
||||
<input id="setKnow" placeholder="~/.antidrift/knowledge.md">
|
||||
<input id="setKnow" placeholder="~/.keel/knowledge.md">
|
||||
<button type="button" class="btn btn-ghost" id="setBrowse">Browse…</button>
|
||||
</div>
|
||||
<div id="browsePane" class="browse" hidden></div>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AntiDrift</title>
|
||||
<title>Keel</title>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<h1>AntiDrift <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||
<h1>Keel <button id="gear" type="button" class="gear" title="Settings" aria-label="Settings">⚙</button></h1>
|
||||
<div class="card" id="view">connecting…</div>
|
||||
<div id="settingsOverlay" class="overlay" hidden></div>
|
||||
</main>
|
||||
|
||||
+67
-89
@@ -7,15 +7,15 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/session"
|
||||
"antidrift/internal/settings"
|
||||
"antidrift/internal/statemachine"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/mode/focus/statemachine"
|
||||
"keel/internal/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -23,9 +23,9 @@ import (
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
// Server wires the session controller to HTTP and SSE.
|
||||
// Server wires the harness to HTTP and SSE.
|
||||
type Server struct {
|
||||
ctrl *session.Controller
|
||||
h *harness.Harness
|
||||
bcast *Broadcaster
|
||||
|
||||
mu sync.Mutex
|
||||
@@ -38,16 +38,20 @@ type Server struct {
|
||||
applyMu sync.Mutex // serializes POST /settings apply+save
|
||||
}
|
||||
|
||||
func NewServer(ctrl *session.Controller) *Server {
|
||||
s := &Server{ctrl: ctrl, bcast: NewBroadcaster()}
|
||||
ctrl.SetOnChange(s.broadcast)
|
||||
// NewServer wires the harness to the SSE broadcaster: every harness change
|
||||
// (mode start, command, async role completion, expiry) fans out the current
|
||||
// state envelope to subscribers. The callback is registered additively, so the
|
||||
// status-file writer can register its own listener alongside this one.
|
||||
func NewServer(h *harness.Harness) *Server {
|
||||
s := &Server{h: h, bcast: NewBroadcaster()}
|
||||
h.AddOnChange(s.broadcast)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Router() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
// antidriftd binds localhost only and sits behind no proxy, so trust no
|
||||
// keeld binds localhost only and sits behind no proxy, so trust no
|
||||
// forwarded-IP headers. This also silences gin's trust-all-proxies warning.
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
|
||||
@@ -68,13 +72,8 @@ func (s *Server) Router() *gin.Engine {
|
||||
c.FileFromFS("/favicon.png", 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)
|
||||
r.POST("/refocus", s.handleRefocus)
|
||||
r.POST("/ontask", s.handleOnTask)
|
||||
r.POST("/mode/:kind/start", s.handleStart)
|
||||
r.POST("/mode/command/:name", s.handleCommand)
|
||||
r.GET("/settings", s.handleGetSettings)
|
||||
r.POST("/settings", s.handlePostSettings)
|
||||
r.GET("/fs/browse", s.handleBrowse)
|
||||
@@ -82,7 +81,7 @@ func (s *Server) Router() *gin.Engine {
|
||||
}
|
||||
|
||||
func (s *Server) stateJSON() string {
|
||||
data, _ := json.Marshal(s.ctrl.State())
|
||||
data, _ := json.Marshal(s.h.State())
|
||||
return string(data)
|
||||
}
|
||||
|
||||
@@ -90,80 +89,58 @@ 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.
|
||||
// respond maps a harness/mode error to an HTTP status, otherwise returns the
|
||||
// new state envelope. Broadcasting is handled solely by the harness onChange
|
||||
// listeners registered in NewServer; callers must not broadcast here.
|
||||
func (s *Server) respond(c *gin.Context, err error) {
|
||||
if err != nil {
|
||||
var illegal statemachine.IllegalTransitionError
|
||||
if errors.As(err, &illegal) {
|
||||
switch {
|
||||
case errors.As(err, &illegal):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
case errors.Is(err, harness.ErrBusy):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, harness.ErrIdle):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, harness.ErrUnknownMode):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
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"`
|
||||
AllowedWindowClasses []string `json:"allowed_window_classes"`
|
||||
Enforce bool `json:"enforce"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
level := domain.EnforcementWarn
|
||||
if req.Enforce {
|
||||
level = domain.EnforcementBlock
|
||||
}
|
||||
err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level)
|
||||
if err == nil {
|
||||
// handleStart activates a mode by kind. Starting focus arms the expiry timer in
|
||||
// case the mode is restored or transitions straight into a deadline-bearing
|
||||
// state; the arm is a no-op when there is no deadline yet.
|
||||
func (s *Server) handleStart(c *gin.Context) {
|
||||
kind := c.Param("kind")
|
||||
err := s.h.Start(kind)
|
||||
if err == nil && kind == "focus" {
|
||||
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) handleRefocus(c *gin.Context) {
|
||||
s.respond(c, s.ctrl.Refocus())
|
||||
}
|
||||
|
||||
func (s *Server) handleOnTask(c *gin.Context) {
|
||||
s.respond(c, s.ctrl.OnTask())
|
||||
// handleCommand routes a surface command to the active mode and manages the
|
||||
// expiry timer for the focus lifecycle: a commitment arms it, completing or
|
||||
// ending cancels it. The raw body is forwarded as the command payload; modes
|
||||
// decode and validate it themselves.
|
||||
func (s *Server) handleCommand(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
body, _ := io.ReadAll(c.Request.Body)
|
||||
err := s.h.Command(c.Request.Context(), name, body)
|
||||
switch name {
|
||||
case "commitment":
|
||||
if err == nil {
|
||||
s.armExpiry()
|
||||
}
|
||||
case "complete", "end":
|
||||
s.cancelExpiry()
|
||||
}
|
||||
s.respond(c, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(c *gin.Context) {
|
||||
@@ -196,9 +173,12 @@ func (s *Server) handleEvents(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// armExpiry schedules an Active -> Review transition at the commitment deadline.
|
||||
// armExpiry schedules an Active -> Review transition at the active mode's
|
||||
// deadline. A zero deadline (no deadline-bearing mode) is a no-op. On firing,
|
||||
// Expire notifies harness listeners, which broadcasts the new state via the
|
||||
// callback registered in NewServer.
|
||||
func (s *Server) armExpiry() {
|
||||
deadline := s.ctrl.Deadline()
|
||||
deadline := s.h.Deadline()
|
||||
if deadline.IsZero() {
|
||||
return
|
||||
}
|
||||
@@ -212,9 +192,7 @@ func (s *Server) armExpiry() {
|
||||
d = 0
|
||||
}
|
||||
s.timer = time.AfterFunc(d, func() {
|
||||
if err := s.ctrl.Expire(); err == nil {
|
||||
s.broadcast()
|
||||
}
|
||||
_ = s.h.Expire()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,17 +205,17 @@ func (s *Server) cancelExpiry() {
|
||||
}
|
||||
}
|
||||
|
||||
// Init re-arms or expires a restored Active session at startup.
|
||||
// Init re-arms or expires a restored deadline-bearing session at startup. With
|
||||
// no active mode the deadline is zero, so this is a no-op until a session is
|
||||
// adopted.
|
||||
func (s *Server) Init() {
|
||||
st := s.ctrl.State()
|
||||
if st.RuntimeState != "active" {
|
||||
deadline := s.h.Deadline()
|
||||
if deadline.IsZero() {
|
||||
return
|
||||
}
|
||||
if s.ctrl.Deadline().After(time.Now()) {
|
||||
if deadline.After(time.Now()) {
|
||||
s.armExpiry()
|
||||
return
|
||||
}
|
||||
if err := s.ctrl.Expire(); err == nil {
|
||||
s.broadcast()
|
||||
}
|
||||
_ = s.h.Expire()
|
||||
}
|
||||
|
||||
+190
-88
@@ -9,26 +9,46 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"antidrift/internal/ai"
|
||||
"antidrift/internal/domain"
|
||||
"antidrift/internal/evidence"
|
||||
"antidrift/internal/knowledge"
|
||||
"antidrift/internal/session"
|
||||
"antidrift/internal/settings"
|
||||
"antidrift/internal/tasks"
|
||||
"keel/internal/ai"
|
||||
"keel/internal/evidence"
|
||||
"keel/internal/harness"
|
||||
"keel/internal/knowledge"
|
||||
"keel/internal/mode"
|
||||
"keel/internal/mode/focus"
|
||||
"keel/internal/mode/focus/domain"
|
||||
"keel/internal/settings"
|
||||
"keel/internal/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
// newTestServer builds a harness around a freshly-constructed focus mode and
|
||||
// returns the server plus the mode so tests can inject per-role stubs (the old
|
||||
// controller's Set* API lives on focus.Mode) and assert on the typed focus
|
||||
// state. The mode is registered behind a factory that wires the harness change
|
||||
// notification onto it, exactly as focus.Factory does, so SSE broadcasts fire on
|
||||
// every focus change. The mode starts Locked; tests drive it through the
|
||||
// command routes (e.g. POST /mode/command/planning), mirroring the original
|
||||
// suite which POSTed /planning first.
|
||||
func newTestServer(t *testing.T) (*Server, *focus.Mode) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
ctrl, err := session.New(path)
|
||||
m, err := focus.New(path)
|
||||
if err != nil {
|
||||
t.Fatalf("controller: %v", err)
|
||||
t.Fatalf("focus.New: %v", err)
|
||||
}
|
||||
return NewServer(ctrl)
|
||||
h := harness.New(harness.Services{Clock: time.Now}, map[string]harness.Factory{
|
||||
"focus": func(svc harness.Services) mode.Mode {
|
||||
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
|
||||
return m
|
||||
},
|
||||
})
|
||||
s := NewServer(h)
|
||||
if err := h.Start("focus"); err != nil {
|
||||
t.Fatalf("harness.Start(focus): %v", err)
|
||||
}
|
||||
return s, m
|
||||
}
|
||||
|
||||
func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder {
|
||||
@@ -40,53 +60,73 @@ func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRec
|
||||
return w
|
||||
}
|
||||
|
||||
// fakeBackend is a no-op ai.Backend so a real *ai.Service can be constructed for
|
||||
// the real-factory parity test without reaching any model.
|
||||
type fakeBackend struct{}
|
||||
|
||||
func (fakeBackend) Run(context.Context, string) (string, error) { return "{}", nil }
|
||||
func (fakeBackend) Name() string { return "fake" }
|
||||
|
||||
func TestPlanningThenCommitmentReachesActive(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, m := newTestServer(t)
|
||||
r := s.Router()
|
||||
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/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 {
|
||||
if w := post(t, r, "/mode/command/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)
|
||||
if m.State().RuntimeState != domain.RuntimeActive {
|
||||
t.Fatalf("expected Active, got %s", m.State().RuntimeState)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitmentRejectsInvalidInput(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
_ = post(t, r, "/mode/command/planning", "")
|
||||
body := `{"next_action":"","success_condition":"x","timebox_secs":1500}`
|
||||
w := post(t, r, "/commitment", body)
|
||||
w := post(t, r, "/mode/command/commitment", body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIllegalTransitionReturns409(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
r := s.Router()
|
||||
// /complete from Locked is illegal.
|
||||
w := post(t, r, "/complete", "")
|
||||
w := post(t, r, "/mode/command/complete", "")
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("expected 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvelopeShape verifies the SSE/state payload is the mode envelope, with
|
||||
// the focus state nested under "mode" and the kind under "active_mode".
|
||||
func TestEnvelopeShape(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"active_mode":"focus"`) {
|
||||
t.Fatalf("payload missing active_mode: %s", js)
|
||||
}
|
||||
if !strings.Contains(js, `"mode"`) {
|
||||
t.Fatalf("payload missing nested mode object: %s", js)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, m := newTestServer(t)
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
_ = post(t, r, "/mode/command/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 {
|
||||
if w := post(t, r, "/mode/command/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}})
|
||||
// A focus update should appear in the serialized state envelope.
|
||||
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "keel", Health: evidence.EvidenceHealth{Available: true}})
|
||||
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"evidence"`) {
|
||||
@@ -98,7 +138,12 @@ func TestActiveStatePayloadCarriesEvidence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLockedStateHasNullEvidence(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, m := newTestServer(t)
|
||||
// End any active session to return to Locked; a freshly-started focus mode is
|
||||
// already Locked (Start does not auto-plan in this harness wiring).
|
||||
if m.State().RuntimeState != domain.RuntimeLocked {
|
||||
t.Fatalf("expected Locked at start, got %s", m.State().RuntimeState)
|
||||
}
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"evidence":null`) {
|
||||
t.Fatalf("locked payload should have null evidence: %s", js)
|
||||
@@ -115,60 +160,60 @@ func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Prop
|
||||
}
|
||||
|
||||
func TestCoachRouteHappyPath(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetCoach(stubCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "done", TimeboxSecs: 1500}})
|
||||
s, m := newTestServer(t)
|
||||
m.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 {
|
||||
_ = post(t, r, "/mode/command/planning", "")
|
||||
if w := post(t, r, "/mode/command/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" {
|
||||
if cv := m.State().Coach; cv != nil && cv.Status == "ready" {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("coach never reached ready: %+v", s.ctrl.State().Coach)
|
||||
t.Fatalf("coach never reached ready: %+v", m.State().Coach)
|
||||
}
|
||||
|
||||
func TestCoachRouteOutsidePlanning(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetCoach(stubCoach{})
|
||||
s, m := newTestServer(t)
|
||||
m.SetCoach(stubCoach{})
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
|
||||
if w := post(t, r, "/mode/command/coach", `{"intent":"x"}`); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoachRouteInvalidJSON(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
if w := post(t, r, "/coach", `not json`); w.Code != http.StatusBadRequest {
|
||||
_ = post(t, r, "/mode/command/planning", "")
|
||||
if w := post(t, r, "/mode/command/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
|
||||
s, m := newTestServer(t) // no SetCoach
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
if w := post(t, r, "/coach", `{"intent":"x"}`); w.Code != http.StatusOK {
|
||||
_ = post(t, r, "/mode/command/planning", "")
|
||||
if w := post(t, r, "/mode/command/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" {
|
||||
if cv := m.State().Coach; cv == nil || cv.Status != "error" {
|
||||
t.Fatalf("want error status, got %+v", cv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefocusAndOnTaskOutsideActiveIs400(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/refocus", ""); w.Code != http.StatusBadRequest {
|
||||
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("refocus outside Active: want 400, got %d", w.Code)
|
||||
}
|
||||
if w := post(t, r, "/ontask", ""); w.Code != http.StatusBadRequest {
|
||||
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("ontask outside Active: want 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -180,22 +225,22 @@ func (s stubNudger) Nudge(ctx context.Context, commitment string, recentTitles [
|
||||
}
|
||||
|
||||
func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
||||
s, m := newTestServer(t)
|
||||
m.SetNudge(stubNudger{msg: "drifted to unrelated work"})
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
_ = post(t, r, "/mode/command/planning", "")
|
||||
body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Two distinct on-task titles trip the nudge (history >= 2; first call has no
|
||||
// debounce to wait on).
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "a.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
m.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "b.go", Health: evidence.EvidenceHealth{Available: true}})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if d := s.ctrl.State().Drift; d != nil && d.Nudge != "" {
|
||||
if d := m.State().Drift; d != nil && d.Nudge != "" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
@@ -207,7 +252,7 @@ func TestActiveStatePayloadCarriesNudge(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServesStaticAssets(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, _ := newTestServer(t)
|
||||
r := s.Router()
|
||||
|
||||
cases := []struct {
|
||||
@@ -243,16 +288,18 @@ func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) {
|
||||
return s.list, nil
|
||||
}
|
||||
|
||||
func (s stubProvider) Create(ctx context.Context, t tasks.Task) error { return nil }
|
||||
|
||||
func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
|
||||
s, m := newTestServer(t)
|
||||
m.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}})
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" {
|
||||
if tv := m.State().Tasks; tv != nil && tv.Status == "ready" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
@@ -267,21 +314,21 @@ func TestPlanningStatePayloadCarriesTasks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCommitmentCarriesAllowedClassesAndRoutesWork(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, m := newTestServer(t)
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
_ = post(t, r, "/mode/command/planning", "")
|
||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"]}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("commitment: want 200, got %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if got := s.ctrl.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
||||
if got := m.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
|
||||
t.Fatalf("commitment did not carry allowed classes: %#v", got)
|
||||
}
|
||||
// Now Active: overrides succeed.
|
||||
if w := post(t, r, "/ontask", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/ontask", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("ontask while Active: want 200, got %d", w.Code)
|
||||
}
|
||||
if w := post(t, r, "/refocus", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/refocus", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("refocus while Active: want 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -298,15 +345,15 @@ func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, e
|
||||
}
|
||||
|
||||
func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}})
|
||||
s, m := newTestServer(t)
|
||||
m.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.keel/knowledge.md"}})
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" {
|
||||
if kv := m.State().Knowledge; kv != nil && kv.Status == "ready" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
@@ -329,20 +376,20 @@ func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.
|
||||
}
|
||||
|
||||
func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
|
||||
s, m := newTestServer(t)
|
||||
m.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}})
|
||||
r := s.Router()
|
||||
_ = post(t, r, "/planning", "")
|
||||
_ = post(t, r, "/mode/command/planning", "")
|
||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := post(t, r, "/complete", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/complete code %d", w.Code)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" {
|
||||
if rv := m.State().Reflection; rv != nil && rv.Status == "ready" {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
@@ -352,11 +399,16 @@ func TestReflectionFlowsToReviewThenPlanning(t *testing.T) {
|
||||
t.Fatalf("review payload missing recap: %s", js)
|
||||
}
|
||||
|
||||
// End -> Locked -> Planning: the carry-forward should surface on planning.
|
||||
if w := post(t, r, "/end", ""); w.Code != http.StatusOK {
|
||||
// End -> Locked: the harness releases the focus mode and goes idle. Re-start
|
||||
// the mode (re-adopting the same in-memory focus, carry-forward intact) and
|
||||
// re-enter Planning; the carry-forward should surface there.
|
||||
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/end code %d", w.Code)
|
||||
}
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
js2 := s.stateJSON()
|
||||
@@ -372,19 +424,19 @@ func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title stri
|
||||
}
|
||||
|
||||
func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s, m := newTestServer(t)
|
||||
r := s.Router()
|
||||
s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
m.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
|
||||
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}`
|
||||
if w := post(t, r, "/commitment", body); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
|
||||
m.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
@@ -397,15 +449,15 @@ func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestKnowledgePathSelectionReloads(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
||||
s, m := newTestServer(t)
|
||||
m.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}})
|
||||
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"),
|
||||
settings.Settings{}, func(ss settings.Settings) error {
|
||||
s.ctrl.SetKnowledgePath(ss.KnowledgePath)
|
||||
m.SetKnowledgePath(ss.KnowledgePath)
|
||||
return nil
|
||||
})
|
||||
r := s.Router()
|
||||
if w := post(t, r, "/planning", ""); w.Code != http.StatusOK {
|
||||
if w := post(t, r, "/mode/command/planning", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/planning code %d", w.Code)
|
||||
}
|
||||
if w := post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/custom/profile.md"}`); w.Code != http.StatusOK {
|
||||
@@ -413,10 +465,60 @@ func TestKnowledgePathSelectionReloads(t *testing.T) {
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
|
||||
if kv := m.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge)
|
||||
t.Fatalf("path selection did not reload: %+v", m.State().Knowledge)
|
||||
}
|
||||
|
||||
// TestRealFocusFactoryParity drives the REAL focus.Factory through the harness
|
||||
// (Start -> commitment -> complete -> end), proving the generalized routes work
|
||||
// end-to-end against a focus mode the harness itself constructs from Services —
|
||||
// not a pre-built mode injected by the test. focus.Factory auto-enters Planning
|
||||
// on Start, so no explicit planning command is needed.
|
||||
func TestRealFocusFactoryParity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := harness.Services{
|
||||
AI: ai.NewService(fakeBackend{}),
|
||||
Clock: time.Now,
|
||||
Dir: t.TempDir(),
|
||||
}
|
||||
h := harness.New(svc, map[string]harness.Factory{"focus": focus.Factory})
|
||||
s := NewServer(h)
|
||||
r := s.Router()
|
||||
|
||||
if w := post(t, r, "/mode/focus/start", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/mode/focus/start code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
// Start auto-plans; the envelope should report focus active and Planning.
|
||||
js := s.stateJSON()
|
||||
if !strings.Contains(js, `"active_mode":"focus"`) {
|
||||
t.Fatalf("envelope missing active_mode focus: %s", js)
|
||||
}
|
||||
if !strings.Contains(js, `"runtime_state":"planning"`) {
|
||||
t.Fatalf("expected planning after start: %s", js)
|
||||
}
|
||||
|
||||
body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}`
|
||||
if w := post(t, r, "/mode/command/commitment", body); w.Code != http.StatusOK {
|
||||
t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(s.stateJSON(), `"runtime_state":"active"`) {
|
||||
t.Fatalf("expected active after commitment: %s", s.stateJSON())
|
||||
}
|
||||
if w := post(t, r, "/mode/command/complete", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/complete code %d", w.Code)
|
||||
}
|
||||
if !strings.Contains(s.stateJSON(), `"runtime_state":"review"`) {
|
||||
t.Fatalf("expected review after complete: %s", s.stateJSON())
|
||||
}
|
||||
if w := post(t, r, "/mode/command/end", ""); w.Code != http.StatusOK {
|
||||
t.Fatalf("/end code %d", w.Code)
|
||||
}
|
||||
// End drives focus to Locked; the harness releases it and goes idle.
|
||||
if got := h.State().ActiveMode; got != "" {
|
||||
t.Fatalf("after end ActiveMode = %q, want idle", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user