Files
OmniRoute/src/lib/db/a2aTasks.ts
Diego Rodrigues de Sa e Souza 84b345d9c0 feat(dashboard): orchestration canvas fase 2 — History tab over persisted A2A runs (2.2) (#12479)
* feat(db): a2a task history module over migration-002 tables

* feat(a2a): persist task lifecycle to a2a_tasks with 30d retention purge

* feat(api): a2a task history listing + historical detail fallback

* feat(dashboard): orchestration History tab (Airflow-grid) over persisted runs (2.2)

* fix(dashboard): assert history preset window + loading and time axis in the grid

* chore(dashboard): history i18n + changelog

* fix(dashboard): explicit history purge cascade + keep live drawer off the History tab

* docs: document OMNIROUTE_A2A_HISTORY_RETENTION_DAYS

* refactor(dashboard): split HistoryTab helpers under the complexity ratchet

---------

Co-authored-by: Markus Hartung <diegosouzapw@users.noreply.github.com>
2026-09-02 15:07:58 -03:00

223 lines
6.6 KiB
TypeScript

/**
* db/a2aTasks.ts — A2A task history writer/reader over the tables created by migration
* `002_mcp_a2a_tables.sql` (`a2a_tasks`, `a2a_task_events`). No new migration: this module only
* adds the write/read surface those tables never had (Orchestration Canvas Fase 2, Task C1).
*
* Owner visibility mirrors `A2ATaskManager.isVisibleTo` (src/lib/a2a/taskManager.ts): with an
* `owner` given, a row is visible when `api_key_id IS NULL OR api_key_id = owner`; without one,
* every row is visible.
*/
import { getDbInstance } from "./core.ts";
export interface A2ATaskHistoryRow {
id: string;
state: string;
skill_id: string | null;
input_json: string | null;
output_json: string | null;
api_key_id: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
}
export interface A2ATaskHistoryEventRow {
event_type: string;
data_json: string | null;
created_at: string;
}
export interface A2AHistoryFilter {
/** ISO timestamp — created_at >= from */
from?: string;
/** ISO timestamp — created_at <= to */
to?: string;
skill?: string;
state?: string;
/** See owner semantics in the module doc comment above. */
owner?: string;
limit: number;
offset: number;
}
export interface UpsertA2ATaskInput {
id: string;
state: string;
skillId: string | null;
inputJson: string | null;
outputJson: string | null;
apiKeyId: string | null;
createdAt: string;
updatedAt: string;
completedAt: string | null;
}
const HISTORY_COLUMNS =
"id, state, skill_id, input_json, output_json, api_key_id, created_at, updated_at, completed_at";
/** Builds the shared WHERE clause (+ params) for filter/owner conditions used by list/count. */
function buildHistoryWhere(f: Pick<A2AHistoryFilter, "from" | "to" | "skill" | "state" | "owner">) {
const clauses: string[] = [];
const params: Record<string, unknown> = {};
if (f.from !== undefined) {
clauses.push("created_at >= @from");
params.from = f.from;
}
if (f.to !== undefined) {
clauses.push("created_at <= @to");
params.to = f.to;
}
if (f.skill !== undefined) {
clauses.push("skill_id = @skill");
params.skill = f.skill;
}
if (f.state !== undefined) {
clauses.push("state = @state");
params.state = f.state;
}
if (f.owner !== undefined) {
clauses.push("(api_key_id IS NULL OR api_key_id = @owner)");
params.owner = f.owner;
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
return { where, params };
}
/**
* Insert a new task history row, or update the mutable fields of an existing one keyed by `id`.
*/
export function upsertA2ATask(row: UpsertA2ATaskInput): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO a2a_tasks (
id, state, skill_id, input_json, output_json, api_key_id,
created_at, updated_at, completed_at
) VALUES (
@id, @state, @skillId, @inputJson, @outputJson, @apiKeyId,
@createdAt, @updatedAt, @completedAt
)
ON CONFLICT(id) DO UPDATE SET
state = excluded.state,
output_json = excluded.output_json,
updated_at = excluded.updated_at,
completed_at = excluded.completed_at
`
).run(row);
}
/** Append an event to a task's event log. */
export function appendA2ATaskEvent(taskId: string, eventType: string, dataJson?: string): void {
const db = getDbInstance();
db.prepare(
`
INSERT INTO a2a_task_events (task_id, event_type, data_json)
VALUES (@taskId, @eventType, @dataJson)
`
).run({ taskId, eventType, dataJson: dataJson ?? null });
}
/** List a task's events in insertion order (oldest first). */
export function listA2ATaskEvents(taskId: string): A2ATaskHistoryEventRow[] {
const db = getDbInstance();
return db
.prepare(
`
SELECT event_type, data_json, created_at
FROM a2a_task_events
WHERE task_id = @taskId
ORDER BY id ASC
`
)
.all({ taskId }) as A2ATaskHistoryEventRow[];
}
/** List finished/in-flight task history, newest first, filtered and paginated. */
export function listA2ATaskHistory(f: A2AHistoryFilter): {
rows: A2ATaskHistoryRow[];
total: number;
} {
const db = getDbInstance();
const { where, params } = buildHistoryWhere(f);
const total = db
.prepare(`SELECT COUNT(*) AS count FROM a2a_tasks ${where}`)
.get(params) as { count: number };
const rows = db
.prepare(
`
SELECT ${HISTORY_COLUMNS}
FROM a2a_tasks
${where}
ORDER BY created_at DESC
LIMIT @limit OFFSET @offset
`
)
.all({ ...params, limit: f.limit, offset: f.offset }) as A2ATaskHistoryRow[];
return { rows, total: total.count };
}
/**
* Fetch a single task history row by id, applying the same owner rule as `listA2ATaskHistory`.
* Returns `null` when the row does not exist or is not visible to `owner`.
*/
export function getA2ATaskHistoryById(id: string, owner?: string): A2ATaskHistoryRow | null {
const db = getDbInstance();
const { where, params } = buildHistoryWhere({ owner });
const clause = where ? `${where} AND id = @id` : "WHERE id = @id";
const row = db
.prepare(
`
SELECT ${HISTORY_COLUMNS}
FROM a2a_tasks
${clause}
`
)
.get({ ...params, id }) as A2ATaskHistoryRow | undefined;
return row ?? null;
}
/**
* Delete task history rows older than `retentionDays`, along with their events. Does NOT rely
* on the `ON DELETE CASCADE` foreign key declared on `a2a_task_events.task_id` (migration 002):
* `better-sqlite3` runs with foreign keys enabled, but the other drivers under
* `src/lib/db/adapters/` never issue `PRAGMA foreign_keys = ON`, and migrations 072/073/126
* already document that this project does not depend on cascade behavior. On a
* non-better-sqlite3 driver, a cascade-reliant purge would silently leave that task's
* `a2a_task_events` rows behind forever — this module's only unbounded-growth path. Both
* deletes run inside one transaction so a crash between them cannot leave orphaned events.
* Returns the number of `a2a_tasks` rows deleted.
*/
export function purgeA2AHistory(retentionDays: number): number {
const db = getDbInstance();
const purge = db.transaction((days: number) => {
db.prepare(
`
DELETE FROM a2a_task_events
WHERE task_id IN (
SELECT id FROM a2a_tasks WHERE created_at < datetime('now', '-' || @days || ' days')
)
`
).run({ days });
const result = db
.prepare(
`
DELETE FROM a2a_tasks
WHERE created_at < datetime('now', '-' || @days || ' days')
`
)
.run({ days });
return result.changes;
});
return purge(retentionDays);
}