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:
@@ -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)
|
||||
|
||||
53
migrations/018_tasks.up.sql
Normal file
53
migrations/018_tasks.up.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- 018_tasks.up.sql
|
||||
-- Elevate a chat session into a "task": a goal-structured unit of work with a
|
||||
-- lifecycle status, an outcome, and a one-line summary — the first-class object
|
||||
-- the task board and the live context panel render. See
|
||||
-- plans/2026-07-11-goal-oriented-chat-control-panel.md.
|
||||
--
|
||||
-- entity_id links the session to its OWN entity (type 'task', registered in
|
||||
-- seeds/ontology.yaml) so knowledge notes and involved-entity edges hang off
|
||||
-- the existing relationships graph unchanged — get_relations and
|
||||
-- get_entity_knowledge just work. Intentionally no hard FK (mirrors 017's
|
||||
-- decoupling): a race between task-entity creation and the session insert must
|
||||
-- not be able to break the session.
|
||||
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS goal TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS outcome TEXT;
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS entity_id UUID;
|
||||
|
||||
-- Ordered plan steps. A step is a described unit of work that maps to a
|
||||
-- run/request_execution call (no fixed step enum, per general-gated-execution).
|
||||
-- execution_id is the gated action a step runs, if any; its terminal status
|
||||
-- auto-closes the step server-side.
|
||||
CREATE TABLE IF NOT EXISTS session_plan_steps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
seq INT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- pending | running | done | failed | skipped | blocked
|
||||
execution_id UUID,
|
||||
target_slug TEXT,
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON session_plan_steps(session_id, seq);
|
||||
|
||||
-- Structured decisions the agent surfaces to the operator mid-task. context
|
||||
-- carries { entities:[], options:[], why:"" } for the pinned question card.
|
||||
CREATE TABLE IF NOT EXISTS session_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
prompt TEXT NOT NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
|
||||
answer TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
answered_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_session_open
|
||||
ON session_questions(session_id) WHERE status = 'open';
|
||||
@@ -641,6 +641,14 @@ entity_types:
|
||||
domain: cognition
|
||||
layer: cognition
|
||||
description: Recorded investigation/postmortem.
|
||||
task:
|
||||
parent: entity
|
||||
domain: cognition
|
||||
layer: cognition
|
||||
description: A goal-structured unit of agent work — one chat/session elevated
|
||||
to a task with a plan, lifecycle status, and outcome. Anchors the knowledge
|
||||
and involved-entity relationships for the task so future tasks can learn
|
||||
from it. Typed rows in agent_sessions.
|
||||
|
||||
# ─── Relationship types ────────────────────────────────────────────────
|
||||
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
|
||||
@@ -928,6 +936,14 @@ relationship_types:
|
||||
target: entity
|
||||
cardinality: many-to-one
|
||||
description: Document describes an entity.
|
||||
involves:
|
||||
inverse: involved-in
|
||||
source: task
|
||||
target: entity
|
||||
cardinality: many-to-many
|
||||
description: Task explored or acted on an entity (captured from its tool
|
||||
calls). A task's involved-entity set is its graph neighborhood, so future
|
||||
tasks on the same entities can surface this task's knowledge and outcome.
|
||||
procedure-for:
|
||||
inverse: has-procedure
|
||||
source: runbook
|
||||
|
||||
Reference in New Issue
Block a user