feat(tasks): phase 1 — elevate chat session to a task (schema + task entity)

Migration 018 adds goal/status/outcome/summary/entity_id to agent_sessions
and creates session_plan_steps + session_questions. Registers a 'task'
entity type and an 'involves' (task→entity) relationship in the ontology
so each session anchors its knowledge and involved-entity edges on the
existing relationships graph.

nomos createSession now mints a task:<session-id> entity (type task) and
links it via agent_sessions.entity_id — best-effort so chat never blocks on
it. listSessions/GET /sessions surface the new task fields.

No behaviour change yet; this is the data foundation for the task board and
live context panel. Verified end-to-end against the local stack: migration
applied, ontology ingested (60 types/47 rels), a new session mints a linked
task entity and the API returns status/goal/entity_id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:25:28 +02:00
parent eed6e3b1c5
commit 72e9fe534e
3 changed files with 116 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
@@ -37,10 +38,18 @@ func (s *store) close() {
}
}
// session is a chat session elevated to a task: goal-structured work with a
// lifecycle status and an outcome (see migration 018 / the task-board plan).
// Outcome/Summary/EntityID are empty until set, hence omitempty.
type session struct {
ID string `json:"id"`
Title string `json:"title"`
Actor string `json:"actor"`
Goal string `json:"goal"`
Status string `json:"status"`
Outcome string `json:"outcome,omitempty"`
Summary string `json:"summary,omitempty"`
EntityID string `json:"entity_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastActiveAt time.Time `json:"last_active_at"`
}
@@ -55,7 +64,7 @@ type message struct {
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
if s == nil {
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil
}
var id string
err := s.pool.QueryRow(ctx,
@@ -64,7 +73,38 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
if err != nil {
return nil, err
}
return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
// Give the task its own entity so knowledge and involved-entity edges hang
// off the existing relationships graph. Best-effort: a failure here must not
// block the chat — the session is usable without a graph anchor.
entityID := s.createTaskEntity(ctx, id, title)
return &session{ID: id, Title: title, Actor: "agent:nomos", Status: "active",
EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
}
// createTaskEntity creates (or reuses) the task:<session-id> entity that
// anchors this task's knowledge and involved-entity relationships, and records
// it on the session. Returns the entity id, or "" on failure — non-fatal, see
// caller. Requires the 'task' entity type (seeds/ontology.yaml).
func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) string {
entityID, _ := uuid.NewV7()
slug := "task:" + sessionID
// name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the
// name on the session id and keep the human title in attributes for display.
name := "task " + sessionID
attrs, _ := json.Marshal(map[string]any{"title": title})
if err := s.pool.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, attributes)
VALUES ($1, $2, 'task', $3, $4)
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
RETURNING id`, entityID, slug, name, string(attrs)).Scan(&entityID); err != nil {
slog.Warn("nomos: could not create task entity", "session", sessionID, "error", err)
return ""
}
if _, err := s.pool.Exec(ctx,
`UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil {
slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err)
}
return entityID.String()
}
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
@@ -151,7 +191,9 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
return nil, nil
}
rows, err := s.pool.Query(ctx,
`SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
COALESCE(entity_id::text, ''), created_at, last_active_at
FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
if err != nil {
return nil, err
}
@@ -160,7 +202,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
var out []session
for rows.Next() {
var sess session
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
return nil, err
}
out = append(out, sess)