3288 lines
108 KiB
TypeScript
3288 lines
108 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
|
|
import { SERVICE_SESSION_SCOPE, normalizeServiceId } from '../config.js';
|
|
import {
|
|
inferAgentTypeFromServiceShadow,
|
|
resolveRoleServiceShadow,
|
|
} from '../role-service-shadow.js';
|
|
import {
|
|
canonicalizeChannelOwnerLeaseMetadata,
|
|
canonicalizePairedTaskMetadata,
|
|
canonicalizeServiceHandoffMetadata,
|
|
inferExecutionLeaseServiceIdFromCanonicalMetadata,
|
|
} from './canonical-role-metadata.js';
|
|
import {
|
|
backfillPairedTurnAttemptsFromTurns,
|
|
buildPairedTurnAttemptId,
|
|
} from './paired-turn-attempts.js';
|
|
import { normalizeStoredAgentType } from './room-registration.js';
|
|
import {
|
|
backfillLegacyServiceSessions,
|
|
dropLegacyServiceSessionsTable,
|
|
migrateSessionsTableToCompositePk,
|
|
} from './sessions.js';
|
|
|
|
function getTableColumns(database: Database, tableName: string): string[] {
|
|
return (
|
|
database.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
|
|
name: string;
|
|
}>
|
|
).map((column) => column.name);
|
|
}
|
|
|
|
export function tableHasColumn(
|
|
database: Database,
|
|
tableName: string,
|
|
columnName: string,
|
|
): boolean {
|
|
return getTableColumns(database, tableName).includes(columnName);
|
|
}
|
|
|
|
function getTableSql(database: Database, tableName: string): string | null {
|
|
const row = database
|
|
.prepare(
|
|
`
|
|
SELECT sql
|
|
FROM sqlite_master
|
|
WHERE type = 'table'
|
|
AND name = ?
|
|
`,
|
|
)
|
|
.get(tableName) as { sql?: string | null } | undefined;
|
|
return row?.sql ?? null;
|
|
}
|
|
|
|
function tryExecMigration(database: Database, sql: string): void {
|
|
try {
|
|
database.exec(sql);
|
|
} catch {
|
|
/* column already exists */
|
|
}
|
|
}
|
|
|
|
function buildPairedTurnAttemptIdSql(
|
|
turnIdExpr: string,
|
|
attemptNoExpr: string,
|
|
): string {
|
|
return `${turnIdExpr} || ':attempt:' || CAST(${attemptNoExpr} AS TEXT)`;
|
|
}
|
|
|
|
function buildPairedTurnAttemptParentIdSql(
|
|
turnIdExpr: string,
|
|
attemptNoExpr: string,
|
|
): string {
|
|
return `CASE
|
|
WHEN ${attemptNoExpr} <= 1 THEN NULL
|
|
ELSE ${buildPairedTurnAttemptIdSql(turnIdExpr, `(${attemptNoExpr}) - 1`)}
|
|
END`;
|
|
}
|
|
|
|
function buildPairedTurnAttemptParentHandoffMatchSql(args: {
|
|
parentHandoffIdExpr: string;
|
|
turnIdExpr: string;
|
|
parentAttemptIdExpr: string;
|
|
attemptNoExpr: string;
|
|
}): string {
|
|
return `EXISTS (
|
|
SELECT 1
|
|
FROM service_handoffs
|
|
WHERE service_handoffs.id = ${args.parentHandoffIdExpr}
|
|
AND service_handoffs.turn_id = ${args.turnIdExpr}
|
|
AND service_handoffs.status = 'failed'
|
|
AND (
|
|
service_handoffs.turn_attempt_id = ${args.parentAttemptIdExpr}
|
|
OR (
|
|
service_handoffs.turn_attempt_id IS NULL
|
|
AND service_handoffs.turn_attempt_no = (${args.attemptNoExpr}) - 1
|
|
)
|
|
)
|
|
)`;
|
|
}
|
|
|
|
function buildPairedTurnAttemptContinuationHandoffMatchSql(args: {
|
|
continuationHandoffIdExpr: string;
|
|
turnIdExpr: string;
|
|
attemptIdExpr: string;
|
|
attemptNoExpr: string;
|
|
}): string {
|
|
return `EXISTS (
|
|
SELECT 1
|
|
FROM service_handoffs
|
|
WHERE service_handoffs.id = ${args.continuationHandoffIdExpr}
|
|
AND service_handoffs.turn_id = ${args.turnIdExpr}
|
|
AND (
|
|
service_handoffs.turn_attempt_id = ${args.attemptIdExpr}
|
|
OR (
|
|
service_handoffs.turn_attempt_id IS NULL
|
|
AND service_handoffs.turn_attempt_no = ${args.attemptNoExpr}
|
|
)
|
|
)
|
|
)`;
|
|
}
|
|
|
|
function tableHasForeignKey(
|
|
database: Database,
|
|
args: {
|
|
tableName: string;
|
|
referencedTable: string;
|
|
fromColumns: string[];
|
|
toColumns: string[];
|
|
onDelete?: string;
|
|
},
|
|
): boolean {
|
|
const rows = database
|
|
.prepare(`PRAGMA foreign_key_list(${args.tableName})`)
|
|
.all() as Array<{
|
|
id: number;
|
|
seq: number;
|
|
table: string;
|
|
from: string;
|
|
to: string;
|
|
on_delete: string;
|
|
}>;
|
|
if (rows.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const byId = new Map<number, typeof rows>();
|
|
for (const row of rows) {
|
|
const group = byId.get(row.id) ?? [];
|
|
group.push(row);
|
|
byId.set(row.id, group);
|
|
}
|
|
|
|
for (const group of byId.values()) {
|
|
const sorted = [...group].sort((left, right) => left.seq - right.seq);
|
|
if (sorted.length !== args.fromColumns.length) {
|
|
continue;
|
|
}
|
|
if (sorted[0]!.table !== args.referencedTable) {
|
|
continue;
|
|
}
|
|
if (
|
|
args.onDelete &&
|
|
sorted[0]!.on_delete.toUpperCase() !== args.onDelete.toUpperCase()
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const matches = sorted.every((row, index) => {
|
|
return (
|
|
row.from === args.fromColumns[index] && row.to === args.toColumns[index]
|
|
);
|
|
});
|
|
if (matches) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function backfillMessageSeq(database: Database): void {
|
|
const rows = database
|
|
.prepare(
|
|
`SELECT rowid, seq
|
|
FROM messages
|
|
ORDER BY CASE WHEN seq IS NULL THEN 1 ELSE 0 END, seq, timestamp, rowid`,
|
|
)
|
|
.all() as Array<{ rowid: number; seq: number | null }>;
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
let nextSeq = 1;
|
|
const assignSeq = database.prepare(
|
|
'UPDATE messages SET seq = ? WHERE rowid = ? AND seq IS NULL',
|
|
);
|
|
const tx = database.transaction(() => {
|
|
for (const row of rows) {
|
|
if (row.seq === null) {
|
|
assignSeq.run(nextSeq, row.rowid);
|
|
}
|
|
nextSeq = Math.max(nextSeq, (row.seq ?? nextSeq) + 1);
|
|
}
|
|
});
|
|
tx();
|
|
|
|
const maxSeqRow = database
|
|
.prepare('SELECT MAX(seq) AS maxSeq FROM messages')
|
|
.get() as { maxSeq: number | null };
|
|
const maxSeq = maxSeqRow.maxSeq ?? 0;
|
|
if (maxSeq > 0) {
|
|
database
|
|
.prepare('INSERT OR IGNORE INTO message_sequence (id) VALUES (?)')
|
|
.run(maxSeq);
|
|
}
|
|
}
|
|
|
|
interface LegacyExecutionLeaseServiceRow {
|
|
rowid: number;
|
|
role: string;
|
|
owner_service_id?: string | null;
|
|
reviewer_service_id?: string | null;
|
|
arbiter_service_id?: string | null;
|
|
owner_agent_type?: string | null;
|
|
reviewer_agent_type?: string | null;
|
|
arbiter_agent_type?: string | null;
|
|
}
|
|
|
|
interface StoredPairedTaskServiceRow {
|
|
rowid: number;
|
|
id: string;
|
|
chat_jid: string;
|
|
group_folder: string;
|
|
owner_service_id?: string | null;
|
|
reviewer_service_id?: string | null;
|
|
owner_agent_type?: string | null;
|
|
reviewer_agent_type?: string | null;
|
|
arbiter_agent_type?: string | null;
|
|
}
|
|
|
|
interface StoredChannelOwnerLeaseServiceRow {
|
|
rowid: number;
|
|
chat_jid: string;
|
|
owner_service_id?: string | null;
|
|
reviewer_service_id?: string | null;
|
|
arbiter_service_id?: string | null;
|
|
owner_agent_type?: string | null;
|
|
reviewer_agent_type?: string | null;
|
|
arbiter_agent_type?: string | null;
|
|
}
|
|
|
|
interface StoredServiceHandoffServiceRow {
|
|
id: number;
|
|
chat_jid: string;
|
|
group_folder: string;
|
|
source_service_id?: string | null;
|
|
target_service_id?: string | null;
|
|
source_role?: string | null;
|
|
target_role?: string | null;
|
|
source_agent_type?: string | null;
|
|
target_agent_type?: string | null;
|
|
intended_role?: string | null;
|
|
}
|
|
|
|
interface PairedTurnAttemptTimingRow {
|
|
turn_id: string;
|
|
attempt_no: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface PairedTurnAttemptTiming {
|
|
attemptNo: number;
|
|
createdAtMs: number;
|
|
}
|
|
|
|
function parseBackfillTimestampMs(
|
|
value: string | null | undefined,
|
|
): number | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T');
|
|
if (!/[zZ]|[+-]\d{2}:\d{2}$/.test(normalized)) {
|
|
normalized = `${normalized}Z`;
|
|
}
|
|
|
|
const parsed = Date.parse(normalized);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
}
|
|
|
|
function getPairedTurnAttemptTimingsByTurnId(
|
|
database: Database,
|
|
): Map<string, PairedTurnAttemptTiming[]> {
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT turn_id, attempt_no, created_at
|
|
FROM paired_turn_attempts
|
|
WHERE attempt_no >= 1
|
|
ORDER BY turn_id ASC, attempt_no ASC
|
|
`,
|
|
)
|
|
.all() as PairedTurnAttemptTimingRow[];
|
|
const timingsByTurnId = new Map<string, PairedTurnAttemptTiming[]>();
|
|
|
|
for (const row of rows) {
|
|
const createdAtMs = parseBackfillTimestampMs(row.created_at);
|
|
if (createdAtMs === null) {
|
|
throw new Error(
|
|
`paired_turn_attempts(${row.turn_id}, attempt=${row.attempt_no}) has an invalid created_at timestamp`,
|
|
);
|
|
}
|
|
const timings = timingsByTurnId.get(row.turn_id) ?? [];
|
|
timings.push({
|
|
attemptNo: row.attempt_no,
|
|
createdAtMs,
|
|
});
|
|
timingsByTurnId.set(row.turn_id, timings);
|
|
}
|
|
|
|
return timingsByTurnId;
|
|
}
|
|
|
|
function resolveAttemptNoForBackfill(
|
|
args: {
|
|
turnId: string;
|
|
eventTimestamp: string | null | undefined;
|
|
rowLabel: string;
|
|
},
|
|
timingsByTurnId: Map<string, PairedTurnAttemptTiming[]>,
|
|
): number | null {
|
|
const attemptTimings = timingsByTurnId.get(args.turnId) ?? [];
|
|
if (attemptTimings.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (attemptTimings.length === 1) {
|
|
return attemptTimings[0]!.attemptNo;
|
|
}
|
|
|
|
const eventAtMs = parseBackfillTimestampMs(args.eventTimestamp);
|
|
if (eventAtMs === null) {
|
|
throw new Error(
|
|
`${args.rowLabel} has turn_id=${args.turnId} but no timestamp that can be mapped to a specific attempt`,
|
|
);
|
|
}
|
|
|
|
const exactMatches = attemptTimings.filter((timing, index) => {
|
|
const nextTiming = attemptTimings[index + 1];
|
|
return (
|
|
eventAtMs >= timing.createdAtMs &&
|
|
(nextTiming === undefined || eventAtMs < nextTiming.createdAtMs)
|
|
);
|
|
});
|
|
if (exactMatches.length === 1) {
|
|
return exactMatches[0]!.attemptNo;
|
|
}
|
|
if (exactMatches.length > 1) {
|
|
throw new Error(
|
|
`${args.rowLabel} has an ambiguous exact timestamp match for turn_id=${args.turnId}`,
|
|
);
|
|
}
|
|
|
|
const eventAtSecond = Math.floor(eventAtMs / 1000);
|
|
const secondPrecisionMatches = attemptTimings.filter((timing, index) => {
|
|
const nextTiming = attemptTimings[index + 1];
|
|
const timingSecond = Math.floor(timing.createdAtMs / 1000);
|
|
const nextTimingSecond =
|
|
nextTiming === undefined
|
|
? null
|
|
: Math.floor(nextTiming.createdAtMs / 1000);
|
|
return (
|
|
eventAtSecond >= timingSecond &&
|
|
(nextTimingSecond === null || eventAtSecond < nextTimingSecond)
|
|
);
|
|
});
|
|
if (secondPrecisionMatches.length === 1) {
|
|
return secondPrecisionMatches[0]!.attemptNo;
|
|
}
|
|
if (secondPrecisionMatches.length > 1) {
|
|
throw new Error(
|
|
`${args.rowLabel} has an ambiguous second-precision timestamp match for turn_id=${args.turnId}`,
|
|
);
|
|
}
|
|
|
|
throw new Error(
|
|
`${args.rowLabel} could not be mapped to a specific attempt for turn_id=${args.turnId}`,
|
|
);
|
|
}
|
|
|
|
function backfillPairedTurnAttemptIds(database: Database): void {
|
|
if (!tableHasColumn(database, 'paired_turn_attempts', 'attempt_id')) {
|
|
return;
|
|
}
|
|
|
|
database.exec(`
|
|
UPDATE paired_turn_attempts
|
|
SET attempt_id = ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')}
|
|
WHERE attempt_id IS NULL
|
|
OR TRIM(attempt_id) = ''
|
|
`);
|
|
}
|
|
|
|
function backfillPairedTurnAttemptParentIds(database: Database): void {
|
|
if (!tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id')) {
|
|
return;
|
|
}
|
|
|
|
database.exec(`
|
|
UPDATE paired_turn_attempts
|
|
SET parent_attempt_id = ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
|
|
WHERE attempt_no > 1
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
|
|
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
|
|
)
|
|
AND (
|
|
parent_attempt_id IS NULL
|
|
OR TRIM(parent_attempt_id) = ''
|
|
OR parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
|
|
)
|
|
`);
|
|
|
|
database.exec(`
|
|
UPDATE paired_turn_attempts
|
|
SET parent_attempt_id = NULL
|
|
WHERE attempt_no <= 1
|
|
AND parent_attempt_id IS NOT NULL
|
|
`);
|
|
}
|
|
|
|
function backfillPairedTurnAttemptActiveRunIds(database: Database): void {
|
|
if (!tableHasColumn(database, 'paired_turn_attempts', 'active_run_id')) {
|
|
return;
|
|
}
|
|
|
|
const pairedTurnsActiveRunIdExists = tableHasColumn(
|
|
database,
|
|
'paired_turns',
|
|
'active_run_id',
|
|
);
|
|
const leaseTurnIdExists = tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_id',
|
|
);
|
|
const leaseTurnAttemptNoExists = tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_no',
|
|
);
|
|
const leaseActiveRunIdSql = leaseTurnIdExists
|
|
? leaseTurnAttemptNoExists
|
|
? `COALESCE(
|
|
(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
),
|
|
(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
AND paired_task_execution_leases.turn_attempt_no IS NULL
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
)
|
|
)`
|
|
: `(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
)`
|
|
: 'NULL';
|
|
const legacyTurnActiveRunIdSql = pairedTurnsActiveRunIdExists
|
|
? `(
|
|
SELECT paired_turns.active_run_id
|
|
FROM paired_turns
|
|
WHERE paired_turns.turn_id = paired_turn_attempts.turn_id
|
|
)`
|
|
: 'NULL';
|
|
|
|
database.exec(`
|
|
UPDATE paired_turn_attempts
|
|
SET active_run_id = COALESCE(
|
|
active_run_id,
|
|
CASE
|
|
WHEN state = 'running'
|
|
THEN COALESCE(
|
|
${leaseActiveRunIdSql},
|
|
${legacyTurnActiveRunIdSql}
|
|
)
|
|
ELSE NULL
|
|
END
|
|
)
|
|
WHERE state = 'running'
|
|
AND (active_run_id IS NULL OR TRIM(active_run_id) = '')
|
|
`);
|
|
|
|
database.exec(`
|
|
UPDATE paired_turn_attempts
|
|
SET active_run_id = NULL
|
|
WHERE state != 'running'
|
|
AND active_run_id IS NOT NULL
|
|
`);
|
|
}
|
|
|
|
function backfillPairedTurnAttemptEntityIds(database: Database): void {
|
|
if (
|
|
tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') &&
|
|
tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')
|
|
) {
|
|
database.exec(`
|
|
UPDATE paired_turn_reservations
|
|
SET turn_attempt_id = (
|
|
SELECT paired_turn_attempts.attempt_id
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.turn_id = paired_turn_reservations.turn_id
|
|
AND paired_turn_attempts.attempt_no = paired_turn_reservations.turn_attempt_no
|
|
)
|
|
WHERE turn_attempt_no IS NOT NULL
|
|
AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '')
|
|
`);
|
|
}
|
|
|
|
if (
|
|
tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_id',
|
|
) &&
|
|
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
|
|
) {
|
|
database.exec(`
|
|
UPDATE paired_task_execution_leases
|
|
SET turn_attempt_id = (
|
|
SELECT paired_turn_attempts.attempt_id
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.turn_id = paired_task_execution_leases.turn_id
|
|
AND paired_turn_attempts.attempt_no = paired_task_execution_leases.turn_attempt_no
|
|
)
|
|
WHERE turn_attempt_no IS NOT NULL
|
|
AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '')
|
|
`);
|
|
}
|
|
|
|
if (
|
|
tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') &&
|
|
tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')
|
|
) {
|
|
database.exec(`
|
|
UPDATE service_handoffs
|
|
SET turn_attempt_id = (
|
|
SELECT paired_turn_attempts.attempt_id
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.turn_id = service_handoffs.turn_id
|
|
AND paired_turn_attempts.attempt_no = service_handoffs.turn_attempt_no
|
|
)
|
|
WHERE turn_attempt_no IS NOT NULL
|
|
AND (turn_attempt_id IS NULL OR TRIM(turn_attempt_id) = '')
|
|
`);
|
|
}
|
|
}
|
|
|
|
function backfillPairedTurnAttemptProvenance(database: Database): void {
|
|
const timingsByTurnId = getPairedTurnAttemptTimingsByTurnId(database);
|
|
|
|
if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) {
|
|
const hasTurnAttemptIdColumn = tableHasColumn(
|
|
database,
|
|
'paired_turn_reservations',
|
|
'turn_attempt_id',
|
|
);
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT rowid, turn_id, status, created_at, updated_at, consumed_at
|
|
FROM paired_turn_reservations
|
|
WHERE turn_id IS NOT NULL
|
|
AND turn_attempt_no IS NULL
|
|
`,
|
|
)
|
|
.all() as Array<{
|
|
rowid: number;
|
|
turn_id: string;
|
|
status: string;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
consumed_at: string | null;
|
|
}>;
|
|
const update = hasTurnAttemptIdColumn
|
|
? database.prepare(
|
|
'UPDATE paired_turn_reservations SET turn_attempt_id = ?, turn_attempt_no = ? WHERE rowid = ?',
|
|
)
|
|
: database.prepare(
|
|
'UPDATE paired_turn_reservations SET turn_attempt_no = ? WHERE rowid = ?',
|
|
);
|
|
|
|
for (const row of rows) {
|
|
if (row.status !== 'completed') {
|
|
continue;
|
|
}
|
|
const attemptNo = resolveAttemptNoForBackfill(
|
|
{
|
|
turnId: row.turn_id,
|
|
eventTimestamp: row.consumed_at ?? row.updated_at ?? row.created_at,
|
|
rowLabel: `paired_turn_reservations(rowid=${row.rowid})`,
|
|
},
|
|
timingsByTurnId,
|
|
);
|
|
if (attemptNo !== null) {
|
|
const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo);
|
|
if (hasTurnAttemptIdColumn) {
|
|
update.run(attemptId, attemptNo, row.rowid);
|
|
} else {
|
|
update.run(attemptNo, row.rowid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
|
|
) {
|
|
const hasTurnAttemptIdColumn = tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_id',
|
|
);
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT rowid, turn_id, claimed_at, updated_at
|
|
FROM paired_task_execution_leases
|
|
WHERE turn_id IS NOT NULL
|
|
AND turn_attempt_no IS NULL
|
|
`,
|
|
)
|
|
.all() as Array<{
|
|
rowid: number;
|
|
turn_id: string;
|
|
claimed_at: string | null;
|
|
updated_at: string | null;
|
|
}>;
|
|
const update = hasTurnAttemptIdColumn
|
|
? database.prepare(
|
|
'UPDATE paired_task_execution_leases SET turn_attempt_id = ?, turn_attempt_no = ? WHERE rowid = ?',
|
|
)
|
|
: database.prepare(
|
|
'UPDATE paired_task_execution_leases SET turn_attempt_no = ? WHERE rowid = ?',
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const attemptNo = resolveAttemptNoForBackfill(
|
|
{
|
|
turnId: row.turn_id,
|
|
eventTimestamp: row.updated_at ?? row.claimed_at,
|
|
rowLabel: `paired_task_execution_leases(rowid=${row.rowid})`,
|
|
},
|
|
timingsByTurnId,
|
|
);
|
|
if (attemptNo !== null) {
|
|
const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo);
|
|
if (hasTurnAttemptIdColumn) {
|
|
update.run(attemptId, attemptNo, row.rowid);
|
|
} else {
|
|
update.run(attemptNo, row.rowid);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')) {
|
|
const hasTurnAttemptIdColumn = tableHasColumn(
|
|
database,
|
|
'service_handoffs',
|
|
'turn_attempt_id',
|
|
);
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT id, turn_id, created_at, claimed_at, completed_at
|
|
FROM service_handoffs
|
|
WHERE turn_id IS NOT NULL
|
|
AND turn_attempt_no IS NULL
|
|
`,
|
|
)
|
|
.all() as Array<{
|
|
id: number;
|
|
turn_id: string;
|
|
created_at: string | null;
|
|
claimed_at: string | null;
|
|
completed_at: string | null;
|
|
}>;
|
|
const update = hasTurnAttemptIdColumn
|
|
? database.prepare(
|
|
'UPDATE service_handoffs SET turn_attempt_id = ?, turn_attempt_no = ? WHERE id = ?',
|
|
)
|
|
: database.prepare(
|
|
'UPDATE service_handoffs SET turn_attempt_no = ? WHERE id = ?',
|
|
);
|
|
|
|
for (const row of rows) {
|
|
const attemptNo = resolveAttemptNoForBackfill(
|
|
{
|
|
turnId: row.turn_id,
|
|
eventTimestamp: row.completed_at ?? row.claimed_at ?? row.created_at,
|
|
rowLabel: `service_handoffs(id=${row.id})`,
|
|
},
|
|
timingsByTurnId,
|
|
);
|
|
if (attemptNo !== null) {
|
|
const attemptId = buildPairedTurnAttemptId(row.turn_id, attemptNo);
|
|
if (hasTurnAttemptIdColumn) {
|
|
update.run(attemptId, attemptNo, row.id);
|
|
} else {
|
|
update.run(attemptNo, row.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertPairedTurnAttemptProvenanceIntegrity(database: Database): void {
|
|
if (tableHasColumn(database, 'paired_turn_attempts', 'attempt_id')) {
|
|
const invalidAttemptIdRow = database
|
|
.prepare(
|
|
`
|
|
SELECT turn_id, attempt_no, attempt_id
|
|
FROM paired_turn_attempts
|
|
WHERE attempt_id IS NULL
|
|
OR attempt_id != ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')}
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as
|
|
| {
|
|
turn_id: string;
|
|
attempt_no: number;
|
|
attempt_id: string | null;
|
|
}
|
|
| undefined;
|
|
if (invalidAttemptIdRow) {
|
|
throw new Error(
|
|
`paired_turn_attempts(${invalidAttemptIdRow.turn_id}, attempt=${invalidAttemptIdRow.attempt_no}) has invalid attempt_id provenance`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id')) {
|
|
const invalidParentAttemptRow = database
|
|
.prepare(
|
|
`
|
|
SELECT turn_id, attempt_no, parent_attempt_id
|
|
FROM paired_turn_attempts
|
|
WHERE (
|
|
attempt_no <= 1
|
|
AND parent_attempt_id IS NOT NULL
|
|
)
|
|
OR (
|
|
attempt_no > 1
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
|
|
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
|
|
)
|
|
)
|
|
OR (
|
|
attempt_no > 1
|
|
AND (
|
|
parent_attempt_id IS NULL
|
|
OR parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE COALESCE(
|
|
previous_attempt.attempt_id,
|
|
${buildPairedTurnAttemptIdSql(
|
|
'previous_attempt.turn_id',
|
|
'previous_attempt.attempt_no',
|
|
)}
|
|
) = paired_turn_attempts.parent_attempt_id
|
|
AND previous_attempt.turn_id = paired_turn_attempts.turn_id
|
|
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
|
|
)
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as
|
|
| {
|
|
turn_id: string;
|
|
attempt_no: number;
|
|
parent_attempt_id: string | null;
|
|
}
|
|
| undefined;
|
|
if (invalidParentAttemptRow) {
|
|
throw new Error(
|
|
`paired_turn_attempts(${invalidParentAttemptRow.turn_id}, attempt=${invalidParentAttemptRow.attempt_no}) has invalid parent_attempt_id provenance`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tableHasColumn(database, 'paired_turn_attempts', 'parent_handoff_id')) {
|
|
const invalidParentHandoffRow = database
|
|
.prepare(
|
|
`
|
|
SELECT turn_id, attempt_no, parent_handoff_id
|
|
FROM paired_turn_attempts
|
|
WHERE parent_handoff_id IS NOT NULL
|
|
AND (
|
|
attempt_no <= 1
|
|
OR NOT ${buildPairedTurnAttemptParentHandoffMatchSql({
|
|
parentHandoffIdExpr: 'paired_turn_attempts.parent_handoff_id',
|
|
turnIdExpr: 'paired_turn_attempts.turn_id',
|
|
parentAttemptIdExpr: 'paired_turn_attempts.parent_attempt_id',
|
|
attemptNoExpr: 'paired_turn_attempts.attempt_no',
|
|
})}
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as
|
|
| {
|
|
turn_id: string;
|
|
attempt_no: number;
|
|
parent_handoff_id: number | null;
|
|
}
|
|
| undefined;
|
|
if (invalidParentHandoffRow) {
|
|
throw new Error(
|
|
`paired_turn_attempts(${invalidParentHandoffRow.turn_id}, attempt=${invalidParentHandoffRow.attempt_no}) has invalid parent_handoff_id provenance`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
tableHasColumn(database, 'paired_turn_attempts', 'continuation_handoff_id')
|
|
) {
|
|
const invalidContinuationHandoffRow = database
|
|
.prepare(
|
|
`
|
|
SELECT turn_id, attempt_no, continuation_handoff_id
|
|
FROM paired_turn_attempts
|
|
WHERE continuation_handoff_id IS NOT NULL
|
|
AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({
|
|
continuationHandoffIdExpr:
|
|
'paired_turn_attempts.continuation_handoff_id',
|
|
turnIdExpr: 'paired_turn_attempts.turn_id',
|
|
attemptIdExpr:
|
|
"COALESCE(paired_turn_attempts.attempt_id, paired_turn_attempts.turn_id || ':attempt:' || CAST(paired_turn_attempts.attempt_no AS TEXT))",
|
|
attemptNoExpr: 'paired_turn_attempts.attempt_no',
|
|
})}
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as
|
|
| {
|
|
turn_id: string;
|
|
attempt_no: number;
|
|
continuation_handoff_id: number | null;
|
|
}
|
|
| undefined;
|
|
if (invalidContinuationHandoffRow) {
|
|
throw new Error(
|
|
`paired_turn_attempts(${invalidContinuationHandoffRow.turn_id}, attempt=${invalidContinuationHandoffRow.attempt_no}) has invalid continuation_handoff_id provenance`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const invalidAttemptRow = database
|
|
.prepare(
|
|
`
|
|
SELECT turn_id, attempt_no
|
|
FROM paired_turn_attempts
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turns
|
|
WHERE paired_turns.turn_id = paired_turn_attempts.turn_id
|
|
AND paired_turns.task_id = paired_turn_attempts.task_id
|
|
AND paired_turns.task_updated_at = paired_turn_attempts.task_updated_at
|
|
AND paired_turns.role = paired_turn_attempts.role
|
|
AND paired_turns.intent_kind = paired_turn_attempts.intent_kind
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as
|
|
| {
|
|
turn_id: string;
|
|
attempt_no: number;
|
|
}
|
|
| undefined;
|
|
if (invalidAttemptRow) {
|
|
throw new Error(
|
|
`paired_turn_attempts(${invalidAttemptRow.turn_id}, attempt=${invalidAttemptRow.attempt_no}) does not match its paired_turns identity`,
|
|
);
|
|
}
|
|
|
|
if (tableHasColumn(database, 'paired_turn_attempts', 'active_run_id')) {
|
|
const invalidAttemptActiveRunRow = database
|
|
.prepare(
|
|
`
|
|
SELECT turn_id, attempt_no
|
|
FROM paired_turn_attempts
|
|
WHERE (
|
|
state = 'running'
|
|
AND (
|
|
active_run_id IS NULL
|
|
OR TRIM(active_run_id) = ''
|
|
)
|
|
)
|
|
OR (
|
|
state != 'running'
|
|
AND active_run_id IS NOT NULL
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as
|
|
| {
|
|
turn_id: string;
|
|
attempt_no: number;
|
|
}
|
|
| undefined;
|
|
if (invalidAttemptActiveRunRow) {
|
|
throw new Error(
|
|
`paired_turn_attempts(${invalidAttemptActiveRunRow.turn_id}, attempt=${invalidAttemptActiveRunRow.attempt_no}) has invalid active_run_id provenance`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) {
|
|
const invalidReservation = database
|
|
.prepare(
|
|
`
|
|
SELECT rowid
|
|
FROM paired_turn_reservations
|
|
WHERE turn_attempt_no IS NOT NULL
|
|
AND (
|
|
turn_id IS NULL
|
|
OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') ? '1 = 1' : '0 = 1'})
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.turn_id = paired_turn_reservations.turn_id
|
|
AND paired_turn_attempts.attempt_no = paired_turn_reservations.turn_attempt_no
|
|
${
|
|
tableHasColumn(
|
|
database,
|
|
'paired_turn_reservations',
|
|
'turn_attempt_id',
|
|
)
|
|
? 'AND paired_turn_attempts.attempt_id = paired_turn_reservations.turn_attempt_id'
|
|
: ''
|
|
}
|
|
AND paired_turn_attempts.task_id = paired_turn_reservations.task_id
|
|
AND paired_turn_attempts.task_updated_at = paired_turn_reservations.task_updated_at
|
|
AND paired_turn_attempts.role = paired_turn_reservations.turn_role
|
|
AND paired_turn_attempts.intent_kind = paired_turn_reservations.intent_kind
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as { rowid: number } | undefined;
|
|
if (invalidReservation) {
|
|
throw new Error(
|
|
`paired_turn_reservations(rowid=${invalidReservation.rowid}) has invalid paired_turn_attempt provenance`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
|
|
) {
|
|
const invalidLease = database
|
|
.prepare(
|
|
`
|
|
SELECT rowid
|
|
FROM paired_task_execution_leases
|
|
WHERE turn_attempt_no IS NOT NULL
|
|
AND (
|
|
turn_id IS NULL
|
|
OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_id') ? '1 = 1' : '0 = 1'})
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.turn_id = paired_task_execution_leases.turn_id
|
|
AND paired_turn_attempts.attempt_no = paired_task_execution_leases.turn_attempt_no
|
|
${
|
|
tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_id',
|
|
)
|
|
? 'AND paired_turn_attempts.attempt_id = paired_task_execution_leases.turn_attempt_id'
|
|
: ''
|
|
}
|
|
AND paired_turn_attempts.task_id = paired_task_execution_leases.task_id
|
|
AND paired_turn_attempts.task_updated_at = paired_task_execution_leases.task_updated_at
|
|
AND paired_turn_attempts.role = paired_task_execution_leases.role
|
|
AND paired_turn_attempts.intent_kind = paired_task_execution_leases.intent_kind
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as { rowid: number } | undefined;
|
|
if (invalidLease) {
|
|
throw new Error(
|
|
`paired_task_execution_leases(rowid=${invalidLease.rowid}) has invalid paired_turn_attempt provenance`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tableHasColumn(database, 'service_handoffs', 'turn_attempt_no')) {
|
|
const invalidHandoff = database
|
|
.prepare(
|
|
`
|
|
SELECT id
|
|
FROM service_handoffs
|
|
WHERE turn_attempt_no IS NOT NULL
|
|
AND (
|
|
turn_id IS NULL
|
|
OR (turn_attempt_id IS NULL AND ${tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') ? '1 = 1' : '0 = 1'})
|
|
OR paired_task_id IS NULL
|
|
OR paired_task_updated_at IS NULL
|
|
OR turn_role IS NULL
|
|
OR turn_intent_kind IS NULL
|
|
OR NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.turn_id = service_handoffs.turn_id
|
|
AND paired_turn_attempts.attempt_no = service_handoffs.turn_attempt_no
|
|
${
|
|
tableHasColumn(
|
|
database,
|
|
'service_handoffs',
|
|
'turn_attempt_id',
|
|
)
|
|
? 'AND paired_turn_attempts.attempt_id = service_handoffs.turn_attempt_id'
|
|
: ''
|
|
}
|
|
AND paired_turn_attempts.task_id = service_handoffs.paired_task_id
|
|
AND paired_turn_attempts.task_updated_at = service_handoffs.paired_task_updated_at
|
|
AND paired_turn_attempts.role = service_handoffs.turn_role
|
|
AND paired_turn_attempts.intent_kind = service_handoffs.turn_intent_kind
|
|
)
|
|
)
|
|
LIMIT 1
|
|
`,
|
|
)
|
|
.get() as { id: number } | undefined;
|
|
if (invalidHandoff) {
|
|
throw new Error(
|
|
`service_handoffs(id=${invalidHandoff.id}) has invalid paired_turn_attempt provenance`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function rebuildPairedTurnAttemptsWithForeignKeys(database: Database): void {
|
|
const tableSql = getTableSql(database, 'paired_turn_attempts') ?? '';
|
|
if (
|
|
tableHasColumn(database, 'paired_turn_attempts', 'attempt_id') &&
|
|
tableHasColumn(database, 'paired_turn_attempts', 'parent_attempt_id') &&
|
|
tableHasColumn(database, 'paired_turn_attempts', 'parent_handoff_id') &&
|
|
tableHasColumn(
|
|
database,
|
|
'paired_turn_attempts',
|
|
'continuation_handoff_id',
|
|
) &&
|
|
tableSql.includes('attempt_id TEXT NOT NULL PRIMARY KEY') &&
|
|
tableSql.includes('parent_attempt_id TEXT') &&
|
|
tableSql.includes('parent_handoff_id INTEGER') &&
|
|
tableSql.includes('continuation_handoff_id INTEGER') &&
|
|
tableSql.includes('UNIQUE (turn_id, attempt_no)') &&
|
|
tableHasForeignKey(database, {
|
|
tableName: 'paired_turn_attempts',
|
|
referencedTable: 'paired_turn_attempts',
|
|
fromColumns: ['parent_attempt_id'],
|
|
toColumns: ['attempt_id'],
|
|
onDelete: 'CASCADE',
|
|
}) &&
|
|
tableHasForeignKey(database, {
|
|
tableName: 'paired_turn_attempts',
|
|
referencedTable: 'service_handoffs',
|
|
fromColumns: ['parent_handoff_id'],
|
|
toColumns: ['id'],
|
|
onDelete: 'SET NULL',
|
|
}) &&
|
|
tableHasForeignKey(database, {
|
|
tableName: 'paired_turn_attempts',
|
|
referencedTable: 'service_handoffs',
|
|
fromColumns: ['continuation_handoff_id'],
|
|
toColumns: ['id'],
|
|
onDelete: 'SET NULL',
|
|
}) &&
|
|
tableHasForeignKey(database, {
|
|
tableName: 'paired_turn_attempts',
|
|
referencedTable: 'paired_turns',
|
|
fromColumns: ['turn_id'],
|
|
toColumns: ['turn_id'],
|
|
onDelete: 'CASCADE',
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const parentAttemptIdSelectSql = tableHasColumn(
|
|
database,
|
|
'paired_turn_attempts',
|
|
'parent_attempt_id',
|
|
)
|
|
? `COALESCE(
|
|
parent_attempt_id,
|
|
CASE
|
|
WHEN attempt_no > 1
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
|
|
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
|
|
)
|
|
THEN ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
|
|
ELSE NULL
|
|
END
|
|
)`
|
|
: `CASE
|
|
WHEN attempt_no > 1
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE previous_attempt.turn_id = paired_turn_attempts.turn_id
|
|
AND previous_attempt.attempt_no = paired_turn_attempts.attempt_no - 1
|
|
)
|
|
THEN ${buildPairedTurnAttemptParentIdSql('turn_id', 'attempt_no')}
|
|
ELSE NULL
|
|
END`;
|
|
const parentHandoffIdSelectSql = tableHasColumn(
|
|
database,
|
|
'paired_turn_attempts',
|
|
'parent_handoff_id',
|
|
)
|
|
? 'parent_handoff_id'
|
|
: 'NULL';
|
|
const continuationHandoffIdSelectSql = tableHasColumn(
|
|
database,
|
|
'paired_turn_attempts',
|
|
'continuation_handoff_id',
|
|
)
|
|
? 'continuation_handoff_id'
|
|
: 'NULL';
|
|
const activeRunIdSelectSql = tableHasColumn(
|
|
database,
|
|
'paired_turn_attempts',
|
|
'active_run_id',
|
|
)
|
|
? `CASE
|
|
WHEN state = 'running' THEN active_run_id
|
|
ELSE NULL
|
|
END`
|
|
: `CASE
|
|
WHEN state = 'running'
|
|
THEN ${
|
|
tableHasColumn(database, 'paired_turns', 'active_run_id')
|
|
? `COALESCE(
|
|
${
|
|
tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_no',
|
|
)
|
|
? `COALESCE(
|
|
(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
),
|
|
(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
AND paired_task_execution_leases.turn_attempt_no IS NULL
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
)
|
|
)`
|
|
: `(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
)`
|
|
},
|
|
(
|
|
SELECT paired_turns.active_run_id
|
|
FROM paired_turns
|
|
WHERE paired_turns.turn_id = paired_turn_attempts.turn_id
|
|
)
|
|
)`
|
|
: `(
|
|
${
|
|
tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_no',
|
|
)
|
|
? `COALESCE(
|
|
(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
AND paired_task_execution_leases.turn_attempt_no = paired_turn_attempts.attempt_no
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
),
|
|
(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
AND paired_task_execution_leases.turn_attempt_no IS NULL
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
)
|
|
)`
|
|
: `(
|
|
SELECT paired_task_execution_leases.claimed_run_id
|
|
FROM paired_task_execution_leases
|
|
WHERE paired_task_execution_leases.turn_id = paired_turn_attempts.turn_id
|
|
ORDER BY paired_task_execution_leases.updated_at DESC,
|
|
paired_task_execution_leases.claimed_at DESC
|
|
LIMIT 1
|
|
)`
|
|
}`
|
|
}
|
|
ELSE NULL
|
|
END`;
|
|
|
|
database.transaction(() => {
|
|
database.exec(`
|
|
CREATE TABLE paired_turn_attempts_new (
|
|
attempt_id TEXT NOT NULL PRIMARY KEY,
|
|
parent_attempt_id TEXT,
|
|
parent_handoff_id INTEGER,
|
|
continuation_handoff_id INTEGER,
|
|
turn_id TEXT NOT NULL,
|
|
attempt_no INTEGER NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
task_updated_at TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
intent_kind TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
executor_service_id TEXT,
|
|
executor_agent_type TEXT,
|
|
active_run_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
completed_at TEXT,
|
|
last_error TEXT,
|
|
UNIQUE (turn_id, attempt_no),
|
|
FOREIGN KEY (parent_attempt_id)
|
|
REFERENCES paired_turn_attempts_new(attempt_id)
|
|
ON DELETE CASCADE,
|
|
FOREIGN KEY (parent_handoff_id)
|
|
REFERENCES service_handoffs(id)
|
|
ON DELETE SET NULL,
|
|
FOREIGN KEY (continuation_handoff_id)
|
|
REFERENCES service_handoffs(id)
|
|
ON DELETE SET NULL,
|
|
FOREIGN KEY (turn_id) REFERENCES paired_turns(turn_id) ON DELETE CASCADE,
|
|
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
|
CHECK (
|
|
intent_kind IN (
|
|
'owner-turn',
|
|
'reviewer-turn',
|
|
'arbiter-turn',
|
|
'owner-follow-up',
|
|
'finalize-owner-turn'
|
|
)
|
|
),
|
|
CHECK (
|
|
state IN (
|
|
'running',
|
|
'delegated',
|
|
'completed',
|
|
'failed',
|
|
'cancelled'
|
|
)
|
|
),
|
|
CHECK (executor_agent_type IN ('claude-code', 'codex') OR executor_agent_type IS NULL)
|
|
);
|
|
INSERT INTO paired_turn_attempts_new (
|
|
attempt_id,
|
|
parent_attempt_id,
|
|
parent_handoff_id,
|
|
continuation_handoff_id,
|
|
turn_id,
|
|
attempt_no,
|
|
task_id,
|
|
task_updated_at,
|
|
role,
|
|
intent_kind,
|
|
state,
|
|
executor_service_id,
|
|
executor_agent_type,
|
|
active_run_id,
|
|
created_at,
|
|
updated_at,
|
|
completed_at,
|
|
last_error
|
|
)
|
|
SELECT
|
|
COALESCE(attempt_id, ${buildPairedTurnAttemptIdSql('turn_id', 'attempt_no')}),
|
|
${parentAttemptIdSelectSql},
|
|
${parentHandoffIdSelectSql},
|
|
${continuationHandoffIdSelectSql},
|
|
turn_id,
|
|
attempt_no,
|
|
task_id,
|
|
task_updated_at,
|
|
role,
|
|
intent_kind,
|
|
state,
|
|
executor_service_id,
|
|
executor_agent_type,
|
|
${activeRunIdSelectSql},
|
|
created_at,
|
|
updated_at,
|
|
completed_at,
|
|
last_error
|
|
FROM paired_turn_attempts
|
|
ORDER BY turn_id ASC, attempt_no ASC;
|
|
DROP TABLE paired_turn_attempts;
|
|
ALTER TABLE paired_turn_attempts_new RENAME TO paired_turn_attempts;
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_attempt_id
|
|
ON paired_turn_attempts(attempt_id);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_attempt_id
|
|
ON paired_turn_attempts(parent_attempt_id);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_handoff_id
|
|
ON paired_turn_attempts(parent_handoff_id);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_continuation_handoff_id
|
|
ON paired_turn_attempts(continuation_handoff_id);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_turn
|
|
ON paired_turn_attempts(turn_id, attempt_no);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_task
|
|
ON paired_turn_attempts(task_id, task_updated_at, attempt_no);
|
|
`);
|
|
})();
|
|
}
|
|
|
|
function rebuildPairedTurnsWithoutLegacyScratchColumns(
|
|
database: Database,
|
|
): void {
|
|
if (
|
|
!tableHasColumn(database, 'paired_turns', 'next_parent_handoff_id') &&
|
|
!tableHasColumn(database, 'paired_turns', 'active_run_id') &&
|
|
!tableHasColumn(database, 'paired_turns', 'state') &&
|
|
!tableHasColumn(database, 'paired_turns', 'executor_service_id') &&
|
|
!tableHasColumn(database, 'paired_turns', 'executor_agent_type') &&
|
|
!tableHasColumn(database, 'paired_turns', 'attempt_no') &&
|
|
!tableHasColumn(database, 'paired_turns', 'completed_at') &&
|
|
!tableHasColumn(database, 'paired_turns', 'last_error')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
database.transaction(() => {
|
|
database.exec(`
|
|
CREATE TABLE paired_turns_new (
|
|
turn_id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
task_updated_at TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
intent_kind TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
|
CHECK (
|
|
intent_kind IN (
|
|
'owner-turn',
|
|
'reviewer-turn',
|
|
'arbiter-turn',
|
|
'owner-follow-up',
|
|
'finalize-owner-turn'
|
|
)
|
|
)
|
|
);
|
|
INSERT INTO paired_turns_new (
|
|
turn_id,
|
|
task_id,
|
|
task_updated_at,
|
|
role,
|
|
intent_kind,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
SELECT
|
|
turn_id,
|
|
task_id,
|
|
task_updated_at,
|
|
role,
|
|
intent_kind,
|
|
created_at,
|
|
updated_at
|
|
FROM paired_turns
|
|
ORDER BY created_at ASC, turn_id ASC;
|
|
DROP TABLE paired_turns;
|
|
ALTER TABLE paired_turns_new RENAME TO paired_turns;
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turns_task
|
|
ON paired_turns(task_id, task_updated_at, updated_at);
|
|
`);
|
|
})();
|
|
}
|
|
|
|
function rebuildPairedTurnReservationsWithForeignKeys(
|
|
database: Database,
|
|
): void {
|
|
if (
|
|
!tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no') ||
|
|
(tableHasForeignKey(database, {
|
|
tableName: 'paired_turn_reservations',
|
|
referencedTable: 'paired_turn_attempts',
|
|
fromColumns: ['turn_id', 'turn_attempt_no'],
|
|
toColumns: ['turn_id', 'attempt_no'],
|
|
onDelete: 'CASCADE',
|
|
}) &&
|
|
tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_id') &&
|
|
tableHasForeignKey(database, {
|
|
tableName: 'paired_turn_reservations',
|
|
referencedTable: 'paired_turn_attempts',
|
|
fromColumns: ['turn_attempt_id'],
|
|
toColumns: ['attempt_id'],
|
|
onDelete: 'CASCADE',
|
|
}))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
database.transaction(() => {
|
|
database.exec(`
|
|
CREATE TABLE paired_turn_reservations_new (
|
|
chat_jid TEXT NOT NULL,
|
|
task_id TEXT NOT NULL,
|
|
task_status TEXT NOT NULL,
|
|
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
|
task_updated_at TEXT NOT NULL,
|
|
turn_id TEXT NOT NULL,
|
|
turn_attempt_id TEXT,
|
|
turn_attempt_no INTEGER,
|
|
turn_role TEXT NOT NULL,
|
|
intent_kind TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
scheduled_run_id TEXT,
|
|
consumed_run_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
consumed_at TEXT,
|
|
PRIMARY KEY (chat_jid, task_id, task_updated_at, intent_kind),
|
|
FOREIGN KEY (turn_attempt_id)
|
|
REFERENCES paired_turn_attempts(attempt_id)
|
|
ON DELETE CASCADE,
|
|
FOREIGN KEY (turn_id, turn_attempt_no)
|
|
REFERENCES paired_turn_attempts(turn_id, attempt_no)
|
|
ON DELETE CASCADE,
|
|
CHECK (status IN ('pending', 'completed')),
|
|
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter')),
|
|
CHECK (
|
|
intent_kind IN (
|
|
'owner-turn',
|
|
'reviewer-turn',
|
|
'arbiter-turn',
|
|
'owner-follow-up',
|
|
'finalize-owner-turn'
|
|
)
|
|
)
|
|
);
|
|
INSERT INTO paired_turn_reservations_new (
|
|
chat_jid,
|
|
task_id,
|
|
task_status,
|
|
round_trip_count,
|
|
task_updated_at,
|
|
turn_id,
|
|
turn_attempt_id,
|
|
turn_attempt_no,
|
|
turn_role,
|
|
intent_kind,
|
|
status,
|
|
scheduled_run_id,
|
|
consumed_run_id,
|
|
created_at,
|
|
updated_at,
|
|
consumed_at
|
|
)
|
|
SELECT
|
|
chat_jid,
|
|
task_id,
|
|
task_status,
|
|
round_trip_count,
|
|
task_updated_at,
|
|
turn_id,
|
|
COALESCE(
|
|
turn_attempt_id,
|
|
CASE
|
|
WHEN turn_attempt_no IS NOT NULL
|
|
THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')}
|
|
ELSE NULL
|
|
END
|
|
),
|
|
turn_attempt_no,
|
|
turn_role,
|
|
intent_kind,
|
|
status,
|
|
scheduled_run_id,
|
|
consumed_run_id,
|
|
created_at,
|
|
updated_at,
|
|
consumed_at
|
|
FROM paired_turn_reservations;
|
|
DROP TABLE paired_turn_reservations;
|
|
ALTER TABLE paired_turn_reservations_new RENAME TO paired_turn_reservations;
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_task
|
|
ON paired_turn_reservations(task_id, task_updated_at, status);
|
|
`);
|
|
})();
|
|
}
|
|
|
|
function rebuildPairedTaskExecutionLeasesWithForeignKeys(
|
|
database: Database,
|
|
): void {
|
|
if (
|
|
!tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_no',
|
|
) ||
|
|
(tableHasForeignKey(database, {
|
|
tableName: 'paired_task_execution_leases',
|
|
referencedTable: 'paired_turn_attempts',
|
|
fromColumns: ['turn_id', 'turn_attempt_no'],
|
|
toColumns: ['turn_id', 'attempt_no'],
|
|
onDelete: 'CASCADE',
|
|
}) &&
|
|
tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'turn_attempt_id',
|
|
) &&
|
|
tableHasForeignKey(database, {
|
|
tableName: 'paired_task_execution_leases',
|
|
referencedTable: 'paired_turn_attempts',
|
|
fromColumns: ['turn_attempt_id'],
|
|
toColumns: ['attempt_id'],
|
|
onDelete: 'CASCADE',
|
|
}))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
database.transaction(() => {
|
|
database.exec(`
|
|
CREATE TABLE paired_task_execution_leases_new (
|
|
task_id TEXT PRIMARY KEY,
|
|
chat_jid TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
turn_id TEXT NOT NULL,
|
|
turn_attempt_id TEXT,
|
|
turn_attempt_no INTEGER,
|
|
intent_kind TEXT NOT NULL,
|
|
claimed_run_id TEXT NOT NULL,
|
|
claimed_service_id TEXT NOT NULL,
|
|
task_status TEXT NOT NULL,
|
|
task_updated_at TEXT NOT NULL,
|
|
claimed_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
FOREIGN KEY (turn_attempt_id)
|
|
REFERENCES paired_turn_attempts(attempt_id)
|
|
ON DELETE CASCADE,
|
|
FOREIGN KEY (turn_id, turn_attempt_no)
|
|
REFERENCES paired_turn_attempts(turn_id, attempt_no)
|
|
ON DELETE CASCADE,
|
|
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
|
CHECK (
|
|
intent_kind IN (
|
|
'owner-turn',
|
|
'reviewer-turn',
|
|
'arbiter-turn',
|
|
'owner-follow-up',
|
|
'finalize-owner-turn'
|
|
)
|
|
)
|
|
);
|
|
INSERT INTO paired_task_execution_leases_new (
|
|
task_id,
|
|
chat_jid,
|
|
role,
|
|
turn_id,
|
|
turn_attempt_id,
|
|
turn_attempt_no,
|
|
intent_kind,
|
|
claimed_run_id,
|
|
claimed_service_id,
|
|
task_status,
|
|
task_updated_at,
|
|
claimed_at,
|
|
updated_at,
|
|
expires_at
|
|
)
|
|
SELECT
|
|
task_id,
|
|
chat_jid,
|
|
role,
|
|
turn_id,
|
|
COALESCE(
|
|
turn_attempt_id,
|
|
CASE
|
|
WHEN turn_attempt_no IS NOT NULL
|
|
THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')}
|
|
ELSE NULL
|
|
END
|
|
),
|
|
turn_attempt_no,
|
|
intent_kind,
|
|
claimed_run_id,
|
|
claimed_service_id,
|
|
task_status,
|
|
task_updated_at,
|
|
claimed_at,
|
|
updated_at,
|
|
expires_at
|
|
FROM paired_task_execution_leases;
|
|
DROP TABLE paired_task_execution_leases;
|
|
ALTER TABLE paired_task_execution_leases_new RENAME TO paired_task_execution_leases;
|
|
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_expires_at
|
|
ON paired_task_execution_leases(expires_at);
|
|
`);
|
|
})();
|
|
}
|
|
|
|
function rebuildServiceHandoffsWithForeignKeys(database: Database): void {
|
|
if (
|
|
!tableHasColumn(database, 'service_handoffs', 'turn_attempt_no') ||
|
|
(tableHasForeignKey(database, {
|
|
tableName: 'service_handoffs',
|
|
referencedTable: 'paired_turn_attempts',
|
|
fromColumns: ['turn_id', 'turn_attempt_no'],
|
|
toColumns: ['turn_id', 'attempt_no'],
|
|
onDelete: 'CASCADE',
|
|
}) &&
|
|
tableHasColumn(database, 'service_handoffs', 'turn_attempt_id') &&
|
|
tableHasForeignKey(database, {
|
|
tableName: 'service_handoffs',
|
|
referencedTable: 'paired_turn_attempts',
|
|
fromColumns: ['turn_attempt_id'],
|
|
toColumns: ['attempt_id'],
|
|
onDelete: 'CASCADE',
|
|
}))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
database.transaction(() => {
|
|
database.exec(`
|
|
CREATE TABLE service_handoffs_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
chat_jid TEXT NOT NULL,
|
|
group_folder TEXT NOT NULL,
|
|
source_service_id TEXT NOT NULL,
|
|
target_service_id TEXT NOT NULL,
|
|
paired_task_id TEXT,
|
|
paired_task_updated_at TEXT,
|
|
turn_id TEXT,
|
|
turn_attempt_id TEXT,
|
|
turn_attempt_no INTEGER,
|
|
turn_intent_kind TEXT,
|
|
turn_role TEXT,
|
|
source_role TEXT,
|
|
source_agent_type TEXT,
|
|
target_role TEXT,
|
|
target_agent_type TEXT NOT NULL,
|
|
prompt TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
start_seq INTEGER,
|
|
end_seq INTEGER,
|
|
reason TEXT,
|
|
intended_role TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
claimed_at TEXT,
|
|
completed_at TEXT,
|
|
last_error TEXT,
|
|
FOREIGN KEY (turn_attempt_id)
|
|
REFERENCES paired_turn_attempts(attempt_id)
|
|
ON DELETE CASCADE,
|
|
FOREIGN KEY (turn_id, turn_attempt_no)
|
|
REFERENCES paired_turn_attempts(turn_id, attempt_no)
|
|
ON DELETE CASCADE,
|
|
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
|
|
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL),
|
|
CHECK (
|
|
turn_intent_kind IN (
|
|
'owner-turn',
|
|
'reviewer-turn',
|
|
'arbiter-turn',
|
|
'owner-follow-up',
|
|
'finalize-owner-turn'
|
|
) OR turn_intent_kind IS NULL
|
|
),
|
|
CHECK (turn_role IN ('owner', 'reviewer', 'arbiter') OR turn_role IS NULL),
|
|
CHECK (source_role IN ('owner', 'reviewer', 'arbiter') OR source_role IS NULL),
|
|
CHECK (target_role IN ('owner', 'reviewer', 'arbiter') OR target_role IS NULL)
|
|
);
|
|
INSERT INTO service_handoffs_new (
|
|
id,
|
|
chat_jid,
|
|
group_folder,
|
|
source_service_id,
|
|
target_service_id,
|
|
paired_task_id,
|
|
paired_task_updated_at,
|
|
turn_id,
|
|
turn_attempt_id,
|
|
turn_attempt_no,
|
|
turn_intent_kind,
|
|
turn_role,
|
|
source_role,
|
|
source_agent_type,
|
|
target_role,
|
|
target_agent_type,
|
|
prompt,
|
|
status,
|
|
start_seq,
|
|
end_seq,
|
|
reason,
|
|
intended_role,
|
|
created_at,
|
|
claimed_at,
|
|
completed_at,
|
|
last_error
|
|
)
|
|
SELECT
|
|
id,
|
|
chat_jid,
|
|
group_folder,
|
|
source_service_id,
|
|
target_service_id,
|
|
paired_task_id,
|
|
paired_task_updated_at,
|
|
turn_id,
|
|
COALESCE(
|
|
turn_attempt_id,
|
|
CASE
|
|
WHEN turn_attempt_no IS NOT NULL
|
|
THEN ${buildPairedTurnAttemptIdSql('turn_id', 'turn_attempt_no')}
|
|
ELSE NULL
|
|
END
|
|
),
|
|
turn_attempt_no,
|
|
turn_intent_kind,
|
|
turn_role,
|
|
source_role,
|
|
source_agent_type,
|
|
target_role,
|
|
target_agent_type,
|
|
prompt,
|
|
status,
|
|
start_seq,
|
|
end_seq,
|
|
reason,
|
|
intended_role,
|
|
created_at,
|
|
claimed_at,
|
|
completed_at,
|
|
last_error
|
|
FROM service_handoffs;
|
|
DROP TABLE service_handoffs;
|
|
ALTER TABLE service_handoffs_new RENAME TO service_handoffs;
|
|
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
|
|
ON service_handoffs(status, target_role, target_agent_type, created_at);
|
|
`);
|
|
})();
|
|
}
|
|
|
|
function rebuildPairedTurnAttemptForeignKeyTables(database: Database): void {
|
|
rebuildPairedTurnAttemptsWithForeignKeys(database);
|
|
rebuildPairedTurnReservationsWithForeignKeys(database);
|
|
rebuildPairedTaskExecutionLeasesWithForeignKeys(database);
|
|
rebuildServiceHandoffsWithForeignKeys(database);
|
|
}
|
|
|
|
function dropPairedTurnAttemptProvenanceConstraints(database: Database): void {
|
|
database.exec(`
|
|
DROP TRIGGER IF EXISTS paired_turn_attempts_validate_insert;
|
|
DROP TRIGGER IF EXISTS paired_turn_attempts_validate_update;
|
|
DROP TRIGGER IF EXISTS paired_turn_reservations_validate_attempt_insert;
|
|
DROP TRIGGER IF EXISTS paired_turn_reservations_validate_attempt_update;
|
|
DROP TRIGGER IF EXISTS paired_task_execution_leases_validate_attempt_insert;
|
|
DROP TRIGGER IF EXISTS paired_task_execution_leases_validate_attempt_update;
|
|
DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_insert;
|
|
DROP TRIGGER IF EXISTS service_handoffs_validate_attempt_update;
|
|
`);
|
|
}
|
|
|
|
function applyPairedTurnAttemptProvenanceConstraints(database: Database): void {
|
|
database.exec(`
|
|
CREATE TRIGGER IF NOT EXISTS paired_turn_attempts_validate_insert
|
|
BEFORE INSERT ON paired_turn_attempts
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts attempt_id must match turn_id/attempt_no')
|
|
WHERE NEW.attempt_id IS NULL
|
|
OR NEW.attempt_id != ${buildPairedTurnAttemptIdSql('NEW.turn_id', 'NEW.attempt_no')};
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_attempt_id')
|
|
WHERE NEW.attempt_no <= 1
|
|
AND NEW.parent_attempt_id IS NOT NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts must preserve contiguous parent lineage')
|
|
WHERE NEW.attempt_no > 1
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE previous_attempt.turn_id = NEW.turn_id
|
|
AND previous_attempt.attempt_no = NEW.attempt_no - 1
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts must keep parent_attempt_id lineage')
|
|
WHERE NEW.attempt_no > 1
|
|
AND (
|
|
NEW.parent_attempt_id IS NULL
|
|
OR NEW.parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('NEW.turn_id', 'NEW.attempt_no')}
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts parent_attempt_id must point to the previous attempt of the same turn')
|
|
WHERE NEW.parent_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE COALESCE(
|
|
previous_attempt.attempt_id,
|
|
${buildPairedTurnAttemptIdSql(
|
|
'previous_attempt.turn_id',
|
|
'previous_attempt.attempt_no',
|
|
)}
|
|
) = NEW.parent_attempt_id
|
|
AND previous_attempt.turn_id = NEW.turn_id
|
|
AND previous_attempt.attempt_no = NEW.attempt_no - 1
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_handoff_id')
|
|
WHERE NEW.attempt_no <= 1
|
|
AND NEW.parent_handoff_id IS NOT NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts parent_handoff_id must reference the previous attempt handoff of the same turn')
|
|
WHERE NEW.parent_handoff_id IS NOT NULL
|
|
AND NOT ${buildPairedTurnAttemptParentHandoffMatchSql({
|
|
parentHandoffIdExpr: 'NEW.parent_handoff_id',
|
|
turnIdExpr: 'NEW.turn_id',
|
|
parentAttemptIdExpr: 'NEW.parent_attempt_id',
|
|
attemptNoExpr: 'NEW.attempt_no',
|
|
})};
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts continuation_handoff_id must reference a handoff of the same attempt')
|
|
WHERE NEW.continuation_handoff_id IS NOT NULL
|
|
AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({
|
|
continuationHandoffIdExpr: 'NEW.continuation_handoff_id',
|
|
turnIdExpr: 'NEW.turn_id',
|
|
attemptIdExpr: 'NEW.attempt_id',
|
|
attemptNoExpr: 'NEW.attempt_no',
|
|
})};
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts running attempts must declare active_run_id')
|
|
WHERE NEW.state = 'running'
|
|
AND (
|
|
NEW.active_run_id IS NULL
|
|
OR TRIM(NEW.active_run_id) = ''
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts only running attempts may declare active_run_id')
|
|
WHERE NEW.state != 'running'
|
|
AND NEW.active_run_id IS NOT NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts must reference a matching paired_turns row')
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turns
|
|
WHERE paired_turns.turn_id = NEW.turn_id
|
|
AND paired_turns.task_id = NEW.task_id
|
|
AND paired_turns.task_updated_at = NEW.task_updated_at
|
|
AND paired_turns.role = NEW.role
|
|
AND paired_turns.intent_kind = NEW.intent_kind
|
|
);
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS paired_turn_attempts_validate_update
|
|
BEFORE UPDATE OF attempt_id, parent_attempt_id, parent_handoff_id, continuation_handoff_id, turn_id, attempt_no, task_id, task_updated_at, role, intent_kind, state, executor_service_id, executor_agent_type, active_run_id
|
|
ON paired_turn_attempts
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts attempt_id must match turn_id/attempt_no')
|
|
WHERE NEW.attempt_id IS NULL
|
|
OR NEW.attempt_id != ${buildPairedTurnAttemptIdSql('NEW.turn_id', 'NEW.attempt_no')};
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_attempt_id')
|
|
WHERE NEW.attempt_no <= 1
|
|
AND NEW.parent_attempt_id IS NOT NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts must preserve contiguous parent lineage')
|
|
WHERE NEW.attempt_no > 1
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE previous_attempt.turn_id = NEW.turn_id
|
|
AND previous_attempt.attempt_no = NEW.attempt_no - 1
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts must keep parent_attempt_id lineage')
|
|
WHERE NEW.attempt_no > 1
|
|
AND (
|
|
NEW.parent_attempt_id IS NULL
|
|
OR NEW.parent_attempt_id != ${buildPairedTurnAttemptParentIdSql('NEW.turn_id', 'NEW.attempt_no')}
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts parent_attempt_id must point to the previous attempt of the same turn')
|
|
WHERE NEW.parent_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts previous_attempt
|
|
WHERE COALESCE(
|
|
previous_attempt.attempt_id,
|
|
${buildPairedTurnAttemptIdSql(
|
|
'previous_attempt.turn_id',
|
|
'previous_attempt.attempt_no',
|
|
)}
|
|
) = NEW.parent_attempt_id
|
|
AND previous_attempt.turn_id = NEW.turn_id
|
|
AND previous_attempt.attempt_no = NEW.attempt_no - 1
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts attempt 1 cannot declare parent_handoff_id')
|
|
WHERE NEW.attempt_no <= 1
|
|
AND NEW.parent_handoff_id IS NOT NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts parent_handoff_id must reference the previous attempt handoff of the same turn')
|
|
WHERE NEW.parent_handoff_id IS NOT NULL
|
|
AND NOT ${buildPairedTurnAttemptParentHandoffMatchSql({
|
|
parentHandoffIdExpr: 'NEW.parent_handoff_id',
|
|
turnIdExpr: 'NEW.turn_id',
|
|
parentAttemptIdExpr: 'NEW.parent_attempt_id',
|
|
attemptNoExpr: 'NEW.attempt_no',
|
|
})};
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts continuation_handoff_id must reference a handoff of the same attempt')
|
|
WHERE NEW.continuation_handoff_id IS NOT NULL
|
|
AND NOT ${buildPairedTurnAttemptContinuationHandoffMatchSql({
|
|
continuationHandoffIdExpr: 'NEW.continuation_handoff_id',
|
|
turnIdExpr: 'NEW.turn_id',
|
|
attemptIdExpr: 'NEW.attempt_id',
|
|
attemptNoExpr: 'NEW.attempt_no',
|
|
})};
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts running attempts must declare active_run_id')
|
|
WHERE NEW.state = 'running'
|
|
AND (
|
|
NEW.active_run_id IS NULL
|
|
OR TRIM(NEW.active_run_id) = ''
|
|
);
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts only running attempts may declare active_run_id')
|
|
WHERE NEW.state != 'running'
|
|
AND NEW.active_run_id IS NOT NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_attempts must reference a matching paired_turns row')
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turns
|
|
WHERE paired_turns.turn_id = NEW.turn_id
|
|
AND paired_turns.task_id = NEW.task_id
|
|
AND paired_turns.task_updated_at = NEW.task_updated_at
|
|
AND paired_turns.role = NEW.role
|
|
AND paired_turns.intent_kind = NEW.intent_kind
|
|
);
|
|
END;
|
|
`);
|
|
|
|
if (tableHasColumn(database, 'paired_turn_reservations', 'turn_attempt_no')) {
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_turn_attempt
|
|
ON paired_turn_reservations(turn_id, turn_attempt_no);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_reservations_turn_attempt_id
|
|
ON paired_turn_reservations(turn_attempt_id);
|
|
CREATE TRIGGER IF NOT EXISTS paired_turn_reservations_validate_attempt_insert
|
|
BEFORE INSERT ON paired_turn_reservations
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt_no requires turn_id')
|
|
WHERE NEW.turn_attempt_no IS NOT NULL
|
|
AND NEW.turn_id IS NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt provenance must keep attempt_id and attempt_no in sync')
|
|
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
|
|
SELECT RAISE(ABORT, 'paired_turn_reservations turn_attempt_no must reference a matching paired_turn_attempts row')
|
|
WHERE NEW.turn_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
|
|
AND paired_turn_attempts.turn_id = NEW.turn_id
|
|
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
|
|
AND paired_turn_attempts.task_id = NEW.task_id
|
|
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
|
|
AND paired_turn_attempts.role = NEW.turn_role
|
|
AND paired_turn_attempts.intent_kind = NEW.intent_kind
|
|
);
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS paired_turn_reservations_validate_attempt_update
|
|
BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, task_id, task_updated_at, turn_role, intent_kind
|
|
ON paired_turn_reservations
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt_no requires turn_id')
|
|
WHERE NEW.turn_attempt_no IS NOT NULL
|
|
AND NEW.turn_id IS NULL;
|
|
SELECT RAISE(ABORT, 'paired_turn_reservations.turn_attempt provenance must keep attempt_id and attempt_no in sync')
|
|
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
|
|
SELECT RAISE(ABORT, 'paired_turn_reservations turn_attempt_no must reference a matching paired_turn_attempts row')
|
|
WHERE NEW.turn_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
|
|
AND paired_turn_attempts.turn_id = NEW.turn_id
|
|
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
|
|
AND paired_turn_attempts.task_id = NEW.task_id
|
|
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
|
|
AND paired_turn_attempts.role = NEW.turn_role
|
|
AND paired_turn_attempts.intent_kind = NEW.intent_kind
|
|
);
|
|
END;
|
|
`);
|
|
}
|
|
|
|
if (
|
|
tableHasColumn(database, 'paired_task_execution_leases', 'turn_attempt_no')
|
|
) {
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_turn_attempt
|
|
ON paired_task_execution_leases(turn_id, turn_attempt_no);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_turn_attempt_id
|
|
ON paired_task_execution_leases(turn_attempt_id);
|
|
CREATE TRIGGER IF NOT EXISTS paired_task_execution_leases_validate_attempt_insert
|
|
BEFORE INSERT ON paired_task_execution_leases
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'paired_task_execution_leases.turn_attempt_no requires turn_id')
|
|
WHERE NEW.turn_attempt_no IS NOT NULL
|
|
AND NEW.turn_id IS NULL;
|
|
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt provenance must keep attempt_id and attempt_no in sync')
|
|
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
|
|
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt_no must reference a matching paired_turn_attempts row')
|
|
WHERE NEW.turn_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
|
|
AND paired_turn_attempts.turn_id = NEW.turn_id
|
|
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
|
|
AND paired_turn_attempts.task_id = NEW.task_id
|
|
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
|
|
AND paired_turn_attempts.role = NEW.role
|
|
AND paired_turn_attempts.intent_kind = NEW.intent_kind
|
|
);
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS paired_task_execution_leases_validate_attempt_update
|
|
BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, task_id, task_updated_at, role, intent_kind
|
|
ON paired_task_execution_leases
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'paired_task_execution_leases.turn_attempt_no requires turn_id')
|
|
WHERE NEW.turn_attempt_no IS NOT NULL
|
|
AND NEW.turn_id IS NULL;
|
|
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt provenance must keep attempt_id and attempt_no in sync')
|
|
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
|
|
SELECT RAISE(ABORT, 'paired_task_execution_leases turn_attempt_no must reference a matching paired_turn_attempts row')
|
|
WHERE NEW.turn_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
|
|
AND paired_turn_attempts.turn_id = NEW.turn_id
|
|
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
|
|
AND paired_turn_attempts.task_id = NEW.task_id
|
|
AND paired_turn_attempts.task_updated_at = NEW.task_updated_at
|
|
AND paired_turn_attempts.role = NEW.role
|
|
AND paired_turn_attempts.intent_kind = NEW.intent_kind
|
|
);
|
|
END;
|
|
`);
|
|
}
|
|
|
|
if (
|
|
tableHasColumn(database, 'service_handoffs', 'turn_attempt_no') &&
|
|
tableHasColumn(database, 'service_handoffs', 'paired_task_id') &&
|
|
tableHasColumn(database, 'service_handoffs', 'paired_task_updated_at') &&
|
|
tableHasColumn(database, 'service_handoffs', 'turn_role') &&
|
|
tableHasColumn(database, 'service_handoffs', 'turn_intent_kind')
|
|
) {
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_service_handoffs_turn_attempt
|
|
ON service_handoffs(turn_id, turn_attempt_no);
|
|
CREATE INDEX IF NOT EXISTS idx_service_handoffs_turn_attempt_id
|
|
ON service_handoffs(turn_attempt_id);
|
|
CREATE TRIGGER IF NOT EXISTS service_handoffs_validate_attempt_insert
|
|
BEFORE INSERT ON service_handoffs
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'service_handoffs.turn_attempt_no requires paired turn identity')
|
|
WHERE NEW.turn_attempt_no IS NOT NULL
|
|
AND (
|
|
NEW.turn_id IS NULL
|
|
OR NEW.paired_task_id IS NULL
|
|
OR NEW.paired_task_updated_at IS NULL
|
|
OR NEW.turn_role IS NULL
|
|
OR NEW.turn_intent_kind IS NULL
|
|
);
|
|
SELECT RAISE(ABORT, 'service_handoffs turn_attempt provenance must keep attempt_id and attempt_no in sync')
|
|
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
|
|
SELECT RAISE(ABORT, 'service_handoffs turn_attempt_no must reference a matching paired_turn_attempts row')
|
|
WHERE NEW.turn_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
|
|
AND paired_turn_attempts.turn_id = NEW.turn_id
|
|
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
|
|
AND paired_turn_attempts.task_id = NEW.paired_task_id
|
|
AND paired_turn_attempts.task_updated_at = NEW.paired_task_updated_at
|
|
AND paired_turn_attempts.role = NEW.turn_role
|
|
AND paired_turn_attempts.intent_kind = NEW.turn_intent_kind
|
|
);
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS service_handoffs_validate_attempt_update
|
|
BEFORE UPDATE OF turn_id, turn_attempt_id, turn_attempt_no, paired_task_id, paired_task_updated_at, turn_role, turn_intent_kind
|
|
ON service_handoffs
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'service_handoffs.turn_attempt_no requires paired turn identity')
|
|
WHERE NEW.turn_attempt_no IS NOT NULL
|
|
AND (
|
|
NEW.turn_id IS NULL
|
|
OR NEW.paired_task_id IS NULL
|
|
OR NEW.paired_task_updated_at IS NULL
|
|
OR NEW.turn_role IS NULL
|
|
OR NEW.turn_intent_kind IS NULL
|
|
);
|
|
SELECT RAISE(ABORT, 'service_handoffs turn_attempt provenance must keep attempt_id and attempt_no in sync')
|
|
WHERE (NEW.turn_attempt_id IS NULL) != (NEW.turn_attempt_no IS NULL);
|
|
SELECT RAISE(ABORT, 'service_handoffs turn_attempt_no must reference a matching paired_turn_attempts row')
|
|
WHERE NEW.turn_attempt_id IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM paired_turn_attempts
|
|
WHERE paired_turn_attempts.attempt_id = NEW.turn_attempt_id
|
|
AND paired_turn_attempts.turn_id = NEW.turn_id
|
|
AND paired_turn_attempts.attempt_no = NEW.turn_attempt_no
|
|
AND paired_turn_attempts.task_id = NEW.paired_task_id
|
|
AND paired_turn_attempts.task_updated_at = NEW.paired_task_updated_at
|
|
AND paired_turn_attempts.role = NEW.turn_role
|
|
AND paired_turn_attempts.intent_kind = NEW.turn_intent_kind
|
|
);
|
|
END;
|
|
`);
|
|
}
|
|
}
|
|
|
|
interface StoredWorkItemServiceRow {
|
|
id: number;
|
|
agent_type?: string | null;
|
|
service_id?: string | null;
|
|
delivery_role?: string | null;
|
|
}
|
|
|
|
function normalizePairedRole(
|
|
role: string | null | undefined,
|
|
): 'owner' | 'reviewer' | 'arbiter' | null {
|
|
return role === 'owner' || role === 'reviewer' || role === 'arbiter'
|
|
? role
|
|
: null;
|
|
}
|
|
|
|
function inferLegacyExecutionLeaseServiceId(
|
|
row: LegacyExecutionLeaseServiceRow,
|
|
): string | null {
|
|
return inferExecutionLeaseServiceIdFromCanonicalMetadata(row);
|
|
}
|
|
|
|
function backfillLegacyExecutionLeaseServiceShadows(database: Database): void {
|
|
if (
|
|
!tableHasColumn(
|
|
database,
|
|
'paired_task_execution_leases',
|
|
'claimed_service_id',
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const pairedTasksTable = database
|
|
.prepare(
|
|
`
|
|
SELECT 1
|
|
FROM sqlite_master
|
|
WHERE type = 'table'
|
|
AND name = 'paired_tasks'
|
|
`,
|
|
)
|
|
.get();
|
|
if (!pairedTasksTable) {
|
|
return;
|
|
}
|
|
|
|
const pairedTaskColumns = getTableColumns(database, 'paired_tasks');
|
|
const selectPairedTaskColumn = (columnName: string): string =>
|
|
pairedTaskColumns.includes(columnName)
|
|
? `paired_tasks.${columnName} AS ${columnName}`
|
|
: `NULL AS ${columnName}`;
|
|
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
paired_task_execution_leases.rowid AS rowid,
|
|
paired_task_execution_leases.role AS role,
|
|
${selectPairedTaskColumn('owner_service_id')},
|
|
${selectPairedTaskColumn('reviewer_service_id')},
|
|
${selectPairedTaskColumn('arbiter_service_id')},
|
|
${selectPairedTaskColumn('owner_agent_type')},
|
|
${selectPairedTaskColumn('reviewer_agent_type')},
|
|
${selectPairedTaskColumn('arbiter_agent_type')}
|
|
FROM paired_task_execution_leases
|
|
LEFT JOIN paired_tasks
|
|
ON paired_tasks.id = paired_task_execution_leases.task_id
|
|
WHERE paired_task_execution_leases.claimed_service_id IS NULL
|
|
`,
|
|
)
|
|
.all() as LegacyExecutionLeaseServiceRow[];
|
|
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const update = database.prepare(
|
|
`
|
|
UPDATE paired_task_execution_leases
|
|
SET claimed_service_id = ?
|
|
WHERE rowid = ?
|
|
AND claimed_service_id IS NULL
|
|
`,
|
|
);
|
|
const tx = database.transaction(
|
|
(leaseRows: LegacyExecutionLeaseServiceRow[]) => {
|
|
for (const row of leaseRows) {
|
|
const claimedServiceId = inferLegacyExecutionLeaseServiceId(row);
|
|
if (!claimedServiceId) {
|
|
continue;
|
|
}
|
|
update.run(claimedServiceId, row.rowid);
|
|
}
|
|
},
|
|
);
|
|
|
|
tx(rows);
|
|
}
|
|
|
|
function backfillCanonicalPairedTaskServiceIds(database: Database): void {
|
|
if (
|
|
!tableHasColumn(database, 'paired_tasks', 'owner_service_id') ||
|
|
!tableHasColumn(database, 'paired_tasks', 'reviewer_service_id')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
rowid,
|
|
id,
|
|
chat_jid,
|
|
group_folder,
|
|
owner_service_id,
|
|
reviewer_service_id,
|
|
owner_agent_type,
|
|
reviewer_agent_type,
|
|
arbiter_agent_type
|
|
FROM paired_tasks
|
|
`,
|
|
)
|
|
.all() as StoredPairedTaskServiceRow[];
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const update = database.prepare(
|
|
`
|
|
UPDATE paired_tasks
|
|
SET owner_service_id = ?,
|
|
reviewer_service_id = ?,
|
|
owner_agent_type = ?,
|
|
reviewer_agent_type = ?,
|
|
arbiter_agent_type = ?
|
|
WHERE rowid = ?
|
|
`,
|
|
);
|
|
const tx = database.transaction((taskRows: StoredPairedTaskServiceRow[]) => {
|
|
for (const row of taskRows) {
|
|
const {
|
|
ownerAgentType,
|
|
reviewerAgentType,
|
|
arbiterAgentType,
|
|
ownerServiceId,
|
|
reviewerServiceId,
|
|
} = canonicalizePairedTaskMetadata({
|
|
id: row.id,
|
|
owner_service_id: row.owner_service_id,
|
|
reviewer_service_id: row.reviewer_service_id,
|
|
owner_agent_type: row.owner_agent_type,
|
|
reviewer_agent_type: row.reviewer_agent_type,
|
|
arbiter_agent_type: row.arbiter_agent_type,
|
|
});
|
|
|
|
if (
|
|
row.owner_service_id === ownerServiceId &&
|
|
row.reviewer_service_id === reviewerServiceId &&
|
|
row.owner_agent_type === ownerAgentType &&
|
|
row.reviewer_agent_type === reviewerAgentType &&
|
|
row.arbiter_agent_type === arbiterAgentType
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
update.run(
|
|
ownerServiceId,
|
|
reviewerServiceId,
|
|
ownerAgentType,
|
|
reviewerAgentType,
|
|
arbiterAgentType,
|
|
row.rowid,
|
|
);
|
|
}
|
|
});
|
|
|
|
tx(rows);
|
|
}
|
|
|
|
function backfillCanonicalChannelOwnerServiceIds(database: Database): void {
|
|
if (!tableHasColumn(database, 'channel_owner', 'owner_service_id')) {
|
|
return;
|
|
}
|
|
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
rowid,
|
|
chat_jid,
|
|
owner_service_id,
|
|
reviewer_service_id,
|
|
arbiter_service_id,
|
|
owner_agent_type,
|
|
reviewer_agent_type,
|
|
arbiter_agent_type
|
|
FROM channel_owner
|
|
`,
|
|
)
|
|
.all() as StoredChannelOwnerLeaseServiceRow[];
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const update = database.prepare(
|
|
`
|
|
UPDATE channel_owner
|
|
SET owner_service_id = ?,
|
|
reviewer_service_id = ?,
|
|
arbiter_service_id = ?,
|
|
owner_agent_type = ?,
|
|
reviewer_agent_type = ?,
|
|
arbiter_agent_type = ?
|
|
WHERE rowid = ?
|
|
`,
|
|
);
|
|
const tx = database.transaction(
|
|
(leaseRows: StoredChannelOwnerLeaseServiceRow[]) => {
|
|
for (const row of leaseRows) {
|
|
const {
|
|
ownerAgentType,
|
|
reviewerAgentType,
|
|
arbiterAgentType,
|
|
ownerServiceId,
|
|
reviewerServiceId,
|
|
arbiterServiceId,
|
|
} = canonicalizeChannelOwnerLeaseMetadata({
|
|
chat_jid: row.chat_jid,
|
|
owner_service_id: row.owner_service_id,
|
|
reviewer_service_id: row.reviewer_service_id,
|
|
arbiter_service_id: row.arbiter_service_id,
|
|
owner_agent_type: row.owner_agent_type,
|
|
reviewer_agent_type: row.reviewer_agent_type,
|
|
arbiter_agent_type: row.arbiter_agent_type,
|
|
});
|
|
|
|
if (
|
|
row.owner_service_id === ownerServiceId &&
|
|
row.reviewer_service_id === reviewerServiceId &&
|
|
row.arbiter_service_id === arbiterServiceId &&
|
|
row.owner_agent_type === ownerAgentType &&
|
|
row.reviewer_agent_type === reviewerAgentType &&
|
|
row.arbiter_agent_type === arbiterAgentType
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
update.run(
|
|
ownerServiceId,
|
|
reviewerServiceId,
|
|
arbiterServiceId,
|
|
ownerAgentType,
|
|
reviewerAgentType,
|
|
arbiterAgentType,
|
|
row.rowid,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
tx(rows);
|
|
}
|
|
|
|
function backfillCanonicalServiceHandoffServiceIds(database: Database): void {
|
|
if (!tableHasColumn(database, 'service_handoffs', 'source_service_id')) {
|
|
return;
|
|
}
|
|
|
|
const rows = database
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
id,
|
|
chat_jid,
|
|
group_folder,
|
|
source_service_id,
|
|
target_service_id,
|
|
source_role,
|
|
target_role,
|
|
source_agent_type,
|
|
target_agent_type,
|
|
intended_role
|
|
FROM service_handoffs
|
|
`,
|
|
)
|
|
.all() as StoredServiceHandoffServiceRow[];
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const update = database.prepare(
|
|
`
|
|
UPDATE service_handoffs
|
|
SET source_service_id = ?,
|
|
target_service_id = ?,
|
|
source_agent_type = ?,
|
|
target_agent_type = ?,
|
|
source_role = ?,
|
|
target_role = ?
|
|
WHERE id = ?
|
|
`,
|
|
);
|
|
const tx = database.transaction(
|
|
(handoffRows: StoredServiceHandoffServiceRow[]) => {
|
|
for (const row of handoffRows) {
|
|
const {
|
|
sourceRole,
|
|
targetRole,
|
|
sourceAgentType,
|
|
targetAgentType,
|
|
sourceServiceId,
|
|
targetServiceId,
|
|
} = canonicalizeServiceHandoffMetadata({
|
|
id: row.id,
|
|
chat_jid: row.chat_jid,
|
|
source_service_id: row.source_service_id,
|
|
target_service_id: row.target_service_id,
|
|
source_role: row.source_role,
|
|
target_role: row.target_role,
|
|
intended_role: row.intended_role,
|
|
source_agent_type: row.source_agent_type,
|
|
target_agent_type: row.target_agent_type,
|
|
});
|
|
|
|
if (
|
|
row.source_service_id === sourceServiceId &&
|
|
row.target_service_id === targetServiceId &&
|
|
row.source_agent_type === (sourceAgentType ?? null) &&
|
|
row.target_agent_type === targetAgentType &&
|
|
row.source_role === sourceRole &&
|
|
row.target_role === targetRole
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
update.run(
|
|
sourceServiceId,
|
|
targetServiceId,
|
|
sourceAgentType,
|
|
targetAgentType,
|
|
sourceRole,
|
|
targetRole,
|
|
row.id,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
tx(rows);
|
|
}
|
|
|
|
function backfillCanonicalWorkItemServiceIds(database: Database): void {
|
|
if (!tableHasColumn(database, 'work_items', 'service_id')) {
|
|
return;
|
|
}
|
|
|
|
const rows = database
|
|
.prepare(
|
|
`SELECT id, agent_type, service_id, delivery_role
|
|
FROM work_items`,
|
|
)
|
|
.all() as StoredWorkItemServiceRow[];
|
|
if (rows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const update = database.prepare(
|
|
`UPDATE work_items
|
|
SET service_id = ?
|
|
WHERE id = ?`,
|
|
);
|
|
const tx = database.transaction(
|
|
(workItemRows: StoredWorkItemServiceRow[]) => {
|
|
for (const row of workItemRows) {
|
|
const agentType =
|
|
normalizeStoredAgentType(row.agent_type) ?? 'claude-code';
|
|
const deliveryRole = normalizePairedRole(row.delivery_role) ?? 'owner';
|
|
const serviceId =
|
|
(row.service_id ? normalizeServiceId(row.service_id) : null) ??
|
|
resolveRoleServiceShadow(deliveryRole, agentType) ??
|
|
SERVICE_SESSION_SCOPE;
|
|
|
|
if (row.service_id === serviceId) {
|
|
continue;
|
|
}
|
|
|
|
update.run(serviceId, row.id);
|
|
}
|
|
},
|
|
);
|
|
|
|
tx(rows);
|
|
}
|
|
|
|
export function applySchemaMigrations(
|
|
database: Database,
|
|
args: {
|
|
assistantName: string;
|
|
},
|
|
): void {
|
|
const { assistantName } = args;
|
|
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN agent_type TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN ci_provider TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN ci_metadata TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN max_duration_ms INTEGER`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN status_message_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN status_started_at TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE scheduled_tasks ADD COLUMN suspended_until TEXT`,
|
|
);
|
|
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN mode_source TEXT NOT NULL DEFAULT 'explicit'`,
|
|
);
|
|
tryExecMigration(database, `ALTER TABLE room_settings ADD COLUMN name TEXT`);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN folder TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN trigger_pattern TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN requires_trigger INTEGER DEFAULT 1`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN is_main INTEGER DEFAULT 0`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN owner_agent_type TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN work_dir TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE room_settings ADD COLUMN created_at TEXT`,
|
|
);
|
|
if (tableHasColumn(database, 'room_settings', 'created_at')) {
|
|
database.exec(`
|
|
UPDATE room_settings
|
|
SET created_at = COALESCE(created_at, updated_at, CURRENT_TIMESTAMP)
|
|
`);
|
|
}
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS room_role_overrides (
|
|
chat_jid TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
agent_type TEXT NOT NULL,
|
|
agent_config_json TEXT,
|
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (chat_jid, role),
|
|
CHECK (role IN ('owner', 'reviewer', 'arbiter')),
|
|
CHECK (agent_type IN ('claude-code', 'codex'))
|
|
)
|
|
`);
|
|
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN source_role TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN target_role TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN source_agent_type TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN source_service_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN target_service_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN paired_task_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN paired_task_updated_at TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN turn_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN turn_attempt_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN turn_attempt_no INTEGER`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN turn_intent_kind TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE service_handoffs ADD COLUMN turn_role TEXT`,
|
|
);
|
|
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_tasks ADD COLUMN owner_agent_type TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_tasks ADD COLUMN reviewer_agent_type TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_tasks ADD COLUMN arbiter_agent_type TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_tasks ADD COLUMN owner_service_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_tasks ADD COLUMN reviewer_service_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_task_execution_leases ADD COLUMN expires_at TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_task_execution_leases ADD COLUMN claimed_service_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_task_execution_leases ADD COLUMN turn_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_task_execution_leases ADD COLUMN turn_attempt_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_task_execution_leases ADD COLUMN turn_attempt_no INTEGER`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_attempts ADD COLUMN attempt_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_attempts ADD COLUMN parent_attempt_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_attempts ADD COLUMN parent_handoff_id INTEGER`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_attempts ADD COLUMN continuation_handoff_id INTEGER`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_attempts ADD COLUMN active_run_id TEXT`,
|
|
);
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_parent_handoff_id
|
|
ON paired_turn_attempts(parent_handoff_id)
|
|
`);
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_paired_turn_attempts_continuation_handoff_id
|
|
ON paired_turn_attempts(continuation_handoff_id)
|
|
`);
|
|
database.exec(`
|
|
UPDATE paired_task_execution_leases
|
|
SET expires_at = COALESCE(
|
|
expires_at,
|
|
strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+10 minutes')
|
|
)
|
|
`);
|
|
// Legacy lease rows predate claimed_service_id. Infer the original service
|
|
// from paired-task role metadata when possible instead of blanket-marking
|
|
// everything as the current service, which would let startup cleanup delete
|
|
// leases that belong to another runtime.
|
|
backfillLegacyExecutionLeaseServiceShadows(database);
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_paired_task_execution_leases_expires_at
|
|
ON paired_task_execution_leases(expires_at)
|
|
`);
|
|
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE work_items ADD COLUMN delivery_role TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE work_items ADD COLUMN service_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_attempt_id TEXT`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_attempt_no INTEGER`,
|
|
);
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_turn_reservations ADD COLUMN turn_role TEXT`,
|
|
);
|
|
|
|
database.exec(`
|
|
UPDATE paired_turn_reservations
|
|
SET turn_id = COALESCE(
|
|
turn_id,
|
|
task_id || ':' || task_updated_at || ':' || intent_kind
|
|
)
|
|
`);
|
|
database.exec(`
|
|
UPDATE paired_turn_reservations
|
|
SET turn_role = COALESCE(
|
|
turn_role,
|
|
CASE
|
|
WHEN intent_kind = 'reviewer-turn' THEN 'reviewer'
|
|
WHEN intent_kind = 'arbiter-turn' THEN 'arbiter'
|
|
ELSE 'owner'
|
|
END
|
|
)
|
|
`);
|
|
database.exec(`
|
|
UPDATE paired_task_execution_leases
|
|
SET turn_id = COALESCE(
|
|
turn_id,
|
|
task_id || ':' || task_updated_at || ':' || intent_kind
|
|
)
|
|
`);
|
|
database.exec(
|
|
`UPDATE service_handoffs
|
|
SET target_role = COALESCE(
|
|
target_role,
|
|
intended_role,
|
|
CASE
|
|
WHEN reason LIKE 'reviewer-%' THEN 'reviewer'
|
|
WHEN reason LIKE 'arbiter-%' THEN 'arbiter'
|
|
WHEN reason IS NOT NULL THEN 'owner'
|
|
ELSE NULL
|
|
END
|
|
)
|
|
WHERE target_role IS NULL`,
|
|
);
|
|
|
|
database.exec(
|
|
`UPDATE service_handoffs
|
|
SET source_role = COALESCE(source_role, target_role, intended_role)
|
|
WHERE source_role IS NULL`,
|
|
);
|
|
database.exec(
|
|
`UPDATE service_handoffs
|
|
SET turn_role = COALESCE(turn_role, target_role, intended_role)
|
|
WHERE turn_role IS NULL`,
|
|
);
|
|
database.exec(
|
|
`UPDATE service_handoffs
|
|
SET turn_intent_kind = COALESCE(
|
|
turn_intent_kind,
|
|
CASE
|
|
WHEN turn_role = 'reviewer' THEN 'reviewer-turn'
|
|
WHEN turn_role = 'arbiter' THEN 'arbiter-turn'
|
|
ELSE turn_intent_kind
|
|
END
|
|
)`,
|
|
);
|
|
database.exec(
|
|
`UPDATE service_handoffs
|
|
SET turn_id = COALESCE(
|
|
turn_id,
|
|
CASE
|
|
WHEN paired_task_id IS NOT NULL
|
|
AND paired_task_updated_at IS NOT NULL
|
|
AND turn_intent_kind IS NOT NULL
|
|
THEN paired_task_id || ':' || paired_task_updated_at || ':' || turn_intent_kind
|
|
ELSE turn_id
|
|
END
|
|
)`,
|
|
);
|
|
|
|
backfillPairedTurnAttemptsFromTurns(database);
|
|
backfillPairedTurnAttemptIds(database);
|
|
backfillPairedTurnAttemptParentIds(database);
|
|
backfillPairedTurnAttemptActiveRunIds(database);
|
|
backfillPairedTurnAttemptProvenance(database);
|
|
backfillPairedTurnAttemptEntityIds(database);
|
|
assertPairedTurnAttemptProvenanceIntegrity(database);
|
|
dropPairedTurnAttemptProvenanceConstraints(database);
|
|
rebuildPairedTurnsWithoutLegacyScratchColumns(database);
|
|
rebuildPairedTurnAttemptForeignKeyTables(database);
|
|
applyPairedTurnAttemptProvenanceConstraints(database);
|
|
|
|
for (const column of [
|
|
'owner_service_id',
|
|
'reviewer_service_id',
|
|
'arbiter_service_id',
|
|
'owner_agent_type',
|
|
'reviewer_agent_type',
|
|
'arbiter_agent_type',
|
|
]) {
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE channel_owner ADD COLUMN ${column} TEXT`,
|
|
);
|
|
}
|
|
|
|
backfillCanonicalPairedTaskServiceIds(database);
|
|
backfillCanonicalChannelOwnerServiceIds(database);
|
|
backfillCanonicalServiceHandoffServiceIds(database);
|
|
backfillCanonicalWorkItemServiceIds(database);
|
|
|
|
database.exec(
|
|
`UPDATE room_settings
|
|
SET mode_source = 'explicit'
|
|
WHERE COALESCE(mode_source, '') NOT IN ('explicit', 'inferred')`,
|
|
);
|
|
|
|
database.exec(`
|
|
UPDATE scheduled_tasks
|
|
SET agent_type = COALESCE(
|
|
(
|
|
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
|
|
FROM registered_groups
|
|
WHERE jid = scheduled_tasks.chat_jid
|
|
AND folder = scheduled_tasks.group_folder
|
|
),
|
|
(
|
|
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
|
|
FROM registered_groups
|
|
WHERE jid = scheduled_tasks.chat_jid
|
|
),
|
|
(
|
|
SELECT CASE WHEN COUNT(*) = 1 THEN MIN(agent_type) ELSE NULL END
|
|
FROM registered_groups
|
|
WHERE folder = scheduled_tasks.group_folder
|
|
)
|
|
)
|
|
WHERE agent_type IS NULL;
|
|
`);
|
|
|
|
try {
|
|
database.exec(
|
|
`ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`,
|
|
);
|
|
database
|
|
.prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`)
|
|
.run(`${assistantName}:%`);
|
|
} catch {
|
|
/* column already exists */
|
|
}
|
|
|
|
tryExecMigration(database, `ALTER TABLE messages ADD COLUMN seq INTEGER`);
|
|
|
|
backfillMessageSeq(database);
|
|
|
|
database.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_jid_seq ON messages(chat_jid, seq);
|
|
`);
|
|
database.exec(`DROP INDEX IF EXISTS idx_work_items_group_agent;`);
|
|
database.exec(`DROP INDEX IF EXISTS idx_work_items_open;`);
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_work_items_group_agent
|
|
ON work_items(chat_jid, agent_type, service_id, delivery_role, status);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_items_open
|
|
ON work_items(chat_jid, agent_type, IFNULL(service_id, ''), IFNULL(delivery_role, ''))
|
|
WHERE status IN ('produced', 'delivery_retry');
|
|
`);
|
|
|
|
const roomBindingsSql = (
|
|
database
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
|
|
)
|
|
.get() as { sql?: string } | undefined
|
|
)?.sql;
|
|
if (
|
|
roomBindingsSql &&
|
|
!roomBindingsSql.includes('PRIMARY KEY (jid, agent_type)')
|
|
) {
|
|
const registeredGroupCols = database
|
|
.prepare('PRAGMA table_info(registered_groups)')
|
|
.all() as Array<{ name: string }>;
|
|
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
|
|
const hasAgentType = registeredGroupCols.some(
|
|
(col) => col.name === 'agent_type',
|
|
);
|
|
const hasWorkDir = registeredGroupCols.some(
|
|
(col) => col.name === 'work_dir',
|
|
);
|
|
const hasAgentConfig = registeredGroupCols.some(
|
|
(col) => col.name === 'agent_config',
|
|
);
|
|
const hasContainerConfig = registeredGroupCols.some(
|
|
(col) => col.name === 'container_config',
|
|
);
|
|
|
|
database.exec(`
|
|
CREATE TABLE registered_groups_new (
|
|
jid TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
folder TEXT NOT NULL,
|
|
trigger_pattern TEXT NOT NULL,
|
|
added_at TEXT NOT NULL,
|
|
agent_config TEXT,
|
|
requires_trigger INTEGER DEFAULT 1,
|
|
is_main INTEGER DEFAULT 0,
|
|
agent_type TEXT NOT NULL DEFAULT 'claude-code',
|
|
work_dir TEXT,
|
|
PRIMARY KEY (jid, agent_type),
|
|
UNIQUE (folder, agent_type)
|
|
);
|
|
`);
|
|
|
|
database.exec(`
|
|
INSERT INTO registered_groups_new (
|
|
jid,
|
|
name,
|
|
folder,
|
|
trigger_pattern,
|
|
added_at,
|
|
agent_config,
|
|
requires_trigger,
|
|
is_main,
|
|
agent_type,
|
|
work_dir
|
|
)
|
|
SELECT
|
|
jid,
|
|
name,
|
|
folder,
|
|
trigger_pattern,
|
|
added_at,
|
|
${
|
|
hasAgentConfig
|
|
? 'agent_config'
|
|
: hasContainerConfig
|
|
? 'container_config'
|
|
: 'NULL'
|
|
},
|
|
requires_trigger,
|
|
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
|
|
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
|
|
${hasWorkDir ? 'work_dir' : 'NULL'}
|
|
FROM registered_groups;
|
|
`);
|
|
|
|
database.exec(`
|
|
DROP TABLE registered_groups;
|
|
ALTER TABLE registered_groups_new RENAME TO registered_groups;
|
|
`);
|
|
} else {
|
|
database.exec(
|
|
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
|
|
);
|
|
}
|
|
|
|
const registeredGroupCols = database
|
|
.prepare('PRAGMA table_info(registered_groups)')
|
|
.all() as Array<{ name: string }>;
|
|
const hasAgentConfig = registeredGroupCols.some(
|
|
(col) => col.name === 'agent_config',
|
|
);
|
|
const hasContainerConfig = registeredGroupCols.some(
|
|
(col) => col.name === 'container_config',
|
|
);
|
|
if (!hasAgentConfig) {
|
|
database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`);
|
|
}
|
|
if (hasContainerConfig) {
|
|
database.exec(
|
|
`UPDATE registered_groups
|
|
SET agent_config = COALESCE(agent_config, container_config)
|
|
WHERE container_config IS NOT NULL`,
|
|
);
|
|
}
|
|
|
|
const pairedTasksSqlRow = database
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
|
)
|
|
.get() as { sql?: string } | undefined;
|
|
const pairedTasksSql = pairedTasksSqlRow?.sql || '';
|
|
const pairedTasksNeedsRebuild =
|
|
pairedTasksSql &&
|
|
(pairedTasksSql.includes('task_policy') ||
|
|
!pairedTasksSql.includes('round_trip_count'));
|
|
if (pairedTasksNeedsRebuild) {
|
|
database.exec(`DROP TABLE IF EXISTS paired_tasks`);
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS paired_tasks (
|
|
id TEXT PRIMARY KEY,
|
|
chat_jid TEXT NOT NULL,
|
|
group_folder TEXT NOT NULL,
|
|
owner_service_id TEXT NOT NULL,
|
|
reviewer_service_id TEXT NOT NULL,
|
|
owner_agent_type TEXT,
|
|
reviewer_agent_type TEXT,
|
|
arbiter_agent_type TEXT,
|
|
title TEXT,
|
|
source_ref TEXT,
|
|
plan_notes TEXT,
|
|
review_requested_at TEXT,
|
|
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
arbiter_verdict TEXT,
|
|
arbiter_requested_at TEXT,
|
|
completion_reason TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
|
|
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
|
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
|
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
|
ON paired_tasks(chat_jid, status, updated_at);
|
|
`);
|
|
}
|
|
|
|
{
|
|
const ptSqlRow = database
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_tasks'`,
|
|
)
|
|
.get() as { sql?: string } | undefined;
|
|
const ptSql = ptSqlRow?.sql || '';
|
|
if (ptSql && !ptSql.includes('arbiter_requested')) {
|
|
const pairedTaskCols = getTableColumns(database, 'paired_tasks');
|
|
const selectPairedTaskColumn = (columnName: string): string =>
|
|
pairedTaskCols.includes(columnName)
|
|
? columnName
|
|
: `NULL AS ${columnName}`;
|
|
database.exec(`
|
|
CREATE TABLE paired_tasks_new (
|
|
id TEXT PRIMARY KEY,
|
|
chat_jid TEXT NOT NULL,
|
|
group_folder TEXT NOT NULL,
|
|
owner_service_id TEXT,
|
|
reviewer_service_id TEXT,
|
|
owner_agent_type TEXT,
|
|
reviewer_agent_type TEXT,
|
|
arbiter_agent_type TEXT,
|
|
title TEXT,
|
|
source_ref TEXT,
|
|
plan_notes TEXT,
|
|
review_requested_at TEXT,
|
|
round_trip_count INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
arbiter_verdict TEXT,
|
|
arbiter_requested_at TEXT,
|
|
completion_reason TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
CHECK (status IN ('active', 'review_ready', 'in_review', 'merge_ready', 'completed', 'arbiter_requested', 'in_arbitration')),
|
|
CHECK (owner_agent_type IN ('claude-code', 'codex') OR owner_agent_type IS NULL),
|
|
CHECK (reviewer_agent_type IN ('claude-code', 'codex') OR reviewer_agent_type IS NULL),
|
|
CHECK (arbiter_agent_type IN ('claude-code', 'codex') OR arbiter_agent_type IS NULL)
|
|
);
|
|
INSERT INTO paired_tasks_new (
|
|
id, chat_jid, group_folder, owner_service_id, reviewer_service_id,
|
|
owner_agent_type, reviewer_agent_type,
|
|
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
|
|
round_trip_count, status, created_at, updated_at
|
|
)
|
|
SELECT
|
|
id, chat_jid, group_folder,
|
|
${selectPairedTaskColumn('owner_service_id')},
|
|
${selectPairedTaskColumn('reviewer_service_id')},
|
|
owner_agent_type, reviewer_agent_type,
|
|
arbiter_agent_type, title, source_ref, plan_notes, review_requested_at,
|
|
round_trip_count, status, created_at, updated_at
|
|
FROM paired_tasks;
|
|
DROP TABLE paired_tasks;
|
|
ALTER TABLE paired_tasks_new RENAME TO paired_tasks;
|
|
CREATE INDEX IF NOT EXISTS idx_paired_tasks_chat_status
|
|
ON paired_tasks(chat_jid, status, updated_at);
|
|
`);
|
|
}
|
|
}
|
|
|
|
tryExecMigration(
|
|
database,
|
|
`ALTER TABLE paired_tasks ADD COLUMN completion_reason TEXT`,
|
|
);
|
|
|
|
for (const table of [
|
|
'paired_executions',
|
|
'paired_approvals',
|
|
'paired_artifacts',
|
|
'paired_events',
|
|
]) {
|
|
database.exec(`DROP TABLE IF EXISTS ${table}`);
|
|
}
|
|
|
|
const pairedWsSqlRow = database
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_workspaces'`,
|
|
)
|
|
.get() as { sql?: string } | undefined;
|
|
const pairedWsSql = pairedWsSqlRow?.sql || '';
|
|
if (pairedWsSql && pairedWsSql.includes('snapshot_source_fingerprint')) {
|
|
database.exec(`DROP TABLE IF EXISTS paired_workspaces`);
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS paired_workspaces (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
workspace_dir TEXT NOT NULL,
|
|
snapshot_source_dir TEXT,
|
|
snapshot_ref TEXT,
|
|
status TEXT NOT NULL DEFAULT 'ready',
|
|
snapshot_refreshed_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
CHECK (role IN ('owner', 'reviewer')),
|
|
CHECK (status IN ('ready', 'stale'))
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_paired_workspaces_task_role
|
|
ON paired_workspaces(task_id, role);
|
|
`);
|
|
}
|
|
|
|
const pairedProjSqlRow = database
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'paired_projects'`,
|
|
)
|
|
.get() as { sql?: string } | undefined;
|
|
const pairedProjSql = pairedProjSqlRow?.sql || '';
|
|
if (pairedProjSql && pairedProjSql.includes('workspace_topology')) {
|
|
database.exec(`DROP TABLE IF EXISTS paired_projects`);
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS paired_projects (
|
|
chat_jid TEXT PRIMARY KEY,
|
|
group_folder TEXT NOT NULL,
|
|
canonical_work_dir TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
`);
|
|
}
|
|
|
|
migrateSessionsTableToCompositePk(database, 'claude-code');
|
|
backfillLegacyServiceSessions(database, inferAgentTypeFromServiceShadow);
|
|
dropLegacyServiceSessionsTable(database);
|
|
|
|
try {
|
|
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
|
|
database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`);
|
|
database.exec(
|
|
`UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`,
|
|
);
|
|
database.exec(
|
|
`UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`,
|
|
);
|
|
database.exec(
|
|
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
|
|
);
|
|
database.exec(
|
|
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
|
|
);
|
|
} catch {
|
|
/* columns already exist */
|
|
}
|
|
}
|