import type { AgentConsumerRow, CharterDatabaseSnapshot, ProjectEventRow, SpecChange, SpecImportPlan, WorkItemRow } from "./types.js"; export function listSpecChanges(plan: SpecImportPlan, snapshot: CharterDatabaseSnapshot): SpecChange[] { const changes: SpecChange[] = []; const currentRows = new Map(plan.rows.map((row) => [row.id, row])); for (const row of plan.rows) { const previous = snapshot.documents[row.id]; if (!previous) { changes.push({ type: "added", documentId: row.id, path: row.path, title: row.title, currentSha256: row.sha256, currentPath: row.path, }); continue; } if (previous.sha256 !== row.sha256 || previous.path !== row.path) { changes.push({ type: "modified", documentId: row.id, path: row.path, title: row.title, previousSha256: previous.sha256, currentSha256: row.sha256, previousPath: previous.path, currentPath: row.path, }); continue; } changes.push({ type: "unchanged", documentId: row.id, path: row.path, title: row.title, previousSha256: previous.sha256, currentSha256: row.sha256, previousPath: previous.path, currentPath: row.path, }); } for (const [documentId, row] of Object.entries(snapshot.documents)) { if (currentRows.has(documentId)) continue; changes.push({ type: "deleted", documentId, path: row.path, title: row.title, previousSha256: row.sha256, previousPath: row.path, }); } return changes.sort((left, right) => { const order = changeOrder(left.type) - changeOrder(right.type); return order === 0 ? left.documentId.localeCompare(right.documentId) : order; }); } export function actionableChanges(changes: SpecChange[]): SpecChange[] { return changes.filter((change) => change.type !== "unchanged"); } export function buildEventsFromChanges( plan: SpecImportPlan, changes: SpecChange[], options: { authority?: ProjectEventRow["authority"] } = {}, ): ProjectEventRow[] { const rowById = new Map(plan.rows.map((row) => [row.id, row])); const authority = options.authority ?? "importer.sync"; return changes .filter((change) => change.type !== "unchanged") .map((change): ProjectEventRow => { if (change.type === "deleted") { const event: ProjectEventRow = { id: `${plan.generatedAt}:delete:${change.documentId}`, type: "spec.document.deleted", documentId: change.documentId, path: change.path, occurredAt: plan.generatedAt, authority, sourceField: "body", }; if (change.previousSha256 !== undefined) event.sha256 = change.previousSha256; return event; } const row = rowById.get(change.documentId); if (!row) throw new Error(`Missing current row for ${change.documentId}`); return { id: `${plan.generatedAt}:upsert:${row.id}:${row.sha256.slice(0, 12)}`, type: "spec.document.upserted", documentId: row.id, path: row.path, sha256: row.sha256, occurredAt: plan.generatedAt, authority, sourceField: "body", }; }); } export function buildWorkItemsFromEvents( plan: SpecImportPlan, events: ProjectEventRow[], changes: SpecChange[], consumers: AgentConsumerRow[] = [], ): WorkItemRow[] { const changeByDocumentId = new Map(changes.map((change) => [change.documentId, change])); const titleByDocumentId = new Map(plan.rows.map((row) => [row.id, row.title])); return events.map((event): WorkItemRow => { const change = changeByDocumentId.get(event.documentId); const title = titleByDocumentId.get(event.documentId) ?? change?.title ?? event.documentId; const row: WorkItemRow = { id: `work:${event.id}`, kind: "spec-change", status: "queued", eventId: event.id, documentId: event.documentId, path: event.path, title, createdAt: event.occurredAt, updatedAt: event.occurredAt, authority: event.authority, attachedDocumentIds: [event.documentId], matchedConsumerIds: matchingConsumersForEvent(event, consumers).map((consumer) => consumer.id), }; if (event.sourceJazzRowId !== undefined) row.sourceJazzRowId = event.sourceJazzRowId; if (event.sourceField !== undefined) row.sourceField = event.sourceField; return row; }); } export function queuedWorkItems(snapshot: CharterDatabaseSnapshot): WorkItemRow[] { return Object.values(snapshot.workItems) .filter((item) => item.status === "queued") .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); } export function matchingConsumersForEvent(event: ProjectEventRow, consumers: AgentConsumerRow[]): AgentConsumerRow[] { return consumers .filter((consumer) => consumer.status === "active") .filter((consumer) => eventTypeMatches(event, consumer)) .filter((consumer) => documentSelectorMatches(event, consumer)) .sort((left, right) => left.id.localeCompare(right.id)); } function eventTypeMatches(event: ProjectEventRow, consumer: AgentConsumerRow): boolean { return consumer.eventTypes.length === 0 || consumer.eventTypes.includes("*") || consumer.eventTypes.includes("all") || consumer.eventTypes.includes(event.type); } function documentSelectorMatches(event: ProjectEventRow, consumer: AgentConsumerRow): boolean { if (consumer.documentSelectors.length === 0) return true; return consumer.documentSelectors.some((selector) => selectorMatchesDocument(selector, event.documentId, event.path)); } function selectorMatchesDocument(selector: string, documentId: string, documentPath: string): boolean { const normalized = selector.trim(); if (!normalized || normalized === "*" || normalized === "**" || normalized === "**/*.md") return true; if (normalized === documentId || normalized === documentPath) return true; if (normalized.endsWith("/**")) return documentPath.startsWith(normalized.slice(0, -2)); if (normalized.endsWith("/*")) { const prefix = normalized.slice(0, -1); const remainder = documentPath.slice(prefix.length); return documentPath.startsWith(prefix) && !remainder.includes("/"); } if (normalized.endsWith("*")) return documentPath.startsWith(normalized.slice(0, -1)); if (normalized.endsWith("/")) return documentPath.startsWith(normalized); return false; } function changeOrder(type: SpecChange["type"]): number { if (type === "added") return 0; if (type === "modified") return 1; if (type === "deleted") return 2; return 3; }