UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

407 lines (396 loc) • 16.5 kB
import { mkdirSync, renameSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { redactSecrets, redactStringList } from "./redact.ts"; import { canonicalWorkflowEvidenceKey, journalPromotionState, normalizeVerifiedWorkflowEvidence, rankImprovementSignals, type WorkflowJournalEntry, type WorkflowRun, } from "./workflow.ts"; export interface RetrospectiveEvidenceAuditV1 { stageId: string; requirementKey: string; attempt: number; policy: { kind: "peer-reply"; minProducers: number; effectiveMinProducers: number; acceptedStatuses: ["replied"]; }; eligibleProducers: Array<{ id: string; name: string }>; verifiedProducerIds: string[]; verifiedMessages: Array<{ schema: "pi-mesh.verified-peer-evidence.v1"; messageId: string; producerId: string; producerName: string; context: { schema: "pi-mesh.workflow-message-context.v1"; runId: string; stageId: string; requirementKey: string; attempt: number; }; status: "replied"; hashes: { requestSha256: string; replySha256: string; }; timestamps: { createdAt: string; replyCreatedAt: string; repliedAt: string; verifiedAt: string; }; }>; degraded: boolean; degradationApprovals: Array<{ schema: "pi-mesh.workflow-degradation-approval.v1"; id: string; requirementKey: string; attempt: number; policyMinProducers: number; approvedMinProducers: number; approvedBy: "kxm-admin"; reason: string; approvedAt: string; }>; } export interface RetrospectiveV1 { schema: "pi-mesh.retrospective.v1"; runId: string; definitionId: string; status: WorkflowRun["status"]; exportedAt: string; reviewDecision: "proposed"; stages: Array<{ id: string; area?: string; status: string; attempts: number; startedAt?: string; completedAt?: string; durationMs?: number; updatedAt?: string; summary?: string; }>; counts: { byCategory: Record<string, number>; byArea: Record<string, number>; byClass: Record<string, number>; }; openContradictions: Array<{ id: string; summary: string; area: string }>; decisions: Array<{ id: string; summary: string; area: string }>; recurringErrorClasses: Array<{ class: string; count: number }>; entries: Array<{ id: string; category: string; area: string; severity: string; summary: string; evidence: string[]; relatedEntryIds: string[]; createdAt: string; stageId?: string; attempt?: number; /** Governed promotion lifecycle state for promotable categories. * Absent on v0.4 records and non-promotable categories. */ promotionState?: string; }>; proposedImprovements: Array<{ area: string; summary: string; successMeasure: string; status: "proposed" }>; /** Additive metadata-only provenance audit. Optional for v1 consumers and * absent from runs created before peer-evidence policies existed. */ evidenceAudit?: RetrospectiveEvidenceAuditV1[]; /** Stage IDs that completed through an explicit, attempt-bound admin * degradation approval. */ degradedStageIds?: string[]; } const MAX_RETROSPECTIVE_ENTRIES = 500; const SAFE_RUN_ID = /^run_[A-Za-z0-9_-]{1,120}$/; function durationMs(startedAt: string | undefined, completedAt: string | undefined): number | undefined { if (!startedAt || !completedAt) return undefined; const value = Date.parse(completedAt) - Date.parse(startedAt); return Number.isFinite(value) && value >= 0 ? value : undefined; } function increment(map: Record<string, number>, key: string): void { map[key] = (map[key] ?? 0) + 1; } function classFromEvidence(evidence: string[]): string { const match = evidence.find((item) => item.startsWith("class:")); return match?.slice("class:".length) || "unknown"; } function buildEvidenceAudit(run: WorkflowRun): RetrospectiveEvidenceAuditV1[] { const audit: RetrospectiveEvidenceAuditV1[] = []; for (const stage of run.stages) { const verifiedEvidence = normalizeVerifiedWorkflowEvidence(stage.verifiedEvidence); const degradedRequirements = new Set( (stage.degradedRequirements ?? []).map(canonicalWorkflowEvidenceKey), ); const policies = Object.entries(stage.resolvedEvidencePolicies ?? {}) .sort(([left], [right]) => left.localeCompare(right)); for (const [rawRequirement, policy] of policies) { const requirementKey = canonicalWorkflowEvidenceKey(rawRequirement); const attempt = stage.status === "passed" || stage.status === "failed" ? Math.max(1, stage.attempts) : stage.attempts + 1; const verifiedMessages = [...(verifiedEvidence[requirementKey] ?? [])] .filter((snapshot) => ( snapshot.context.runId === run.id && snapshot.context.stageId === stage.id && snapshot.context.requirementKey === requirementKey && snapshot.context.attempt === attempt )) .sort((left, right) => left.messageId.localeCompare(right.messageId)) .map((snapshot) => ({ schema: snapshot.schema, messageId: snapshot.messageId, producerId: snapshot.producerId, producerName: snapshot.producerName, context: { schema: snapshot.context.schema, runId: snapshot.context.runId, stageId: snapshot.context.stageId, requirementKey: snapshot.context.requirementKey, attempt: snapshot.context.attempt, }, status: snapshot.status, hashes: { requestSha256: snapshot.requestSha256, replySha256: snapshot.replySha256, }, timestamps: { createdAt: snapshot.createdAt, replyCreatedAt: snapshot.replyCreatedAt, repliedAt: snapshot.repliedAt, verifiedAt: snapshot.verifiedAt, }, })); const degradationApprovals = (stage.degradationApprovals ?? []) .filter((approval) => ( approval.schema === "pi-mesh.workflow-degradation-approval.v1" && approval.requirementKey === requirementKey && approval.attempt === attempt )) .sort((left, right) => left.attempt - right.attempt || left.approvedAt.localeCompare(right.approvedAt) || left.id.localeCompare(right.id)) .map((approval) => ({ schema: approval.schema, id: approval.id, requirementKey: approval.requirementKey, attempt: approval.attempt, policyMinProducers: approval.policyMinProducers, approvedMinProducers: approval.approvedMinProducers, approvedBy: approval.approvedBy, reason: redactSecrets(approval.reason).replace(/\s+/gu, " ").trim(), approvedAt: approval.approvedAt, })); const degraded = Boolean(stage.degraded && degradedRequirements.has(requirementKey)); const appliedApproval = degradationApprovals.at(-1); audit.push({ stageId: stage.id, requirementKey, attempt, policy: { kind: policy.kind, minProducers: policy.minProducers, effectiveMinProducers: appliedApproval?.approvedMinProducers ?? policy.minProducers, acceptedStatuses: ["replied"], }, eligibleProducers: [...policy.eligibleProducers] .sort((left, right) => left.id.localeCompare(right.id) || left.name.localeCompare(right.name)) .map((producer) => ({ id: producer.id, name: producer.name })), verifiedProducerIds: [...new Set(verifiedMessages.map((snapshot) => snapshot.producerId))].sort(), verifiedMessages, degraded, degradationApprovals, }); } } return audit; } export function buildRetrospective( run: WorkflowRun, journal: WorkflowJournalEntry[], exportedAt = new Date().toISOString(), ): RetrospectiveV1 { if (!SAFE_RUN_ID.test(run.id)) throw new Error("invalid retrospective run id"); const entries = journal .filter((entry) => entry.runId === run.id) .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)) .slice(-MAX_RETROSPECTIVE_ENTRIES) .map((entry) => { const promotionState = journalPromotionState(entry); const exported: RetrospectiveV1["entries"][number] = { id: entry.id, category: entry.category, area: entry.area, severity: entry.severity, summary: redactSecrets(entry.summary), evidence: redactStringList(entry.evidence), relatedEntryIds: entry.relatedEntryIds.slice(0, 16), createdAt: entry.createdAt, }; if (entry.stageId !== undefined) exported.stageId = entry.stageId; if (entry.attempt !== undefined) exported.attempt = entry.attempt; if (promotionState !== undefined) exported.promotionState = promotionState; return exported; }); const byCategory: Record<string, number> = {}; const byArea: Record<string, number> = {}; const byClass: Record<string, number> = {}; const byErrorClass: Record<string, number> = {}; for (const entry of entries) { increment(byCategory, entry.category); increment(byArea, entry.area); increment(byClass, classFromEvidence(entry.evidence)); if (entry.category === "error") increment(byErrorClass, classFromEvidence(entry.evidence)); } const recurringErrorClasses = Object.entries(byErrorClass) .map(([errorClass, count]) => ({ class: errorClass, count })) .sort((left, right) => right.count - left.count || left.class.localeCompare(right.class)); const resolvedContradictions = new Set(entries.filter((entry) => entry.category === "decision" || entry.category === "lesson").flatMap((entry) => entry.relatedEntryIds)); const openContradictions = entries .filter((entry) => entry.category === "contradiction" && !resolvedContradictions.has(entry.id)) .map((entry) => ({ id: entry.id, summary: entry.summary, area: entry.area })); const decisions = entries .filter((entry) => entry.category === "decision") .map((entry) => ({ id: entry.id, summary: entry.summary, area: entry.area })); // Ranked, merged error and lesson signals over this run's raw entries. The // category filter runs before the limit so up to 12 of them survive. const proposedImprovements = rankImprovementSignals( journal.filter((entry) => entry.runId === run.id && (entry.category === "error" || entry.category === "lesson")), new Map([[run.id, run]]), 12, ).map((signal) => ({ area: signal.area, summary: signal.summary, successMeasure: redactSecrets(`no recurrence of ${signal.key} in the next ${run.definitionId} run`), status: "proposed" as const, })); const evidenceAudit = buildEvidenceAudit(run); const degradedStageIds = run.stages .filter((stage) => stage.degraded) .map((stage) => stage.id); return { schema: "pi-mesh.retrospective.v1", runId: run.id, definitionId: run.definitionId, status: run.status, exportedAt, reviewDecision: "proposed", stages: run.stages.map((stage) => { const elapsed = durationMs(stage.startedAt, stage.completedAt); return { id: stage.id, ...(stage.area ? { area: stage.area } : {}), status: stage.status, attempts: stage.attempts, ...(stage.startedAt ? { startedAt: stage.startedAt } : {}), ...(stage.completedAt ? { completedAt: stage.completedAt } : {}), ...(elapsed !== undefined ? { durationMs: elapsed } : {}), ...(stage.updatedAt ? { updatedAt: stage.updatedAt } : {}), ...(stage.summary ? { summary: redactSecrets(stage.summary) } : {}) }; }), counts: { byCategory, byArea, byClass }, openContradictions, decisions, recurringErrorClasses, entries, proposedImprovements, ...(evidenceAudit.length ? { evidenceAudit } : {}), ...(degradedStageIds.length ? { degradedStageIds } : {}), }; } export function renderRetrospectiveMarkdown(doc: RetrospectiveV1): string { const stageRows = doc.stages .map((stage) => `| ${stage.id} | ${stage.area ?? ""} | ${stage.status} | ${stage.attempts} | ${stage.durationMs ?? ""} |`) .join("\n"); const contradictionRows = doc.openContradictions .map((entry) => `- ${entry.id} (${entry.area}): ${entry.summary}`) .join("\n") || "- none"; const decisionRows = doc.decisions .map((entry) => `- ${entry.id} (${entry.area}): ${entry.summary}`) .join("\n") || "- none"; const classRows = doc.recurringErrorClasses .map((entry) => `- ${entry.class}: ${entry.count}`) .join("\n") || "- none"; const entryRows = doc.entries.map((entry) => { const related = entry.relatedEntryIds.length > 0 ? `; related: ${entry.relatedEntryIds.join(", ")}` : ""; const evidence = entry.evidence.length > 0 ? `; evidence: ${entry.evidence.join(", ")}` : ""; return `- ${entry.id} [${entry.category}/${entry.area}/${entry.severity}]: ${entry.summary}${related}${evidence}`; }).join("\n") || "- none"; const evidenceAuditRows = (doc.evidenceAudit ?? []).flatMap((audit) => { const producerNames = audit.eligibleProducers .map((producer) => `${producer.name} (${producer.id})`) .join(", ") || "none"; const verified = audit.verifiedMessages.length ? audit.verifiedMessages.map((snapshot) => ( ` - ${snapshot.messageId}: producer ${snapshot.producerName} (${snapshot.producerId}), attempt ${snapshot.context.attempt}, request ${snapshot.hashes.requestSha256}, reply ${snapshot.hashes.replySha256}, replied ${snapshot.timestamps.repliedAt}, verified ${snapshot.timestamps.verifiedAt}` )) : [" - no verified messages"]; const approvals = audit.degradationApprovals.map((approval) => ( ` - approval ${approval.id}: attempt ${approval.attempt}, ${approval.policyMinProducers} -> ${approval.approvedMinProducers} producers, ${approval.approvedBy}, ${approval.approvedAt}; reason: ${approval.reason}` )); return [ `- ${audit.stageId} / ${audit.requirementKey} / attempt ${audit.attempt}: ${audit.verifiedProducerIds.length}/${audit.policy.effectiveMinProducers} verified producers; policy minimum ${audit.policy.minProducers}; degraded: ${audit.degraded}`, ` - eligible: ${producerNames}`, ...verified, ...(approvals.length ? [" - degradation approvals:", ...approvals] : []), ]; }); return [ `# Workflow retrospective ${doc.runId}`, "", `- Definition: ${doc.definitionId}`, `- Status: ${doc.status}`, `- Exported: ${doc.exportedAt}`, `- Review decision: ${doc.reviewDecision}`, "", "## Stages", "", "| id | area | status | attempts | duration ms |", "|---|---|---|---|---|", stageRows, "", "## Decisions", "", decisionRows, "", "## Open contradictions", "", contradictionRows, "", "## Recurring error classes", "", classRows, "", "## Bounded journal evidence", "", entryRows, "", ...(doc.evidenceAudit?.length ? [ "## Peer-evidence audit", "", "This section contains immutable provenance metadata and content hashes only; prompt and reply bodies are excluded.", "", ...evidenceAuditRows, "", ] : []), "## Proposed improvements", "", ...doc.proposedImprovements.map((item) => `- [${item.status}] (${item.area}) ${item.summary}`), "", "Proposed improvements are evidence, not policy. Do not apply them until an explicit review decision.", "", ].join("\n"); } export function writeRetrospective(outDir: string, doc: RetrospectiveV1): { jsonPath: string; mdPath: string } { if (!SAFE_RUN_ID.test(doc.runId)) throw new Error("invalid retrospective run id"); mkdirSync(outDir, { recursive: true }); const root = resolve(outDir); const jsonPath = resolve(root, `${doc.runId}.json`); const mdPath = resolve(root, `${doc.runId}.md`); const prefix = `${root}${process.platform === "win32" ? "\\" : "/"}`; if (!jsonPath.startsWith(prefix) || !mdPath.startsWith(prefix)) throw new Error("retrospective path escaped output directory"); const jsonTmp = `${jsonPath}.tmp`; const mdTmp = `${mdPath}.tmp`; writeFileSync(jsonTmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); writeFileSync(mdTmp, renderRetrospectiveMarkdown(doc), { encoding: "utf8", mode: 0o600 }); renameSync(jsonTmp, jsonPath); renameSync(mdTmp, mdPath); return { jsonPath, mdPath }; }