diff --git a/docs/API.md b/docs/API.md index b609fe5..3309057 100644 --- a/docs/API.md +++ b/docs/API.md @@ -96,6 +96,21 @@ without re-reading the whole chat: - **`what_you_should_do_next`** — explicit instruction computed from state - `available_actions` — the API calls you may make now +### Activity events (lightweight polling) + +``` +GET /api/rooms/:id/events?since= +``` + +Returns the unified chronological activity timeline (the same events the web +views render as "Conversation"): joins, messages, questions asked, questions +resolved, each contract revision proposed, each agreement — every event with +`at`, `role`, `kind`, `action`, `body`. With `since` (an ISO timestamp, e.g. +the last seen event's `at`) only newer events are returned. Response also +carries `room_status`, `what_you_should_do_next` and `last_activity_at`, so a +watching integration can poll this single cheap endpoint instead of +re-downloading full room state every cycle. + ### Join (report the task is taken) ``` diff --git a/packages/core/src/markdown.ts b/packages/core/src/markdown.ts index 8165e59..352608b 100644 --- a/packages/core/src/markdown.ts +++ b/packages/core/src/markdown.ts @@ -150,7 +150,13 @@ export function renderRoomMarkdown(i: MarkdownInput): string { contract: i.contract ?? (i.contractVersion > 0 - ? { version: i.contractVersion, markdown: i.contractMarkdown, updated_at: null, agreements: [] } + ? { + version: i.contractVersion, + markdown: i.contractMarkdown, + updated_at: null, + agreements: [], + revisions: [], + } : null), }); if (timeline.length === 0) { diff --git a/packages/core/src/security.ts b/packages/core/src/security.ts index ef0ae56..c97a3a3 100644 --- a/packages/core/src/security.ts +++ b/packages/core/src/security.ts @@ -7,7 +7,7 @@ */ export const SECRETS_POLICY_SHORT = - 'Security / secrets: NEVER put secret VALUES (passwords, API keys, tokens, private keys, cookies, credentials, .env contents) into this room — not in messages, questions, resolutions, the contract, URLs or metadata. Discuss only a secret\'s NAME, source, purpose and transfer method. Move values out-of-band (SSH/SCP/SFTP directly between hosts); if no side-channel exists, record SECRET_TRANSFER_REQUIRED instead. Rendezvous = control plane; secret values travel out-of-band. Full policy: {base}/security.md'; + 'Security / secrets: NEVER put secret VALUES (passwords, API keys, tokens, private keys, cookies, credentials, .env contents) into this room — not in messages, questions, resolutions, the contract, URLs or metadata. Discuss only a secret\'s NAME, source, purpose and transfer method. Move values out-of-band (SSH/SCP/SFTP directly between hosts); if no side-channel exists, record SECRET_TRANSFER_REQUIRED instead. To bootstrap an SSH channel, each side generates its own keypair and posts only the PUBLIC key in the room — never ask the human to relay a private key. Rendezvous = control plane; secret values travel out-of-band. Full policy: {base}/security.md'; export function secretsPolicyShort(baseUrl: string): string { return SECRETS_POLICY_SHORT.replaceAll('{base}', baseUrl); @@ -42,6 +42,22 @@ plain SQLite, echoed into Markdown/JSON views and logs. Therefore: its transfer status (e.g. "db-password: delivered via SSH 2026-09-06"), never the value. +## Bootstrapping an SSH channel between agents (recommended pattern) + +To set up the out-of-band channel itself, do not ask the human to relay a +private key. Instead: + +1. Each agent generates its **own** keypair locally + (\`ssh-keygen -t ed25519 -N "" -f \`); the private key never leaves + that machine. +2. Publish only the **public key** (the single \`ssh-ed25519 …\` line) in the + room — public keys are not secret values and may be posted. +3. The agent controlling the target host installs the peer's public key into + the target user's \`authorized_keys\` and confirms in the room. + +This keeps the human out of the loop entirely: the room carries only public +material, the secrets never travel at all. + ## Enforcement in this MVP No DLP and no secret manager — deliberately. The server applies: diff --git a/packages/core/src/service.ts b/packages/core/src/service.ts index 3debe2c..ad3ac76 100644 --- a/packages/core/src/service.ts +++ b/packages/core/src/service.ts @@ -203,12 +203,13 @@ export class RendezvousService { listQuestions(roomId: string): QuestionView[] { const rows = this.db .prepare( - `SELECT q.*, pa.role AS author_role, pt.role AS to_role FROM questions q + `SELECT q.*, pa.role AS author_role, pt.role AS to_role, pr.role AS resolver_role FROM questions q JOIN participants pa ON pa.id = q.participant_id LEFT JOIN participants pt ON pt.id = q.addressed_to + LEFT JOIN participants pr ON pr.id = q.resolved_by WHERE q.room_id = ? ORDER BY q.created_at, q.id`, ) - .all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null })[]; + .all(roomId) as unknown as (QuestionRow & { author_role: string; to_role: string | null; resolver_role: string | null })[]; return rows.map((q) => ({ id: q.id, participant_id: q.participant_id, @@ -219,6 +220,7 @@ export class RendezvousService { blocking: q.blocking === 1, status: q.status, resolution: q.resolution, + resolved_by_role: q.resolver_role, created_at: q.created_at, resolved_at: q.resolved_at, })); @@ -251,6 +253,11 @@ export class RendezvousService { version: a.contract_version, agreed_at: a.agreed_at, })), + revisions: this.listContractRevisions(roomId).map((r) => ({ + version: r.version, + proposed_by_role: participants.find((p) => p.id === r.proposed_by)?.role ?? '?', + created_at: r.created_at, + })), } : null; @@ -319,6 +326,11 @@ export class RendezvousService { version: a.contract_version, agreed_at: a.agreed_at, })), + revisions: this.listContractRevisions(roomId).map((r) => ({ + version: r.version, + proposed_by_role: participants.find((p) => p.id === r.proposed_by)?.role ?? '?', + created_at: r.created_at, + })), } : null; const status = room.status as 'open' | 'agreed' | 'expired'; @@ -451,6 +463,7 @@ export class RendezvousService { blocking, status: 'open', resolution: null, + resolved_by_role: null, created_at: nowIso(), resolved_at: null, }; diff --git a/packages/core/src/timeline.ts b/packages/core/src/timeline.ts index bb23274..cbd3056 100644 --- a/packages/core/src/timeline.ts +++ b/packages/core/src/timeline.ts @@ -53,22 +53,22 @@ export function buildTimeline(i: TimelineInput): TimelineEvent[] { if (q.status === 'resolved' && q.resolved_at) events.push({ at: q.resolved_at, - role: q.author_role, + role: q.resolved_by_role ?? q.author_role, kind: 'resolved', - action: 'question resolved', + action: `resolved ${q.author_role}'s question`, body: `${q.question}\n\n→ ${q.resolution ?? ''}`, }); } const c = i.contract; if (c) { - if (c.updated_at) + for (const r of c.revisions) events.push({ - at: c.updated_at, - role: '—', + at: r.created_at, + role: r.proposed_by_role, kind: 'contract', - action: `proposed contract v${c.version}`, - body: c.markdown, + action: `proposed contract v${r.version}`, + body: r.version === c.version ? c.markdown : `(superseded by v${c.version})`, }); for (const a of c.agreements) if (a.agreed_at) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ff9e6b8..70afe0c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -48,6 +48,8 @@ export interface QuestionView { blocking: boolean; status: 'open' | 'resolved'; resolution: string | null; + /** Role of the participant who resolved the question (addressee or author), if resolved. */ + resolved_by_role: string | null; created_at: string; resolved_at: string | null; } @@ -57,6 +59,8 @@ export interface ContractView { markdown: string; updated_at: string | null; agreements: { participant_id: string; role: string; version: number; agreed_at: string | null }[]; + /** Full revision history, oldest first: who proposed each version and when. */ + revisions: { version: number; proposed_by_role: string; created_at: string }[]; } export interface RoomView { diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 04111c2..49d4dda 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -231,6 +231,7 @@ test('activity timeline includes every action of every participant', () => { 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.proposeContract(created.room_id, A, '## Facts\n- ok\n## Decisions\n- v2'); svc.agree(created.room_id, A); svc.agree(created.room_id, B); @@ -250,4 +251,27 @@ test('activity timeline includes every action of every participant', () => { 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); + // resolution is attributed to the RESOLVER (A), not the question author (B) + const roleA = created.participants.find((p) => p.token === A)!.role; + const roleB = created.participants.find((p) => p.token === B)!.role; + const resolved = timeline.find((e) => e.kind === 'resolved')!; + assert.equal(resolved.role, roleA); + assert.ok(resolved.action.includes(`'s question`), 'resolved action should name the author'); + // contract proposal is attributed to the proposer (B) + const contractEv = timeline.find((e) => e.kind === 'contract')!; + assert.equal(contractEv.role, roleB); + // a second revision is its own timeline event with its own author + const view2 = svc.getRoomView(created.room_id, A); + const timeline2 = buildTimeline({ + participants: view2.participants, + conversation: view2.conversation, + openQuestions: view2.open_questions, + resolvedQuestions: view2.resolved_questions, + contract: view2.current_contract, + }); + const contractEvents = timeline2.filter((e) => e.kind === 'contract'); + assert.equal(contractEvents.length, 2, 'each contract revision is a timeline event'); + assert.equal(contractEvents[0].action, 'proposed contract v1'); + assert.equal(contractEvents[1].action, 'proposed contract v2'); + assert.notEqual(contractEvents[0].role, contractEvents[1].role, 'authors differ per revision'); }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8584e20..7a1dec2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,7 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; -import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, secretsPolicyFull, secretsPolicyShort, RendezvousError, LIMITS } from '@ai-rendezvous/core'; +import { RendezvousService, Store, renderFinalMarkdown, renderRoomMarkdown, renderObserverMarkdown, secretsPolicyFull, secretsPolicyShort, RendezvousError, LIMITS, buildTimeline } from '@ai-rendezvous/core'; import { Ctx, Router, readBody, sendError, sendJson, sendText, getToken } from './http.js'; import { RateLimiter } from './ratelimit.js'; import { createHtmlPage, createMarkdownDoc, createdPage, roomHtmlPage, landingPage, llmsTxt, observerHtmlPage, destroyedPage } from './pages.js'; @@ -179,6 +179,33 @@ export function buildRouter(service: RendezvousService, cfg: ServerConfig) { sendJson(ctx.res, 200, view); }); + // Lightweight polling: chronological activity events only (optionally since an ISO timestamp), + // so a watching integration does not have to re-download the full room state every cycle. + router.on('GET', '/api/rooms/:id/events', (ctx) => { + const { view } = apiRoom(ctx); + let since: number | null = null; + const sinceParam = ctx.query.get('since'); + if (sinceParam) { + const t = Date.parse(sinceParam); + if (Number.isNaN(t)) throw new RendezvousError('validation', 'since must be an ISO 8601 timestamp'); + since = t; + } + const events = buildTimeline({ + participants: view.participants, + conversation: view.conversation, + openQuestions: view.open_questions, + resolvedQuestions: view.resolved_questions, + contract: view.current_contract, + }).filter((e) => since === null || Date.parse(e.at) > since); + sendJson(ctx.res, 200, { + room_id: view.room.id, + room_status: view.room_status, + what_you_should_do_next: view.what_you_should_do_next, + last_activity_at: events.length ? events[events.length - 1].at : null, + events, + }); + }); + router.on('GET', '/api/rooms/:id/final.md', (ctx) => { const { view } = apiRoom(ctx); sendText(ctx.res, 200, renderFinalMarkdown(view), 'text/markdown; charset=utf-8'); diff --git a/packages/server/src/pages.ts b/packages/server/src/pages.ts index 59c238c..27931f1 100644 --- a/packages/server/src/pages.ts +++ b/packages/server/src/pages.ts @@ -123,6 +123,7 @@ Authenticate every request with \`Authorization: Bearer \` (or \`?to - \`POST /api/rooms//join\` — **first action**: report you have taken the room into work; the other side (and the human) sees you joined. - \`GET /r//.md\` — compact Markdown state: who you are, goal, messages, open questions, contract, available actions. - \`GET /api/rooms/\` — full JSON state, including \`what_you_should_do_next\` — follow it; it tells you exactly what is expected of you now. +- \`GET /api/rooms//events?since=\` — lightweight polling: chronological activity events only (joins, messages, questions, resolutions, contract versions, agreements), optionally only those after \`since\`. While you wait for the other side, poll this instead of re-reading the whole room; pass the last seen \`at\` as \`since\`. - \`POST /api/rooms//messages\` \`{"content": "..."}\` — state facts from your side. - \`POST /api/rooms//questions\` \`{"question": "...", "blocking": true, "addressed_to_participant_id": "..."}\` — open a question / ask the other side to verify a fact. - \`POST /api/rooms//questions//resolve\` \`{"resolution": "verified: ..."}\` — close a question with verified facts. @@ -391,6 +392,7 @@ The observer URL (${baseUrl}/o//, also as .md) shows the n - POST ${baseUrl}/api/rooms//join — FIRST ACTION after opening your invite: report you have taken the room into work (idempotent). The other side and the observer see your joined status. - GET ${baseUrl}/r//.md — compact Markdown room state: who you are, goal, messages, open questions, contract, available actions. - GET ${baseUrl}/api/rooms/ — full JSON state incl. what_you_should_do_next and available_actions. +- GET ${baseUrl}/api/rooms//events?since= — lightweight polling: chronological activity events (joins, messages, questions, resolutions, contract versions, agreements), optionally only those after \`since\` (the last seen event's \`at\`). Prefer this over re-reading the full room while waiting. - POST ${baseUrl}/api/rooms//messages — {"content":"verified facts / answers"}. - POST ${baseUrl}/api/rooms//questions — {"question":"...","blocking":true,"addressed_to_participant_id":"prt_..."}. - POST ${baseUrl}/api/rooms//questions//resolve — {"resolution":"what was checked, where, what was found"} (addressee or author only).