- 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)
689 lines
19 KiB
Rust
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>,
|
|
}
|