Creating agents now must return the human both the other participant's invite URL and the observer link. Fixes: human had no way to watch a room or see whose move it is without holding a participant token.
This commit is contained in:
@@ -91,6 +91,38 @@ export function computeAdvice(input: AdviceInput): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-level "whose turn is it" for the human observer.
|
||||
* Priority: blocking questions first, then contract agreements, then finalize.
|
||||
*/
|
||||
export function computeTurn(
|
||||
status: string,
|
||||
participants: { id: string; role: string }[],
|
||||
openQuestions: { addressed_to: string | null; participant_id: string; blocking: boolean; question: string }[],
|
||||
contract: { version: number; agreements: { participant_id: string; version: number }[] } | null,
|
||||
): string {
|
||||
if (status === 'expired') return 'Room expired — all data has been deleted.';
|
||||
if (status === 'agreed') return 'Done: contract agreed by everyone. Fetch final.md before the room expires.';
|
||||
|
||||
const roleOf = (id: string) => participants.find((p) => p.id === id)?.role ?? 'unknown';
|
||||
const blocking = openQuestions.filter((q) => q.blocking);
|
||||
if (blocking.length > 0) {
|
||||
const q = blocking[0];
|
||||
const who = q.addressed_to ? roleOf(q.addressed_to) : `${participants.filter((p) => p.id !== q.participant_id).map((p) => p.role).join(', ')} (asked by ${roleOf(q.participant_id)})`;
|
||||
return `Waiting for ${who} to answer/resolve the blocking question: "${q.question.slice(0, 120)}"${q.question.length > 120 ? '…' : ''}`;
|
||||
}
|
||||
if (!contract || contract.version === 0) {
|
||||
return 'No blocking questions. Waiting for someone to draft the Agreed Contract.';
|
||||
}
|
||||
const pending = participants.filter(
|
||||
(p) => (contract.agreements.find((a) => a.participant_id === p.id)?.version ?? 0) !== contract.version,
|
||||
);
|
||||
if (pending.length > 0) {
|
||||
return `Contract v${contract.version} is on the table. Waiting for ${pending.map((p) => p.role).join(', ')} to agree or propose changes.`;
|
||||
}
|
||||
return 'All questions resolved and everyone agreed — the next agree call finalizes the room.';
|
||||
}
|
||||
|
||||
export function availableActions(status: string): string[] {
|
||||
if (status !== 'open') {
|
||||
return ['GET /api/rooms/{room_id} (read)', 'GET /api/rooms/{room_id}/final.md (download artifact)'];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { Store } from './store.js';
|
||||
export { RendezvousService, newToken } from './service.js';
|
||||
export { LIMITS } from './limits.js';
|
||||
export { computeAdvice, availableActions } from './advice.js';
|
||||
export { renderRoomMarkdown, renderFinalMarkdown } from './markdown.js';
|
||||
export { computeAdvice, computeTurn, availableActions } from './advice.js';
|
||||
export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -1,4 +1,46 @@
|
||||
import type { MessageView, ParticipantView, QuestionView, RoomView } from './types.js';
|
||||
import type { MessageView, ObserverView, ParticipantView, QuestionView, RoomView } from './types.js';
|
||||
|
||||
/** Read-only Markdown state for the human observer of a room. */
|
||||
export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`# AI Rendezvous (observer): ${o.room.title}`);
|
||||
parts.push('');
|
||||
parts.push(`Read-only observer view — this link cannot post messages or agree. Goal: ${o.room.goal || '(not set)'}.`);
|
||||
parts.push(`Status: ${o.room_status} · expires ${o.room.expires_at} (all data deleted then).`);
|
||||
parts.push('');
|
||||
parts.push(`**Whose turn:** ${o.turn}`);
|
||||
parts.push('');
|
||||
parts.push(`Participants: ${o.participants.map((p) => p.role).join(', ')}`);
|
||||
parts.push('');
|
||||
parts.push('## Open questions');
|
||||
if (o.open_questions.length === 0) parts.push('(none)');
|
||||
else
|
||||
for (const q of o.open_questions)
|
||||
parts.push(`- [${q.blocking ? 'BLOCKING' : 'non-blocking'}] (id: ${q.id}) ${q.author_role}${q.addressed_to_role ? ` → ${q.addressed_to_role}` : ''}: ${q.question}`);
|
||||
parts.push('');
|
||||
parts.push('## Agreed Contract');
|
||||
parts.push(o.current_contract?.markdown?.trim() || '(not drafted yet)');
|
||||
parts.push('');
|
||||
if (o.resolved_questions.length) {
|
||||
parts.push('## Resolved questions');
|
||||
for (const q of o.resolved_questions) {
|
||||
parts.push(`- ${q.author_role}: ${q.question}`);
|
||||
parts.push(` - resolution: ${q.resolution ?? ''}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
parts.push('## Conversation');
|
||||
if (o.conversation.length === 0) parts.push('(no messages yet)');
|
||||
else
|
||||
for (const m of o.conversation) {
|
||||
parts.push(`**${m.role}** (${m.created_at}):`);
|
||||
parts.push('');
|
||||
parts.push(m.content);
|
||||
parts.push('');
|
||||
}
|
||||
parts.push(`---\n_Observer link for room ${o.room.id}. Final artifact: ${baseUrl}/api/rooms/${o.room.id}/final.md (requires a participant token)._`);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export interface MarkdownInput {
|
||||
baseUrl: string;
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
Store,
|
||||
} from './store.js';
|
||||
import { LIMITS } from './limits.js';
|
||||
import { availableActions, computeAdvice } from './advice.js';
|
||||
import { availableActions, computeAdvice, computeTurn } from './advice.js';
|
||||
import {
|
||||
CreateRoomInput,
|
||||
CreatedRoom,
|
||||
MessageView,
|
||||
ObserverView,
|
||||
ParticipantView,
|
||||
QuestionView,
|
||||
RendezvousError,
|
||||
@@ -80,16 +81,17 @@ export class RendezvousService {
|
||||
|
||||
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', ?, ?)`,
|
||||
`INSERT INTO rooms (id, title, brief, goal, status, created_at, expires_at, observer_token) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
const observerToken = newToken();
|
||||
this.db.exec('BEGIN');
|
||||
try {
|
||||
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt);
|
||||
insertRoom.run(roomId, title, input.brief ?? '', input.goal ?? '', createdAt, expiresAt, observerToken);
|
||||
for (const p of ps) {
|
||||
const pid = newId('prt');
|
||||
const token = newToken();
|
||||
@@ -118,6 +120,7 @@ export class RendezvousService {
|
||||
expires_at: expiresAt,
|
||||
participants: created,
|
||||
invite_urls: created.map((p) => `${baseUrl}/r/${roomId}/${p.token}`),
|
||||
observer_url: `${baseUrl}/o/${roomId}/${observerToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,6 +286,58 @@ export class RendezvousService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Read-only view for the human observer: room state + whose turn it is. */
|
||||
getObserverView(roomId: string, observerToken: string): ObserverView {
|
||||
const room = this.getRoomRow(roomId);
|
||||
if (room.observer_token !== observerToken) {
|
||||
throw new RendezvousError('forbidden', 'invalid observer 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 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 }[];
|
||||
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: participants.find((p) => p.id === a.participant_id)?.role ?? '?',
|
||||
version: a.contract_version,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
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,
|
||||
},
|
||||
participants,
|
||||
conversation,
|
||||
open_questions: openQuestions,
|
||||
resolved_questions: resolvedQuestions,
|
||||
current_contract: contract,
|
||||
room_status: viewStatus,
|
||||
turn: computeTurn(viewStatus, participants, openQuestions, contract),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- writes ----------
|
||||
|
||||
postMessage(roomId: string, token: string, content: string): MessageView {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
export interface RoomRow {
|
||||
id: string;
|
||||
@@ -11,6 +12,7 @@ export interface RoomRow {
|
||||
contract_markdown: string;
|
||||
contract_version: number;
|
||||
contract_updated_at: string | null;
|
||||
observer_token: string | null;
|
||||
}
|
||||
|
||||
export interface ParticipantRow {
|
||||
@@ -74,7 +76,8 @@ CREATE TABLE IF NOT EXISTS rooms (
|
||||
expires_at TEXT NOT NULL,
|
||||
contract_markdown TEXT NOT NULL DEFAULT '',
|
||||
contract_version INTEGER NOT NULL DEFAULT 0,
|
||||
contract_updated_at TEXT
|
||||
contract_updated_at TEXT,
|
||||
observer_token TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -134,6 +137,20 @@ export class Store {
|
||||
this.db.exec('PRAGMA journal_mode = WAL');
|
||||
this.db.exec('PRAGMA foreign_keys = ON');
|
||||
this.db.exec(SCHEMA);
|
||||
this.migrate();
|
||||
}
|
||||
|
||||
/** Lightweight migrations for existing deployments. */
|
||||
private migrate(): void {
|
||||
const cols = this.db.prepare('PRAGMA table_info(rooms)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'observer_token')) {
|
||||
this.db.exec('ALTER TABLE rooms ADD COLUMN observer_token TEXT');
|
||||
}
|
||||
// Backfill: every room, including pre-observer-token ones, gets one.
|
||||
const update = this.db.prepare('UPDATE rooms SET observer_token = ? WHERE id = ?');
|
||||
for (const r of this.db.prepare('SELECT id FROM rooms WHERE observer_token IS NULL').all() as { id: string }[]) {
|
||||
update.run(randomBytes(24).toString('base64url'), r.id);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
||||
@@ -85,6 +85,21 @@ export interface CreatedRoom {
|
||||
expires_at: string;
|
||||
invite_urls: string[]; // same order as input participants
|
||||
participants: { id: string; role: string; token: string }[];
|
||||
/** Read-only link for the human to watch the room; see ObserverView. */
|
||||
observer_url: string;
|
||||
}
|
||||
|
||||
/** Read-only projection of a room for the human observer (no participant identity). */
|
||||
export interface ObserverView {
|
||||
room: RoomView['room'];
|
||||
participants: ParticipantView[];
|
||||
conversation: MessageView[];
|
||||
open_questions: QuestionView[];
|
||||
resolved_questions: QuestionView[];
|
||||
current_contract: ContractView | null;
|
||||
room_status: 'open' | 'agreed' | 'expired';
|
||||
/** Whose move it is right now, for a human watching the negotiation. */
|
||||
turn: string;
|
||||
}
|
||||
|
||||
export class RendezvousError extends Error {
|
||||
|
||||
Reference in New Issue
Block a user