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"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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 {
|
type session struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Actor string `json:"actor"`
|
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"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastActiveAt time.Time `json:"last_active_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) {
|
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
|
||||||
if s == nil {
|
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
|
var id string
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
@@ -64,7 +73,38 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -160,7 +202,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
|||||||
var out []session
|
var out []session
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sess session
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, sess)
|
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
|
domain: cognition
|
||||||
layer: cognition
|
layer: cognition
|
||||||
description: Recorded investigation/postmortem.
|
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 ────────────────────────────────────────────────
|
# ─── Relationship types ────────────────────────────────────────────────
|
||||||
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
|
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
|
||||||
@@ -928,6 +936,14 @@ relationship_types:
|
|||||||
target: entity
|
target: entity
|
||||||
cardinality: many-to-one
|
cardinality: many-to-one
|
||||||
description: Document describes an entity.
|
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:
|
procedure-for:
|
||||||
inverse: has-procedure
|
inverse: has-procedure
|
||||||
source: runbook
|
source: runbook
|
||||||
|
|||||||
Reference in New Issue
Block a user