8052 lines
330 KiB
Python
8052 lines
330 KiB
Python
"""SQLite-based persistent storage for tasks, collaboration state, and observability."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import inspect
|
|
import uuid
|
|
from dataclasses import asdict, is_dataclass
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
import sqlite3
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from opc.core.models import (
|
|
AgentCompactionRecord,
|
|
AgentMemorySnapshotRecord,
|
|
AgentMessage,
|
|
ApprovalDecision,
|
|
ArtifactRecord,
|
|
CommsSemanticType,
|
|
CommsState,
|
|
CommsTransportKind,
|
|
CostEvent,
|
|
DelegationCell,
|
|
DelegationEvent,
|
|
DelegationRoleSession,
|
|
DelegationRun,
|
|
DelegationWorkItem,
|
|
ExecutionCheckpoint,
|
|
ExternalSession,
|
|
Goal,
|
|
GoalLevel,
|
|
GoalStatus,
|
|
HandoffRecord,
|
|
MeetingRoom,
|
|
MeetingStatus,
|
|
MessageUrgency,
|
|
MessageStatus,
|
|
OrgAgent,
|
|
OrgSnapshot,
|
|
Organization,
|
|
OPCEvent,
|
|
ReorgEventKind,
|
|
ReorgEventRecord,
|
|
ReorgProposal,
|
|
ReorgProposalStatus,
|
|
ReorgRiskLevel,
|
|
ReorgScope,
|
|
RoleMemoryRecord,
|
|
RoleRuntimeSession,
|
|
SeatState,
|
|
SessionCompactionRecord,
|
|
SessionMemorySnapshotRecord,
|
|
SessionLinkRecord,
|
|
SessionMessageRecord,
|
|
SessionPartRecord,
|
|
SessionRecord,
|
|
TeamInstance,
|
|
Task,
|
|
TaskStatus,
|
|
WorkItemDecisionRecord,
|
|
normalize_role_runtime_status,
|
|
)
|
|
from opc.core.models import Phase
|
|
from opc.core.transcript_visibility import (
|
|
normalize_transcript_detail_level,
|
|
transcript_visibility_sql,
|
|
)
|
|
from opc.layer2_organization.phase import (
|
|
DONE_PHASES,
|
|
IN_PROGRESS_PHASES,
|
|
IN_REVIEW_PHASES,
|
|
InvalidPhaseTransition,
|
|
TODO_PHASES,
|
|
coerce_phase,
|
|
is_stale_claim_releasable,
|
|
is_terminal,
|
|
kanban_column,
|
|
on_phase_transition,
|
|
validate_transition,
|
|
)
|
|
from opc.layer2_organization.work_item_identity import (
|
|
WORK_ITEM_PROJECTION_ID_KEY,
|
|
WORK_ITEM_TURN_TYPE_KEY,
|
|
migrate_work_item_projection_metadata,
|
|
projection_id_for_work_item,
|
|
)
|
|
from opc.layer2_organization.work_item_links import (
|
|
linked_work_item_id_for_task,
|
|
set_linked_work_item_id,
|
|
)
|
|
from opc.layer2_organization.work_item_runtime import (
|
|
is_work_item_runtime_metadata,
|
|
migrate_work_item_runtime_metadata,
|
|
)
|
|
from opc.layer2_organization.work_item_runtime_invariants import (
|
|
validate_work_item_runtime_projection,
|
|
)
|
|
|
|
|
|
def _json_dumps(value: Any) -> str:
|
|
def _default(obj: Any) -> Any:
|
|
if is_dataclass(obj):
|
|
return asdict(obj)
|
|
if isinstance(obj, datetime):
|
|
return obj.isoformat()
|
|
if isinstance(obj, Enum):
|
|
return obj.value
|
|
raise TypeError(f"Object of type {obj.__class__.__name__} is not JSON serializable")
|
|
|
|
return json.dumps(value, ensure_ascii=False, default=_default)
|
|
|
|
|
|
def _json_loads(value: str | None, default: Any) -> Any:
|
|
if not value:
|
|
return default
|
|
try:
|
|
return json.loads(value)
|
|
except (json.JSONDecodeError, TypeError):
|
|
# JSON columns can hold corrupt/partial values after a crash or manual edit.
|
|
# Raising here would abort store.initialize() (e.g. via _sweep_stale_claims) and
|
|
# prevent the store from ever opening, so fall back to the default instead.
|
|
return default
|
|
|
|
|
|
class _SQLiteCursorAdapter:
|
|
def __init__(self, cursor: sqlite3.Cursor) -> None:
|
|
self._cursor = cursor
|
|
|
|
async def __aenter__(self) -> "_SQLiteCursorAdapter":
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
|
self._cursor.close()
|
|
return False
|
|
|
|
async def fetchone(self) -> Any:
|
|
return self._cursor.fetchone()
|
|
|
|
async def fetchall(self) -> list[Any]:
|
|
return self._cursor.fetchall()
|
|
|
|
@property
|
|
def description(self) -> Any:
|
|
return self._cursor.description
|
|
|
|
@property
|
|
def rowcount(self) -> int:
|
|
return self._cursor.rowcount
|
|
|
|
|
|
class _SQLiteExecuteResult:
|
|
def __init__(self, connection: sqlite3.Connection, sql: str, params: tuple[Any, ...] | list[Any] = ()) -> None:
|
|
self._connection = connection
|
|
self._sql = sql
|
|
self._params = tuple(params)
|
|
self._cursor: sqlite3.Cursor | None = None
|
|
|
|
def __await__(self):
|
|
async def _run() -> _SQLiteCursorAdapter:
|
|
cursor = self._connection.cursor()
|
|
cursor.execute(self._sql, self._params)
|
|
return _SQLiteCursorAdapter(cursor)
|
|
|
|
return _run().__await__()
|
|
|
|
async def __aenter__(self) -> _SQLiteCursorAdapter:
|
|
cursor = self._connection.cursor()
|
|
cursor.execute(self._sql, self._params)
|
|
self._cursor = cursor
|
|
return _SQLiteCursorAdapter(cursor)
|
|
|
|
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
|
if self._cursor is not None:
|
|
self._cursor.close()
|
|
return False
|
|
|
|
|
|
class _SQLiteConnectionAdapter:
|
|
def __init__(self, db_path: str) -> None:
|
|
self._conn = sqlite3.connect(db_path, check_same_thread=False, timeout=30.0)
|
|
self._conn.execute("PRAGMA foreign_keys=ON")
|
|
self._conn.execute("PRAGMA busy_timeout=30000")
|
|
|
|
def execute(self, sql: str, parameters: tuple[Any, ...] | list[Any] = ()) -> _SQLiteExecuteResult:
|
|
return _SQLiteExecuteResult(self._conn, sql, parameters)
|
|
|
|
async def executescript(self, script: str) -> None:
|
|
self._conn.executescript(script)
|
|
|
|
async def commit(self) -> None:
|
|
self._conn.commit()
|
|
|
|
async def rollback(self) -> None:
|
|
self._conn.rollback()
|
|
|
|
async def close(self) -> None:
|
|
self._conn.close()
|
|
|
|
|
|
class OPCStore:
|
|
"""Async SQLite store for OPC data (WAL mode for concurrency)."""
|
|
|
|
def __init__(self, db_path: str | Path) -> None:
|
|
self.db_path = str(db_path)
|
|
self.project_id = self._infer_project_id_from_db_path(db_path)
|
|
self._db: _SQLiteConnectionAdapter | None = None
|
|
# Fix 5 PR3 feature flag mirrored onto the store so phase hooks
|
|
# (which receive ``store`` but not the top-level OPCConfig) can
|
|
# consult it cheaply. Engine sets this during init from
|
|
# ``OPCConfig.org.role_serial_queue_enabled``. Tests can flip it
|
|
# directly to exercise both branches.
|
|
self.role_serial_queue_enabled: bool = True
|
|
|
|
@staticmethod
|
|
def _infer_project_id_from_db_path(db_path: str | Path) -> str | None:
|
|
path = Path(db_path)
|
|
try:
|
|
if path.name == "tasks.db" and path.parent.parent.name == "projects":
|
|
return path.parent.name or None
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
def _assert_project_write_scope(
|
|
self,
|
|
value: str | None,
|
|
*,
|
|
operation: str,
|
|
entity: str,
|
|
) -> None:
|
|
store_project_id = str(self.project_id or "").strip()
|
|
entity_project_id = str(value or "").strip()
|
|
if not store_project_id or not entity_project_id:
|
|
return
|
|
if entity_project_id != store_project_id:
|
|
raise RuntimeError(
|
|
f"{operation} rejected cross-project {entity} write: "
|
|
f"store_project={store_project_id!r} entity_project={entity_project_id!r} "
|
|
f"db_path={self.db_path!r}"
|
|
)
|
|
|
|
@property
|
|
def is_ready(self) -> bool:
|
|
"""Whether the SQLite connection has been initialized."""
|
|
return self._db is not None
|
|
|
|
def _require_db(self) -> aiosqlite.Connection:
|
|
"""Return the active DB connection or raise a descriptive error."""
|
|
if self._db is None:
|
|
raise RuntimeError(
|
|
f"OPCStore database not initialized (db_path={self.db_path!r}). "
|
|
"Call await store.initialize() before using store methods."
|
|
)
|
|
return self._db
|
|
|
|
async def ensure_ready(self) -> None:
|
|
"""Initialize the store if not already connected."""
|
|
if self._db is None:
|
|
await self.initialize()
|
|
|
|
async def initialize(self, *, run_startup_maintenance: bool = True) -> None:
|
|
"""Open the store.
|
|
|
|
``run_startup_maintenance`` is reserved for the owning OpenOPC runtime
|
|
process. Lightweight collaboration clients such as ``opc-collab`` open
|
|
the already-initialized project DB to service one tool call; they must
|
|
not run schema migrations or stale-claim sweeps as a side effect of a
|
|
read-only command.
|
|
"""
|
|
if run_startup_maintenance:
|
|
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
elif not Path(self.db_path).exists():
|
|
raise FileNotFoundError(f"OPCStore database does not exist: {self.db_path}")
|
|
self._db = _SQLiteConnectionAdapter(self.db_path)
|
|
if not run_startup_maintenance:
|
|
return
|
|
await self._db.execute("PRAGMA journal_mode=WAL")
|
|
await self._db.execute("PRAGMA foreign_keys=ON")
|
|
await self._create_tables()
|
|
await self._ensure_schema()
|
|
await self._sweep_stale_claims()
|
|
await self._migrate_drop_runtime_topology_version_columns()
|
|
# Fix 5 PR2: merge every (run_id, role_id) group into a single
|
|
# canonical row with PK = ``role-runtime::{run_id}::{role_id}``.
|
|
# Combines inbox, memory_slices, list fields, and picks the most
|
|
# recent state for scalar fields — so historical data survives
|
|
# the collapse. Subsumes the old Fix-2 duplicate-collapse and
|
|
# _no_team-sentinel migrations (the 3-segment canonical form
|
|
# eliminates both problems at the source).
|
|
await self._migrate_role_sessions_merge_by_role()
|
|
await self._migrate_work_item_runtime_metadata()
|
|
await self._migrate_work_item_projection_metadata()
|
|
await self._purge_cross_project_runtime_rows()
|
|
await self._validate_work_item_runtime_links()
|
|
await self._ensure_indexes()
|
|
|
|
async def _table_columns(self, table: str) -> list[str]:
|
|
assert self._db is not None
|
|
if not await self._table_exists(table):
|
|
return []
|
|
async with self._db.execute(f"PRAGMA table_info({table})") as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [str(row[1]) for row in rows]
|
|
|
|
async def _purge_cross_project_runtime_rows(self) -> dict[str, int]:
|
|
"""Remove rows that were written into the wrong project-scoped DB.
|
|
|
|
Project databases under ``.opc/projects/<project_id>/tasks.db`` are
|
|
single-project stores. A row whose explicit ``project_id`` names a
|
|
different project is corruption from a prior mutable-store race; keeping
|
|
it can break canonical WorkItem validation and project switching.
|
|
"""
|
|
if self._db is None:
|
|
return {}
|
|
project_id = str(self.project_id or "").strip()
|
|
if not project_id:
|
|
return {}
|
|
deleted: dict[str, int] = {}
|
|
for table in ("external_sessions", "runtime_sessions", "tasks"):
|
|
if not await self._table_exists(table):
|
|
continue
|
|
columns = set(await self._table_columns(table))
|
|
if "project_id" not in columns:
|
|
continue
|
|
cursor = await self._db.execute(
|
|
f"""DELETE FROM {table}
|
|
WHERE project_id IS NOT NULL
|
|
AND TRIM(project_id) != ''
|
|
AND project_id != ?""",
|
|
(project_id,),
|
|
)
|
|
count = int(getattr(cursor, "rowcount", 0) or 0)
|
|
if count:
|
|
deleted[table] = count
|
|
if deleted:
|
|
await self._db.commit()
|
|
logger.warning(
|
|
"Purged cross-project runtime rows from project store: "
|
|
f"project_id={project_id} db_path={self.db_path} deleted={deleted}"
|
|
)
|
|
return deleted
|
|
|
|
async def _migrate_drop_runtime_topology_version_columns(self) -> None:
|
|
# runtime_topology_version is no longer tracked: reorg never increments it,
|
|
# snapshots never read it, and gates aren't keyed off it. Drop the
|
|
# vestigial columns from legacy DBs (idempotent).
|
|
assert self._db
|
|
for table, column in (
|
|
("reorg_proposals", "old_runtime_topology_version"),
|
|
("reorg_proposals", "new_runtime_topology_version"),
|
|
("org_snapshots", "runtime_topology_version"),
|
|
):
|
|
async with self._db.execute(f"PRAGMA table_info({table})") as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = {row[1] for row in rows}
|
|
if column in cols:
|
|
try:
|
|
await self._db.execute(f"ALTER TABLE {table} DROP COLUMN {column}")
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to drop {table}.{column}: {exc}")
|
|
await self._db.commit()
|
|
|
|
async def _migrate_work_item_runtime_metadata(self) -> dict[str, int]:
|
|
"""Normalize canonical company work-item runtime metadata."""
|
|
if self._db is None:
|
|
return {}
|
|
|
|
targets = (
|
|
("tasks", "id"),
|
|
("delegation_runs", "run_id"),
|
|
("delegation_cells", "cell_id"),
|
|
("delegation_work_items", "work_item_id"),
|
|
("team_instances", "team_instance_id"),
|
|
("seat_states", "seat_state_id"),
|
|
("role_runtime_sessions", "role_session_id"),
|
|
("delegation_role_sessions", "role_session_id"),
|
|
)
|
|
stats: dict[str, int] = {}
|
|
for table, key_column in targets:
|
|
stats[table] = await self._migrate_work_item_runtime_metadata_table(
|
|
table=table,
|
|
key_column=key_column,
|
|
)
|
|
changed = sum(stats.values())
|
|
if changed:
|
|
await self._db.commit()
|
|
logger.info(f"work-item runtime metadata migration: updated {changed} rows")
|
|
return stats
|
|
|
|
async def _migrate_work_item_runtime_metadata_table(
|
|
self,
|
|
*,
|
|
table: str,
|
|
key_column: str,
|
|
) -> int:
|
|
assert self._db is not None
|
|
if not await self._table_exists(table):
|
|
return 0
|
|
async with self._db.execute(f"PRAGMA table_info({table})") as cursor:
|
|
columns = {row[1] for row in await cursor.fetchall()}
|
|
if key_column not in columns or "metadata" not in columns:
|
|
return 0
|
|
|
|
async with self._db.execute(
|
|
f"""SELECT {key_column}, metadata
|
|
FROM {table}
|
|
WHERE metadata LIKE '%work_item_runtime%'"""
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
|
|
updated = 0
|
|
for row_id, metadata_json in rows:
|
|
try:
|
|
metadata = _json_loads(metadata_json, {})
|
|
except Exception as exc:
|
|
logger.warning(
|
|
f"work-item runtime metadata migration: skipping invalid "
|
|
f"{table}.{key_column}={row_id}: {exc}"
|
|
)
|
|
continue
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
migrated, changed = migrate_work_item_runtime_metadata(metadata)
|
|
if not changed:
|
|
continue
|
|
await self._db.execute(
|
|
f"UPDATE {table} SET metadata=? WHERE {key_column}=?",
|
|
(_json_dumps(migrated), row_id),
|
|
)
|
|
updated += 1
|
|
return updated
|
|
|
|
async def _migrate_work_item_projection_metadata(self) -> dict[str, int]:
|
|
"""Normalize canonical work-item projection identity metadata."""
|
|
if self._db is None:
|
|
return {}
|
|
stats = {
|
|
"tasks": await self._migrate_task_projection_metadata(),
|
|
"delegation_work_items": await self._migrate_delegation_work_item_projection_metadata(),
|
|
}
|
|
changed = sum(stats.values())
|
|
if changed:
|
|
await self._db.commit()
|
|
logger.info(f"work-item projection metadata migration: updated {changed} rows")
|
|
return stats
|
|
|
|
async def _migrate_task_projection_metadata(self) -> int:
|
|
assert self._db is not None
|
|
if not await self._table_exists("tasks"):
|
|
return 0
|
|
async with self._db.execute("PRAGMA table_info(tasks)") as cursor:
|
|
columns = {row[1] for row in await cursor.fetchall()}
|
|
if "id" not in columns or "metadata" not in columns:
|
|
return 0
|
|
async with self._db.execute(
|
|
"""SELECT id, metadata
|
|
FROM tasks
|
|
WHERE metadata LIKE '%work_item_projection_id%'
|
|
OR metadata LIKE '%work_item_turn_type%'"""
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
updated = 0
|
|
for task_id, metadata_json in rows:
|
|
try:
|
|
metadata = _json_loads(metadata_json, {})
|
|
except Exception as exc:
|
|
logger.warning(
|
|
f"work-item projection metadata migration: skipping invalid task {task_id}: {exc}"
|
|
)
|
|
continue
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
migrated, changed = migrate_work_item_projection_metadata(metadata)
|
|
if not changed:
|
|
continue
|
|
await self._db.execute(
|
|
"UPDATE tasks SET metadata=? WHERE id=?",
|
|
(_json_dumps(migrated), task_id),
|
|
)
|
|
updated += 1
|
|
return updated
|
|
|
|
async def _migrate_delegation_work_item_projection_metadata(self) -> int:
|
|
assert self._db is not None
|
|
if not await self._table_exists("delegation_work_items"):
|
|
return 0
|
|
async with self._db.execute("PRAGMA table_info(delegation_work_items)") as cursor:
|
|
columns = {row[1] for row in await cursor.fetchall()}
|
|
required = {"work_item_id", "projection_id", "kind", "metadata"}
|
|
if not required.issubset(columns):
|
|
return 0
|
|
async with self._db.execute(
|
|
"""SELECT work_item_id, projection_id, kind, metadata
|
|
FROM delegation_work_items
|
|
WHERE COALESCE(projection_id, '') != ''
|
|
OR COALESCE(kind, '') != ''
|
|
OR metadata LIKE '%work_item_projection_id%'
|
|
OR metadata LIKE '%work_item_turn_type%'"""
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
updated = 0
|
|
for work_item_id, projection_id, kind, metadata_json in rows:
|
|
try:
|
|
metadata = _json_loads(metadata_json, {})
|
|
except Exception as exc:
|
|
logger.warning(
|
|
f"work-item projection metadata migration: skipping invalid work item {work_item_id}: {exc}"
|
|
)
|
|
continue
|
|
if not isinstance(metadata, dict):
|
|
continue
|
|
migrated, changed = migrate_work_item_projection_metadata(
|
|
metadata,
|
|
projection_id_fallback=str(projection_id or work_item_id or "").strip(),
|
|
turn_type_fallback=str(kind or "execute").strip(),
|
|
)
|
|
if not changed:
|
|
continue
|
|
await self._db.execute(
|
|
"UPDATE delegation_work_items SET metadata=? WHERE work_item_id=?",
|
|
(_json_dumps(migrated), work_item_id),
|
|
)
|
|
updated += 1
|
|
return updated
|
|
|
|
async def _task_exists(self, task_id: str) -> bool:
|
|
assert self._db is not None
|
|
tid = str(task_id or "").strip()
|
|
if not tid:
|
|
return False
|
|
async with self._db.execute("SELECT 1 FROM tasks WHERE id=? LIMIT 1", (tid,)) as cursor:
|
|
return await cursor.fetchone() is not None
|
|
|
|
async def _work_item_exists(self, work_item_id: str) -> bool:
|
|
assert self._db is not None
|
|
wid = str(work_item_id or "").strip()
|
|
if not wid:
|
|
return False
|
|
async with self._db.execute(
|
|
"SELECT 1 FROM delegation_work_items WHERE work_item_id=? LIMIT 1",
|
|
(wid,),
|
|
) as cursor:
|
|
return await cursor.fetchone() is not None
|
|
|
|
async def _runtime_link_task_id_for_work_item(self, work_item_id: str) -> str:
|
|
assert self._db is not None
|
|
wid = str(work_item_id or "").strip()
|
|
if not wid:
|
|
return ""
|
|
async with self._db.execute(
|
|
"SELECT runtime_task_id FROM work_item_runtime_links WHERE work_item_id=?",
|
|
(wid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return str(row[0] or "").strip() if row else ""
|
|
|
|
async def _runtime_link_work_item_id_for_task(self, task_id: str) -> str:
|
|
assert self._db is not None
|
|
tid = str(task_id or "").strip()
|
|
if not tid:
|
|
return ""
|
|
async with self._db.execute(
|
|
"SELECT work_item_id FROM work_item_runtime_links WHERE runtime_task_id=?",
|
|
(tid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return str(row[0] or "").strip() if row else ""
|
|
|
|
async def _get_task_unhydrated(self, task_id: str) -> Task | None:
|
|
assert self._db is not None
|
|
tid = str(task_id or "").strip()
|
|
if not tid:
|
|
return None
|
|
async with self._db.execute("SELECT * FROM tasks WHERE id = ?", (tid,)) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_task(row, cursor.description)
|
|
|
|
async def _task_status_for_id(self, task_id: str) -> str:
|
|
assert self._db is not None
|
|
tid = str(task_id or "").strip()
|
|
if not tid:
|
|
return ""
|
|
async with self._db.execute(
|
|
"SELECT status FROM tasks WHERE id=? LIMIT 1",
|
|
(tid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return str(row[0] or "").strip().lower() if row else ""
|
|
|
|
@staticmethod
|
|
def _terminal_task_statuses() -> set[str]:
|
|
return {
|
|
TaskStatus.DONE.value,
|
|
TaskStatus.FAILED.value,
|
|
TaskStatus.CANCELLED.value,
|
|
}
|
|
|
|
async def _runtime_task_link_is_replaceable(self, task_id: str) -> bool:
|
|
status = await self._task_status_for_id(task_id)
|
|
return not status or status in self._terminal_task_statuses()
|
|
|
|
@staticmethod
|
|
def _runtime_task_matches_work_item(
|
|
task: Task,
|
|
item: DelegationWorkItem,
|
|
*,
|
|
preferred_task_id: str = "",
|
|
) -> bool:
|
|
metadata = dict(getattr(task, "metadata", {}) or {})
|
|
wid = str(getattr(item, "work_item_id", "") or "").strip()
|
|
if not wid:
|
|
return False
|
|
|
|
preferred = str(preferred_task_id or "").strip()
|
|
if not is_work_item_runtime_metadata(metadata) and str(getattr(task, "id", "") or "").strip() != preferred:
|
|
return False
|
|
|
|
projection_id = projection_id_for_work_item(item)
|
|
task_projection_id = str(metadata.get(WORK_ITEM_PROJECTION_ID_KEY, "") or "").strip()
|
|
if projection_id and task_projection_id != projection_id:
|
|
return False
|
|
|
|
item_run_id = str(getattr(item, "run_id", "") or "").strip()
|
|
task_run_id = str(metadata.get("delegation_run_id", "") or "").strip()
|
|
if item_run_id and task_run_id != item_run_id:
|
|
return False
|
|
|
|
item_role_id = str(getattr(item, "role_id", "") or "").strip()
|
|
task_role_id = str(
|
|
metadata.get("work_item_role_id", "")
|
|
or metadata.get("role_id", "")
|
|
or getattr(task, "assigned_to", "")
|
|
or ""
|
|
).strip()
|
|
if item_role_id and task_role_id and task_role_id != item_role_id:
|
|
return False
|
|
|
|
item_seat_id = str(
|
|
getattr(item, "seat_id", "")
|
|
or dict(getattr(item, "metadata", {}) or {}).get("seat_id", "")
|
|
or ""
|
|
).strip()
|
|
task_seat_id = str(
|
|
metadata.get("delegation_seat_id", "")
|
|
or metadata.get("seat_id", "")
|
|
or ""
|
|
).strip()
|
|
if item_seat_id and task_seat_id and task_seat_id != item_seat_id:
|
|
return False
|
|
|
|
return True
|
|
|
|
async def _write_work_item_runtime_link(
|
|
self,
|
|
work_item_id: str,
|
|
runtime_task_id: str,
|
|
*,
|
|
link_kind: str = "primary",
|
|
commit: bool = True,
|
|
) -> bool:
|
|
assert self._db is not None
|
|
wid = str(work_item_id or "").strip()
|
|
tid = str(runtime_task_id or "").strip()
|
|
if not wid or not tid:
|
|
return False
|
|
now = datetime.now().isoformat()
|
|
# runtime_task_id is UNIQUE. If stale legacy data points this Task at
|
|
# another WorkItem, the explicit WorkItem link wins.
|
|
await self._db.execute(
|
|
"DELETE FROM work_item_runtime_links WHERE runtime_task_id=? AND work_item_id != ?",
|
|
(tid, wid),
|
|
)
|
|
await self._db.execute(
|
|
"""INSERT INTO work_item_runtime_links
|
|
(work_item_id, runtime_task_id, link_kind, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(work_item_id) DO UPDATE SET
|
|
runtime_task_id=excluded.runtime_task_id,
|
|
link_kind=excluded.link_kind,
|
|
updated_at=excluded.updated_at""",
|
|
(wid, tid, str(link_kind or "primary").strip() or "primary", now, now),
|
|
)
|
|
if commit:
|
|
await self._db.commit()
|
|
return True
|
|
|
|
async def _validate_work_item_runtime_links(self) -> dict[str, int]:
|
|
"""Validate canonical WorkItem/Task links.
|
|
|
|
WorkItem is the business source of truth; runtime Tasks are connected
|
|
only through ``work_item_runtime_links``.
|
|
"""
|
|
if self._db is None:
|
|
return {}
|
|
required_tables = ("tasks", "delegation_work_items", "work_item_runtime_links")
|
|
for table in required_tables:
|
|
if not await self._table_exists(table):
|
|
return {"existing": 0, "missing": 0}
|
|
|
|
stats = {"existing": 0, "missing": 0}
|
|
diagnostics: list[str] = []
|
|
linked_task_ids: set[str] = set()
|
|
|
|
async with self._db.execute(
|
|
"SELECT work_item_id, runtime_task_id FROM work_item_runtime_links"
|
|
) as cursor:
|
|
for work_item_id, task_id in await cursor.fetchall():
|
|
wid = str(work_item_id or "").strip()
|
|
tid = str(task_id or "").strip()
|
|
if not wid or not tid:
|
|
continue
|
|
stats["existing"] += 1
|
|
linked_task_ids.add(tid)
|
|
if not await self._work_item_exists(wid):
|
|
diagnostics.append(f"runtime link points to missing WorkItem: work_item={wid} task={tid}")
|
|
if not await self._task_exists(tid):
|
|
diagnostics.append(f"runtime link points to missing Task: work_item={wid} task={tid}")
|
|
|
|
async with self._db.execute(
|
|
"""SELECT id, metadata
|
|
FROM tasks
|
|
WHERE metadata LIKE '%work_item_runtime%'
|
|
AND metadata LIKE '%work_item_projection_id%'"""
|
|
) as cursor:
|
|
projection_rows = await cursor.fetchall()
|
|
for task_id, metadata_json in projection_rows:
|
|
tid = str(task_id or "").strip()
|
|
try:
|
|
metadata = _json_loads(metadata_json, {})
|
|
except Exception as exc:
|
|
diagnostics.append(f"invalid company runtime projection metadata: task={tid} error={exc}")
|
|
continue
|
|
if not isinstance(metadata, dict) or not is_work_item_runtime_metadata(metadata):
|
|
continue
|
|
projection_id = str(metadata.get(WORK_ITEM_PROJECTION_ID_KEY, "") or "").strip()
|
|
if projection_id and tid not in linked_task_ids:
|
|
stats["missing"] += 1
|
|
diagnostics.append(
|
|
"company runtime projection missing canonical link: "
|
|
f"task={tid} projection={projection_id}"
|
|
)
|
|
|
|
if diagnostics:
|
|
sample = "; ".join(diagnostics[:12])
|
|
suffix = "" if len(diagnostics) <= 12 else f"; ... {len(diagnostics) - 12} more"
|
|
raise RuntimeError(
|
|
"canonical WorkItem runtime link validation failed "
|
|
f"(db_path={self.db_path}): {sample}{suffix}"
|
|
)
|
|
|
|
return stats
|
|
|
|
async def _create_tables(self) -> None:
|
|
assert self._db
|
|
await self._db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
parent_session_id TEXT,
|
|
title TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
assigned_to TEXT DEFAULT '',
|
|
status TEXT DEFAULT 'pending',
|
|
priority INTEGER DEFAULT 5,
|
|
dependencies TEXT DEFAULT '[]',
|
|
execution_lock INTEGER DEFAULT 0,
|
|
context_snapshot TEXT DEFAULT '{}',
|
|
assigned_external_agent TEXT,
|
|
created_at TEXT NOT NULL,
|
|
deadline TEXT,
|
|
result TEXT,
|
|
parent_id TEXT,
|
|
project_id TEXT DEFAULT 'default',
|
|
tags TEXT DEFAULT '[]',
|
|
comments TEXT DEFAULT '[]',
|
|
retry_count INTEGER DEFAULT 0,
|
|
max_retries INTEGER DEFAULT 3,
|
|
metadata TEXT DEFAULT '{}',
|
|
org_id TEXT,
|
|
goal_id TEXT,
|
|
checkout_run_id TEXT,
|
|
execution_locked_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS agent_messages (
|
|
msg_id TEXT PRIMARY KEY,
|
|
msg_type TEXT NOT NULL,
|
|
from_agent TEXT NOT NULL,
|
|
to_agents TEXT NOT NULL,
|
|
subject TEXT DEFAULT '',
|
|
body TEXT DEFAULT '',
|
|
context_ref TEXT,
|
|
urgency TEXT DEFAULT 'normal',
|
|
reply_needed INTEGER DEFAULT 0,
|
|
requires_ack INTEGER DEFAULT 0,
|
|
timeout_action TEXT,
|
|
reply_to_msg_id TEXT,
|
|
task_id TEXT,
|
|
status TEXT DEFAULT 'sent',
|
|
timestamp TEXT NOT NULL,
|
|
processed_at TEXT,
|
|
metadata TEXT DEFAULT '{}'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS meetings (
|
|
room_id TEXT PRIMARY KEY,
|
|
task_id TEXT,
|
|
topic TEXT NOT NULL,
|
|
participants TEXT NOT NULL,
|
|
shared_context TEXT DEFAULT '',
|
|
agenda TEXT DEFAULT '[]',
|
|
max_rounds INTEGER DEFAULT 5,
|
|
decision_owner TEXT DEFAULT 'coordinator',
|
|
status TEXT DEFAULT 'open',
|
|
decision_method TEXT DEFAULT '',
|
|
current_round INTEGER DEFAULT 0,
|
|
pending_participants TEXT DEFAULT '[]',
|
|
consensus TEXT DEFAULT '{}',
|
|
outcome TEXT,
|
|
transcript TEXT DEFAULT '[]',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
last_activity_at TEXT NOT NULL,
|
|
deadline_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS work_item_decisions (
|
|
decision_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
task_id TEXT,
|
|
role_id TEXT DEFAULT '',
|
|
projection_id TEXT DEFAULT '',
|
|
category TEXT DEFAULT 'general',
|
|
summary TEXT DEFAULT '',
|
|
details TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS artifact_records (
|
|
artifact_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
task_id TEXT,
|
|
projection_id TEXT DEFAULT '',
|
|
role_id TEXT DEFAULT '',
|
|
name TEXT DEFAULT '',
|
|
artifact_type TEXT DEFAULT 'generic',
|
|
location TEXT DEFAULT '',
|
|
status TEXT DEFAULT 'active',
|
|
details TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS role_memory (
|
|
memory_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
role_id TEXT NOT NULL,
|
|
scope TEXT DEFAULT 'project',
|
|
summary TEXT DEFAULT '',
|
|
details TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS handoff_records (
|
|
handoff_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT,
|
|
task_id TEXT,
|
|
from_role TEXT DEFAULT '',
|
|
to_role TEXT DEFAULT '',
|
|
source_projection_id TEXT DEFAULT '',
|
|
target_projection_id TEXT DEFAULT '',
|
|
summary TEXT DEFAULT '',
|
|
payload TEXT DEFAULT '{}',
|
|
requires_ack INTEGER DEFAULT 0,
|
|
status TEXT DEFAULT 'sent',
|
|
received_at TEXT,
|
|
acked_at TEXT,
|
|
accepted_at TEXT,
|
|
rejected_at TEXT,
|
|
response_summary TEXT DEFAULT '',
|
|
ack_message_id TEXT,
|
|
response_message_id TEXT,
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS delegation_runs (
|
|
run_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT NOT NULL,
|
|
company_profile TEXT DEFAULT 'corporate',
|
|
execution_model TEXT DEFAULT 'recursive_delegation',
|
|
final_decider_role_id TEXT DEFAULT '',
|
|
top_level_role_ids TEXT DEFAULT '[]',
|
|
status TEXT DEFAULT 'pending',
|
|
lifecycle_status TEXT DEFAULT 'active',
|
|
current_revision INTEGER DEFAULT 1,
|
|
latest_deliverable_summary TEXT DEFAULT '',
|
|
recovery_pointer TEXT DEFAULT '{}',
|
|
project_dossier TEXT DEFAULT '{}',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS delegation_cells (
|
|
cell_id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL,
|
|
manager_role_id TEXT DEFAULT '',
|
|
member_role_ids TEXT DEFAULT '[]',
|
|
status TEXT DEFAULT 'idle',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS delegation_work_items (
|
|
work_item_id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL,
|
|
cell_id TEXT NOT NULL,
|
|
team_instance_id TEXT DEFAULT '',
|
|
team_id TEXT DEFAULT '',
|
|
role_id TEXT DEFAULT '',
|
|
seat_id TEXT DEFAULT '',
|
|
seat_state_id TEXT DEFAULT '',
|
|
role_runtime_session_id TEXT DEFAULT '',
|
|
parent_work_item_id TEXT,
|
|
source_role_id TEXT,
|
|
source_seat_id TEXT,
|
|
title TEXT DEFAULT '',
|
|
summary TEXT DEFAULT '',
|
|
kind TEXT DEFAULT 'execute',
|
|
projection_id TEXT DEFAULT '',
|
|
phase TEXT NOT NULL DEFAULT 'ready',
|
|
batch_id TEXT DEFAULT '',
|
|
batch_index INTEGER DEFAULT 0,
|
|
deliverable_summary TEXT DEFAULT '',
|
|
blocked_reason TEXT DEFAULT '',
|
|
handoff_status TEXT DEFAULT 'pending',
|
|
continuation_source TEXT DEFAULT '',
|
|
manager_role_id TEXT DEFAULT '',
|
|
manager_seat_id TEXT DEFAULT '',
|
|
claimed_by_role_runtime_session_id TEXT DEFAULT '',
|
|
claimed_by_seat_id TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS work_item_runtime_links (
|
|
work_item_id TEXT PRIMARY KEY,
|
|
runtime_task_id TEXT NOT NULL UNIQUE,
|
|
link_kind TEXT DEFAULT 'primary',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY(work_item_id) REFERENCES delegation_work_items(work_item_id) ON DELETE CASCADE,
|
|
FOREIGN KEY(runtime_task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS delegation_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL,
|
|
work_item_id TEXT,
|
|
cell_id TEXT,
|
|
role_id TEXT,
|
|
event_type TEXT NOT NULL,
|
|
payload TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS delegation_role_sessions (
|
|
role_session_id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL,
|
|
project_id TEXT DEFAULT 'default',
|
|
team_instance_id TEXT DEFAULT '',
|
|
team_id TEXT DEFAULT '',
|
|
role_id TEXT NOT NULL,
|
|
seat_id TEXT DEFAULT '',
|
|
seat_state_id TEXT DEFAULT '',
|
|
employee_id TEXT DEFAULT '',
|
|
focused_work_item_id TEXT DEFAULT '',
|
|
background_work_item_ids TEXT DEFAULT '[]',
|
|
manager_role_ids TEXT DEFAULT '[]',
|
|
manager_seat_ids TEXT DEFAULT '[]',
|
|
seat_ids TEXT DEFAULT '[]',
|
|
adapter_session_state TEXT DEFAULT '{}',
|
|
inbox_state TEXT DEFAULT '{}',
|
|
memory_slices_by_work_item TEXT DEFAULT '{}',
|
|
resume_state TEXT DEFAULT '{}',
|
|
current_work_item TEXT DEFAULT '{}',
|
|
latest_notification TEXT DEFAULT '{}',
|
|
manager_digest TEXT DEFAULT '{}',
|
|
status TEXT DEFAULT 'idle',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS team_instances (
|
|
team_instance_id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL,
|
|
project_id TEXT DEFAULT 'default',
|
|
team_id TEXT NOT NULL,
|
|
session_id TEXT DEFAULT '',
|
|
status TEXT DEFAULT 'pending',
|
|
seat_ids TEXT DEFAULT '[]',
|
|
role_ids TEXT DEFAULT '[]',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS seat_states (
|
|
seat_state_id TEXT PRIMARY KEY,
|
|
team_instance_id TEXT NOT NULL,
|
|
run_id TEXT NOT NULL,
|
|
project_id TEXT DEFAULT 'default',
|
|
team_id TEXT NOT NULL,
|
|
seat_id TEXT NOT NULL,
|
|
role_id TEXT DEFAULT '',
|
|
employee_id TEXT DEFAULT '',
|
|
member_session_id TEXT DEFAULT '',
|
|
role_runtime_session_id TEXT DEFAULT '',
|
|
status TEXT DEFAULT 'idle',
|
|
resident_status TEXT DEFAULT 'idle',
|
|
current_task_id TEXT DEFAULT '',
|
|
current_work_item_id TEXT DEFAULT '',
|
|
manager_role_id TEXT DEFAULT '',
|
|
manager_seat_id TEXT DEFAULT '',
|
|
manager_role_ids TEXT DEFAULT '[]',
|
|
manager_seat_ids TEXT DEFAULT '[]',
|
|
inbox_state TEXT DEFAULT '{}',
|
|
resume_state TEXT DEFAULT '{}',
|
|
current_work_item TEXT DEFAULT '{}',
|
|
latest_notification TEXT DEFAULT '{}',
|
|
manager_digest TEXT DEFAULT '{}',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS role_runtime_sessions (
|
|
role_session_id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL,
|
|
project_id TEXT DEFAULT 'default',
|
|
team_instance_id TEXT DEFAULT '',
|
|
team_id TEXT DEFAULT '',
|
|
role_id TEXT NOT NULL,
|
|
seat_id TEXT DEFAULT '',
|
|
seat_state_id TEXT DEFAULT '',
|
|
employee_id TEXT DEFAULT '',
|
|
focused_work_item_id TEXT DEFAULT '',
|
|
background_work_item_ids TEXT DEFAULT '[]',
|
|
manager_role_ids TEXT DEFAULT '[]',
|
|
manager_seat_ids TEXT DEFAULT '[]',
|
|
seat_ids TEXT DEFAULT '[]',
|
|
adapter_session_state TEXT DEFAULT '{}',
|
|
inbox_state TEXT DEFAULT '{}',
|
|
memory_slices_by_work_item TEXT DEFAULT '{}',
|
|
resume_state TEXT DEFAULT '{}',
|
|
current_work_item TEXT DEFAULT '{}',
|
|
latest_notification TEXT DEFAULT '{}',
|
|
manager_digest TEXT DEFAULT '{}',
|
|
status TEXT DEFAULT 'idle',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS reorg_proposals (
|
|
proposal_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT,
|
|
task_id TEXT,
|
|
initiated_by TEXT DEFAULT 'owner',
|
|
source_role_id TEXT DEFAULT '',
|
|
scope TEXT NOT NULL,
|
|
risk_level TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
title TEXT DEFAULT '',
|
|
summary TEXT DEFAULT '',
|
|
rationale TEXT DEFAULT '',
|
|
user_confirmation_required INTEGER DEFAULT 1,
|
|
old_org_version INTEGER DEFAULT 1,
|
|
new_org_version INTEGER DEFAULT 1,
|
|
changeset TEXT DEFAULT '{}',
|
|
migration_plan TEXT DEFAULT '{}',
|
|
impact_summary TEXT DEFAULT '{}',
|
|
approval_notes TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS org_snapshots (
|
|
snapshot_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
org_version INTEGER DEFAULT 1,
|
|
company_name TEXT DEFAULT '',
|
|
topology TEXT DEFAULT '',
|
|
roles TEXT DEFAULT '[]',
|
|
company_profile TEXT DEFAULT 'corporate',
|
|
active_tasks TEXT DEFAULT '[]',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS reorg_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
proposal_id TEXT DEFAULT '',
|
|
project_id TEXT DEFAULT 'default',
|
|
event_kind TEXT NOT NULL,
|
|
summary TEXT DEFAULT '',
|
|
details TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
event_id TEXT PRIMARY KEY,
|
|
event_type TEXT NOT NULL,
|
|
payload TEXT DEFAULT '{}',
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS cost_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id TEXT,
|
|
agent_id TEXT,
|
|
model TEXT,
|
|
tokens_in INTEGER DEFAULT 0,
|
|
tokens_out INTEGER DEFAULT 0,
|
|
cost REAL DEFAULT 0.0,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS approval_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id TEXT,
|
|
project_id TEXT DEFAULT 'default',
|
|
action_kind TEXT NOT NULL,
|
|
action_name TEXT NOT NULL,
|
|
target_agent TEXT DEFAULT '',
|
|
decision_action TEXT NOT NULL,
|
|
risk_level TEXT NOT NULL,
|
|
confidence REAL DEFAULT 0.0,
|
|
rationale TEXT DEFAULT '',
|
|
policy_source TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS external_sessions (
|
|
session_key TEXT PRIMARY KEY,
|
|
agent_type TEXT NOT NULL,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT NOT NULL,
|
|
opc_session_id TEXT,
|
|
task_id TEXT,
|
|
workspace_path TEXT DEFAULT '',
|
|
run_mode TEXT DEFAULT 'batch',
|
|
status TEXT DEFAULT 'unknown',
|
|
metadata TEXT DEFAULT '{}',
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS execution_checkpoints (
|
|
checkpoint_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT,
|
|
checkpoint_type TEXT NOT NULL,
|
|
status TEXT DEFAULT 'pending',
|
|
task_id TEXT,
|
|
payload TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_sessions (
|
|
runtime_session_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT,
|
|
task_id TEXT,
|
|
status TEXT DEFAULT 'running',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
payload TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_transcript_entries (
|
|
entry_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
task_id TEXT,
|
|
session_id TEXT,
|
|
message_id TEXT DEFAULT '',
|
|
role TEXT DEFAULT 'assistant',
|
|
entry_type TEXT DEFAULT 'message',
|
|
content TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_tool_calls (
|
|
call_record_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
task_id TEXT,
|
|
session_id TEXT,
|
|
message_id TEXT DEFAULT '',
|
|
tool_call_id TEXT NOT NULL,
|
|
tool_name TEXT DEFAULT '',
|
|
arguments TEXT DEFAULT '{}',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_tool_results (
|
|
result_record_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
task_id TEXT,
|
|
session_id TEXT,
|
|
message_id TEXT DEFAULT '',
|
|
tool_call_id TEXT DEFAULT '',
|
|
tool_name TEXT DEFAULT '',
|
|
payload TEXT DEFAULT '{}',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_permission_grants (
|
|
grant_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
project_id TEXT DEFAULT 'default',
|
|
scope TEXT DEFAULT 'once',
|
|
tool_name TEXT DEFAULT '',
|
|
candidate TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_subagent_runs (
|
|
subagent_run_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
task_id TEXT,
|
|
agent_id TEXT NOT NULL,
|
|
profile TEXT DEFAULT 'general',
|
|
status TEXT DEFAULT 'running',
|
|
worktree_path TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_worktree_sessions (
|
|
worktree_session_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
task_id TEXT,
|
|
path TEXT NOT NULL,
|
|
status TEXT DEFAULT 'active',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS runtime_compaction_boundaries (
|
|
boundary_id TEXT PRIMARY KEY,
|
|
runtime_session_id TEXT NOT NULL,
|
|
task_id TEXT,
|
|
summary TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
parent_session_id TEXT,
|
|
title TEXT DEFAULT '',
|
|
mode TEXT DEFAULT 'primary',
|
|
status TEXT DEFAULT 'active',
|
|
summary TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS session_messages (
|
|
message_id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
task_id TEXT,
|
|
agent_id TEXT,
|
|
parent_message_id TEXT,
|
|
summary_flag INTEGER DEFAULT 0,
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS session_parts (
|
|
part_id TEXT PRIMARY KEY,
|
|
message_id TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
part_type TEXT NOT NULL,
|
|
payload TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS session_compactions (
|
|
compaction_id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
compaction_message_id TEXT NOT NULL,
|
|
source_boundary_message_id TEXT NOT NULL,
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS session_memory_snapshots (
|
|
snapshot_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT NOT NULL,
|
|
summary_message_id TEXT NOT NULL,
|
|
source_boundary_message_id TEXT NOT NULL,
|
|
summary_text TEXT DEFAULT '',
|
|
memory_text TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS agent_compactions (
|
|
compaction_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT NOT NULL,
|
|
employee_id TEXT NOT NULL,
|
|
role_id TEXT DEFAULT '',
|
|
compaction_message_id TEXT NOT NULL,
|
|
source_boundary_message_id TEXT NOT NULL,
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS agent_memory_snapshots (
|
|
snapshot_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT NOT NULL,
|
|
employee_id TEXT NOT NULL,
|
|
role_id TEXT DEFAULT '',
|
|
memory_scope TEXT DEFAULT 'session',
|
|
memory_kind TEXT DEFAULT 'process',
|
|
summary_message_id TEXT NOT NULL,
|
|
source_boundary_message_id TEXT NOT NULL,
|
|
summary_text TEXT DEFAULT '',
|
|
memory_text TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS session_links (
|
|
link_id TEXT PRIMARY KEY,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT NOT NULL,
|
|
linked_session_id TEXT,
|
|
task_id TEXT,
|
|
link_type TEXT DEFAULT 'child_session',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS organizations (
|
|
org_id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
status TEXT DEFAULT 'active',
|
|
company_profile TEXT DEFAULT 'corporate',
|
|
budget_monthly_cents INTEGER DEFAULT 0,
|
|
spent_monthly_cents INTEGER DEFAULT 0,
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS goals (
|
|
goal_id TEXT PRIMARY KEY,
|
|
org_id TEXT NOT NULL,
|
|
parent_id TEXT,
|
|
owner_agent_id TEXT,
|
|
level TEXT DEFAULT 'task',
|
|
title TEXT NOT NULL,
|
|
description TEXT DEFAULT '',
|
|
status TEXT DEFAULT 'active',
|
|
priority INTEGER DEFAULT 5,
|
|
deadline TEXT,
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS org_agents (
|
|
agent_id TEXT PRIMARY KEY,
|
|
org_id TEXT NOT NULL,
|
|
role_id TEXT NOT NULL,
|
|
name TEXT DEFAULT '',
|
|
reports_to TEXT,
|
|
budget_monthly_cents INTEGER DEFAULT 0,
|
|
spent_monthly_cents INTEGER DEFAULT 0,
|
|
heartbeat_enabled INTEGER DEFAULT 0,
|
|
heartbeat_interval_sec INTEGER DEFAULT 300,
|
|
last_heartbeat_at TEXT,
|
|
status TEXT DEFAULT 'idle',
|
|
capabilities TEXT DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS cost_events (
|
|
event_id TEXT PRIMARY KEY,
|
|
org_id TEXT,
|
|
agent_id TEXT,
|
|
task_id TEXT,
|
|
model TEXT DEFAULT '',
|
|
tokens_in INTEGER DEFAULT 0,
|
|
tokens_out INTEGER DEFAULT 0,
|
|
cost_usd REAL DEFAULT 0.0,
|
|
timestamp TEXT NOT NULL
|
|
);
|
|
""")
|
|
await self._db.commit()
|
|
|
|
async def _ensure_schema(self) -> None:
|
|
assert self._db
|
|
await self._ensure_external_session_layout()
|
|
await self._ensure_columns(
|
|
"tasks",
|
|
{
|
|
"session_id": "TEXT",
|
|
"parent_session_id": "TEXT",
|
|
"description": "TEXT DEFAULT ''",
|
|
"assigned_to": "TEXT DEFAULT ''",
|
|
"status": "TEXT DEFAULT 'pending'",
|
|
"priority": "INTEGER DEFAULT 5",
|
|
"dependencies": "TEXT DEFAULT '[]'",
|
|
"execution_lock": "INTEGER DEFAULT 0",
|
|
"context_snapshot": "TEXT DEFAULT '{}'",
|
|
"assigned_external_agent": "TEXT",
|
|
"deadline": "TEXT",
|
|
"result": "TEXT",
|
|
"parent_id": "TEXT",
|
|
"project_id": "TEXT DEFAULT 'default'",
|
|
"tags": "TEXT DEFAULT '[]'",
|
|
"comments": "TEXT DEFAULT '[]'",
|
|
"retry_count": "INTEGER DEFAULT 0",
|
|
"max_retries": "INTEGER DEFAULT 3",
|
|
"metadata": "TEXT DEFAULT '{}'",
|
|
"org_id": "TEXT",
|
|
"goal_id": "TEXT",
|
|
"checkout_run_id": "TEXT",
|
|
"execution_locked_at": "TEXT",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"agent_messages",
|
|
{
|
|
"requires_ack": "INTEGER DEFAULT 0",
|
|
"reply_to_msg_id": "TEXT",
|
|
"task_id": "TEXT",
|
|
"status": "TEXT DEFAULT 'sent'",
|
|
"processed_at": "TEXT",
|
|
"metadata": "TEXT DEFAULT '{}'",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"meetings",
|
|
{
|
|
"task_id": "TEXT",
|
|
"status": "TEXT DEFAULT 'open'",
|
|
"decision_method": "TEXT DEFAULT ''",
|
|
"current_round": "INTEGER DEFAULT 0",
|
|
"pending_participants": "TEXT DEFAULT '[]'",
|
|
"consensus": "TEXT DEFAULT '{}'",
|
|
"metadata": "TEXT DEFAULT '{}'",
|
|
"updated_at": "TEXT",
|
|
"last_activity_at": "TEXT",
|
|
"deadline_at": "TEXT",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"handoff_records",
|
|
{
|
|
"session_id": "TEXT",
|
|
"source_work_item_id": "TEXT DEFAULT ''",
|
|
"target_work_item_id": "TEXT DEFAULT ''",
|
|
"requires_ack": "INTEGER DEFAULT 0",
|
|
"status": "TEXT DEFAULT 'sent'",
|
|
"received_at": "TEXT",
|
|
"acked_at": "TEXT",
|
|
"accepted_at": "TEXT",
|
|
"rejected_at": "TEXT",
|
|
"response_summary": "TEXT DEFAULT ''",
|
|
"ack_message_id": "TEXT",
|
|
"response_message_id": "TEXT",
|
|
"metadata": "TEXT DEFAULT '{}'",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"external_sessions",
|
|
{
|
|
"session_key": "TEXT",
|
|
"opc_session_id": "TEXT",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"execution_checkpoints",
|
|
{
|
|
"session_id": "TEXT",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"agent_memory_snapshots",
|
|
{
|
|
"memory_scope": "TEXT DEFAULT 'session'",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"delegation_runs",
|
|
{
|
|
"company_profile": "TEXT DEFAULT 'corporate'",
|
|
"lifecycle_status": "TEXT DEFAULT 'active'",
|
|
"current_revision": "INTEGER DEFAULT 1",
|
|
"latest_deliverable_summary": "TEXT DEFAULT ''",
|
|
"recovery_pointer": "TEXT DEFAULT '{}'",
|
|
"project_dossier": "TEXT DEFAULT '{}'",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"delegation_work_items",
|
|
{
|
|
"team_instance_id": "TEXT DEFAULT ''",
|
|
"team_id": "TEXT DEFAULT ''",
|
|
"role_id": "TEXT DEFAULT ''",
|
|
"seat_id": "TEXT DEFAULT ''",
|
|
"seat_state_id": "TEXT DEFAULT ''",
|
|
"role_runtime_session_id": "TEXT DEFAULT ''",
|
|
"parent_work_item_id": "TEXT",
|
|
"source_role_id": "TEXT",
|
|
"source_seat_id": "TEXT",
|
|
"title": "TEXT DEFAULT ''",
|
|
"summary": "TEXT DEFAULT ''",
|
|
"kind": "TEXT DEFAULT 'execute'",
|
|
"projection_id": "TEXT DEFAULT ''",
|
|
"batch_id": "TEXT DEFAULT ''",
|
|
"batch_index": "INTEGER DEFAULT 0",
|
|
"deliverable_summary": "TEXT DEFAULT ''",
|
|
"blocked_reason": "TEXT DEFAULT ''",
|
|
"handoff_status": "TEXT DEFAULT 'pending'",
|
|
"continuation_source": "TEXT DEFAULT ''",
|
|
"manager_role_id": "TEXT DEFAULT ''",
|
|
"manager_seat_id": "TEXT DEFAULT ''",
|
|
"claimed_by_role_runtime_session_id": "TEXT DEFAULT ''",
|
|
"claimed_by_seat_id": "TEXT DEFAULT ''",
|
|
"metadata": "TEXT DEFAULT '{}'",
|
|
# Added during the Phase unification refactor. SQLite
|
|
# ALTER TABLE ADD COLUMN cannot enforce NOT NULL on a
|
|
# populated table, so we use 'ready' as the default and
|
|
# rely on writes to fill in the canonical value.
|
|
"phase": "TEXT DEFAULT 'ready'",
|
|
},
|
|
)
|
|
await self._drop_mismatched_delegation_work_item_indexes()
|
|
await self._ensure_columns(
|
|
"delegation_role_sessions",
|
|
{
|
|
"project_id": "TEXT DEFAULT 'default'",
|
|
"team_instance_id": "TEXT DEFAULT ''",
|
|
"team_id": "TEXT DEFAULT ''",
|
|
"seat_id": "TEXT DEFAULT ''",
|
|
"seat_state_id": "TEXT DEFAULT ''",
|
|
"manager_seat_ids": "TEXT DEFAULT '[]'",
|
|
"seat_ids": "TEXT DEFAULT '[]'",
|
|
"current_work_item": "TEXT DEFAULT '{}'",
|
|
"latest_notification": "TEXT DEFAULT '{}'",
|
|
"manager_digest": "TEXT DEFAULT '{}'",
|
|
# Fix 5 PR3: per-role serial task queue.
|
|
"pending_work_item_ids": "TEXT DEFAULT '[]'",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"role_runtime_sessions",
|
|
{
|
|
"current_work_item": "TEXT DEFAULT '{}'",
|
|
"latest_notification": "TEXT DEFAULT '{}'",
|
|
"manager_digest": "TEXT DEFAULT '{}'",
|
|
"pending_work_item_ids": "TEXT DEFAULT '[]'",
|
|
},
|
|
)
|
|
await self._ensure_columns(
|
|
"seat_states",
|
|
{
|
|
"current_work_item": "TEXT DEFAULT '{}'",
|
|
"latest_notification": "TEXT DEFAULT '{}'",
|
|
"manager_digest": "TEXT DEFAULT '{}'",
|
|
},
|
|
)
|
|
|
|
async def _ensure_indexes(self) -> None:
|
|
assert self._db
|
|
# These indexes depend on columns that may be added by migrations for
|
|
# older databases, so create them only after _ensure_schema() runs.
|
|
await self._db.executescript("""
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_project_status_created ON tasks(project_id, status, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_project_priority_created ON tasks(project_id, priority, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_task ON agent_messages(task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_status ON agent_messages(status);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON agent_messages(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_meetings_task ON meetings(task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
|
CREATE INDEX IF NOT EXISTS idx_cost_task ON cost_records(task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_approval_project ON approval_records(project_id);
|
|
CREATE INDEX IF NOT EXISTS idx_approval_name ON approval_records(action_name);
|
|
CREATE INDEX IF NOT EXISTS idx_decisions_project ON work_item_decisions(project_id, projection_id);
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_project ON artifact_records(project_id, projection_id);
|
|
CREATE INDEX IF NOT EXISTS idx_role_memory_project ON role_memory(project_id, role_id);
|
|
CREATE INDEX IF NOT EXISTS idx_handoff_project ON handoff_records(project_id, target_projection_id);
|
|
CREATE INDEX IF NOT EXISTS idx_handoff_status ON handoff_records(project_id, status, target_projection_id);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_runs_session ON delegation_runs(session_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_runs_project_lifecycle ON delegation_runs(project_id, lifecycle_status, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_cells_run ON delegation_cells(run_id, manager_role_id);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_work_items_run ON delegation_work_items(run_id, phase, role_id);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_work_items_team ON delegation_work_items(team_instance_id, team_id, seat_id, phase);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_work_items_batch ON delegation_work_items(run_id, batch_id, batch_index, phase);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_work_items_manager_board ON delegation_work_items(run_id, manager_seat_id, parent_work_item_id, phase);
|
|
CREATE INDEX IF NOT EXISTS idx_work_item_runtime_links_task ON work_item_runtime_links(runtime_task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_work_item_runtime_links_kind ON work_item_runtime_links(link_kind, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_events_run ON delegation_events(run_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_role_sessions_run ON delegation_role_sessions(run_id, role_id, status);
|
|
CREATE INDEX IF NOT EXISTS idx_delegation_role_sessions_team ON delegation_role_sessions(team_instance_id, team_id, seat_id, role_id, status);
|
|
CREATE INDEX IF NOT EXISTS idx_team_instances_run ON team_instances(run_id, team_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_seat_states_team ON seat_states(team_instance_id, team_id, seat_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_role_runtime_sessions_team ON role_runtime_sessions(team_instance_id, team_id, seat_id, role_id, status);
|
|
CREATE INDEX IF NOT EXISTS idx_reorg_proposals_project ON reorg_proposals(project_id, status, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_org_snapshots_project ON org_snapshots(project_id, org_version, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_reorg_events_project ON reorg_events(project_id, proposal_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_checkpoint_project_status ON execution_checkpoints(project_id, status);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_session_messages_session ON session_messages(session_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_session_parts_session ON session_parts(session_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_session_compactions_session ON session_compactions(session_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_session_memory_snapshots_session ON session_memory_snapshots(session_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_agent_compactions_scope ON agent_compactions(project_id, session_id, employee_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_agent_memory_snapshots_scope ON agent_memory_snapshots(project_id, session_id, employee_id, memory_kind, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_agent_memory_snapshots_scope_v2 ON agent_memory_snapshots(project_id, employee_id, memory_scope, memory_kind, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_session_links_session ON session_links(session_id, link_type);
|
|
CREATE INDEX IF NOT EXISTS idx_external_sessions_agent_project ON external_sessions(agent_type, project_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_runtime_sessions_task ON runtime_sessions(task_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_runtime_events_session ON runtime_events(runtime_session_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_runtime_transcript_session ON runtime_transcript_entries(runtime_session_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_runtime_tool_calls_session ON runtime_tool_calls(runtime_session_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_runtime_tool_results_session ON runtime_tool_results(runtime_session_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_runtime_subagent_runs_session ON runtime_subagent_runs(runtime_session_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_runtime_worktrees_session ON runtime_worktree_sessions(runtime_session_id, updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_organizations_status ON organizations(status);
|
|
CREATE INDEX IF NOT EXISTS idx_goals_org ON goals(org_id, status);
|
|
CREATE INDEX IF NOT EXISTS idx_goals_parent ON goals(parent_id);
|
|
CREATE INDEX IF NOT EXISTS idx_org_agents_org ON org_agents(org_id, status);
|
|
CREATE INDEX IF NOT EXISTS idx_org_agents_reports_to ON org_agents(reports_to);
|
|
CREATE INDEX IF NOT EXISTS idx_cost_events_org ON cost_events(org_id, timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_cost_events_agent ON cost_events(agent_id, timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_cost_events_task ON cost_events(task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_org ON tasks(org_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks(goal_id);
|
|
""")
|
|
await self._db.commit()
|
|
|
|
async def _ensure_columns(self, table: str, columns: dict[str, str]) -> None:
|
|
assert self._db
|
|
async with self._db.execute(f"PRAGMA table_info({table})") as cursor:
|
|
rows = await cursor.fetchall()
|
|
existing = {row[1] for row in rows}
|
|
for name, ddl in columns.items():
|
|
if name in existing:
|
|
continue
|
|
await self._db.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
|
|
await self._db.commit()
|
|
|
|
async def _drop_mismatched_delegation_work_item_indexes(self) -> None:
|
|
assert self._db
|
|
expected = {
|
|
"idx_delegation_work_items_run": ["run_id", "phase", "role_id"],
|
|
"idx_delegation_work_items_team": ["team_instance_id", "team_id", "seat_id", "phase"],
|
|
"idx_delegation_work_items_batch": ["run_id", "batch_id", "batch_index", "phase"],
|
|
"idx_delegation_work_items_manager_board": ["run_id", "manager_seat_id", "parent_work_item_id", "phase"],
|
|
}
|
|
async with self._db.execute("PRAGMA index_list(delegation_work_items)") as cursor:
|
|
rows = await cursor.fetchall()
|
|
existing = {str(row[1]) for row in rows}
|
|
for index_name, expected_columns in expected.items():
|
|
if index_name not in existing:
|
|
continue
|
|
async with self._db.execute(f"PRAGMA index_info({index_name})") as cursor:
|
|
info = await cursor.fetchall()
|
|
columns = [str(row[2]) for row in info]
|
|
if columns != expected_columns:
|
|
await self._db.execute(f"DROP INDEX IF EXISTS {index_name}")
|
|
await self._db.commit()
|
|
|
|
async def _ensure_external_session_layout(self) -> None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'external_sessions'"
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
create_sql = row[0] if row else ""
|
|
if create_sql and "PRIMARY KEY (agent_type, project_id)" not in create_sql:
|
|
return
|
|
await self._db.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS external_sessions_v2 (
|
|
session_key TEXT PRIMARY KEY,
|
|
agent_type TEXT NOT NULL,
|
|
project_id TEXT DEFAULT 'default',
|
|
session_id TEXT NOT NULL,
|
|
opc_session_id TEXT,
|
|
task_id TEXT,
|
|
workspace_path TEXT DEFAULT '',
|
|
run_mode TEXT DEFAULT 'batch',
|
|
status TEXT DEFAULT 'unknown',
|
|
metadata TEXT DEFAULT '{}',
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
INSERT OR REPLACE INTO external_sessions_v2
|
|
(session_key, agent_type, project_id, session_id, opc_session_id, task_id, workspace_path, run_mode, status, metadata, updated_at)
|
|
SELECT
|
|
printf('%s|%s|%s|%s|%s', agent_type, project_id, ifnull(opc_session_id, ''), ifnull(task_id, ''), session_id),
|
|
agent_type,
|
|
project_id,
|
|
session_id,
|
|
opc_session_id,
|
|
task_id,
|
|
workspace_path,
|
|
run_mode,
|
|
status,
|
|
metadata,
|
|
updated_at
|
|
FROM external_sessions;
|
|
DROP TABLE external_sessions;
|
|
ALTER TABLE external_sessions_v2 RENAME TO external_sessions;
|
|
CREATE INDEX IF NOT EXISTS idx_external_sessions_agent_project ON external_sessions(agent_type, project_id, updated_at);
|
|
"""
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def close(self) -> None:
|
|
if self._db:
|
|
await self._db.close()
|
|
self._db = None
|
|
|
|
async def _table_exists(self, table: str) -> bool:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
(table,),
|
|
) as cursor:
|
|
return await cursor.fetchone() is not None
|
|
|
|
@staticmethod
|
|
def _clean_text_ids(values: Any) -> set[str]:
|
|
return {
|
|
str(value or "").strip()
|
|
for value in values
|
|
if str(value or "").strip()
|
|
}
|
|
|
|
@staticmethod
|
|
def _chunked_ids(values: set[str] | list[str], size: int = 400) -> list[list[str]]:
|
|
ids = sorted(OPCStore._clean_text_ids(values))
|
|
return [ids[index : index + size] for index in range(0, len(ids), size)]
|
|
|
|
async def _fetch_text_column(
|
|
self,
|
|
query: str,
|
|
params: tuple[Any, ...] | list[Any] = (),
|
|
) -> set[str]:
|
|
assert self._db
|
|
async with self._db.execute(query, tuple(params)) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return self._clean_text_ids(row[0] for row in rows)
|
|
|
|
async def _fetch_text_column_where_in(
|
|
self,
|
|
table: str,
|
|
select_column: str,
|
|
where_column: str,
|
|
values: set[str] | list[str],
|
|
*,
|
|
extra_where: str = "",
|
|
) -> set[str]:
|
|
assert self._db
|
|
results: set[str] = set()
|
|
for chunk in self._chunked_ids(values):
|
|
placeholders = ", ".join("?" for _ in chunk)
|
|
query = f"SELECT {select_column} FROM {table} WHERE {where_column} IN ({placeholders})"
|
|
if extra_where:
|
|
query += f" AND {extra_where}"
|
|
async with self._db.execute(query, tuple(chunk)) as cursor:
|
|
rows = await cursor.fetchall()
|
|
results.update(self._clean_text_ids(row[0] for row in rows))
|
|
return results
|
|
|
|
async def _delete_where_in(
|
|
self,
|
|
table: str,
|
|
column: str,
|
|
values: set[str] | list[str],
|
|
) -> None:
|
|
assert self._db
|
|
for chunk in self._chunked_ids(values):
|
|
placeholders = ", ".join("?" for _ in chunk)
|
|
await self._db.execute(
|
|
f"DELETE FROM {table} WHERE {column} IN ({placeholders})",
|
|
tuple(chunk),
|
|
)
|
|
|
|
async def _delete_events_by_payload_ids(
|
|
self,
|
|
json_path: str,
|
|
values: set[str] | list[str],
|
|
) -> None:
|
|
assert self._db
|
|
for chunk in self._chunked_ids(values):
|
|
placeholders = ", ".join("?" for _ in chunk)
|
|
await self._db.execute(
|
|
f"""
|
|
DELETE FROM events
|
|
WHERE json_valid(payload)
|
|
AND json_extract(payload, ?) IN ({placeholders})
|
|
""",
|
|
tuple([json_path, *chunk]),
|
|
)
|
|
|
|
async def _fetch_text_column_where_text_contains(
|
|
self,
|
|
table: str,
|
|
select_column: str,
|
|
text_column: str,
|
|
values: set[str] | list[str],
|
|
) -> set[str]:
|
|
assert self._db
|
|
results: set[str] = set()
|
|
for chunk in self._chunked_ids(values):
|
|
conditions = " OR ".join(f"{text_column} LIKE ?" for _ in chunk)
|
|
async with self._db.execute(
|
|
f"SELECT {select_column} FROM {table} WHERE {conditions}",
|
|
tuple(f"%{value}%" for value in chunk),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
results.update(self._clean_text_ids(row[0] for row in rows))
|
|
return results
|
|
|
|
async def _delete_where_text_contains(
|
|
self,
|
|
table: str,
|
|
text_column: str,
|
|
values: set[str] | list[str],
|
|
) -> None:
|
|
assert self._db
|
|
for chunk in self._chunked_ids(values):
|
|
conditions = " OR ".join(f"{text_column} LIKE ?" for _ in chunk)
|
|
await self._db.execute(
|
|
f"DELETE FROM {table} WHERE {conditions}",
|
|
tuple(f"%{value}%" for value in chunk),
|
|
)
|
|
|
|
async def _delete_by_json_path_or_text_ids(
|
|
self,
|
|
table: str,
|
|
json_column: str,
|
|
json_paths: tuple[str, ...],
|
|
values: set[str] | list[str],
|
|
) -> None:
|
|
assert self._db
|
|
clean_values = self._clean_text_ids(values)
|
|
if not clean_values:
|
|
return
|
|
for path in json_paths:
|
|
for chunk in self._chunked_ids(clean_values):
|
|
placeholders = ", ".join("?" for _ in chunk)
|
|
await self._db.execute(
|
|
f"""
|
|
DELETE FROM {table}
|
|
WHERE json_valid({json_column})
|
|
AND json_extract({json_column}, ?) IN ({placeholders})
|
|
""",
|
|
tuple([path, *chunk]),
|
|
)
|
|
await self._delete_where_text_contains(table, json_column, clean_values)
|
|
|
|
async def _delete_company_runtime_artifacts_for_task(
|
|
self,
|
|
task_id: str,
|
|
session_id: str | None,
|
|
*,
|
|
shared_session: bool,
|
|
) -> None:
|
|
"""Delete company-mode and runtime rows tied to one task/session.
|
|
|
|
Top-level chat deletion removes the full delegation run for that
|
|
session. Child work-item deletion removes only the linked work item and
|
|
its runtime traces so a sibling/parent run is not destroyed.
|
|
"""
|
|
if not self._db:
|
|
return
|
|
|
|
clean_task_id = str(task_id or "").strip()
|
|
clean_session_id = str(session_id or "").strip()
|
|
if not clean_task_id and not clean_session_id:
|
|
return
|
|
|
|
task_row: dict[str, Any] = {}
|
|
if clean_task_id:
|
|
async with self._db.execute(
|
|
"SELECT id, session_id, parent_session_id, project_id, metadata FROM tasks WHERE id = ?",
|
|
(clean_task_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row:
|
|
cols = [description[0] for description in cursor.description]
|
|
task_row = dict(zip(cols, row))
|
|
|
|
parent_session_id = str(task_row.get("parent_session_id") or "").strip()
|
|
if not clean_session_id and task_row:
|
|
clean_session_id = str(task_row.get("session_id") or parent_session_id or "").strip()
|
|
is_primary_session_task = bool(clean_session_id and not shared_session and not parent_session_id)
|
|
|
|
task_ids: set[str] = set()
|
|
if clean_task_id:
|
|
task_ids.add(clean_task_id)
|
|
if clean_session_id and not shared_session:
|
|
task_ids.update(
|
|
await self._fetch_text_column(
|
|
"SELECT id FROM tasks WHERE session_id = ? OR parent_session_id = ?",
|
|
(clean_session_id, clean_session_id),
|
|
)
|
|
)
|
|
session_ids = {clean_session_id} if clean_session_id and not shared_session else set()
|
|
full_run_ids: set[str] = set()
|
|
work_item_ids: set[str] = set()
|
|
role_runtime_session_ids: set[str] = set()
|
|
runtime_session_ids: set[str] = set()
|
|
|
|
if is_primary_session_task:
|
|
full_run_ids.update(
|
|
await self._fetch_text_column(
|
|
"SELECT run_id FROM delegation_runs WHERE session_id = ?",
|
|
(clean_session_id,),
|
|
)
|
|
)
|
|
|
|
if task_ids:
|
|
work_item_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"work_item_runtime_links",
|
|
"work_item_id",
|
|
"runtime_task_id",
|
|
task_ids,
|
|
)
|
|
)
|
|
|
|
metadata_paths = (
|
|
"$.task_id",
|
|
"$.runtime_task_id",
|
|
"$.execution_task_id",
|
|
"$.origin_task_id",
|
|
"$.worker_task_id",
|
|
)
|
|
if task_ids:
|
|
for path in metadata_paths:
|
|
for chunk in self._chunked_ids(task_ids):
|
|
placeholders = ", ".join("?" for _ in chunk)
|
|
work_item_ids.update(
|
|
await self._fetch_text_column(
|
|
f"""
|
|
SELECT work_item_id FROM delegation_work_items
|
|
WHERE json_valid(metadata)
|
|
AND json_extract(metadata, ?) IN ({placeholders})
|
|
""",
|
|
tuple([path, *chunk]),
|
|
)
|
|
)
|
|
|
|
if session_ids:
|
|
for chunk in self._chunked_ids(session_ids):
|
|
placeholders = ", ".join("?" for _ in chunk)
|
|
work_item_ids.update(
|
|
await self._fetch_text_column(
|
|
f"""
|
|
SELECT work_item_id FROM delegation_work_items
|
|
WHERE json_valid(metadata)
|
|
AND (
|
|
json_extract(metadata, '$.session_id') IN ({placeholders})
|
|
OR json_extract(metadata, '$.parent_session_id') IN ({placeholders})
|
|
OR json_extract(metadata, '$.session_scope_id') IN ({placeholders})
|
|
)
|
|
""",
|
|
tuple([*chunk, *chunk, *chunk]),
|
|
)
|
|
)
|
|
|
|
if full_run_ids:
|
|
work_item_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"delegation_work_items",
|
|
"work_item_id",
|
|
"run_id",
|
|
full_run_ids,
|
|
)
|
|
)
|
|
|
|
if work_item_ids:
|
|
role_runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"delegation_work_items",
|
|
"role_runtime_session_id",
|
|
"work_item_id",
|
|
work_item_ids,
|
|
extra_where="COALESCE(role_runtime_session_id, '') != ''",
|
|
)
|
|
)
|
|
role_runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"delegation_work_items",
|
|
"claimed_by_role_runtime_session_id",
|
|
"work_item_id",
|
|
work_item_ids,
|
|
extra_where="COALESCE(claimed_by_role_runtime_session_id, '') != ''",
|
|
)
|
|
)
|
|
|
|
if full_run_ids:
|
|
role_runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"role_runtime_sessions",
|
|
"role_session_id",
|
|
"run_id",
|
|
full_run_ids,
|
|
)
|
|
)
|
|
role_runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"delegation_role_sessions",
|
|
"role_session_id",
|
|
"run_id",
|
|
full_run_ids,
|
|
)
|
|
)
|
|
role_runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"seat_states",
|
|
"role_runtime_session_id",
|
|
"run_id",
|
|
full_run_ids,
|
|
extra_where="COALESCE(role_runtime_session_id, '') != ''",
|
|
)
|
|
)
|
|
role_runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"seat_states",
|
|
"member_session_id",
|
|
"run_id",
|
|
full_run_ids,
|
|
extra_where="COALESCE(member_session_id, '') != ''",
|
|
)
|
|
)
|
|
|
|
if task_ids:
|
|
runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"runtime_sessions",
|
|
"runtime_session_id",
|
|
"task_id",
|
|
task_ids,
|
|
)
|
|
)
|
|
if session_ids:
|
|
runtime_session_ids.update(
|
|
await self._fetch_text_column_where_in(
|
|
"runtime_sessions",
|
|
"runtime_session_id",
|
|
"session_id",
|
|
session_ids,
|
|
)
|
|
)
|
|
if full_run_ids:
|
|
runtime_session_ids.update(role_runtime_session_ids)
|
|
# Some company runtime events are scoped directly to the run id
|
|
# rather than a role/runtime session id.
|
|
runtime_session_ids.update(full_run_ids)
|
|
runtime_lookup_ids = task_ids | session_ids | full_run_ids | work_item_ids
|
|
if full_run_ids:
|
|
runtime_lookup_ids.update(role_runtime_session_ids)
|
|
runtime_session_ids.update(
|
|
await self._fetch_text_column_where_text_contains(
|
|
"runtime_sessions",
|
|
"runtime_session_id",
|
|
"runtime_session_id",
|
|
runtime_lookup_ids,
|
|
)
|
|
)
|
|
runtime_session_ids.update(
|
|
await self._fetch_text_column_where_text_contains(
|
|
"runtime_sessions",
|
|
"runtime_session_id",
|
|
"metadata",
|
|
runtime_lookup_ids,
|
|
)
|
|
)
|
|
|
|
task_tables = (
|
|
"runtime_transcript_entries",
|
|
"runtime_tool_calls",
|
|
"runtime_tool_results",
|
|
"runtime_subagent_runs",
|
|
"runtime_worktree_sessions",
|
|
"runtime_compaction_boundaries",
|
|
)
|
|
for table in task_tables:
|
|
await self._delete_where_in(table, "task_id", task_ids)
|
|
if session_ids and table in {
|
|
"runtime_transcript_entries",
|
|
"runtime_tool_calls",
|
|
"runtime_tool_results",
|
|
}:
|
|
await self._delete_where_in(table, "session_id", session_ids)
|
|
|
|
if runtime_session_ids:
|
|
for table in (
|
|
"runtime_events",
|
|
"runtime_transcript_entries",
|
|
"runtime_tool_calls",
|
|
"runtime_tool_results",
|
|
"runtime_permission_grants",
|
|
"runtime_subagent_runs",
|
|
"runtime_worktree_sessions",
|
|
"runtime_compaction_boundaries",
|
|
"runtime_sessions",
|
|
):
|
|
await self._delete_where_in(table, "runtime_session_id", runtime_session_ids)
|
|
|
|
await self._delete_where_in("execution_checkpoints", "task_id", task_ids)
|
|
await self._delete_where_in("execution_checkpoints", "session_id", session_ids)
|
|
await self._delete_by_json_path_or_text_ids(
|
|
"execution_checkpoints",
|
|
"payload",
|
|
("$.task_id", "$.session_id", "$.run_id", "$.work_item_id"),
|
|
task_ids | session_ids | full_run_ids | work_item_ids,
|
|
)
|
|
await self._delete_where_in("external_sessions", "task_id", task_ids)
|
|
await self._delete_where_in("external_sessions", "opc_session_id", session_ids)
|
|
await self._delete_where_in("external_sessions", "session_id", session_ids)
|
|
await self._delete_where_text_contains(
|
|
"external_sessions",
|
|
"metadata",
|
|
task_ids | session_ids | full_run_ids | work_item_ids | runtime_session_ids,
|
|
)
|
|
if await self._table_exists("external_sessions_v2"):
|
|
await self._delete_where_in("external_sessions_v2", "task_id", task_ids)
|
|
await self._delete_where_in("external_sessions_v2", "opc_session_id", session_ids)
|
|
await self._delete_where_in("external_sessions_v2", "session_id", session_ids)
|
|
await self._delete_where_text_contains(
|
|
"external_sessions_v2",
|
|
"metadata",
|
|
task_ids | session_ids | full_run_ids | work_item_ids | runtime_session_ids,
|
|
)
|
|
|
|
if clean_task_id:
|
|
await self._db.execute(
|
|
"""
|
|
DELETE FROM events
|
|
WHERE json_valid(payload)
|
|
AND (
|
|
json_extract(payload, '$.task_id') = ?
|
|
OR json_extract(payload, '$.runtime_task_id') = ?
|
|
OR json_extract(payload, '$.execution_task_id') = ?
|
|
OR json_extract(payload, '$.escalation_id') LIKE ?
|
|
)
|
|
""",
|
|
(clean_task_id, clean_task_id, clean_task_id, f"esc_{clean_task_id}_%"),
|
|
)
|
|
for path in (
|
|
"$.task_id",
|
|
"$.runtime_task_id",
|
|
"$.execution_task_id",
|
|
"$.origin_task_id",
|
|
"$.worker_task_id",
|
|
):
|
|
await self._delete_events_by_payload_ids(path, task_ids)
|
|
for path in ("$.session_id", "$.parent_session_id", "$.opc_session_id"):
|
|
await self._delete_events_by_payload_ids(path, session_ids)
|
|
await self._delete_events_by_payload_ids("$.run_id", full_run_ids)
|
|
await self._delete_events_by_payload_ids("$.work_item_id", work_item_ids)
|
|
await self._delete_events_by_payload_ids("$.runtime_session_id", runtime_session_ids)
|
|
role_event_session_ids = set(runtime_session_ids)
|
|
if full_run_ids:
|
|
role_event_session_ids.update(role_runtime_session_ids)
|
|
await self._delete_events_by_payload_ids("$.role_runtime_session_id", role_event_session_ids)
|
|
await self._delete_events_by_payload_ids("$.member_session_id", role_event_session_ids)
|
|
event_scope_ids = task_ids | session_ids | full_run_ids | work_item_ids | runtime_session_ids
|
|
if full_run_ids:
|
|
event_scope_ids.update(role_runtime_session_ids)
|
|
await self._delete_where_text_contains(
|
|
"events",
|
|
"payload",
|
|
event_scope_ids,
|
|
)
|
|
|
|
if full_run_ids:
|
|
await self._delete_where_in("work_item_runtime_links", "work_item_id", work_item_ids)
|
|
await self._delete_where_in("delegation_events", "run_id", full_run_ids)
|
|
await self._delete_where_in("delegation_work_items", "run_id", full_run_ids)
|
|
await self._delete_where_in("delegation_cells", "run_id", full_run_ids)
|
|
await self._delete_where_in("delegation_role_sessions", "run_id", full_run_ids)
|
|
await self._delete_where_in("role_runtime_sessions", "run_id", full_run_ids)
|
|
await self._delete_where_in("seat_states", "run_id", full_run_ids)
|
|
await self._delete_where_in("team_instances", "run_id", full_run_ids)
|
|
await self._delete_where_in("delegation_runs", "run_id", full_run_ids)
|
|
elif work_item_ids:
|
|
await self._delete_where_in("work_item_runtime_links", "work_item_id", work_item_ids)
|
|
await self._delete_where_in("delegation_events", "work_item_id", work_item_ids)
|
|
await self._delete_where_in("delegation_work_items", "work_item_id", work_item_ids)
|
|
if await self._table_exists("external_sessions_v2"):
|
|
await self._delete_where_text_contains(
|
|
"external_sessions_v2",
|
|
"metadata",
|
|
task_ids | session_ids | full_run_ids | work_item_ids | runtime_session_ids,
|
|
)
|
|
await self._delete_where_in("work_item_runtime_links", "runtime_task_id", task_ids)
|
|
for chunk in self._chunked_ids(work_item_ids):
|
|
placeholders = ", ".join("?" for _ in chunk)
|
|
await self._db.execute(
|
|
f"""
|
|
UPDATE seat_states
|
|
SET current_work_item_id = '',
|
|
current_task_id = ''
|
|
WHERE current_work_item_id IN ({placeholders})
|
|
""",
|
|
tuple(chunk),
|
|
)
|
|
await self._db.execute(
|
|
f"""
|
|
UPDATE role_runtime_sessions
|
|
SET focused_work_item_id = ''
|
|
WHERE focused_work_item_id IN ({placeholders})
|
|
""",
|
|
tuple(chunk),
|
|
)
|
|
|
|
async def delete_company_runtime_artifacts_for_session(
|
|
self,
|
|
session_id: str,
|
|
) -> None:
|
|
"""Delete company runtime rows tied to a chat session id.
|
|
|
|
This is intentionally session-driven rather than task-driven so a hard
|
|
chat delete can clean orphan company runtime rows even if the task row
|
|
was already removed by an earlier or partial delete path.
|
|
"""
|
|
if not self._db:
|
|
return
|
|
clean_session_id = str(session_id or "").strip()
|
|
if not clean_session_id:
|
|
return
|
|
await self._delete_company_runtime_artifacts_for_task(
|
|
"",
|
|
clean_session_id,
|
|
shared_session=False,
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def delete_session_data(self, task_id: str, session_id: str | None = None) -> None:
|
|
"""Delete all data associated with a session/task.
|
|
|
|
Cleans: agent_messages, session_messages, session_parts,
|
|
session_compactions, execution_checkpoints, external_sessions.
|
|
The task row itself is NOT deleted (caller marks it CANCELLED).
|
|
"""
|
|
if not self._db:
|
|
return
|
|
await self._db.execute("DELETE FROM agent_messages WHERE task_id = ?", (task_id,))
|
|
shared_session = False
|
|
if session_id:
|
|
async with self._db.execute(
|
|
"SELECT 1 FROM tasks WHERE session_id = ? AND id != ? LIMIT 1",
|
|
(session_id, task_id),
|
|
) as cursor:
|
|
shared_session = await cursor.fetchone() is not None
|
|
if session_id:
|
|
if not shared_session:
|
|
await self._db.execute("DELETE FROM session_parts WHERE session_id = ?", (session_id,))
|
|
await self._db.execute("DELETE FROM session_compactions WHERE session_id = ?", (session_id,))
|
|
await self._db.execute("DELETE FROM session_memory_snapshots WHERE session_id = ?", (session_id,))
|
|
await self._db.execute("DELETE FROM agent_compactions WHERE session_id = ?", (session_id,))
|
|
await self._db.execute("DELETE FROM agent_memory_snapshots WHERE session_id = ?", (session_id,))
|
|
await self._db.execute("DELETE FROM session_messages WHERE session_id = ?", (session_id,))
|
|
else:
|
|
await self._db.execute("DELETE FROM session_messages WHERE task_id = ?", (task_id,))
|
|
if session_id and not shared_session:
|
|
await self._db.execute(
|
|
"DELETE FROM execution_checkpoints WHERE task_id = ? OR session_id = ?",
|
|
(task_id, session_id),
|
|
)
|
|
await self._db.execute(
|
|
"DELETE FROM external_sessions WHERE task_id = ? OR opc_session_id = ?",
|
|
(task_id, session_id),
|
|
)
|
|
if await self._table_exists("external_sessions_v2"):
|
|
await self._db.execute(
|
|
"DELETE FROM external_sessions_v2 WHERE task_id = ? OR opc_session_id = ?",
|
|
(task_id, session_id),
|
|
)
|
|
else:
|
|
await self._db.execute("DELETE FROM execution_checkpoints WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM external_sessions WHERE task_id = ?", (task_id,))
|
|
if await self._table_exists("external_sessions_v2"):
|
|
await self._db.execute("DELETE FROM external_sessions_v2 WHERE task_id = ?", (task_id,))
|
|
await self._db.commit()
|
|
|
|
async def hard_delete_task(self, task_id: str, session_id: str | None = None) -> None:
|
|
"""Permanently delete a task row and all persisted lifecycle data."""
|
|
if not self._db:
|
|
return
|
|
shared_session = False
|
|
if session_id:
|
|
async with self._db.execute(
|
|
"SELECT 1 FROM tasks WHERE session_id = ? AND id != ? LIMIT 1",
|
|
(session_id, task_id),
|
|
) as cursor:
|
|
shared_session = await cursor.fetchone() is not None
|
|
await self._delete_company_runtime_artifacts_for_task(
|
|
task_id,
|
|
session_id,
|
|
shared_session=shared_session,
|
|
)
|
|
await self.delete_session_data(task_id, session_id)
|
|
await self._db.execute("DELETE FROM meetings WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM work_item_decisions WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM artifact_records WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM cost_records WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM cost_events WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM approval_records WHERE task_id = ?", (task_id,))
|
|
if session_id and not shared_session:
|
|
await self._db.execute(
|
|
"DELETE FROM handoff_records WHERE task_id = ? OR session_id = ?",
|
|
(task_id, session_id),
|
|
)
|
|
await self._db.execute(
|
|
"DELETE FROM reorg_proposals WHERE task_id = ? OR session_id = ?",
|
|
(task_id, session_id),
|
|
)
|
|
await self._db.execute(
|
|
"DELETE FROM session_links WHERE task_id = ? OR session_id = ? OR linked_session_id = ?",
|
|
(task_id, session_id, session_id),
|
|
)
|
|
await self._db.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
|
|
else:
|
|
await self._db.execute("DELETE FROM handoff_records WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM reorg_proposals WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM session_links WHERE task_id = ?", (task_id,))
|
|
await self._db.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
|
await self._db.commit()
|
|
|
|
# --- Tasks ---
|
|
|
|
@staticmethod
|
|
def _metadata_has_work_item_projection_identity(metadata: dict[str, Any]) -> bool:
|
|
return any(
|
|
str(metadata.get(key, "") or "").strip()
|
|
for key in (
|
|
WORK_ITEM_PROJECTION_ID_KEY,
|
|
WORK_ITEM_TURN_TYPE_KEY,
|
|
)
|
|
)
|
|
|
|
async def _save_task_row(self, task: Task, *, commit: bool = True) -> None:
|
|
assert self._db
|
|
self._assert_project_write_scope(
|
|
getattr(task, "project_id", None),
|
|
operation="save_task",
|
|
entity=f"task {getattr(task, 'id', '')!r}",
|
|
)
|
|
task.metadata = dict(task.metadata or {})
|
|
if self._metadata_has_work_item_projection_identity(task.metadata):
|
|
task.metadata, _ = migrate_work_item_projection_metadata(
|
|
task.metadata,
|
|
turn_type_fallback="",
|
|
)
|
|
await self._db.execute(
|
|
"""INSERT INTO tasks
|
|
(id, session_id, parent_session_id, title, description, assigned_to, status, priority, dependencies,
|
|
execution_lock, context_snapshot, assigned_external_agent, created_at,
|
|
deadline, result, parent_id, project_id, tags, comments,
|
|
retry_count, max_retries, metadata,
|
|
org_id, goal_id, checkout_run_id, execution_locked_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
session_id=excluded.session_id,
|
|
parent_session_id=excluded.parent_session_id,
|
|
title=excluded.title,
|
|
description=excluded.description,
|
|
assigned_to=excluded.assigned_to,
|
|
status=excluded.status,
|
|
priority=excluded.priority,
|
|
dependencies=excluded.dependencies,
|
|
execution_lock=excluded.execution_lock,
|
|
context_snapshot=excluded.context_snapshot,
|
|
assigned_external_agent=excluded.assigned_external_agent,
|
|
created_at=excluded.created_at,
|
|
deadline=excluded.deadline,
|
|
result=excluded.result,
|
|
parent_id=excluded.parent_id,
|
|
project_id=excluded.project_id,
|
|
tags=excluded.tags,
|
|
comments=excluded.comments,
|
|
retry_count=excluded.retry_count,
|
|
max_retries=excluded.max_retries,
|
|
metadata=excluded.metadata,
|
|
org_id=excluded.org_id,
|
|
goal_id=excluded.goal_id,
|
|
checkout_run_id=excluded.checkout_run_id,
|
|
execution_locked_at=excluded.execution_locked_at""",
|
|
(
|
|
task.id,
|
|
task.session_id,
|
|
task.parent_session_id,
|
|
task.title,
|
|
task.description,
|
|
task.assigned_to,
|
|
task.status.value,
|
|
task.priority,
|
|
_json_dumps(task.dependencies),
|
|
int(task.execution_lock),
|
|
_json_dumps(task.context_snapshot),
|
|
task.assigned_external_agent,
|
|
task.created_at.isoformat(),
|
|
task.deadline.isoformat() if task.deadline else None,
|
|
_json_dumps(task.result) if task.result else None,
|
|
task.parent_id,
|
|
task.project_id,
|
|
_json_dumps(task.tags),
|
|
_json_dumps(task.comments),
|
|
task.retry_count,
|
|
task.max_retries,
|
|
_json_dumps(task.metadata),
|
|
task.org_id,
|
|
task.goal_id,
|
|
task.checkout_run_id,
|
|
task.execution_locked_at.isoformat() if task.execution_locked_at else None,
|
|
),
|
|
)
|
|
if commit:
|
|
await self._db.commit()
|
|
|
|
async def save_task(self, task: Task) -> None:
|
|
await self._save_task_row(task, commit=True)
|
|
|
|
async def _find_existing_runtime_task_for_work_item(
|
|
self,
|
|
work_item: DelegationWorkItem,
|
|
candidate_task: Task,
|
|
) -> Task | None:
|
|
assert self._db is not None
|
|
session_id = str(getattr(candidate_task, "session_id", "") or "").strip()
|
|
project_id = str(getattr(candidate_task, "project_id", "") or "").strip()
|
|
projection_id = projection_id_for_work_item(work_item)
|
|
run_id = str(getattr(work_item, "run_id", "") or "").strip()
|
|
if not session_id or not project_id or not projection_id or not run_id:
|
|
return None
|
|
async with self._db.execute(
|
|
"""SELECT * FROM tasks
|
|
WHERE session_id=?
|
|
AND project_id=?
|
|
AND metadata LIKE ?
|
|
AND metadata LIKE ?
|
|
ORDER BY created_at ASC, id ASC""",
|
|
(
|
|
session_id,
|
|
project_id,
|
|
"%work_item_runtime%",
|
|
f"%{projection_id}%",
|
|
),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
description = cursor.description
|
|
|
|
candidates: list[Task] = []
|
|
for row in rows:
|
|
task = self._row_to_task(row, description)
|
|
metadata = dict(task.metadata or {})
|
|
if str(metadata.get("delegation_run_id", "") or "").strip() != run_id:
|
|
continue
|
|
if str(metadata.get(WORK_ITEM_PROJECTION_ID_KEY, "") or "").strip() != projection_id:
|
|
continue
|
|
if not self._runtime_task_matches_work_item(task, work_item):
|
|
continue
|
|
candidates.append(task)
|
|
if not candidates:
|
|
return None
|
|
|
|
def _candidate_sort_key(task: Task) -> tuple[int, int, datetime, str]:
|
|
metadata = dict(task.metadata or {})
|
|
status = str(task.status.value if hasattr(task.status, "value") else task.status or "").strip().lower()
|
|
terminal = 1 if status in self._terminal_task_statuses() else 0
|
|
duplicate = 1 if str(metadata.get("duplicate_runtime_task_of", "") or "").strip() else 0
|
|
return (duplicate, terminal, task.created_at, str(task.id or ""))
|
|
|
|
candidates.sort(key=_candidate_sort_key)
|
|
if len(candidates) > 1:
|
|
logger.warning(
|
|
"ensure_runtime_task_for_work_item: multiple exact runtime Tasks "
|
|
f"for work_item={work_item.work_item_id}; using task={candidates[0].id} "
|
|
f"candidates={[task.id for task in candidates[:5]]}"
|
|
)
|
|
return candidates[0]
|
|
|
|
async def ensure_runtime_task_for_work_item(
|
|
self,
|
|
work_item: DelegationWorkItem,
|
|
task_factory: Any,
|
|
*,
|
|
replace_policy: str = "never_active",
|
|
) -> Task:
|
|
"""Return the authoritative runtime Task for a WorkItem.
|
|
|
|
This is the only hot-path creation/reuse entry point for company-mode
|
|
runtime Tasks. It writes the structured link in the same transaction as
|
|
task creation, and ordinary read paths do not repair or rescore links.
|
|
"""
|
|
if self._db is None:
|
|
raise RuntimeError("store is not initialized")
|
|
wid = str(getattr(work_item, "work_item_id", "") or "").strip()
|
|
if not wid:
|
|
raise ValueError("work_item_id is required")
|
|
policy = str(replace_policy or "never_active").strip() or "never_active"
|
|
if policy != "never_active":
|
|
raise ValueError(f"unsupported runtime link replace_policy: {replace_policy}")
|
|
|
|
await self._db.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
if not await self._work_item_exists(wid):
|
|
raise ValueError(f"WorkItem does not exist: {wid}")
|
|
|
|
linked_task_id = await self._runtime_link_task_id_for_work_item(wid)
|
|
if linked_task_id:
|
|
linked_task = await self._get_task_unhydrated(linked_task_id)
|
|
if linked_task is not None:
|
|
set_linked_work_item_id(linked_task, wid)
|
|
issues = [
|
|
issue for issue in validate_work_item_runtime_projection(linked_task, work_item)
|
|
if issue.severity == "error"
|
|
]
|
|
if issues:
|
|
raise RuntimeError(
|
|
"work-item runtime invariant failed for linked Task "
|
|
f"{linked_task.id}: "
|
|
+ "; ".join(f"{issue.code}: {issue.message}" for issue in issues)
|
|
)
|
|
await self._db.commit()
|
|
return linked_task
|
|
await self._db.execute(
|
|
"DELETE FROM work_item_runtime_links WHERE work_item_id=?",
|
|
(wid,),
|
|
)
|
|
|
|
candidate = task_factory() if callable(task_factory) else task_factory
|
|
if inspect.isawaitable(candidate):
|
|
candidate = await candidate
|
|
if not isinstance(candidate, Task):
|
|
raise TypeError("task_factory must return a Task")
|
|
candidate.metadata = dict(candidate.metadata or {})
|
|
|
|
task = await self._find_existing_runtime_task_for_work_item(work_item, candidate)
|
|
if task is None:
|
|
task = candidate
|
|
await self._save_task_row(task, commit=False)
|
|
|
|
existing_owner_id = await self._runtime_link_work_item_id_for_task(task.id)
|
|
if existing_owner_id and existing_owner_id != wid:
|
|
raise RuntimeError(
|
|
"runtime Task is already linked to another WorkItem: "
|
|
f"task={task.id} owner={existing_owner_id} requested={wid}"
|
|
)
|
|
|
|
linked = await self._write_work_item_runtime_link(
|
|
wid,
|
|
task.id,
|
|
link_kind="primary",
|
|
commit=False,
|
|
)
|
|
if not linked:
|
|
raise RuntimeError(
|
|
f"failed to link runtime Task {task.id} for WorkItem {wid}"
|
|
)
|
|
set_linked_work_item_id(task, wid)
|
|
issues = [
|
|
issue for issue in validate_work_item_runtime_projection(task, work_item)
|
|
if issue.severity == "error"
|
|
]
|
|
if issues:
|
|
raise RuntimeError(
|
|
"work-item runtime invariant failed for Task "
|
|
f"{task.id}: "
|
|
+ "; ".join(f"{issue.code}: {issue.message}" for issue in issues)
|
|
)
|
|
await self._db.commit()
|
|
return task
|
|
except Exception:
|
|
await self._db.rollback()
|
|
raise
|
|
|
|
async def link_work_item_runtime_task(
|
|
self,
|
|
work_item_id: str,
|
|
runtime_task_id: str,
|
|
*,
|
|
link_kind: str = "primary",
|
|
allow_replace: bool = False,
|
|
) -> bool:
|
|
"""Persist the authoritative WorkItem -> runtime Task link."""
|
|
if self._db is None:
|
|
return False
|
|
wid = str(work_item_id or "").strip()
|
|
tid = str(runtime_task_id or "").strip()
|
|
if not wid or not tid:
|
|
return False
|
|
if not await self._work_item_exists(wid) or not await self._task_exists(tid):
|
|
return False
|
|
await self._db.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
existing_task_id = await self._runtime_link_task_id_for_work_item(wid)
|
|
existing_owner_id = await self._runtime_link_work_item_id_for_task(tid)
|
|
if existing_task_id and existing_task_id != tid and not allow_replace:
|
|
if not await self._runtime_task_link_is_replaceable(existing_task_id):
|
|
logger.warning(
|
|
"work-item runtime link refused to overwrite active link: "
|
|
f"work_item={wid} existing_task={existing_task_id} requested_task={tid}"
|
|
)
|
|
await self._db.rollback()
|
|
return False
|
|
if existing_owner_id and existing_owner_id != wid and not allow_replace:
|
|
logger.warning(
|
|
"work-item runtime link refused to steal task from another work item: "
|
|
f"task={tid} existing_work_item={existing_owner_id} requested_work_item={wid}"
|
|
)
|
|
await self._db.rollback()
|
|
return False
|
|
wrote = await self._write_work_item_runtime_link(
|
|
wid,
|
|
tid,
|
|
link_kind=link_kind,
|
|
commit=False,
|
|
)
|
|
await self._db.commit()
|
|
return wrote
|
|
except Exception:
|
|
await self._db.rollback()
|
|
raise
|
|
|
|
async def hydrate_task_work_item_links(self, tasks: list[Task]) -> list[Task]:
|
|
"""Attach non-persisted WorkItem link ids from the link table only."""
|
|
if self._db is None or not tasks:
|
|
return tasks
|
|
task_by_id = {
|
|
str(task.id or "").strip(): task
|
|
for task in tasks
|
|
if str(getattr(task, "id", "") or "").strip()
|
|
}
|
|
if not task_by_id:
|
|
return tasks
|
|
placeholders = ", ".join("?" for _ in task_by_id)
|
|
async with self._db.execute(
|
|
f"""SELECT work_item_id, runtime_task_id
|
|
FROM work_item_runtime_links
|
|
WHERE runtime_task_id IN ({placeholders})""",
|
|
list(task_by_id),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
linked_task_ids: set[str] = set()
|
|
for work_item_id, task_id in rows:
|
|
tid = str(task_id or "").strip()
|
|
wid = str(work_item_id or "").strip()
|
|
task = task_by_id.get(tid)
|
|
if task is None or not wid:
|
|
continue
|
|
set_linked_work_item_id(task, wid)
|
|
linked_task_ids.add(tid)
|
|
|
|
for tid, task in task_by_id.items():
|
|
if tid not in linked_task_ids:
|
|
set_linked_work_item_id(task, "")
|
|
return tasks
|
|
|
|
async def get_runtime_links_for_work_items(self, work_item_ids: list[str]) -> dict[str, str]:
|
|
if self._db is None:
|
|
return {}
|
|
ids = [
|
|
str(work_item_id or "").strip()
|
|
for work_item_id in work_item_ids
|
|
if str(work_item_id or "").strip()
|
|
]
|
|
if not ids:
|
|
return {}
|
|
placeholders = ", ".join("?" for _ in ids)
|
|
async with self._db.execute(
|
|
f"""SELECT work_item_id, runtime_task_id
|
|
FROM work_item_runtime_links
|
|
WHERE work_item_id IN ({placeholders})""",
|
|
ids,
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return {
|
|
str(work_item_id or "").strip(): str(task_id or "").strip()
|
|
for work_item_id, task_id in rows
|
|
if str(work_item_id or "").strip() and str(task_id or "").strip()
|
|
}
|
|
|
|
async def get_runtime_task_for_work_item(self, work_item_id: str) -> Task | None:
|
|
if self._db is None:
|
|
return None
|
|
wid = str(work_item_id or "").strip()
|
|
if not wid:
|
|
return None
|
|
task_id = await self._runtime_link_task_id_for_work_item(wid)
|
|
if not task_id:
|
|
return None
|
|
task = await self._get_task_unhydrated(task_id)
|
|
if task is None:
|
|
return None
|
|
set_linked_work_item_id(task, wid)
|
|
return task
|
|
|
|
async def get_work_item_for_runtime_task(self, task_id: str) -> DelegationWorkItem | None:
|
|
if self._db is None:
|
|
return None
|
|
tid = str(task_id or "").strip()
|
|
if not tid:
|
|
return None
|
|
work_item_id = await self._runtime_link_work_item_id_for_task(tid)
|
|
if not work_item_id:
|
|
return None
|
|
return await self.get_delegation_work_item(work_item_id)
|
|
|
|
async def get_task(self, task_id: str) -> Task | None:
|
|
assert self._db
|
|
task = await self._get_task_unhydrated(task_id)
|
|
if task is None:
|
|
return None
|
|
await self.hydrate_task_work_item_links([task])
|
|
return task
|
|
|
|
async def get_tasks(
|
|
self,
|
|
project_id: str | None = None,
|
|
status: TaskStatus | None = None,
|
|
parent_id: str | None = None,
|
|
) -> list[Task]:
|
|
assert self._db
|
|
query = "SELECT * FROM tasks WHERE 1=1"
|
|
params: list[Any] = []
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status.value)
|
|
if parent_id is not None:
|
|
query += " AND parent_id = ?"
|
|
params.append(parent_id)
|
|
query += " ORDER BY priority ASC, created_at ASC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
tasks = [self._row_to_task(row, cursor.description) for row in rows]
|
|
await self.hydrate_task_work_item_links(tasks)
|
|
return tasks
|
|
|
|
async def get_tasks_by_session_id(
|
|
self,
|
|
session_id: str,
|
|
project_id: str | None = None,
|
|
) -> list[Task]:
|
|
assert self._db
|
|
query = "SELECT * FROM tasks WHERE session_id = ?"
|
|
params: list[Any] = [session_id]
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
query += " ORDER BY priority ASC, created_at ASC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
tasks = [self._row_to_task(row, cursor.description) for row in rows]
|
|
await self.hydrate_task_work_item_links(tasks)
|
|
return tasks
|
|
|
|
async def update_task_status(self, task_id: str, status: TaskStatus) -> None:
|
|
assert self._db
|
|
await self._db.execute("UPDATE tasks SET status = ? WHERE id = ?", (status.value, task_id))
|
|
await self._db.commit()
|
|
|
|
async def append_task_comment(self, task_id: str, comment: dict[str, Any]) -> None:
|
|
task = await self.get_task(task_id)
|
|
if not task:
|
|
return
|
|
task.comments = list(task.comments)
|
|
task.comments.append(comment)
|
|
await self.save_task(task)
|
|
|
|
async def acquire_task_lock(self, task_id: str, *, lease_seconds: int | None = None) -> bool:
|
|
"""Atomically acquire the execution lock on a task.
|
|
|
|
When ``lease_seconds`` is provided, a stale lock (one whose
|
|
``execution_locked_at`` timestamp is older than ``now - lease_seconds``)
|
|
can be stolen. This lets a new process claim an execution slot whose
|
|
original holder died without releasing — the common crash-recovery case.
|
|
|
|
Always refreshes ``execution_locked_at`` to ``now()`` on successful
|
|
acquire, so the returned lease starts fresh.
|
|
"""
|
|
assert self._db
|
|
now_iso = datetime.now().isoformat()
|
|
if lease_seconds is not None and lease_seconds > 0:
|
|
cutoff_iso = (datetime.now() - timedelta(seconds=int(lease_seconds))).isoformat()
|
|
query = (
|
|
"UPDATE tasks SET execution_lock = 1, execution_locked_at = ? "
|
|
"WHERE id = ? AND ("
|
|
"execution_lock = 0 "
|
|
"OR execution_locked_at IS NULL "
|
|
"OR execution_locked_at < ?"
|
|
")"
|
|
)
|
|
params: tuple[Any, ...] = (now_iso, task_id, cutoff_iso)
|
|
else:
|
|
query = (
|
|
"UPDATE tasks SET execution_lock = 1, execution_locked_at = ? "
|
|
"WHERE id = ? AND execution_lock = 0"
|
|
)
|
|
params = (now_iso, task_id)
|
|
async with self._db.execute(query, params) as cursor:
|
|
await self._db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
async def renew_task_lock(self, task_id: str) -> bool:
|
|
"""Refresh ``execution_locked_at`` for a live running task.
|
|
|
|
This is the heartbeat used by the WS handler while it is actively
|
|
processing a session message. It only updates rows whose ``status`` is
|
|
still ``running`` (anything else has already ended and must not keep a
|
|
live timestamp), and deliberately ignores ``execution_lock``: that bit
|
|
is set only by delegation checkout, while the office_ui dispatch path
|
|
serializes through an in-memory asyncio.Lock and never flips it. The
|
|
refreshed ``execution_locked_at`` is what ``reset_orphan_running_tasks``
|
|
compares against on startup to distinguish live work from abandoned
|
|
``running`` rows.
|
|
|
|
Returns ``True`` when the row was still ``status=running`` (heartbeat
|
|
extended), ``False`` otherwise (task ended or gone — caller should
|
|
stop heartbeating).
|
|
"""
|
|
assert self._db
|
|
now_iso = datetime.now().isoformat()
|
|
async with self._db.execute(
|
|
"UPDATE tasks SET execution_locked_at = ? WHERE id = ? AND status = 'running'",
|
|
(now_iso, task_id),
|
|
) as cursor:
|
|
await self._db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
async def release_task_lock(self, task_id: str) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"UPDATE tasks SET execution_lock = 0, execution_locked_at = NULL WHERE id = ?",
|
|
(task_id,),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def reset_orphan_running_tasks(self, *, lease_seconds: int = 300) -> dict[str, int]:
|
|
"""Reset ``status=running`` tasks abandoned by a prior process.
|
|
|
|
Safe single-process assumption: when this runs during server startup,
|
|
any task that still claims ``status=running`` must be an orphan — its
|
|
worker coroutine died with the previous process. We revert it to
|
|
``idle`` so the UI can Continue it, and clear any stale execution lock
|
|
regardless of status when the lease has expired.
|
|
|
|
Returns a summary dict with keys ``statuses_reset`` and
|
|
``locks_cleared`` giving the affected row counts.
|
|
"""
|
|
assert self._db
|
|
cutoff_iso = (datetime.now() - timedelta(seconds=int(lease_seconds))).isoformat()
|
|
async with self._db.execute(
|
|
"UPDATE tasks SET status = 'idle', execution_lock = 0, execution_locked_at = NULL "
|
|
"WHERE status = 'running' AND ("
|
|
"execution_locked_at IS NULL OR execution_locked_at < ?"
|
|
")",
|
|
(cutoff_iso,),
|
|
) as cursor:
|
|
statuses_reset = cursor.rowcount or 0
|
|
async with self._db.execute(
|
|
"UPDATE tasks SET execution_lock = 0, execution_locked_at = NULL "
|
|
"WHERE execution_lock = 1 AND execution_locked_at IS NOT NULL AND execution_locked_at < ?",
|
|
(cutoff_iso,),
|
|
) as cursor:
|
|
locks_cleared = cursor.rowcount or 0
|
|
await self._db.commit()
|
|
return {"statuses_reset": statuses_reset, "locks_cleared": locks_cleared}
|
|
|
|
def _row_to_task(self, row: Any, description: Any) -> Task:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return Task(
|
|
id=data["id"],
|
|
session_id=data.get("session_id"),
|
|
parent_session_id=data.get("parent_session_id"),
|
|
title=data["title"],
|
|
description=data["description"],
|
|
assigned_to=data["assigned_to"],
|
|
status=TaskStatus(data["status"]),
|
|
priority=data["priority"],
|
|
dependencies=_json_loads(data["dependencies"], []),
|
|
execution_lock=bool(data["execution_lock"]),
|
|
context_snapshot=_json_loads(data["context_snapshot"], {}),
|
|
assigned_external_agent=data["assigned_external_agent"],
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
deadline=datetime.fromisoformat(data["deadline"]) if data["deadline"] else None,
|
|
result=_json_loads(data["result"], None),
|
|
parent_id=data["parent_id"],
|
|
project_id=data["project_id"],
|
|
tags=_json_loads(data["tags"], []),
|
|
comments=_json_loads(data["comments"], []),
|
|
retry_count=data["retry_count"],
|
|
max_retries=data["max_retries"],
|
|
metadata=_json_loads(data["metadata"], {}),
|
|
org_id=data.get("org_id"),
|
|
goal_id=data.get("goal_id"),
|
|
checkout_run_id=data.get("checkout_run_id"),
|
|
execution_locked_at=(
|
|
datetime.fromisoformat(data["execution_locked_at"])
|
|
if data.get("execution_locked_at")
|
|
else None
|
|
),
|
|
)
|
|
|
|
# --- Agent Messages ---
|
|
|
|
async def save_message(self, msg: AgentMessage) -> None:
|
|
assert self._db
|
|
metadata = {
|
|
**dict(msg.metadata or {}),
|
|
"transport_kind": getattr(getattr(msg, "transport_kind", None), "value", getattr(msg, "transport_kind", "")) or "",
|
|
"semantic_type": getattr(getattr(msg, "semantic_type", None), "value", getattr(msg, "semantic_type", "")) or "",
|
|
"comms_state": getattr(getattr(msg, "comms_state", None), "value", getattr(msg, "comms_state", "")) or "",
|
|
"correlation_id": str(getattr(msg, "correlation_id", "") or "").strip(),
|
|
"refs": dict(getattr(msg, "refs", {}) or {}),
|
|
}
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO agent_messages
|
|
(msg_id, msg_type, from_agent, to_agents, subject, body, context_ref, urgency,
|
|
reply_needed, requires_ack, timeout_action, reply_to_msg_id, task_id, status,
|
|
timestamp, processed_at, metadata)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
msg.msg_id,
|
|
msg.msg_type,
|
|
msg.from_agent,
|
|
_json_dumps(msg.to_agents),
|
|
msg.subject,
|
|
msg.body,
|
|
msg.context_ref,
|
|
msg.urgency.value,
|
|
int(msg.reply_needed),
|
|
int(msg.requires_ack),
|
|
msg.timeout_action,
|
|
msg.reply_to_msg_id,
|
|
msg.task_id,
|
|
msg.status.value,
|
|
msg.timestamp.isoformat(),
|
|
msg.processed_at.isoformat() if msg.processed_at else None,
|
|
_json_dumps(metadata),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_message(self, row: Any, description: Any) -> AgentMessage:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
metadata = _json_loads(data.get("metadata"), {})
|
|
return AgentMessage(
|
|
msg_id=data["msg_id"],
|
|
msg_type=data["msg_type"],
|
|
from_agent=data["from_agent"],
|
|
to_agents=_json_loads(data["to_agents"], []),
|
|
subject=data["subject"],
|
|
body=data["body"],
|
|
context_ref=data["context_ref"],
|
|
urgency=MessageUrgency(data["urgency"]),
|
|
reply_needed=bool(data["reply_needed"]),
|
|
requires_ack=bool(data.get("requires_ack", 0)),
|
|
timeout_action=data["timeout_action"],
|
|
reply_to_msg_id=data.get("reply_to_msg_id"),
|
|
task_id=data.get("task_id"),
|
|
status=MessageStatus(data.get("status") or MessageStatus.SENT.value),
|
|
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
processed_at=datetime.fromisoformat(data["processed_at"]) if data.get("processed_at") else None,
|
|
transport_kind=CommsTransportKind(str(metadata.get("transport_kind") or CommsTransportKind.DM.value)),
|
|
semantic_type=CommsSemanticType(str(metadata.get("semantic_type") or CommsSemanticType.WORK_UPDATE.value)),
|
|
comms_state=CommsState(str(metadata.get("comms_state") or CommsState.OPEN.value)),
|
|
correlation_id=str(metadata.get("correlation_id", "") or "").strip(),
|
|
refs=dict(metadata.get("refs", {}) or {}),
|
|
metadata=metadata,
|
|
)
|
|
|
|
async def get_message(self, msg_id: str) -> AgentMessage | None:
|
|
assert self._db
|
|
async with self._db.execute("SELECT * FROM agent_messages WHERE msg_id = ?", (msg_id,)) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_message(row, cursor.description)
|
|
|
|
async def update_message_status(
|
|
self,
|
|
msg_id: str,
|
|
status: MessageStatus,
|
|
processed_at: datetime | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"UPDATE agent_messages SET status = ?, processed_at = ? WHERE msg_id = ?",
|
|
(status.value, processed_at.isoformat() if processed_at else None, msg_id),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_messages_for_agent(
|
|
self,
|
|
agent_id: str,
|
|
limit: int = 20,
|
|
unread_only: bool = False,
|
|
task_id: str | None = None,
|
|
task_ids: list[str] | None = None,
|
|
) -> list[AgentMessage]:
|
|
assert self._db
|
|
query = """SELECT * FROM agent_messages
|
|
WHERE to_agents LIKE ?"""
|
|
params: list[Any] = [f'%"{agent_id}"%']
|
|
if unread_only:
|
|
query += " AND status IN (?, ?)"
|
|
params.extend([MessageStatus.SENT.value, MessageStatus.DELIVERED.value])
|
|
scope_ids = [str(item).strip() for item in list(task_ids or []) if str(item).strip()]
|
|
if not scope_ids and task_id:
|
|
scope_ids = [str(task_id).strip()]
|
|
if scope_ids:
|
|
scope_clauses: list[str] = []
|
|
for scope_id in scope_ids:
|
|
scope_clauses.append("(task_id = ? OR context_ref = ?)")
|
|
params.extend([scope_id, scope_id])
|
|
query += " AND (" + " OR ".join(scope_clauses) + ")"
|
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_message(row, cursor.description) for row in rows]
|
|
|
|
async def get_outbox_for_agent(
|
|
self,
|
|
agent_id: str,
|
|
limit: int = 20,
|
|
task_id: str | None = None,
|
|
task_ids: list[str] | None = None,
|
|
) -> list[AgentMessage]:
|
|
assert self._db
|
|
query = "SELECT * FROM agent_messages WHERE from_agent = ?"
|
|
params: list[Any] = [agent_id]
|
|
scope_ids = [str(item).strip() for item in list(task_ids or []) if str(item).strip()]
|
|
if not scope_ids and task_id:
|
|
scope_ids = [str(task_id).strip()]
|
|
if scope_ids:
|
|
scope_clauses: list[str] = []
|
|
for scope_id in scope_ids:
|
|
scope_clauses.append("(task_id = ? OR context_ref = ?)")
|
|
params.extend([scope_id, scope_id])
|
|
query += " AND (" + " OR ".join(scope_clauses) + ")"
|
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_message(row, cursor.description) for row in rows]
|
|
|
|
async def list_agent_messages_for_tasks(
|
|
self,
|
|
task_ids: list[str],
|
|
limit: int = 50,
|
|
) -> list[AgentMessage]:
|
|
assert self._db
|
|
clean_ids = [str(item).strip() for item in list(task_ids or []) if str(item).strip()]
|
|
if not clean_ids:
|
|
return []
|
|
clauses: list[str] = []
|
|
params: list[Any] = []
|
|
for task_id in clean_ids:
|
|
clauses.append("(task_id = ? OR context_ref = ?)")
|
|
params.extend([task_id, task_id])
|
|
query = "SELECT * FROM agent_messages WHERE " + " OR ".join(clauses)
|
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(max(1, int(limit or 50)))
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_message(row, cursor.description) for row in rows]
|
|
|
|
async def get_replies_for_message(self, msg_id: str) -> list[AgentMessage]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM agent_messages WHERE reply_to_msg_id = ? ORDER BY timestamp ASC",
|
|
(msg_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_message(row, cursor.description) for row in rows]
|
|
|
|
async def get_latest_reply(self, msg_id: str) -> AgentMessage | None:
|
|
replies = await self.get_replies_for_message(msg_id)
|
|
return replies[-1] if replies else None
|
|
|
|
async def get_unprocessed_messages(self, limit: int = 200) -> list[AgentMessage]:
|
|
"""Return messages with status SENT or DELIVERED (not yet read/replied/timed out)."""
|
|
assert self._db
|
|
query = """SELECT * FROM agent_messages
|
|
WHERE status IN (?, ?)
|
|
ORDER BY timestamp ASC LIMIT ?"""
|
|
params = [MessageStatus.SENT.value, MessageStatus.DELIVERED.value, limit]
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_message(row, cursor.description) for row in rows]
|
|
|
|
# --- Meetings ---
|
|
|
|
async def save_meeting(self, meeting: MeetingRoom) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO meetings
|
|
(room_id, task_id, topic, participants, shared_context, agenda, max_rounds,
|
|
decision_owner, status, decision_method, current_round, pending_participants,
|
|
consensus, outcome, transcript, metadata, created_at, updated_at,
|
|
last_activity_at, deadline_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
meeting.room_id,
|
|
meeting.task_id,
|
|
meeting.topic,
|
|
_json_dumps(meeting.participants),
|
|
meeting.shared_context,
|
|
_json_dumps(meeting.agenda),
|
|
meeting.max_rounds,
|
|
meeting.decision_owner,
|
|
meeting.status.value,
|
|
meeting.decision_method,
|
|
int(meeting.current_round or 0),
|
|
_json_dumps(meeting.pending_participants),
|
|
_json_dumps(meeting.consensus),
|
|
_json_dumps(meeting.outcome) if meeting.outcome is not None else None,
|
|
_json_dumps(meeting.transcript),
|
|
_json_dumps(meeting.metadata),
|
|
meeting.created_at.isoformat(),
|
|
meeting.updated_at.isoformat(),
|
|
meeting.last_activity_at.isoformat(),
|
|
meeting.deadline_at.isoformat() if meeting.deadline_at else None,
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_meeting(self, room_id: str) -> MeetingRoom | None:
|
|
assert self._db
|
|
async with self._db.execute("SELECT * FROM meetings WHERE room_id = ?", (room_id,)) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_meeting(row, cursor.description)
|
|
|
|
async def get_meetings_for_task(self, task_id: str) -> list[MeetingRoom]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM meetings WHERE task_id = ? ORDER BY updated_at DESC",
|
|
(task_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_meeting(row, cursor.description) for row in rows]
|
|
|
|
def _row_to_meeting(self, row: Any, description: Any) -> MeetingRoom:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return MeetingRoom(
|
|
room_id=data["room_id"],
|
|
task_id=data.get("task_id"),
|
|
topic=data["topic"],
|
|
participants=_json_loads(data["participants"], []),
|
|
shared_context=data["shared_context"],
|
|
agenda=_json_loads(data["agenda"], []),
|
|
max_rounds=data["max_rounds"],
|
|
decision_owner=data["decision_owner"],
|
|
status=MeetingStatus(data.get("status") or MeetingStatus.OPEN.value),
|
|
decision_method=str(data.get("decision_method", "") or ""),
|
|
current_round=int(data.get("current_round") or 0),
|
|
pending_participants=_json_loads(data.get("pending_participants"), []),
|
|
consensus=_json_loads(data.get("consensus"), {}),
|
|
outcome=_json_loads(data["outcome"], None),
|
|
transcript=_json_loads(data["transcript"], []),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
last_activity_at=datetime.fromisoformat(data.get("last_activity_at") or data["updated_at"]),
|
|
deadline_at=datetime.fromisoformat(data["deadline_at"]) if data.get("deadline_at") else None,
|
|
)
|
|
|
|
# --- Work-item decisions, artifacts, role memory, handoffs ---
|
|
|
|
async def record_work_item_decision(self, record: WorkItemDecisionRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO work_item_decisions
|
|
(decision_id, project_id, task_id, role_id, projection_id, category, summary, details, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.decision_id,
|
|
record.project_id,
|
|
record.task_id,
|
|
record.role_id,
|
|
record.projection_id,
|
|
record.category,
|
|
record.summary,
|
|
_json_dumps(record.details),
|
|
record.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_work_item_decisions(
|
|
self,
|
|
project_id: str,
|
|
projection_id: str | None = None,
|
|
limit: int = 20,
|
|
) -> list[WorkItemDecisionRecord]:
|
|
assert self._db
|
|
query = "SELECT * FROM work_item_decisions WHERE project_id = ?"
|
|
params: list[Any] = [project_id]
|
|
if projection_id:
|
|
query += " AND projection_id = ?"
|
|
params.append(projection_id)
|
|
query += " ORDER BY created_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
WorkItemDecisionRecord(
|
|
decision_id=data["decision_id"],
|
|
project_id=data["project_id"],
|
|
task_id=data["task_id"],
|
|
role_id=data["role_id"],
|
|
projection_id=data["projection_id"],
|
|
category=data["category"],
|
|
summary=data["summary"],
|
|
details=_json_loads(data["details"], {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
async def record_artifact(self, record: ArtifactRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO artifact_records
|
|
(artifact_id, project_id, task_id, projection_id, role_id, name, artifact_type, location, status, details, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.artifact_id,
|
|
record.project_id,
|
|
record.task_id,
|
|
record.projection_id,
|
|
record.role_id,
|
|
record.name,
|
|
record.artifact_type,
|
|
record.location,
|
|
record.status,
|
|
_json_dumps(record.details),
|
|
record.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_artifacts(
|
|
self,
|
|
project_id: str,
|
|
projection_id: str | None = None,
|
|
limit: int = 50,
|
|
) -> list[ArtifactRecord]:
|
|
assert self._db
|
|
query = "SELECT * FROM artifact_records WHERE project_id = ?"
|
|
params: list[Any] = [project_id]
|
|
if projection_id:
|
|
query += " AND projection_id = ?"
|
|
params.append(projection_id)
|
|
query += " ORDER BY created_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
ArtifactRecord(
|
|
artifact_id=data["artifact_id"],
|
|
project_id=data["project_id"],
|
|
task_id=data["task_id"],
|
|
projection_id=data["projection_id"],
|
|
role_id=data["role_id"],
|
|
name=data["name"],
|
|
artifact_type=data["artifact_type"],
|
|
location=data["location"],
|
|
status=data["status"],
|
|
details=_json_loads(data["details"], {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
async def record_role_memory(self, record: RoleMemoryRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO role_memory
|
|
(memory_id, project_id, role_id, scope, summary, details, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.memory_id,
|
|
record.project_id,
|
|
record.role_id,
|
|
record.scope,
|
|
record.summary,
|
|
_json_dumps(record.details),
|
|
record.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_role_memory(
|
|
self,
|
|
project_id: str,
|
|
role_id: str,
|
|
limit: int = 10,
|
|
) -> list[RoleMemoryRecord]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"""SELECT * FROM role_memory
|
|
WHERE project_id = ? AND role_id = ?
|
|
ORDER BY created_at DESC LIMIT ?""",
|
|
(project_id, role_id, limit),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
RoleMemoryRecord(
|
|
memory_id=data["memory_id"],
|
|
project_id=data["project_id"],
|
|
role_id=data["role_id"],
|
|
scope=data["scope"],
|
|
summary=data["summary"],
|
|
details=_json_loads(data["details"], {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
async def save_handoff_record(self, record: HandoffRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO handoff_records
|
|
(handoff_id, project_id, session_id, task_id, from_role, to_role, source_projection_id, target_projection_id,
|
|
source_work_item_id, target_work_item_id, summary, payload, requires_ack, status, received_at, acked_at, accepted_at, rejected_at,
|
|
response_summary, ack_message_id, response_message_id, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.handoff_id,
|
|
record.project_id,
|
|
record.session_id,
|
|
record.task_id,
|
|
record.from_role,
|
|
record.to_role,
|
|
record.source_projection_id,
|
|
record.target_projection_id,
|
|
record.source_work_item_id,
|
|
record.target_work_item_id,
|
|
record.summary,
|
|
_json_dumps(record.payload),
|
|
int(record.requires_ack),
|
|
record.status,
|
|
record.received_at.isoformat() if record.received_at else None,
|
|
record.acked_at.isoformat() if record.acked_at else None,
|
|
record.accepted_at.isoformat() if record.accepted_at else None,
|
|
record.rejected_at.isoformat() if record.rejected_at else None,
|
|
record.response_summary,
|
|
record.ack_message_id,
|
|
record.response_message_id,
|
|
_json_dumps(record.metadata),
|
|
record.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_handoff_record(self, row: Any, description: Any) -> HandoffRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return HandoffRecord(
|
|
handoff_id=data["handoff_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data.get("session_id"),
|
|
task_id=data["task_id"],
|
|
from_role=data["from_role"],
|
|
to_role=data["to_role"],
|
|
source_projection_id=data["source_projection_id"],
|
|
target_projection_id=data["target_projection_id"],
|
|
source_work_item_id=data.get("source_work_item_id") or "",
|
|
target_work_item_id=data.get("target_work_item_id") or "",
|
|
summary=data["summary"],
|
|
payload=_json_loads(data["payload"], {}),
|
|
requires_ack=bool(data.get("requires_ack", 0)),
|
|
status=data.get("status") or "sent",
|
|
received_at=datetime.fromisoformat(data["received_at"]) if data.get("received_at") else None,
|
|
acked_at=datetime.fromisoformat(data["acked_at"]) if data.get("acked_at") else None,
|
|
accepted_at=datetime.fromisoformat(data["accepted_at"]) if data.get("accepted_at") else None,
|
|
rejected_at=datetime.fromisoformat(data["rejected_at"]) if data.get("rejected_at") else None,
|
|
response_summary=data.get("response_summary") or "",
|
|
ack_message_id=data.get("ack_message_id"),
|
|
response_message_id=data.get("response_message_id"),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
async def get_handoff_record(self, handoff_id: str) -> HandoffRecord | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM handoff_records WHERE handoff_id = ?",
|
|
(handoff_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_handoff_record(row, cursor.description)
|
|
|
|
async def get_handoff_records(
|
|
self,
|
|
project_id: str,
|
|
target_projection_id: str | None = None,
|
|
target_work_item_id: str | None = None,
|
|
task_id: str | None = None,
|
|
status: str | None = None,
|
|
limit: int = 20,
|
|
) -> list[HandoffRecord]:
|
|
assert self._db
|
|
query = "SELECT * FROM handoff_records WHERE project_id = ?"
|
|
params: list[Any] = [project_id]
|
|
if target_projection_id:
|
|
query += " AND target_projection_id = ?"
|
|
params.append(target_projection_id)
|
|
if target_work_item_id:
|
|
query += " AND target_work_item_id = ?"
|
|
params.append(target_work_item_id)
|
|
if task_id:
|
|
query += " AND task_id = ?"
|
|
params.append(task_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
query += " ORDER BY created_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_handoff_record(row, cursor.description) for row in rows]
|
|
|
|
async def update_handoff_record(
|
|
self,
|
|
handoff_id: str,
|
|
*,
|
|
status: str | None = None,
|
|
received_at: datetime | None = None,
|
|
acked_at: datetime | None = None,
|
|
accepted_at: datetime | None = None,
|
|
rejected_at: datetime | None = None,
|
|
response_summary: str | None = None,
|
|
ack_message_id: str | None = None,
|
|
response_message_id: str | None = None,
|
|
metadata_updates: dict[str, Any] | None = None,
|
|
) -> HandoffRecord | None:
|
|
record = await self.get_handoff_record(handoff_id)
|
|
if record is None:
|
|
return None
|
|
if status is not None:
|
|
record.status = str(status).strip() or record.status
|
|
if received_at is not None:
|
|
record.received_at = received_at
|
|
if acked_at is not None:
|
|
record.acked_at = acked_at
|
|
if accepted_at is not None:
|
|
record.accepted_at = accepted_at
|
|
if rejected_at is not None:
|
|
record.rejected_at = rejected_at
|
|
if response_summary is not None:
|
|
record.response_summary = str(response_summary)
|
|
if ack_message_id is not None:
|
|
record.ack_message_id = ack_message_id
|
|
if response_message_id is not None:
|
|
record.response_message_id = response_message_id
|
|
if metadata_updates:
|
|
record.metadata = {**dict(record.metadata or {}), **dict(metadata_updates)}
|
|
await self.save_handoff_record(record)
|
|
return record
|
|
|
|
def _row_to_role_runtime_session(self, row: Any, description: Any) -> RoleRuntimeSession:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
focused_work_item_id = data.get("focused_work_item_id") or ""
|
|
status = normalize_role_runtime_status(data.get("status"), focused_work_item_id)
|
|
if status == "idle":
|
|
focused_work_item_id = ""
|
|
return RoleRuntimeSession(
|
|
role_session_id=data["role_session_id"],
|
|
run_id=data["run_id"],
|
|
project_id=data.get("project_id") or "default",
|
|
team_instance_id=data.get("team_instance_id") or "",
|
|
team_id=data.get("team_id") or "",
|
|
role_id=data.get("role_id") or "",
|
|
seat_id=data.get("seat_id") or "",
|
|
seat_state_id=data.get("seat_state_id") or "",
|
|
employee_id=data.get("employee_id") or "",
|
|
focused_work_item_id=focused_work_item_id,
|
|
background_work_item_ids=_json_loads(data.get("background_work_item_ids"), []),
|
|
manager_role_ids=_json_loads(data.get("manager_role_ids"), []),
|
|
manager_seat_ids=_json_loads(data.get("manager_seat_ids"), []),
|
|
seat_ids=_json_loads(data.get("seat_ids"), []),
|
|
adapter_session_state=_json_loads(data.get("adapter_session_state"), {}),
|
|
inbox_state=_json_loads(data.get("inbox_state"), {}),
|
|
memory_slices_by_work_item=_json_loads(data.get("memory_slices_by_work_item"), {}),
|
|
resume_state=_json_loads(data.get("resume_state"), {}),
|
|
current_work_item=_json_loads(data.get("current_work_item"), {}),
|
|
latest_notification=_json_loads(data.get("latest_notification"), {}),
|
|
manager_digest=_json_loads(data.get("manager_digest"), {}),
|
|
status=status,
|
|
pending_work_item_ids=_json_loads(data.get("pending_work_item_ids"), []),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def _save_role_runtime_session_row(self, session: RoleRuntimeSession, *, table: str) -> None:
|
|
db = self._require_db()
|
|
session.focused_work_item_id = str(session.focused_work_item_id or "").strip()
|
|
session.status = normalize_role_runtime_status(
|
|
session.status,
|
|
session.focused_work_item_id,
|
|
)
|
|
if session.status == "idle":
|
|
session.focused_work_item_id = ""
|
|
await db.execute(
|
|
f"""INSERT OR REPLACE INTO {table}
|
|
(role_session_id, run_id, project_id, team_instance_id, team_id, role_id, seat_id, seat_state_id,
|
|
employee_id, focused_work_item_id, background_work_item_ids, manager_role_ids, manager_seat_ids,
|
|
seat_ids, adapter_session_state, inbox_state, memory_slices_by_work_item, resume_state,
|
|
current_work_item, latest_notification, manager_digest, status, pending_work_item_ids,
|
|
metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
session.role_session_id,
|
|
session.run_id,
|
|
session.project_id,
|
|
session.team_instance_id,
|
|
session.team_id,
|
|
session.role_id,
|
|
session.seat_id,
|
|
session.seat_state_id,
|
|
session.employee_id,
|
|
session.focused_work_item_id,
|
|
_json_dumps(session.background_work_item_ids),
|
|
_json_dumps(session.manager_role_ids),
|
|
_json_dumps(session.manager_seat_ids),
|
|
_json_dumps(session.seat_ids),
|
|
_json_dumps(session.adapter_session_state),
|
|
_json_dumps(session.inbox_state),
|
|
_json_dumps(session.memory_slices_by_work_item),
|
|
_json_dumps(session.resume_state),
|
|
_json_dumps(session.current_work_item),
|
|
_json_dumps(session.latest_notification),
|
|
_json_dumps(session.manager_digest),
|
|
session.status,
|
|
_json_dumps(list(getattr(session, "pending_work_item_ids", []) or [])),
|
|
_json_dumps(session.metadata),
|
|
session.created_at.isoformat(),
|
|
session.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
|
|
async def save_role_runtime_session(self, session: RoleRuntimeSession) -> None:
|
|
await self._save_role_runtime_session_row(session, table="role_runtime_sessions")
|
|
await self._save_role_runtime_session_row(session, table="delegation_role_sessions")
|
|
|
|
async def save_delegation_role_session(self, session: RoleRuntimeSession) -> None:
|
|
await self.save_role_runtime_session(session)
|
|
|
|
async def get_role_runtime_session(self, role_session_id: str) -> RoleRuntimeSession | None:
|
|
db = self._require_db()
|
|
for table in ("role_runtime_sessions", "delegation_role_sessions"):
|
|
async with db.execute(
|
|
f"SELECT * FROM {table} WHERE role_session_id = ?",
|
|
(role_session_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is not None:
|
|
return self._row_to_role_runtime_session(row, cursor.description)
|
|
return None
|
|
|
|
async def get_delegation_role_session(self, role_session_id: str) -> RoleRuntimeSession | None:
|
|
return await self.get_role_runtime_session(role_session_id)
|
|
|
|
async def get_delegation_role_session_for_role(
|
|
self,
|
|
run_id: str,
|
|
role_id: str,
|
|
*,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
) -> RoleRuntimeSession | None:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM delegation_role_sessions WHERE run_id = ? AND role_id = ?"
|
|
params: list[Any] = [run_id, role_id]
|
|
if team_id:
|
|
query += " AND team_id = ?"
|
|
params.append(team_id)
|
|
if seat_id:
|
|
query += " AND seat_id = ?"
|
|
params.append(seat_id)
|
|
query += " ORDER BY created_at ASC LIMIT 1"
|
|
async with db.execute(query, params) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is not None:
|
|
return self._row_to_role_runtime_session(row, cursor.description)
|
|
return None
|
|
|
|
async def get_role_runtime_session_for_role(
|
|
self,
|
|
run_id: str,
|
|
role_id: str,
|
|
*,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
) -> RoleRuntimeSession | None:
|
|
return await self.get_delegation_role_session_for_role(
|
|
run_id,
|
|
role_id,
|
|
team_id=team_id,
|
|
seat_id=seat_id,
|
|
)
|
|
|
|
async def list_role_runtime_sessions(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
role_id: str | None = None,
|
|
status: str | None = None,
|
|
) -> list[RoleRuntimeSession]:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM role_runtime_sessions WHERE run_id = ?"
|
|
params: list[Any] = [run_id]
|
|
if team_id:
|
|
query += " AND team_id = ?"
|
|
params.append(team_id)
|
|
if seat_id:
|
|
query += " AND seat_id = ?"
|
|
params.append(seat_id)
|
|
if role_id:
|
|
query += " AND role_id = ?"
|
|
params.append(role_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
query += " ORDER BY created_at ASC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_role_runtime_session(row, cursor.description) for row in rows]
|
|
|
|
async def list_delegation_role_sessions(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
role_id: str | None = None,
|
|
status: str | None = None,
|
|
) -> list[RoleRuntimeSession]:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM delegation_role_sessions WHERE run_id = ?"
|
|
params: list[Any] = [run_id]
|
|
if team_id:
|
|
query += " AND team_id = ?"
|
|
params.append(team_id)
|
|
if seat_id:
|
|
query += " AND seat_id = ?"
|
|
params.append(seat_id)
|
|
if role_id:
|
|
query += " AND role_id = ?"
|
|
params.append(role_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
query += " ORDER BY created_at ASC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_role_runtime_session(row, cursor.description) for row in rows]
|
|
|
|
async def update_delegation_role_session(
|
|
self,
|
|
role_session_id: str,
|
|
*,
|
|
team_instance_id: str | None = None,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
seat_state_id: str | None = None,
|
|
focused_work_item_id: str | None = None,
|
|
background_work_item_ids: list[str] | None = None,
|
|
manager_role_ids: list[str] | None = None,
|
|
manager_seat_ids: list[str] | None = None,
|
|
seat_ids: list[str] | None = None,
|
|
adapter_session_state: dict[str, Any] | None = None,
|
|
inbox_state: dict[str, Any] | None = None,
|
|
memory_slices_by_work_item: dict[str, list[str]] | None = None,
|
|
resume_state: dict[str, Any] | None = None,
|
|
current_work_item: dict[str, Any] | None = None,
|
|
latest_notification: dict[str, Any] | None = None,
|
|
manager_digest: dict[str, Any] | None = None,
|
|
status: str | None = None,
|
|
metadata_updates: dict[str, Any] | None = None,
|
|
) -> RoleRuntimeSession | None:
|
|
session = await self.get_delegation_role_session(role_session_id)
|
|
if session is None:
|
|
return None
|
|
if team_instance_id is not None:
|
|
session.team_instance_id = str(team_instance_id or "").strip()
|
|
if team_id is not None:
|
|
session.team_id = str(team_id or "").strip()
|
|
if seat_id is not None:
|
|
session.seat_id = str(seat_id or "").strip()
|
|
if seat_state_id is not None:
|
|
session.seat_state_id = str(seat_state_id or "").strip()
|
|
if focused_work_item_id is not None:
|
|
session.focused_work_item_id = str(focused_work_item_id or "").strip()
|
|
if background_work_item_ids is not None:
|
|
session.background_work_item_ids = [
|
|
str(item).strip() for item in background_work_item_ids if str(item).strip()
|
|
]
|
|
if manager_role_ids is not None:
|
|
session.manager_role_ids = [str(item).strip() for item in manager_role_ids if str(item).strip()]
|
|
if manager_seat_ids is not None:
|
|
session.manager_seat_ids = [str(item).strip() for item in manager_seat_ids if str(item).strip()]
|
|
if seat_ids is not None:
|
|
session.seat_ids = [str(item).strip() for item in seat_ids if str(item).strip()]
|
|
if adapter_session_state is not None:
|
|
session.adapter_session_state = dict(adapter_session_state)
|
|
if inbox_state is not None:
|
|
session.inbox_state = dict(inbox_state)
|
|
if memory_slices_by_work_item is not None:
|
|
session.memory_slices_by_work_item = {
|
|
str(key).strip(): [str(item).strip() for item in list(value or []) if str(item).strip()]
|
|
for key, value in dict(memory_slices_by_work_item or {}).items()
|
|
if str(key).strip()
|
|
}
|
|
if resume_state is not None:
|
|
session.resume_state = dict(resume_state)
|
|
if current_work_item is not None:
|
|
session.current_work_item = dict(current_work_item)
|
|
if latest_notification is not None:
|
|
session.latest_notification = dict(latest_notification)
|
|
if manager_digest is not None:
|
|
session.manager_digest = dict(manager_digest)
|
|
if status is not None:
|
|
session.status = normalize_role_runtime_status(
|
|
status,
|
|
session.focused_work_item_id,
|
|
)
|
|
if metadata_updates:
|
|
session.metadata = {**dict(session.metadata or {}), **dict(metadata_updates)}
|
|
session.status = normalize_role_runtime_status(
|
|
session.status,
|
|
session.focused_work_item_id,
|
|
)
|
|
if session.status == "idle":
|
|
session.focused_work_item_id = ""
|
|
session.updated_at = datetime.now()
|
|
await self.save_delegation_role_session(session)
|
|
return session
|
|
|
|
async def update_role_runtime_session(
|
|
self,
|
|
role_session_id: str,
|
|
**kwargs: Any,
|
|
) -> RoleRuntimeSession | None:
|
|
return await self.update_delegation_role_session(role_session_id, **kwargs)
|
|
|
|
# ── Fix 5 PR3: pending queue atomic helpers ────────────────────────
|
|
|
|
async def enqueue_pending_work_item(
|
|
self,
|
|
role_session_id: str,
|
|
work_item_id: str,
|
|
) -> bool:
|
|
"""Append ``work_item_id`` to the session's pending queue.
|
|
|
|
Returns ``True`` if the item was enqueued, ``False`` if the session
|
|
was not found or the item was already present. The write uses the
|
|
SQL row as the source of truth (read → append → write in one
|
|
transaction) so two concurrent dispatcher ticks cannot clobber
|
|
each other's append. Idempotent on duplicate work_item_id: the
|
|
queue is a set-in-FIFO-order, not a bag.
|
|
"""
|
|
wid = str(work_item_id or "").strip()
|
|
sid = str(role_session_id or "").strip()
|
|
if not wid or not sid:
|
|
return False
|
|
db = self._require_db()
|
|
# Atomic read-modify-write inside a transaction so concurrent
|
|
# enqueues don't race each other. SQLite's default journal_mode
|
|
# (WAL) serializes writes; the BEGIN IMMEDIATE here forces the
|
|
# write lock upfront to avoid a lock upgrade halfway through.
|
|
await db.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
async with db.execute(
|
|
"""SELECT pending_work_item_ids, updated_at
|
|
FROM role_runtime_sessions
|
|
WHERE role_session_id = ?""",
|
|
(sid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
await db.execute("ROLLBACK")
|
|
return False
|
|
pending_json, _updated_at = row
|
|
pending = _json_loads(pending_json, [])
|
|
if not isinstance(pending, list):
|
|
pending = []
|
|
if wid in pending:
|
|
await db.execute("ROLLBACK")
|
|
return False
|
|
pending.append(wid)
|
|
now_iso = datetime.now().isoformat()
|
|
for table in ("role_runtime_sessions", "delegation_role_sessions"):
|
|
await db.execute(
|
|
f"""UPDATE {table}
|
|
SET pending_work_item_ids = ?,
|
|
updated_at = ?
|
|
WHERE role_session_id = ?""",
|
|
(_json_dumps(pending), now_iso, sid),
|
|
)
|
|
await db.commit()
|
|
return True
|
|
except Exception:
|
|
await db.execute("ROLLBACK")
|
|
raise
|
|
|
|
async def dequeue_pending_work_item(
|
|
self,
|
|
role_session_id: str,
|
|
) -> str | None:
|
|
"""Pop the FIFO head of the session's pending queue.
|
|
|
|
Returns the dequeued ``work_item_id`` or ``None`` if the queue is
|
|
empty / session missing. Atomic read-modify-write so the pop is
|
|
safe against concurrent enqueues.
|
|
"""
|
|
sid = str(role_session_id or "").strip()
|
|
if not sid:
|
|
return None
|
|
db = self._require_db()
|
|
await db.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
async with db.execute(
|
|
"""SELECT pending_work_item_ids
|
|
FROM role_runtime_sessions
|
|
WHERE role_session_id = ?""",
|
|
(sid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
await db.execute("ROLLBACK")
|
|
return None
|
|
pending = _json_loads(row[0], [])
|
|
if not isinstance(pending, list) or not pending:
|
|
await db.execute("ROLLBACK")
|
|
return None
|
|
head = str(pending[0])
|
|
remaining = pending[1:]
|
|
now_iso = datetime.now().isoformat()
|
|
for table in ("role_runtime_sessions", "delegation_role_sessions"):
|
|
await db.execute(
|
|
f"""UPDATE {table}
|
|
SET pending_work_item_ids = ?,
|
|
updated_at = ?
|
|
WHERE role_session_id = ?""",
|
|
(_json_dumps(remaining), now_iso, sid),
|
|
)
|
|
await db.commit()
|
|
return head
|
|
except Exception:
|
|
await db.execute("ROLLBACK")
|
|
raise
|
|
|
|
async def role_session_is_busy(self, role_session_id: str) -> bool:
|
|
"""Return ``True`` when the session is already focused on a work
|
|
item (i.e. a new runnable work item should be queued, not claimed).
|
|
|
|
A session is busy when ``focused_work_item_id`` is non-empty. The
|
|
status column is intentionally not consulted: a session marked
|
|
``idle`` but still carrying a focus stamp is in the "claim
|
|
completing" gap and should still hold new work back.
|
|
"""
|
|
sid = str(role_session_id or "").strip()
|
|
if not sid:
|
|
return False
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"""SELECT focused_work_item_id FROM role_runtime_sessions
|
|
WHERE role_session_id = ?""",
|
|
(sid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return False
|
|
return bool(str(row[0] or "").strip())
|
|
|
|
# ── Fix 5 PR6: role-level adapter resume tokens ────────────────────
|
|
|
|
async def update_role_session_adapter_state(
|
|
self,
|
|
role_session_id: str,
|
|
agent_type: str,
|
|
token_record: dict[str, Any] | None,
|
|
) -> bool:
|
|
"""Merge a single-agent entry into ``adapter_session_state``.
|
|
|
|
PR6: the resume token for each external agent (codex,
|
|
claude_code, opencode) lives under ``adapter_session_state[agent_type]``
|
|
on the ROLE session — not per-task. Consecutive tasks for the
|
|
same role resume the same external session (same codex thread,
|
|
same claude-code session, same opencode session). A single role
|
|
can hold independent tokens for different adapters simultaneously
|
|
(keyed by ``agent_type``) so switching executor types is safe.
|
|
|
|
``token_record`` shape:
|
|
{
|
|
"resume_session_id": str,
|
|
"provider_session_id": str,
|
|
"updated_at": iso string,
|
|
"last_task_id": str,
|
|
"last_project_id": str,
|
|
}
|
|
|
|
Passing ``token_record=None`` clears the entry for ``agent_type``
|
|
(used when the adapter signals the session is no longer resumable).
|
|
Returns True on success, False when the role session is missing.
|
|
|
|
Atomic read-modify-write inside ``BEGIN IMMEDIATE`` so concurrent
|
|
broker writes (parallel roles, or two executors per role during
|
|
rollover) don't clobber each other.
|
|
"""
|
|
sid = str(role_session_id or "").strip()
|
|
agent = str(agent_type or "").strip()
|
|
if not sid or not agent:
|
|
return False
|
|
db = self._require_db()
|
|
await db.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
async with db.execute(
|
|
"""SELECT adapter_session_state FROM role_runtime_sessions
|
|
WHERE role_session_id = ?""",
|
|
(sid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
await db.execute("ROLLBACK")
|
|
return False
|
|
current = _json_loads(row[0], {})
|
|
if not isinstance(current, dict):
|
|
current = {}
|
|
if token_record is None:
|
|
current.pop(agent, None)
|
|
else:
|
|
current[agent] = {
|
|
str(k): v for k, v in dict(token_record).items()
|
|
}
|
|
now_iso = datetime.now().isoformat()
|
|
serialized = _json_dumps(current)
|
|
for table in ("role_runtime_sessions", "delegation_role_sessions"):
|
|
await db.execute(
|
|
f"""UPDATE {table}
|
|
SET adapter_session_state = ?,
|
|
updated_at = ?
|
|
WHERE role_session_id = ?""",
|
|
(serialized, now_iso, sid),
|
|
)
|
|
await db.commit()
|
|
return True
|
|
except Exception:
|
|
await db.execute("ROLLBACK")
|
|
raise
|
|
|
|
async def get_role_session_adapter_state(
|
|
self,
|
|
role_session_id: str,
|
|
agent_type: str,
|
|
) -> dict[str, Any] | None:
|
|
"""Read the per-agent token entry from ``adapter_session_state``.
|
|
|
|
Returns the stored dict (``{"resume_session_id": ..., ...}``) or
|
|
``None`` when the role session, the dict, or the agent's entry
|
|
is missing. Never raises on malformed JSON — returns None.
|
|
"""
|
|
sid = str(role_session_id or "").strip()
|
|
agent = str(agent_type or "").strip()
|
|
if not sid or not agent:
|
|
return None
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"""SELECT adapter_session_state FROM role_runtime_sessions
|
|
WHERE role_session_id = ?""",
|
|
(sid,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
state = _json_loads(row[0], {})
|
|
if not isinstance(state, dict):
|
|
return None
|
|
entry = state.get(agent)
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
return entry
|
|
|
|
def _row_to_team_instance(self, row: Any, description: Any) -> TeamInstance:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return TeamInstance(
|
|
team_instance_id=data["team_instance_id"],
|
|
run_id=data["run_id"],
|
|
project_id=data.get("project_id") or "default",
|
|
team_id=data["team_id"],
|
|
session_id=data.get("session_id") or "",
|
|
status=data.get("status") or "pending",
|
|
seat_ids=_json_loads(data.get("seat_ids"), []),
|
|
role_ids=_json_loads(data.get("role_ids"), []),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def save_team_instance(self, team: TeamInstance) -> None:
|
|
db = self._require_db()
|
|
await db.execute(
|
|
"""INSERT OR REPLACE INTO team_instances
|
|
(team_instance_id, run_id, project_id, team_id, session_id, status, seat_ids, role_ids, metadata,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
team.team_instance_id,
|
|
team.run_id,
|
|
team.project_id,
|
|
team.team_id,
|
|
team.session_id,
|
|
team.status,
|
|
_json_dumps(team.seat_ids),
|
|
_json_dumps(team.role_ids),
|
|
_json_dumps(team.metadata),
|
|
team.created_at.isoformat(),
|
|
team.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
|
|
async def get_team_instance(self, team_instance_id: str) -> TeamInstance | None:
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"SELECT * FROM team_instances WHERE team_instance_id = ?",
|
|
(team_instance_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
return self._row_to_team_instance(row, cursor.description)
|
|
|
|
async def list_team_instances(
|
|
self,
|
|
*,
|
|
run_id: str | None = None,
|
|
team_id: str | None = None,
|
|
project_id: str | None = None,
|
|
status: str | None = None,
|
|
) -> list[TeamInstance]:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM team_instances WHERE 1=1"
|
|
params: list[Any] = []
|
|
if run_id:
|
|
query += " AND run_id = ?"
|
|
params.append(run_id)
|
|
if team_id:
|
|
query += " AND team_id = ?"
|
|
params.append(team_id)
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
query += " ORDER BY created_at ASC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_team_instance(row, cursor.description) for row in rows]
|
|
|
|
async def update_team_instance(
|
|
self,
|
|
team_instance_id: str,
|
|
*,
|
|
status: str | None = None,
|
|
seat_ids: list[str] | None = None,
|
|
role_ids: list[str] | None = None,
|
|
metadata_updates: dict[str, Any] | None = None,
|
|
) -> TeamInstance | None:
|
|
team = await self.get_team_instance(team_instance_id)
|
|
if team is None:
|
|
return None
|
|
if status is not None:
|
|
team.status = str(status).strip() or team.status
|
|
if seat_ids is not None:
|
|
team.seat_ids = [str(item).strip() for item in seat_ids if str(item).strip()]
|
|
if role_ids is not None:
|
|
team.role_ids = [str(item).strip() for item in role_ids if str(item).strip()]
|
|
if metadata_updates:
|
|
team.metadata = {**dict(team.metadata or {}), **dict(metadata_updates)}
|
|
team.updated_at = datetime.now()
|
|
await self.save_team_instance(team)
|
|
return team
|
|
|
|
def _row_to_seat_state(self, row: Any, description: Any) -> SeatState:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
current_work_item_id = data.get("current_work_item_id") or ""
|
|
status = normalize_role_runtime_status(data.get("status"), current_work_item_id)
|
|
if status == "idle":
|
|
current_work_item_id = ""
|
|
return SeatState(
|
|
seat_state_id=data["seat_state_id"],
|
|
team_instance_id=data["team_instance_id"],
|
|
run_id=data["run_id"],
|
|
project_id=data.get("project_id") or "default",
|
|
team_id=data["team_id"],
|
|
seat_id=data["seat_id"],
|
|
role_id=data.get("role_id") or "",
|
|
employee_id=data.get("employee_id") or "",
|
|
member_session_id=data.get("member_session_id") or "",
|
|
role_runtime_session_id=data.get("role_runtime_session_id") or "",
|
|
status=status,
|
|
resident_status=status,
|
|
current_task_id=data.get("current_task_id") or "",
|
|
current_work_item_id=current_work_item_id,
|
|
manager_role_id=data.get("manager_role_id") or "",
|
|
manager_seat_id=data.get("manager_seat_id") or "",
|
|
manager_role_ids=_json_loads(data.get("manager_role_ids"), []),
|
|
manager_seat_ids=_json_loads(data.get("manager_seat_ids"), []),
|
|
inbox_state=_json_loads(data.get("inbox_state"), {}),
|
|
resume_state=_json_loads(data.get("resume_state"), {}),
|
|
current_work_item=_json_loads(data.get("current_work_item"), {}),
|
|
latest_notification=_json_loads(data.get("latest_notification"), {}),
|
|
manager_digest=_json_loads(data.get("manager_digest"), {}),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def save_seat_state(self, seat: SeatState) -> None:
|
|
db = self._require_db()
|
|
seat.current_work_item_id = str(seat.current_work_item_id or "").strip()
|
|
seat.status = normalize_role_runtime_status(
|
|
seat.status,
|
|
seat.current_work_item_id,
|
|
)
|
|
if seat.status == "idle":
|
|
seat.current_work_item_id = ""
|
|
seat.resident_status = seat.status
|
|
await db.execute(
|
|
"""INSERT OR REPLACE INTO seat_states
|
|
(seat_state_id, team_instance_id, run_id, project_id, team_id, seat_id, role_id, employee_id,
|
|
member_session_id, role_runtime_session_id, status, resident_status, current_task_id,
|
|
current_work_item_id, manager_role_id, manager_seat_id, manager_role_ids, manager_seat_ids,
|
|
inbox_state, resume_state, current_work_item, latest_notification, manager_digest, metadata,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
seat.seat_state_id,
|
|
seat.team_instance_id,
|
|
seat.run_id,
|
|
seat.project_id,
|
|
seat.team_id,
|
|
seat.seat_id,
|
|
seat.role_id,
|
|
seat.employee_id,
|
|
seat.member_session_id,
|
|
seat.role_runtime_session_id,
|
|
seat.status,
|
|
seat.resident_status,
|
|
seat.current_task_id,
|
|
seat.current_work_item_id,
|
|
seat.manager_role_id,
|
|
seat.manager_seat_id,
|
|
_json_dumps(seat.manager_role_ids),
|
|
_json_dumps(seat.manager_seat_ids),
|
|
_json_dumps(seat.inbox_state),
|
|
_json_dumps(seat.resume_state),
|
|
_json_dumps(seat.current_work_item),
|
|
_json_dumps(seat.latest_notification),
|
|
_json_dumps(seat.manager_digest),
|
|
_json_dumps(seat.metadata),
|
|
seat.created_at.isoformat(),
|
|
seat.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
|
|
async def get_seat_state(self, seat_state_id: str) -> SeatState | None:
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"SELECT * FROM seat_states WHERE seat_state_id = ?",
|
|
(seat_state_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
return self._row_to_seat_state(row, cursor.description)
|
|
|
|
async def get_seat_state_for_seat(
|
|
self,
|
|
team_instance_id: str,
|
|
seat_id: str,
|
|
) -> SeatState | None:
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"SELECT * FROM seat_states WHERE team_instance_id = ? AND seat_id = ? ORDER BY created_at ASC LIMIT 1",
|
|
(team_instance_id, seat_id),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
return self._row_to_seat_state(row, cursor.description)
|
|
|
|
async def list_seat_states(
|
|
self,
|
|
*,
|
|
team_instance_id: str | None = None,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
run_id: str | None = None,
|
|
) -> list[SeatState]:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM seat_states WHERE 1=1"
|
|
params: list[Any] = []
|
|
if team_instance_id:
|
|
query += " AND team_instance_id = ?"
|
|
params.append(team_instance_id)
|
|
if team_id:
|
|
query += " AND team_id = ?"
|
|
params.append(team_id)
|
|
if seat_id:
|
|
query += " AND seat_id = ?"
|
|
params.append(seat_id)
|
|
if run_id:
|
|
query += " AND run_id = ?"
|
|
params.append(run_id)
|
|
query += " ORDER BY created_at ASC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_seat_state(row, cursor.description) for row in rows]
|
|
|
|
async def update_seat_state(
|
|
self,
|
|
seat_state_id: str,
|
|
*,
|
|
status: str | None = None,
|
|
resident_status: str | None = None,
|
|
current_task_id: str | None = None,
|
|
current_work_item_id: str | None = None,
|
|
role_runtime_session_id: str | None = None,
|
|
inbox_state: dict[str, Any] | None = None,
|
|
resume_state: dict[str, Any] | None = None,
|
|
current_work_item: dict[str, Any] | None = None,
|
|
latest_notification: dict[str, Any] | None = None,
|
|
manager_digest: dict[str, Any] | None = None,
|
|
metadata_updates: dict[str, Any] | None = None,
|
|
) -> SeatState | None:
|
|
seat = await self.get_seat_state(seat_state_id)
|
|
if seat is None:
|
|
return None
|
|
if status is not None:
|
|
seat.status = normalize_role_runtime_status(
|
|
status,
|
|
seat.current_work_item_id,
|
|
)
|
|
if resident_status is not None:
|
|
seat.resident_status = normalize_role_runtime_status(
|
|
resident_status,
|
|
seat.current_work_item_id,
|
|
)
|
|
if current_task_id is not None:
|
|
seat.current_task_id = str(current_task_id or "").strip()
|
|
if current_work_item_id is not None:
|
|
seat.current_work_item_id = str(current_work_item_id or "").strip()
|
|
seat.status = normalize_role_runtime_status(
|
|
seat.status,
|
|
seat.current_work_item_id,
|
|
)
|
|
if seat.status == "idle":
|
|
seat.current_work_item_id = ""
|
|
seat.resident_status = seat.status
|
|
if role_runtime_session_id is not None:
|
|
seat.role_runtime_session_id = str(role_runtime_session_id or "").strip()
|
|
if inbox_state is not None:
|
|
seat.inbox_state = dict(inbox_state)
|
|
if resume_state is not None:
|
|
seat.resume_state = dict(resume_state)
|
|
if current_work_item is not None:
|
|
seat.current_work_item = dict(current_work_item)
|
|
if latest_notification is not None:
|
|
seat.latest_notification = dict(latest_notification)
|
|
if manager_digest is not None:
|
|
seat.manager_digest = dict(manager_digest)
|
|
if metadata_updates:
|
|
seat.metadata = {**dict(seat.metadata or {}), **dict(metadata_updates)}
|
|
seat.updated_at = datetime.now()
|
|
await self.save_seat_state(seat)
|
|
return seat
|
|
|
|
async def save_delegation_seat_state(self, seat: SeatState) -> None:
|
|
await self.save_seat_state(seat)
|
|
|
|
async def get_delegation_seat_state(self, seat_state_id: str) -> SeatState | None:
|
|
return await self.get_seat_state(seat_state_id)
|
|
|
|
async def list_delegation_seat_states(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
) -> list[SeatState]:
|
|
return await self.list_seat_states(
|
|
run_id=run_id,
|
|
team_id=team_id,
|
|
seat_id=seat_id,
|
|
)
|
|
|
|
async def update_delegation_seat_state(
|
|
self,
|
|
seat_state_id: str,
|
|
**kwargs: Any,
|
|
) -> SeatState | None:
|
|
return await self.update_seat_state(seat_state_id, **kwargs)
|
|
|
|
def _row_to_delegation_run(self, row: Any, description: Any) -> DelegationRun:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return DelegationRun(
|
|
run_id=data["run_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
company_profile=data.get("company_profile") or "corporate",
|
|
execution_model=data.get("execution_model") or "recursive_delegation",
|
|
final_decider_role_id=data.get("final_decider_role_id") or "",
|
|
top_level_role_ids=_json_loads(data.get("top_level_role_ids"), []),
|
|
status=data.get("status") or "pending",
|
|
lifecycle_status=data.get("lifecycle_status") or "active",
|
|
current_revision=int(data.get("current_revision") or 1),
|
|
latest_deliverable_summary=data.get("latest_deliverable_summary") or "",
|
|
recovery_pointer=_json_loads(data.get("recovery_pointer"), {}),
|
|
project_dossier=_json_loads(data.get("project_dossier"), {}),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def save_delegation_run(self, run: DelegationRun) -> None:
|
|
db = self._require_db()
|
|
await db.execute(
|
|
"""INSERT OR REPLACE INTO delegation_runs
|
|
(run_id, project_id, session_id, company_profile, execution_model, final_decider_role_id,
|
|
top_level_role_ids, status, lifecycle_status, current_revision, latest_deliverable_summary,
|
|
recovery_pointer, project_dossier, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
run.run_id,
|
|
run.project_id,
|
|
run.session_id,
|
|
run.company_profile,
|
|
run.execution_model,
|
|
run.final_decider_role_id,
|
|
_json_dumps(run.top_level_role_ids),
|
|
run.status,
|
|
run.lifecycle_status,
|
|
int(run.current_revision or 1),
|
|
run.latest_deliverable_summary,
|
|
_json_dumps(run.recovery_pointer),
|
|
_json_dumps(run.project_dossier),
|
|
_json_dumps(run.metadata),
|
|
run.created_at.isoformat(),
|
|
run.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
|
|
async def get_delegation_run(self, run_id: str) -> DelegationRun | None:
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"SELECT * FROM delegation_runs WHERE run_id = ?",
|
|
(run_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
return self._row_to_delegation_run(row, cursor.description)
|
|
|
|
async def list_delegation_runs(
|
|
self,
|
|
*,
|
|
project_id: str | None = None,
|
|
status: str | None = None,
|
|
lifecycle_status: str | None = None,
|
|
session_id: str | None = None,
|
|
) -> list[DelegationRun]:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM delegation_runs WHERE 1=1"
|
|
params: list[Any] = []
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
if lifecycle_status:
|
|
query += " AND lifecycle_status = ?"
|
|
params.append(lifecycle_status)
|
|
if session_id:
|
|
query += " AND session_id = ?"
|
|
params.append(session_id)
|
|
query += " ORDER BY updated_at DESC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_delegation_run(row, cursor.description) for row in rows]
|
|
|
|
async def list_open_delegation_runs(
|
|
self,
|
|
*,
|
|
project_id: str | None = None,
|
|
) -> list[DelegationRun]:
|
|
db = self._require_db()
|
|
open_states = ("active", "paused", "blocked", "awaiting_owner", "deliverable")
|
|
query = (
|
|
"SELECT * FROM delegation_runs WHERE lifecycle_status IN (?, ?, ?, ?, ?)"
|
|
)
|
|
params: list[Any] = list(open_states)
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
query += " ORDER BY updated_at DESC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_delegation_run(row, cursor.description) for row in rows]
|
|
|
|
async def get_latest_delegation_run(
|
|
self,
|
|
project_id: str,
|
|
*,
|
|
include_session_id: str | None = None,
|
|
) -> DelegationRun | None:
|
|
runs = await self.list_delegation_runs(project_id=project_id)
|
|
for run in runs:
|
|
if include_session_id and run.session_id == include_session_id:
|
|
continue
|
|
return run
|
|
return None
|
|
|
|
def _row_to_delegation_cell(self, row: Any, description: Any) -> DelegationCell:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return DelegationCell(
|
|
cell_id=data["cell_id"],
|
|
run_id=data["run_id"],
|
|
manager_role_id=data.get("manager_role_id") or "",
|
|
member_role_ids=_json_loads(data.get("member_role_ids"), []),
|
|
status=data.get("status") or "idle",
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def save_delegation_cell(self, cell: DelegationCell) -> None:
|
|
db = self._require_db()
|
|
await db.execute(
|
|
"""INSERT OR REPLACE INTO delegation_cells
|
|
(cell_id, run_id, manager_role_id, member_role_ids, status, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
cell.cell_id,
|
|
cell.run_id,
|
|
cell.manager_role_id,
|
|
_json_dumps(cell.member_role_ids),
|
|
cell.status,
|
|
_json_dumps(cell.metadata),
|
|
cell.created_at.isoformat(),
|
|
cell.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
|
|
async def list_delegation_cells(self, run_id: str) -> list[DelegationCell]:
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"SELECT * FROM delegation_cells WHERE run_id = ? ORDER BY created_at ASC",
|
|
(run_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_delegation_cell(row, cursor.description) for row in rows]
|
|
|
|
def _row_to_delegation_work_item(self, row: Any, description: Any) -> DelegationWorkItem:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return DelegationWorkItem(
|
|
work_item_id=data["work_item_id"],
|
|
run_id=data["run_id"],
|
|
cell_id=data["cell_id"],
|
|
team_instance_id=data.get("team_instance_id") or "",
|
|
team_id=data.get("team_id") or "",
|
|
role_id=data.get("role_id") or "",
|
|
seat_id=data.get("seat_id") or "",
|
|
seat_state_id=data.get("seat_state_id") or "",
|
|
role_runtime_session_id=data.get("role_runtime_session_id") or "",
|
|
parent_work_item_id=data.get("parent_work_item_id"),
|
|
source_role_id=data.get("source_role_id"),
|
|
source_seat_id=data.get("source_seat_id"),
|
|
title=data.get("title") or "",
|
|
summary=data.get("summary") or "",
|
|
kind=data.get("kind") or "execute",
|
|
projection_id=data.get("projection_id") or "",
|
|
phase=coerce_phase(data.get("phase") or "ready"),
|
|
batch_id=data.get("batch_id") or "",
|
|
batch_index=int(data.get("batch_index") or 0),
|
|
deliverable_summary=data.get("deliverable_summary") or "",
|
|
blocked_reason=data.get("blocked_reason") or "",
|
|
handoff_status=data.get("handoff_status") or "pending",
|
|
continuation_source=data.get("continuation_source") or "",
|
|
manager_role_id=data.get("manager_role_id") or "",
|
|
manager_seat_id=data.get("manager_seat_id") or "",
|
|
claimed_by_role_runtime_session_id=data.get("claimed_by_role_runtime_session_id") or "",
|
|
claimed_by_seat_id=data.get("claimed_by_seat_id") or "",
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def _sweep_stale_claims(self) -> int:
|
|
"""Crash-recovery sweep: clear claim metadata on every in-flight
|
|
work item at startup.
|
|
|
|
Process restart drops every in-memory runtime session, but the
|
|
persistent ``claimed_by_*`` fields on the work items still point
|
|
to those dead session IDs. Without this sweep, the dispatcher
|
|
would treat those cards as "actively claimed" forever — they
|
|
would become permanent zombies (Bug C).
|
|
|
|
We only touch in-flight phases (RUNNING / WAITING_FOR_* /
|
|
PAUSED / NEEDS_ATTENTION / AWAITING_*) so we never disturb
|
|
terminal cards. The phase itself is left alone; the sweep only
|
|
clears the claim. Active execution can subsequently be re-picked;
|
|
passive AWAITING_* parents remain non-dispatchable while their
|
|
report/review chain (or human) advances them.
|
|
"""
|
|
if self._db is None:
|
|
return 0
|
|
async with self._db.execute(
|
|
"""SELECT work_item_id, phase, metadata
|
|
FROM delegation_work_items
|
|
WHERE claimed_by_role_runtime_session_id != ''
|
|
OR claimed_by_seat_id != ''"""
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cleared = 0
|
|
for work_item_id, phase_str, metadata_json in rows:
|
|
try:
|
|
phase = coerce_phase(phase_str)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not is_stale_claim_releasable(phase):
|
|
continue
|
|
metadata = _json_loads(metadata_json, {})
|
|
metadata["claimed_by_role_session_id"] = ""
|
|
metadata["claimed_task_id"] = ""
|
|
metadata["claim_swept_at"] = datetime.now().isoformat()
|
|
await self._db.execute(
|
|
"""UPDATE delegation_work_items
|
|
SET claimed_by_role_runtime_session_id='',
|
|
claimed_by_seat_id='',
|
|
metadata=?,
|
|
updated_at=?
|
|
WHERE work_item_id=?""",
|
|
(_json_dumps(metadata), datetime.now().isoformat(), work_item_id),
|
|
)
|
|
cleared += 1
|
|
if cleared:
|
|
await self._db.commit()
|
|
logger.info(
|
|
f"stale-claim sweep: released {cleared} in-flight work item claims on startup"
|
|
)
|
|
return cleared
|
|
|
|
# ── Fix 5 PR2: rich field-level merge by (run_id, role_id) ──────────
|
|
|
|
async def _migrate_role_sessions_merge_by_role(self) -> dict[str, int]:
|
|
"""Collapse every ``(run_id, role_id)`` group into a single canonical
|
|
row, merging state field-by-field so inbox / memory / adapter
|
|
session tokens survive the collapse.
|
|
|
|
Design (see Fix 5 PR2 plan):
|
|
|
|
Target PK ``role-runtime::{run_id}::{role_id}`` (3-segment).
|
|
Groups every (run_id, role_id) with ≥1 row. A size-1 group
|
|
whose PK already equals the canonical form is
|
|
skipped (noop — common case after migration).
|
|
Merge rules inbox_state, memory_slices_by_work_item, and the
|
|
list-of-ids columns (background_work_item_ids,
|
|
manager_role_ids, manager_seat_ids, seat_ids) are
|
|
UNIONED across all rows. Scalar state columns
|
|
(focused_work_item_id, status, resume_state,
|
|
current_work_item, latest_notification,
|
|
manager_digest, adapter_session_state, team_*,
|
|
seat_id, seat_state_id) are taken from the
|
|
"active" row — the one with a populated focus and
|
|
the highest status priority, tiebroken by
|
|
updated_at. ``adapter_session_state`` is the
|
|
externally-observable LLM / codex session token:
|
|
losing rows' states are retained under
|
|
``metadata.adapter_session_state_audit`` so the
|
|
old codex sessions can be recovered for debugging.
|
|
References foreign references in delegation_work_items
|
|
(role_runtime_session_id,
|
|
claimed_by_role_runtime_session_id), seat_states,
|
|
and JSON metadata (tasks.metadata.delegation_role_
|
|
session_id, delegation_work_items.metadata.
|
|
assigned_role_runtime_id) are all redirected to
|
|
the canonical PK before losers are deleted.
|
|
Idempotent re-running the migration is a noop (every row is
|
|
already canonical).
|
|
|
|
Returns counters for observability / tests.
|
|
"""
|
|
# Deferred import — the company_runtime module transitively imports
|
|
# layer2 phase machinery, which we also use; importing at module
|
|
# scope would create a cycle during initial bootstrap.
|
|
from opc.layer2_organization.company_runtime import canonical_role_session_id
|
|
|
|
if self._db is None:
|
|
return {
|
|
"groups": 0,
|
|
"canonical_written": 0,
|
|
"deleted": 0,
|
|
"refs_updated": 0,
|
|
}
|
|
|
|
stats = {
|
|
"groups": 0,
|
|
"canonical_written": 0,
|
|
"deleted": 0,
|
|
"refs_updated": 0,
|
|
}
|
|
|
|
# Collect every (run_id, role_id) with any rows — unlike the old
|
|
# "duplicate" migration, this pass also catches single legacy rows
|
|
# whose PK is not the 3-segment canonical form.
|
|
async with self._db.execute(
|
|
"""SELECT DISTINCT run_id, role_id
|
|
FROM role_runtime_sessions
|
|
WHERE run_id != '' AND role_id != ''"""
|
|
) as cursor:
|
|
groups = await cursor.fetchall()
|
|
|
|
if not groups:
|
|
return stats
|
|
|
|
for run_id, role_id in groups:
|
|
try:
|
|
canonical_id = canonical_role_session_id(
|
|
run_id=str(run_id), role_id=str(role_id)
|
|
)
|
|
except ValueError:
|
|
logger.warning(
|
|
f"role-session merge: cannot build canonical ID for "
|
|
f"run={run_id} role={role_id}; skipping"
|
|
)
|
|
continue
|
|
|
|
async with self._db.execute(
|
|
"""SELECT * FROM role_runtime_sessions
|
|
WHERE run_id=? AND role_id=?
|
|
ORDER BY updated_at DESC""",
|
|
(str(run_id), str(role_id)),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
columns = [desc[0] for desc in cursor.description]
|
|
|
|
if not rows:
|
|
continue
|
|
|
|
row_dicts = [dict(zip(columns, row)) for row in rows]
|
|
|
|
# Fast path: single row already at canonical PK → nothing to do.
|
|
if len(row_dicts) == 1 and row_dicts[0]["role_session_id"] == canonical_id:
|
|
continue
|
|
|
|
stats["groups"] += 1
|
|
|
|
merged = self._merge_role_session_rows(
|
|
rows=row_dicts, canonical_id=canonical_id
|
|
)
|
|
|
|
# Upsert the merged row under the canonical PK in both tables.
|
|
for table in ("role_runtime_sessions", "delegation_role_sessions"):
|
|
await self._upsert_role_session_row(table=table, row=merged)
|
|
stats["canonical_written"] += 1
|
|
|
|
# Redirect every foreign reference from any non-canonical PK
|
|
# in the group to the canonical PK. We do this before deleting
|
|
# the rows so a crash mid-migration leaves references resolvable.
|
|
losers = [
|
|
rd["role_session_id"]
|
|
for rd in row_dicts
|
|
if rd["role_session_id"] != canonical_id
|
|
]
|
|
for loser_id in losers:
|
|
refs = await self._redirect_role_session_references(
|
|
source_id=loser_id, target_id=canonical_id
|
|
)
|
|
stats["refs_updated"] += refs
|
|
|
|
# Delete the orphaned rows from both tables.
|
|
for loser_id in losers:
|
|
for table in ("role_runtime_sessions", "delegation_role_sessions"):
|
|
await self._db.execute(
|
|
f"DELETE FROM {table} WHERE role_session_id=?",
|
|
(loser_id,),
|
|
)
|
|
stats["deleted"] += 1
|
|
|
|
await self._db.commit()
|
|
if stats["groups"]:
|
|
logger.info(
|
|
"role-session merge: "
|
|
f"groups={stats['groups']} "
|
|
f"canonical_written={stats['canonical_written']} "
|
|
f"deleted={stats['deleted']} "
|
|
f"refs_updated={stats['refs_updated']}"
|
|
)
|
|
return stats
|
|
|
|
@staticmethod
|
|
def _status_priority(status: str) -> int:
|
|
"""Higher priority wins when merging scalar state."""
|
|
return {
|
|
"running": 3,
|
|
"reserved": 2,
|
|
"blocked": 2,
|
|
"idle": 1,
|
|
"cold": 0,
|
|
}.get((status or "").strip().lower(), 1)
|
|
|
|
@classmethod
|
|
def _merge_role_session_rows(
|
|
cls, *, rows: list[dict[str, Any]], canonical_id: str
|
|
) -> dict[str, Any]:
|
|
"""Field-level merge of N role_runtime_sessions rows → single canonical
|
|
row. See ``_migrate_role_sessions_merge_by_role`` for the policy.
|
|
Rows must all belong to the same (run_id, role_id)."""
|
|
# Pick the "active" row for scalar fields — the one most likely to
|
|
# reflect the live state of the role. Ordering: has_focus desc,
|
|
# status_priority desc, updated_at desc.
|
|
active = max(
|
|
rows,
|
|
key=lambda r: (
|
|
1 if (r.get("focused_work_item_id") or "").strip() else 0,
|
|
cls._status_priority(r.get("status") or ""),
|
|
str(r.get("updated_at") or ""),
|
|
),
|
|
)
|
|
|
|
# Team instance: prefer any non-empty value across the group. Old
|
|
# short-form rows had it blank; the long-form row carries the truth.
|
|
team_instance_id = str(active.get("team_instance_id") or "").strip()
|
|
if not team_instance_id:
|
|
for r in rows:
|
|
candidate = str(r.get("team_instance_id") or "").strip()
|
|
if candidate:
|
|
team_instance_id = candidate
|
|
break
|
|
|
|
# Inbox: union + de-dup by message id, then sort by timestamp.
|
|
inbox_messages: list[dict[str, Any]] = []
|
|
seen_msg_ids: set[str] = set()
|
|
for r in rows:
|
|
state = _json_loads(r.get("inbox_state") or "{}", {})
|
|
for msg in list(state.get("messages", []) or []):
|
|
if not isinstance(msg, dict):
|
|
continue
|
|
mid = str(msg.get("message_id") or msg.get("id") or "").strip()
|
|
# Preserve order for messages that have no ID (rare) — use
|
|
# a synthetic marker so they don't all collide on "".
|
|
key = mid or f"__noid__::{len(inbox_messages)}"
|
|
if key in seen_msg_ids:
|
|
continue
|
|
seen_msg_ids.add(key)
|
|
inbox_messages.append(dict(msg))
|
|
inbox_messages.sort(key=lambda m: str(m.get("timestamp") or m.get("created_at") or ""))
|
|
# Preserve any non-messages keys from the active row's inbox_state.
|
|
active_inbox = _json_loads(active.get("inbox_state") or "{}", {})
|
|
active_inbox["messages"] = inbox_messages
|
|
merged_inbox_state = active_inbox
|
|
|
|
# Memory slices: union dict[work_item_id → list], merging lists per key.
|
|
# Iterate oldest-first so older notes appear before newer ones in the
|
|
# merged list (natural reading order; rows come in DESC so reverse).
|
|
merged_memory: dict[str, list[Any]] = {}
|
|
for r in reversed(rows):
|
|
slices = _json_loads(r.get("memory_slices_by_work_item") or "{}", {})
|
|
if not isinstance(slices, dict):
|
|
continue
|
|
for wid, items in slices.items():
|
|
if not isinstance(items, list):
|
|
continue
|
|
merged_memory.setdefault(str(wid), []).extend(items)
|
|
# De-dup identical entries per work item (keep order).
|
|
for wid, items in list(merged_memory.items()):
|
|
seen: set[str] = set()
|
|
deduped: list[Any] = []
|
|
for entry in items:
|
|
sig = _json_dumps(entry) if not isinstance(entry, str) else entry
|
|
if sig in seen:
|
|
continue
|
|
seen.add(sig)
|
|
deduped.append(entry)
|
|
merged_memory[wid] = deduped
|
|
|
|
# List unions (order: active row's entries first, others appended).
|
|
def _union_list(field: str) -> list[str]:
|
|
merged: list[str] = []
|
|
seen: set[str] = set()
|
|
for r in [active] + [r for r in rows if r is not active]:
|
|
raw = _json_loads(r.get(field) or "[]", [])
|
|
for item in raw if isinstance(raw, list) else []:
|
|
normalized = str(item)
|
|
if normalized in seen:
|
|
continue
|
|
seen.add(normalized)
|
|
merged.append(normalized)
|
|
return merged
|
|
|
|
background_ids = _union_list("background_work_item_ids")
|
|
manager_role_ids = _union_list("manager_role_ids")
|
|
manager_seat_ids = _union_list("manager_seat_ids")
|
|
seat_ids = _union_list("seat_ids")
|
|
# Pending queue: preserve FIFO — active row's queue first (these
|
|
# are the ones already committed to this role's runtime), then any
|
|
# extras from siblings. De-dup but keep earliest occurrence.
|
|
pending_ids = _union_list("pending_work_item_ids")
|
|
|
|
# Adapter session state (codex / LLM resume token): active row
|
|
# wins; every other row's state is retained as audit breadcrumbs.
|
|
active_adapter = _json_loads(active.get("adapter_session_state") or "{}", {})
|
|
adapter_audit: list[dict[str, Any]] = []
|
|
for r in rows:
|
|
if r is active:
|
|
continue
|
|
raw_state = r.get("adapter_session_state") or ""
|
|
if not raw_state or raw_state in ("{}", "null"):
|
|
continue
|
|
adapter_audit.append(
|
|
{
|
|
"source_role_session_id": str(r.get("role_session_id") or ""),
|
|
"updated_at": str(r.get("updated_at") or ""),
|
|
"adapter_session_state": _json_loads(raw_state, {}),
|
|
}
|
|
)
|
|
|
|
# Base metadata: start from active row's metadata, then append the
|
|
# adapter audit list (append, don't overwrite — a role that has
|
|
# been merged multiple times retains its full trail).
|
|
merged_metadata = _json_loads(active.get("metadata") or "{}", {})
|
|
if not isinstance(merged_metadata, dict):
|
|
merged_metadata = {}
|
|
if adapter_audit:
|
|
existing_audit = list(merged_metadata.get("adapter_session_state_audit", []) or [])
|
|
existing_audit.extend(adapter_audit)
|
|
merged_metadata["adapter_session_state_audit"] = existing_audit
|
|
|
|
# team_instance_id history — diagnostic trail of which team contexts
|
|
# this role has been seen in. Useful when debugging cross-team flows.
|
|
prior_team_instances = [
|
|
str(r.get("team_instance_id") or "").strip()
|
|
for r in rows
|
|
if str(r.get("team_instance_id") or "").strip()
|
|
]
|
|
if prior_team_instances:
|
|
existing_history = list(merged_metadata.get("team_instance_history", []) or [])
|
|
existing_history.extend(prior_team_instances)
|
|
# De-dup preserving order.
|
|
dedup_history: list[str] = []
|
|
seen_tid: set[str] = set()
|
|
for tid in existing_history:
|
|
if tid in seen_tid:
|
|
continue
|
|
seen_tid.add(tid)
|
|
dedup_history.append(tid)
|
|
merged_metadata["team_instance_history"] = dedup_history
|
|
|
|
# Preserve the oldest created_at across the group (role has been
|
|
# around since the earliest row was written), use the most recent
|
|
# updated_at (merged row reflects the latest activity).
|
|
created_at = min(str(r.get("created_at") or "") for r in rows if r.get("created_at"))
|
|
updated_at = max(str(r.get("updated_at") or "") for r in rows if r.get("updated_at"))
|
|
if not updated_at:
|
|
updated_at = datetime.now().isoformat()
|
|
|
|
return {
|
|
"role_session_id": canonical_id,
|
|
"run_id": str(active.get("run_id") or ""),
|
|
"project_id": str(active.get("project_id") or "default"),
|
|
"team_instance_id": team_instance_id,
|
|
"team_id": str(active.get("team_id") or ""),
|
|
"role_id": str(active.get("role_id") or ""),
|
|
"seat_id": str(active.get("seat_id") or ""),
|
|
"seat_state_id": str(active.get("seat_state_id") or ""),
|
|
"employee_id": str(active.get("employee_id") or ""),
|
|
"focused_work_item_id": str(active.get("focused_work_item_id") or ""),
|
|
"background_work_item_ids": _json_dumps(background_ids),
|
|
"manager_role_ids": _json_dumps(manager_role_ids),
|
|
"manager_seat_ids": _json_dumps(manager_seat_ids),
|
|
"seat_ids": _json_dumps(seat_ids),
|
|
"adapter_session_state": _json_dumps(active_adapter),
|
|
"inbox_state": _json_dumps(merged_inbox_state),
|
|
"memory_slices_by_work_item": _json_dumps(merged_memory),
|
|
"resume_state": active.get("resume_state") or "{}",
|
|
"current_work_item": active.get("current_work_item") or "{}",
|
|
"latest_notification": active.get("latest_notification") or "{}",
|
|
"manager_digest": active.get("manager_digest") or "{}",
|
|
"status": normalize_role_runtime_status(
|
|
active.get("status"),
|
|
active.get("focused_work_item_id"),
|
|
),
|
|
"pending_work_item_ids": _json_dumps(pending_ids),
|
|
"metadata": _json_dumps(merged_metadata),
|
|
"created_at": created_at or updated_at,
|
|
"updated_at": updated_at,
|
|
}
|
|
|
|
async def _upsert_role_session_row(self, *, table: str, row: dict[str, Any]) -> None:
|
|
"""Write the merged row under its canonical PK. ``INSERT OR REPLACE``
|
|
so an earlier migration pass (or a pre-existing canonical row) is
|
|
overwritten with the merged state."""
|
|
assert self._db is not None
|
|
columns = [
|
|
"role_session_id", "run_id", "project_id", "team_instance_id",
|
|
"team_id", "role_id", "seat_id", "seat_state_id", "employee_id",
|
|
"focused_work_item_id", "background_work_item_ids",
|
|
"manager_role_ids", "manager_seat_ids", "seat_ids",
|
|
"adapter_session_state", "inbox_state", "memory_slices_by_work_item",
|
|
"resume_state", "current_work_item", "latest_notification",
|
|
"manager_digest", "status", "pending_work_item_ids",
|
|
"metadata", "created_at", "updated_at",
|
|
]
|
|
placeholders = ", ".join("?" for _ in columns)
|
|
await self._db.execute(
|
|
f"INSERT OR REPLACE INTO {table} ({', '.join(columns)}) "
|
|
f"VALUES ({placeholders})",
|
|
tuple(row.get(c) for c in columns),
|
|
)
|
|
|
|
async def _redirect_role_session_references(
|
|
self,
|
|
*,
|
|
source_id: str,
|
|
target_id: str,
|
|
) -> int:
|
|
"""Rewrite every table/column that references ``source_id`` to
|
|
``target_id``. Returns the number of rows modified (for observability).
|
|
|
|
Scope is intentionally explicit: we know which columns hold
|
|
role_session_id references and update exactly those. JSON metadata
|
|
columns (tasks.metadata, delegation_work_items.metadata) are
|
|
rewritten with a targeted JSON-level replace that only touches the
|
|
specific keys we control — we never blindly string-replace the
|
|
JSON blob.
|
|
"""
|
|
assert self._db is not None
|
|
total = 0
|
|
|
|
# delegation_work_items: two direct columns.
|
|
cursor = await self._db.execute(
|
|
"""UPDATE delegation_work_items
|
|
SET role_runtime_session_id=?,
|
|
updated_at=?
|
|
WHERE role_runtime_session_id=?""",
|
|
(target_id, datetime.now().isoformat(), source_id),
|
|
)
|
|
total += getattr(cursor, "rowcount", 0) or 0
|
|
cursor = await self._db.execute(
|
|
"""UPDATE delegation_work_items
|
|
SET claimed_by_role_runtime_session_id=?,
|
|
updated_at=?
|
|
WHERE claimed_by_role_runtime_session_id=?""",
|
|
(target_id, datetime.now().isoformat(), source_id),
|
|
)
|
|
total += getattr(cursor, "rowcount", 0) or 0
|
|
|
|
# seat_states: single column.
|
|
cursor = await self._db.execute(
|
|
"""UPDATE seat_states
|
|
SET role_runtime_session_id=?,
|
|
updated_at=?
|
|
WHERE role_runtime_session_id=?""",
|
|
(target_id, datetime.now().isoformat(), source_id),
|
|
)
|
|
total += getattr(cursor, "rowcount", 0) or 0
|
|
|
|
# JSON metadata references: load, rewrite keys, store.
|
|
# delegation_work_items.metadata.assigned_role_runtime_id
|
|
async with self._db.execute(
|
|
"""SELECT work_item_id, metadata FROM delegation_work_items
|
|
WHERE metadata LIKE ?""",
|
|
(f'%"{source_id}"%',),
|
|
) as cursor:
|
|
wi_rows = await cursor.fetchall()
|
|
for work_item_id, metadata_json in wi_rows:
|
|
meta = _json_loads(metadata_json, {})
|
|
mutated = False
|
|
if str(meta.get("assigned_role_runtime_id", "")) == source_id:
|
|
meta["assigned_role_runtime_id"] = target_id
|
|
mutated = True
|
|
if mutated:
|
|
await self._db.execute(
|
|
"""UPDATE delegation_work_items
|
|
SET metadata=?, updated_at=?
|
|
WHERE work_item_id=?""",
|
|
(_json_dumps(meta), datetime.now().isoformat(), work_item_id),
|
|
)
|
|
total += 1
|
|
|
|
# tasks.metadata.delegation_role_session_id
|
|
async with self._db.execute(
|
|
"""SELECT id, metadata FROM tasks
|
|
WHERE metadata LIKE ?""",
|
|
(f'%"{source_id}"%',),
|
|
) as cursor:
|
|
task_rows = await cursor.fetchall()
|
|
for task_id, metadata_json in task_rows:
|
|
meta = _json_loads(metadata_json, {})
|
|
mutated = False
|
|
if str(meta.get("delegation_role_session_id", "")) == source_id:
|
|
meta["delegation_role_session_id"] = target_id
|
|
mutated = True
|
|
if mutated:
|
|
await self._db.execute(
|
|
"""UPDATE tasks SET metadata=? WHERE id=?""",
|
|
(_json_dumps(meta), task_id),
|
|
)
|
|
total += 1
|
|
|
|
return total
|
|
|
|
async def save_delegation_work_item(
|
|
self,
|
|
item: DelegationWorkItem,
|
|
) -> None:
|
|
await self._write_delegation_work_item(item, if_absent=False)
|
|
|
|
async def _write_delegation_work_item(
|
|
self,
|
|
item: DelegationWorkItem,
|
|
*,
|
|
if_absent: bool,
|
|
) -> bool:
|
|
# Single-source-of-truth gate: every write — whether it goes through
|
|
# update_delegation_work_item or directly mutates `item.phase` and
|
|
# then calls save — passes through validate_transition. Skipping the
|
|
# validation requires a separate code path; there is no way to write
|
|
# an invalid phase by accident.
|
|
existing = await self.get_delegation_work_item(item.work_item_id)
|
|
if if_absent and existing is not None:
|
|
return False
|
|
previous_phase = existing.phase if existing is not None else None
|
|
validate_transition(previous_phase, item.phase)
|
|
item.metadata = dict(item.metadata or {})
|
|
if (
|
|
self._metadata_has_work_item_projection_identity(item.metadata)
|
|
or str(item.projection_id or "").strip()
|
|
or str(item.kind or "").strip()
|
|
):
|
|
item.metadata, _ = migrate_work_item_projection_metadata(
|
|
item.metadata,
|
|
projection_id_fallback=str(item.projection_id or item.work_item_id or "").strip(),
|
|
turn_type_fallback=str(item.kind or "").strip(),
|
|
)
|
|
# Capture pre-write state we need for hook context (the persisted
|
|
# row's previous claim, etc.) — hooks fire on the saved item but
|
|
# may want to know "what changed".
|
|
target_phase = item.phase
|
|
db = self._require_db()
|
|
conflict_action = (
|
|
"DO NOTHING"
|
|
if if_absent
|
|
else """DO UPDATE SET
|
|
run_id=excluded.run_id,
|
|
cell_id=excluded.cell_id,
|
|
team_instance_id=excluded.team_instance_id,
|
|
team_id=excluded.team_id,
|
|
role_id=excluded.role_id,
|
|
seat_id=excluded.seat_id,
|
|
seat_state_id=excluded.seat_state_id,
|
|
role_runtime_session_id=excluded.role_runtime_session_id,
|
|
parent_work_item_id=excluded.parent_work_item_id,
|
|
source_role_id=excluded.source_role_id,
|
|
source_seat_id=excluded.source_seat_id,
|
|
title=excluded.title,
|
|
summary=excluded.summary,
|
|
kind=excluded.kind,
|
|
projection_id=excluded.projection_id,
|
|
phase=excluded.phase,
|
|
batch_id=excluded.batch_id,
|
|
batch_index=excluded.batch_index,
|
|
deliverable_summary=excluded.deliverable_summary,
|
|
blocked_reason=excluded.blocked_reason,
|
|
handoff_status=excluded.handoff_status,
|
|
continuation_source=excluded.continuation_source,
|
|
manager_role_id=excluded.manager_role_id,
|
|
manager_seat_id=excluded.manager_seat_id,
|
|
claimed_by_role_runtime_session_id=excluded.claimed_by_role_runtime_session_id,
|
|
claimed_by_seat_id=excluded.claimed_by_seat_id,
|
|
metadata=excluded.metadata,
|
|
created_at=excluded.created_at,
|
|
updated_at=excluded.updated_at"""
|
|
)
|
|
cursor = await db.execute(
|
|
f"""INSERT INTO delegation_work_items
|
|
(work_item_id, run_id, cell_id, team_instance_id, team_id, role_id, seat_id, seat_state_id,
|
|
role_runtime_session_id, parent_work_item_id, source_role_id, source_seat_id, title, summary,
|
|
kind, projection_id, phase, batch_id, batch_index,
|
|
deliverable_summary, blocked_reason, handoff_status, continuation_source, manager_role_id,
|
|
manager_seat_id, claimed_by_role_runtime_session_id, claimed_by_seat_id, metadata,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(work_item_id) {conflict_action}""",
|
|
(
|
|
item.work_item_id,
|
|
item.run_id,
|
|
item.cell_id,
|
|
item.team_instance_id,
|
|
item.team_id,
|
|
item.role_id,
|
|
item.seat_id,
|
|
item.seat_state_id,
|
|
item.role_runtime_session_id,
|
|
item.parent_work_item_id,
|
|
item.source_role_id,
|
|
item.source_seat_id,
|
|
item.title,
|
|
item.summary,
|
|
item.kind,
|
|
item.projection_id,
|
|
item.phase.value,
|
|
item.batch_id,
|
|
int(item.batch_index or 0),
|
|
item.deliverable_summary,
|
|
item.blocked_reason,
|
|
item.handoff_status,
|
|
item.continuation_source,
|
|
item.manager_role_id,
|
|
item.manager_seat_id,
|
|
item.claimed_by_role_runtime_session_id,
|
|
item.claimed_by_seat_id,
|
|
_json_dumps(item.metadata),
|
|
item.created_at.isoformat(),
|
|
item.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
if if_absent and not (getattr(cursor, "rowcount", 0) or 0):
|
|
return False
|
|
# D2 hook fire — propagate phase change to dependent layers
|
|
# (task.status, role_session.status, dispatcher wake, etc.). All
|
|
# writes to delegation_work_items pass through here, so this is
|
|
# the single chokepoint where hooks fire.
|
|
try:
|
|
await on_phase_transition(previous_phase, target_phase, item, store=self)
|
|
except Exception: # never let hook failures break the write
|
|
logger.opt(exception=True).debug("on_phase_transition raised at top level")
|
|
return True
|
|
|
|
async def insert_delegation_work_item_if_absent(
|
|
self,
|
|
item: DelegationWorkItem,
|
|
) -> bool:
|
|
"""Atomically create a WorkItem without overwriting a concurrent claim.
|
|
|
|
Auxiliary report/review IDs are deterministic. A read-before-write
|
|
check is insufficient because another dispatcher can create and claim
|
|
the same card between those operations; the conflict decision must be
|
|
made by SQLite in the insert statement itself.
|
|
"""
|
|
return await self._write_delegation_work_item(item, if_absent=True)
|
|
|
|
async def list_delegation_work_items(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
team_instance_id: str | None = None,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
role_runtime_session_id: str | None = None,
|
|
role_id: str | None = None,
|
|
batch_id: str | None = None,
|
|
) -> list[DelegationWorkItem]:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM delegation_work_items WHERE run_id = ?"
|
|
params: list[Any] = [run_id]
|
|
if team_instance_id:
|
|
query += " AND team_instance_id = ?"
|
|
params.append(team_instance_id)
|
|
if team_id:
|
|
query += " AND team_id = ?"
|
|
params.append(team_id)
|
|
if seat_id:
|
|
query += " AND seat_id = ?"
|
|
params.append(seat_id)
|
|
if role_runtime_session_id:
|
|
query += " AND role_runtime_session_id = ?"
|
|
params.append(role_runtime_session_id)
|
|
if role_id:
|
|
query += " AND role_id = ?"
|
|
params.append(role_id)
|
|
if batch_id:
|
|
query += " AND batch_id = ?"
|
|
params.append(batch_id)
|
|
query += " ORDER BY created_at ASC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_delegation_work_item(row, cursor.description) for row in rows]
|
|
|
|
async def list_manager_board(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
manager_seat_id: str,
|
|
parent_work_item_id: str | None = None,
|
|
) -> list[DelegationWorkItem]:
|
|
db = self._require_db()
|
|
query = "SELECT * FROM delegation_work_items WHERE run_id = ? AND manager_seat_id = ?"
|
|
params: list[Any] = [run_id, str(manager_seat_id or "").strip()]
|
|
normalized_parent = str(parent_work_item_id or "").strip()
|
|
if normalized_parent:
|
|
query += " AND parent_work_item_id = ?"
|
|
params.append(normalized_parent)
|
|
query += " ORDER BY batch_index ASC, created_at ASC"
|
|
async with db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_delegation_work_item(row, cursor.description) for row in rows]
|
|
|
|
async def summarize_parent_status(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
manager_seat_id: str,
|
|
parent_work_item_id: str,
|
|
) -> dict[str, Any]:
|
|
parent_id = str(parent_work_item_id or "").strip()
|
|
manager_id = str(manager_seat_id or "").strip()
|
|
if not run_id or not parent_id or not manager_id:
|
|
return {
|
|
"run_id": str(run_id or "").strip(),
|
|
"manager_seat_id": manager_id,
|
|
"parent_work_item_id": parent_id,
|
|
"total_children": 0,
|
|
"phase_counts": {},
|
|
"column_counts": {},
|
|
"releasable_work_item_ids": [],
|
|
"blocked_reasons": [],
|
|
"blocker_count": 0,
|
|
"rework_count": 0,
|
|
"upstream_summary": [],
|
|
"derived_parent_column": "todo",
|
|
}
|
|
children = await self.list_manager_board(
|
|
run_id,
|
|
manager_seat_id=manager_id,
|
|
parent_work_item_id=parent_id,
|
|
)
|
|
phase_counts: dict[str, int] = {}
|
|
column_counts: dict[str, int] = {}
|
|
blocked_reasons: list[str] = []
|
|
releasable_work_item_ids: list[str] = []
|
|
upstream_summary: list[dict[str, Any]] = []
|
|
blocker_count = 0
|
|
rework_count = 0
|
|
done_children = 0
|
|
active_children = 0
|
|
review_children = 0
|
|
todo_children = 0
|
|
visible_children = 0
|
|
for item in children:
|
|
phase = item.phase
|
|
metadata = dict(item.metadata or {})
|
|
column_value = kanban_column(phase)
|
|
phase_counts[phase.value] = phase_counts.get(phase.value, 0) + 1
|
|
column_counts[column_value] = column_counts.get(column_value, 0) + 1
|
|
if item.blocked_reason:
|
|
blocked_reasons.append(str(item.blocked_reason).strip())
|
|
if item.blocked_reason or phase in {
|
|
Phase.WAITING_FOR_PEER,
|
|
Phase.WAITING_FOR_CHILDREN,
|
|
Phase.NEEDS_ATTENTION,
|
|
Phase.WAITING_DEPENDENCIES,
|
|
}:
|
|
blocker_count += 1
|
|
if str(metadata.get("rework_feedback", "") or "").strip():
|
|
rework_count += 1
|
|
if phase == Phase.QUEUED:
|
|
releasable_work_item_ids.append(str(item.work_item_id))
|
|
if metadata.get("hidden_from_company_kanban"):
|
|
continue
|
|
visible_children += 1
|
|
if phase in DONE_PHASES:
|
|
done_children += 1
|
|
elif phase in IN_PROGRESS_PHASES:
|
|
active_children += 1
|
|
elif phase in IN_REVIEW_PHASES:
|
|
review_children += 1
|
|
else:
|
|
todo_children += 1
|
|
visibility = str(metadata.get("upstream_visibility", "summary_only") or "summary_only").strip().lower()
|
|
if visibility != "hidden":
|
|
payload = {
|
|
"work_item_id": str(item.work_item_id),
|
|
"title": str(item.title or "").strip(),
|
|
"role_id": str(item.role_id or "").strip(),
|
|
"phase": phase.value,
|
|
"kanban_column": column_value,
|
|
"deliverable_summary": str(item.deliverable_summary or "").strip(),
|
|
"blocked_reason": str(item.blocked_reason or "").strip(),
|
|
"completion_report": str(metadata.get("completion_report", "") or "").strip(),
|
|
"review_owner_role_id": str(
|
|
metadata.get("review_owner_role_id")
|
|
or item.manager_role_id
|
|
or ""
|
|
).strip(),
|
|
"review_owner_seat_id": str(
|
|
metadata.get("review_owner_seat_id")
|
|
or item.manager_seat_id
|
|
or ""
|
|
).strip(),
|
|
"review_evidence": dict(metadata.get("review_evidence", {}) or {}),
|
|
}
|
|
if visibility == "debug":
|
|
payload["summary"] = str(item.summary or "").strip()
|
|
payload["dependency_work_item_ids"] = [
|
|
str(dep).strip()
|
|
for dep in list(metadata.get("dependency_work_item_ids", []) or [])
|
|
if str(dep).strip()
|
|
]
|
|
upstream_summary.append(payload)
|
|
derived_parent_column = "todo"
|
|
if visible_children and done_children == visible_children:
|
|
derived_parent_column = "done"
|
|
elif review_children:
|
|
derived_parent_column = "in_review"
|
|
elif active_children:
|
|
derived_parent_column = "in_progress"
|
|
return {
|
|
"run_id": str(run_id or "").strip(),
|
|
"manager_seat_id": manager_id,
|
|
"parent_work_item_id": parent_id,
|
|
"total_children": visible_children,
|
|
"phase_counts": phase_counts,
|
|
"column_counts": column_counts,
|
|
"releasable_work_item_ids": releasable_work_item_ids,
|
|
"blocked_reasons": list(dict.fromkeys(item for item in blocked_reasons if item)),
|
|
"blocker_count": blocker_count,
|
|
"rework_count": rework_count,
|
|
"upstream_summary": upstream_summary[:12],
|
|
"derived_parent_column": derived_parent_column,
|
|
}
|
|
|
|
async def get_delegation_work_item(self, work_item_id: str) -> DelegationWorkItem | None:
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"SELECT * FROM delegation_work_items WHERE work_item_id = ?",
|
|
(work_item_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
return self._row_to_delegation_work_item(row, cursor.description)
|
|
|
|
async def update_delegation_work_item(
|
|
self,
|
|
work_item_id: str,
|
|
*,
|
|
team_instance_id: str | None = None,
|
|
team_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
seat_state_id: str | None = None,
|
|
role_runtime_session_id: str | None = None,
|
|
phase: Phase | str | None = None,
|
|
summary: str | None = None,
|
|
batch_id: str | None = None,
|
|
batch_index: int | None = None,
|
|
deliverable_summary: str | None = None,
|
|
blocked_reason: str | None = None,
|
|
handoff_status: str | None = None,
|
|
continuation_source: str | None = None,
|
|
metadata_updates: dict[str, Any] | None = None,
|
|
metadata_unset: list[str] | tuple[str, ...] | None = None,
|
|
manager_role_id: str | None = None,
|
|
manager_seat_id: str | None = None,
|
|
claimed_by_role_runtime_session_id: str | None = None,
|
|
claimed_by_seat_id: str | None = None,
|
|
) -> DelegationWorkItem | None:
|
|
item = await self.get_delegation_work_item(work_item_id)
|
|
if item is None:
|
|
return None
|
|
previous_phase = item.phase
|
|
if team_instance_id is not None:
|
|
item.team_instance_id = str(team_instance_id or "").strip()
|
|
if team_id is not None:
|
|
item.team_id = str(team_id or "").strip()
|
|
if seat_id is not None:
|
|
item.seat_id = str(seat_id or "").strip()
|
|
if seat_state_id is not None:
|
|
item.seat_state_id = str(seat_state_id or "").strip()
|
|
if role_runtime_session_id is not None:
|
|
item.role_runtime_session_id = str(role_runtime_session_id or "").strip()
|
|
if phase is not None:
|
|
target_phase = coerce_phase(phase)
|
|
validate_transition(previous_phase, target_phase)
|
|
item.phase = target_phase
|
|
if summary is not None:
|
|
item.summary = summary
|
|
if batch_id is not None:
|
|
item.batch_id = str(batch_id or "").strip()
|
|
if batch_index is not None:
|
|
item.batch_index = int(batch_index)
|
|
if deliverable_summary is not None:
|
|
item.deliverable_summary = str(deliverable_summary or "").strip()
|
|
if blocked_reason is not None:
|
|
item.blocked_reason = str(blocked_reason or "").strip()
|
|
if handoff_status is not None:
|
|
item.handoff_status = str(handoff_status or "").strip()
|
|
if continuation_source is not None:
|
|
item.continuation_source = str(continuation_source or "").strip()
|
|
if manager_role_id is not None:
|
|
item.manager_role_id = str(manager_role_id or "").strip()
|
|
if manager_seat_id is not None:
|
|
item.manager_seat_id = str(manager_seat_id or "").strip()
|
|
if claimed_by_role_runtime_session_id is not None:
|
|
item.claimed_by_role_runtime_session_id = str(claimed_by_role_runtime_session_id or "").strip()
|
|
if claimed_by_seat_id is not None:
|
|
item.claimed_by_seat_id = str(claimed_by_seat_id or "").strip()
|
|
if metadata_unset or metadata_updates:
|
|
metadata = dict(item.metadata or {})
|
|
for key in list(metadata_unset or []):
|
|
metadata.pop(str(key), None)
|
|
if metadata_updates:
|
|
metadata.update(dict(metadata_updates))
|
|
item.metadata = metadata
|
|
item.updated_at = datetime.now()
|
|
await self.save_delegation_work_item(item)
|
|
return item
|
|
|
|
async def apply_delegation_review_resolution(
|
|
self,
|
|
work_item_id: str,
|
|
*,
|
|
source_report_work_item_id: str,
|
|
target_phase: Phase | str,
|
|
blocked_reason: str,
|
|
metadata_updates: dict[str, Any],
|
|
) -> DelegationWorkItem | None:
|
|
"""Atomically apply a manager verdict to its exact report generation.
|
|
|
|
The phase predicate and latest-applied-report predicate live in the
|
|
same SQLite UPDATE as the child phase + applied-stamp write. This
|
|
prevents a late manager turn from crossing an AWAITING_HUMAN
|
|
transition or approving an older report after a newer report landed.
|
|
``updated_at`` provides optimistic metadata concurrency; a few retries
|
|
preserve unrelated same-phase updates without weakening either guard.
|
|
"""
|
|
target = coerce_phase(target_phase)
|
|
validate_transition(Phase.AWAITING_MANAGER_REVIEW, target)
|
|
expected_source = str(source_report_work_item_id or "").strip()
|
|
db = self._require_db()
|
|
|
|
for _attempt in range(3):
|
|
item = await self.get_delegation_work_item(work_item_id)
|
|
if item is None or item.phase != Phase.AWAITING_MANAGER_REVIEW:
|
|
return None
|
|
metadata = dict(item.metadata or {})
|
|
metadata.update(dict(metadata_updates or {}))
|
|
if (
|
|
self._metadata_has_work_item_projection_identity(metadata)
|
|
or str(item.projection_id or "").strip()
|
|
or str(item.kind or "").strip()
|
|
):
|
|
metadata, _ = migrate_work_item_projection_metadata(
|
|
metadata,
|
|
projection_id_fallback=str(
|
|
item.projection_id or item.work_item_id or ""
|
|
).strip(),
|
|
turn_type_fallback=str(item.kind or "").strip(),
|
|
)
|
|
previous_updated_at = item.updated_at.isoformat()
|
|
updated_at = datetime.now()
|
|
cursor = await db.execute(
|
|
"""UPDATE delegation_work_items
|
|
SET phase = ?, blocked_reason = ?, metadata = ?, updated_at = ?
|
|
WHERE work_item_id = ?
|
|
AND phase = ?
|
|
AND updated_at = ?
|
|
AND COALESCE((
|
|
SELECT report.work_item_id
|
|
FROM delegation_work_items AS report
|
|
WHERE report.parent_work_item_id = ?
|
|
AND report.kind = 'report'
|
|
AND json_extract(
|
|
report.metadata,
|
|
'$.report_target_work_item_id'
|
|
) = ?
|
|
AND json_extract(
|
|
report.metadata,
|
|
'$.report_card_outcome'
|
|
) = 'applied'
|
|
ORDER BY report.batch_index DESC,
|
|
report.created_at DESC,
|
|
report.work_item_id DESC
|
|
LIMIT 1
|
|
), '') = ?""",
|
|
(
|
|
target.value,
|
|
str(blocked_reason or ""),
|
|
_json_dumps(metadata),
|
|
updated_at.isoformat(),
|
|
work_item_id,
|
|
Phase.AWAITING_MANAGER_REVIEW.value,
|
|
previous_updated_at,
|
|
work_item_id,
|
|
work_item_id,
|
|
expected_source,
|
|
),
|
|
)
|
|
await db.commit()
|
|
if not (getattr(cursor, "rowcount", 0) or 0):
|
|
continue
|
|
persisted = await self.get_delegation_work_item(work_item_id)
|
|
if persisted is None:
|
|
return None
|
|
try:
|
|
await on_phase_transition(
|
|
Phase.AWAITING_MANAGER_REVIEW,
|
|
target,
|
|
persisted,
|
|
store=self,
|
|
)
|
|
except Exception:
|
|
logger.opt(exception=True).debug(
|
|
"on_phase_transition raised after review-resolution CAS"
|
|
)
|
|
return persisted
|
|
return None
|
|
|
|
async def reopen_approved_delegation_work_item_for_rework(
|
|
self,
|
|
work_item_id: str,
|
|
*,
|
|
target_phase: Phase | str = Phase.READY_FOR_REWORK,
|
|
summary: str | None = None,
|
|
deliverable_summary: str | None = "",
|
|
blocked_reason: str | None = "",
|
|
metadata_updates: dict[str, Any] | None = None,
|
|
metadata_unset: list[str] | tuple[str, ...] | None = None,
|
|
release_claim: bool = True,
|
|
) -> DelegationWorkItem | None:
|
|
"""Named bypass for invalidating approved work after manager/user feedback."""
|
|
item = await self.get_delegation_work_item(work_item_id)
|
|
if item is None:
|
|
return None
|
|
previous_phase = item.phase
|
|
target = coerce_phase(target_phase)
|
|
if previous_phase != Phase.APPROVED:
|
|
raise InvalidPhaseTransition(
|
|
f"executive rework can only reopen approved work items, got {previous_phase.value}"
|
|
)
|
|
if target not in {Phase.READY_FOR_REWORK, Phase.READY}:
|
|
raise InvalidPhaseTransition(
|
|
f"executive rework target must be ready_for_rework or ready, got {target.value}"
|
|
)
|
|
|
|
item.phase = target
|
|
if summary is not None:
|
|
item.summary = str(summary or "").strip()
|
|
if deliverable_summary is not None:
|
|
item.deliverable_summary = str(deliverable_summary or "").strip()
|
|
if blocked_reason is not None:
|
|
item.blocked_reason = str(blocked_reason or "").strip()
|
|
if release_claim:
|
|
item.claimed_by_role_runtime_session_id = ""
|
|
item.claimed_by_seat_id = ""
|
|
|
|
metadata = dict(item.metadata or {})
|
|
for key in list(metadata_unset or []):
|
|
metadata.pop(str(key), None)
|
|
if metadata_updates:
|
|
metadata.update(dict(metadata_updates))
|
|
if (
|
|
self._metadata_has_work_item_projection_identity(metadata)
|
|
or str(item.projection_id or "").strip()
|
|
or str(item.kind or "").strip()
|
|
):
|
|
metadata, _ = migrate_work_item_projection_metadata(
|
|
metadata,
|
|
projection_id_fallback=str(item.projection_id or item.work_item_id or "").strip(),
|
|
turn_type_fallback=str(item.kind or "").strip(),
|
|
)
|
|
item.metadata = metadata
|
|
item.updated_at = datetime.now()
|
|
|
|
db = self._require_db()
|
|
await db.execute(
|
|
"""UPDATE delegation_work_items
|
|
SET phase=?,
|
|
summary=?,
|
|
deliverable_summary=?,
|
|
blocked_reason=?,
|
|
claimed_by_role_runtime_session_id=?,
|
|
claimed_by_seat_id=?,
|
|
metadata=?,
|
|
updated_at=?
|
|
WHERE work_item_id=?""",
|
|
(
|
|
item.phase.value,
|
|
item.summary,
|
|
item.deliverable_summary,
|
|
item.blocked_reason,
|
|
item.claimed_by_role_runtime_session_id,
|
|
item.claimed_by_seat_id,
|
|
_json_dumps(item.metadata),
|
|
item.updated_at.isoformat(),
|
|
item.work_item_id,
|
|
),
|
|
)
|
|
await db.commit()
|
|
try:
|
|
await on_phase_transition(previous_phase, target, item, store=self)
|
|
except Exception:
|
|
logger.opt(exception=True).debug("on_phase_transition raised during approved work-item reopen")
|
|
return item
|
|
|
|
async def amend_delegation_work_item(
|
|
self,
|
|
work_item_id: str,
|
|
*,
|
|
title: str | None = None,
|
|
summary: str | None = None,
|
|
kind: str | None = None,
|
|
role_id: str | None = None,
|
|
seat_id: str | None = None,
|
|
team_instance_id: str | None = None,
|
|
team_id: str | None = None,
|
|
role_runtime_session_id: str | None = None,
|
|
claimed_by_role_runtime_session_id: str | None = None,
|
|
claimed_by_seat_id: str | None = None,
|
|
dependency_work_item_ids: list[str] | None = None,
|
|
phase: Phase | str | None = None,
|
|
metadata_set: dict[str, Any] | None = None,
|
|
metadata_unset: list[str] | None = None,
|
|
) -> DelegationWorkItem | None:
|
|
item = await self.get_delegation_work_item(work_item_id)
|
|
if item is None:
|
|
return None
|
|
previous_phase = item.phase
|
|
if title is not None:
|
|
item.title = str(title or "").strip()
|
|
if summary is not None:
|
|
item.summary = str(summary or "").strip()
|
|
if kind is not None:
|
|
item.kind = str(kind or "").strip() or item.kind
|
|
if role_id is not None:
|
|
item.role_id = str(role_id or "").strip()
|
|
if seat_id is not None:
|
|
item.seat_id = str(seat_id or "").strip()
|
|
if team_instance_id is not None:
|
|
item.team_instance_id = str(team_instance_id or "").strip()
|
|
if team_id is not None:
|
|
item.team_id = str(team_id or "").strip()
|
|
if role_runtime_session_id is not None:
|
|
item.role_runtime_session_id = str(role_runtime_session_id or "").strip()
|
|
if claimed_by_role_runtime_session_id is not None:
|
|
item.claimed_by_role_runtime_session_id = str(claimed_by_role_runtime_session_id or "").strip()
|
|
if claimed_by_seat_id is not None:
|
|
item.claimed_by_seat_id = str(claimed_by_seat_id or "").strip()
|
|
if phase is not None:
|
|
target_phase = coerce_phase(phase)
|
|
validate_transition(previous_phase, target_phase)
|
|
item.phase = target_phase
|
|
|
|
metadata = dict(item.metadata or {})
|
|
if dependency_work_item_ids is not None:
|
|
raw_dependencies = (
|
|
[dependency_work_item_ids]
|
|
if isinstance(dependency_work_item_ids, str)
|
|
else list(dependency_work_item_ids or [])
|
|
)
|
|
metadata["dependency_work_item_ids"] = [
|
|
str(dep).strip()
|
|
for dep in raw_dependencies
|
|
if str(dep).strip()
|
|
]
|
|
if metadata_set:
|
|
metadata.update(dict(metadata_set))
|
|
for key in list(metadata_unset or []):
|
|
metadata.pop(str(key), None)
|
|
item.metadata = metadata
|
|
item.updated_at = datetime.now()
|
|
await self.save_delegation_work_item(item)
|
|
return item
|
|
|
|
async def replace_work_item_dependency(
|
|
self,
|
|
run_id: str,
|
|
old_work_item_id: str,
|
|
new_work_item_ids: list[str],
|
|
) -> list[DelegationWorkItem]:
|
|
rid = str(run_id or "").strip()
|
|
old_id = str(old_work_item_id or "").strip()
|
|
raw_replacements = (
|
|
[new_work_item_ids]
|
|
if isinstance(new_work_item_ids, str)
|
|
else list(new_work_item_ids or [])
|
|
)
|
|
replacements = [
|
|
str(item).strip()
|
|
for item in raw_replacements
|
|
if str(item).strip()
|
|
]
|
|
if not rid or not old_id:
|
|
return []
|
|
updated: list[DelegationWorkItem] = []
|
|
for item in await self.list_delegation_work_items(rid):
|
|
metadata = dict(item.metadata or {})
|
|
dependency_ids = [
|
|
str(dep).strip()
|
|
for dep in list(metadata.get("dependency_work_item_ids", []) or [])
|
|
if str(dep).strip()
|
|
]
|
|
waiting_ids = [
|
|
str(dep).strip()
|
|
for dep in list(metadata.get("waiting_on_work_item_ids", []) or [])
|
|
if str(dep).strip()
|
|
]
|
|
if old_id not in dependency_ids and old_id not in waiting_ids:
|
|
continue
|
|
rewritten: list[str] = []
|
|
for dep in dependency_ids:
|
|
if dep == old_id:
|
|
rewritten.extend(replacements)
|
|
else:
|
|
rewritten.append(dep)
|
|
deduped = list(dict.fromkeys(dep for dep in rewritten if dep))
|
|
rewritten_waiting: list[str] = []
|
|
for dep in waiting_ids:
|
|
if dep == old_id:
|
|
rewritten_waiting.extend(replacements)
|
|
else:
|
|
rewritten_waiting.append(dep)
|
|
item.metadata = {
|
|
**metadata,
|
|
"dependency_work_item_ids": deduped,
|
|
"waiting_on_work_item_ids": list(
|
|
dict.fromkeys(dep for dep in rewritten_waiting if dep)
|
|
),
|
|
"dependency_rewritten_from_work_item_id": old_id,
|
|
"dependency_rewritten_at": datetime.now().isoformat(),
|
|
}
|
|
item.updated_at = datetime.now()
|
|
await self.save_delegation_work_item(item)
|
|
updated.append(item)
|
|
return updated
|
|
|
|
async def release_manager_work_items(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
manager_seat_id: str,
|
|
work_item_ids: list[str] | None = None,
|
|
parent_work_item_id: str | None = None,
|
|
release_note: str = "",
|
|
released_by_message_id: str = "",
|
|
action_hint: str = "",
|
|
) -> list[DelegationWorkItem]:
|
|
target_ids = {
|
|
str(item).strip()
|
|
for item in list(work_item_ids or [])
|
|
if str(item).strip()
|
|
}
|
|
candidates = await self.list_manager_board(
|
|
run_id,
|
|
manager_seat_id=manager_seat_id,
|
|
parent_work_item_id=parent_work_item_id,
|
|
)
|
|
released: list[DelegationWorkItem] = []
|
|
for item in candidates:
|
|
if target_ids and item.work_item_id not in target_ids:
|
|
continue
|
|
metadata_updates = {
|
|
"last_release_note": str(release_note or "").strip(),
|
|
"last_released_by_message_id": str(released_by_message_id or "").strip(),
|
|
"last_release_action_hint": str(action_hint or "").strip(),
|
|
"released_at": datetime.now().isoformat(),
|
|
}
|
|
target_phase = Phase.READY if item.phase == Phase.QUEUED else item.phase
|
|
updated = await self.update_delegation_work_item(
|
|
item.work_item_id,
|
|
phase=target_phase,
|
|
metadata_updates=metadata_updates,
|
|
)
|
|
if updated is not None:
|
|
released.append(updated)
|
|
return released
|
|
|
|
async def rollup_manager_board(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
manager_seat_id: str,
|
|
parent_work_item_id: str,
|
|
summary: str = "",
|
|
phase: Phase | str | None = None,
|
|
blocked_reason: str | None = None,
|
|
metadata_updates: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
rollup = await self.summarize_parent_status(
|
|
run_id,
|
|
manager_seat_id=manager_seat_id,
|
|
parent_work_item_id=parent_work_item_id,
|
|
)
|
|
parent = await self.get_delegation_work_item(parent_work_item_id)
|
|
if parent is not None:
|
|
merged_updates = {
|
|
"manager_board_rollup": dict(rollup),
|
|
"manager_board_rollup_updated_at": datetime.now().isoformat(),
|
|
}
|
|
if summary:
|
|
merged_updates["manager_board_rollup_summary"] = str(summary).strip()
|
|
if metadata_updates:
|
|
merged_updates.update(dict(metadata_updates))
|
|
await self.update_delegation_work_item(
|
|
parent_work_item_id,
|
|
phase=phase,
|
|
deliverable_summary=summary or None,
|
|
blocked_reason=blocked_reason,
|
|
metadata_updates=merged_updates,
|
|
)
|
|
refreshed_parent = await self.get_delegation_work_item(parent_work_item_id)
|
|
if refreshed_parent is not None:
|
|
rollup["parent_phase"] = refreshed_parent.phase.value
|
|
rollup["parent_column"] = kanban_column(refreshed_parent.phase)
|
|
rollup["parent_deliverable_summary"] = str(getattr(refreshed_parent, "deliverable_summary", "") or "").strip()
|
|
rollup["parent_blocked_reason"] = str(getattr(refreshed_parent, "blocked_reason", "") or "").strip()
|
|
return rollup
|
|
|
|
async def save_delegation_event(self, event: DelegationEvent) -> None:
|
|
db = self._require_db()
|
|
await db.execute(
|
|
"""INSERT OR REPLACE INTO delegation_events
|
|
(event_id, run_id, work_item_id, cell_id, role_id, event_type, payload, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
event.event_id,
|
|
event.run_id,
|
|
event.work_item_id,
|
|
event.cell_id,
|
|
event.role_id,
|
|
event.event_type,
|
|
_json_dumps(event.payload),
|
|
event.created_at.isoformat(),
|
|
),
|
|
)
|
|
await db.commit()
|
|
|
|
async def list_delegation_events(self, run_id: str) -> list[DelegationEvent]:
|
|
db = self._require_db()
|
|
async with db.execute(
|
|
"SELECT * FROM delegation_events WHERE run_id = ? ORDER BY created_at ASC",
|
|
(run_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
DelegationEvent(
|
|
event_id=data["event_id"],
|
|
run_id=data["run_id"],
|
|
work_item_id=data.get("work_item_id"),
|
|
cell_id=data.get("cell_id"),
|
|
role_id=data.get("role_id"),
|
|
event_type=data["event_type"],
|
|
payload=_json_loads(data.get("payload"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
# --- Dynamic reorg persistence ---
|
|
|
|
async def save_reorg_proposal(self, proposal: ReorgProposal) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO reorg_proposals
|
|
(proposal_id, project_id, session_id, task_id, initiated_by, source_role_id, scope, risk_level,
|
|
status, title, summary, rationale, user_confirmation_required, old_org_version, new_org_version,
|
|
changeset, migration_plan, impact_summary,
|
|
approval_notes, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
proposal.proposal_id,
|
|
proposal.project_id,
|
|
proposal.session_id,
|
|
proposal.task_id,
|
|
proposal.initiated_by,
|
|
proposal.source_role_id,
|
|
proposal.scope.value,
|
|
proposal.risk_level.value,
|
|
proposal.status.value,
|
|
proposal.title,
|
|
proposal.summary,
|
|
proposal.rationale,
|
|
int(proposal.user_confirmation_required),
|
|
proposal.old_org_version,
|
|
proposal.new_org_version,
|
|
_json_dumps(proposal.changeset.__dict__),
|
|
_json_dumps(proposal.migration_plan.__dict__),
|
|
_json_dumps(proposal.impact_summary),
|
|
proposal.approval_notes,
|
|
_json_dumps(proposal.metadata),
|
|
proposal.created_at.isoformat(),
|
|
proposal.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_reorg_proposal(self, proposal_id: str) -> ReorgProposal | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM reorg_proposals WHERE proposal_id = ?",
|
|
(proposal_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_reorg_proposal(row, cursor.description)
|
|
|
|
async def list_reorg_proposals(
|
|
self,
|
|
project_id: str,
|
|
status: ReorgProposalStatus | None = None,
|
|
limit: int = 20,
|
|
) -> list[ReorgProposal]:
|
|
assert self._db
|
|
query = "SELECT * FROM reorg_proposals WHERE project_id = ?"
|
|
params: list[Any] = [project_id]
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status.value)
|
|
query += " ORDER BY updated_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_reorg_proposal(row, cursor.description) for row in rows]
|
|
|
|
def _row_to_reorg_proposal(self, row: Any, description: Any) -> ReorgProposal:
|
|
from dataclasses import fields as _dc_fields
|
|
|
|
from opc.core.models import (
|
|
ReorgChangeSet,
|
|
ReorgMigrationPlan,
|
|
ReorgRoleChange,
|
|
ReorgTaskAdjustment,
|
|
)
|
|
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
changeset_data = _json_loads(data.get("changeset"), {})
|
|
migration_plan_data = _json_loads(data.get("migration_plan"), {})
|
|
migration_plan_data = {
|
|
k: v
|
|
for k, v in migration_plan_data.items()
|
|
if k in {f.name for f in _dc_fields(ReorgMigrationPlan)}
|
|
}
|
|
return ReorgProposal(
|
|
proposal_id=data["proposal_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data.get("session_id"),
|
|
task_id=data.get("task_id"),
|
|
initiated_by=data.get("initiated_by") or "owner",
|
|
source_role_id=data.get("source_role_id") or "",
|
|
scope=ReorgScope(data["scope"]),
|
|
risk_level=ReorgRiskLevel(data["risk_level"]),
|
|
status=ReorgProposalStatus(data["status"]),
|
|
title=data.get("title") or "",
|
|
summary=data.get("summary") or "",
|
|
rationale=data.get("rationale") or "",
|
|
user_confirmation_required=bool(data.get("user_confirmation_required", 1)),
|
|
old_org_version=int(data.get("old_org_version") or 1),
|
|
new_org_version=int(data.get("new_org_version") or 1),
|
|
old_runtime_topology_version=int(data.get("old_runtime_topology_version") or 1),
|
|
new_runtime_topology_version=int(data.get("new_runtime_topology_version") or 1),
|
|
changeset=ReorgChangeSet(
|
|
role_changes=[ReorgRoleChange(**item) for item in changeset_data.get("role_changes", [])],
|
|
task_adjustments=[ReorgTaskAdjustment(**item) for item in changeset_data.get("task_adjustments", [])],
|
|
metadata=dict(changeset_data.get("metadata", {})),
|
|
),
|
|
migration_plan=ReorgMigrationPlan(**migration_plan_data),
|
|
impact_summary=_json_loads(data.get("impact_summary"), {}),
|
|
approval_notes=data.get("approval_notes") or "",
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def save_org_snapshot(self, snapshot: OrgSnapshot) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO org_snapshots
|
|
(snapshot_id, project_id, org_version, company_name, topology, roles,
|
|
company_profile, active_tasks, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
snapshot.snapshot_id,
|
|
snapshot.project_id,
|
|
snapshot.org_version,
|
|
snapshot.company_name,
|
|
snapshot.topology,
|
|
_json_dumps(snapshot.roles),
|
|
snapshot.company_profile,
|
|
_json_dumps(snapshot.active_tasks),
|
|
_json_dumps(snapshot.metadata),
|
|
snapshot.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_org_snapshot(self, snapshot_id: str) -> OrgSnapshot | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM org_snapshots WHERE snapshot_id = ?",
|
|
(snapshot_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_org_snapshot(row, cursor.description)
|
|
|
|
async def get_latest_org_snapshot(self, project_id: str) -> OrgSnapshot | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM org_snapshots WHERE project_id = ? ORDER BY org_version DESC, created_at DESC LIMIT 1",
|
|
(project_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_org_snapshot(row, cursor.description)
|
|
|
|
def _row_to_org_snapshot(self, row: Any, description: Any) -> OrgSnapshot:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return OrgSnapshot(
|
|
snapshot_id=data["snapshot_id"],
|
|
project_id=data["project_id"],
|
|
org_version=int(data.get("org_version") or 1),
|
|
runtime_topology_version=int(data.get("runtime_topology_version") or 1),
|
|
company_name=data.get("company_name") or "",
|
|
topology=data.get("topology") or "",
|
|
roles=_json_loads(data.get("roles"), []),
|
|
company_profile=data.get("company_profile") or "corporate",
|
|
active_tasks=_json_loads(data.get("active_tasks"), []),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
async def record_reorg_event(self, event: ReorgEventRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO reorg_events
|
|
(event_id, proposal_id, project_id, event_kind, summary, details, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
event.event_id,
|
|
event.proposal_id,
|
|
event.project_id,
|
|
event.event_kind.value,
|
|
event.summary,
|
|
_json_dumps(event.details),
|
|
event.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def list_reorg_events(
|
|
self,
|
|
project_id: str,
|
|
proposal_id: str | None = None,
|
|
limit: int = 50,
|
|
) -> list[ReorgEventRecord]:
|
|
assert self._db
|
|
query = "SELECT * FROM reorg_events WHERE project_id = ?"
|
|
params: list[Any] = [project_id]
|
|
if proposal_id:
|
|
query += " AND proposal_id = ?"
|
|
params.append(proposal_id)
|
|
query += " ORDER BY created_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
ReorgEventRecord(
|
|
event_id=data["event_id"],
|
|
proposal_id=data.get("proposal_id") or "",
|
|
project_id=data["project_id"],
|
|
event_kind=ReorgEventKind(data["event_kind"]),
|
|
summary=data.get("summary") or "",
|
|
details=_json_loads(data.get("details"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
async def get_tasks_by_ids(self, task_ids: list[str]) -> list[Task]:
|
|
if not task_ids:
|
|
return []
|
|
assert self._db
|
|
placeholders = ", ".join("?" for _ in task_ids)
|
|
query = f"SELECT * FROM tasks WHERE id IN ({placeholders}) ORDER BY priority ASC, created_at ASC"
|
|
async with self._db.execute(query, task_ids) as cursor:
|
|
rows = await cursor.fetchall()
|
|
tasks = [self._row_to_task(row, cursor.description) for row in rows]
|
|
await self.hydrate_task_work_item_links(tasks)
|
|
return tasks
|
|
|
|
# --- Session memory ---
|
|
|
|
async def save_session(self, session: SessionRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO sessions
|
|
(session_id, project_id, parent_session_id, title, mode, status, summary, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
session.session_id,
|
|
session.project_id,
|
|
session.parent_session_id,
|
|
session.title,
|
|
session.mode,
|
|
session.status,
|
|
session.summary,
|
|
_json_dumps(session.metadata),
|
|
session.created_at.isoformat(),
|
|
session.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_session(self, row: Any, description: Any) -> SessionRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return SessionRecord(
|
|
session_id=data["session_id"],
|
|
project_id=data["project_id"],
|
|
parent_session_id=data["parent_session_id"],
|
|
title=data["title"],
|
|
mode=data["mode"],
|
|
status=data["status"],
|
|
summary=data.get("summary") or "",
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def get_session(self, session_id: str) -> SessionRecord | None:
|
|
assert self._db
|
|
async with self._db.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,)) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_session(row, cursor.description)
|
|
|
|
async def list_sessions(
|
|
self,
|
|
project_id: str = "default",
|
|
parent_session_id: str | None = None,
|
|
limit: int = 50,
|
|
) -> list[SessionRecord]:
|
|
assert self._db
|
|
query = "SELECT * FROM sessions WHERE project_id = ?"
|
|
params: list[Any] = [project_id]
|
|
if parent_session_id is None:
|
|
query += " AND parent_session_id IS NULL"
|
|
else:
|
|
query += " AND parent_session_id = ?"
|
|
params.append(parent_session_id)
|
|
query += " ORDER BY updated_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_session(row, cursor.description) for row in rows]
|
|
|
|
async def touch_session(self, session_id: str) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"UPDATE sessions SET updated_at = ? WHERE session_id = ?",
|
|
(datetime.now().isoformat(), session_id),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def save_session_message(self, message: SessionMessageRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO session_messages
|
|
(message_id, session_id, role, task_id, agent_id, parent_message_id, summary_flag, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
message.message_id,
|
|
message.session_id,
|
|
message.role,
|
|
message.task_id,
|
|
message.agent_id,
|
|
message.parent_message_id,
|
|
int(message.summary_flag),
|
|
_json_dumps(message.metadata),
|
|
message.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.execute(
|
|
"UPDATE sessions SET updated_at = ? WHERE session_id = ?",
|
|
(message.created_at.isoformat(), message.session_id),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_session_message(self, row: Any, description: Any) -> SessionMessageRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return SessionMessageRecord(
|
|
message_id=data["message_id"],
|
|
session_id=data["session_id"],
|
|
role=data["role"],
|
|
task_id=data["task_id"],
|
|
agent_id=data["agent_id"],
|
|
parent_message_id=data["parent_message_id"],
|
|
summary_flag=bool(data["summary_flag"]),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
async def get_session_message(self, message_id: str) -> SessionMessageRecord | None:
|
|
assert self._db
|
|
async with self._db.execute("SELECT * FROM session_messages WHERE message_id = ?", (message_id,)) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_session_message(row, cursor.description)
|
|
|
|
async def list_session_messages(self, session_id: str) -> list[SessionMessageRecord]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM session_messages WHERE session_id = ? ORDER BY created_at ASC",
|
|
(session_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_session_message(row, cursor.description) for row in rows]
|
|
|
|
async def get_session_transcript_page(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
limit: int = 200,
|
|
before_created_at: datetime | None = None,
|
|
before_message_id: str | None = None,
|
|
detail_level: str = "summary",
|
|
) -> dict[str, Any]:
|
|
assert self._db
|
|
normalized_limit = max(1, min(int(limit), 500))
|
|
normalized_detail_level = normalize_transcript_detail_level(detail_level)
|
|
visibility_sql, visibility_params = transcript_visibility_sql(
|
|
detail_level=normalized_detail_level,
|
|
)
|
|
query = (
|
|
"SELECT * FROM session_messages "
|
|
"WHERE session_id = ? AND summary_flag = 0 "
|
|
)
|
|
params: list[Any] = [session_id]
|
|
query += visibility_sql
|
|
params.extend(visibility_params)
|
|
normalized_before_id = str(before_message_id or "").strip()
|
|
if before_created_at is not None:
|
|
before_iso = before_created_at.isoformat()
|
|
if normalized_before_id:
|
|
query += " AND (created_at < ? OR (created_at = ? AND message_id < ?))"
|
|
params.extend([before_iso, before_iso, normalized_before_id])
|
|
else:
|
|
query += " AND created_at < ?"
|
|
params.append(before_iso)
|
|
query += " ORDER BY created_at DESC, message_id DESC LIMIT ?"
|
|
params.append(normalized_limit + 1)
|
|
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
description = cursor.description
|
|
|
|
has_more = len(rows) > normalized_limit
|
|
visible_rows = rows[:normalized_limit]
|
|
messages_desc = [self._row_to_session_message(row, description) for row in visible_rows]
|
|
messages = list(reversed(messages_desc))
|
|
parts_by_message: dict[str, list[SessionPartRecord]] = {}
|
|
if messages:
|
|
placeholders = ",".join("?" for _ in messages)
|
|
part_params = [session_id, *[message.message_id for message in messages]]
|
|
async with self._db.execute(
|
|
f"SELECT * FROM session_parts WHERE session_id = ? AND message_id IN ({placeholders}) "
|
|
"ORDER BY created_at ASC",
|
|
part_params,
|
|
) as cursor:
|
|
part_rows = await cursor.fetchall()
|
|
part_description = cursor.description
|
|
for row in part_rows:
|
|
part = self._row_to_session_part(row, part_description)
|
|
parts_by_message.setdefault(part.message_id, []).append(part)
|
|
|
|
count_query = (
|
|
"SELECT COUNT(*) FROM session_messages "
|
|
"WHERE session_id = ? AND summary_flag = 0 "
|
|
)
|
|
count_params: list[Any] = [session_id]
|
|
count_query += visibility_sql
|
|
count_params.extend(visibility_params)
|
|
async with self._db.execute(count_query, count_params) as cursor:
|
|
row = await cursor.fetchone()
|
|
total_count = int(row[0] or 0) if row else 0
|
|
|
|
return {
|
|
"messages": [
|
|
{
|
|
"message": message,
|
|
"parts": parts_by_message.get(message.message_id, []),
|
|
}
|
|
for message in messages
|
|
],
|
|
"has_more": has_more,
|
|
"total_count": total_count,
|
|
}
|
|
|
|
async def save_session_part(self, part: SessionPartRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO session_parts
|
|
(part_id, message_id, session_id, part_type, payload, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
part.part_id,
|
|
part.message_id,
|
|
part.session_id,
|
|
part.part_type,
|
|
_json_dumps(part.payload),
|
|
part.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_session_part(self, row: Any, description: Any) -> SessionPartRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return SessionPartRecord(
|
|
part_id=data["part_id"],
|
|
message_id=data["message_id"],
|
|
session_id=data["session_id"],
|
|
part_type=data["part_type"],
|
|
payload=_json_loads(data.get("payload"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
async def list_session_parts(self, session_id: str, message_id: str | None = None) -> list[SessionPartRecord]:
|
|
assert self._db
|
|
query = "SELECT * FROM session_parts WHERE session_id = ?"
|
|
params: list[Any] = [session_id]
|
|
if message_id:
|
|
query += " AND message_id = ?"
|
|
params.append(message_id)
|
|
query += " ORDER BY created_at ASC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_session_part(row, cursor.description) for row in rows]
|
|
|
|
async def get_session_transcript(self, session_id: str) -> list[dict[str, Any]]:
|
|
messages = await self.list_session_messages(session_id)
|
|
parts = await self.list_session_parts(session_id)
|
|
parts_by_message: dict[str, list[SessionPartRecord]] = {}
|
|
for part in parts:
|
|
parts_by_message.setdefault(part.message_id, []).append(part)
|
|
return [
|
|
{
|
|
"message": message,
|
|
"parts": parts_by_message.get(message.message_id, []),
|
|
}
|
|
for message in messages
|
|
]
|
|
|
|
async def save_session_compaction(self, record: SessionCompactionRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO session_compactions
|
|
(compaction_id, session_id, compaction_message_id, source_boundary_message_id, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.compaction_id,
|
|
record.session_id,
|
|
record.compaction_message_id,
|
|
record.source_boundary_message_id,
|
|
_json_dumps(record.metadata),
|
|
record.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_session_compaction(self, row: Any, description: Any) -> SessionCompactionRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return SessionCompactionRecord(
|
|
compaction_id=data["compaction_id"],
|
|
session_id=data["session_id"],
|
|
compaction_message_id=data["compaction_message_id"],
|
|
source_boundary_message_id=data["source_boundary_message_id"],
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
async def get_latest_session_compaction(self, session_id: str) -> SessionCompactionRecord | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM session_compactions WHERE session_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
(session_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_session_compaction(row, cursor.description)
|
|
|
|
async def save_session_memory_snapshot(self, record: SessionMemorySnapshotRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO session_memory_snapshots
|
|
(snapshot_id, project_id, session_id, summary_message_id, source_boundary_message_id,
|
|
summary_text, memory_text, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.snapshot_id,
|
|
record.project_id,
|
|
record.session_id,
|
|
record.summary_message_id,
|
|
record.source_boundary_message_id,
|
|
record.summary_text,
|
|
record.memory_text,
|
|
_json_dumps(record.metadata),
|
|
record.created_at.isoformat(),
|
|
record.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_session_memory_snapshot(self, row: Any, description: Any) -> SessionMemorySnapshotRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return SessionMemorySnapshotRecord(
|
|
snapshot_id=data["snapshot_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
summary_message_id=data["summary_message_id"],
|
|
source_boundary_message_id=data["source_boundary_message_id"],
|
|
summary_text=data.get("summary_text") or "",
|
|
memory_text=data.get("memory_text") or "",
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def get_latest_session_memory_snapshot(self, session_id: str) -> SessionMemorySnapshotRecord | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM session_memory_snapshots WHERE session_id = ? ORDER BY updated_at DESC LIMIT 1",
|
|
(session_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_session_memory_snapshot(row, cursor.description)
|
|
|
|
async def save_agent_compaction(self, record: AgentCompactionRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO agent_compactions
|
|
(compaction_id, project_id, session_id, employee_id, role_id, compaction_message_id,
|
|
source_boundary_message_id, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.compaction_id,
|
|
record.project_id,
|
|
record.session_id,
|
|
record.employee_id,
|
|
record.role_id,
|
|
record.compaction_message_id,
|
|
record.source_boundary_message_id,
|
|
_json_dumps(record.metadata),
|
|
record.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_agent_compaction(self, row: Any, description: Any) -> AgentCompactionRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return AgentCompactionRecord(
|
|
compaction_id=data["compaction_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
employee_id=data["employee_id"],
|
|
role_id=data.get("role_id") or "",
|
|
compaction_message_id=data["compaction_message_id"],
|
|
source_boundary_message_id=data["source_boundary_message_id"],
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
async def get_latest_agent_compaction(
|
|
self,
|
|
*,
|
|
project_id: str,
|
|
session_id: str,
|
|
employee_id: str,
|
|
) -> AgentCompactionRecord | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"""SELECT * FROM agent_compactions
|
|
WHERE project_id = ? AND session_id = ? AND employee_id = ?
|
|
ORDER BY created_at DESC LIMIT 1""",
|
|
(project_id, session_id, employee_id),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_agent_compaction(row, cursor.description)
|
|
|
|
async def save_agent_memory_snapshot(self, record: AgentMemorySnapshotRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO agent_memory_snapshots
|
|
(snapshot_id, project_id, session_id, employee_id, role_id, memory_scope, memory_kind,
|
|
summary_message_id, source_boundary_message_id, summary_text, memory_text,
|
|
metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
record.snapshot_id,
|
|
record.project_id,
|
|
record.session_id,
|
|
record.employee_id,
|
|
record.role_id,
|
|
record.memory_scope,
|
|
record.memory_kind,
|
|
record.summary_message_id,
|
|
record.source_boundary_message_id,
|
|
record.summary_text,
|
|
record.memory_text,
|
|
_json_dumps(record.metadata),
|
|
record.created_at.isoformat(),
|
|
record.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
def _row_to_agent_memory_snapshot(self, row: Any, description: Any) -> AgentMemorySnapshotRecord:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return AgentMemorySnapshotRecord(
|
|
snapshot_id=data["snapshot_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
employee_id=data["employee_id"],
|
|
role_id=data.get("role_id") or "",
|
|
memory_scope=data.get("memory_scope") or "session",
|
|
memory_kind=data.get("memory_kind") or "process",
|
|
summary_message_id=data["summary_message_id"],
|
|
source_boundary_message_id=data["source_boundary_message_id"],
|
|
summary_text=data.get("summary_text") or "",
|
|
memory_text=data.get("memory_text") or "",
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def get_agent_memory_snapshot(
|
|
self,
|
|
*,
|
|
project_id: str,
|
|
session_id: str | None = None,
|
|
employee_id: str,
|
|
memory_kind: str | None = None,
|
|
memory_scope: str | None = None,
|
|
) -> AgentMemorySnapshotRecord | None:
|
|
assert self._db
|
|
query = (
|
|
"SELECT * FROM agent_memory_snapshots "
|
|
"WHERE project_id = ? AND employee_id = ?"
|
|
)
|
|
params: list[Any] = [project_id, employee_id]
|
|
if session_id is not None:
|
|
query += " AND session_id = ?"
|
|
params.append(session_id)
|
|
if memory_scope:
|
|
query += " AND memory_scope = ?"
|
|
params.append(memory_scope)
|
|
if memory_kind:
|
|
query += " AND memory_kind = ?"
|
|
params.append(memory_kind)
|
|
query += " ORDER BY updated_at DESC LIMIT 1"
|
|
async with self._db.execute(query, params) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_agent_memory_snapshot(row, cursor.description)
|
|
|
|
async def list_agent_memory_snapshots(
|
|
self,
|
|
*,
|
|
project_id: str,
|
|
session_id: str | None = None,
|
|
employee_id: str,
|
|
memory_kind: str | None = None,
|
|
memory_scope: str | None = None,
|
|
) -> list[AgentMemorySnapshotRecord]:
|
|
assert self._db
|
|
query = (
|
|
"SELECT * FROM agent_memory_snapshots "
|
|
"WHERE project_id = ? AND employee_id = ?"
|
|
)
|
|
params: list[Any] = [project_id, employee_id]
|
|
if session_id is not None:
|
|
query += " AND session_id = ?"
|
|
params.append(session_id)
|
|
if memory_scope:
|
|
query += " AND memory_scope = ?"
|
|
params.append(memory_scope)
|
|
if memory_kind:
|
|
query += " AND memory_kind = ?"
|
|
params.append(memory_kind)
|
|
query += " ORDER BY updated_at DESC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_agent_memory_snapshot(row, cursor.description) for row in rows]
|
|
|
|
async def delete_agent_memory_snapshots(
|
|
self,
|
|
*,
|
|
project_id: str,
|
|
session_id: str | None = None,
|
|
employee_id: str,
|
|
memory_kind: str | None = None,
|
|
memory_scope: str | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
query = (
|
|
"DELETE FROM agent_memory_snapshots "
|
|
"WHERE project_id = ? AND employee_id = ?"
|
|
)
|
|
params: list[Any] = [project_id, employee_id]
|
|
if session_id is not None:
|
|
query += " AND session_id = ?"
|
|
params.append(session_id)
|
|
if memory_scope:
|
|
query += " AND memory_scope = ?"
|
|
params.append(memory_scope)
|
|
if memory_kind:
|
|
query += " AND memory_kind = ?"
|
|
params.append(memory_kind)
|
|
await self._db.execute(query, params)
|
|
await self._db.commit()
|
|
|
|
async def save_session_link(self, link: SessionLinkRecord) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO session_links
|
|
(link_id, project_id, session_id, linked_session_id, task_id, link_type, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
link.link_id,
|
|
link.project_id,
|
|
link.session_id,
|
|
link.linked_session_id,
|
|
link.task_id,
|
|
link.link_type,
|
|
_json_dumps(link.metadata),
|
|
link.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_session_links(
|
|
self,
|
|
session_id: str,
|
|
link_type: str | None = None,
|
|
limit: int = 50,
|
|
) -> list[SessionLinkRecord]:
|
|
assert self._db
|
|
query = "SELECT * FROM session_links WHERE session_id = ?"
|
|
params: list[Any] = [session_id]
|
|
if link_type:
|
|
query += " AND link_type = ?"
|
|
params.append(link_type)
|
|
query += " ORDER BY created_at ASC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
SessionLinkRecord(
|
|
link_id=data["link_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
linked_session_id=data["linked_session_id"],
|
|
task_id=data["task_id"],
|
|
link_type=data["link_type"],
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
# --- Events ---
|
|
|
|
async def save_event(self, event: OPCEvent) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"INSERT INTO events (event_id, event_type, payload, timestamp) VALUES (?, ?, ?, ?)",
|
|
(event.event_id, event.event_type, _json_dumps(event.payload), event.timestamp.isoformat()),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_events(self, event_type: str | None = None, limit: int = 50) -> list[dict]:
|
|
assert self._db
|
|
query = "SELECT * FROM events"
|
|
params: list[Any] = []
|
|
if event_type:
|
|
query += " WHERE event_type = ?"
|
|
params.append(event_type)
|
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [dict(zip(cols, row)) for row in rows]
|
|
|
|
# --- Costs ---
|
|
|
|
async def record_cost(
|
|
self,
|
|
task_id: str | None,
|
|
agent_id: str | None,
|
|
model: str,
|
|
tokens_in: int,
|
|
tokens_out: int,
|
|
cost: float,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT INTO cost_records (task_id, agent_id, model, tokens_in, tokens_out, cost, timestamp)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
(task_id, agent_id, model, tokens_in, tokens_out, cost, datetime.now().isoformat()),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_total_cost(self, project_id: str | None = None) -> dict:
|
|
assert self._db
|
|
if project_id:
|
|
query = """SELECT SUM(tokens_in), SUM(tokens_out), SUM(cost), COUNT(*)
|
|
FROM cost_records cr JOIN tasks t ON cr.task_id = t.id
|
|
WHERE t.project_id = ?"""
|
|
params: tuple[Any, ...] = (project_id,)
|
|
else:
|
|
query = "SELECT SUM(tokens_in), SUM(tokens_out), SUM(cost), COUNT(*) FROM cost_records"
|
|
params = ()
|
|
async with self._db.execute(query, params) as cursor:
|
|
row = await cursor.fetchone()
|
|
return {
|
|
"total_tokens_in": row[0] or 0,
|
|
"total_tokens_out": row[1] or 0,
|
|
"total_cost": row[2] or 0.0,
|
|
"total_calls": row[3] or 0,
|
|
}
|
|
|
|
# --- Approvals and autonomy ---
|
|
|
|
async def record_approval(
|
|
self,
|
|
decision: ApprovalDecision,
|
|
task_id: str | None,
|
|
project_id: str,
|
|
action_kind: str,
|
|
action_name: str,
|
|
target_agent: str = "",
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT INTO approval_records
|
|
(task_id, project_id, action_kind, action_name, target_agent, decision_action,
|
|
risk_level, confidence, rationale, policy_source, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
task_id,
|
|
project_id or "default",
|
|
action_kind,
|
|
action_name,
|
|
target_agent,
|
|
decision.action.value,
|
|
decision.risk_level.value,
|
|
decision.confidence,
|
|
decision.rationale,
|
|
decision.policy_source,
|
|
_json_dumps(decision.metadata),
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_recent_approvals(
|
|
self,
|
|
project_id: str | None = None,
|
|
limit: int = 20,
|
|
) -> list[dict[str, Any]]:
|
|
assert self._db
|
|
query = "SELECT * FROM approval_records"
|
|
params: list[Any] = []
|
|
if project_id:
|
|
query += " WHERE project_id = ?"
|
|
params.append(project_id)
|
|
query += " ORDER BY created_at DESC LIMIT ?"
|
|
params.append(limit)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [dict(zip(cols, row)) for row in rows]
|
|
|
|
async def get_autonomy_stats(self, project_id: str | None = None) -> dict[str, Any]:
|
|
assert self._db
|
|
query = "SELECT decision_action, COUNT(*) FROM approval_records"
|
|
params: list[Any] = []
|
|
if project_id:
|
|
query += " WHERE project_id = ?"
|
|
params.append(project_id)
|
|
query += " GROUP BY decision_action"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
counts = {row[0]: row[1] for row in rows}
|
|
total = sum(counts.values())
|
|
auto = counts.get("auto_approve", 0)
|
|
escalate = counts.get("escalate", 0)
|
|
reject = counts.get("reject", 0)
|
|
return {
|
|
"total": total,
|
|
"auto_approved": auto,
|
|
"escalated": escalate,
|
|
"rejected": reject,
|
|
"auto_approval_rate": (auto / total) if total else 0.0,
|
|
}
|
|
|
|
# --- External sessions ---
|
|
|
|
async def save_external_session(self, session: ExternalSession) -> None:
|
|
assert self._db
|
|
self._assert_project_write_scope(
|
|
getattr(session, "project_id", None),
|
|
operation="save_external_session",
|
|
entity=f"external session task={getattr(session, 'task_id', '')!r}",
|
|
)
|
|
session_key = self._external_session_key(session)
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO external_sessions
|
|
(session_key, agent_type, project_id, session_id, opc_session_id, task_id, workspace_path, run_mode, status, metadata, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
session_key,
|
|
session.agent_type,
|
|
session.project_id,
|
|
session.session_id,
|
|
session.opc_session_id,
|
|
session.task_id,
|
|
session.workspace_path,
|
|
session.run_mode,
|
|
session.status,
|
|
_json_dumps(session.metadata),
|
|
session.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await self._close_replaced_external_session_rows(session, session_key=session_key)
|
|
await self._db.commit()
|
|
|
|
async def _close_replaced_external_session_rows(
|
|
self,
|
|
session: ExternalSession,
|
|
*,
|
|
session_key: str,
|
|
) -> None:
|
|
"""Close stale placeholder rows once the real provider session finishes."""
|
|
assert self._db
|
|
status = str(session.status or "").strip().lower()
|
|
if status not in {
|
|
"done",
|
|
"completed",
|
|
"complete",
|
|
"finished",
|
|
"failed",
|
|
"cancelled",
|
|
"canceled",
|
|
"suspended",
|
|
"hard_timeout",
|
|
"idle_timeout",
|
|
"startup_timeout",
|
|
"denied",
|
|
"rejected",
|
|
}:
|
|
return
|
|
task_id = str(session.task_id or "").strip()
|
|
if not task_id:
|
|
return
|
|
await self._db.execute(
|
|
"""
|
|
UPDATE external_sessions
|
|
SET status = ?, updated_at = ?
|
|
WHERE project_id = ?
|
|
AND agent_type = ?
|
|
AND task_id = ?
|
|
AND COALESCE(opc_session_id, '') = ?
|
|
AND session_key != ?
|
|
AND status IN ('starting', 'running', 'working')
|
|
""",
|
|
(
|
|
session.status,
|
|
session.updated_at.isoformat(),
|
|
session.project_id,
|
|
session.agent_type,
|
|
task_id,
|
|
str(session.opc_session_id or "").strip(),
|
|
session_key,
|
|
),
|
|
)
|
|
|
|
async def get_external_session(
|
|
self,
|
|
agent_type: str,
|
|
project_id: str = "default",
|
|
*,
|
|
opc_session_id: str | None = None,
|
|
task_id: str | None = None,
|
|
) -> ExternalSession | None:
|
|
assert self._db
|
|
query = "SELECT * FROM external_sessions WHERE agent_type = ? AND project_id = ?"
|
|
params: list[Any] = [agent_type, project_id]
|
|
if opc_session_id:
|
|
query += " AND opc_session_id = ?"
|
|
params.append(opc_session_id)
|
|
if task_id:
|
|
query += " AND task_id = ?"
|
|
params.append(task_id)
|
|
query += " ORDER BY updated_at DESC LIMIT 1"
|
|
async with self._db.execute(query, params) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
cols = [d[0] for d in cursor.description]
|
|
data = dict(zip(cols, row))
|
|
return ExternalSession(
|
|
agent_type=data["agent_type"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
opc_session_id=data.get("opc_session_id"),
|
|
task_id=data["task_id"],
|
|
workspace_path=data["workspace_path"],
|
|
run_mode=data["run_mode"],
|
|
status=data["status"],
|
|
metadata=_json_loads(data["metadata"], {}),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def get_latest_external_session_for_task(
|
|
self,
|
|
project_id: str,
|
|
task_id: str,
|
|
) -> ExternalSession | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"""
|
|
SELECT * FROM external_sessions
|
|
WHERE project_id = ? AND task_id = ?
|
|
ORDER BY updated_at DESC
|
|
LIMIT 1
|
|
""",
|
|
(project_id, task_id),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
cols = [d[0] for d in cursor.description]
|
|
data = dict(zip(cols, row))
|
|
return ExternalSession(
|
|
agent_type=data["agent_type"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
opc_session_id=data.get("opc_session_id"),
|
|
task_id=data["task_id"],
|
|
workspace_path=data["workspace_path"],
|
|
run_mode=data["run_mode"],
|
|
status=data["status"],
|
|
metadata=_json_loads(data["metadata"], {}),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def list_external_sessions(
|
|
self,
|
|
*,
|
|
project_id: str | None = None,
|
|
status: str | None = None,
|
|
task_id: str | None = None,
|
|
opc_session_id: str | None = None,
|
|
limit: int = 50,
|
|
) -> list[ExternalSession]:
|
|
assert self._db
|
|
query = "SELECT * FROM external_sessions WHERE 1=1"
|
|
params: list[Any] = []
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
if task_id:
|
|
query += " AND task_id = ?"
|
|
params.append(task_id)
|
|
if opc_session_id:
|
|
query += " AND opc_session_id = ?"
|
|
params.append(opc_session_id)
|
|
query += " ORDER BY updated_at DESC LIMIT ?"
|
|
params.append(max(1, int(limit or 50)))
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
ExternalSession(
|
|
agent_type=data["agent_type"],
|
|
project_id=data["project_id"],
|
|
session_id=data["session_id"],
|
|
opc_session_id=data.get("opc_session_id"),
|
|
task_id=data["task_id"],
|
|
workspace_path=data["workspace_path"],
|
|
run_mode=data["run_mode"],
|
|
status=data["status"],
|
|
metadata=_json_loads(data["metadata"], {}),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
def _external_session_key(self, session: ExternalSession) -> str:
|
|
return "|".join(
|
|
[
|
|
session.agent_type,
|
|
session.project_id or "default",
|
|
session.opc_session_id or "",
|
|
session.task_id or "",
|
|
session.session_id,
|
|
]
|
|
)
|
|
|
|
# --- Execution checkpoints ---
|
|
|
|
async def save_execution_checkpoint(self, checkpoint: ExecutionCheckpoint) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO execution_checkpoints
|
|
(checkpoint_id, project_id, session_id, checkpoint_type, status, task_id, payload, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
checkpoint.checkpoint_id,
|
|
checkpoint.project_id,
|
|
checkpoint.session_id,
|
|
checkpoint.checkpoint_type,
|
|
checkpoint.status,
|
|
checkpoint.task_id,
|
|
_json_dumps(checkpoint.payload),
|
|
checkpoint.created_at.isoformat(),
|
|
checkpoint.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_execution_checkpoints(
|
|
self,
|
|
project_id: str = "default",
|
|
session_id: str | None = None,
|
|
checkpoint_types: list[str] | None = None,
|
|
statuses: list[str] | None = None,
|
|
) -> list[ExecutionCheckpoint]:
|
|
assert self._db
|
|
query = "SELECT * FROM execution_checkpoints WHERE project_id = ?"
|
|
params: list[Any] = [project_id]
|
|
clean_statuses = [str(status).strip() for status in list(statuses or []) if str(status).strip()]
|
|
if clean_statuses:
|
|
placeholders = ", ".join("?" for _ in clean_statuses)
|
|
query += f" AND status IN ({placeholders})"
|
|
params.extend(clean_statuses)
|
|
if session_id:
|
|
query += " AND session_id = ?"
|
|
params.append(session_id)
|
|
if checkpoint_types:
|
|
placeholders = ", ".join("?" for _ in checkpoint_types)
|
|
query += f" AND checkpoint_type IN ({placeholders})"
|
|
params.extend(checkpoint_types)
|
|
query += " ORDER BY updated_at DESC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
ExecutionCheckpoint(
|
|
checkpoint_id=data["checkpoint_id"],
|
|
project_id=data["project_id"],
|
|
session_id=data.get("session_id"),
|
|
checkpoint_type=data["checkpoint_type"],
|
|
status=data["status"],
|
|
task_id=data["task_id"],
|
|
payload=_json_loads(data["payload"], {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
for data in (dict(zip(cols, row)) for row in rows)
|
|
]
|
|
|
|
async def get_pending_checkpoints(
|
|
self,
|
|
project_id: str = "default",
|
|
session_id: str | None = None,
|
|
checkpoint_types: list[str] | None = None,
|
|
) -> list[ExecutionCheckpoint]:
|
|
return await self.get_execution_checkpoints(
|
|
project_id=project_id,
|
|
session_id=session_id,
|
|
checkpoint_types=checkpoint_types,
|
|
statuses=["pending"],
|
|
)
|
|
|
|
async def get_latest_pending_checkpoint(
|
|
self,
|
|
project_id: str = "default",
|
|
session_id: str | None = None,
|
|
) -> ExecutionCheckpoint | None:
|
|
checkpoints = await self.get_pending_checkpoints(project_id=project_id, session_id=session_id)
|
|
return checkpoints[0] if checkpoints else None
|
|
|
|
async def resolve_execution_checkpoint(self, checkpoint_id: str, status: str = "resolved") -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"UPDATE execution_checkpoints SET status = ?, updated_at = ? WHERE checkpoint_id = ?",
|
|
(status, datetime.now().isoformat(), checkpoint_id),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def supersede_pending_checkpoints(
|
|
self,
|
|
*,
|
|
project_id: str = "default",
|
|
task_id: str | None = None,
|
|
session_id: str | None = None,
|
|
checkpoint_types: list[str] | None = None,
|
|
basis_hash: str | None = None,
|
|
exclude_checkpoint_id: str | None = None,
|
|
) -> list[str]:
|
|
assert self._db
|
|
query = "SELECT * FROM execution_checkpoints WHERE project_id = ? AND status = 'pending'"
|
|
params: list[Any] = [project_id]
|
|
if task_id:
|
|
query += " AND task_id = ?"
|
|
params.append(task_id)
|
|
if session_id:
|
|
query += " AND session_id = ?"
|
|
params.append(session_id)
|
|
if checkpoint_types:
|
|
placeholders = ", ".join("?" for _ in checkpoint_types)
|
|
query += f" AND checkpoint_type IN ({placeholders})"
|
|
params.extend(checkpoint_types)
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
|
|
superseded_ids: list[str] = []
|
|
now = datetime.now().isoformat()
|
|
for data in (dict(zip(cols, row)) for row in rows):
|
|
checkpoint_id = str(data.get("checkpoint_id", "") or "").strip()
|
|
if not checkpoint_id or checkpoint_id == str(exclude_checkpoint_id or "").strip():
|
|
continue
|
|
payload = _json_loads(data.get("payload"), {})
|
|
existing_basis_hash = str(payload.get("basis_hash", "") or "").strip()
|
|
if basis_hash and existing_basis_hash and existing_basis_hash == basis_hash:
|
|
continue
|
|
payload["superseded_at"] = now
|
|
if exclude_checkpoint_id:
|
|
payload["superseded_by_checkpoint_id"] = str(exclude_checkpoint_id)
|
|
await self._db.execute(
|
|
"UPDATE execution_checkpoints SET status = ?, payload = ?, updated_at = ? WHERE checkpoint_id = ?",
|
|
("superseded", _json_dumps(payload), now, checkpoint_id),
|
|
)
|
|
superseded_ids.append(checkpoint_id)
|
|
if superseded_ids:
|
|
await self._db.commit()
|
|
return superseded_ids
|
|
|
|
# --- Runtime V2 persistence ---
|
|
|
|
async def save_runtime_session(
|
|
self,
|
|
*,
|
|
runtime_session_id: str,
|
|
project_id: str = "default",
|
|
session_id: str | None = None,
|
|
task_id: str | None = None,
|
|
status: str = "running",
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
self._assert_project_write_scope(
|
|
project_id,
|
|
operation="save_runtime_session",
|
|
entity=f"runtime session {runtime_session_id!r}",
|
|
)
|
|
now = datetime.now().isoformat()
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_sessions
|
|
(runtime_session_id, project_id, session_id, task_id, status, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM runtime_sessions WHERE runtime_session_id = ?), ?), ?)""",
|
|
(
|
|
runtime_session_id,
|
|
project_id,
|
|
session_id,
|
|
task_id,
|
|
status,
|
|
_json_dumps(metadata or {}),
|
|
runtime_session_id,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_runtime_session(self, runtime_session_id: str) -> dict[str, Any] | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM runtime_sessions WHERE runtime_session_id = ?",
|
|
(runtime_session_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if row is None:
|
|
return None
|
|
cols = [d[0] for d in cursor.description]
|
|
data = dict(zip(cols, row))
|
|
data["metadata"] = _json_loads(data.get("metadata"), {})
|
|
return data
|
|
|
|
async def list_runtime_sessions(
|
|
self,
|
|
*,
|
|
project_id: str | None = None,
|
|
status: str | None = None,
|
|
task_id: str | None = None,
|
|
session_id: str | None = None,
|
|
limit: int = 50,
|
|
) -> list[dict[str, Any]]:
|
|
assert self._db
|
|
query = "SELECT * FROM runtime_sessions WHERE 1=1"
|
|
params: list[Any] = []
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
if task_id:
|
|
query += " AND task_id = ?"
|
|
params.append(task_id)
|
|
if session_id:
|
|
query += " AND session_id = ?"
|
|
params.append(session_id)
|
|
query += " ORDER BY updated_at DESC LIMIT ?"
|
|
params.append(max(1, int(limit or 50)))
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
sessions: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
data = dict(zip(cols, row))
|
|
data["metadata"] = _json_loads(data.get("metadata"), {})
|
|
sessions.append(data)
|
|
return sessions
|
|
|
|
async def save_runtime_event(
|
|
self,
|
|
runtime_session_id: str,
|
|
event_type: str,
|
|
payload: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT INTO runtime_events
|
|
(event_id, runtime_session_id, event_type, payload, created_at)
|
|
VALUES (?, ?, ?, ?, ?)""",
|
|
(
|
|
str(uuid.uuid4()),
|
|
runtime_session_id,
|
|
event_type,
|
|
_json_dumps(payload or {}),
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def list_runtime_events(
|
|
self,
|
|
runtime_session_id: str,
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"""
|
|
SELECT * FROM (
|
|
SELECT * FROM runtime_events
|
|
WHERE runtime_session_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
)
|
|
ORDER BY created_at ASC
|
|
""",
|
|
(runtime_session_id, max(1, int(limit or 100))),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
results: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
data = dict(zip(cols, row))
|
|
data["payload"] = _json_loads(data.get("payload"), {})
|
|
results.append(data)
|
|
return results
|
|
|
|
async def save_runtime_transcript_entry(
|
|
self,
|
|
*,
|
|
runtime_session_id: str,
|
|
task_id: str | None = None,
|
|
session_id: str | None = None,
|
|
message_id: str = "",
|
|
role: str = "assistant",
|
|
entry_type: str = "message",
|
|
content: str = "",
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_transcript_entries
|
|
(entry_id, runtime_session_id, task_id, session_id, message_id, role, entry_type, content, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
str(uuid.uuid4()),
|
|
runtime_session_id,
|
|
task_id,
|
|
session_id,
|
|
message_id,
|
|
role,
|
|
entry_type,
|
|
content,
|
|
_json_dumps(metadata or {}),
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def save_runtime_tool_call(
|
|
self,
|
|
*,
|
|
runtime_session_id: str,
|
|
tool_call_id: str,
|
|
tool_name: str,
|
|
arguments: dict[str, Any] | None = None,
|
|
task_id: str | None = None,
|
|
session_id: str | None = None,
|
|
message_id: str = "",
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_tool_calls
|
|
(call_record_id, runtime_session_id, task_id, session_id, message_id, tool_call_id, tool_name, arguments, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
f"{runtime_session_id}|{tool_call_id or uuid.uuid4().hex}",
|
|
runtime_session_id,
|
|
task_id,
|
|
session_id,
|
|
message_id,
|
|
tool_call_id,
|
|
tool_name,
|
|
_json_dumps(arguments or {}),
|
|
_json_dumps(metadata or {}),
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def save_runtime_tool_result(
|
|
self,
|
|
*,
|
|
runtime_session_id: str,
|
|
tool_name: str,
|
|
payload: dict[str, Any] | None = None,
|
|
tool_call_id: str = "",
|
|
task_id: str | None = None,
|
|
session_id: str | None = None,
|
|
message_id: str = "",
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_tool_results
|
|
(result_record_id, runtime_session_id, task_id, session_id, message_id, tool_call_id, tool_name, payload, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
f"{runtime_session_id}|{tool_call_id or tool_name}|{uuid.uuid4().hex}",
|
|
runtime_session_id,
|
|
task_id,
|
|
session_id,
|
|
message_id,
|
|
tool_call_id,
|
|
tool_name,
|
|
_json_dumps(payload or {}),
|
|
_json_dumps(metadata or {}),
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def list_runtime_transcript_entries(
|
|
self,
|
|
runtime_session_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM runtime_transcript_entries WHERE runtime_session_id = ? ORDER BY created_at ASC",
|
|
(runtime_session_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
return [
|
|
{**dict(zip(cols, row)), "metadata": _json_loads(dict(zip(cols, row)).get("metadata"), {})}
|
|
for row in rows
|
|
]
|
|
|
|
async def list_runtime_tool_calls(
|
|
self,
|
|
runtime_session_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM runtime_tool_calls WHERE runtime_session_id = ? ORDER BY created_at ASC",
|
|
(runtime_session_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
results: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
data = dict(zip(cols, row))
|
|
data["arguments"] = _json_loads(data.get("arguments"), {})
|
|
data["metadata"] = _json_loads(data.get("metadata"), {})
|
|
results.append(data)
|
|
return results
|
|
|
|
async def list_runtime_tool_results(
|
|
self,
|
|
runtime_session_id: str,
|
|
) -> list[dict[str, Any]]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM runtime_tool_results WHERE runtime_session_id = ? ORDER BY created_at ASC",
|
|
(runtime_session_id,),
|
|
) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
results: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
data = dict(zip(cols, row))
|
|
data["payload"] = _json_loads(data.get("payload"), {})
|
|
data["metadata"] = _json_loads(data.get("metadata"), {})
|
|
results.append(data)
|
|
return results
|
|
|
|
async def save_runtime_permission_grant(
|
|
self,
|
|
*,
|
|
runtime_session_id: str,
|
|
project_id: str = "default",
|
|
scope: str,
|
|
tool_name: str,
|
|
candidate: str,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_permission_grants
|
|
(grant_id, runtime_session_id, project_id, scope, tool_name, candidate, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
f"{runtime_session_id}|{scope}|{tool_name}|{candidate}",
|
|
runtime_session_id,
|
|
project_id,
|
|
scope,
|
|
tool_name,
|
|
candidate,
|
|
_json_dumps(metadata or {}),
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def list_runtime_permission_grants(
|
|
self,
|
|
*,
|
|
runtime_session_id: str | None = None,
|
|
project_id: str | None = None,
|
|
scopes: list[str] | None = None,
|
|
tool_name: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
assert self._db
|
|
query = "SELECT * FROM runtime_permission_grants WHERE 1=1"
|
|
params: list[Any] = []
|
|
if runtime_session_id:
|
|
query += " AND runtime_session_id = ?"
|
|
params.append(runtime_session_id)
|
|
if project_id:
|
|
query += " AND project_id = ?"
|
|
params.append(project_id)
|
|
if scopes:
|
|
placeholders = ",".join("?" for _ in scopes)
|
|
query += f" AND scope IN ({placeholders})"
|
|
params.extend(scopes)
|
|
if tool_name:
|
|
query += " AND tool_name = ?"
|
|
params.append(tool_name)
|
|
query += " ORDER BY created_at ASC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
results: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
data = dict(zip(cols, row))
|
|
data["metadata"] = _json_loads(data.get("metadata"), {})
|
|
results.append(data)
|
|
return results
|
|
|
|
async def save_runtime_subagent_run(
|
|
self,
|
|
*,
|
|
subagent_run_id: str,
|
|
runtime_session_id: str,
|
|
agent_id: str,
|
|
profile: str,
|
|
status: str,
|
|
task_id: str | None = None,
|
|
worktree_path: str = "",
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
now = datetime.now().isoformat()
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_subagent_runs
|
|
(subagent_run_id, runtime_session_id, task_id, agent_id, profile, status, worktree_path, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM runtime_subagent_runs WHERE subagent_run_id = ?), ?), ?)""",
|
|
(
|
|
subagent_run_id,
|
|
runtime_session_id,
|
|
task_id,
|
|
agent_id,
|
|
profile,
|
|
status,
|
|
worktree_path,
|
|
_json_dumps(metadata or {}),
|
|
subagent_run_id,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def save_runtime_worktree_session(
|
|
self,
|
|
*,
|
|
worktree_session_id: str,
|
|
runtime_session_id: str,
|
|
path: str,
|
|
status: str,
|
|
task_id: str | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
now = datetime.now().isoformat()
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_worktree_sessions
|
|
(worktree_session_id, runtime_session_id, task_id, path, status, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM runtime_worktree_sessions WHERE worktree_session_id = ?), ?), ?)""",
|
|
(
|
|
worktree_session_id,
|
|
runtime_session_id,
|
|
task_id,
|
|
path,
|
|
status,
|
|
_json_dumps(metadata or {}),
|
|
worktree_session_id,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def save_runtime_compaction_boundary(
|
|
self,
|
|
*,
|
|
boundary_id: str,
|
|
runtime_session_id: str,
|
|
summary: str,
|
|
task_id: str | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO runtime_compaction_boundaries
|
|
(boundary_id, runtime_session_id, task_id, summary, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
boundary_id,
|
|
runtime_session_id,
|
|
task_id,
|
|
summary,
|
|
_json_dumps(metadata or {}),
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
# --- Organizations ---
|
|
|
|
async def save_organization(self, org: Organization) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO organizations
|
|
(org_id, name, description, status, company_profile,
|
|
budget_monthly_cents, spent_monthly_cents, metadata, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
org.org_id,
|
|
org.name,
|
|
org.description,
|
|
org.status,
|
|
org.company_profile,
|
|
org.budget_monthly_cents,
|
|
org.spent_monthly_cents,
|
|
_json_dumps(org.metadata),
|
|
org.created_at.isoformat(),
|
|
org.updated_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_organization(self, org_id: str) -> Organization | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM organizations WHERE org_id = ?", (org_id,)
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
cols = [d[0] for d in cursor.description]
|
|
data = dict(zip(cols, row))
|
|
return Organization(
|
|
org_id=data["org_id"],
|
|
name=data["name"],
|
|
description=data.get("description") or "",
|
|
status=data.get("status") or "active",
|
|
company_profile=data.get("company_profile") or "corporate",
|
|
budget_monthly_cents=int(data.get("budget_monthly_cents") or 0),
|
|
spent_monthly_cents=int(data.get("spent_monthly_cents") or 0),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
)
|
|
|
|
async def list_organizations(self, status: str | None = None) -> list[Organization]:
|
|
assert self._db
|
|
query = "SELECT * FROM organizations"
|
|
params: list[Any] = []
|
|
if status:
|
|
query += " WHERE status = ?"
|
|
params.append(status)
|
|
query += " ORDER BY updated_at DESC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
cols = [d[0] for d in cursor.description]
|
|
results: list[Organization] = []
|
|
for row in rows:
|
|
data = dict(zip(cols, row))
|
|
results.append(Organization(
|
|
org_id=data["org_id"],
|
|
name=data["name"],
|
|
description=data.get("description") or "",
|
|
status=data.get("status") or "active",
|
|
company_profile=data.get("company_profile") or "corporate",
|
|
budget_monthly_cents=int(data.get("budget_monthly_cents") or 0),
|
|
spent_monthly_cents=int(data.get("spent_monthly_cents") or 0),
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
updated_at=datetime.fromisoformat(data["updated_at"]),
|
|
))
|
|
return results
|
|
|
|
async def update_organization(self, org_id: str, **kwargs: Any) -> None:
|
|
assert self._db
|
|
allowed = {"name", "description", "status", "company_profile",
|
|
"budget_monthly_cents", "spent_monthly_cents", "metadata"}
|
|
sets: list[str] = []
|
|
params: list[Any] = []
|
|
for key, value in kwargs.items():
|
|
if key not in allowed:
|
|
continue
|
|
if key == "metadata":
|
|
value = _json_dumps(value)
|
|
sets.append(f"{key} = ?")
|
|
params.append(value)
|
|
if not sets:
|
|
return
|
|
sets.append("updated_at = ?")
|
|
params.append(datetime.now().isoformat())
|
|
params.append(org_id)
|
|
await self._db.execute(
|
|
f"UPDATE organizations SET {', '.join(sets)} WHERE org_id = ?", params
|
|
)
|
|
await self._db.commit()
|
|
|
|
# --- Goals ---
|
|
|
|
async def save_goal(self, goal: Goal) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO goals
|
|
(goal_id, org_id, parent_id, owner_agent_id, level, title, description,
|
|
status, priority, deadline, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
goal.goal_id,
|
|
goal.org_id,
|
|
goal.parent_id,
|
|
goal.owner_agent_id,
|
|
goal.level.value if isinstance(goal.level, GoalLevel) else goal.level,
|
|
goal.title,
|
|
goal.description,
|
|
goal.status.value if isinstance(goal.status, GoalStatus) else goal.status,
|
|
goal.priority,
|
|
goal.deadline.isoformat() if goal.deadline else None,
|
|
_json_dumps(goal.metadata),
|
|
goal.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_goal(self, goal_id: str) -> Goal | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM goals WHERE goal_id = ?", (goal_id,)
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_goal(row, cursor.description)
|
|
|
|
async def list_goals(
|
|
self,
|
|
org_id: str,
|
|
status: str | None = None,
|
|
parent_id: str | None = "__unset__",
|
|
) -> list[Goal]:
|
|
assert self._db
|
|
query = "SELECT * FROM goals WHERE org_id = ?"
|
|
params: list[Any] = [org_id]
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
if parent_id != "__unset__":
|
|
if parent_id is None:
|
|
query += " AND parent_id IS NULL"
|
|
else:
|
|
query += " AND parent_id = ?"
|
|
params.append(parent_id)
|
|
query += " ORDER BY priority ASC, created_at ASC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_goal(row, cursor.description) for row in rows]
|
|
|
|
async def get_goal_tree(self, org_id: str) -> list[Goal]:
|
|
return await self.list_goals(org_id, parent_id="__unset__")
|
|
|
|
def _row_to_goal(self, row: Any, description: Any) -> Goal:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return Goal(
|
|
goal_id=data["goal_id"],
|
|
org_id=data["org_id"],
|
|
parent_id=data.get("parent_id"),
|
|
owner_agent_id=data.get("owner_agent_id"),
|
|
level=GoalLevel(data.get("level") or "task"),
|
|
title=data["title"],
|
|
description=data.get("description") or "",
|
|
status=GoalStatus(data.get("status") or "active"),
|
|
priority=int(data.get("priority") or 5),
|
|
deadline=datetime.fromisoformat(data["deadline"]) if data.get("deadline") else None,
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
# --- Org Agents ---
|
|
|
|
async def save_org_agent(self, agent: OrgAgent) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT OR REPLACE INTO org_agents
|
|
(agent_id, org_id, role_id, name, reports_to,
|
|
budget_monthly_cents, spent_monthly_cents,
|
|
heartbeat_enabled, heartbeat_interval_sec, last_heartbeat_at,
|
|
status, capabilities, metadata, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
agent.agent_id,
|
|
agent.org_id,
|
|
agent.role_id,
|
|
agent.name,
|
|
agent.reports_to,
|
|
agent.budget_monthly_cents,
|
|
agent.spent_monthly_cents,
|
|
int(agent.heartbeat_enabled),
|
|
agent.heartbeat_interval_sec,
|
|
agent.last_heartbeat_at.isoformat() if agent.last_heartbeat_at else None,
|
|
agent.status,
|
|
agent.capabilities,
|
|
_json_dumps(agent.metadata),
|
|
agent.created_at.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_org_agent(self, agent_id: str) -> OrgAgent | None:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT * FROM org_agents WHERE agent_id = ?", (agent_id,)
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return self._row_to_org_agent(row, cursor.description)
|
|
|
|
async def list_org_agents(
|
|
self,
|
|
org_id: str,
|
|
status: str | None = None,
|
|
) -> list[OrgAgent]:
|
|
assert self._db
|
|
query = "SELECT * FROM org_agents WHERE org_id = ?"
|
|
params: list[Any] = [org_id]
|
|
if status:
|
|
query += " AND status = ?"
|
|
params.append(status)
|
|
query += " ORDER BY created_at ASC"
|
|
async with self._db.execute(query, params) as cursor:
|
|
rows = await cursor.fetchall()
|
|
return [self._row_to_org_agent(row, cursor.description) for row in rows]
|
|
|
|
def _row_to_org_agent(self, row: Any, description: Any) -> OrgAgent:
|
|
cols = [d[0] for d in description]
|
|
data = dict(zip(cols, row))
|
|
return OrgAgent(
|
|
agent_id=data["agent_id"],
|
|
org_id=data["org_id"],
|
|
role_id=data["role_id"],
|
|
name=data.get("name") or "",
|
|
reports_to=data.get("reports_to"),
|
|
budget_monthly_cents=int(data.get("budget_monthly_cents") or 0),
|
|
spent_monthly_cents=int(data.get("spent_monthly_cents") or 0),
|
|
heartbeat_enabled=bool(data.get("heartbeat_enabled", 0)),
|
|
heartbeat_interval_sec=int(data.get("heartbeat_interval_sec") or 300),
|
|
last_heartbeat_at=(
|
|
datetime.fromisoformat(data["last_heartbeat_at"])
|
|
if data.get("last_heartbeat_at")
|
|
else None
|
|
),
|
|
status=data.get("status") or "idle",
|
|
capabilities=data.get("capabilities") or "",
|
|
metadata=_json_loads(data.get("metadata"), {}),
|
|
created_at=datetime.fromisoformat(data["created_at"]),
|
|
)
|
|
|
|
# --- Atomic task checkout / release ---
|
|
|
|
async def checkout_task(self, task_id: str, agent_id: str, run_id: str | None = None) -> bool:
|
|
"""Atomically claim a task for execution. Returns True on success."""
|
|
assert self._db
|
|
import uuid as _uuid
|
|
run_id = run_id or str(_uuid.uuid4())
|
|
now = datetime.now().isoformat()
|
|
async with self._db.execute(
|
|
"""UPDATE tasks
|
|
SET status = 'running',
|
|
assigned_to = ?,
|
|
checkout_run_id = ?,
|
|
execution_locked_at = ?
|
|
WHERE id = ?
|
|
AND status IN ('pending', 'todo')
|
|
AND (assigned_to IS NULL OR assigned_to = '' OR assigned_to = ?)""",
|
|
(agent_id, run_id, now, task_id, agent_id),
|
|
) as cursor:
|
|
await self._db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
async def release_task(self, task_id: str, agent_id: str) -> bool:
|
|
"""Release a checked-out task back to pending."""
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"""UPDATE tasks
|
|
SET status = 'pending',
|
|
checkout_run_id = NULL,
|
|
execution_locked_at = NULL
|
|
WHERE id = ?
|
|
AND assigned_to = ?
|
|
AND status = 'running'""",
|
|
(task_id, agent_id),
|
|
) as cursor:
|
|
await self._db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
# --- Cost events ---
|
|
|
|
async def record_cost_event(self, event: CostEvent) -> None:
|
|
assert self._db
|
|
await self._db.execute(
|
|
"""INSERT INTO cost_events
|
|
(event_id, org_id, agent_id, task_id, model, tokens_in, tokens_out, cost_usd, timestamp)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
(
|
|
event.event_id,
|
|
event.org_id,
|
|
event.agent_id,
|
|
event.task_id,
|
|
event.model,
|
|
event.tokens_in,
|
|
event.tokens_out,
|
|
event.cost_usd,
|
|
event.timestamp.isoformat(),
|
|
),
|
|
)
|
|
await self._db.commit()
|
|
|
|
async def get_agent_spend(self, agent_id: str) -> dict[str, Any]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT SUM(tokens_in), SUM(tokens_out), SUM(cost_usd), COUNT(*) FROM cost_events WHERE agent_id = ?",
|
|
(agent_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return {
|
|
"tokens_in": row[0] or 0,
|
|
"tokens_out": row[1] or 0,
|
|
"cost_usd": row[2] or 0.0,
|
|
"calls": row[3] or 0,
|
|
}
|
|
|
|
async def get_org_spend(self, org_id: str) -> dict[str, Any]:
|
|
assert self._db
|
|
async with self._db.execute(
|
|
"SELECT SUM(tokens_in), SUM(tokens_out), SUM(cost_usd), COUNT(*) FROM cost_events WHERE org_id = ?",
|
|
(org_id,),
|
|
) as cursor:
|
|
row = await cursor.fetchone()
|
|
return {
|
|
"tokens_in": row[0] or 0,
|
|
"tokens_out": row[1] or 0,
|
|
"cost_usd": row[2] or 0.0,
|
|
"calls": row[3] or 0,
|
|
}
|