synops/tools/synops-agent/src/graph.rs
vegard 202682e2e0 Implementer checkpoint og recovery i synops-agent
- Ny checkpoint-modul: lagrer sesjonsstatus (meldinger, tokens, oppgave) til JSON
- --resume flagg for å gjenoppta etter krasj (sesjons-ID eller "latest")
- --checkpoint-interval for å styre hvor ofte mellomtilstand lagres
- Kostnadslogging til ai_usage_log i PG ved sesjonsslutt
- Sesjonsrapport: modell, varighet, tokens, kostnad, filer endret
- Integrert i agent-loop (periodisk checkpoint), batch-modus og daemon
- Automatisk opprydding av gamle checkpoints (beholder siste 20)
2026-03-19 18:49:09 +00:00

689 lines
19 KiB
Rust

//! Grafintegrasjon — les/skriv noder og edges via PG.
//!
//! Kobler synops-agent til Synops-grafen:
//! - Plukk task-noder (node_kind: 'task') fra PG
//! - Oppdater oppgavestatus (open → active → done)
//! - Skriv tilbakemelding i oppdragets chat-node
//! - Krasj-deteksjon: frigjør tasks >60 min
//! - Spørring av noder og edges (synops_query-verktøy)
use chrono::{DateTime, Utc};
use serde_json::json;
use sqlx::PgPool;
use uuid::Uuid;
/// Agent node ID (claude-main).
pub const AGENT_NODE_ID: &str = "d3eebc99-9c0b-4ef8-bb6d-6bb9bd380a44";
/// Pick the highest-priority open task and claim it atomically.
///
/// Uses `FOR UPDATE SKIP LOCKED` to avoid contention with other agents.
/// Sets status → "active", agent_id, started_at.
pub async fn pick_task(pool: &PgPool) -> Result<Option<TaskInfo>, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
let now = Utc::now();
// Atomically pick and claim the task
let row = sqlx::query_as::<_, TaskRow>(
r#"
UPDATE nodes SET metadata = metadata
|| jsonb_build_object(
'status', 'active',
'agent_id', $1::text,
'started_at', $2::text
)
WHERE id = (
SELECT id FROM nodes
WHERE node_kind = 'task'
AND metadata->>'status' = 'open'
ORDER BY (metadata->>'priority')::int ASC NULLS LAST, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, title, content, metadata, created_at
"#,
)
.bind(agent_id.to_string())
.bind(now.to_rfc3339())
.fetch_optional(pool)
.await
.map_err(|e| format!("pick_task feilet: {e}"))?;
Ok(row.map(|r| TaskInfo {
id: r.id,
title: r.title,
content: r.content,
metadata: r.metadata.unwrap_or_default(),
created_at: r.created_at,
}))
}
/// Update a task's status (e.g. "done", "failed", "skipped").
pub async fn update_task_status(
pool: &PgPool,
task_id: Uuid,
status: &str,
result: Option<&str>,
) -> Result<(), String> {
let now = Utc::now();
let mut patch = json!({ "status": status });
if matches!(status, "done" | "failed" | "skipped") {
patch["completed_at"] = json!(now.to_rfc3339());
}
if let Some(r) = result {
patch["result"] = json!(r);
}
sqlx::query(
r#"
UPDATE nodes SET metadata = metadata || $1
WHERE id = $2 AND node_kind = 'task'
"#,
)
.bind(&patch)
.bind(task_id)
.execute(pool)
.await
.map_err(|e| format!("update_task_status feilet: {e}"))?;
Ok(())
}
/// Write a message in a task's discussion chat.
///
/// Finds the communication node linked via `has_discussion` edge,
/// then creates a content node as a message and links it.
pub async fn write_task_message(
pool: &PgPool,
task_id: Uuid,
message: &str,
) -> Result<Uuid, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
// Find or create discussion node for this task
let discussion_id = find_or_create_discussion(pool, task_id).await?;
// Create message node
let msg_id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, content, visibility, metadata, created_by)
VALUES ($1, 'message', $2, 'hidden', '{}', $3)
"#,
)
.bind(msg_id)
.bind(message)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("write_task_message (node): {e}"))?;
// Link message → discussion via belongs_to
sqlx::query(
r#"
INSERT INTO edges (source_id, target_id, edge_type, system, created_by)
VALUES ($1, $2, 'belongs_to', true, $3)
"#,
)
.bind(msg_id)
.bind(discussion_id)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("write_task_message (edge): {e}"))?;
Ok(msg_id)
}
/// Release stale tasks: active >60 min → back to open.
///
/// Returns the number of tasks released.
pub async fn release_stale_tasks(pool: &PgPool) -> Result<u64, String> {
let result = sqlx::query(
r#"
UPDATE nodes SET metadata = metadata
|| '{"status": "open"}'::jsonb
- 'agent_id'
- 'started_at'
WHERE node_kind = 'task'
AND metadata->>'status' = 'active'
AND (metadata->>'started_at')::timestamptz < now() - interval '60 minutes'
"#,
)
.execute(pool)
.await
.map_err(|e| format!("release_stale_tasks feilet: {e}"))?;
Ok(result.rows_affected())
}
/// Query nodes by kind, with optional status filter and limit.
pub async fn query_nodes(
pool: &PgPool,
node_kind: Option<&str>,
status: Option<&str>,
limit: i64,
) -> Result<Vec<NodeInfo>, String> {
let rows = sqlx::query_as::<_, NodeInfoRow>(
r#"
SELECT id, node_kind::text, title, visibility::text, metadata, created_at
FROM nodes
WHERE ($1::text IS NULL OR node_kind = $1)
AND ($2::text IS NULL OR metadata->>'status' = $2)
ORDER BY created_at DESC
LIMIT $3
"#,
)
.bind(node_kind)
.bind(status)
.bind(limit)
.fetch_all(pool)
.await
.map_err(|e| format!("query_nodes feilet: {e}"))?;
Ok(rows
.into_iter()
.map(|r| NodeInfo {
id: r.id,
node_kind: r.node_kind,
title: r.title,
visibility: r.visibility,
metadata: r.metadata,
created_at: r.created_at,
})
.collect())
}
/// Query edges for a node (both outgoing and incoming).
pub async fn query_edges(
pool: &PgPool,
node_id: Uuid,
direction: EdgeDirection,
edge_type: Option<&str>,
limit: i64,
) -> Result<Vec<EdgeInfo>, String> {
let rows = match direction {
EdgeDirection::Outgoing => {
sqlx::query_as::<_, EdgeInfoRow>(
r#"
SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata, e.created_at,
n.title as other_title, n.node_kind::text as other_kind
FROM edges e
JOIN nodes n ON n.id = e.target_id
WHERE e.source_id = $1
AND ($2::text IS NULL OR e.edge_type = $2)
ORDER BY e.created_at DESC
LIMIT $3
"#,
)
.bind(node_id)
.bind(edge_type)
.bind(limit)
.fetch_all(pool)
.await
}
EdgeDirection::Incoming => {
sqlx::query_as::<_, EdgeInfoRow>(
r#"
SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata, e.created_at,
n.title as other_title, n.node_kind::text as other_kind
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND ($2::text IS NULL OR e.edge_type = $2)
ORDER BY e.created_at DESC
LIMIT $3
"#,
)
.bind(node_id)
.bind(edge_type)
.bind(limit)
.fetch_all(pool)
.await
}
EdgeDirection::Both => {
sqlx::query_as::<_, EdgeInfoRow>(
r#"
SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata, e.created_at,
CASE WHEN e.source_id = $1 THEN n2.title ELSE n1.title END as other_title,
CASE WHEN e.source_id = $1 THEN n2.node_kind::text ELSE n1.node_kind::text END as other_kind
FROM edges e
JOIN nodes n1 ON n1.id = e.source_id
JOIN nodes n2 ON n2.id = e.target_id
WHERE (e.source_id = $1 OR e.target_id = $1)
AND ($2::text IS NULL OR e.edge_type = $2)
ORDER BY e.created_at DESC
LIMIT $3
"#,
)
.bind(node_id)
.bind(edge_type)
.bind(limit)
.fetch_all(pool)
.await
}
}
.map_err(|e| format!("query_edges feilet: {e}"))?;
Ok(rows
.into_iter()
.map(|r| EdgeInfo {
id: r.id,
source_id: r.source_id,
target_id: r.target_id,
edge_type: r.edge_type,
metadata: r.metadata,
created_at: r.created_at,
other_title: r.other_title,
other_kind: r.other_kind,
})
.collect())
}
/// Read a single node by ID.
pub async fn get_node(pool: &PgPool, node_id: Uuid) -> Result<Option<NodeInfo>, String> {
let row = sqlx::query_as::<_, NodeInfoRow>(
r#"
SELECT id, node_kind::text, title, visibility::text, metadata, created_at
FROM nodes WHERE id = $1
"#,
)
.bind(node_id)
.fetch_optional(pool)
.await
.map_err(|e| format!("get_node feilet: {e}"))?;
Ok(row.map(|r| NodeInfo {
id: r.id,
node_kind: r.node_kind,
title: r.title,
visibility: r.visibility,
metadata: r.metadata,
created_at: r.created_at,
}))
}
// ============================================================================
// Internal helpers
// ============================================================================
/// Find or create a discussion (communication) node for a task/assignment.
async fn find_or_create_discussion(pool: &PgPool, task_id: Uuid) -> Result<Uuid, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
// Look for existing has_discussion edge
let existing = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT target_id FROM edges
WHERE source_id = $1 AND edge_type = 'has_discussion'
LIMIT 1
"#,
)
.bind(task_id)
.fetch_optional(pool)
.await
.map_err(|e| format!("find_discussion: {e}"))?;
if let Some(id) = existing {
return Ok(id);
}
// Create a new communication node
let disc_id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
VALUES ($1, 'communication', 'Diskusjon', 'hidden', '{"type":"discussion"}', $2)
"#,
)
.bind(disc_id)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("create discussion node: {e}"))?;
// Link task → discussion
sqlx::query(
r#"
INSERT INTO edges (source_id, target_id, edge_type, system, created_by)
VALUES ($1, $2, 'has_discussion', true, $3)
"#,
)
.bind(task_id)
.bind(disc_id)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("create discussion edge: {e}"))?;
// Make agent a member of the discussion
sqlx::query(
r#"
INSERT INTO edges (source_id, target_id, edge_type, system, created_by)
VALUES ($1, $2, 'member_of', true, $3)
ON CONFLICT (source_id, target_id, edge_type) DO NOTHING
"#,
)
.bind(agent_id)
.bind(disc_id)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("join discussion: {e}"))?;
Ok(disc_id)
}
// ============================================================================
// Daemon: chat polling and node creation
// ============================================================================
/// Find or create the vaktmester communication node.
///
/// Looks for a communication node titled "Vaktmester" owned by the agent.
/// If none exists, creates one and makes the agent a member.
pub async fn find_or_create_vaktmester_chat(pool: &PgPool) -> Result<Uuid, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
// Look for existing vaktmester chat
let existing = sqlx::query_scalar::<_, Uuid>(
r#"
SELECT n.id FROM nodes n
JOIN edges e ON e.target_id = n.id AND e.source_id = $1 AND e.edge_type = 'member_of'
WHERE n.node_kind = 'communication'
AND n.metadata->>'type' = 'vaktmester'
LIMIT 1
"#,
)
.bind(agent_id)
.fetch_optional(pool)
.await
.map_err(|e| format!("find vaktmester chat: {e}"))?;
if let Some(id) = existing {
return Ok(id);
}
// Create vaktmester communication node
let chat_id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
VALUES ($1, 'communication', 'Vaktmester', 'discoverable',
'{"type":"vaktmester"}', $2)
"#,
)
.bind(chat_id)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("create vaktmester node: {e}"))?;
// Agent is member_of
sqlx::query(
r#"
INSERT INTO edges (source_id, target_id, edge_type, system, created_by)
VALUES ($1, $2, 'member_of', true, $1)
"#,
)
.bind(agent_id)
.bind(chat_id)
.execute(pool)
.await
.map_err(|e| format!("agent join vaktmester: {e}"))?;
tracing::info!(chat_id = %chat_id, "Opprettet vaktmester-chat");
Ok(chat_id)
}
/// A chat message from the vaktmester chat.
#[derive(Debug)]
pub struct ChatMessage {
pub id: Uuid,
pub content: String,
pub created_by: Option<Uuid>,
pub created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct ChatMessageRow {
id: Uuid,
content: Option<String>,
created_by: Option<Uuid>,
created_at: DateTime<Utc>,
}
/// Poll for new messages in a communication node since a given timestamp.
///
/// Returns messages not sent by the agent itself, ordered by time.
pub async fn poll_chat_messages(
pool: &PgPool,
chat_id: Uuid,
since: DateTime<Utc>,
) -> Result<Vec<ChatMessage>, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
let rows = sqlx::query_as::<_, ChatMessageRow>(
r#"
SELECT n.id, n.content, n.created_by, n.created_at
FROM nodes n
JOIN edges e ON e.source_id = n.id AND e.target_id = $1 AND e.edge_type = 'belongs_to'
WHERE n.node_kind = 'message'
AND n.created_at > $2
AND (n.created_by IS NULL OR n.created_by != $3)
ORDER BY n.created_at ASC
"#,
)
.bind(chat_id)
.bind(since)
.bind(agent_id)
.fetch_all(pool)
.await
.map_err(|e| format!("poll_chat_messages: {e}"))?;
Ok(rows
.into_iter()
.filter_map(|r| {
Some(ChatMessage {
id: r.id,
content: r.content?,
created_by: r.created_by,
created_at: r.created_at,
})
})
.collect())
}
/// Write a reply message in a communication node.
pub async fn write_chat_reply(
pool: &PgPool,
chat_id: Uuid,
message: &str,
) -> Result<Uuid, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
let msg_id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, content, visibility, metadata, created_by)
VALUES ($1, 'message', $2, 'hidden', '{}', $3)
"#,
)
.bind(msg_id)
.bind(message)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("write_chat_reply (node): {e}"))?;
sqlx::query(
r#"
INSERT INTO edges (source_id, target_id, edge_type, system, created_by)
VALUES ($1, $2, 'belongs_to', true, $3)
"#,
)
.bind(msg_id)
.bind(chat_id)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("write_chat_reply (edge): {e}"))?;
// Notify frontend via PG NOTIFY
let _ = sqlx::query("SELECT pg_notify('node_changes', $1)")
.bind(serde_json::json!({
"type": "message",
"node_id": msg_id.to_string(),
"communication_id": chat_id.to_string(),
}).to_string())
.execute(pool)
.await;
Ok(msg_id)
}
/// Create a proposal node.
pub async fn create_proposal(
pool: &PgPool,
title: &str,
description: &str,
) -> Result<Uuid, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
let id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'proposal', $2, $3, 'internal',
'{"status":"draft"}', $4)
"#,
)
.bind(id)
.bind(title)
.bind(description)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("create_proposal: {e}"))?;
Ok(id)
}
/// Create a task node.
pub async fn create_task(
pool: &PgPool,
title: &str,
description: &str,
priority: i32,
) -> Result<Uuid, String> {
let agent_id = Uuid::parse_str(AGENT_NODE_ID).unwrap();
let id = Uuid::now_v7();
sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, 'task', $2, $3, 'internal',
jsonb_build_object('status', 'open', 'priority', $4), $5)
"#,
)
.bind(id)
.bind(title)
.bind(description)
.bind(priority)
.bind(agent_id)
.execute(pool)
.await
.map_err(|e| format!("create_task: {e}"))?;
Ok(id)
}
/// Check if the agent is active (kill switch).
pub async fn is_agent_active(pool: &PgPool) -> Result<bool, String> {
let active: Option<bool> = sqlx::query_scalar(
r#"
SELECT is_active FROM agent_identities
WHERE agent_key = 'claude-main'
"#,
)
.fetch_optional(pool)
.await
.map_err(|e| format!("is_agent_active: {e}"))?;
Ok(active.unwrap_or(true))
}
// ============================================================================
// Types
// ============================================================================
#[derive(Debug)]
pub struct TaskInfo {
pub id: Uuid,
pub title: Option<String>,
pub content: Option<String>,
pub metadata: serde_json::Value,
pub created_at: DateTime<Utc>,
}
#[derive(Debug)]
pub struct NodeInfo {
pub id: Uuid,
pub node_kind: String,
pub title: Option<String>,
pub visibility: String,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy)]
pub enum EdgeDirection {
Outgoing,
Incoming,
Both,
}
#[derive(Debug)]
pub struct EdgeInfo {
pub id: Uuid,
pub source_id: Uuid,
pub target_id: Uuid,
pub edge_type: String,
pub metadata: Option<serde_json::Value>,
pub created_at: DateTime<Utc>,
pub other_title: Option<String>,
pub other_kind: Option<String>,
}
// sqlx FromRow types (private)
#[derive(sqlx::FromRow)]
struct TaskRow {
id: Uuid,
title: Option<String>,
content: Option<String>,
metadata: Option<serde_json::Value>,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct NodeInfoRow {
id: Uuid,
node_kind: String,
title: Option<String>,
visibility: String,
metadata: Option<serde_json::Value>,
created_at: DateTime<Utc>,
}
#[derive(sqlx::FromRow)]
struct EdgeInfoRow {
id: Uuid,
source_id: Uuid,
target_id: Uuid,
edge_type: String,
metadata: Option<serde_json::Value>,
created_at: DateTime<Utc>,
other_title: Option<String>,
other_kind: Option<String>,
}