rspace-online/modules/rtime/settlement.ts

267 lines
8.2 KiB
TypeScript

/**
* 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<SettlementOutcome> {
const outcome: SettlementOutcome = {
success: false,
resultId,
connectionsCreated: 0,
tasksCreated: 0,
};
// Load all docs
const solverDoc = syncServer.getDoc<SolverResultsDoc>(solverResultsDocId(space));
const intentsDoc = syncServer.getDoc<IntentsDoc>(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<string, number> = {};
for (const need of needIntentsAll) {
taskNeeds[need.skill] = (taskNeeds[need.skill] || 0) + need.hours;
}
syncServer.changeDoc<TasksDoc>(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<TasksDoc>(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<CommitmentsDoc>(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<IntentsDoc>(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<SolverResultsDoc>(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<ReputationDoc>(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<IntentsDoc>(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<Skill, { supply: number; demand: number }>();
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<SkillCurvesDoc>(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);
}