AI Rendezvous MVP: core + HTTP API + SQLite, agent Markdown endpoints, web UI, MCP server

This commit is contained in:
2026-09-06 19:00:16 +03:00
commit f3abd3c741
35 changed files with 4213 additions and 0 deletions
+520
View File
@@ -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();
}
}