diff --git a/internal/session/session.go b/internal/session/session.go index 5091df5..dc65e99 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -19,6 +19,7 @@ import ( "antidrift/internal/evidence" "antidrift/internal/statemachine" "antidrift/internal/store" + "antidrift/internal/tasks" "github.com/google/uuid" ) @@ -37,6 +38,15 @@ const ( coachError = "error" ) +const tasksTimeout = 30 * time.Second + +const ( + tasksIdle = "idle" + tasksPending = "pending" + tasksReady = "ready" + tasksError = "error" +) + const ( driftDebounce = 10 * time.Second driftTimeout = 30 * time.Second @@ -92,6 +102,11 @@ type Controller struct { coachErr string coachGen int + tasksProvider tasks.Provider + tasksStatus string + tasksList []tasks.Task + tasksGen int + allowedClasses []string // durable: the active session's allowed window classes judge ai.DriftJudge driftStatus string @@ -139,6 +154,19 @@ type CoachView struct { Error string `json:"error,omitempty"` } +// TaskView is one to-do item in the planning tasks list. +type TaskView struct { + ID string `json:"id"` + Title string `json:"title"` + Day string `json:"day,omitempty"` +} + +// TasksView projects the ephemeral planning tasks state (Marvin's today list). +type TasksView struct { + Status string `json:"status"` + Tasks []TaskView `json:"tasks,omitempty"` +} + // WindowView / BucketView / EvidenceView are the evidence projection. type WindowView struct { Class string `json:"class"` @@ -165,6 +193,7 @@ type State struct { Commitment *CommitmentView `json:"commitment"` Evidence *EvidenceView `json:"evidence"` Coach *CoachView `json:"coach,omitempty"` + Tasks *TasksView `json:"tasks,omitempty"` Drift *DriftView `json:"drift,omitempty"` } @@ -279,6 +308,17 @@ func (c *Controller) stateLocked() State { } } st.Coach = cv + if c.tasksProvider != nil { + tstatus := c.tasksStatus + if tstatus == "" { + tstatus = tasksIdle + } + tv := &TasksView{Status: tstatus} + for _, t := range c.tasksList { + tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day}) + } + st.Tasks = tv + } } if c.runtimeState == domain.RuntimeActive { status := c.driftStatus @@ -325,6 +365,7 @@ func (c *Controller) EnterPlanning() error { } c.runtimeState = next c.resetCoachLocked() + c.startTasksFetchLocked() return c.persistLocked() } @@ -345,6 +386,49 @@ func (c *Controller) resetCoachLocked() { c.coachGen++ } +// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps +// the planning tasks list absent. +func (c *Controller) SetTasks(p tasks.Provider) { + c.mu.Lock() + c.tasksProvider = p + c.mu.Unlock() +} + +// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider +// is set. Mirrors RequestCoach: generation-guarded, discards stale or +// post-planning results, and notifies on completion. Caller holds mu. +func (c *Controller) startTasksFetchLocked() { + c.tasksList = nil + if c.tasksProvider == nil { + c.tasksStatus = tasksIdle + return + } + c.tasksGen++ + gen := c.tasksGen + c.tasksStatus = tasksPending + provider := c.tasksProvider + go func() { + ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout) + defer cancel() + list, err := provider.Today(ctx) + + c.mu.Lock() + if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning { + c.mu.Unlock() + return // stale or left planning: discard + } + if err != nil { + c.tasksStatus = tasksError + c.tasksList = nil + } else { + c.tasksStatus = tasksReady + c.tasksList = list + } + c.mu.Unlock() + c.notify() + }() +} + // AllowedClassesForTest exposes the session allowed classes for tests. func (c *Controller) AllowedClassesForTest() []string { c.mu.Lock() diff --git a/internal/session/session_test.go b/internal/session/session_test.go index b650689..7250538 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -13,6 +13,7 @@ import ( "antidrift/internal/domain" "antidrift/internal/evidence" "antidrift/internal/store" + "antidrift/internal/tasks" ) func newTestController(t *testing.T) (*Controller, string) { @@ -772,3 +773,73 @@ func TestRecentTitlesRingCapsAtTen(t *testing.T) { t.Fatalf("ring should drop oldest first, got first=%q", titles[0]) } } + +type fakeProvider struct { + list []tasks.Task + err error + gate chan struct{} // if non-nil, Today blocks until it receives +} + +func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) { + if f.gate != nil { + <-f.gate + } + return f.list, f.err +} + +// waitTasksStatus polls until the tasks view reaches want, or fails after 2s. +func waitTasksStatus(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.Tasks != nil && st.Tasks.Status == want { + return st + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks) + return State{} +} + +func TestEnterPlanningFetchesTasks(t *testing.T) { + c, _ := newTestController(t) + c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitTasksStatus(t, c, "ready") + if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" { + t.Fatalf("tasks list wrong: %+v", st.Tasks) + } +} + +func TestTasksFetchError(t *testing.T) { + c, _ := newTestController(t) + c.SetTasks(&fakeProvider{err: errors.New("am down")}) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + st := waitTasksStatus(t, c, "error") + if len(st.Tasks.Tasks) != 0 { + t.Fatalf("error state should carry no tasks: %+v", st.Tasks) + } +} + +func TestNoProviderNoTasksView(t *testing.T) { + c, _ := newTestController(t) + if err := c.EnterPlanning(); err != nil { + t.Fatalf("enter planning: %v", err) + } + if c.State().Tasks != nil { + t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks) + } +} + +func TestTasksViewAbsentOutsidePlanning(t *testing.T) { + c, _ := newTestController(t) + c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}}) + if c.State().Tasks != nil { + t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks) + } +}