/** * Atomic settlement via saga pattern. * * When all members accept a solver result: * 1. Validate all acceptances * 2. Confirm escrow burns for need intents * 3. Create Connection entries in CommitmentsDoc * 4. Create Task entries in TasksDoc * 5. Update intent statuses → 'settled' * 6. Update reputation entries * 7. Update skill curve supply/demand counters * * On failure at any step: reverse all confirmed burns and abort. */ import * as Automerge from '@automerge/automerge'; import type { SyncServer } from '../../server/local-first/sync-server'; import { confirmBurn, reverseBurn } from '../../server/token-service'; import { commitmentsDocId, tasksDocId } from './schemas'; import type { CommitmentsDoc, TasksDoc, Skill } from './schemas'; import { intentsDocId, solverResultsDocId, skillCurvesDocId, reputationDocId, } from './schemas-intent'; import type { IntentsDoc, SolverResultsDoc, SkillCurvesDoc, ReputationDoc, SolverResult, Intent, } from './schemas-intent'; import { reputationKey, DEFAULT_REPUTATION } from './reputation'; import { recalculateCurve } from './skill-curve'; // ── Settlement ── export interface SettlementOutcome { success: boolean; resultId: string; connectionsCreated: number; tasksCreated: number; error?: string; } /** * Settle a solver result — atomic multi-doc update with saga rollback. */ export async function settleResult( resultId: string, space: string, syncServer: SyncServer, ): Promise { const outcome: SettlementOutcome = { success: false, resultId, connectionsCreated: 0, tasksCreated: 0, }; // Load all docs const solverDoc = syncServer.getDoc(solverResultsDocId(space)); const intentsDoc = syncServer.getDoc(intentsDocId(space)); if (!solverDoc || !intentsDoc) { outcome.error = 'Required documents not found'; return outcome; } const result = solverDoc.results[resultId]; if (!result) { outcome.error = `Solver result ${resultId} not found`; return outcome; } // Step 1: Validate all acceptances const allAccepted = result.members.every(m => result.acceptances[m] === true); if (!allAccepted) { outcome.error = 'Not all members have accepted'; return outcome; } if (result.status !== 'proposed' && result.status !== 'accepted') { outcome.error = `Result status is ${result.status}, expected proposed or accepted`; return outcome; } // Gather intents const intents = result.intents .map(id => intentsDoc.intents[id]) .filter(Boolean); if (intents.length !== result.intents.length) { outcome.error = 'Some intents in the result no longer exist'; return outcome; } // Step 2: Confirm escrow burns for need intents (with saga tracking) const confirmedEscrows: string[] = []; const needIntents = intents.filter(i => i.type === 'need' && i.escrowTxId); for (const need of needIntents) { const success = confirmBurn('cusdc', need.escrowTxId!); if (!success) { // Rollback all previously confirmed burns for (const txId of confirmedEscrows) { reverseBurn('cusdc', txId); } outcome.error = `Failed to confirm escrow for intent ${need.id} (txId: ${need.escrowTxId})`; return outcome; } confirmedEscrows.push(need.escrowTxId!); } // Step 3 & 4: Create connections and tasks const now = Date.now(); const offerIntents = intents.filter(i => i.type === 'offer'); const needIntentsAll = intents.filter(i => i.type === 'need'); // Create a task for this collaboration const taskId = crypto.randomUUID(); const taskSkills = [...new Set(intents.map(i => i.skill))]; const taskNeeds: Record = {}; for (const need of needIntentsAll) { taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours; } syncServer.changeDoc(tasksDocId(space), 'settlement: create task', (d) => { d.tasks[taskId] = { id: taskId, name: `Collaboration: ${taskSkills.map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' + ')}`, description: `Auto-generated from solver result. Members: ${result.members.length}`, needs: taskNeeds, links: [], notes: `Settled from solver result ${resultId}`, } as any; }); outcome.tasksCreated = 1; // Create connections (offer → task) syncServer.changeDoc(tasksDocId(space), 'settlement: create connections', (d) => { for (const offer of offerIntents) { // Find or create a commitment for this offer in CommitmentsDoc const connId = crypto.randomUUID(); d.connections[connId] = { id: connId, fromCommitmentId: offer.id, // Using intent ID as reference toTaskId: taskId, skill: offer.skill, } as any; outcome.connectionsCreated++; } }); // Create commitment entries for each offer intent (so they appear in the pool) syncServer.changeDoc(commitmentsDocId(space), 'settlement: create commitments', (d) => { for (const offer of offerIntents) { if (!d.items[offer.id]) { d.items[offer.id] = { id: offer.id, memberName: offer.memberName, hours: offer.hours, skill: offer.skill, desc: offer.description, createdAt: offer.createdAt, intentId: offer.id, status: 'settled', } as any; } } }); // Step 5: Update intent statuses syncServer.changeDoc(intentsDocId(space), 'settlement: mark intents settled', (d) => { for (const intent of intents) { if (d.intents[intent.id]) { d.intents[intent.id].status = 'settled'; } } }); // Step 6: Update solver result status syncServer.changeDoc(solverResultsDocId(space), 'settlement: mark result settled', (d) => { if (d.results[resultId]) { d.results[resultId].status = 'settled'; } }); // Step 7: Update reputation (initial entry — actual ratings come later) syncServer.changeDoc(reputationDocId(space), 'settlement: init reputation', (d) => { for (const intent of intents) { const key = reputationKey(intent.memberId, intent.skill); if (!d.entries[key]) { d.entries[key] = { memberId: intent.memberId, skill: intent.skill, score: DEFAULT_REPUTATION, completedHours: 0, ratings: [], } as any; } // Add completed hours d.entries[key].completedHours += intent.hours; } }); // Step 8: Update skill curves updateSkillCurves(space, syncServer); outcome.success = true; return outcome; } /** * Recalculate skill curves from current active intents. */ export function updateSkillCurves(space: string, syncServer: SyncServer): void { const intentsDoc = syncServer.getDoc(intentsDocId(space)); if (!intentsDoc) return; const activeIntents = Object.values(intentsDoc.intents) .filter(i => i.status === 'active'); // Aggregate supply/demand per skill const skills: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics']; const aggregates = new Map(); for (const skill of skills) { aggregates.set(skill, { supply: 0, demand: 0 }); } for (const intent of activeIntents) { const agg = aggregates.get(intent.skill); if (!agg) continue; if (intent.type === 'offer') agg.supply += intent.hours; else agg.demand += intent.hours; } syncServer.changeDoc(skillCurvesDocId(space), 'update skill curves', (d) => { for (const [skill, agg] of aggregates) { const updated = recalculateCurve(skill, agg.supply, agg.demand); const now = Date.now(); if (!d.curves[skill]) { d.curves[skill] = { ...updated, history: [{ price: updated.currentPrice, timestamp: now }], } as any; } else { d.curves[skill].supplyHours = updated.supplyHours; d.curves[skill].demandHours = updated.demandHours; d.curves[skill].currentPrice = updated.currentPrice; // Append to history (keep last 100 entries) if (!d.curves[skill].history) d.curves[skill].history = [] as any; d.curves[skill].history.push({ price: updated.currentPrice, timestamp: now } as any); if (d.curves[skill].history.length > 100) { d.curves[skill].history.splice(0, d.curves[skill].history.length - 100); } } } }); } /** * Check if a result is ready for settlement (all members accepted). */ export function isReadyForSettlement(result: SolverResult): boolean { if (result.status !== 'proposed' && result.status !== 'accepted') return false; return result.members.every(m => result.acceptances[m] === true); }