267 lines
8.2 KiB
TypeScript
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);
|
|
}
|