AI Rendezvous MVP: core + HTTP API + SQLite, agent Markdown endpoints, web UI, MCP server
This commit is contained in:
@@ -0,0 +1,520 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import {
|
||||
AgreementRow,
|
||||
ContractRevisionRow,
|
||||
MessageRow,
|
||||
ParticipantRow,
|
||||
QuestionRow,
|
||||
RoomRow,
|
||||
Store,
|
||||
} from './store.js';
|
||||
import { LIMITS } from './limits.js';
|
||||
import { availableActions, computeAdvice } from './advice.js';
|
||||
import {
|
||||
CreateRoomInput,
|
||||
CreatedRoom,
|
||||
MessageView,
|
||||
ParticipantView,
|
||||
QuestionView,
|
||||
RendezvousError,
|
||||
RoomView,
|
||||
} from './types.js';
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function newId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 20)}`;
|
||||
}
|
||||
|
||||
/** Cryptographically random invite token; the token IS the authorization. */
|
||||
export function newToken(): string {
|
||||
return randomBytes(24).toString('base64url'); // 32 chars, 192 bits
|
||||
}
|
||||
|
||||
export class RendezvousService {
|
||||
private db: DatabaseSync;
|
||||
|
||||
constructor(readonly store: Store) {
|
||||
this.db = store.db;
|
||||
}
|
||||
|
||||
// ---------- room lifecycle ----------
|
||||
|
||||
createRoom(input: CreateRoomInput, baseUrl: string): CreatedRoom {
|
||||
const title = (input.title ?? '').trim();
|
||||
if (!title) throw new RendezvousError('validation', 'title is required');
|
||||
if (Buffer.byteLength(title) > 2000) {
|
||||
throw new RendezvousError('validation', 'title too long (max 2000 bytes)');
|
||||
}
|
||||
const ps = input.participants ?? [];
|
||||
if (ps.length < LIMITS.minParticipants) {
|
||||
throw new RendezvousError('validation', `at least ${LIMITS.minParticipants} participants required`);
|
||||
}
|
||||
if (ps.length > LIMITS.maxParticipants) {
|
||||
throw new RendezvousError('validation', `at most ${LIMITS.maxParticipants} participants allowed`);
|
||||
}
|
||||
const roles = new Set<string>();
|
||||
for (const p of ps) {
|
||||
const role = (p.role ?? '').trim();
|
||||
if (!role) throw new RendezvousError('validation', 'each participant needs a role');
|
||||
if (Buffer.byteLength(role) > 200) {
|
||||
throw new RendezvousError('validation', 'participant role too long');
|
||||
}
|
||||
if (roles.has(role)) {
|
||||
throw new RendezvousError('validation', `duplicate participant role: ${role}`);
|
||||
}
|
||||
roles.add(role);
|
||||
}
|
||||
|
||||
const ttl = input.ttl_hours ?? LIMITS.defaultTtlHours;
|
||||
if (!Number.isFinite(ttl) || ttl <= 0 || ttl > LIMITS.maxTtlHours) {
|
||||
throw new RendezvousError('validation', `ttl_hours must be between 1 and ${LIMITS.maxTtlHours}`);
|
||||
}
|
||||
|
||||
const roomId = randomBytes(9).toString('base64url'); // 12 chars
|
||||
const createdAt = nowIso();
|
||||
const expiresAt = new Date(Date.now() + ttl * 3600_000).toISOString();
|
||||
|
||||
const created: { id: string; role: string; token: string }[] = [];
|
||||
const insertRoom = this.db.prepare(
|
||||
`INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at) VALUES (?, ?, ?, ?, 'open', ?, ?)`,
|
||||
);
|
||||
const insertParticipant = this.db.prepare(
|
||||
`INSERT INTO participants (id, room_id, role, display_name, token, knows, needs_to_determine, instructions, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt);
|
||||
for (const p of ps) {
|
||||
const pid = newId('prt');
|
||||
const token = newToken();
|
||||
insertParticipant.run(
|
||||
pid,
|
||||
roomId,
|
||||
p.role.trim(),
|
||||
(p.display_name ?? p.role).trim().slice(0, 200),
|
||||
token,
|
||||
JSON.stringify((p.knows ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
||||
JSON.stringify((p.needs_to_determine ?? []).slice(0, 50).map((s) => String(s).slice(0, 1000))),
|
||||
(p.instructions ?? '').slice(0, 20000),
|
||||
createdAt,
|
||||
);
|
||||
created.push({ id: pid, role: p.role.trim(), token });
|
||||
}
|
||||
this.db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
|
||||
return {
|
||||
room_id: roomId,
|
||||
status: 'open',
|
||||
expires_at: expiresAt,
|
||||
participants: created,
|
||||
invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- auth ----------
|
||||
|
||||
authenticate(roomId: string, token: string): { room: RoomRow; participant: ParticipantRow } {
|
||||
const room = this.getRoomRow(roomId);
|
||||
const row = this.db
|
||||
.prepare('SELECT * FROM participants WHERE room_id = ? AND token = ?')
|
||||
.get(roomId, token) as ParticipantRow | undefined;
|
||||
if (!row) {
|
||||
throw new RendezvousError('forbidden', 'invalid token for this room');
|
||||
}
|
||||
return { room, participant: row };
|
||||
}
|
||||
|
||||
authenticateByToken(token: string): { room: RoomRow; participant: ParticipantRow } {
|
||||
const row = this.db.prepare('SELECT * FROM participants WHERE token = ?').get(token) as
|
||||
| ParticipantRow
|
||||
| undefined;
|
||||
if (!row) throw new RendezvousError('not_found', 'unknown token');
|
||||
const room = this.getRoomRow(row.room_id);
|
||||
return { room, participant: row };
|
||||
}
|
||||
|
||||
private getRoomRow(roomId: string): RoomRow {
|
||||
const room = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId) as RoomRow | undefined;
|
||||
if (!room) throw new RendezvousError('not_found', 'room not found');
|
||||
return room;
|
||||
}
|
||||
|
||||
private ensureOpen(room: RoomRow): void {
|
||||
if (new Date(room.expires_at).getTime() < Date.now()) {
|
||||
this.cleanupExpired();
|
||||
throw new RendezvousError('not_found', 'room not found');
|
||||
}
|
||||
if (room.status !== 'open') {
|
||||
throw new RendezvousError('conflict', `room status is '${room.status}'; no further changes allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- reads ----------
|
||||
|
||||
listParticipants(roomId: string): ParticipantView[] {
|
||||
return (this.db
|
||||
.prepare('SELECT * FROM participants WHERE room_id = ? ORDER BY created_at, id')
|
||||
.all(roomId) as unknown as ParticipantRow[]).map((p) => this.toParticipantView(p));
|
||||
}
|
||||
|
||||
private toParticipantView(p: ParticipantRow): ParticipantView {
|
||||
return {
|
||||
id: p.id,
|
||||
role: p.role,
|
||||
display_name: p.display_name,
|
||||
knows: JSON.parse(p.knows) as string[],
|
||||
needs_to_determine: JSON.parse(p.needs_to_determine) as string[],
|
||||
instructions: p.instructions,
|
||||
};
|
||||
}
|
||||
|
||||
listMessages(roomId: string): MessageView[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT m.*, p.role FROM messages m JOIN participants p ON p.id = m.participant_id
|
||||
WHERE m.room_id = ? ORDER BY m.created_at, m.id`,
|
||||
)
|
||||
.all(roomId) as unknown as (MessageRow & { role: string })[];
|
||||
return rows.map((m) => ({
|
||||
id: m.id,
|
||||
participant_id: m.participant_id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
created_at: m.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
listQuestions(roomId: string): QuestionView[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT q.*, pa.role AS author_role, pt.role AS to_role FROM questions q
|
||||
JOIN participants pa ON pa.id = q.participant_id
|
||||
LEFT JOIN participants pt ON pt.id = q.addressed_to
|
||||
WHERE q.room_id = ? ORDER BY q.created_at, q.id`,
|
||||
)
|
||||
.all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null })[];
|
||||
return rows.map((q) => ({
|
||||
id: q.id,
|
||||
participant_id: q.participant_id,
|
||||
author_role: q.author_role,
|
||||
addressed_to: q.addressed_to,
|
||||
addressed_to_role: q.to_role,
|
||||
question: q.question,
|
||||
blocking: q.blocking === 1,
|
||||
status: q.status,
|
||||
resolution: q.resolution,
|
||||
created_at: q.created_at,
|
||||
resolved_at: q.resolved_at,
|
||||
}));
|
||||
}
|
||||
|
||||
getRoomView(roomId: string, token: string): RoomView {
|
||||
const { room, participant } = this.authenticate(roomId, token);
|
||||
const participants = this.listParticipants(roomId);
|
||||
const conversation = this.listMessages(roomId);
|
||||
const questions = this.listQuestions(roomId);
|
||||
const openQuestions = questions.filter((q) => q.status === 'open');
|
||||
const resolvedQuestions = questions.filter((q) => q.status === 'resolved');
|
||||
|
||||
const agreements = this.db
|
||||
.prepare(
|
||||
`SELECT a.participant_id, a.contract_version, p.role FROM agreements a
|
||||
JOIN participants p ON p.id = a.participant_id WHERE a.room_id = ?`,
|
||||
)
|
||||
.all(roomId) as { participant_id: string; contract_version: number; role: string }[];
|
||||
|
||||
const contract =
|
||||
room.contract_version > 0
|
||||
? {
|
||||
version: room.contract_version,
|
||||
markdown: room.contract_markdown,
|
||||
updated_at: room.contract_updated_at,
|
||||
agreements: agreements.map((a) => ({
|
||||
participant_id: a.participant_id,
|
||||
role: a.role,
|
||||
version: a.contract_version,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
const mine = agreements.find((a) => a.participant_id === participant.id);
|
||||
const status = room.status as 'open' | 'agreed' | 'expired';
|
||||
const viewStatus = new Date(room.expires_at).getTime() < Date.now() ? 'expired' : status;
|
||||
|
||||
return {
|
||||
room: {
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
brief: room.brief,
|
||||
goal: room.goal,
|
||||
status,
|
||||
created_at: room.created_at,
|
||||
expires_at: room.expires_at,
|
||||
},
|
||||
your_role: participant.role,
|
||||
your_participant_id: participant.id,
|
||||
participants,
|
||||
conversation,
|
||||
open_questions: openQuestions,
|
||||
resolved_questions: resolvedQuestions,
|
||||
current_contract: contract,
|
||||
room_status: viewStatus,
|
||||
what_you_should_do_next: computeAdvice({
|
||||
status: viewStatus,
|
||||
you: this.toParticipantView(participant),
|
||||
participants,
|
||||
openQuestions,
|
||||
resolvedQuestions,
|
||||
conversation,
|
||||
contract,
|
||||
yourAgreedVersion: mine ? mine.contract_version : null,
|
||||
}),
|
||||
available_actions: availableActions(viewStatus),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- writes ----------
|
||||
|
||||
postMessage(roomId: string, token: string, content: string): MessageView {
|
||||
const { room, participant } = this.authenticate(roomId, token);
|
||||
this.ensureOpen(room);
|
||||
const body = typeof content === 'string' ? content : '';
|
||||
if (!body.trim()) throw new RendezvousError('validation', 'content is required');
|
||||
if (Buffer.byteLength(body) > LIMITS.maxMessageBytes) {
|
||||
throw new RendezvousError('limit', `message too large (max ${LIMITS.maxMessageBytes} bytes)`);
|
||||
}
|
||||
const count = this.db.prepare('SELECT COUNT(*) AS c FROM messages WHERE room_id = ?').get(roomId) as {
|
||||
c: number;
|
||||
};
|
||||
if (count.c >= LIMITS.maxMessagesPerRoom) {
|
||||
throw new RendezvousError('limit', `message limit reached (${LIMITS.maxMessagesPerRoom})`);
|
||||
}
|
||||
const totalRow = this.db
|
||||
.prepare(
|
||||
`SELECT
|
||||
(SELECT COALESCE(SUM(LENGTH(content)),0) FROM messages WHERE room_id = ?)
|
||||
+ (SELECT COALESCE(SUM(LENGTH(question) + LENGTH(resolution)),0) FROM questions WHERE room_id = ?)
|
||||
+ (SELECT COALESCE(SUM(LENGTH(markdown)),0) FROM contract_revisions WHERE room_id = ?) AS s`,
|
||||
)
|
||||
.get(roomId, roomId, roomId) as { s: number };
|
||||
if (totalRow.s + Buffer.byteLength(body) > LIMITS.maxRoomTotalBytes) {
|
||||
throw new RendezvousError('limit', 'room total size limit reached');
|
||||
}
|
||||
const id = newId('msg');
|
||||
this.db
|
||||
.prepare('INSERT INTO messages (id, room_id, participant_id, content, created_at) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, roomId, participant.id, body, nowIso());
|
||||
return {
|
||||
id,
|
||||
participant_id: participant.id,
|
||||
role: participant.role,
|
||||
content: body,
|
||||
created_at: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
openQuestion(
|
||||
roomId: string,
|
||||
token: string,
|
||||
question: string,
|
||||
blocking: boolean,
|
||||
addressedToParticipantId?: string | null,
|
||||
): QuestionView {
|
||||
const { room, participant } = this.authenticate(roomId, token);
|
||||
this.ensureOpen(room);
|
||||
const body = typeof question === 'string' ? question.trim() : '';
|
||||
if (!body) throw new RendezvousError('validation', 'question is required');
|
||||
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
||||
throw new RendezvousError('limit', `question too large (max ${LIMITS.maxQuestionBytes} bytes)`);
|
||||
}
|
||||
let addressedTo: string | null = null;
|
||||
if (addressedToParticipantId) {
|
||||
const target = this.db
|
||||
.prepare('SELECT id FROM participants WHERE id = ? AND room_id = ?')
|
||||
.get(addressedToParticipantId, roomId);
|
||||
if (!target) throw new RendezvousError('validation', 'addressed_to_participant_id not found in this room');
|
||||
if (addressedToParticipantId === participant.id) {
|
||||
throw new RendezvousError('validation', 'cannot address a question to yourself');
|
||||
}
|
||||
addressedTo = addressedToParticipantId;
|
||||
}
|
||||
const count = this.db.prepare('SELECT COUNT(*) AS c FROM questions WHERE room_id = ?').get(roomId) as {
|
||||
c: number;
|
||||
};
|
||||
if (count.c >= LIMITS.maxQuestionsPerRoom) {
|
||||
throw new RendezvousError('limit', `question limit reached (${LIMITS.maxQuestionsPerRoom})`);
|
||||
}
|
||||
const id = newId('q');
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO questions (id, room_id, participant_id, addressed_to, question, blocking, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'open', ?)`,
|
||||
)
|
||||
.run(id, roomId, participant.id, addressedTo, body, blocking ? 1 : 0, nowIso());
|
||||
return {
|
||||
id,
|
||||
participant_id: participant.id,
|
||||
author_role: participant.role,
|
||||
addressed_to: addressedTo,
|
||||
addressed_to_role: addressedTo
|
||||
? ((this.db.prepare('SELECT role FROM participants WHERE id = ?').get(addressedTo) as { role: string }).role)
|
||||
: null,
|
||||
question: body,
|
||||
blocking,
|
||||
status: 'open',
|
||||
resolution: null,
|
||||
created_at: nowIso(),
|
||||
resolved_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
resolveQuestion(roomId: string, token: string, questionId: string, resolution: string): QuestionView {
|
||||
const { room, participant } = this.authenticate(roomId, token);
|
||||
this.ensureOpen(room);
|
||||
const q = this.db
|
||||
.prepare('SELECT * FROM questions WHERE id = ? AND room_id = ?')
|
||||
.get(questionId, roomId) as QuestionRow | undefined;
|
||||
if (!q) throw new RendezvousError('not_found', 'question not found');
|
||||
if (q.status === 'resolved') {
|
||||
throw new RendezvousError('conflict', 'question is already resolved');
|
||||
}
|
||||
const body = typeof resolution === 'string' ? resolution.trim() : '';
|
||||
if (!body) throw new RendezvousError('validation', 'resolution text is required');
|
||||
if (Buffer.byteLength(body) > LIMITS.maxQuestionBytes) {
|
||||
throw new RendezvousError('limit', 'resolution too large');
|
||||
}
|
||||
// The addressee or the author may resolve; anyone may resolve a question
|
||||
// addressed to "anyone" only if they are the addressee in practice. Keep
|
||||
// it simple: the addressee (or author) resolves.
|
||||
const isAddressee = q.addressed_to === participant.id;
|
||||
const isAuthor = q.participant_id === participant.id;
|
||||
if (!isAddressee && !isAuthor) {
|
||||
throw new RendezvousError('forbidden', 'only the addressee or the author can resolve a question');
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE questions SET status = 'resolved', resolution = ?, resolved_by = ?, resolved_at = ? WHERE id = ?`,
|
||||
)
|
||||
.run(body, participant.id, nowIso(), questionId);
|
||||
return this.listQuestions(roomId).find((x) => x.id === questionId)!;
|
||||
}
|
||||
|
||||
proposeContract(roomId: string, token: string, markdown: string): { version: number; markdown: string } {
|
||||
const { room, participant } = this.authenticate(roomId, token);
|
||||
this.ensureOpen(room);
|
||||
const body = typeof markdown === 'string' ? markdown : '';
|
||||
if (!body.trim()) throw new RendezvousError('validation', 'markdown is required');
|
||||
if (Buffer.byteLength(body) > LIMITS.maxContractBytes) {
|
||||
throw new RendezvousError('limit', `contract too large (max ${LIMITS.maxContractBytes} bytes)`);
|
||||
}
|
||||
const version = room.contract_version + 1;
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO contract_revisions (id, room_id, version, markdown, proposed_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(newId('rev'), roomId, version, body, participant.id, nowIso());
|
||||
this.db
|
||||
.prepare('UPDATE rooms SET contract_markdown = ?, contract_version = ?, contract_updated_at = ? WHERE id = ?')
|
||||
.run(body, version, nowIso(), roomId);
|
||||
this.db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
return { version, markdown: body };
|
||||
}
|
||||
|
||||
listContractRevisions(roomId: string): ContractRevisionRow[] {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM contract_revisions WHERE room_id = ? ORDER BY version')
|
||||
.all(roomId) as unknown as ContractRevisionRow[];
|
||||
}
|
||||
|
||||
agree(roomId: string, token: string): {
|
||||
agreed_contract_version: number;
|
||||
room_status: string;
|
||||
everyone_agreed: boolean;
|
||||
} {
|
||||
const { room, participant } = this.authenticate(roomId, token);
|
||||
this.ensureOpen(room);
|
||||
if (room.contract_version === 0 || room.contract_markdown.trim() === '') {
|
||||
throw new RendezvousError('conflict', 'no contract has been proposed yet');
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO agreements (room_id, participant_id, contract_version, created_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(room_id, participant_id) DO UPDATE SET contract_version = excluded.contract_version, created_at = excluded.created_at`,
|
||||
)
|
||||
.run(roomId, participant.id, room.contract_version, nowIso());
|
||||
|
||||
const participants = this.listParticipants(roomId);
|
||||
const agreements = this.db
|
||||
.prepare('SELECT participant_id, contract_version FROM agreements WHERE room_id = ?')
|
||||
.all(roomId) as { participant_id: string; contract_version: number }[];
|
||||
const everyone = participants.every((p) => {
|
||||
const a = agreements.find((x) => x.participant_id === p.id);
|
||||
return a && a.contract_version === room.contract_version;
|
||||
});
|
||||
|
||||
const blocking = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM questions WHERE room_id = ? AND status = 'open' AND blocking = 1`)
|
||||
.get(roomId) as { c: number };
|
||||
|
||||
if (everyone && blocking.c === 0) {
|
||||
this.db.prepare(`UPDATE rooms SET status = 'agreed' WHERE id = ?`).run(roomId);
|
||||
return { agreed_contract_version: room.contract_version, room_status: 'agreed', everyone_agreed: true };
|
||||
}
|
||||
return {
|
||||
agreed_contract_version: room.contract_version,
|
||||
room_status: 'open',
|
||||
everyone_agreed: everyone,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- TTL cleanup ----------
|
||||
|
||||
/** Deletes everything belonging to expired rooms. Returns number of rooms removed. */
|
||||
cleanupExpired(): number {
|
||||
const expired = this.db
|
||||
.prepare(`SELECT id FROM rooms WHERE expires_at < ? AND status != 'expired'`)
|
||||
.all(nowIso()) as { id: string }[];
|
||||
if (expired.length === 0) return 0;
|
||||
const delRoom = this.db.prepare('DELETE FROM rooms WHERE id = ?');
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
for (const r of expired) delRoom.run(r.id); // ON DELETE CASCADE removes the rest
|
||||
this.db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
this.db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
return expired.length;
|
||||
}
|
||||
|
||||
startCleanupTimer(intervalMs = 10 * 60_000): NodeJS.Timeout {
|
||||
return setInterval(() => {
|
||||
try {
|
||||
this.cleanupExpired();
|
||||
} catch (e) {
|
||||
console.error('[cleanup] failed:', e);
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.store.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user