rspace-online/modules/rtime/intent-routes.ts

409 lines
14 KiB
TypeScript

/**
* Intent API routes — Hono sub-router for intent CRUD, solver, and settlement.
*
* All routes are relative to the module mount point (/:space/rtime/).
*/
import { Hono } from 'hono';
import * as Automerge from '@automerge/automerge';
import { verifyToken, extractToken } from '../../server/auth';
import { burnTokensEscrow, reverseBurn } from '../../server/token-service';
import type { SyncServer } from '../../server/local-first/sync-server';
import {
intentsDocId, solverResultsDocId,
skillCurvesDocId, reputationDocId,
intentsSchema, solverResultsSchema,
skillCurvesSchema, reputationSchema,
} from './schemas-intent';
import type {
IntentsDoc, SolverResultsDoc,
SkillCurvesDoc, ReputationDoc,
Intent, IntentStatus,
} from './schemas-intent';
import type { Skill } from './schemas';
import { getSkillPrice, calculateIntentCost, getAllSkillPrices, getSkillCurveConfig } from './skill-curve';
import { getMemberSkillReputation, getMemberOverallReputation } from './reputation';
import { solve } from './solver';
import { settleResult, isReadyForSettlement, updateSkillCurves } from './settlement';
const VALID_SKILLS: Skill[] = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
/**
* Create the intent routes sub-router.
* Requires a SyncServer reference (passed from mod.ts onInit).
*/
export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono {
const routes = new Hono();
// ── Helpers ──
function syncServer(): SyncServer {
const ss = getSyncServer();
if (!ss) throw new Error('SyncServer not initialized');
return ss;
}
function ensureIntentsDoc(space: string): IntentsDoc {
const docId = intentsDocId(space);
let doc = syncServer().getDoc<IntentsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<IntentsDoc>(), 'init intents', (d) => {
const init = intentsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
function ensureSolverResultsDoc(space: string): SolverResultsDoc {
const docId = solverResultsDocId(space);
let doc = syncServer().getDoc<SolverResultsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SolverResultsDoc>(), 'init solver results', (d) => {
const init = solverResultsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
function ensureSkillCurvesDoc(space: string): SkillCurvesDoc {
const docId = skillCurvesDocId(space);
let doc = syncServer().getDoc<SkillCurvesDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SkillCurvesDoc>(), 'init skill curves', (d) => {
const init = skillCurvesSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
function ensureReputationDoc(space: string): ReputationDoc {
const docId = reputationDocId(space);
let doc = syncServer().getDoc<ReputationDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ReputationDoc>(), 'init reputation', (d) => {
const init = reputationSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
syncServer().setDoc(docId, doc);
}
return doc;
}
async function requireAuth(headers: Headers): Promise<{ did: string; username: string } | null> {
const token = extractToken(headers);
if (!token) return null;
try {
const claims = await verifyToken(token);
return { did: claims.sub, username: (claims as any).username || claims.sub };
} catch {
return null;
}
}
// ── Intent CRUD ──
// POST /api/intent — Create a new intent
routes.post('/api/intent', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const body = await c.req.json();
const { type, skill, hours, description, minReputation, maxPrice, preferredMembers, expiresAt } = body;
// Validate
if (!type || !['offer', 'need'].includes(type)) {
return c.json({ error: 'type must be "offer" or "need"' }, 400);
}
if (!skill || !VALID_SKILLS.includes(skill)) {
return c.json({ error: `skill must be one of: ${VALID_SKILLS.join(', ')}` }, 400);
}
if (!hours || hours < 1 || hours > 100) {
return c.json({ error: 'hours must be between 1 and 100' }, 400);
}
ensureIntentsDoc(space);
ensureSkillCurvesDoc(space);
const id = crypto.randomUUID();
const now = Date.now();
let escrowTxId: string | undefined;
// For need intents: lock tokens in escrow
if (type === 'need') {
const curvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
const cost = calculateIntentCost(skill, hours, curvesDoc);
const offRampId = `intent-${id}`;
const success = burnTokensEscrow(
'cusdc',
auth.did,
auth.username,
cost,
offRampId,
`Intent escrow: ${hours}h ${skill} @ ${getSkillPrice(skill, curvesDoc)} tokens/h`,
);
if (!success) {
return c.json({ error: 'Insufficient balance for escrow' }, 402);
}
escrowTxId = offRampId;
}
// Write intent
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'create intent', (d) => {
d.intents[id] = {
id,
memberId: auth.did,
memberName: auth.username,
type,
skill,
hours: Math.max(1, Math.min(100, hours)),
description: description || '',
status: 'active',
createdAt: now,
...(minReputation != null ? { minReputation } : {}),
...(maxPrice != null ? { maxPrice } : {}),
...(preferredMembers?.length ? { preferredMembers } : {}),
...(escrowTxId ? { escrowTxId } : {}),
...(expiresAt ? { expiresAt } : {}),
} as any;
});
// Update skill curves
updateSkillCurves(space, syncServer());
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
return c.json(doc.intents[id], 201);
});
// PATCH /api/intent/:id — Update or withdraw an intent
routes.patch('/api/intent/:id', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const id = c.req.param('id');
const body = await c.req.json();
ensureIntentsDoc(space);
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const intent = doc.intents[id];
if (!intent) return c.json({ error: 'Intent not found' }, 404);
if (intent.memberId !== auth.did) return c.json({ error: 'Not your intent' }, 403);
if (intent.status !== 'active') return c.json({ error: `Cannot modify intent in status: ${intent.status}` }, 400);
// Handle withdrawal
if (body.status === 'withdrawn') {
// Reverse escrow if need intent
if (intent.escrowTxId) {
reverseBurn('cusdc', intent.escrowTxId);
}
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'withdraw intent', (d) => {
d.intents[id].status = 'withdrawn';
});
updateSkillCurves(space, syncServer());
return c.json({ ok: true, status: 'withdrawn' });
}
// Handle field updates
syncServer().changeDoc<IntentsDoc>(intentsDocId(space), 'update intent', (d) => {
if (body.description !== undefined) d.intents[id].description = body.description;
if (body.minReputation !== undefined) d.intents[id].minReputation = body.minReputation;
if (body.maxPrice !== undefined) d.intents[id].maxPrice = body.maxPrice;
if (body.preferredMembers !== undefined) d.intents[id].preferredMembers = body.preferredMembers;
});
const updated = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
return c.json(updated.intents[id]);
});
// GET /api/intents — List active intents for space
routes.get('/api/intents', (c) => {
const space = c.req.param('space') || 'demo';
ensureIntentsDoc(space);
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const intents = Object.values(doc.intents);
return c.json({ intents });
});
// GET /api/intents/:memberId — List member's intents
routes.get('/api/intents/:memberId', (c) => {
const space = c.req.param('space') || 'demo';
const memberId = c.req.param('memberId');
ensureIntentsDoc(space);
const doc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const intents = Object.values(doc.intents).filter(i => i.memberId === memberId);
return c.json({ intents });
});
// ── Solver ──
// GET /api/solver-results — Get current solver recommendations
routes.get('/api/solver-results', (c) => {
const space = c.req.param('space') || 'demo';
ensureSolverResultsDoc(space);
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const results = Object.values(doc.results)
.filter(r => r.status === 'proposed' || r.status === 'accepted');
return c.json({ results });
});
// POST /api/solver/run — Manual solver trigger
routes.post('/api/solver/run', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const resultCount = runSolver(space);
return c.json({ ok: true, resultsGenerated: resultCount });
});
// POST /api/solver-results/:id/accept — Member accepts a recommendation
routes.post('/api/solver-results/:id/accept', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const resultId = c.req.param('id');
ensureSolverResultsDoc(space);
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const result = doc.results[resultId];
if (!result) return c.json({ error: 'Result not found' }, 404);
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
if (result.status !== 'proposed' && result.status !== 'accepted') {
return c.json({ error: `Cannot accept result in status: ${result.status}` }, 400);
}
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'accept result', (d) => {
d.results[resultId].acceptances[auth.did] = true;
});
// Check if all accepted → auto-settle
const updated = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const updatedResult = updated.results[resultId];
if (isReadyForSettlement(updatedResult)) {
// Mark as accepted first
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'mark accepted', (d) => {
d.results[resultId].status = 'accepted';
});
const outcome = await settleResult(resultId, space, syncServer());
return c.json({ ok: true, settled: outcome.success, outcome });
}
return c.json({
ok: true,
settled: false,
acceptances: updatedResult.acceptances,
remaining: updatedResult.members.filter(m => !updatedResult.acceptances[m]),
});
});
// POST /api/solver-results/:id/reject — Member rejects a recommendation
routes.post('/api/solver-results/:id/reject', async (c) => {
const auth = await requireAuth(c.req.raw.headers);
if (!auth) return c.json({ error: 'Authentication required' }, 401);
const space = c.req.param('space') || 'demo';
const resultId = c.req.param('id');
ensureSolverResultsDoc(space);
const doc = syncServer().getDoc<SolverResultsDoc>(solverResultsDocId(space))!;
const result = doc.results[resultId];
if (!result) return c.json({ error: 'Result not found' }, 404);
if (!result.members.includes(auth.did)) return c.json({ error: 'Not a participant' }, 403);
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'reject result', (d) => {
d.results[resultId].status = 'rejected';
});
return c.json({ ok: true });
});
// ── Skill Curves ──
// GET /api/skill-curves — Get current skill prices
routes.get('/api/skill-curves', (c) => {
const space = c.req.param('space') || 'demo';
ensureSkillCurvesDoc(space);
const doc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
const prices = getAllSkillPrices(doc);
return c.json({ curves: Object.values(doc.curves), prices, config: getSkillCurveConfig() });
});
// ── Reputation ──
// GET /api/reputation/:memberId — Get member reputation
routes.get('/api/reputation/:memberId', (c) => {
const space = c.req.param('space') || 'demo';
const memberId = c.req.param('memberId');
ensureReputationDoc(space);
const doc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
const entries = Object.values(doc.entries).filter(e => e.memberId === memberId);
const overall = getMemberOverallReputation(memberId, doc);
return c.json({ memberId, overall, skills: entries });
});
// ── Internal: Run solver ──
function runSolver(space: string): number {
ensureIntentsDoc(space);
ensureSolverResultsDoc(space);
ensureSkillCurvesDoc(space);
ensureReputationDoc(space);
const intentsDoc = syncServer().getDoc<IntentsDoc>(intentsDocId(space))!;
const reputationDoc = syncServer().getDoc<ReputationDoc>(reputationDocId(space))!;
const skillCurvesDoc = syncServer().getDoc<SkillCurvesDoc>(skillCurvesDocId(space))!;
const candidates = solve(intentsDoc, reputationDoc, skillCurvesDoc);
if (candidates.length === 0) return 0;
// Clear old proposed results and write new ones
syncServer().changeDoc<SolverResultsDoc>(solverResultsDocId(space), 'solver: write results', (d) => {
// Remove stale proposed results
for (const [id, result] of Object.entries(d.results)) {
if (result.status === 'proposed') delete d.results[id];
}
// Add new results
const now = Date.now();
for (const candidate of candidates) {
const id = crypto.randomUUID();
d.results[id] = {
id,
...candidate,
createdAt: now,
} as any;
}
});
console.log(`[rTime] Solver produced ${candidates.length} results for space "${space}"`);
return candidates.length;
}
return routes;
}