diff --git a/cmd/nomos/main.go b/cmd/nomos/main.go index 5b70592..1bc5f66 100644 --- a/cmd/nomos/main.go +++ b/cmd/nomos/main.go @@ -255,6 +255,32 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *a return } + // GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for + // the context panel when it first opens a task; live events carry deltas + // from there. + if len(parts) == 2 && r.Method == http.MethodGet { + switch parts[1] { + case "plan": + steps, err := st.getPlanSteps(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"steps": steps}) + return + case "questions": + questions, err := st.getQuestions(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"questions": questions}) + return + } + } + switch r.Method { case http.MethodDelete: if err := st.deleteSession(r.Context(), id); err != nil { diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index 686f705..b6c515b 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -432,6 +432,88 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st return nil } +// planStep is a persisted plan step, as returned to the frontend for hydration +// (the panel otherwise only sees steps live via plan.proposed/plan.step.*). +type planStep struct { + ID string `json:"id"` + Seq int `json:"seq"` + Title string `json:"title"` + Detail string `json:"detail"` + Status string `json:"status"` + ExecutionID *string `json:"execution_id,omitempty"` + TargetSlug *string `json:"target_slug,omitempty"` + StartedAt *string `json:"started_at,omitempty"` + FinishedAt *string `json:"finished_at,omitempty"` +} + +// getPlanSteps returns a task's plan in order — REST hydration for the context +// panel when it first opens a task (live events only carry deltas from then on). +func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep, error) { + if s == nil { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT id::text, seq, title, detail, status, + execution_id::text, target_slug, + started_at::text, finished_at::text + FROM session_plan_steps WHERE session_id = $1 ORDER BY seq`, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []planStep + for rows.Next() { + var st planStep + var execID, target, started, finished *string + if err := rows.Scan(&st.ID, &st.Seq, &st.Title, &st.Detail, &st.Status, + &execID, &target, &started, &finished); err != nil { + return nil, err + } + st.ExecutionID, st.TargetSlug, st.StartedAt, st.FinishedAt = execID, target, started, finished + out = append(out, st) + } + return out, rows.Err() +} + +// sessionQuestion is a persisted question, as returned to the frontend. +type sessionQuestion struct { + ID string `json:"id"` + Prompt string `json:"prompt"` + Context map[string]any `json:"context"` + Status string `json:"status"` + Answer *string `json:"answer,omitempty"` + CreatedAt string `json:"created_at"` + AnsweredAt *string `json:"answered_at,omitempty"` +} + +// getQuestions returns a task's questions (open and answered) newest-first — +// REST hydration for the context panel's pinned question card and history. +func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) { + if s == nil { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT id::text, prompt, context, status, answer, created_at::text, answered_at::text + FROM session_questions WHERE session_id = $1 ORDER BY created_at DESC`, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []sessionQuestion + for rows.Next() { + var q sessionQuestion + var ctxJSON []byte + var answer, answeredAt *string + if err := rows.Scan(&q.ID, &q.Prompt, &ctxJSON, &q.Status, &answer, &q.CreatedAt, &answeredAt); err != nil { + return nil, err + } + json.Unmarshal(ctxJSON, &q.Context) + q.Answer, q.AnsweredAt = answer, answeredAt + out = append(out, q) + } + return out, rows.Err() +} + // askOperator records a structured decision the agent needs from the operator, // moves the task to awaiting_input, and emits question.raised so the context // panel pins it. qctx carries {why, options, entities}. Returns the question id. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 92f8c43..77a8353 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -44,6 +44,51 @@ export async function deleteSession(sessionId: string): Promise { return res.ok } +export interface PlanStep { + id: string + seq: number + title: string + detail: string + status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked' + execution_id?: string + target_slug?: string + started_at?: string + finished_at?: string +} + +export async function fetchPlan(sessionId: string): Promise { + const res = await fetch(`${BASE}/sessions/${sessionId}/plan`) + if (!res.ok) return [] + const data = await res.json() + return data.steps ?? [] +} + +export interface SessionQuestion { + id: string + prompt: string + context: { why?: string; options?: string[]; entities?: string[] } + status: 'open' | 'answered' | 'dismissed' + answer?: string + created_at: string + answered_at?: string +} + +export async function fetchQuestions(sessionId: string): Promise { + const res = await fetch(`${BASE}/sessions/${sessionId}/questions`) + if (!res.ok) return [] + const data = await res.json() + return data.questions ?? [] +} + +export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise { + const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answer }) + }) + return res.ok +} + export interface ChatEvent { type: string data: any diff --git a/web/src/lib/components/GoalHeader.svelte b/web/src/lib/components/GoalHeader.svelte new file mode 100644 index 0000000..eed55cf --- /dev/null +++ b/web/src/lib/components/GoalHeader.svelte @@ -0,0 +1,38 @@ + + +{#if $currentTask} + {@const st = statusStyle($currentTask.status, $currentTask.outcome)} +
+
+ + {st.label} +
+

+ {$currentTask.goal || $currentTask.title || 'Untitled task'} +

+
+{/if} diff --git a/web/src/lib/components/OperatorQuestion.svelte b/web/src/lib/components/OperatorQuestion.svelte new file mode 100644 index 0000000..d739578 --- /dev/null +++ b/web/src/lib/components/OperatorQuestion.svelte @@ -0,0 +1,76 @@ + + +{#if $openQuestion} + {@const q = $openQuestion} +
+
+ +
+

{q.prompt}

+ {#if q.context.why} +

{q.context.why}

+ {/if} +
+
+ + {#if q.context.entities?.length} +
+ {#each q.context.entities as slug} + {slug} + {/each} +
+ {/if} + + {#if q.context.options?.length} +
+ {#each q.context.options as opt} + + {/each} +
+ {/if} + +
+