Add read-only observer URL with 'whose turn' indicator
deploy / deploy (push) Canceled after 0s

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:
2026-09-06 20:20:41 +03:00
parent 61919ab00d
commit 91fc74efc1
13 changed files with 280 additions and 27 deletions
+32
View File
@@ -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)'];
+2 -2
View File
@@ -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';
+43 -1
View File
@@ -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;
+58 -3
View File
@@ -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 {
+18 -1
View File
@@ -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 {
+15
View File
@@ -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 {