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
+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 {