import { createClient, type Client, type Row } from '@libsql/client'; import { chmod, mkdir, readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { patchpawPaths, legacyRuntimePaths } from '../config/paths.ts'; import type { HumanReply } from '../github/comments.ts'; import { type InboundRecord, type InboundStatus, LIFECYCLE_PURPOSES, type OutboundItem, type OutboundLifecycleStatus, type OutboundStatus, type SafeCommunicationError, } from './communication-types.ts'; export const COMMUNICATION_SCHEMA_VERSION = '1'; export const communicationDbPath = (runtimeHome: string) => patchpawPaths(runtimeHome).communicationDb; const schema = ` CREATE TABLE IF NOT EXISTS communication_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS outbound_delivery ( id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_id TEXT NOT NULL UNIQUE, semantic_key TEXT NOT NULL, repo TEXT NOT NULL COLLATE NOCASE, pr_number INTEGER NOT NULL CHECK (pr_number > 0), kind TEXT NOT NULL CHECK (kind IN ('comment', 'review')), purpose TEXT NOT NULL, payload_json TEXT NOT NULL, marker TEXT NOT NULL, source_json TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending', 'sending', 'pending_retry', 'blocked', 'delivered', 'cancelled_stale')), attempt_count INTEGER NOT NULL DEFAULT 0, last_attempt_at TEXT, next_attempt_at TEXT NOT NULL, sending_until_at TEXT, last_error_json TEXT, receipt_json TEXT, lifecycle_status TEXT NOT NULL CHECK (lifecycle_status IN ('pending', 'finalized')), finalized_at TEXT, finalization_attempt_count INTEGER NOT NULL DEFAULT 0, finalization_last_error_json TEXT, next_finalization_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(repo, pr_number, semantic_key) ); CREATE INDEX IF NOT EXISTS idx_outbound_pr_order ON outbound_delivery(repo, pr_number, id); CREATE INDEX IF NOT EXISTS idx_outbound_due ON outbound_delivery(status, next_attempt_at); CREATE INDEX IF NOT EXISTS idx_outbound_finalization_due ON outbound_delivery(lifecycle_status, next_finalization_at); CREATE TABLE IF NOT EXISTS inbound_comment ( id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_id TEXT NOT NULL, repo TEXT NOT NULL COLLATE NOCASE, pr_number INTEGER NOT NULL CHECK (pr_number > 0), comment_id INTEGER NOT NULL, reply_json TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('pending_verification', 'verified', 'rejected', 'dispatched', 'retired')), attempt_count INTEGER NOT NULL DEFAULT 0, next_attempt_at TEXT NOT NULL, last_error_json TEXT, rejected_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(repo, pr_number, comment_id) ); CREATE INDEX IF NOT EXISTS idx_inbound_due ON inbound_comment(status, next_attempt_at); `; type DbArgs = Record; function value(row: Row, key: string) { return row[key] as string | number | bigint | null | undefined; } function textValue(row: Row, key: string, fallback = '') { const current = value(row, key); return current === null || current === undefined ? fallback : String(current); } function numberValue(row: Row, key: string, fallback = 0) { const current = value(row, key); return current === null || current === undefined ? fallback : Number(current); } function jsonValue(row: Row, key: string, fallback: T): T { const current = value(row, key); if (current === null || current === undefined || current === '') return fallback; return JSON.parse(String(current)) as T; } function optionalJsonValue(row: Row, key: string): T | null { const current = value(row, key); return current === null || current === undefined ? null : JSON.parse(String(current)) as T; } function lifecycleRequired(purpose: string) { return LIFECYCLE_PURPOSES.includes(purpose as typeof LIFECYCLE_PURPOSES[number]); } // The delivery transition is SQL-owned, but its lifecycle classification comes from the same // domain list used when enqueueing and reading old rows. Keep this generated from the union's // canonical runtime value so adding a lifecycle purpose cannot silently finalize it on delivery. const LIFECYCLE_PURPOSES_SQL = LIFECYCLE_PURPOSES.map(purpose => `'${purpose}'`).join(', '); function toOutbound(row: Row, dbPath: string): { item: OutboundItem; path: string } { const purpose = textValue(row, 'purpose'); const lifecycleStatus = textValue(row, 'lifecycle_status', lifecycleRequired(purpose) ? 'pending' : 'finalized') as OutboundLifecycleStatus; return { path: dbPath, item: { version: 1, delivery_id: textValue(row, 'delivery_id'), semantic_key: textValue(row, 'semantic_key'), repo: textValue(row, 'repo'), pr_number: numberValue(row, 'pr_number'), sequence: numberValue(row, 'id'), kind: textValue(row, 'kind') as OutboundItem['kind'], purpose, created_at: textValue(row, 'created_at'), updated_at: textValue(row, 'updated_at'), status: textValue(row, 'status') as OutboundStatus, payload: jsonValue(row, 'payload_json', {} as OutboundItem['payload']), marker: textValue(row, 'marker'), source: jsonValue(row, 'source_json', {}), attempt_count: numberValue(row, 'attempt_count'), last_attempt_at: value(row, 'last_attempt_at') === null ? null : textValue(row, 'last_attempt_at'), next_attempt_at: textValue(row, 'next_attempt_at'), sending_until_at: value(row, 'sending_until_at') === null ? null : textValue(row, 'sending_until_at'), last_error: optionalJsonValue(row, 'last_error_json'), receipt: optionalJsonValue(row, 'receipt_json'), lifecycle_status: lifecycleStatus, finalized_at: value(row, 'finalized_at') === null ? null : textValue(row, 'finalized_at'), finalization_attempt_count: numberValue(row, 'finalization_attempt_count'), finalization_last_error: optionalJsonValue(row, 'finalization_last_error_json'), next_finalization_at: value(row, 'next_finalization_at') === null ? null : textValue(row, 'next_finalization_at'), }, }; } function toInbound(row: Row, dbPath: string): { record: InboundRecord; path: string } { const reply = jsonValue(row, 'reply_json', { repo: textValue(row, 'repo'), pr_number: numberValue(row, 'pr_number'), comment_id: numberValue(row, 'comment_id'), installation_id: 0, author: '', body: '', url: '', }); return { path: dbPath, record: { version: 1, delivery_id: textValue(row, 'delivery_id'), repo: textValue(row, 'repo'), pr_number: numberValue(row, 'pr_number'), comment_id: numberValue(row, 'comment_id'), reply, status: textValue(row, 'status') as InboundStatus, attempt_count: numberValue(row, 'attempt_count'), next_attempt_at: textValue(row, 'next_attempt_at'), last_error: optionalJsonValue(row, 'last_error_json'), rejected_reason: value(row, 'rejected_reason') === null ? undefined : textValue(row, 'rejected_reason'), created_at: textValue(row, 'created_at'), updated_at: textValue(row, 'updated_at'), }, }; } function isoNow() { return new Date().toISOString(); } export class CommunicationStore { readonly path: string; constructor(readonly root: string, private readonly client: Client) { this.path = communicationDbPath(root); } async execute(sql: string, args?: DbArgs) { return this.client.execute({ sql, args }); } async close() { this.client.close(); } async getMeta(key: string) { const result = await this.execute('SELECT value FROM communication_meta WHERE key = :key', { key }); return result.rows[0] ? textValue(result.rows[0], 'value') : undefined; } async setMeta(key: string, valueToSet: string) { await this.execute(`INSERT INTO communication_meta(key, value) VALUES (:key, :value) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, { key, value: valueToSet }); } async enqueueOutbound(item: Omit & { updated_at?: string }) { const now = item.updated_at ?? isoNow(); await this.execute(`INSERT INTO outbound_delivery( delivery_id, semantic_key, repo, pr_number, kind, purpose, payload_json, marker, source_json, status, attempt_count, last_attempt_at, next_attempt_at, sending_until_at, last_error_json, receipt_json, lifecycle_status, finalized_at, finalization_attempt_count, finalization_last_error_json, next_finalization_at, created_at, updated_at ) VALUES ( :delivery_id, :semantic_key, :repo, :pr_number, :kind, :purpose, :payload_json, :marker, :source_json, :status, :attempt_count, :last_attempt_at, :next_attempt_at, :sending_until_at, :last_error_json, :receipt_json, :lifecycle_status, :finalized_at, :finalization_attempt_count, :finalization_last_error_json, :next_finalization_at, :created_at, :updated_at ) ON CONFLICT(repo, pr_number, semantic_key) DO NOTHING`, { delivery_id: item.delivery_id, semantic_key: item.semantic_key, repo: item.repo, pr_number: item.pr_number, kind: item.kind, purpose: item.purpose, payload_json: JSON.stringify(item.payload), marker: item.marker, source_json: JSON.stringify(item.source), status: item.status, attempt_count: item.attempt_count, last_attempt_at: item.last_attempt_at, next_attempt_at: item.next_attempt_at, sending_until_at: item.sending_until_at ?? null, last_error_json: item.last_error ? JSON.stringify(item.last_error) : null, receipt_json: item.receipt ? JSON.stringify(item.receipt) : null, lifecycle_status: item.lifecycle_status, finalized_at: item.finalized_at ?? null, finalization_attempt_count: item.finalization_attempt_count, finalization_last_error_json: item.finalization_last_error ? JSON.stringify(item.finalization_last_error) : null, next_finalization_at: item.next_finalization_at ?? null, created_at: item.created_at, updated_at: now, }); return this.getOutboundBySemanticKey(item.repo, item.pr_number, item.semantic_key); } async getOutboundBySemanticKey(repo: string, prNumber: number, semanticKey: string) { const result = await this.execute(`SELECT * FROM outbound_delivery WHERE repo = :repo AND pr_number = :pr_number AND semantic_key = :semantic_key`, { repo, pr_number: prNumber, semantic_key: semanticKey }); return result.rows[0] ? toOutbound(result.rows[0], this.path) : undefined; } async getOutboundByDeliveryId(deliveryId: string) { const result = await this.execute('SELECT * FROM outbound_delivery WHERE delivery_id = :delivery_id', { delivery_id: deliveryId }); return result.rows[0] ? toOutbound(result.rows[0], this.path) : undefined; } async listOutbound(filters?: { repo?: string; prNumber?: number }) { const where: string[] = []; const args: DbArgs = {}; if (filters?.repo) { where.push('repo = :repo'); args.repo = filters.repo; } if (filters?.prNumber !== undefined) { where.push('pr_number = :pr_number'); args.pr_number = filters.prNumber; } const result = await this.execute(`SELECT * FROM outbound_delivery ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`, args); return result.rows.map(row => toOutbound(row, this.path)); } async claimOutbound(deliveryId: string, now: string, leaseUntil: string, force = false) { const result = await this.execute(`UPDATE outbound_delivery AS target SET status = 'sending', attempt_count = attempt_count + 1, last_attempt_at = :now, sending_until_at = :lease_until, next_attempt_at = :lease_until, updated_at = :now WHERE target.delivery_id = :delivery_id AND target.status IN ('pending', 'pending_retry') AND (:force = 1 OR target.next_attempt_at <= :now) AND NOT EXISTS ( SELECT 1 FROM outbound_delivery AS older WHERE older.repo = target.repo AND older.pr_number = target.pr_number AND older.id < target.id AND older.status NOT IN ('delivered', 'cancelled_stale') )`, { delivery_id: deliveryId, now, lease_until: leaseUntil, force: force ? 1 : 0 }); if (!result.rowsAffected) return undefined; return this.getOutboundByDeliveryId(deliveryId); } async markOutboundDelivered(deliveryId: string, receipt: OutboundItem['receipt'], now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET status = 'delivered', receipt_json = :receipt_json, last_error_json = NULL, sending_until_at = NULL, next_attempt_at = :now, lifecycle_status = CASE WHEN purpose IN (${LIFECYCLE_PURPOSES_SQL}) THEN lifecycle_status ELSE 'finalized' END, finalized_at = CASE WHEN purpose IN (${LIFECYCLE_PURPOSES_SQL}) THEN finalized_at ELSE :now END, updated_at = :now WHERE delivery_id = :delivery_id AND status = 'sending'`, { delivery_id: deliveryId, receipt_json: receipt ? JSON.stringify(receipt) : null, now }); return this.getOutboundByDeliveryId(deliveryId); } async markOutboundStale(deliveryId: string, error: SafeCommunicationError, now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET status = 'cancelled_stale', last_error_json = :error_json, sending_until_at = NULL, next_attempt_at = :now, updated_at = :now WHERE delivery_id = :delivery_id AND status = 'sending'`, { delivery_id: deliveryId, error_json: JSON.stringify(error), now }); return this.getOutboundByDeliveryId(deliveryId); } async cancelOutboundStale(deliveryId: string, error: SafeCommunicationError, now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET status = 'cancelled_stale', last_error_json = :error_json, sending_until_at = NULL, next_attempt_at = :now, next_finalization_at = :now, updated_at = :now WHERE delivery_id = :delivery_id AND status NOT IN ('delivered', 'cancelled_stale', 'sending')`, { delivery_id: deliveryId, error_json: JSON.stringify(error), now }); return this.getOutboundByDeliveryId(deliveryId); } async markOutboundRetry(deliveryId: string, error: SafeCommunicationError, nextAttemptAt: string, now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET status = 'pending_retry', last_error_json = :error_json, sending_until_at = NULL, next_attempt_at = :next_attempt_at, updated_at = :now WHERE delivery_id = :delivery_id AND status = 'sending'`, { delivery_id: deliveryId, error_json: JSON.stringify(error), next_attempt_at: nextAttemptAt, now }); return this.getOutboundByDeliveryId(deliveryId); } async markOutboundBlocked(deliveryId: string, error: SafeCommunicationError, now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET status = 'blocked', last_error_json = :error_json, sending_until_at = NULL, next_attempt_at = :now, updated_at = :now WHERE delivery_id = :delivery_id AND status = 'sending'`, { delivery_id: deliveryId, error_json: JSON.stringify(error), now }); return this.getOutboundByDeliveryId(deliveryId); } async recordOutboundFailure(deliveryId: string, error: SafeCommunicationError, nextAttemptAt?: string, now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET status = CASE WHEN :next_attempt_at IS NULL THEN 'blocked' ELSE 'pending_retry' END, attempt_count = attempt_count + CASE WHEN status = 'sending' THEN 0 ELSE 1 END, last_attempt_at = :now, last_error_json = :error_json, sending_until_at = NULL, next_attempt_at = COALESCE(:next_attempt_at, :now), updated_at = :now WHERE delivery_id = :delivery_id AND status NOT IN ('delivered', 'cancelled_stale')`, { delivery_id: deliveryId, error_json: JSON.stringify(error), next_attempt_at: nextAttemptAt ?? null, now, }); return this.getOutboundByDeliveryId(deliveryId); } async recoverExpiredSending(now = isoNow()) { // This transition has a semantic meaning used by /close: the sender's lease expired while // its remote POST may still be in flight. Never let a caller replace this marker with a // generic transport error, or close could cancel the row and lose a late receipt. const leaseExpired = { status: null, code: 'sending_lease_expired', name: 'SendingLeaseExpired', category: 'transport', retry_after_ms: null }; const result = await this.execute(`UPDATE outbound_delivery SET status = 'pending_retry', attempt_count = CASE WHEN attempt_count < 2 THEN 2 ELSE attempt_count END, sending_until_at = NULL, next_attempt_at = :now, last_error_json = :error_json, updated_at = :now WHERE status = 'sending' AND COALESCE(sending_until_at, next_attempt_at) <= :now`, { now, error_json: JSON.stringify(leaseExpired) }); return result.rowsAffected; } async listDueOutboundHeads(now = isoNow(), limit = 25) { const result = await this.execute(`WITH heads AS ( SELECT MIN(id) AS id FROM outbound_delivery WHERE status NOT IN ('delivered', 'cancelled_stale') GROUP BY repo, pr_number ) SELECT o.* FROM outbound_delivery o JOIN heads h ON h.id = o.id WHERE o.status IN ('pending', 'pending_retry') AND o.next_attempt_at <= :now ORDER BY o.next_attempt_at, o.id LIMIT :limit`, { now, limit }); return result.rows.map(row => toOutbound(row, this.path)); } async nextCommunicationDeadline() { const result = await this.execute(`WITH heads AS ( SELECT MIN(id) AS id FROM outbound_delivery WHERE status NOT IN ('delivered', 'cancelled_stale') GROUP BY repo, pr_number ), deadlines AS ( SELECT CASE WHEN o.status = 'sending' THEN COALESCE(o.sending_until_at, o.next_attempt_at) WHEN o.status IN ('pending', 'pending_retry') THEN o.next_attempt_at ELSE NULL END AS deadline FROM outbound_delivery o JOIN heads h ON h.id = o.id WHERE o.status <> 'blocked' UNION ALL SELECT next_finalization_at FROM outbound_delivery WHERE lifecycle_status = 'pending' AND status IN ('delivered', 'cancelled_stale') AND next_finalization_at IS NOT NULL UNION ALL SELECT next_attempt_at FROM inbound_comment WHERE status IN ('pending_verification', 'verified') ) SELECT MIN(deadline) AS deadline FROM deadlines WHERE deadline IS NOT NULL`); return result.rows[0] && value(result.rows[0], 'deadline') !== null ? String(value(result.rows[0], 'deadline')) : undefined; } async hasDueCommunication(now = isoNow()) { const deadline = await this.nextCommunicationDeadline(); return deadline !== undefined && deadline <= now; } async listDueFinalizations(now = isoNow(), limit = 25) { const result = await this.execute(`SELECT * FROM outbound_delivery WHERE status IN ('delivered', 'cancelled_stale') AND lifecycle_status = 'pending' AND next_finalization_at IS NOT NULL AND next_finalization_at <= :now ORDER BY next_finalization_at, id LIMIT :limit`, { now, limit }); return result.rows.map(row => toOutbound(row, this.path)); } async markFinalized(deliveryId: string, now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET lifecycle_status = 'finalized', finalized_at = :now, finalization_last_error_json = NULL, next_finalization_at = NULL, updated_at = :now WHERE delivery_id = :delivery_id AND status IN ('delivered', 'cancelled_stale') AND lifecycle_status = 'pending'`, { delivery_id: deliveryId, now }); return this.getOutboundByDeliveryId(deliveryId); } async deferFinalization(deliveryId: string, error: SafeCommunicationError, nextAttemptAt: string, now = isoNow()) { await this.execute(`UPDATE outbound_delivery SET finalization_attempt_count = finalization_attempt_count + 1, finalization_last_error_json = :error_json, next_finalization_at = :next_attempt_at, updated_at = :now WHERE delivery_id = :delivery_id AND status IN ('delivered', 'cancelled_stale') AND lifecycle_status = 'pending'`, { delivery_id: deliveryId, error_json: JSON.stringify(error), next_attempt_at: nextAttemptAt, now }); return this.getOutboundByDeliveryId(deliveryId); } async requeueOldestBlocked(repo: string, prNumber: number, now = isoNow()) { const result = await this.execute(`UPDATE outbound_delivery SET status = 'pending', next_attempt_at = :now, last_error_json = NULL, updated_at = :now WHERE id = ( SELECT id FROM outbound_delivery WHERE repo = :repo AND pr_number = :pr_number AND status = 'blocked' ORDER BY id LIMIT 1 ) RETURNING *`, { repo, pr_number: prNumber, now }); return result.rows[0] ? toOutbound(result.rows[0], this.path).item : undefined; } async insertInbound(deliveryId: string, reply: HumanReply, now = isoNow(), initial: { status?: InboundStatus; attemptCount?: number; nextAttemptAt?: string; lastError?: SafeCommunicationError | null; rejectedReason?: string; updatedAt?: string; } = {}) { const status = initial.status ?? 'pending_verification'; const attemptCount = initial.attemptCount ?? 0; const nextAttemptAt = initial.nextAttemptAt ?? now; const updatedAt = initial.updatedAt ?? now; await this.execute(`INSERT INTO inbound_comment( delivery_id, repo, pr_number, comment_id, reply_json, status, attempt_count, next_attempt_at, last_error_json, rejected_reason, created_at, updated_at ) VALUES (:delivery_id, :repo, :pr_number, :comment_id, :reply_json, :status, :attempt_count, :next_attempt_at, :last_error_json, :rejected_reason, :created_at, :updated_at) ON CONFLICT(repo, pr_number, comment_id) DO NOTHING`, { delivery_id: deliveryId, repo: reply.repo, pr_number: reply.pr_number, comment_id: reply.comment_id, reply_json: JSON.stringify(reply), status, attempt_count: attemptCount, next_attempt_at: nextAttemptAt, last_error_json: initial.lastError ? JSON.stringify(initial.lastError) : null, rejected_reason: initial.rejectedReason ?? null, created_at: now, updated_at: updatedAt, }); return this.getInbound(reply.repo, reply.pr_number, reply.comment_id); } async getInbound(repo: string, prNumber: number, commentId: number) { const result = await this.execute(`SELECT * FROM inbound_comment WHERE repo = :repo AND pr_number = :pr_number AND comment_id = :comment_id`, { repo, pr_number: prNumber, comment_id: commentId }); return result.rows[0] ? toInbound(result.rows[0], this.path) : undefined; } async listInbound(filters?: { repo?: string; prNumber?: number }) { const where: string[] = []; const args: DbArgs = {}; if (filters?.repo) { where.push('repo = :repo'); args.repo = filters.repo; } if (filters?.prNumber !== undefined) { where.push('pr_number = :pr_number'); args.pr_number = filters.prNumber; } const result = await this.execute(`SELECT * FROM inbound_comment ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id`, args); return result.rows.map(row => toInbound(row, this.path)); } async listDueInbound(now = isoNow(), limit = 25) { const result = await this.execute(`SELECT * FROM inbound_comment WHERE status IN ('pending_verification', 'verified') AND next_attempt_at <= :now ORDER BY next_attempt_at, id LIMIT :limit`, { now, limit }); return result.rows.map(row => toInbound(row, this.path)); } async markInboundVerified(repo: string, prNumber: number, commentId: number, now = isoNow()) { await this.execute(`UPDATE inbound_comment SET status = 'verified', next_attempt_at = :now, last_error_json = NULL, rejected_reason = NULL, updated_at = :now WHERE repo = :repo AND pr_number = :pr_number AND comment_id = :comment_id AND status = 'pending_verification'`, { repo, pr_number: prNumber, comment_id: commentId, now }); return this.getInbound(repo, prNumber, commentId); } async markInboundDispatched(repo: string, prNumber: number, commentId: number, now = isoNow()) { await this.execute(`UPDATE inbound_comment SET status = 'dispatched', next_attempt_at = :now, last_error_json = NULL, updated_at = :now WHERE repo = :repo AND pr_number = :pr_number AND comment_id = :comment_id AND status = 'verified'`, { repo, pr_number: prNumber, comment_id: commentId, now }); return this.getInbound(repo, prNumber, commentId); } async markInboundRetired(repo: string, prNumber: number, commentId: number, now = isoNow()) { await this.execute(`UPDATE inbound_comment SET status = 'retired', next_attempt_at = :now, last_error_json = NULL, updated_at = :now WHERE repo = :repo AND pr_number = :pr_number AND comment_id = :comment_id AND status IN ('pending_verification', 'verified')`, { repo, pr_number: prNumber, comment_id: commentId, now }); return this.getInbound(repo, prNumber, commentId); } async markInboundRetry(repo: string, prNumber: number, commentId: number, error: SafeCommunicationError, nextAttemptAt: string, now = isoNow()) { await this.execute(`UPDATE inbound_comment SET status = 'pending_verification', attempt_count = attempt_count + 1, next_attempt_at = :next_attempt_at, last_error_json = :error_json, updated_at = :now WHERE repo = :repo AND pr_number = :pr_number AND comment_id = :comment_id AND status IN ('pending_verification', 'verified')`, { repo, pr_number: prNumber, comment_id: commentId, next_attempt_at: nextAttemptAt, error_json: JSON.stringify(error), now }); return this.getInbound(repo, prNumber, commentId); } async markInboundRejected(repo: string, prNumber: number, commentId: number, reason: string, error: SafeCommunicationError | null = null, now = isoNow()) { await this.execute(`UPDATE inbound_comment SET status = 'rejected', rejected_reason = :reason, next_attempt_at = :now, last_error_json = :error_json, updated_at = :now WHERE repo = :repo AND pr_number = :pr_number AND comment_id = :comment_id AND status IN ('pending_verification', 'verified')`, { repo, pr_number: prNumber, comment_id: commentId, reason, error_json: error ? JSON.stringify(error) : null, now }); return this.getInbound(repo, prNumber, commentId); } async maxInboundCommentId(repo: string, prNumber: number) { const result = await this.execute(`SELECT MAX(comment_id) AS max_comment_id FROM inbound_comment WHERE repo = :repo AND pr_number = :pr_number`, { repo, pr_number: prNumber }); const current = result.rows[0] ? value(result.rows[0], 'max_comment_id') : null; return current === null || current === undefined ? 0 : Number(current); } } async function readJsonFile(path: string) { return JSON.parse(await readFile(path, 'utf8')) as Record; } async function legacyOutboundFiles(runtimeHome: string, legacyHome?: string) { const rootDir = legacyRuntimePaths(runtimeHome, legacyHome).outbox; const files: { path: string; item: Record }[] = []; let repoKeys: string[]; try { repoKeys = await readdir(rootDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return files; throw error; } for (const repoKey of repoKeys) { let prs: string[]; try { prs = await readdir(join(rootDir, repoKey)); } catch { continue; } for (const pr of prs) { if (!/^pr-\d+$/.test(pr)) continue; let names: string[]; try { names = await readdir(join(rootDir, repoKey, pr)); } catch { continue; } for (const name of names.filter(value => /^\d+-[\w-]+\.json$/.test(value))) { const path = join(rootDir, repoKey, pr, name); files.push({ path, item: await readJsonFile(path) }); } } } return files; } async function legacyInboundFiles(runtimeHome: string, legacyHome?: string) { const rootDir = legacyRuntimePaths(runtimeHome, legacyHome).inbound; const files: { path: string; record: Record }[] = []; let repoKeys: string[]; try { repoKeys = await readdir(rootDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return files; throw error; } for (const repoKey of repoKeys) { let prs: string[]; try { prs = await readdir(join(rootDir, repoKey)); } catch { continue; } for (const pr of prs.filter(value => /^pr-\d+$/.test(value))) { let names: string[]; try { names = await readdir(join(rootDir, repoKey, pr)); } catch { continue; } for (const name of names.filter(value => /^comment-\d+\.json$/.test(value))) { const path = join(rootDir, repoKey, pr, name); files.push({ path, record: await readJsonFile(path) }); } } } return files; } async function importLegacyFiles(store: CommunicationStore, legacyHome?: string) { if (await store.getMeta('file_queue_import_v1')) return { outbound: 0, inbound: 0, skipped: true }; const outbound = await legacyOutboundFiles(store.root, legacyHome); outbound.sort((a, b) => { const ak = `${String(a.item.repo ?? '')}\u0000${String(a.item.pr_number ?? '')}`; const bk = `${String(b.item.repo ?? '')}\u0000${String(b.item.pr_number ?? '')}`; return ak.localeCompare(bk) || Number(a.item.sequence ?? 0) - Number(b.item.sequence ?? 0); }); for (const { item } of outbound) { if (!item.repo || !Number.isSafeInteger(item.pr_number) || !item.delivery_id || !item.semantic_key) continue; const purpose = String(item.purpose ?? 'legacy'); const lifecycle = (item.lifecycle_status ?? (lifecycleRequired(purpose) ? 'pending' : 'finalized')) as OutboundLifecycleStatus; await store.enqueueOutbound({ version: 1, delivery_id: String(item.delivery_id), semantic_key: String(item.semantic_key), repo: String(item.repo), pr_number: Number(item.pr_number), kind: item.kind === 'review' ? 'review' : 'comment', purpose, created_at: String(item.created_at ?? isoNow()), status: item.status as OutboundStatus ?? 'pending', payload: item.payload, marker: String(item.marker ?? ''), source: item.source ?? {}, attempt_count: Number(item.attempt_count ?? 0), last_attempt_at: item.last_attempt_at ?? null, next_attempt_at: String(item.next_attempt_at ?? isoNow()), sending_until_at: item.sending_until_at ?? null, last_error: item.last_error ?? null, receipt: item.receipt ?? null, lifecycle_status: lifecycle, finalized_at: item.finalized_at ?? null, finalization_attempt_count: Number(item.finalization_attempt_count ?? 0), finalization_last_error: item.finalization_last_error ?? null, next_finalization_at: item.next_finalization_at ?? (lifecycle === 'pending' ? isoNow() : null), }); } const inbound = await legacyInboundFiles(store.root, legacyHome); for (const { record } of inbound) { const reply = record.reply as HumanReply | undefined; if (!reply?.repo || !Number.isSafeInteger(reply.pr_number) || !Number.isSafeInteger(reply.comment_id)) continue; const knownStatuses: InboundStatus[] = ['pending_verification', 'verified', 'rejected', 'dispatched', 'retired']; const status = knownStatuses.includes(record.status) ? record.status : 'pending_verification'; await store.insertInbound(String(record.delivery_id ?? `legacy-${reply.comment_id}`), reply, String(record.created_at ?? isoNow()), { status, attemptCount: Number(record.attempt_count ?? 0), nextAttemptAt: String(record.next_attempt_at ?? record.created_at ?? isoNow()), lastError: record.last_error ?? null, rejectedReason: record.rejected_reason, updatedAt: String(record.updated_at ?? record.created_at ?? isoNow()) }); } await store.setMeta('file_queue_import_v1', isoNow()); return { outbound: outbound.length, inbound: inbound.length, skipped: false }; } export async function openCommunicationStore(root: string, legacyHome = process.env.PATCHPAW_LEGACY_HOME) { const paths = patchpawPaths(root); await mkdir(paths.data, { recursive: true, mode: 0o700 }); const path = communicationDbPath(root); const client = createClient({ url: pathToFileURL(path).href, timeout: 5000 }); try { await client.execute('PRAGMA journal_mode=WAL'); await client.execute('PRAGMA foreign_keys=ON'); await client.executeMultiple(schema); for (const file of [path, `${path}-wal`, `${path}-shm`]) { await chmod(file, 0o600).catch(error => { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }); } const store = new CommunicationStore(root, client); await store.setMeta('schema_version', COMMUNICATION_SCHEMA_VERSION); await importLegacyFiles(store, legacyHome); return store; } catch (error) { client.close(); throw error; } } export async function ensureCommunicationStore(root: string) { return openCommunicationStore(root); } export async function closeCommunicationStore(store: CommunicationStore) { await store.close(); }