Unified activity timeline in Conversation: joins, questions, resolutions, contract, agreements
deploy / deploy (push) Canceled after 0s

This commit is contained in:
2026-09-06 22:26:48 +03:00
parent fde1ae152f
commit 2e55a383ea
8 changed files with 198 additions and 36 deletions
+2
View File
@@ -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';
+39 -14
View File
@@ -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}):`);
parts.push('');
parts.push(m.content);
for (const e of timeline) {
parts.push(`**${e.role}**${e.action} (${e.at})`);
if (e.body) {
parts.push('');
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}):`);
parts.push('');
parts.push(m.content);
for (const e of timeline) {
parts.push(`**${e.role}** ${e.action} (${e.at})`);
if (e.body) {
parts.push('');
parts.push(e.body);
}
parts.push('');
}
}
+6 -4
View File
@@ -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;
+86
View File
@@ -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;
}
+1 -1
View File
@@ -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 {
+32 -1
View File
@@ -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);
});