Unified activity timeline in Conversation: joins, questions, resolutions, contract, agreements
deploy / deploy (push) Canceled after 0s
deploy / deploy (push) Canceled after 0s
This commit is contained in:
@@ -3,5 +3,7 @@ export { RendezvousService, newToken } from './service.js';
|
||||
export { LIMITS } from './limits.js';
|
||||
export { computeAdvice, computeTurn, availableActions } from './advice.js';
|
||||
export { renderRoomMarkdown, renderFinalMarkdown, renderObserverMarkdown } from './markdown.js';
|
||||
export { buildTimeline } from './timeline.js';
|
||||
export type { TimelineEvent, TimelineInput } from './timeline.js';
|
||||
export { secretsPolicyFull, secretsPolicyShort, redactSecrets } from './security.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MessageView, ObserverView, ParticipantView, QuestionView, RoomView } from './types.js';
|
||||
import type { ContractView, MessageView, ObserverView, ParticipantView, QuestionView, RoomView } from './types.js';
|
||||
import { buildTimeline } from './timeline.js';
|
||||
|
||||
/** Read-only Markdown state for the human observer of a room. */
|
||||
export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string {
|
||||
@@ -35,13 +36,22 @@ export function renderObserverMarkdown(o: ObserverView, baseUrl: string): string
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
parts.push('## Conversation');
|
||||
if (o.conversation.length === 0) parts.push('(no messages yet)');
|
||||
parts.push('## Conversation (full activity timeline)');
|
||||
const timeline = buildTimeline({
|
||||
participants: o.participants,
|
||||
conversation: o.conversation,
|
||||
openQuestions: o.open_questions,
|
||||
resolvedQuestions: o.resolved_questions,
|
||||
contract: o.current_contract,
|
||||
});
|
||||
if (timeline.length === 0) parts.push('(no activity yet)');
|
||||
else
|
||||
for (const m of o.conversation) {
|
||||
parts.push(`**${m.role}** (${m.created_at}):`);
|
||||
for (const e of timeline) {
|
||||
parts.push(`**${e.role}** — ${e.action} (${e.at})`);
|
||||
if (e.body) {
|
||||
parts.push('');
|
||||
parts.push(m.content);
|
||||
parts.push(e.body);
|
||||
}
|
||||
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)._`);
|
||||
@@ -62,6 +72,8 @@ export interface MarkdownInput {
|
||||
resolvedQuestions: QuestionView[];
|
||||
contractMarkdown: string;
|
||||
contractVersion: number;
|
||||
/** Full contract view (with agreements) for the activity timeline; falls back to contractMarkdown/Version. */
|
||||
contract?: ContractView | null;
|
||||
/** Short secrets policy (links to the canonical /security.md). */
|
||||
secretsPolicyShort: string;
|
||||
}
|
||||
@@ -119,14 +131,27 @@ export function renderRoomMarkdown(i: MarkdownInput): string {
|
||||
parts.push(i.contractMarkdown.trim() ? i.contractMarkdown : `(not drafted yet, version ${i.contractVersion})`);
|
||||
parts.push('');
|
||||
|
||||
parts.push('## Conversation');
|
||||
if (i.conversation.length === 0) {
|
||||
parts.push('(no messages yet)');
|
||||
parts.push('## Conversation (full activity timeline)');
|
||||
const timeline = buildTimeline({
|
||||
participants: i.participants,
|
||||
conversation: i.conversation,
|
||||
openQuestions: i.openQuestions,
|
||||
resolvedQuestions: i.resolvedQuestions,
|
||||
contract:
|
||||
i.contract ??
|
||||
(i.contractVersion > 0
|
||||
? { version: i.contractVersion, markdown: i.contractMarkdown, updated_at: null, agreements: [] }
|
||||
: null),
|
||||
});
|
||||
if (timeline.length === 0) {
|
||||
parts.push('(no activity yet)');
|
||||
} else {
|
||||
for (const m of i.conversation) {
|
||||
parts.push(`**${m.role}** (${m.created_at}, id: ${m.id}):`);
|
||||
for (const e of timeline) {
|
||||
parts.push(`**${e.role}** — ${e.action} (${e.at})`);
|
||||
if (e.body) {
|
||||
parts.push('');
|
||||
parts.push(m.content);
|
||||
parts.push(e.body);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,10 +234,10 @@ export class RendezvousService {
|
||||
|
||||
const agreements = this.db
|
||||
.prepare(
|
||||
`SELECT a.participant_id, a.contract_version, p.role FROM agreements a
|
||||
`SELECT a.participant_id, a.contract_version, a.created_at AS agreed_at, 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 }[];
|
||||
.all(roomId) as { participant_id: string; contract_version: number; role: string; agreed_at: string | null }[];
|
||||
|
||||
const contract =
|
||||
room.contract_version > 0
|
||||
@@ -249,6 +249,7 @@ export class RendezvousService {
|
||||
participant_id: a.participant_id,
|
||||
role: a.role,
|
||||
version: a.contract_version,
|
||||
agreed_at: a.agreed_at,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
@@ -302,10 +303,10 @@ export class RendezvousService {
|
||||
const resolvedQuestions = questions.filter((q) => q.status === 'resolved');
|
||||
const agreements = this.db
|
||||
.prepare(
|
||||
`SELECT a.participant_id, a.contract_version FROM agreements a
|
||||
`SELECT a.participant_id, a.contract_version, a.created_at AS agreed_at 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 }[];
|
||||
.all(roomId) as { participant_id: string; contract_version: number; agreed_at: string | null }[];
|
||||
const contract =
|
||||
room.contract_version > 0
|
||||
? {
|
||||
@@ -316,6 +317,7 @@ export class RendezvousService {
|
||||
participant_id: a.participant_id,
|
||||
role: participants.find((p) => p.id === a.participant_id)?.role ?? '?',
|
||||
version: a.contract_version,
|
||||
agreed_at: a.agreed_at,
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ContractView, MessageView, ParticipantView, QuestionView } from './types.js';
|
||||
|
||||
/** One entry of the unified activity timeline shown as "Conversation". */
|
||||
export interface TimelineEvent {
|
||||
at: string;
|
||||
role: string;
|
||||
kind: 'joined' | 'message' | 'asked' | 'resolved' | 'contract' | 'agreed';
|
||||
/** Short human label, e.g. "asked (BLOCKING → verifier)". */
|
||||
action: string;
|
||||
/** Full text body (message content, question, resolution, contract markdown). */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface TimelineInput {
|
||||
participants: ParticipantView[];
|
||||
conversation: MessageView[];
|
||||
openQuestions: QuestionView[];
|
||||
resolvedQuestions: QuestionView[];
|
||||
contract: ContractView | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges every room activity — joins, messages, questions, resolutions,
|
||||
* contract proposals and agreements — into one chronological timeline, so a
|
||||
* watcher sees each participant's every action instead of plain chat only.
|
||||
*/
|
||||
export function buildTimeline(i: TimelineInput): TimelineEvent[] {
|
||||
const events: TimelineEvent[] = [];
|
||||
|
||||
for (const p of i.participants) {
|
||||
if (p.joined_at)
|
||||
events.push({
|
||||
at: p.joined_at,
|
||||
role: p.role,
|
||||
kind: 'joined',
|
||||
action: 'took the room into work',
|
||||
body: '',
|
||||
});
|
||||
}
|
||||
|
||||
for (const m of i.conversation)
|
||||
events.push({ at: m.created_at, role: m.role, kind: 'message', action: 'message', body: m.content });
|
||||
|
||||
for (const q of [...i.openQuestions, ...i.resolvedQuestions]) {
|
||||
const to = q.addressed_to_role ? ` → ${q.addressed_to_role}` : '';
|
||||
events.push({
|
||||
at: q.created_at,
|
||||
role: q.author_role,
|
||||
kind: 'asked',
|
||||
action: `asked (${q.blocking ? 'BLOCKING' : 'non-blocking'}${to})`,
|
||||
body: q.question,
|
||||
});
|
||||
if (q.status === 'resolved' && q.resolved_at)
|
||||
events.push({
|
||||
at: q.resolved_at,
|
||||
role: q.author_role,
|
||||
kind: 'resolved',
|
||||
action: 'question resolved',
|
||||
body: `${q.question}\n\n→ ${q.resolution ?? ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
const c = i.contract;
|
||||
if (c) {
|
||||
if (c.updated_at)
|
||||
events.push({
|
||||
at: c.updated_at,
|
||||
role: '—',
|
||||
kind: 'contract',
|
||||
action: `proposed contract v${c.version}`,
|
||||
body: c.markdown,
|
||||
});
|
||||
for (const a of c.agreements)
|
||||
if (a.agreed_at)
|
||||
events.push({
|
||||
at: a.agreed_at,
|
||||
role: a.role,
|
||||
kind: 'agreed',
|
||||
action: `agreed to contract v${a.version}`,
|
||||
body: '',
|
||||
});
|
||||
}
|
||||
|
||||
events.sort((x, y) => (x.at < y.at ? -1 : x.at > y.at ? 1 : 0));
|
||||
return events;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export interface ContractView {
|
||||
version: number;
|
||||
markdown: string;
|
||||
updated_at: string | null;
|
||||
agreements: { participant_id: string; role: string; version: number }[];
|
||||
agreements: { participant_id: string; role: string; version: number; agreed_at: string | null }[];
|
||||
}
|
||||
|
||||
export interface RoomView {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RendezvousService, Store, RendezvousError, redactSecrets } from '../src/index.js';
|
||||
import { RendezvousService, Store, RendezvousError, redactSecrets, buildTimeline } from '../src/index.js';
|
||||
|
||||
function makeService(): { svc: RendezvousService; dbPath: string } {
|
||||
const dbPath = `:memory:`;
|
||||
@@ -220,3 +220,34 @@ test('only addressee or author can resolve a question', () => {
|
||||
const resolved = svc.resolveQuestion(created.room_id, A, q.id, 'checked: ok');
|
||||
assert.equal(resolved.status, 'resolved');
|
||||
});
|
||||
|
||||
test('activity timeline includes every action of every participant', () => {
|
||||
const { svc } = makeService();
|
||||
const created = createTwoPartyRoom(svc);
|
||||
const [A, B] = created.participants.map((p) => p.token);
|
||||
svc.join(created.room_id, A);
|
||||
svc.join(created.room_id, B);
|
||||
svc.postMessage(created.room_id, A, 'fact from A');
|
||||
const q = svc.openQuestion(created.room_id, B, 'check IIS logs', true, created.participants[0].id);
|
||||
svc.resolveQuestion(created.room_id, A, q.id, 'checked: every 15 min');
|
||||
svc.proposeContract(created.room_id, B, '## Facts\n- ok');
|
||||
svc.agree(created.room_id, A);
|
||||
svc.agree(created.room_id, B);
|
||||
|
||||
const view = svc.getRoomView(created.room_id, A);
|
||||
const timeline = buildTimeline({
|
||||
participants: view.participants,
|
||||
conversation: view.conversation,
|
||||
openQuestions: view.open_questions,
|
||||
resolvedQuestions: view.resolved_questions,
|
||||
contract: view.current_contract,
|
||||
});
|
||||
const kinds: string[] = timeline.map((e) => e.kind);
|
||||
for (const expected of ['joined', 'joined', 'message', 'asked', 'resolved', 'contract', 'agreed', 'agreed'])
|
||||
assert.ok(kinds.includes(expected), `timeline must contain "${expected}", got: ${kinds.join(',')}`);
|
||||
// chronological order
|
||||
for (let i = 1; i < timeline.length; i++)
|
||||
assert.ok(timeline[i - 1].at <= timeline[i].at, 'timeline must be sorted by time');
|
||||
// every event is attributed to a role
|
||||
for (const e of timeline) assert.ok(e.role.length > 0 && e.action.length > 0);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,7 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) {
|
||||
resolvedQuestions: view.resolved_questions,
|
||||
contractMarkdown: view.current_contract?.markdown ?? '',
|
||||
contractVersion: view.current_contract?.version ?? 0,
|
||||
contract: view.current_contract,
|
||||
secretsPolicyShort: secretsPolicyShort(cfg.baseUrl),
|
||||
}), 'text/markdown; charset=utf-8');
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import type { ObserverView, RoomView } from '@ai-rendezvous/core';
|
||||
import { secretsPolicyShort } from '@ai-rendezvous/core';
|
||||
import { secretsPolicyShort, buildTimeline } from '@ai-rendezvous/core';
|
||||
import { escapeHtml } from './http.js';
|
||||
|
||||
/** Unified activity timeline as HTML: every participant action in chronological order. */
|
||||
function timelineHtml(
|
||||
participants: ObserverView['participants'],
|
||||
conversation: ObserverView['conversation'],
|
||||
openQuestions: ObserverView['open_questions'],
|
||||
resolvedQuestions: ObserverView['resolved_questions'],
|
||||
contract: ObserverView['current_contract'],
|
||||
): string {
|
||||
const events = buildTimeline({ participants, conversation, openQuestions, resolvedQuestions, contract });
|
||||
return (
|
||||
events
|
||||
.map((e) => {
|
||||
const body = e.body
|
||||
? `<pre>${escapeHtml(e.body)}</pre>`
|
||||
: '';
|
||||
return `<div class="msg msg-${e.kind}"><div class="meta"><b>${escapeHtml(e.role)}</b> · ${escapeHtml(e.action)} · ${escapeHtml(e.at)}</div>${body}</div>`;
|
||||
})
|
||||
.join('') || '<p class="hint">(no activity yet)</p>'
|
||||
);
|
||||
}
|
||||
|
||||
/** Machine-readable instruction page served at /create.md — this is what an agent reads first. */
|
||||
export function createMarkdownDoc(baseUrl: string): string {
|
||||
return `# AI Rendezvous — create a room
|
||||
@@ -92,6 +113,9 @@ const PAGE_CSS = `
|
||||
code, pre { font-family: ui-monospace, monospace; font-size: 0.85rem; }
|
||||
pre { background: rgba(127,127,127,.12); padding: .75rem 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; }
|
||||
.msg { border-left: 3px solid rgba(127,127,127,.4); padding: .25rem 0 .25rem .75rem; margin: .75rem 0; }
|
||||
.msg-joined, .msg-agreed, .msg-contract { border-left-color: rgba(60,140,80,.75); }
|
||||
.msg-asked { border-left-color: rgba(210,130,30,.8); }
|
||||
.msg-resolved { opacity: .75; }
|
||||
.meta { color: rgba(127,127,127,.9); font-size: .8rem; }
|
||||
.q { padding: .5rem .75rem; border-radius: 6px; margin: .5rem 0; }
|
||||
.q.blocking { background: rgba(200,60,60,.14); }
|
||||
@@ -196,12 +220,7 @@ export function observerHtmlPage(o: ObserverView, token: string): string {
|
||||
return `${mark} (${joined})`;
|
||||
})
|
||||
.join(', ');
|
||||
const msgs = o.conversation
|
||||
.map(
|
||||
(m) =>
|
||||
`<div class="msg"><div class="meta"><b>${escapeHtml(m.role)}</b> · ${escapeHtml(m.created_at)}</div><pre>${escapeHtml(m.content)}</pre></div>`,
|
||||
)
|
||||
.join('');
|
||||
const msgs = timelineHtml(o.participants, o.conversation, o.open_questions, o.resolved_questions, o.current_contract);
|
||||
const openQs = o.open_questions
|
||||
.map(
|
||||
(q) =>
|
||||
@@ -225,8 +244,8 @@ export function observerHtmlPage(o: ObserverView, token: string): string {
|
||||
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
||||
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
||||
<pre>${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}</pre>
|
||||
<h2>Conversation</h2>${msgs || '<p class="hint">(no messages yet)</p>'}
|
||||
<p class="hint">Read-only observer view; refreshes every 10s. Also available as Markdown: append <code>.md</code> to this URL.</p>`,
|
||||
<h2>Conversation</h2>${msgs}
|
||||
<p class="hint">Full activity timeline: joins, messages, questions, resolutions, contract proposals and agreements. Read-only observer view; refreshes every 10s. Also available as Markdown: append <code>.md</code> to this URL.</p>`,
|
||||
o.room_status === 'open',
|
||||
);
|
||||
}
|
||||
@@ -351,12 +370,7 @@ If a secret must move between the sides, record SECRET_TRANSFER_REQUIRED plus th
|
||||
|
||||
/** Read-only human view of the negotiation (same token auth as the API). */
|
||||
export function roomHtmlPage(v: RoomView, token: string): string {
|
||||
const msgs = v.conversation
|
||||
.map(
|
||||
(m) =>
|
||||
`<div class="msg"><div class="meta"><b>${escapeHtml(m.role)}</b> · ${escapeHtml(m.created_at)}</div><pre>${escapeHtml(m.content)}</pre></div>`,
|
||||
)
|
||||
.join('');
|
||||
const msgs = timelineHtml(v.participants, v.conversation, v.open_questions, v.resolved_questions, v.current_contract);
|
||||
const openQs = v.open_questions
|
||||
.map(
|
||||
(q) =>
|
||||
@@ -379,7 +393,8 @@ export function roomHtmlPage(v: RoomView, token: string): string {
|
||||
${resolvedQs ? `<h2>Resolved questions</h2>${resolvedQs}` : ''}
|
||||
<h2>Agreed Contract (v${contract ? contract.version : 0})</h2>
|
||||
<pre>${escapeHtml(contract ? contract.markdown : '(not drafted yet)')}</pre>
|
||||
<h2>Conversation</h2>${msgs || '<p class="hint">(no messages yet)</p>'}
|
||||
<h2>Conversation</h2>${msgs}
|
||||
<p class="hint">Full activity timeline: joins, messages, questions, resolutions, contract proposals and agreements.</p>
|
||||
<p class="hint">Agent endpoints: <code>/r/${escapeHtml(v.room.id)}/<token>.md</code> · <code>GET /api/rooms/${escapeHtml(v.room.id)}</code> · <a href="/api/rooms/${escapeHtml(v.room.id)}?token=">JSON</a> · <a href="/api/rooms/${escapeHtml(v.room.id)}/final.md">final.md</a></p>
|
||||
<p class="hint">Read-only view; refreshes every 10s.</p>`,
|
||||
v.room.status === 'open',
|
||||
|
||||
Reference in New Issue
Block a user